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 Map<String, Object> eventParams = new HashMap<>(); 299 eventParams.put(ObservationConstants.ARGS_CONTENT, content); 300 eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId()); 301 302 return _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_ADDED, getUser(transientVars), eventParams)); 303 } 304 305 /** 306 * Create the content object. 307 * @param transientVars the workflow parameters. 308 * @param desiredContentName the desired content name. 309 * @param contentsNode the content root node in the repository. 310 * @return the created Content. 311 */ 312 protected ModifiableWorkflowAwareContent _createContent(Map transientVars, String desiredContentName, ModifiableTraversableAmetysObject contentsNode) 313 { 314 ModifiableWorkflowAwareContent content = null; 315 316 String contentName = FilterNameHelper.filterName(desiredContentName); 317 int errorCount = 0; 318 do 319 { 320 if (errorCount != 0) 321 { 322 contentName = FilterNameHelper.filterName(desiredContentName + " " + (errorCount + 1)); 323 } 324 try 325 { 326 String type = _getObjectType(transientVars); 327 content = contentsNode.createChild(contentName, type); 328 } 329 catch (RepositoryIntegrityViolationException e) 330 { 331 // Content name is already used 332 errorCount++; 333 } 334 } 335 while (content == null); 336 337 return content; 338 } 339 340 /** 341 * Return the type of the object to be created. 342 * Ex: ametys:defaultContent. 343 * @param transientVars The workflow vars 344 * @return The type of the object to be used during content creation. 345 */ 346 protected String _getObjectType(Map transientVars) 347 { 348 return RepositoryConstants.NAMESPACE_PREFIX + ":defaultContent"; 349 } 350 351 /** 352 * Retrieve the content root. 353 * @param transientVars the workflow parameters. 354 * @return the content root node. 355 * @throws WorkflowException if an error occurs 356 */ 357 protected ModifiableTraversableAmetysObject _getContentRoot(Map transientVars) throws WorkflowException 358 { 359 String parentContentId = (String) transientVars.get(PARENT_CONTENT_ID_KEY); 360 String parentContentMetadataPath = (String) transientVars.get(PARENT_CONTENT_METADATA_PATH_KEY); 361 362 if (StringUtils.isNotBlank(parentContentId) && StringUtils.isNotBlank(parentContentMetadataPath)) 363 { 364 return _getSubContentRoot(parentContentId, parentContentMetadataPath); 365 } 366 367 return _resolver.resolveByPath("/" + RepositoryConstants.NAMESPACE_PREFIX + ":contents"); 368 } 369 370 /** 371 * Get the content root when creating a sub-content. 372 * @param parentContentId the parent content ID. 373 * @param parentContentMetadataPath the path of the metadata in which to create the sub-content. 374 * @return the content collection metadata. 375 * @throws WorkflowException if an error occurs. 376 */ 377 protected ModifiableTraversableAmetysObject _getSubContentRoot(String parentContentId, String parentContentMetadataPath) throws WorkflowException 378 { 379 Content parentContent = _resolver.resolveById(parentContentId); 380 381 if (parentContent instanceof ModifiableContent) 382 { 383 ModifiableCompositeMetadata meta = ((ModifiableContent) parentContent).getMetadataHolder(); 384 385 String metaPath = parentContentMetadataPath.replace('/', '.'); 386 387 String[] metadatas = StringUtils.split(metaPath, '.'); 388 for (int i = 0; i < metadatas.length - 1; i++) 389 { 390 meta = meta.getCompositeMetadata(metadatas[i], true); 391 } 392 393 String metaName = metadatas[metadatas.length - 1]; 394 395 return meta.getObjectCollection(metaName, true); 396 } 397 else 398 { 399 throw new WorkflowException("The content " + parentContentId + " is not modifiable."); 400 } 401 } 402 403 /** 404 * Get a workflow parameter, throwing an exception if it is null. 405 * @param transientVars the workflow parameters. 406 * @param name the variable name. 407 * @param exceptionName label of the exception to throw if the variable is null. 408 * @return the variable value. 409 * @throws WorkflowException If an error occurred 410 */ 411 protected String _getNonNullVar(Map transientVars, String name, String exceptionName) throws WorkflowException 412 { 413 String value = (String) transientVars.get(name); 414 if (value == null) 415 { 416 throw new WorkflowException(exceptionName); 417 } 418 return value; 419 } 420 421 /** 422 * Populate the content. 423 * @param transientVars the transient variables. 424 * @param content the content. 425 * @throws WorkflowException if an error occurs. 426 */ 427 protected void _populateContent(Map transientVars, ModifiableContent content) throws WorkflowException 428 { 429 UserIdentity user = getUser(transientVars); 430 if (user == null) 431 { 432 // FIXME Login can be null when creating the content in the background environment. 433 user = getSystemUser(); 434 } 435 436 // Add standard metadata 437 content.setCreator(user); 438 content.setCreationDate(new Date()); 439 content.setLastContributor(user); 440 content.setLastModified(new Date()); 441 } 442 443 /** 444 * Get the user to use when the content is created in a background environment. 445 * @return The anonymous user 446 */ 447 protected UserIdentity getSystemUser () 448 { 449 return UserPopulationDAO.SYSTEM_USER_IDENTITY; 450 } 451 452 /** 453 * Populate the content. 454 * @param transientVars the transient variables. 455 * @param content the content. 456 * @throws WorkflowException if an error occurs. 457 */ 458 protected void _populateAdditionalData(Map transientVars, ModifiableContent content) throws WorkflowException 459 { 460 // Nothing to do. 461 } 462}