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