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        Map<String, Object> eventParams = new HashMap<>();
299        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
300        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
301
302        return _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_ADDED, getUser(transientVars), eventParams));
303    }
304
305    /**
306     * Create the content object.
307     * @param transientVars the workflow parameters.
308     * @param desiredContentName the desired content name.
309     * @param contentsNode the content root node in the repository.
310     * @return the created Content.
311     */
312    protected ModifiableWorkflowAwareContent _createContent(Map transientVars, String desiredContentName, ModifiableTraversableAmetysObject contentsNode)
313    {
314        ModifiableWorkflowAwareContent content = null;
315        
316        String contentName = FilterNameHelper.filterName(desiredContentName);
317        int errorCount = 0;
318        do
319        {
320            if (errorCount != 0)
321            {
322                contentName = FilterNameHelper.filterName(desiredContentName + " " + (errorCount + 1));
323            }
324            try
325            {
326                String type = _getObjectType(transientVars);
327                content = contentsNode.createChild(contentName, type);
328            }
329            catch (RepositoryIntegrityViolationException e)
330            {
331                // Content name is already used
332                errorCount++;
333            }
334        }
335        while (content == null);
336        
337        return content;
338    }
339    
340    /**
341     * Return the type of the object to be created.
342     * Ex: ametys:defaultContent.
343     * @param transientVars The workflow vars
344     * @return The type of the object to be used during content creation.
345     */
346    protected String _getObjectType(Map transientVars)
347    {
348        return RepositoryConstants.NAMESPACE_PREFIX + ":defaultContent";
349    }
350
351    /**
352     * Retrieve the content root.
353     * @param transientVars the workflow parameters.
354     * @return the content root node.
355     * @throws WorkflowException if an error occurs
356     */
357    protected ModifiableTraversableAmetysObject _getContentRoot(Map transientVars) throws WorkflowException
358    {
359        String parentContentId = (String) transientVars.get(PARENT_CONTENT_ID_KEY);
360        String parentContentMetadataPath = (String) transientVars.get(PARENT_CONTENT_METADATA_PATH_KEY);
361        
362        if (StringUtils.isNotBlank(parentContentId) && StringUtils.isNotBlank(parentContentMetadataPath))
363        {
364            return _getSubContentRoot(parentContentId, parentContentMetadataPath);
365        }
366        
367        return _resolver.resolveByPath("/" + RepositoryConstants.NAMESPACE_PREFIX + ":contents");
368    }
369    
370    /**
371     * Get the content root when creating a sub-content.
372     * @param parentContentId the parent content ID.
373     * @param parentContentMetadataPath the path of the metadata in which to create the sub-content.
374     * @return the content collection metadata.
375     * @throws WorkflowException if an error occurs.
376     */
377    protected ModifiableTraversableAmetysObject _getSubContentRoot(String parentContentId, String parentContentMetadataPath) throws WorkflowException
378    {
379        Content parentContent = _resolver.resolveById(parentContentId);
380        
381        if (parentContent instanceof ModifiableContent)
382        {
383            ModifiableCompositeMetadata meta = ((ModifiableContent) parentContent).getMetadataHolder();
384            
385            String metaPath = parentContentMetadataPath.replace('/', '.');
386            
387            String[] metadatas = StringUtils.split(metaPath, '.');
388            for (int i = 0; i < metadatas.length - 1; i++)
389            {
390                meta = meta.getCompositeMetadata(metadatas[i], true);
391            }
392            
393            String metaName = metadatas[metadatas.length - 1];
394            
395            return meta.getObjectCollection(metaName, true);
396        }
397        else
398        {
399            throw new WorkflowException("The content " + parentContentId + " is not modifiable.");
400        }
401    }
402    
403    /**
404     * Get a workflow parameter, throwing an exception if it is null.
405     * @param transientVars the workflow parameters.
406     * @param name the variable name.
407     * @param exceptionName label of the exception to throw if the variable is null.
408     * @return the variable value.
409     * @throws WorkflowException If an error occurred
410     */
411    protected String _getNonNullVar(Map transientVars, String name, String exceptionName) throws WorkflowException
412    {
413        String value = (String) transientVars.get(name);
414        if (value == null)
415        {
416            throw new WorkflowException(exceptionName);
417        }
418        return value;
419    }
420    
421    /**
422     * Populate the content.
423     * @param transientVars the transient variables.
424     * @param content the content.
425     * @throws WorkflowException if an error occurs.
426     */
427    protected void _populateContent(Map transientVars, ModifiableContent content) throws WorkflowException
428    {
429        UserIdentity user = getUser(transientVars);
430        if (user == null)
431        {
432            // FIXME Login can be null when creating the content in the background environment.
433            user = getSystemUser();
434        }
435        
436        // Add standard metadata
437        content.setCreator(user);
438        content.setCreationDate(new Date());
439        content.setLastContributor(user);
440        content.setLastModified(new Date());
441    }
442    
443    /**
444     * Get the user to use when the content is created in a background environment.
445     * @return The anonymous user
446     */
447    protected UserIdentity getSystemUser ()
448    {
449        return UserPopulationDAO.SYSTEM_USER_IDENTITY;
450    }
451    
452    /**
453     * Populate the content.
454     * @param transientVars the transient variables.
455     * @param content the content.
456     * @throws WorkflowException if an error occurs.
457     */
458    protected void _populateAdditionalData(Map transientVars, ModifiableContent content) throws WorkflowException
459    {
460        // Nothing to do.
461    }
462}