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