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