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