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.ElementDefinition;
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    private Object _getSystemPropertyValue(Content content, String[] pathSegments, SystemProperty systemProperty)
677    {
678        String contentFieldPath = StringUtils.join(pathSegments, ModelItem.ITEM_PATH_SEPARATOR, 0, pathSegments.length - 1);
679        List<Content> contentsContainingSystemProperty = getTargetContents(content, contentFieldPath);
680        if (contentsContainingSystemProperty.size() == 1)
681        {
682            Object value = systemProperty.getValue(contentsContainingSystemProperty.get(0));
683            
684            if (value instanceof Object[])
685            {
686                return Arrays.asList((Object[]) value);
687            }
688            else
689            {
690                return value;
691            }
692        }
693        else
694        {
695            List<Object> values = new ArrayList<>();
696            for (Content contentContainingSystemProperty : contentsContainingSystemProperty)
697            {
698                Object value = systemProperty.getValue(contentContainingSystemProperty);
699                
700                if (value instanceof Object[])
701                {
702                    values.addAll(Arrays.asList((Object[]) value));
703                }
704                else
705                {
706                    values.add(value);
707                }
708            }
709            return values;
710        }
711    }
712    
713    /**
714     * Get the content from which to get the system property.
715     * @param sourceContent The source content.
716     * @param fieldPath The field path
717     * @return The target content.
718     */
719    public Content getTargetContent(Content sourceContent, String fieldPath)
720    {
721        return getTargetContents(sourceContent, fieldPath)
722                .stream()
723                .findFirst()
724                .orElse(null);
725    }
726    
727    /**
728     * Get the contents from which to get the system property.
729     * @param sourceContent The source content.
730     * @param fieldPath The field path
731     * @return The target contents.
732     */
733    public List<Content> getTargetContents(Content sourceContent, String fieldPath)
734    {
735        if (StringUtils.isBlank(fieldPath))
736        {
737            return List.of(sourceContent);
738        }
739        else
740        {
741            ModelItem definition = sourceContent.getDefinition(fieldPath);
742            if (ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID.equals(definition.getType().getId()))
743            {
744                List<ContentValue> values;
745                if (((ElementDefinition) definition).isMultiple())
746                {
747                    values = Arrays.stream(sourceContent.getValue(fieldPath, false, new ContentValue[0]))
748                            .collect(Collectors.toList());
749                }
750                else
751                {
752                    values = new ArrayList<>();
753                    Optional.ofNullable(sourceContent.getValue(fieldPath))
754                            .map(ContentValue.class::cast)
755                            .ifPresent(c -> values.add(c));
756                }
757                
758                return values.stream()
759                        .map(ContentValue::getContentIfExists)
760                        .filter(Optional::isPresent)    // Keep only existing contents
761                        .map(Optional::get)
762                        .collect(Collectors.toList());
763                
764            }
765            else
766            {
767                // The item at the given path is not of type content
768                return List.of();
769            }
770        }
771    }
772
773    /**
774     * Get the title of a content.&lt;br&gt;
775     * 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.
776     * @param content The content
777     * @return The title of the content
778     */
779    public String getTitle(Content content)
780    {
781        Locale defaultLocale = null;
782        
783        try
784        {
785            Map objectModel = (Map) _context.get(ContextHelper.CONTEXT_OBJECT_MODEL);
786            if (objectModel != null)
787            {
788                // The object model can be null if #getTitle(content) is called outside a request
789                defaultLocale = org.apache.cocoon.i18n.I18nUtils.findLocale(objectModel, "locale", null, Locale.getDefault(), true);
790            }
791        }
792        catch (ContextException e)
793        {
794            // There is no context
795        }
796        
797        // TODO Use user preference language ?
798        return content.getTitle(defaultLocale);
799    }
800    
801    /**
802     * Get the title variants of a multilingual content
803     * @param content The multilingual content
804     * @return the content's title for each locale
805     * @throws IllegalArgumentException if the content is not a multilingual content
806     */
807    public Map<String, String> getTitleVariants(Content content)
808    {
809        if (!isMultilingual(content))
810        {
811            throw new IllegalArgumentException("Can not get title variants for a non-multilingual content " + content.getId());
812        }
813        
814        Map<String, String> variants = new HashMap<>();
815        
816        MultilingualString value = content.getValue(Content.ATTRIBUTE_TITLE);
817        for (Locale locale : value.getLocales())
818        {
819            variants.put(locale.getLanguage(), value.getValue(locale));
820        }
821        
822        return variants;
823    }
824    
825    /**
826     * Determines if the content has referencing contents other than whose type is in content types to ignore.
827     * @param content The content to check
828     * @param ignoreContentTypes The content types to ignore for referencing contents
829     * @param includeSubTypes True if sub content types are take into account in ignore content types
830     * @return <code>true</code> if there is at least one Content referencing the content
831     */
832    public boolean hasReferencingContents(Content content, List<String> ignoreContentTypes, boolean includeSubTypes)
833    {
834        List<String> newIgnoreContentTypes = new ArrayList<>();
835        newIgnoreContentTypes.addAll(ignoreContentTypes);
836        if (includeSubTypes)
837        {
838            for (String contentType : ignoreContentTypes)
839            {
840                newIgnoreContentTypes.addAll(_contentTypeEP.getSubTypes(contentType));
841            }
842        }
843        
844        for (Content refContent : content.getReferencingContents())
845        {
846            List<String> contentTypes = Arrays.asList(refContent.getTypes());
847            if (!CollectionUtils.containsAny(contentTypes, newIgnoreContentTypes))
848            {
849                return true;
850            }
851        }
852        
853        return false;
854    }
855    
856    /**
857     * Returns all Contents referencing the given content with their value path
858     * @param content The content to get references
859     * @return the list of pair path / contents
860     */
861    public List<Pair<String, Content>> getReferencingContents(Content content)
862    {
863        List<Pair<String, Content>> incomingReferences = new ArrayList<>();
864        try
865        {
866            NodeIterator results = OutgoingReferencesHelper.getContentOutgoingReferences((JCRAmetysObject) content);
867            while (results.hasNext())
868            {
869                Node node = results.nextNode();
870                
871                Node outgoingRefsNode = node.getParent(); // go up towards node 'ametys-internal:outgoing-references;
872                String path = outgoingRefsNode.getProperty(RepositoryConstants.NAMESPACE_PREFIX_INTERNAL + ':' + DefaultContent.METADATA_OUTGOING_REFERENCES_PATH_PROPERTY).getString();
873                
874                Node contentNode = outgoingRefsNode.getParent()  // go up towards node 'ametys-internal:root-outgoing-references
875                                                   .getParent(); // go up towards node of the content
876                Content refContent = _resolver.resolve(contentNode, false);
877 
878                incomingReferences.add(new ImmutablePair<>(path, refContent));
879            }
880        }
881        catch (RepositoryException e)
882        {
883            throw new AmetysRepositoryException("Unable to resolve references for content " + content.getId(), e);
884        }
885        
886        return incomingReferences;
887    }
888    
889    /**
890     * Get the default workflow name for the content. If several workflows are possible, an empty {@link Optional} is returned.
891     * @param content The content
892     * @return The default workflow name or {@link Optional#empty()} if it cannot be determine
893     */
894    public Optional<String> getDefaultWorkflowName(Content content)
895    {
896        Set<String> defaultWorkflowNames = Stream.of(content.getTypes())
897                .map(_contentTypeEP::getExtension)
898                // Don't use ContentType.getDefaultWorkflowName because it returns an empty Optional if there are several available workflow names
899                .map(ContentType::getConfiguredDefaultWorkflowNames)
900                .flatMap(Set::stream)
901                .collect(Collectors.toSet());
902        
903        if (defaultWorkflowNames.size() > 1)
904        {
905            getLogger().warn("Several default workflows are defined for content {} : {}.", content.toString(), StringUtils.join(defaultWorkflowNames));
906            return Optional.empty();
907        }
908        
909        return defaultWorkflowNames
910                .stream()
911                .findFirst()
912                .or(() -> Optional.of(isReferenceTable(content) ? "reference-table" : "content"));
913        
914    }
915}