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