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