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