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.Iterator;
024import java.util.LinkedHashMap;
025import java.util.List;
026import java.util.Locale;
027import java.util.Map;
028
029import org.apache.avalon.framework.component.Component;
030import org.apache.avalon.framework.context.Context;
031import org.apache.avalon.framework.context.ContextException;
032import org.apache.avalon.framework.context.Contextualizable;
033import org.apache.avalon.framework.logger.AbstractLogEnabled;
034import org.apache.avalon.framework.service.ServiceException;
035import org.apache.avalon.framework.service.ServiceManager;
036import org.apache.avalon.framework.service.Serviceable;
037import org.apache.cocoon.components.ContextHelper;
038import org.apache.commons.lang3.ArrayUtils;
039import org.apache.commons.lang3.StringUtils;
040
041import org.ametys.cms.ObservationConstants;
042import org.ametys.cms.contenttype.ContentConstants;
043import org.ametys.cms.contenttype.ContentType;
044import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
045import org.ametys.cms.contenttype.ContentTypesHelper;
046import org.ametys.cms.contenttype.MetadataDefinition;
047import org.ametys.cms.contenttype.MetadataManager;
048import org.ametys.cms.contenttype.MetadataType;
049import org.ametys.cms.contenttype.RepeaterDefinition;
050import org.ametys.cms.repository.Content;
051import org.ametys.cms.repository.DefaultContent;
052import org.ametys.cms.repository.ModifiableContent;
053import org.ametys.cms.repository.WorkflowAwareContent;
054import org.ametys.cms.search.model.SystemProperty;
055import org.ametys.cms.search.model.SystemPropertyExtensionPoint;
056import org.ametys.cms.workflow.AbstractContentWorkflowComponent;
057import org.ametys.core.observation.Event;
058import org.ametys.core.observation.ObservationManager;
059import org.ametys.core.ui.Callable;
060import org.ametys.core.user.CurrentUserProvider;
061import org.ametys.plugins.explorer.resources.Resource;
062import org.ametys.plugins.repository.AmetysObjectResolver;
063import org.ametys.plugins.repository.AmetysRepositoryException;
064import org.ametys.plugins.repository.UnknownAmetysObjectException;
065import org.ametys.plugins.repository.metadata.CompositeMetadata;
066import org.ametys.plugins.repository.metadata.MultilingualString;
067import org.ametys.plugins.repository.metadata.MultilingualStringHelper;
068import org.ametys.plugins.repository.metadata.UnknownMetadataException;
069import org.ametys.plugins.workflow.AbstractWorkflowComponent;
070import org.ametys.plugins.workflow.support.WorkflowProvider;
071import org.ametys.plugins.workflow.support.WorkflowProvider.AmetysObjectWorkflow;
072
073import com.opensymphony.workflow.WorkflowException;
074
075/**
076 * Helper for {@link Content}
077 *
078 */
079public class ContentHelper extends AbstractLogEnabled implements Component, Serviceable, Contextualizable
080{
081    /** The component role. */
082    public static final String ROLE = ContentHelper.class.getName();
083    
084    private AmetysObjectResolver _resolver;
085    private ContentTypesHelper _contentTypesHelper;
086    private ContentTypeExtensionPoint _contentTypeEP;
087
088    private ObservationManager _observationManager;
089    private WorkflowProvider _workflowProvider;
090    private CurrentUserProvider _currentUserProvider;
091    private SystemPropertyExtensionPoint _systemPropertyExtensionPoint;
092
093    private Context _context;
094
095    @Override
096    public void service(ServiceManager smanager) throws ServiceException
097    {
098        _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
099        _contentTypesHelper = (ContentTypesHelper) smanager.lookup(ContentTypesHelper.ROLE);
100        _contentTypeEP = (ContentTypeExtensionPoint) smanager.lookup(ContentTypeExtensionPoint.ROLE);
101        _workflowProvider = (WorkflowProvider) smanager.lookup(WorkflowProvider.ROLE);
102        _observationManager = (ObservationManager) smanager.lookup(ObservationManager.ROLE);
103        _currentUserProvider = (CurrentUserProvider) smanager.lookup(CurrentUserProvider.ROLE);
104        _systemPropertyExtensionPoint = (SystemPropertyExtensionPoint) smanager.lookup(SystemPropertyExtensionPoint.ROLE);
105    }
106    
107    @Override
108    public void contextualize(Context context) throws ContextException
109    {
110        _context = context;
111    }
112    
113    /**
114     * Add a content type to an existing content
115     * @param contentId The content id
116     * @param contentTypeId The content type to add
117     * @param actionId The workflow action id
118     * @return The result in a Map
119     * @throws WorkflowException if 
120     * @throws AmetysRepositoryException if an error occurred
121     */
122    @Callable
123    public Map<String, Object> addContentType (String contentId, String contentTypeId, int actionId) throws AmetysRepositoryException, WorkflowException
124    {
125        return _setContentType(contentId, contentTypeId, actionId, false);
126    }
127    
128    /**
129     * Remove a content type to an existing content
130     * @param contentId The content id
131     * @param contentTypeId The content type to add
132     * @param actionId The workflow action id
133     * @return The result in a Map
134     * @throws WorkflowException if 
135     * @throws AmetysRepositoryException if an error occurred
136     */
137    @Callable
138    public Map<String, Object> removeContentType (String contentId, String contentTypeId, int actionId) throws AmetysRepositoryException, WorkflowException
139    {
140        return _setContentType(contentId, contentTypeId, actionId, true);
141    }
142    
143    /**
144     * Add a mixin type to an existing content
145     * @param contentId The content id
146     * @param mixinId The mixin type to add
147     * @param actionId The workflow action id
148     * @return The result in a Map
149     * @throws WorkflowException if 
150     * @throws AmetysRepositoryException if an error occurred
151     */
152    @Callable
153    public Map<String, Object> addMixinType (String contentId, String mixinId, int actionId) throws AmetysRepositoryException, WorkflowException
154    {
155        return _setMixinType(contentId, mixinId, actionId, false);
156    }
157    
158    /**
159     * Remove a mixin type to an existing content
160     * @param contentId The content id
161     * @param mixinId The mixin type to add
162     * @param actionId The workflow action id
163     * @return The result in a Map
164     * @throws WorkflowException if 
165     * @throws AmetysRepositoryException if an error occurred
166     */
167    @Callable
168    public Map<String, Object> removeMixinType (String contentId, String mixinId, int actionId) throws AmetysRepositoryException, WorkflowException
169    {
170        return _setMixinType(contentId, mixinId, actionId, true);
171    }
172    
173    /**
174     * Get content edition information.
175     * @param contentId the content ID.
176     * @return a Map containing content edition information.
177     */
178    @Callable
179    public Map<String, Object> getContentEditionInformation(String contentId)
180    {
181        Map<String, Object> info = new HashMap<>();
182        
183        Content content = _resolver.resolveById(contentId);
184        
185        info.put("hasIndexingReferences", hasIndexingReferences(content));
186        
187        return info;
188    }
189    
190    /**
191     * Test if the given content has indexing references, i.e. if modifying it
192     * potentially implies reindexing other contents.
193     * @param content the content to test.
194     * @return <code>true</code> if one of the content types or mixins has indexing references, <code>false</code> otherwise.
195     */
196    public boolean hasIndexingReferences(Content content)
197    {
198        for (String cTypeId : content.getTypes())
199        {
200            if (_contentTypeEP.hasIndexingReferences(cTypeId))
201            {
202                return true;
203            }
204        }
205        
206        for (String mixinId : content.getMixinTypes())
207        {
208            if (_contentTypeEP.hasIndexingReferences(mixinId))
209            {
210                return true;
211            }
212        }
213        
214        return false;
215    }
216    
217    private Map<String, Object> _setContentType (String contentId, String contentTypeId, int actionId, boolean remove) throws AmetysRepositoryException, WorkflowException
218    {
219        Map<String, Object> result = new HashMap<>();
220        
221        Content content = _resolver.resolveById(contentId);
222
223        if (content instanceof ModifiableContent)
224        {
225            ModifiableContent modifiableContent = (ModifiableContent) content;
226            
227            List<String> currentTypes = new ArrayList<>(Arrays.asList(content.getTypes()));
228            
229            boolean hasChange = false;
230            if (remove)
231            {
232                if (currentTypes.size() > 1)
233                {
234                    hasChange = currentTypes.remove(contentTypeId);
235                }
236                else
237                {
238                    result.put("failure", true);
239                    result.put("msg", "empty-list");
240                }
241            }
242            else if (!currentTypes.contains(contentTypeId))
243            {
244                ContentType cType = _contentTypeEP.getExtension(contentTypeId);
245                if (cType.isMixin())
246                {
247                    result.put("failure", true);
248                    result.put("msg", "no-content-type");
249                    getLogger().error("Content type '" + contentTypeId + "' is a mixin type. It can not be added as content type.");
250                }
251                else if (!_contentTypesHelper.isCompatibleContentType(content, contentTypeId))
252                {
253                    result.put("failure", true);
254                    result.put("msg", "invalid-content-type");
255                    getLogger().error("Content type '" + contentTypeId + "' is incompatible with content '" + contentId + "'.");
256                }
257                else
258                {
259                    currentTypes.add(contentTypeId);
260                    hasChange = true;
261                }
262            }
263            
264            if (hasChange)
265            {
266                // TODO check if the content type is compatible
267                modifiableContent.setTypes(currentTypes.toArray(new String[currentTypes.size()]));
268                modifiableContent.saveChanges();
269                
270                if (content instanceof WorkflowAwareContent)
271                {
272                    
273                    WorkflowAwareContent waContent = (WorkflowAwareContent) content;
274                    AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(waContent);
275                    
276                    Map<String, Object> inputs = new HashMap<>();
277                    inputs.put(AbstractContentWorkflowComponent.CONTENT_KEY, content);
278                    inputs.put(AbstractWorkflowComponent.RESULT_MAP_KEY, new HashMap<String, Object>());
279                    inputs.put(AbstractWorkflowComponent.FAIL_CONDITIONS_KEY, new ArrayList<String>());
280                    
281                    workflow.doAction(waContent.getWorkflowId(), actionId, inputs);
282                }
283                
284                result.put("success", true);
285                
286                Map<String, Object> eventParams = new HashMap<>();
287                eventParams.put(ObservationConstants.ARGS_CONTENT, modifiableContent);
288                eventParams.put(ObservationConstants.ARGS_CONTENT_ID, contentId);
289                _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_MODIFIED, _currentUserProvider.getUser(), eventParams));
290            }
291        }
292        else
293        {
294            result.put("failure", true);
295            result.put("msg", "no-modifiable-content");
296            getLogger().error("Can not modified content types to a non-modifiable content '" + content.getId() + "'.");
297        }
298        
299        return result;
300    }
301    
302    private Map<String, Object> _setMixinType (String contentId, String mixinId, int actionId, boolean remove) throws AmetysRepositoryException, WorkflowException
303    {
304        Map<String, Object> result = new HashMap<>();
305        
306        Content content = _resolver.resolveById(contentId);
307
308        if (content instanceof ModifiableContent)
309        {
310            ModifiableContent modifiableContent = (ModifiableContent) content;
311            
312            List<String> currentMixins = new ArrayList<>(Arrays.asList(content.getMixinTypes()));
313            
314            boolean hasChange = false;
315            if (remove)
316            {
317                hasChange = currentMixins.remove(mixinId);
318            }
319            else if (!currentMixins.contains(mixinId))
320            {
321                ContentType cType = _contentTypeEP.getExtension(mixinId);
322                if (!cType.isMixin())
323                {
324                    result.put("failure", true);
325                    result.put("msg", "no-mixin");
326                    getLogger().error("The content type '" + mixinId + "' is not a mixin type, it can be not be added as a mixin.");
327                }
328                else if (!_contentTypesHelper.isCompatibleContentType(content, mixinId))
329                {
330                    result.put("failure", true);
331                    result.put("msg", "invalid-mixin");
332                    getLogger().error("Mixin '" + mixinId + "' is incompatible with content '" + contentId + "'.");
333                }
334                else
335                {
336                    currentMixins.add(mixinId);
337                    hasChange = true;
338                }
339            }
340            
341            if (hasChange)
342            {
343                // TODO check if the content type is compatible
344                modifiableContent.setMixinTypes(currentMixins.toArray(new String[currentMixins.size()]));
345                modifiableContent.saveChanges();
346                
347                if (content instanceof WorkflowAwareContent)
348                {
349                    WorkflowAwareContent waContent = (WorkflowAwareContent) content;
350                    AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(waContent);
351                    
352                    Map<String, Object> inputs = new HashMap<>();
353                    inputs.put(AbstractContentWorkflowComponent.CONTENT_KEY, content);
354                    inputs.put(AbstractWorkflowComponent.RESULT_MAP_KEY, new HashMap<String, Object>());
355                    inputs.put(AbstractWorkflowComponent.FAIL_CONDITIONS_KEY, new ArrayList<String>());
356                    
357                    workflow.doAction(waContent.getWorkflowId(), actionId, inputs);
358                }
359                
360                result.put("success", true);
361                
362                Map<String, Object> eventParams = new HashMap<>();
363                eventParams.put(ObservationConstants.ARGS_CONTENT, modifiableContent);
364                eventParams.put(ObservationConstants.ARGS_CONTENT_ID, contentId);
365                _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_MODIFIED, _currentUserProvider.getUser(), eventParams));
366            }
367        }
368        else
369        {
370            result.put("failure", true);
371            result.put("msg", "no-modifiable-content");
372            getLogger().error("Can not modified mixins to a non-modifiable content '" + content.getId() + "'.");
373        }
374        
375        return result;
376    }
377    
378    /**
379     * Determines if the content is a reference table content type
380     * @param content The content
381     * @return true if content is a reference table
382     */
383    public boolean isReferenceTable(Content content)
384    {
385        for (String cTypeId : content.getTypes())
386        {
387            ContentType cType = _contentTypeEP.getExtension(cTypeId);
388            if (cType != null)
389            {
390                if (!cType.isReferenceTable())
391                {
392                    return false;
393                }
394            }
395            else
396            {
397                if (getLogger().isWarnEnabled())
398                {
399                    getLogger().warn(String.format("Unable to determine if a content is a reference table, unknown content type : '%s'.", cTypeId));
400                }
401            }
402        }
403        return true;
404    }
405    
406    /**
407     * Determines if a content is a multilingual content
408     * @param content The content
409     * @return <code>true</code> if the content is an instance of content type
410     */
411    public boolean isMultilingual(Content content)
412    {
413        for (String cTypeId : content.getTypes())
414        {
415            ContentType cType = _contentTypeEP.getExtension(cTypeId);
416            if (cType != null && cType.isMultilingual())
417            {
418                return true;
419            }
420        }
421        return false;
422    }
423    
424    /**
425     * Determines if the content is a simple content type
426     * @param content The content
427     * @return true if content is simple
428     */
429    public boolean isSimple (Content content)
430    {
431        for (String cTypeId : content.getTypes())
432        {
433            ContentType cType = _contentTypeEP.getExtension(cTypeId);
434            if (cType != null)
435            {
436                if (!cType.isSimple())
437                {
438                    return false;
439                }
440            }
441            else
442            {
443                if (getLogger().isWarnEnabled())
444                {
445                    getLogger().warn(String.format("Unable to determine if a content is simple, unknown content type : '%s'.", cTypeId));
446                }
447            }
448        }
449        return true;
450    }
451    
452    /**
453     * Get the typed value(s) of a content at given path.
454     * The path can represent a system property id or a path of a metadata into the content or a metadata on one or more linked contents, such as 'composite/linkedContent/secondContent/composite/metadata'.
455     * The returned value is typed.
456     * @param content The content
457     * @param fieldPath The field id or the path to the metadata, separated by '/'
458     * @param defaultLocale The default locale to resolve localized values if the content's language is null. Can be null.
459     * @param resolveReferences <code>true</code> true to resolve references (such as resource or content)
460     * @return The typed final value. If the final field is multiple, or contains into a repeater or multiple 'CONTENT' metadata, the returned value will be a Collection
461     */
462    public Object getValue(Content content, String fieldPath, Locale defaultLocale, boolean resolveReferences)
463    {
464        return getValue(content, fieldPath, defaultLocale, resolveReferences, false);
465    }
466    
467    /**
468     * Get the typed value(s) of a content at given path.
469     * The path can represent a system property id or a path of a metadata into the content or a metadata on one or more linked contents, such as 'composite/linkedContent/secondContent/composite/metadata'.
470     * The returned value is typed.
471     * @param content The content
472     * @param fieldPath The field id or the path to the metadata, separated by '/'
473     * @param defaultLocale The default locale to resolve localized values if the content's language is null. Can be null.
474     * @param resolveReferences <code>true</code> true to resolve references (such as resource or content)
475     * @param returnNullValues <code>true</code> true to return null values when the metadata does not exists in a repeater or linked content.
476     * @return The typed final value. If the final field is multiple, or contains into a repeater or multiple 'CONTENT' metadata, the returned value will be a Collection
477     */
478    public Object getValue(Content content, String fieldPath, Locale defaultLocale, boolean resolveReferences, boolean returnNullValues)
479    {
480        if (StringUtils.isAnyEmpty(fieldPath))
481        {
482            return null;
483        }
484        
485        // Manage System Properties
486        String[] pathSegments = fieldPath.split(ContentConstants.METADATA_PATH_SEPARATOR);
487        String propertyName = pathSegments[pathSegments.length - 1];
488        
489        if (_systemPropertyExtensionPoint.hasExtension(propertyName))
490        {
491            if (_systemPropertyExtensionPoint.isDisplayable(propertyName))
492            {
493                SystemProperty systemProperty = _systemPropertyExtensionPoint.getExtension(propertyName);
494                return _getSystemPropertyValue(content, pathSegments, systemProperty);
495            }
496            else
497            {
498                throw new IllegalArgumentException("The system property '" + propertyName + "' is not displayable.");
499            }
500        }
501        else
502        {
503            return getMetadataValue(content, fieldPath, defaultLocale, resolveReferences, returnNullValues);
504        }
505    }
506
507    private Object _getSystemPropertyValue(Content content, String[] pathSegments, SystemProperty systemProperty)
508    {
509        String contentFieldPath = StringUtils.join(pathSegments, ContentConstants.METADATA_PATH_SEPARATOR, 0, pathSegments.length - 1);
510        List<Content> contentsContainingSystemProperty = getTargetContents(content, contentFieldPath);
511        if (contentsContainingSystemProperty.size() == 1)
512        {
513            Object value = systemProperty.getValue(contentsContainingSystemProperty.get(0));
514            
515            if (value instanceof Object[])
516            {
517                return Arrays.asList((Object[]) value);
518            }
519            else
520            {
521                return value;
522            }
523        }
524        else
525        {
526            List<Object> values = new ArrayList<>();
527            for (Content contentContainingSystemProperty : contentsContainingSystemProperty)
528            {
529                Object value = systemProperty.getValue(contentContainingSystemProperty);
530                
531                if (value instanceof Object[])
532                {
533                    values.addAll(Arrays.asList((Object[]) value));
534                }
535                else
536                {
537                    values.add(value);
538                }
539            }
540            return values;
541        }
542    }
543    
544    /**
545     * Get the content from which to get the system property.
546     * @param sourceContent The source content.
547     * @param fieldPath The field path
548     * @return The target content.
549     */
550    public Content getTargetContent(Content sourceContent, String fieldPath)
551    {
552        if (StringUtils.isBlank(fieldPath))
553        {
554            return sourceContent;
555        }
556        else
557        {
558            Object value = getMetadataValue(sourceContent, fieldPath, null, true);
559            if (value != null && value instanceof Content)
560            {
561                return (Content) value;
562            }
563            else if (value != null && value instanceof Collection<?> && ((Collection) value).size() > 0)
564            {
565                Object first = ((Collection) value).iterator().next();
566                if (first instanceof Content)
567                {
568                    return (Content) first;
569                }
570            }
571        }
572        
573        return null;
574    }
575    
576    /**
577     * Get the contents from which to get the system property.
578     * @param sourceContent The source content.
579     * @param fieldPath The field path
580     * @return The target contents.
581     */
582    public List<Content> getTargetContents(Content sourceContent, String fieldPath)
583    {
584        List<Content> targetContents = new ArrayList<>();
585        
586        if (StringUtils.isBlank(fieldPath))
587        {
588            targetContents.add(sourceContent);
589        }
590        else
591        {
592            Object value = getMetadataValue(sourceContent, fieldPath, null, true);
593            if (value != null && value instanceof Content)
594            {
595                targetContents.add((Content) value);
596            }
597            else if (value != null && value instanceof Collection<?>)
598            {
599                Iterator it = ((Collection) value).iterator();
600                while (it.hasNext())
601                {
602                    Object object = it.next();
603                    if (object instanceof Content)
604                    {
605                        targetContents.add((Content) object);
606                    }
607                    
608                }
609            }
610        }
611        
612        return targetContents;
613    }
614
615    /**
616     * Get the typed metadata value(s) of a content at given path.
617     * The path can represent a path of a metadata into the content or a metadata on one or more linked contents, such as 'composite/linkedContent/secondContent/composite/metadata'.
618     * The returned value is typed.
619     * @param content The content
620     * @param metadataPath The path to the metadata, separated by '/'
621     * @param defaultLocale The locale to use to sax localized values such as multilingual content or multilingual string. 
622     * Only to be valued if initial content's language is null, otherwise set this parameter to null.
623     * @param resolveReferences <code>true</code> true to resolve references (such as resource or content)
624     * @return The typed final value. If the final metadata is multiple, or contains into a repeater or multiple 'CONTENT' metadata, the returned value will be a Collection
625     */
626    public Object getMetadataValue(Content content, String metadataPath, Locale defaultLocale, boolean resolveReferences)
627    {
628        return getMetadataValue(content, metadataPath, defaultLocale, resolveReferences, false);
629    }
630    
631    /**
632     * Get the typed metadata value(s) of a content at given path.
633     * The path can represent a path of a metadata into the content or a metadata on one or more linked contents, such as 'composite/linkedContent/secondContent/composite/metadata'.
634     * The returned value is typed.
635     * @param content The content
636     * @param metadataPath The path to the metadata, separated by '/'
637     * @param defaultLocale The locale to use to sax localized values such as multilingual content or multilingual string. 
638     * Only to be valued if initial content's language is null, otherwise set this parameter to null.
639     * @param resolveReferences <code>true</code> true to resolve references (such as resource or content)
640     * @param returnNullValues <code>true</code> true to return null values when the metadata does not exists in a repeater or linked content.
641     * @return The typed final value. If the final metadata is multiple, or contains into a repeater or multiple 'CONTENT' metadata, the returned value will be a Collection
642     */
643    public Object getMetadataValue(Content content, String metadataPath, Locale defaultLocale, boolean resolveReferences, boolean returnNullValues)
644    {
645        String[] pathSegments = metadataPath.split(ContentConstants.METADATA_PATH_SEPARATOR);
646        
647        MetadataDefinition definition = _contentTypesHelper.getMetadataDefinition(pathSegments[0], content);
648        if (definition != null)
649        {
650            Locale contentLocale = content.getLanguage() != null ? new Locale(content.getLanguage()) : defaultLocale;
651            return getMetadataValue(content.getMetadataHolder(), definition, metadataPath, contentLocale, resolveReferences, returnNullValues);
652        }
653        
654        getLogger().warn("Unknown metadata definition at path '" + metadataPath + "' for content '" + content.getId());
655        return null;
656    }
657    
658    /**
659     * Get the typed values of a content at given path.
660     * The value is always returned into a collection of object event if the metadata is a single metadata.
661     * The path can represent a path of a metadata into the content or a metadata on one or more linked contents, such as 'composite/linkedContent/secondContent/composite/metadata'.
662     * The returned value is typed.
663     * @param contentId The ID of the content
664     * @param metadataPath The Path to the metadata, separated by '/'
665     * @return The typed final value. If the final metadata is single, the returned value will be a Collection of one element
666     */
667    @Callable
668    public List<Object> getMetadataValues(String contentId, String metadataPath)
669    {
670        return getMetadataValues(_resolver.resolveById(contentId), metadataPath, null, false, true);
671    }
672    
673    /**
674     * Get the typed values of a content at given path.
675     * The value is always returned into a collection of object event if the metadata is a single metadata.
676     * The path can represent a path of a metadata into the content or a metadata on one or more linked contents, such as 'composite/linkedContent/secondContent/composite/metadata'.
677     * The returned value is typed.
678     * @param content The content
679     * @param metadataPath The path to the metadata, separated by '/'
680     * @param defaultLocale The locale to use to sax localized values such as multilingual content or multilingual string. 
681     * Only to be valued if initial content's language is null, otherwise set this parameter to null.
682     * @param resolveReferences <code>true</code> true to resolve references (such as resource or content)
683     * @param returnNullValues <code>true</code> true to return null values when the metadata does not exists in a repeater or linked content.
684     * @return The typed final value. If the final metadata is single, the returned value will be a Collection of one element
685     */
686    public List<Object> getMetadataValues(Content content, String metadataPath, Locale defaultLocale, boolean resolveReferences, boolean returnNullValues)
687    {
688        String[] pathSegments = metadataPath.split(ContentConstants.METADATA_PATH_SEPARATOR);
689        
690        MetadataDefinition definition = _contentTypesHelper.getMetadataDefinition(pathSegments[0], content);
691        if (definition != null)
692        {
693            Locale locale = content.getLanguage() != null ? new Locale(content.getLanguage()) : defaultLocale;
694            Object values = getMetadataValue(content.getMetadataHolder(), definition, metadataPath, locale, resolveReferences, returnNullValues);
695            if (values instanceof Collection<?>)
696            {
697                return new ArrayList<>((Collection<?>) values); 
698            }
699            else if (values != null || returnNullValues)
700            {
701                return Arrays.asList(values);
702            }
703            else
704            {
705                return Collections.EMPTY_LIST;
706            }
707        }
708        
709        getLogger().warn("Unknown metadata definition at path '" + metadataPath + "' for content '" + content.getId());
710        
711        return null;
712    }
713    
714    /**
715     * Get the title of a content.&lt;br&gt;
716     * 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.
717     * @param content The content
718     * @return The title of the content
719     */
720    public String getTitle(Content content)
721    {
722        Locale defaultLocale = null;
723        
724        try
725        {
726            Map objectModel = (Map) _context.get(ContextHelper.CONTEXT_OBJECT_MODEL);
727            if (objectModel != null)
728            {
729                // The object model can be null if #getTitle(content) is called outside a request
730                defaultLocale = org.apache.cocoon.i18n.I18nUtils.findLocale(objectModel, "locale", null, Locale.getDefault(), true);
731            }
732        }
733        catch (ContextException e)
734        {
735            // There is no context
736        }
737        
738        // TODO Use user preference language ?
739        return content.getTitle(defaultLocale);
740    }
741    
742    /**
743     * Get the title variants of a multilingual content
744     * @param content The multilingual content
745     * @return the content's title for each locale
746     * @throws IllegalArgumentException if the content is not a multilingual content
747     */
748    public Map<String, String> getTitleVariants(Content content)
749    {
750        if (!isMultilingual(content))
751        {
752            throw new IllegalArgumentException("Can not get title variants for a non-multilingual content " + content.getId());
753        }
754        
755        Map<String, String> variants = new HashMap<>();
756        
757        MultilingualString value = content.getMetadataHolder().getMultilingualString(DefaultContent.METADATA_TITLE);
758        for (Locale locale : value.getLocales())
759        {
760            variants.put(locale.getLanguage(), value.getValue(locale));
761        }
762        
763        return variants;
764    }
765    
766    /**
767     * Get the typed value(s) at given path.
768     * The path can represent a path of a metadata in the parent composite metadata or a path of a metadata into a linked content.
769     * The returned value is typed.
770     * @param metadataHolder The parent composite metadata
771     * @param definition The definition of the first metadata in path
772     * @param metadataPath The path to the metadata, separated by '/'
773     * @param locale The locale to used to resolve localized metadata
774     * @param resolveReferences <code>true</code> true to resolve references (such as resource or content)
775     * @param returnNullValues <code>true</code> true to return null values when metadata does not exists.
776     * @return The typed final value. If the final metadata is multiple, or contains into a repeater or multiple 'CONTENT' metadata, the returned value will be a Collection
777     */
778    public Object getMetadataValue(CompositeMetadata metadataHolder, MetadataDefinition definition, String metadataPath, Locale locale, boolean resolveReferences, boolean returnNullValues)
779    {
780        String[] pathSegments = metadataPath.split(ContentConstants.METADATA_PATH_SEPARATOR);
781        
782        String metadataName = pathSegments[0];
783        
784        if (!metadataHolder.hasMetadata(metadataName))
785        {
786            return null;
787        }
788        
789        MetadataType type = definition.getType();
790        switch (type)
791        {
792            case COMPOSITE:
793                return _getCompositeMetadataValue(metadataHolder, definition, locale, resolveReferences, returnNullValues, pathSegments, metadataName);
794            
795            case CONTENT:
796                return _getContentMetadataValue(metadataHolder, definition, locale, resolveReferences, returnNullValues, pathSegments, metadataName);
797            default:
798                if (pathSegments.length == 1)
799                {
800                    return getSimpleMetadataValue(metadataHolder, definition, metadataName, locale, resolveReferences);
801                }
802                
803                throw new IllegalArgumentException("Metadata at path '" + definition.getId() + "' is a simple metadata : can not invoked get sub metadata values at path " + StringUtils.join(pathSegments, ContentConstants.METADATA_PATH_SEPARATOR, 1, pathSegments.length));
804        }
805    }
806
807    private Object _getContentMetadataValue(CompositeMetadata metadataHolder, MetadataDefinition definition, Locale defaultLocale, boolean resolveReferences, boolean returnNullValues,
808            String[] pathSegments, String metadataName)
809    {
810        if (pathSegments.length > 1)
811        {
812            if (definition.isMultiple())
813            {
814                List<Object> values = new ArrayList<>();
815                String[] refContentIds = metadataHolder.getStringArray(metadataName, new String[0]);
816                for (String refContentId : refContentIds)
817                {
818                    Content refContent = _resolver.resolveById(refContentId);
819                    Locale locale = refContent.getLanguage() != null ? new Locale(refContent.getLanguage()) : defaultLocale;
820                    Object remoteValue = getMetadataValue(refContent, StringUtils.join(pathSegments, ContentConstants.METADATA_PATH_SEPARATOR, 1, pathSegments.length), locale, resolveReferences, returnNullValues);
821                    if (remoteValue != null && remoteValue instanceof Collection<?>)
822                    {
823                        values.addAll((Collection<?>) remoteValue);
824                    }
825                    else if (remoteValue != null || returnNullValues)
826                    {
827                        values.add(remoteValue);
828                    }
829                }
830                return values;
831            }
832            else
833            {
834                String refContentId = metadataHolder.getString(metadataName);
835                Content refContent = _resolver.resolveById(refContentId);
836                Locale locale = refContent.getLanguage() != null ? new Locale(refContent.getLanguage()) : defaultLocale;
837                return getMetadataValue(refContent, StringUtils.join(pathSegments, ContentConstants.METADATA_PATH_SEPARATOR, 1, pathSegments.length), locale, resolveReferences, returnNullValues);
838            }   
839        }
840        else
841        {
842            return getSimpleMetadataValue(metadataHolder, definition, metadataName, defaultLocale, resolveReferences);
843        }
844    }
845
846    private Object _getCompositeMetadataValue(CompositeMetadata metadataHolder, MetadataDefinition definition, Locale locale, boolean resolveReferences, boolean returnNullValues, String[] pathSegments, String metadataName)
847    {
848        if (pathSegments.length > 1)
849        {
850            if (definition instanceof RepeaterDefinition)
851            {
852                // Repeater: get and sort the entry names.
853                CompositeMetadata repeater = metadataHolder.getCompositeMetadata(metadataName);
854                String[] entries = repeater.getMetadataNames();
855                Arrays.sort(entries, MetadataManager.REPEATER_ENTRY_COMPARATOR);
856                
857                List<Object> values = new ArrayList<>();
858                
859                for (String entryName : entries)
860                {
861                    CompositeMetadata entry = repeater.getCompositeMetadata(entryName);
862                    
863                    Object entryValue = getMetadataValue(entry, definition.getMetadataDefinition(pathSegments[1]), StringUtils.join(pathSegments, ContentConstants.METADATA_PATH_SEPARATOR, 1, pathSegments.length), locale, resolveReferences, returnNullValues);
864                    if (entryValue != null && entryValue instanceof Collection<?>)
865                    {
866                        values.addAll((Collection<?>) entryValue);
867                    }
868                    else if (entryValue != null || returnNullValues)
869                    {
870                        values.add(entryValue);
871                    }
872                }
873                
874                return values;
875            }
876            else
877            {
878                // Composite.
879                CompositeMetadata subMetadataHolder = metadataHolder.getCompositeMetadata(metadataName);
880                MetadataDefinition subMetadataDef = definition.getMetadataDefinition(pathSegments[1]);
881                return getMetadataValue(subMetadataHolder, subMetadataDef, StringUtils.join(pathSegments, ContentConstants.METADATA_PATH_SEPARATOR, 1, pathSegments.length), locale, resolveReferences, returnNullValues);
882            }
883        }
884                        
885        throw new IllegalArgumentException("Metadata at path '" + definition.getId() + "' is a composite metadata : can not invoked #getMetadataValue");
886    }
887    
888    /**
889     * Get the typed value(s) of a simple metadata.
890     * @param metadataHolder The parent composite metadata
891     * @param definition The definition of the first metadata in path
892     * @param metadataName The name of the metadata
893     * @param locale The locale to used to resolve localized metadata
894     * @param resolveReferences <code>true</code> true to resolve references (such as resource or content)
895     * @return The typed final value.
896     */
897    public Object getSimpleMetadataValue(CompositeMetadata metadataHolder, MetadataDefinition definition, String metadataName, Locale locale, boolean resolveReferences)
898    {
899        if (metadataName.contains(ContentConstants.METADATA_PATH_SEPARATOR))
900        {
901            throw new IllegalArgumentException("The metadata name cannot represent a path : " + metadataName);
902        }
903        
904        Object value = null;
905        
906        switch (definition.getType())
907        {
908            case LONG:
909                value = _getLongValue(metadataHolder, metadataName, definition);
910                break;
911            case DOUBLE:
912                value = _getDoubleValue(metadataHolder, metadataName, definition);
913                break;
914            case BOOLEAN:
915                value = _getBooleanValue(metadataHolder, metadataName, definition);
916                break;
917            case DATE:
918            case DATETIME:
919                value = _getDateValue(metadataHolder, metadataName, definition);
920                break;
921            case USER:
922                value = _getUserValue(metadataHolder, metadataName, definition);
923                break;
924            case BINARY:
925                value = _getBinaryValue(metadataHolder, metadataName);
926                break;
927            case FILE:
928                value = _getFileValue(metadataHolder, metadataName, definition, resolveReferences);
929                break;
930            case GEOCODE:
931                value = _getGeocodeValue(metadataHolder, metadataName);
932                break;
933            case RICH_TEXT:
934                value = _getRichTextValue(metadataHolder, metadataName);
935                break;
936            case CONTENT:
937                value = _getContentValue(metadataHolder, metadataName, definition, resolveReferences);
938                break;
939            case SUB_CONTENT:
940                // TODO or ignore ?
941                break;
942            case REFERENCE:
943                value = _getReferenceValue(metadataHolder, metadataName, definition);
944                break;
945            case MULTILINGUAL_STRING:
946                value = _getMultilingualStringValue(metadataHolder, metadataName, locale, resolveReferences);
947                break;
948            case STRING:
949            default:
950                value = _getStringValue(metadataHolder, metadataName, definition);
951        }
952        
953        return value;
954    }
955    
956    private Object _getStringValue(CompositeMetadata metadataHolder, String metadataName, MetadataDefinition definition)
957    {
958        try
959        {
960            if (definition.isMultiple())
961            {
962                return Arrays.asList(metadataHolder.getStringArray(metadataName));
963            }
964            else
965            {
966                return metadataHolder.getString(metadataName);
967            }
968        }
969        catch (UnknownMetadataException e)
970        {
971            // Ignore, just return null.
972            return null;
973        }
974    }
975    
976    private Object _getMultilingualStringValue(CompositeMetadata metadataHolder, String metadataName, Locale locale, boolean resolve)
977    {
978        if (resolve)
979        {
980            return metadataHolder.getMultilingualString(metadataName);
981        }
982        else
983        {
984            return MultilingualStringHelper.getValue(metadataHolder, metadataName, locale);
985        }
986    }
987    
988    private Object _getContentValue(CompositeMetadata metadataHolder, String metadataName, MetadataDefinition definition, boolean resolve)
989    {
990        try
991        {
992            if (definition.isMultiple())
993            {
994                String[] refContentIds = metadataHolder.getStringArray(metadataName);
995                if (resolve)
996                {
997                    List<Content> contents = new ArrayList<>();
998                    for (String refContentId : refContentIds)
999                    {
1000                        try
1001                        {
1002                            contents.add(_resolver.resolveById(refContentId));
1003                        }
1004                        catch (UnknownAmetysObjectException e)
1005                        {
1006                            // Ignore
1007                            if (getLogger().isWarnEnabled())
1008                            {
1009                                getLogger().warn("Metadata '" + definition.getId() + " refers a non-existing content of id '" + refContentId + "'", e);
1010                            }
1011                        }
1012                    }
1013                    return contents;
1014                }
1015                else
1016                {
1017                    return Arrays.asList(refContentIds);
1018                }
1019            }
1020            else
1021            {
1022                if (resolve)
1023                {
1024                    try
1025                    {
1026                        return _resolver.resolveById(metadataHolder.getString(metadataName));
1027                    }
1028                    catch (UnknownAmetysObjectException e)
1029                    {
1030                        return null;
1031                    }
1032                }
1033                else
1034                {
1035                    return metadataHolder.getString(metadataName);
1036                }
1037            }
1038        }
1039        catch (UnknownMetadataException e)
1040        {
1041            // Ignore, just return null.
1042            return null;
1043        }
1044    }
1045    
1046    private Object _getUserValue(CompositeMetadata metadataHolder, String metadataName, MetadataDefinition definition)
1047    {
1048        try
1049        {
1050            if (definition.isMultiple())
1051            {
1052                return Arrays.asList(metadataHolder.getUserArray(metadataName));
1053            }
1054            else
1055            {
1056                return metadataHolder.getUser(metadataName);
1057            }
1058        }
1059        catch (UnknownMetadataException e)
1060        {
1061            // Ignore, just return null.
1062            return null;
1063        }
1064    }
1065    
1066    private Object _getDateValue(CompositeMetadata metadataHolder, String metadataName, MetadataDefinition definition)
1067    {
1068        try
1069        {
1070            if (definition.isMultiple())
1071            {
1072                return Arrays.asList(metadataHolder.getDateArray(metadataName));
1073            }
1074            else
1075            {
1076                return metadataHolder.getDate(metadataName);
1077            }
1078        }
1079        catch (UnknownMetadataException e)
1080        {
1081            // Ignore, just return null.
1082            return null;
1083        }
1084    }
1085    
1086    private Object _getLongValue(CompositeMetadata metadataHolder, String metadataName, MetadataDefinition definition)
1087    {
1088        try
1089        {
1090            if (definition.isMultiple())
1091            {
1092                return Arrays.asList(ArrayUtils.toObject(metadataHolder.getLongArray(metadataName)));
1093            }
1094            else
1095            {
1096                return metadataHolder.getLong(metadataName);
1097            }
1098        }
1099        catch (UnknownMetadataException e)
1100        {
1101            // Ignore, just return null.
1102            return null;
1103        }
1104    }
1105    
1106    private Object _getDoubleValue(CompositeMetadata metadataHolder, String metadataName, MetadataDefinition definition)
1107    {
1108        try
1109        {
1110            if (definition.isMultiple())
1111            {
1112                return Arrays.asList(ArrayUtils.toObject(metadataHolder.getDoubleArray(metadataName)));
1113            }
1114            else
1115            {
1116                return metadataHolder.getDouble(metadataName);
1117            }
1118        }
1119        catch (UnknownMetadataException e)
1120        {
1121            // Ignore, just return null.
1122            return null;
1123        }
1124    }
1125    
1126    private Object _getBooleanValue(CompositeMetadata metadataHolder, String metadataName, MetadataDefinition definition)
1127    {
1128        try
1129        {
1130            if (definition.isMultiple())
1131            {
1132                return Arrays.asList(ArrayUtils.toObject(metadataHolder.getBooleanArray(metadataName)));
1133            }
1134            else
1135            {
1136                return metadataHolder.getBoolean(metadataName);
1137            }
1138        }
1139        catch (UnknownMetadataException e)
1140        {
1141            // Ignore, just return null.
1142            return null;
1143        }
1144    }
1145    
1146    private Object _getRichTextValue(CompositeMetadata metadataHolder, String metadataName)
1147    {
1148        try
1149        {
1150            return metadataHolder.getRichText(metadataName);
1151        }
1152        catch (UnknownMetadataException e)
1153        {
1154            // Ignore, just return null.
1155            return null;
1156        }
1157    }
1158    
1159    private Object _getBinaryValue(CompositeMetadata metadataHolder, String metadataName)
1160    {
1161        try
1162        {
1163            return metadataHolder.getBinaryMetadata(metadataName);
1164        }
1165        catch (UnknownMetadataException e)
1166        {
1167            // Ignore, just return null.
1168            return null;
1169        }
1170    }
1171    
1172    private Object _getResourceValue(CompositeMetadata metadataHolder, String metadataName, MetadataDefinition definition, boolean resolve)
1173    {
1174        try
1175        {
1176            if (definition.isMultiple())
1177            {
1178                String[] resourceIds = metadataHolder.getStringArray(metadataName);
1179                if (resolve)
1180                {
1181                    List<Resource> resources = new ArrayList<>();
1182                    for (String resourceId : resourceIds)
1183                    {
1184                        try
1185                        {
1186                            resources.add(_resolver.resolveById(resourceId));
1187                        }
1188                        catch (UnknownAmetysObjectException e)
1189                        {
1190                            // Ignore
1191                        }
1192                    }
1193                    return resources;
1194                }
1195                else
1196                {
1197                    return Arrays.asList(resourceIds);
1198                }
1199            }
1200            else
1201            {
1202                if (resolve)
1203                {
1204                    try
1205                    {
1206                        return _resolver.resolveById(metadataHolder.getString(metadataName));
1207                    }
1208                    catch (UnknownAmetysObjectException e)
1209                    {
1210                        return null;
1211                    }
1212                }
1213                else
1214                {
1215                    return metadataHolder.getString(metadataName);
1216                }
1217            }
1218        }
1219        catch (UnknownMetadataException e)
1220        {
1221            // Ignore, just return null.
1222            return null;
1223        }
1224    }
1225    
1226    private Object _getGeocodeValue(CompositeMetadata metadataHolder, String metadataName)
1227    {
1228        try
1229        {
1230            // FIXME should return a GeoCode object
1231            CompositeMetadata geoMetadata = metadataHolder.getCompositeMetadata(metadataName);
1232            
1233            if (geoMetadata.hasMetadata("longitude") && geoMetadata.hasMetadata("latitude")) 
1234            {
1235                Double longitude = geoMetadata.getDouble("longitude");
1236                Double latitude = geoMetadata.getDouble("latitude");
1237                
1238                Map<String, Double> geocode = new LinkedHashMap<>();
1239                geocode.put("longitude", longitude);
1240                geocode.put("latitude", latitude);
1241                
1242                return geocode;
1243            }
1244        }
1245        catch (UnknownMetadataException e)
1246        {
1247            // Ignore, just return null.
1248        }
1249        
1250        return null;
1251    }
1252    
1253    private Object _getReferenceValue(CompositeMetadata metadataHolder, String metadataName, MetadataDefinition definition)
1254    {
1255        try
1256        {
1257            // FIXME should return a Reference object
1258            
1259            CompositeMetadata referencesComposite = metadataHolder.getCompositeMetadata(metadataName);
1260            
1261            if (definition.isMultiple())
1262            {
1263                List<Map<String, Object>> references = new ArrayList<>();
1264                
1265                String[] types = referencesComposite.getStringArray("types");
1266                String[] values = referencesComposite.getStringArray("values");
1267                
1268                for (int i = 0; i < types.length; i++)
1269                {
1270                    Map<String, Object> reference = new HashMap<>(2);
1271                    reference.put("type", types[i]);
1272                    reference.put("value", values[i]);
1273                    
1274                    references.add(reference);
1275                }
1276                
1277                return references;
1278            }
1279            else
1280            {
1281                String type = referencesComposite.getString("type");
1282                String value = referencesComposite.getString("value");
1283                
1284                Map<String, Object> reference = new HashMap<>(2);
1285                reference.put("type", type);
1286                reference.put("value", value);
1287                
1288                return reference;
1289            }
1290        }
1291        catch (UnknownMetadataException e)
1292        {
1293            // Ignore, just return null.
1294            return null;
1295        }
1296    }
1297    
1298    private Object _getFileValue(CompositeMetadata metadataHolder, String metadataName, MetadataDefinition definition, boolean resolveReference)
1299    {
1300        if (org.ametys.plugins.repository.metadata.CompositeMetadata.MetadataType.BINARY.equals(metadataHolder.getType(metadataName)))
1301        {
1302            return _getBinaryValue(metadataHolder, metadataName);
1303        }
1304        else
1305        {
1306            return _getResourceValue(metadataHolder, metadataName, definition, resolveReference);
1307        }
1308    }
1309    
1310}