001/*
002 *  Copyright 2026 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016package org.ametys.cms.clientsideelement;
017
018import java.time.ZonedDateTime;
019import java.util.ArrayList;
020import java.util.HashMap;
021import java.util.List;
022import java.util.Map;
023
024import org.apache.avalon.framework.configuration.Configuration;
025import org.apache.avalon.framework.configuration.ConfigurationException;
026import org.apache.avalon.framework.context.Context;
027import org.apache.avalon.framework.context.ContextException;
028import org.apache.avalon.framework.context.Contextualizable;
029import org.apache.avalon.framework.service.ServiceException;
030import org.apache.avalon.framework.service.ServiceManager;
031import org.apache.cocoon.components.ContextHelper;
032import org.apache.cocoon.environment.Request;
033import org.apache.commons.lang3.StringUtils;
034import org.quartz.JobKey;
035import org.quartz.SchedulerException;
036
037import org.ametys.cms.repository.Content;
038import org.ametys.cms.repository.WorkflowAwareContent;
039import org.ametys.cms.rights.ContentRightAssignmentContext;
040import org.ametys.cms.workflow.schedule.PublishContentRunnable;
041import org.ametys.cms.workflow.schedule.ScheduledPublicationCondition;
042import org.ametys.cms.workflow.schedule.UnpublishContentRunnable;
043import org.ametys.core.schedule.Runnable;
044import org.ametys.core.ui.Callable;
045import org.ametys.core.util.DateUtils;
046import org.ametys.plugins.core.schedule.Scheduler;
047import org.ametys.plugins.repository.data.holder.ModifiableDataHolder;
048import org.ametys.plugins.repository.version.ModifiableDataAwareVersionableAmetysObject;
049import org.ametys.runtime.i18n.I18nizableText;
050
051/**
052 * Client side element to schedule a workflow action on a selected content
053 */
054public class ScheduleContentPublicationClientSideElement extends SmartContentClientSideElement implements Contextualizable
055{
056    /** name of the publish scheduled date data in the content unversioned data holder */
057    public static final String PUBLISH_DATE = "publish-date";
058    /** name of the publish scheduled action id in the content unversioned data holder */
059    public static final String PUBLISH_ACTION_ID = "publish-action-id";
060    /** name of the unpublish scheduled date data in the content unversioned data holder */
061    public static final String UNPUBLISH_DATE = "unpublish-date";
062    /** name of the unpublish scheduled action id in the content unversioned data holder */
063    public static final String UNPUBLISH_ACTION_ID = "unpublish-action-id";
064    
065    /** The avalon context */
066    protected Context _context;
067    
068    /** The scheduler */
069    protected Scheduler _scheduler;
070    private int _publishAction = -1;
071    private int _unpublishAction = -1;
072    
073    public void contextualize(Context context) throws ContextException
074    {
075        _context = context;
076    }
077    
078    @Override
079    public void service(ServiceManager manager) throws ServiceException
080    {
081        super.service(manager);
082        _scheduler = (Scheduler) manager.lookup(Scheduler.ROLE);
083    }
084    
085    @Override
086    protected Script _configureScript(Configuration configuration) throws ConfigurationException
087    {
088        Script script = super._configureScript(configuration);
089        Map<String, Object> parameters = script.getParameters();
090        
091        String publishAction = (String) parameters.get("publish-action");
092        String unpublishAction = (String) parameters.get("unpublish-action");
093        
094        String enabledAction;
095        if (StringUtils.isAllBlank(publishAction, unpublishAction))
096        {
097            throw new ConfigurationException("At least one of 'publish-action' or 'unpublish-action' parameter is required", configuration);
098        }
099        else if (StringUtils.isNotBlank(publishAction))
100        {
101            _publishAction  = Integer.parseInt(publishAction);
102            if (StringUtils.isNotBlank(unpublishAction))
103            {
104                _unpublishAction = Integer.parseInt(unpublishAction);
105                enabledAction = publishAction + "," + unpublishAction;
106            }
107            else
108            {
109                enabledAction = publishAction;
110            }
111        }
112        else
113        {
114            _unpublishAction = Integer.parseInt(unpublishAction);
115            enabledAction = unpublishAction;
116        }
117        
118        parameters.put("enabled-on-workflow-action-only", enabledAction);
119        
120        return script;
121    }
122    
123    /**
124     * Get the schedule info of the content
125     * @param contentId the content identifier
126     * @return the scheduled date if any
127     */
128    @Callable(rights = Callable.READ_ACCESS, paramIndex = 0, rightContext = ContentRightAssignmentContext.ID)
129    public Map<String, Object> getScheduleInfo(String contentId)
130    {
131        WorkflowAwareContent content = _resolver.resolveById(contentId);
132        ModifiableDataHolder unversionedDataHolder = ((ModifiableDataAwareVersionableAmetysObject) content).getUnversionedDataHolder();
133
134        Map<String, Object> result = new HashMap<>();
135        result.put("startDate", unversionedDataHolder.getValue(PUBLISH_DATE));
136        result.put("endDate", unversionedDataHolder.getValue(UNPUBLISH_DATE));
137        return result;
138    }
139
140    
141    /**
142     * Schedule the execution of a workflow action at a given date
143     * @param contentId the identifier of the content to act on
144     * @param startDateAsStr the date of publication as an ISO date-time string
145     * @param endDateAsStr  the date of unpublication as an ISO date-time string
146     * @return true if the action is a success
147     */
148    @Callable(rights = Callable.CHECKED_BY_IMPLEMENTATION)
149    public Map<String, Object> scheduleAction(String contentId, String startDateAsStr, String endDateAsStr)
150    {
151        WorkflowAwareContent content = _resolver.resolveById(contentId);
152        
153        ZonedDateTime startDate = DateUtils.parseZonedDateTime(startDateAsStr);
154        ZonedDateTime endDate = DateUtils.parseZonedDateTime(endDateAsStr);
155        
156        try
157        {
158            // Check that the action is available
159            int workflowAction = _workflowAction(content);
160            
161            if (workflowAction == -1)
162            {
163                // action unavailable
164                return Map.of("success", false, "error", "action-unavailable");
165            }
166            else if (workflowAction == _publishAction)
167            {
168                // start date must be in the future if set
169                if (startDate != null && startDate.isBefore(ZonedDateTime.now()))
170                {
171                    return Map.of("success", false, "error", "past-start-date");
172                }
173                else if (endDate != null)
174                {
175                    // content is not published so a publication date is required to add a depublication date
176                    if (startDate == null)
177                    {
178                        return Map.of("success", false, "error", "missing-start-date");
179                    }
180                    // depublication must be after publication
181                    else if (endDate.isBefore(startDate))
182                    {
183                        return Map.of("success", false, "error", "invalid-end-date");
184                    }
185                }
186                _schedulePublication(content, startDate, workflowAction);
187                _scheduleUnpublication(content, endDate, _unpublishAction);
188            }
189            else
190            {
191                if (endDate != null && endDate.isBefore(ZonedDateTime.now()))
192                {
193                    return Map.of("success", false, "error", "past-end-date");
194                }
195                _scheduleUnpublication(content, endDate, _unpublishAction);
196            }
197            
198            return Map.of("success", true);
199        }
200        catch (SchedulerException e)
201        {
202            getLogger().error("Failed to schedule workflow action for content {}", contentId, e);
203            return Map.of("succes", false, "error", "scheduler-failed");
204        }
205    }
206
207    private void _schedulePublication(WorkflowAwareContent content, ZonedDateTime startDate, int workflowAction) throws SchedulerException
208    {
209        JobKey jobKey = new JobKey(PublishContentRunnable.getId(content.getId()), Scheduler.JOB_GROUP);
210        if (_scheduler.getScheduler().checkExists(jobKey))
211        {
212            // Remove any existing corresponding job
213            getLogger().debug("Removing the existing job for publishing {} ({})", content.getTitle(), content.getId());
214            _scheduler.getScheduler().deleteJob(jobKey);
215        }
216        
217        ModifiableDataHolder unversionedDataHolder = ((ModifiableDataAwareVersionableAmetysObject) content).getUnversionedDataHolder();
218        if (startDate != null)
219        {
220            getLogger().debug("Creating a job for publishing {} ({}) on {}", content.getTitle(), content.getId(), startDate);
221            Runnable publishRunnable = new PublishContentRunnable(content.getId(), _contentHelper.getTitle(content), workflowAction, _currentUserProvider.getUser(), startDate);
222            _scheduler.scheduleJob(publishRunnable);
223            
224            unversionedDataHolder.setValue(PUBLISH_DATE, startDate);
225            unversionedDataHolder.setValue(PUBLISH_ACTION_ID, (long) workflowAction);
226        }
227        else
228        {
229            unversionedDataHolder.removeValue(PUBLISH_DATE);
230            unversionedDataHolder.removeValue(PUBLISH_ACTION_ID);
231        }
232        
233        if (content.needsSave())
234        {
235            content.saveChanges();
236        }
237    }
238
239    private void _scheduleUnpublication(WorkflowAwareContent content, ZonedDateTime endDate, int workflowAction) throws SchedulerException
240    {
241        JobKey jobKey = new JobKey(UnpublishContentRunnable.getId(content.getId()), Scheduler.JOB_GROUP);
242        if (_scheduler.getScheduler().checkExists(jobKey))
243        {
244            // Remove any existing corresponding job
245            getLogger().debug("Removing the existing job for unpublishing {} ({})", content.getTitle(), content.getId());
246            _scheduler.getScheduler().deleteJob(jobKey);
247        }
248        
249        ModifiableDataHolder unversionedDataHolder = ((ModifiableDataAwareVersionableAmetysObject) content).getUnversionedDataHolder();
250        if (endDate != null)
251        {
252            getLogger().debug("Creating a job for unpublishing {} ({}) on {}", content.getTitle(), content.getId(), endDate);
253            Runnable publishRunnable = new UnpublishContentRunnable(content.getId(), _contentHelper.getTitle(content), workflowAction, _currentUserProvider.getUser(), endDate);
254            _scheduler.scheduleJob(publishRunnable);
255            
256            unversionedDataHolder.setValue(UNPUBLISH_DATE, endDate);
257            unversionedDataHolder.setValue(UNPUBLISH_ACTION_ID, (long) workflowAction);
258        }
259        else
260        {
261            unversionedDataHolder.removeValue(UNPUBLISH_DATE);
262            unversionedDataHolder.removeValue(UNPUBLISH_ACTION_ID);
263        }
264        
265        if (content.needsSave())
266        {
267            content.saveChanges();
268        }
269    }
270    
271    @Override
272    @Callable(rights = Callable.CHECKED_BY_IMPLEMENTATION)
273    public Map<String, Object> getStatus(List<String> contentsId)
274    {
275        // perform the right checks on every contents
276        // all contents in "allright-contents" are allowed
277        Map<String, Object> results = super.getStatus(contentsId);
278        
279        List<Map<String, Object>> scheduledContents = new ArrayList<>();
280        List<Map<String, Object>> scheduledValidContents = new ArrayList<>();
281        List<Map<String, Object>> scheduledForthcomingContents = new ArrayList<>();
282        
283        @SuppressWarnings("unchecked")
284        List<Map<String, Object>> contents = (List<Map<String, Object>>) results.get("allright-contents");
285        for (Map<String, Object> contentParams : contents)
286        {
287            String contentId = (String) contentParams.get("id");
288            Content content = _resolver.resolveById(contentId);
289            ModifiableDataHolder unversionedDataHolder = ((ModifiableDataAwareVersionableAmetysObject) content).getUnversionedDataHolder();
290            
291            ZonedDateTime publishDate = _publishAction != -1 ? unversionedDataHolder.getValue(PUBLISH_DATE) : null;
292            ZonedDateTime unpublishDate = _unpublishAction != -1 ? unversionedDataHolder.getValue(UNPUBLISH_DATE) : null;
293            
294            ZonedDateTime now = ZonedDateTime.now();
295            if ((publishDate != null || unpublishDate != null)
296                && !isOutOfDate(publishDate, unpublishDate, now))
297            {
298                if (isForthcoming(publishDate, unpublishDate, now))
299                {
300                    I18nizableText description = _getContentDescription("scheduled-forthcoming", content);
301                    contentParams.put("description", description);
302                    scheduledForthcomingContents.add(contentParams);
303                }
304                else if (_isDateValid(now, publishDate, unpublishDate))
305                {
306                    I18nizableText description = _getContentDescription("scheduled-valid", content);
307                    contentParams.put("description", description);
308                    scheduledValidContents.add(contentParams);
309                }
310                scheduledContents.add(contentParams);
311            }
312        }
313        results.put("scheduled-valid-contents", scheduledValidContents);
314        results.put("scheduled-forthcoming-contents", scheduledForthcomingContents);
315        results.put("scheduled-contents", scheduledContents);
316        
317        return results;
318    }
319
320    private boolean isForthcoming(ZonedDateTime publishDate, ZonedDateTime unpublishDate, ZonedDateTime now)
321    {
322        return publishDate != null && publishDate.isAfter(now) // not publish
323                || unpublishDate != null && unpublishDate.isAfter(now) && _publishAction == -1; // unpublished only and not unpublished
324    }
325
326    private boolean isOutOfDate(ZonedDateTime publishDate, ZonedDateTime unpublishDate, ZonedDateTime now)
327    {
328        return unpublishDate != null && unpublishDate.isBefore(now) // unpublished
329                || publishDate != null && publishDate.isBefore(now) && _unpublishAction == -1; // publish only and published
330    }
331    
332    /**
333     * Get i18n description for the specified key
334     * @param key The description key
335     * @param content The content
336     * @return The {@link I18nizableText} description
337     */
338    private I18nizableText _getContentDescription (String key, Content content)
339    {
340        List<String> workflowI18nParameters = new ArrayList<>();
341        workflowI18nParameters.add(org.ametys.core.util.StringUtils.escapeHTML(_contentHelper.getTitle(content)));
342        
343        I18nizableText ed = (I18nizableText) _script.getParameters().get(key + "-content-description");
344        return new I18nizableText(ed.getCatalogue(), ed.getKey(), workflowI18nParameters);
345    }
346    
347    
348    private boolean _isDateValid(ZonedDateTime now, ZonedDateTime publishDate, ZonedDateTime unpublishDate)
349    {
350        return (publishDate == null || publishDate.isBefore(now))
351                && (unpublishDate == null || unpublishDate.isAfter(now));
352    }
353
354    // Override the workflow action check to ignore case where the step is unavailable due to being already scheduled
355    @Override
356    protected int _workflowAction(Content content)
357    {
358        Request request = ContextHelper.getRequest(_context);
359        try
360        {
361            request.setAttribute(ScheduledPublicationCondition.ALLOW_SCHEDULED_ACTION, true);
362            return super._workflowAction(content);
363        }
364        finally
365        {
366            request.removeAttribute(ScheduledPublicationCondition.ALLOW_SCHEDULED_ACTION);
367        }
368    }
369}