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