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}