001/* 002 * Copyright 2011 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.workflow; 017 018import java.util.Date; 019import java.util.HashMap; 020import java.util.List; 021import java.util.Locale; 022import java.util.Map; 023import java.util.Map.Entry; 024import java.util.Optional; 025import java.util.concurrent.Future; 026 027import javax.jcr.Node; 028import javax.jcr.RepositoryException; 029import javax.jcr.Session; 030 031import org.apache.avalon.framework.service.ServiceException; 032import org.apache.avalon.framework.service.ServiceManager; 033import org.apache.commons.lang3.StringUtils; 034 035import org.ametys.cms.FilterNameHelper; 036import org.ametys.cms.ObservationConstants; 037import org.ametys.cms.contenttype.ContentType; 038import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 039import org.ametys.cms.contenttype.ContentTypesHelper; 040import org.ametys.cms.contenttype.MetadataDefinition; 041import org.ametys.cms.contenttype.MetadataType; 042import org.ametys.cms.repository.Content; 043import org.ametys.cms.repository.DefaultContent; 044import org.ametys.cms.repository.ModifiableContent; 045import org.ametys.cms.repository.ModifiableWorkflowAwareContent; 046import org.ametys.core.observation.Event; 047import org.ametys.core.observation.ObservationManager; 048import org.ametys.core.user.UserIdentity; 049import org.ametys.core.user.population.UserPopulationDAO; 050import org.ametys.plugins.repository.AmetysObjectResolver; 051import org.ametys.plugins.repository.AmetysRepositoryException; 052import org.ametys.plugins.repository.ModifiableTraversableAmetysObject; 053import org.ametys.plugins.repository.RepositoryConstants; 054import org.ametys.plugins.repository.RepositoryIntegrityViolationException; 055import org.ametys.plugins.repository.metadata.ModifiableCompositeMetadata; 056import org.ametys.plugins.workflow.store.AbstractJackrabbitWorkflowStore; 057import org.ametys.plugins.workflow.store.AmetysObjectWorkflowStore; 058 059import com.opensymphony.module.propertyset.PropertySet; 060import com.opensymphony.workflow.FunctionProvider; 061import com.opensymphony.workflow.WorkflowException; 062import com.opensymphony.workflow.spi.WorkflowEntry; 063import com.opensymphony.workflow.spi.WorkflowStore; 064 065/** 066 * OSWorkflow function for creating a content. 067 */ 068public class CreateContentFunction extends AbstractContentWorkflowComponent implements FunctionProvider 069{ 070 /** Constant for storing the content name to use into the transient variables map. */ 071 public static final String CONTENT_NAME_KEY = CreateContentFunction.class.getName() + "$contentName"; 072 /** Constant for storing the content title to use into the transient variables map. */ 073 public static final String CONTENT_TITLE_KEY = CreateContentFunction.class.getName() + "$contentTitle"; 074 /** Constant for storing the content title variants (for multilingual content only) to use into the transient variables map. */ 075 public static final String CONTENT_TITLE_VARIANTS_KEY = CreateContentFunction.class.getName() + "$contentTitleVariants"; 076 /** Constant for storing the content types to use into the transient variables map. */ 077 public static final String CONTENT_TYPES_KEY = CreateContentFunction.class.getName() + "$contentTypes"; 078 /** Constant for storing the content mixins to use into the transient variables map. */ 079 public static final String CONTENT_MIXINS_KEY = CreateContentFunction.class.getName() + "$mixins"; 080 /** Constant for storing the content language to use into the transient variables map. */ 081 public static final String CONTENT_LANGUAGE_KEY = CreateContentFunction.class.getName() + "$contentLanguage"; 082 /** Constant for storing the parent content to use into the transient variables map. */ 083 public static final String PARENT_CONTENT_ID_KEY = CreateContentFunction.class.getName() + "$parentContentId"; 084 /** Constant for storing the parent content metadata path to use into the transient variables map. */ 085 public static final String PARENT_CONTENT_METADATA_PATH_KEY = CreateContentFunction.class.getName() + "$parentContentMetadataPath"; 086 087 /** Ametys object resolver available to subclasses. */ 088 protected AmetysObjectResolver _resolver; 089 /** Observation manager available to subclasses. */ 090 protected ObservationManager _observationManager; 091 /** The content types handler */ 092 protected ContentTypeExtensionPoint _contentTypeEP; 093 /** The content types helper */ 094 protected ContentTypesHelper _contentTypeHelper; 095 096 @Override 097 public void service(ServiceManager manager) throws ServiceException 098 { 099 super.service(manager); 100 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 101 _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE); 102 _contentTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE); 103 _contentTypeHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE); 104 } 105 106 @Override 107 public void execute(Map transientVars, Map args, PropertySet ps) throws WorkflowException 108 { 109 _logger.info("Performing content creation"); 110 111 try 112 { 113 String desiredContentName = _getNonNullVar(transientVars, CONTENT_NAME_KEY, "Missing content name"); 114 115 String[] contentTypes = Optional.of(CONTENT_TYPES_KEY) 116 .map(transientVars::get) 117 .map(ctypes -> (String[]) ctypes) 118 .orElseThrow(() -> new WorkflowException("Missing contents types")); 119 120 String[] mixins = Optional.of(CONTENT_MIXINS_KEY) 121 .map(transientVars::get) 122 .map(m -> (String[]) m) 123 .orElse(new String[0]); 124 125 boolean multilingual = _isMultilingual(contentTypes); 126 127 String contentTitle = (String) transientVars.get(CONTENT_TITLE_KEY); 128 @SuppressWarnings("unchecked") 129 Map<String, String> contentTitleVariants = (Map<String, String>) transientVars.get(CONTENT_TITLE_VARIANTS_KEY); 130 131 if (contentTitle == null && contentTitleVariants == null) 132 { 133 throw new WorkflowException("Missing content title"); 134 } 135 136 String contentLanguage = (String) transientVars.get(CONTENT_LANGUAGE_KEY); 137 if (contentLanguage == null && !multilingual) 138 { 139 throw new WorkflowException("Missing content language for a non-multilingual content"); 140 } 141 142 ModifiableTraversableAmetysObject contents = _getContentRoot(transientVars); 143 144 ModifiableWorkflowAwareContent content = _createContent(transientVars, desiredContentName, contents); 145 content.setTypes(contentTypes); 146 147 if (contentTitleVariants != null) 148 { 149 _setTitle(content, contentTypes, contentTitleVariants, contentLanguage != null ? new Locale(contentLanguage) : null); 150 } 151 else 152 { 153 content.setTitle(contentTitle, contentLanguage != null ? new Locale(contentLanguage) : null); 154 } 155 156 content.setMixinTypes(mixins); 157 if (!_isMultilingual(contentTypes)) 158 { 159 content.setLanguage(contentLanguage); 160 } 161 162 // Set the workflow id 163 long workflowId = ((WorkflowEntry) transientVars.get("entry")).getId(); 164 content.setWorkflowId(workflowId); 165 166 _populateContent(transientVars, content); 167 168 // FIXME previous statements may have failed. 169 contents.saveChanges(); 170 171 _populateAdditionalData(transientVars, content); 172 173 Node node = content.getNode(); 174 Session session = node.getSession(); 175 176 _initWorkflow(transientVars, content, session, workflowId); 177 178 String parentContentId = (String) transientVars.get(PARENT_CONTENT_ID_KEY); 179 String parentContentMetadataPath = (String) transientVars.get(PARENT_CONTENT_METADATA_PATH_KEY); 180 if (StringUtils.isNotBlank(parentContentId) && StringUtils.isNotBlank(parentContentMetadataPath)) 181 { 182 node.setProperty("ametys-internal:subContent", true); 183 } 184 185 session.save(); 186 187 // Notify observers 188 _notifyContentAdded(content, transientVars); 189 190 // Content created 191 transientVars.put(CONTENT_KEY, content); 192 193 getResultsMap(transientVars).put("contentId", content.getId()); 194 getResultsMap(transientVars).put(CONTENT_KEY, content); 195 } 196 catch (RepositoryException e) 197 { 198 throw new WorkflowException("Unable to link the workflow to the content", e); 199 } 200 catch (AmetysRepositoryException e) 201 { 202 throw new WorkflowException("Unable to create the content", e); 203 } 204 } 205 206 private void _initWorkflow(Map transientVars, ModifiableWorkflowAwareContent content, Session session, long workflowId) throws AmetysRepositoryException, WorkflowException 207 { 208 try 209 { 210 WorkflowStore workflowStore = (WorkflowStore) transientVars.get("store"); 211 212 if (workflowStore instanceof AmetysObjectWorkflowStore) 213 { 214 AmetysObjectWorkflowStore ametysObjectWorkflowStore = (AmetysObjectWorkflowStore) workflowStore; 215 ametysObjectWorkflowStore.bindAmetysObject(content); 216 } 217 218 if (workflowStore instanceof AbstractJackrabbitWorkflowStore) 219 { 220 AbstractJackrabbitWorkflowStore jackrabbitWorkflowStore = (AbstractJackrabbitWorkflowStore) workflowStore; 221 Node workflowEntryNode = jackrabbitWorkflowStore.getEntryNode(session, workflowId); 222 223 Integer actionId = (Integer) transientVars.get("actionId"); 224 if (actionId != null) 225 { 226 workflowEntryNode.setProperty("ametys-internal:initialActionId", actionId); 227 } 228 } 229 } 230 catch (RepositoryException e) 231 { 232 throw new AmetysRepositoryException("Unable to link the workflow to the content", e); 233 } 234 } 235 236 /** 237 * Set the content's title variants 238 * @param content The content 239 * @param cTypes The content's content types 240 * @param titleVariants The title's variants 241 * @param locale The title's locale. Cannot be null if content is a non-multilingual content. 242 * @throws AmetysRepositoryException if failed to set title 243 */ 244 protected void _setTitle(ModifiableContent content, String[] cTypes, Map<String, String> titleVariants, Locale locale) throws AmetysRepositoryException 245 { 246 MetadataDefinition titleDef = _contentTypeHelper.getMetadataDefinition(DefaultContent.METADATA_TITLE, cTypes, new String[0]); 247 if (MetadataType.MULTILINGUAL_STRING.equals(titleDef.getType())) 248 { 249 for (Entry<String, String> variant : titleVariants.entrySet()) 250 { 251 content.setTitle(variant.getValue(), new Locale(variant.getKey())); 252 } 253 } 254 else 255 { 256 if (locale == null) 257 { 258 throw new IllegalArgumentException("Cannot set a title from variants with null locale on a non-multilingual content"); 259 } 260 261 if (!titleVariants.containsKey(locale.getLanguage())) 262 { 263 throw new IllegalArgumentException("Title variants do not contains value for the requested locale " + locale + " for non multilingual content"); 264 } 265 266 content.setTitle(titleVariants.get(locale.getLanguage())); 267 } 268 } 269 270 /** 271 * Determines if the content to create is a multilingual content 272 * @param contentTypes The content types of content to create 273 * @return true if multilingual 274 */ 275 protected boolean _isMultilingual(String[] contentTypes) 276 { 277 for (String cTypeId : contentTypes) 278 { 279 ContentType cType = _contentTypeEP.getExtension(cTypeId); 280 if (cType.isMultilingual()) 281 { 282 return true; 283 } 284 } 285 286 return false; 287 } 288 289 /** 290 * Notify observers that the content has been created 291 * @param content The content added 292 * @param transientVars The workflow vars 293 * @return The {@link Future} objects of the asynchronous observers 294 * @throws WorkflowException If an error occurred 295 */ 296 protected List<Future> _notifyContentAdded(Content content, Map transientVars) throws WorkflowException 297 { 298 return _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_ADDED, getUser(transientVars), _eventParamsForContentAdded(content))); 299 } 300 301 /** 302 * Gets the event parameters sent in method {@link #_notifyContentAdded(Content, Map)} 303 * @param content The content added 304 * @return the event parameters 305 */ 306 protected Map<String, Object> _eventParamsForContentAdded(Content content) 307 { 308 Map<String, Object> eventParams = new HashMap<>(); 309 eventParams.put(ObservationConstants.ARGS_CONTENT, content); 310 eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId()); 311 312 return eventParams; 313 } 314 315 /** 316 * Create the content object. 317 * @param transientVars the workflow parameters. 318 * @param desiredContentName the desired content name. 319 * @param contentsNode the content root node in the repository. 320 * @return the created Content. 321 */ 322 protected ModifiableWorkflowAwareContent _createContent(Map transientVars, String desiredContentName, ModifiableTraversableAmetysObject contentsNode) 323 { 324 ModifiableWorkflowAwareContent content = null; 325 326 String contentName = FilterNameHelper.filterName(desiredContentName); 327 int errorCount = 0; 328 do 329 { 330 if (errorCount != 0) 331 { 332 contentName = FilterNameHelper.filterName(desiredContentName + " " + (errorCount + 1)); 333 } 334 try 335 { 336 String type = _getObjectType(transientVars); 337 content = contentsNode.createChild(contentName, type); 338 } 339 catch (RepositoryIntegrityViolationException e) 340 { 341 // Content name is already used 342 errorCount++; 343 } 344 } 345 while (content == null); 346 347 return content; 348 } 349 350 /** 351 * Return the type of the object to be created. 352 * Ex: ametys:defaultContent. 353 * @param transientVars The workflow vars 354 * @return The type of the object to be used during content creation. 355 */ 356 protected String _getObjectType(Map transientVars) 357 { 358 return RepositoryConstants.NAMESPACE_PREFIX + ":defaultContent"; 359 } 360 361 /** 362 * Retrieve the content root. 363 * @param transientVars the workflow parameters. 364 * @return the content root node. 365 * @throws WorkflowException if an error occurs 366 */ 367 protected ModifiableTraversableAmetysObject _getContentRoot(Map transientVars) throws WorkflowException 368 { 369 String parentContentId = (String) transientVars.get(PARENT_CONTENT_ID_KEY); 370 String parentContentMetadataPath = (String) transientVars.get(PARENT_CONTENT_METADATA_PATH_KEY); 371 372 if (StringUtils.isNotBlank(parentContentId) && StringUtils.isNotBlank(parentContentMetadataPath)) 373 { 374 return _getSubContentRoot(parentContentId, parentContentMetadataPath); 375 } 376 377 return _resolver.resolveByPath("/" + RepositoryConstants.NAMESPACE_PREFIX + ":contents"); 378 } 379 380 /** 381 * Get the content root when creating a sub-content. 382 * @param parentContentId the parent content ID. 383 * @param parentContentMetadataPath the path of the metadata in which to create the sub-content. 384 * @return the content collection metadata. 385 * @throws WorkflowException if an error occurs. 386 */ 387 protected ModifiableTraversableAmetysObject _getSubContentRoot(String parentContentId, String parentContentMetadataPath) throws WorkflowException 388 { 389 Content parentContent = _resolver.resolveById(parentContentId); 390 391 if (parentContent instanceof ModifiableContent) 392 { 393 ModifiableCompositeMetadata meta = ((ModifiableContent) parentContent).getMetadataHolder(); 394 395 String metaPath = parentContentMetadataPath.replace('/', '.'); 396 397 String[] metadatas = StringUtils.split(metaPath, '.'); 398 for (int i = 0; i < metadatas.length - 1; i++) 399 { 400 meta = meta.getCompositeMetadata(metadatas[i], true); 401 } 402 403 String metaName = metadatas[metadatas.length - 1]; 404 405 return meta.getObjectCollection(metaName, true); 406 } 407 else 408 { 409 throw new WorkflowException("The content " + parentContentId + " is not modifiable."); 410 } 411 } 412 413 /** 414 * Get a workflow parameter, throwing an exception if it is null. 415 * @param transientVars the workflow parameters. 416 * @param name the variable name. 417 * @param exceptionName label of the exception to throw if the variable is null. 418 * @return the variable value. 419 * @throws WorkflowException If an error occurred 420 */ 421 protected String _getNonNullVar(Map transientVars, String name, String exceptionName) throws WorkflowException 422 { 423 String value = (String) transientVars.get(name); 424 if (value == null) 425 { 426 throw new WorkflowException(exceptionName); 427 } 428 return value; 429 } 430 431 /** 432 * Populate the content. 433 * @param transientVars the transient variables. 434 * @param content the content. 435 * @throws WorkflowException if an error occurs. 436 */ 437 protected void _populateContent(Map transientVars, ModifiableContent content) throws WorkflowException 438 { 439 UserIdentity user = getUser(transientVars); 440 if (user == null) 441 { 442 // FIXME Login can be null when creating the content in the background environment. 443 user = getSystemUser(); 444 } 445 446 // Add standard metadata 447 content.setCreator(user); 448 content.setCreationDate(new Date()); 449 content.setLastContributor(user); 450 content.setLastModified(new Date()); 451 } 452 453 /** 454 * Get the user to use when the content is created in a background environment. 455 * @return The anonymous user 456 */ 457 protected UserIdentity getSystemUser () 458 { 459 return UserPopulationDAO.SYSTEM_USER_IDENTITY; 460 } 461 462 /** 463 * Populate the content. 464 * @param transientVars the transient variables. 465 * @param content the content. 466 * @throws WorkflowException if an error occurs. 467 */ 468 protected void _populateAdditionalData(Map transientVars, ModifiableContent content) throws WorkflowException 469 { 470 // Nothing to do. 471 } 472}