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