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