001/*
002 *  Copyright 2016 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.content;
017
018import java.util.ArrayList;
019import java.util.Arrays;
020import java.util.Collection;
021import java.util.Collections;
022import java.util.HashMap;
023import java.util.LinkedHashMap;
024import java.util.List;
025import java.util.Locale;
026import java.util.Map;
027import java.util.Optional;
028import java.util.Set;
029import java.util.stream.Collectors;
030import java.util.stream.Stream;
031
032import javax.jcr.Node;
033import javax.jcr.NodeIterator;
034import javax.jcr.RepositoryException;
035
036import org.apache.avalon.framework.activity.Initializable;
037import org.apache.avalon.framework.component.Component;
038import org.apache.avalon.framework.context.Context;
039import org.apache.avalon.framework.context.ContextException;
040import org.apache.avalon.framework.context.Contextualizable;
041import org.apache.avalon.framework.service.ServiceException;
042import org.apache.avalon.framework.service.ServiceManager;
043import org.apache.avalon.framework.service.Serviceable;
044import org.apache.cocoon.ProcessingException;
045import org.apache.cocoon.components.ContextHelper;
046import org.apache.commons.collections.CollectionUtils;
047import org.apache.commons.lang3.ArrayUtils;
048import org.apache.commons.lang3.StringUtils;
049import org.apache.commons.lang3.tuple.ImmutablePair;
050import org.apache.commons.lang3.tuple.Pair;
051
052import org.ametys.cms.ObservationConstants;
053import org.ametys.cms.content.references.OutgoingReferencesHelper;
054import org.ametys.cms.contenttype.ContentType;
055import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
056import org.ametys.cms.contenttype.ContentTypesHelper;
057import org.ametys.cms.contenttype.ContentValidator;
058import org.ametys.cms.data.ContentValue;
059import org.ametys.cms.data.holder.DataHolderDisableConditionsEvaluator;
060import org.ametys.cms.data.type.ModelItemTypeConstants;
061import org.ametys.cms.repository.Content;
062import org.ametys.cms.repository.DefaultContent;
063import org.ametys.cms.repository.ModifiableContent;
064import org.ametys.cms.repository.WorkflowAwareContent;
065import org.ametys.cms.search.model.SystemProperty;
066import org.ametys.cms.search.model.SystemPropertyExtensionPoint;
067import org.ametys.cms.workflow.AbstractContentWorkflowComponent;
068import org.ametys.core.observation.Event;
069import org.ametys.core.observation.ObservationManager;
070import org.ametys.core.ui.Callable;
071import org.ametys.core.user.CurrentUserProvider;
072import org.ametys.core.util.I18nUtils;
073import org.ametys.core.util.URIUtils;
074import org.ametys.plugins.repository.AmetysObjectResolver;
075import org.ametys.plugins.repository.AmetysRepositoryException;
076import org.ametys.plugins.repository.RepositoryConstants;
077import org.ametys.plugins.repository.data.holder.ModelAwareDataHolder;
078import org.ametys.plugins.repository.data.holder.group.ModelAwareComposite;
079import org.ametys.plugins.repository.data.holder.group.ModelAwareRepeater;
080import org.ametys.plugins.repository.data.holder.group.ModelAwareRepeaterEntry;
081import org.ametys.plugins.repository.jcr.JCRAmetysObject;
082import org.ametys.plugins.repository.metadata.MultilingualString;
083import org.ametys.plugins.repository.model.CompositeDefinition;
084import org.ametys.plugins.repository.model.RepeaterDefinition;
085import org.ametys.plugins.workflow.AbstractWorkflowComponent;
086import org.ametys.plugins.workflow.support.WorkflowProvider;
087import org.ametys.plugins.workflow.support.WorkflowProvider.AmetysObjectWorkflow;
088import org.ametys.runtime.config.Config;
089import org.ametys.runtime.i18n.I18nizableText;
090import org.ametys.runtime.model.DefinitionContext;
091import org.ametys.runtime.model.ElementDefinition;
092import org.ametys.runtime.model.ModelHelper;
093import org.ametys.runtime.model.ModelItem;
094import org.ametys.runtime.model.View;
095import org.ametys.runtime.model.ViewHelper;
096import org.ametys.runtime.model.disableconditions.DisableConditionsEvaluator;
097import org.ametys.runtime.parameter.ValidationResult;
098import org.ametys.runtime.plugin.component.AbstractLogEnabled;
099
100import com.opensymphony.workflow.WorkflowException;
101
102/**
103 * Helper for {@link Content}
104 *
105 */
106public class ContentHelper extends AbstractLogEnabled implements Component, Serviceable, Contextualizable, Initializable
107{
108    /** The component role. */
109    public static final String ROLE = ContentHelper.class.getName();
110   
111    /** The base URL for the CMS */
112    protected String _baseURL;
113
114    /** The Ametys object resolver */
115    protected AmetysObjectResolver _resolver;
116    
117    /** The context */
118    protected Context _context;
119
120    private ContentTypesHelper _contentTypesHelper;
121    private ContentTypeExtensionPoint _contentTypeEP;
122    private ObservationManager _observationManager;
123    private WorkflowProvider _workflowProvider;
124    private CurrentUserProvider _currentUserProvider;
125    private SystemPropertyExtensionPoint _systemPropertyExtensionPoint;
126    private DisableConditionsEvaluator<ModelAwareDataHolder> _disableConditionsEvaluator;
127    private I18nUtils _i18nUtils;
128    
129    @SuppressWarnings("unchecked")
130    @Override
131    public void service(ServiceManager smanager) throws ServiceException
132    {
133        _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
134        _contentTypesHelper = (ContentTypesHelper) smanager.lookup(ContentTypesHelper.ROLE);
135        _contentTypeEP = (ContentTypeExtensionPoint) smanager.lookup(ContentTypeExtensionPoint.ROLE);
136        _workflowProvider = (WorkflowProvider) smanager.lookup(WorkflowProvider.ROLE);
137        _observationManager = (ObservationManager) smanager.lookup(ObservationManager.ROLE);
138        _currentUserProvider = (CurrentUserProvider) smanager.lookup(CurrentUserProvider.ROLE);
139        _systemPropertyExtensionPoint = (SystemPropertyExtensionPoint) smanager.lookup(SystemPropertyExtensionPoint.ROLE);
140        _disableConditionsEvaluator = (DisableConditionsEvaluator<ModelAwareDataHolder>) smanager.lookup(DataHolderDisableConditionsEvaluator.ROLE);
141        _i18nUtils = (I18nUtils) smanager.lookup(I18nUtils.ROLE);
142    }
143    
144    public void initialize() throws Exception
145    {
146        _baseURL = StringUtils.stripEnd(StringUtils.removeEndIgnoreCase(Config.getInstance().getValue("cms.url"), "index.html"), "/");
147    }
148    
149    @Override
150    public void contextualize(Context context) throws ContextException
151    {
152        _context = context;
153    }
154    
155    /**
156     * Add a content type to an existing content
157     * @param contentId The content id
158     * @param contentTypeId The content type to add
159     * @param actionId The workflow action id
160     * @return The result in a Map
161     * @throws WorkflowException if
162     * @throws AmetysRepositoryException if an error occurred
163     */
164    @Callable
165    public Map<String, Object> addContentType (String contentId, String contentTypeId, int actionId) throws AmetysRepositoryException, WorkflowException
166    {
167        return _setContentType(contentId, contentTypeId, actionId, false);
168    }
169    
170    /**
171     * Remove a content type to an existing content
172     * @param contentId The content id
173     * @param contentTypeId The content type to add
174     * @param actionId The workflow action id
175     * @return The result in a Map
176     * @throws WorkflowException if
177     * @throws AmetysRepositoryException if an error occurred
178     */
179    @Callable
180    public Map<String, Object> removeContentType (String contentId, String contentTypeId, int actionId) throws AmetysRepositoryException, WorkflowException
181    {
182        return _setContentType(contentId, contentTypeId, actionId, true);
183    }
184    
185    /**
186     * Add a mixin type to an existing content
187     * @param contentId The content id
188     * @param mixinId The mixin type to add
189     * @param actionId The workflow action id
190     * @return The result in a Map
191     * @throws WorkflowException if
192     * @throws AmetysRepositoryException if an error occurred
193     */
194    @Callable
195    public Map<String, Object> addMixinType (String contentId, String mixinId, int actionId) throws AmetysRepositoryException, WorkflowException
196    {
197        return _setMixinType(contentId, mixinId, actionId, false);
198    }
199    
200    /**
201     * Remove a mixin type to an existing content
202     * @param contentId The content id
203     * @param mixinId The mixin type to add
204     * @param actionId The workflow action id
205     * @return The result in a Map
206     * @throws WorkflowException if
207     * @throws AmetysRepositoryException if an error occurred
208     */
209    @Callable
210    public Map<String, Object> removeMixinType (String contentId, String mixinId, int actionId) throws AmetysRepositoryException, WorkflowException
211    {
212        return _setMixinType(contentId, mixinId, actionId, true);
213    }
214    
215    /**
216     * Get content edition information.
217     * @param contentId the content ID.
218     * @return a Map containing content edition information.
219     */
220    @Callable
221    public Map<String, Object> getContentEditionInformation(String contentId)
222    {
223        Map<String, Object> info = new HashMap<>();
224        
225        Content content = _resolver.resolveById(contentId);
226        
227        info.put("hasIndexingReferences", hasIndexingReferences(content));
228        
229        return info;
230    }
231    
232    /**
233     * Test if the given content has indexing references, i.e. if modifying it
234     * potentially implies reindexing other contents.
235     * @param content the content to test.
236     * @return <code>true</code> if one of the content types or mixins has indexing references, <code>false</code> otherwise.
237     */
238    public boolean hasIndexingReferences(Content content)
239    {
240        for (String cTypeId : content.getTypes())
241        {
242            if (_contentTypeEP.hasIndexingReferences(cTypeId))
243            {
244                return true;
245            }
246        }
247        
248        for (String mixinId : content.getMixinTypes())
249        {
250            if (_contentTypeEP.hasIndexingReferences(mixinId))
251            {
252                return true;
253            }
254        }
255        
256        return false;
257    }
258    
259    private Map<String, Object> _setContentType (String contentId, String contentTypeId, int actionId, boolean remove) throws AmetysRepositoryException, WorkflowException
260    {
261        Map<String, Object> result = new HashMap<>();
262        
263        Content content = _resolver.resolveById(contentId);
264
265        if (content instanceof ModifiableContent)
266        {
267            ModifiableContent modifiableContent = (ModifiableContent) content;
268            
269            List<String> currentTypes = new ArrayList<>(Arrays.asList(content.getTypes()));
270            
271            boolean hasChange = false;
272            if (remove)
273            {
274                if (currentTypes.size() > 1)
275                {
276                    hasChange = currentTypes.remove(contentTypeId);
277                }
278                else
279                {
280                    result.put("failure", true);
281                    result.put("msg", "empty-list");
282                }
283            }
284            else if (!currentTypes.contains(contentTypeId))
285            {
286                ContentType cType = _contentTypeEP.getExtension(contentTypeId);
287                if (cType.isMixin())
288                {
289                    result.put("failure", true);
290                    result.put("msg", "no-content-type");
291                    getLogger().error("Content type '{}' is a mixin type. It can not be added as content type.", contentTypeId);
292                }
293                else if (!_contentTypesHelper.isCompatibleContentType(content, contentTypeId))
294                {
295                    result.put("failure", true);
296                    result.put("msg", "invalid-content-type");
297                    getLogger().error("Content type '{}' is incompatible with content '{}'.", contentTypeId, contentId);
298                }
299                else
300                {
301                    currentTypes.add(contentTypeId);
302                    hasChange = true;
303                }
304            }
305            
306            if (hasChange)
307            {
308                // TODO check if the content type is compatible
309                modifiableContent.setTypes(currentTypes.toArray(new String[currentTypes.size()]));
310                modifiableContent.saveChanges();
311                
312                if (content instanceof WorkflowAwareContent)
313                {
314                    
315                    WorkflowAwareContent waContent = (WorkflowAwareContent) content;
316                    AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(waContent);
317                    
318                    Map<String, Object> inputs = new HashMap<>();
319                    inputs.put(AbstractContentWorkflowComponent.CONTENT_KEY, content);
320                    inputs.put(AbstractWorkflowComponent.RESULT_MAP_KEY, new LinkedHashMap<>());
321                    inputs.put(AbstractWorkflowComponent.FAIL_CONDITIONS_KEY, new ArrayList<>());
322                    
323                    workflow.doAction(waContent.getWorkflowId(), actionId, inputs);
324                }
325                
326                result.put("success", true);
327                
328                Map<String, Object> eventParams = new HashMap<>();
329                eventParams.put(ObservationConstants.ARGS_CONTENT, modifiableContent);
330                eventParams.put(ObservationConstants.ARGS_CONTENT_ID, contentId);
331                _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_MODIFIED, _currentUserProvider.getUser(), eventParams));
332            }
333        }
334        else
335        {
336            result.put("failure", true);
337            result.put("msg", "no-modifiable-content");
338            getLogger().error("Can not modified content types to a non-modifiable content '{}'.", contentId);
339        }
340        
341        return result;
342    }
343    
344    private Map<String, Object> _setMixinType (String contentId, String mixinId, int actionId, boolean remove) throws AmetysRepositoryException, WorkflowException
345    {
346        Map<String, Object> result = new HashMap<>();
347        
348        Content content = _resolver.resolveById(contentId);
349
350        if (content instanceof ModifiableContent)
351        {
352            ModifiableContent modifiableContent = (ModifiableContent) content;
353            
354            List<String> currentMixins = new ArrayList<>(Arrays.asList(content.getMixinTypes()));
355            
356            boolean hasChange = false;
357            if (remove)
358            {
359                hasChange = currentMixins.remove(mixinId);
360            }
361            else if (!currentMixins.contains(mixinId))
362            {
363                ContentType cType = _contentTypeEP.getExtension(mixinId);
364                if (!cType.isMixin())
365                {
366                    result.put("failure", true);
367                    result.put("msg", "no-mixin");
368                    getLogger().error("The content type '{}' is not a mixin type, it can be not be added as a mixin.", mixinId);
369                }
370                else if (!_contentTypesHelper.isCompatibleContentType(content, mixinId))
371                {
372                    result.put("failure", true);
373                    result.put("msg", "invalid-mixin");
374                    getLogger().error("Mixin '{}' is incompatible with content '{}'.", mixinId, contentId);
375                }
376                else
377                {
378                    currentMixins.add(mixinId);
379                    hasChange = true;
380                }
381            }
382            
383            if (hasChange)
384            {
385                // TODO check if the content type is compatible
386                modifiableContent.setMixinTypes(currentMixins.toArray(new String[currentMixins.size()]));
387                modifiableContent.saveChanges();
388                
389                if (content instanceof WorkflowAwareContent)
390                {
391                    WorkflowAwareContent waContent = (WorkflowAwareContent) content;
392                    AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(waContent);
393                    
394                    Map<String, Object> inputs = new HashMap<>();
395                    inputs.put(AbstractContentWorkflowComponent.CONTENT_KEY, content);
396                    inputs.put(AbstractWorkflowComponent.RESULT_MAP_KEY, new LinkedHashMap<>());
397                    inputs.put(AbstractWorkflowComponent.FAIL_CONDITIONS_KEY, new ArrayList<>());
398                    
399                    workflow.doAction(waContent.getWorkflowId(), actionId, inputs);
400                }
401                
402                result.put("success", true);
403                
404                Map<String, Object> eventParams = new HashMap<>();
405                eventParams.put(ObservationConstants.ARGS_CONTENT, modifiableContent);
406                eventParams.put(ObservationConstants.ARGS_CONTENT_ID, contentId);
407                _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_MODIFIED, _currentUserProvider.getUser(), eventParams));
408            }
409        }
410        else
411        {
412            result.put("failure", true);
413            result.put("msg", "no-modifiable-content");
414            getLogger().error("Can not modified mixins to a non-modifiable content '{}'.", contentId);
415        }
416        
417        return result;
418    }
419    
420    /**
421     * Converts the content attribute definitions in a JSON Map
422     * @param contentId the content identifier
423     * @param isEdition <code>true</code> if the JSON result is used for edition purposes (configure a form panel, ...) <code>false</code> otherwise
424     * @return the content attribute definitions as a JSON Map
425     * @throws ProcessingException if an error occurs when converting the definitions
426     */
427    @Callable
428    public Map<String, Object> getContentAttributeDefinitionsAsJSON(String contentId, boolean isEdition) throws ProcessingException
429    {
430        return getContentAttributeDefinitionsAsJSON(contentId, List.of(), isEdition);
431    }
432    
433    /**
434     * Converts the given content attribute definitions in a JSON Map
435     * @param contentId the content identifier
436     * @param attibutePaths the paths of the attribute definitions to convert
437     * @param isEdition <code>true</code> if the JSON result is used for edition purposes (configure a form panel, ...) <code>false</code> otherwise
438     * @return the content attribute definitions as a JSON Map
439     * @throws ProcessingException if an error occurs when converting the definitions
440     */
441    @Callable
442    public Map<String, Object> getContentAttributeDefinitionsAsJSON(String contentId, List<String> attibutePaths, boolean isEdition) throws ProcessingException
443    {
444        Content content = _resolver.resolveById(contentId);
445        View view = View.of(getContentTypes(content), attibutePaths.toArray(new String[attibutePaths.size()]));
446        DefinitionContext context = DefinitionContext.newInstance().withEdition(isEdition).withObject(content);
447        return Map.of("attributes", view.toJSON(context));
448    }
449    
450    /**
451     * Converts the content view with the given name in a JSON Map
452     * @param contentId the content identifier
453     * @param viewName the name of the view to convert
454     * @param fallbackViewName the name of the view to convert if the initial was not found. Can be null.
455     * @param isEdition <code>true</code> if the JSON result is used for edition purposes (configure a form panel, ...) <code>false</code> otherwise
456     * @return the view as a JSON Map
457     * @throws ProcessingException if an error occurs when converting the view
458     */
459    @Callable
460    public Map<String, Object> getContentViewAsJSON(String contentId, String viewName, String fallbackViewName, boolean isEdition) throws ProcessingException
461    {
462        assert StringUtils.isNotEmpty(viewName);
463        assert StringUtils.isNotEmpty(fallbackViewName);
464        
465        Content content = _resolver.resolveById(contentId);
466        
467        ContextHelper.getRequest(_context).setAttribute(Content.class.getName(), content);
468        
469        View view = _contentTypesHelper.getViewWithFallback(viewName, fallbackViewName, content.getTypes(), content.getMixinTypes());
470        if (isEdition)
471        {
472            if (ViewHelper.areItemsPresentsOnlyOnce(view))
473            {
474                view = ViewHelper.getTruncatedView(view);
475            }
476            else
477            {
478                throw new ProcessingException("The view '" + view.getName() + "' cannot be used in edition mode, some items appear more than once.");
479            }
480        }
481        
482        DefinitionContext context = DefinitionContext.newInstance().withEdition(isEdition).withObject(content);
483        return Map.of("view", view.toJSON(context));
484    }
485    
486    /**
487     * Retrieves a {@link Collection} containing all content types of the given content
488     * @param content the content
489     * @return all content types of the content
490     */
491    public Collection<ContentType> getContentTypes(Content content)
492    {
493        return getContentTypes(content, true);
494    }
495    
496    /**
497     * Retrieves a {@link Collection} containing content types of the given content
498     * @param content the content
499     * @param includeMixins <code>true</code> to retrieve the mixins the the collection, <code>false</code> otherwise
500     * @return content types of the content
501     */
502    public Collection<ContentType> getContentTypes(Content content, boolean includeMixins)
503    {
504        Collection<ContentType> contentTypes = _getContentTypesFromIds(content.getTypes());
505        
506        if (includeMixins)
507        {
508            contentTypes.addAll(_getContentTypesFromIds(content.getMixinTypes()));
509        }
510        
511        return Collections.unmodifiableCollection(contentTypes);
512    }
513    
514    private Collection<ContentType> _getContentTypesFromIds(String[] contentTypeIds)
515    {
516        Collection<ContentType> contentTypes = new ArrayList<>();
517        
518        for (String contentTypeId : contentTypeIds)
519        {
520            ContentType contentType = _contentTypeEP.getExtension(contentTypeId);
521            if (contentType != null)
522            {
523                contentTypes.add(contentType);
524            }
525            else
526            {
527                getLogger().warn("Unknown content type identifier: {}", contentTypeId);
528            }
529        }
530        
531        return contentTypes;
532    }
533    
534    /**
535     * Determines if the given content has some of its types that are unknown (the extension does not exist)
536     * @param content the content
537     * @return <code>true</code> if at least one of the types of the content is unknown, <code>false</code> otherwise
538     */
539    public List<String> getUnknownContentTypeIds(Content content)
540    {
541        return getUnknownContentTypeIds(content, true);
542    }
543
544    /**
545     * Determines if the given content has some of its types that are unknown (the extension does not exist)
546     * @param content the content
547     * @param checkMixins <code>true</code> to check unknown content types in mixin types
548     * @return <code>true</code> if at least one of the types of the content is unknown, <code>false</code> otherwise
549     */
550    public List<String> getUnknownContentTypeIds(Content content, boolean checkMixins)
551    {
552        List<String> unknownContentTypeIds = _getUnknownContentTypeIds(content.getTypes());
553        
554        if (checkMixins)
555        {
556            unknownContentTypeIds.addAll(_getUnknownContentTypeIds(content.getMixinTypes()));
557        }
558        
559        return unknownContentTypeIds;
560    }
561    
562    private List<String> _getUnknownContentTypeIds(String[] contentTypeIds)
563    {
564        List<String> unknownContentTypeIds = new ArrayList<>();
565        for (String contentTypeId : contentTypeIds)
566        {
567            if (!_contentTypeEP.hasExtension(contentTypeId))
568            {
569                unknownContentTypeIds.add(contentTypeId);
570            }
571        }
572        
573        return unknownContentTypeIds;
574    }
575    
576    /**
577     * Determines if the content is a reference table content type
578     * @param content The content
579     * @return true if content is a reference table
580     */
581    public boolean isReferenceTable(Content content)
582    {
583        for (String cTypeId : content.getTypes())
584        {
585            ContentType cType = _contentTypeEP.getExtension(cTypeId);
586            if (cType != null)
587            {
588                if (!cType.isReferenceTable())
589                {
590                    return false;
591                }
592            }
593            else
594            {
595                getLogger().warn("Unable to determine if a content is a reference table, unknown content type : '{}'.", cTypeId);
596            }
597        }
598        return true;
599    }
600    
601    /**
602     * Determines if a content is a multilingual content
603     * @param content The content
604     * @return <code>true</code> if the content is an instance of content type
605     */
606    public boolean isMultilingual(Content content)
607    {
608        for (String cTypeId : content.getTypes())
609        {
610            ContentType cType = _contentTypeEP.getExtension(cTypeId);
611            if (cType != null && cType.isMultilingual())
612            {
613                return true;
614            }
615        }
616        return false;
617    }
618    
619    /**
620     * Determines if the content is a simple content type
621     * @param content The content
622     * @return true if content is simple
623     */
624    public boolean isSimple (Content content)
625    {
626        for (String cTypeId : content.getTypes())
627        {
628            ContentType cType = _contentTypeEP.getExtension(cTypeId);
629            if (cType != null)
630            {
631                if (!cType.isSimple())
632                {
633                    return false;
634                }
635            }
636            else
637            {
638                getLogger().warn("Unable to determine if a content is simple, unknown content type : '{}'.", cTypeId);
639            }
640        }
641        return true;
642    }
643    
644    /**
645     * Determines if the content is archived
646     * @param content the content
647     * @return true if the content is archived
648     */
649    public boolean isArchivedContent(Content content)
650    {
651        boolean canBeArchived = Stream.of(content.getTypes())
652            .filter(_contentTypesHelper::isArchivedContentType)
653            .findAny()
654            .isPresent();
655            
656        return canBeArchived && content.getValue("archived", false, false);
657    }
658    
659    /**
660     * Get the typed value(s) of a content at given path.
661     * The path can represent a system property id or a path of an attribute into the content or an attribute on one or more linked contents, such as 'composite/linkedContent/secondContent/composite/attribute'.
662     * The returned value is typed.
663     * @param content The content
664     * @param fieldPath The field id or the path to the attribute, separated by '/'
665     * @return The typed final value(s). If the final field is multiple, or contained into a repeater or multiple 'CONTENT' attribute, the returned value will be a Collection
666     */
667    public Object getValue(Content content, String fieldPath)
668    {
669        if (StringUtils.isEmpty(fieldPath))
670        {
671            return null;
672        }
673        
674        // Manage System Properties
675        String[] pathSegments = fieldPath.split(ModelItem.ITEM_PATH_SEPARATOR);
676        String propertyName = pathSegments[pathSegments.length - 1];
677        
678        if (_systemPropertyExtensionPoint.hasExtension(propertyName))
679        {
680            if (_systemPropertyExtensionPoint.isDisplayable(propertyName))
681            {
682                SystemProperty systemProperty = _systemPropertyExtensionPoint.getExtension(propertyName);
683                return _getSystemPropertyValue(content, pathSegments, systemProperty);
684            }
685            else
686            {
687                throw new IllegalArgumentException("The system property '" + propertyName + "' is not displayable.");
688            }
689        }
690        else if (content.hasDefinition(fieldPath))
691        {
692            Object value = content.getValue(fieldPath, true);
693            if (value instanceof Object[])
694            {
695                return Arrays.asList((Object[]) value);
696            }
697            else
698            {
699                return value;
700            }
701        }
702        else
703        {
704            getLogger().warn("Unknown data at path '{}' for content {}. No corresponding system property nor attribute definition found." , fieldPath, content);
705            return null;
706        }
707    }
708
709    @SuppressWarnings("unchecked")
710    private Object _getSystemPropertyValue(Content content, String[] pathSegments, SystemProperty systemProperty)
711    {
712        String contentFieldPath = StringUtils.join(pathSegments, ModelItem.ITEM_PATH_SEPARATOR, 0, pathSegments.length - 1);
713        List<Content> contentsContainingSystemProperty = getTargetContents(content, contentFieldPath);
714        if (contentsContainingSystemProperty.size() == 1)
715        {
716            Object value = systemProperty.getValue(contentsContainingSystemProperty.get(0));
717            
718            if (value instanceof Object[])
719            {
720                return Arrays.asList((Object[]) value);
721            }
722            else
723            {
724                return value;
725            }
726        }
727        else
728        {
729            List<Object> values = new ArrayList<>();
730            for (Content contentContainingSystemProperty : contentsContainingSystemProperty)
731            {
732                Object value = systemProperty.getValue(contentContainingSystemProperty);
733                
734                if (value instanceof Object[])
735                {
736                    values.addAll(Arrays.asList((Object[]) value));
737                }
738                else
739                {
740                    values.add(value);
741                }
742            }
743            return values;
744        }
745    }
746    
747    /**
748     * Get the content from which to get the system property.
749     * @param sourceContent The source content.
750     * @param fieldPath The field path
751     * @return The target content.
752     */
753    public Content getTargetContent(Content sourceContent, String fieldPath)
754    {
755        return getTargetContents(sourceContent, fieldPath)
756                .stream()
757                .findFirst()
758                .orElse(null);
759    }
760    
761    /**
762     * Get the contents from which to get the system property.
763     * @param sourceContent The source content.
764     * @param fieldPath The field path
765     * @return The target contents.
766     */
767    public List<Content> getTargetContents(Content sourceContent, String fieldPath)
768    {
769        if (StringUtils.isBlank(fieldPath))
770        {
771            return List.of(sourceContent);
772        }
773        else
774        {
775            ModelItem definition = sourceContent.getDefinition(fieldPath);
776            if (ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID.equals(definition.getType().getId()))
777            {
778                return _transformToStream(sourceContent.getValue(fieldPath, true))  // Get the value and transform it to stream (depending of null, single or multiple value)
779                    .map(ContentValue::getContentIfExists)
780                    .flatMap(Optional::stream)      // Keep only existing contents
781                    .collect(Collectors.toList());
782            }
783            else
784            {
785                // The item at the given path is not of type content
786                return List.of();
787            }
788        }
789    }
790    
791    private Stream<ContentValue> _transformToStream(Object value)
792    {
793        if (value == null)
794        {
795            return Stream.of();
796        }
797        else if (value.getClass().isArray())
798        {
799            return Stream.of((ContentValue[]) value);
800        }
801        return Stream.of((ContentValue) value);
802    }
803
804    /**
805     * Get the title of a content.&lt;br&gt;
806     * If the content is a multilingual content, the title will be retrieved for the current locale if exists, or for default locale 'en' if exists, or for the first found locale.
807     * @param content The content
808     * @return The title of the content
809     */
810    public String getTitle(Content content)
811    {
812        Locale defaultLocale = null;
813        
814        try
815        {
816            Map objectModel = (Map) _context.get(ContextHelper.CONTEXT_OBJECT_MODEL);
817            if (objectModel != null)
818            {
819                // The object model can be null if #getTitle(content) is called outside a request
820                defaultLocale = org.apache.cocoon.i18n.I18nUtils.findLocale(objectModel, "locale", null, Locale.getDefault(), true);
821            }
822        }
823        catch (ContextException e)
824        {
825            // There is no context
826        }
827        
828        // TODO Use user preference language ?
829        return content.getTitle(defaultLocale);
830    }
831    
832    /**
833     * Get the title variants of a multilingual content
834     * @param content The multilingual content
835     * @return the content's title for each locale
836     * @throws IllegalArgumentException if the content is not a multilingual content
837     */
838    public Map<String, String> getTitleVariants(Content content)
839    {
840        if (!isMultilingual(content))
841        {
842            throw new IllegalArgumentException("Can not get title variants for a non-multilingual content " + content.getId());
843        }
844        
845        Map<String, String> variants = new HashMap<>();
846        
847        MultilingualString value = content.getValue(Content.ATTRIBUTE_TITLE);
848        if (value != null)
849        {
850            for (Locale locale : value.getLocales())
851            {
852                variants.put(locale.getLanguage(), value.getValue(locale));
853            }
854        }
855        
856        return variants;
857    }
858    
859    /**
860     * Determines if the content has referencing contents other than whose type is in content types to ignore.
861     * @param content The content to check
862     * @param ignoreContentTypes The content types to ignore for referencing contents
863     * @param includeSubTypes True if sub content types are take into account in ignore content types
864     * @return <code>true</code> if there is at least one Content referencing the content
865     */
866    public boolean hasReferencingContents(Content content, List<String> ignoreContentTypes, boolean includeSubTypes)
867    {
868        List<String> newIgnoreContentTypes = new ArrayList<>();
869        newIgnoreContentTypes.addAll(ignoreContentTypes);
870        if (includeSubTypes)
871        {
872            for (String contentType : ignoreContentTypes)
873            {
874                newIgnoreContentTypes.addAll(_contentTypeEP.getSubTypes(contentType));
875            }
876        }
877        
878        for (Content refContent : content.getReferencingContents())
879        {
880            List<String> contentTypes = Arrays.asList(refContent.getTypes());
881            if (!CollectionUtils.containsAny(contentTypes, newIgnoreContentTypes))
882            {
883                return true;
884            }
885        }
886        
887        return false;
888    }
889    
890    /**
891     * Returns all Contents referencing the given content with their value path
892     * @param content The content to get references
893     * @return the list of pair path / contents
894     */
895    public List<Pair<String, Content>> getReferencingContents(Content content)
896    {
897        List<Pair<String, Content>> incomingReferences = new ArrayList<>();
898        try
899        {
900            NodeIterator results = OutgoingReferencesHelper.getContentOutgoingReferences((JCRAmetysObject) content);
901            while (results.hasNext())
902            {
903                Node node = results.nextNode();
904                
905                Node outgoingRefsNode = node.getParent(); // go up towards node 'ametys-internal:outgoing-references;
906                String path = outgoingRefsNode.getProperty(RepositoryConstants.NAMESPACE_PREFIX_INTERNAL + ':' + DefaultContent.METADATA_OUTGOING_REFERENCES_PATH_PROPERTY).getString();
907                
908                Node contentNode = outgoingRefsNode.getParent()  // go up towards node 'ametys-internal:root-outgoing-references
909                                                   .getParent(); // go up towards node of the content
910                Content refContent = _resolver.resolve(contentNode, false);
911 
912                incomingReferences.add(new ImmutablePair<>(path, refContent));
913            }
914        }
915        catch (RepositoryException e)
916        {
917            throw new AmetysRepositoryException("Unable to resolve references for content " + content.getId(), e);
918        }
919        
920        return incomingReferences;
921    }
922    
923    /**
924     * Get the default workflow name for the content. If several workflows are possible, an empty {@link Optional} is returned.
925     * @param content The content
926     * @return The default workflow name or {@link Optional#empty()} if it cannot be determine
927     */
928    public Optional<String> getDefaultWorkflowName(Content content)
929    {
930        Set<String> defaultWorkflowNames = Stream.of(content.getTypes())
931                .map(_contentTypeEP::getExtension)
932                // Don't use ContentType.getDefaultWorkflowName because it returns an empty Optional if there are several available workflow names
933                .map(ContentType::getConfiguredDefaultWorkflowNames)
934                .flatMap(Set::stream)
935                .collect(Collectors.toSet());
936        
937        if (defaultWorkflowNames.size() > 1)
938        {
939            getLogger().warn("Several default workflows are defined for content {} : {}.", content.toString(), StringUtils.join(defaultWorkflowNames));
940            return Optional.empty();
941        }
942        
943        return defaultWorkflowNames
944                .stream()
945                .findFirst()
946                .or(() -> Optional.of(isReferenceTable(content) ? "reference-table" : "content"));
947        
948    }
949    
950    /**
951     * Get the URL for the HTML view of a content with the needed parameters
952     * @param content the content
953     * @param viewName the view name
954     * @return the content uri
955     */
956    public String getContentHtmlViewUrl(Content content, String viewName)
957    {
958        return getContentHtmlViewUrl(content, viewName, Map.of());
959    }
960    
961    /**
962     * Get the URL for the HTML view of a content with the needed parameters
963     * @param content the content
964     * @param viewName the view name
965     * @param additionalParams the additional parameters. Can be empty
966     * @return the content uri
967     */
968    public String getContentHtmlViewUrl(Content content, String viewName, Map<String, String> additionalParams)
969    {
970        return getContentViewUrl(content, viewName, "html", additionalParams);
971    }
972    
973    /**
974     * Get the URL for the view of a content with the needed parameters
975     * @param content the content
976     * @param viewName the view name
977     * @param format the output format (html, xml, doc, pdf, ...)
978     * @return the content uri
979     */
980    public String getContentViewUrl(Content content, String viewName, String format)
981    {
982        return getContentViewUrl(content, viewName, format, Map.of());
983    }
984    
985    /**
986     * Get the URL for the view of a content with the needed parameters
987     * @param content the content
988     * @param viewName the view name
989     * @param format the output format (html, xml, doc, pdf, ...)
990     * @param additionalParams the additional parameters. Can be empty
991     * @return the content uri
992     */
993    public String getContentViewUrl(Content content, String viewName, String format, Map<String, String> additionalParams)
994    {
995        String uri = "cocoon://_content." + format;
996        Map<String, String> uriParams = getContentViewUrlParameters(content, viewName, format, additionalParams);
997        return URIUtils.buildURI(uri, uriParams);
998    }
999    
1000    
1001    /**
1002     * Get the needed url parameters for a content view
1003     * @param content the content
1004     * @param viewName the view name
1005     * @param format the output format (html, xml, doc, pdf, ...)
1006     * @return the uri parameters
1007     */
1008    public Map<String, String> getContentViewUrlParameters(Content content, String viewName, String format)
1009    {
1010        return getContentViewUrlParameters(content, viewName, format, Map.of());
1011    }
1012    
1013    /**
1014     * Get the needed url parameters for a content view
1015     * @param content the content
1016     * @param viewName the view name
1017     * @param format the output format (html, xml, doc, pdf, ...)
1018     * @param additionalParams the additional parameters. Can be empty
1019     * @return the uri parameters
1020     */
1021    public Map<String, String> getContentViewUrlParameters(Content content, String viewName, String format, Map<String, String> additionalParams)
1022    {
1023        Map<String, String> params = new HashMap<>();
1024        params.put("contentId", content.getId());
1025        params.put("viewName", viewName);
1026        params.put("output-format", format);
1027        params.putAll(additionalParams);
1028        
1029        return params;
1030    }
1031    
1032    /**
1033     * Get the content URL in back office
1034     * @param content the content
1035     * @param contextualParameters the contextual parameters
1036     * @return the content URL
1037     */
1038    public String getContentBOUrl(Content content, Map<String, Object> contextualParameters)
1039    {
1040        return _baseURL + "/index.html?uitool=uitool-content,id:%27" + URIUtils.encodeParameter(content.getId()) + "%27";
1041    }
1042    
1043    /**
1044     * Validate the content (global validator and each attribute validation)
1045     * @param content the content to validate
1046     * @return the validation result
1047     */
1048    public ValidationResult validateContent(Content content)
1049    {
1050        ValidationResult results = new ValidationResult();
1051        
1052        List<ModelItem> modelItems = ModelHelper.getModelItems(content.getModel())
1053                .stream()
1054                .filter(modelItem -> !(modelItem instanceof ElementDefinition) || ((ElementDefinition) modelItem).isEditable())
1055                .collect(Collectors.toList());
1056
1057        ValidationResult result = _validateValues(modelItems, Optional.of(content), "", content);
1058        if (result.hasErrors())
1059        {
1060            return result;
1061        }
1062        
1063        String[] allContentTypes = ArrayUtils.addAll(content.getTypes(), content.getMixinTypes());
1064        for (String cTypeId : allContentTypes)
1065        {
1066            ContentType contentType = _contentTypeEP.getExtension(cTypeId);
1067            
1068            for (ContentValidator contentValidator : contentType.getGlobalValidators())
1069            {
1070                ValidationResult validationResult = contentValidator.validate(content);
1071                if (validationResult.hasErrors())
1072                {
1073                    List<String> translatedErrors = validationResult.getErrors().stream().map(error -> _i18nUtils.translate(error, "en")).collect(Collectors.toList());
1074                    results.addError(new I18nizableText(String.format("Validation failed for content '%s' on global validator '%s' with errors %s", content.getId(), contentValidator.getClass().getName(), translatedErrors)));
1075                    return results;
1076                }
1077            }
1078        }
1079        
1080        return results;
1081    }
1082    
1083    private ValidationResult _validateValues(List<ModelItem> modelItems, Optional<? extends ModelAwareDataHolder> dataHolder, String dataPath, Content content)
1084    {
1085        ValidationResult results = new ValidationResult();
1086        for (ModelItem modelItem : modelItems)
1087        {
1088            String name = modelItem.getName();
1089            if (!_disableConditionsEvaluator.evaluateDisableConditions(modelItem, dataPath + name, content))
1090            {
1091                if (modelItem instanceof ElementDefinition)
1092                {
1093                    // simple element
1094                    ElementDefinition definition = (ElementDefinition) modelItem;
1095                    Object value = dataHolder.map(holder -> holder.getValue(name)).orElse(null);
1096                    
1097                    if (ModelHelper.validateValue(definition, value).hasErrors())
1098                    {
1099                        results.addError(new I18nizableText(String.format("Validate attribute failed for content %s on attribute %s with value %s", content.getId(), dataPath + name, String.valueOf(value))));
1100                        return results;
1101                    }
1102                }
1103                else if (modelItem instanceof CompositeDefinition)
1104                {
1105                    // composite
1106                    Optional<ModelAwareComposite> composite = dataHolder.map(holder -> holder.getComposite(name));
1107                    ValidationResult result = _validateValues(((CompositeDefinition) modelItem).getChildren(), composite, dataPath + name + "/", content);
1108                    if (result.hasErrors())
1109                    {
1110                        return result;
1111                    }
1112                }
1113                else if (modelItem instanceof RepeaterDefinition)
1114                {
1115                    // repeater
1116                    RepeaterDefinition definition = (RepeaterDefinition) modelItem;
1117                    Optional<ModelAwareRepeater> repeater = dataHolder.map(holder -> holder.getRepeater(name));
1118                    
1119                    Optional<List<? extends ModelAwareRepeaterEntry>> entries = repeater.map(ModelAwareRepeater::getEntries);
1120                    
1121                    int repeaterSize = repeater.map(ModelAwareRepeater::getSize).orElse(0);
1122                    int minSize = definition.getMinSize();
1123                    int maxSize = definition.getMaxSize();
1124                    
1125                    if (repeaterSize < minSize
1126                        || maxSize > 0 && repeaterSize > maxSize)
1127                    {
1128                        results.addError(new I18nizableText(String.format("Validate repeater size failed for content %s on repeater %s with size %s", content.getId(), dataPath + name, repeaterSize)));
1129                        return results;
1130                    }
1131                    
1132                    if (entries.isPresent())
1133                    {
1134                        for (ModelAwareRepeaterEntry entry : entries.get())
1135                        {
1136                            ValidationResult result = _validateValues(definition.getChildren(), Optional.of(entry), dataPath + name + "[" + (entry.getPosition())  + "]/", content);
1137                            if (result.hasErrors())
1138                            {
1139                                return result;
1140                            }
1141                        }
1142                    }
1143                }
1144            }
1145        }
1146        
1147        return results;
1148    }
1149}