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.util.Date;
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.FilterNameHelper;
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.RepositoryIntegrityViolationException;
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, 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 desiredContentName the desired content name.
335     * @param contentsNode the content root node in the repository.
336     * @return the created Content.
337     */
338    protected ModifiableWorkflowAwareContent _createContent(Map transientVars, String desiredContentName, ModifiableTraversableAmetysObject contentsNode)
339    {
340        ModifiableWorkflowAwareContent content = null;
341        
342        String contentName = FilterNameHelper.filterName(desiredContentName);
343        int errorCount = 0;
344        do
345        {
346            if (errorCount != 0)
347            {
348                contentName = FilterNameHelper.filterName(desiredContentName + " " + (errorCount + 1));
349            }
350            try
351            {
352                String type = _getObjectType(transientVars);
353                content = contentsNode.createChild(contentName, type);
354            }
355            catch (RepositoryIntegrityViolationException e)
356            {
357                // Content name is already used
358                errorCount++;
359            }
360        }
361        while (content == null);
362        
363        return content;
364    }
365    
366    /**
367     * Return the type of the object to be created.
368     * Ex: ametys:defaultContent.
369     * @param transientVars The workflow vars
370     * @return The type of the object to be used during content creation.
371     */
372    protected String _getObjectType(Map transientVars)
373    {
374        return RepositoryConstants.NAMESPACE_PREFIX + ":defaultContent";
375    }
376
377    /**
378     * Retrieve the content root.
379     * @param transientVars the workflow parameters.
380     * @return the content root node.
381     * @throws WorkflowException if an error occurs
382     */
383    protected ModifiableTraversableAmetysObject _getContentRoot(Map transientVars) throws WorkflowException
384    {
385        String parentContentId = (String) transientVars.get(PARENT_CONTENT_ID_KEY);
386        String parentContentMetadataPath = (String) transientVars.get(PARENT_CONTENT_METADATA_PATH_KEY);
387        
388        if (StringUtils.isNotBlank(parentContentId) && StringUtils.isNotBlank(parentContentMetadataPath))
389        {
390            return _getSubContentRoot(parentContentId, parentContentMetadataPath);
391        }
392        
393        return _resolver.resolveByPath("/" + RepositoryConstants.NAMESPACE_PREFIX + ":contents");
394    }
395    
396    /**
397     * Get the content root when creating a sub-content.
398     * @param parentContentId the parent content ID.
399     * @param parentContentMetadataPath the path of the metadata in which to create the sub-content.
400     * @return the content collection metadata.
401     * @throws WorkflowException if an error occurs.
402     */
403    protected ModifiableTraversableAmetysObject _getSubContentRoot(String parentContentId, String parentContentMetadataPath) throws WorkflowException
404    {
405        Content parentContent = _resolver.resolveById(parentContentId);
406        
407        if (parentContent instanceof ModifiableContent)
408        {
409            ModifiableCompositeMetadata meta = ((ModifiableContent) parentContent).getMetadataHolder();
410            
411            String metaPath = parentContentMetadataPath.replace('/', '.');
412            
413            String[] metadatas = StringUtils.split(metaPath, '.');
414            for (int i = 0; i < metadatas.length - 1; i++)
415            {
416                meta = meta.getCompositeMetadata(metadatas[i], true);
417            }
418            
419            String metaName = metadatas[metadatas.length - 1];
420            
421            return meta.getObjectCollection(metaName, true);
422        }
423        else
424        {
425            throw new WorkflowException("The content " + parentContentId + " is not modifiable.");
426        }
427    }
428    
429    /**
430     * Get a workflow parameter, throwing an exception if it is null.
431     * @param transientVars the workflow parameters.
432     * @param name the variable name.
433     * @param exceptionName label of the exception to throw if the variable is null.
434     * @return the variable value.
435     * @throws WorkflowException If an error occurred
436     */
437    protected String _getNonNullVar(Map transientVars, String name, String exceptionName) throws WorkflowException
438    {
439        String value = (String) transientVars.get(name);
440        if (value == null)
441        {
442            throw new WorkflowException(exceptionName);
443        }
444        return value;
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 _populateContent(Map transientVars, ModifiableContent content) throws WorkflowException
454    {
455        UserIdentity user = getUser(transientVars);
456        if (user == null)
457        {
458            // FIXME Login can be null when creating the content in the background environment.
459            user = getSystemUser();
460        }
461        
462        // Add standard metadata
463        content.setCreator(user);
464        content.setCreationDate(new Date());
465        content.setLastContributor(user);
466        content.setLastModified(new Date());
467    }
468    
469    /**
470     * Get the user to use when the content is created in a background environment.
471     * @return The anonymous user
472     */
473    protected UserIdentity getSystemUser ()
474    {
475        return UserPopulationDAO.SYSTEM_USER_IDENTITY;
476    }
477    
478    /**
479     * Populate the content.
480     * @param transientVars the transient variables.
481     * @param content the content.
482     * @throws WorkflowException if an error occurs.
483     */
484    protected void _populateAdditionalData(Map transientVars, ModifiableContent content) throws WorkflowException
485    {
486        // Nothing to do.
487    }
488}