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