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