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