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.search.content;
017
018import java.io.InputStream;
019import java.lang.reflect.Array;
020import java.time.LocalDate;
021import java.time.ZoneId;
022import java.time.ZonedDateTime;
023import java.util.ArrayList;
024import java.util.Collection;
025import java.util.HashMap;
026import java.util.Iterator;
027import java.util.List;
028import java.util.Locale;
029import java.util.Map;
030import java.util.Optional;
031import java.util.Set;
032
033import org.apache.avalon.framework.component.Component;
034import org.apache.avalon.framework.context.Context;
035import org.apache.avalon.framework.context.ContextException;
036import org.apache.avalon.framework.context.Contextualizable;
037import org.apache.avalon.framework.service.ServiceException;
038import org.apache.avalon.framework.service.ServiceManager;
039import org.apache.avalon.framework.service.Serviceable;
040import org.apache.commons.lang3.ArrayUtils;
041import org.apache.commons.lang3.StringUtils;
042import org.apache.excalibur.xml.sax.SAXParser;
043import org.xml.sax.InputSource;
044
045import org.ametys.cms.content.ContentHelper;
046import org.ametys.cms.content.RichTextHandler;
047import org.ametys.cms.contenttype.ContentAttributeDefinition;
048import org.ametys.cms.contenttype.ContentType;
049import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
050import org.ametys.cms.contenttype.ContentTypesHelper;
051import org.ametys.cms.contenttype.MetadataDefinition;
052import org.ametys.cms.contenttype.MetadataType;
053import org.ametys.cms.contenttype.RepeaterDefinition;
054import org.ametys.cms.contenttype.indexing.CustomIndexingField;
055import org.ametys.cms.contenttype.indexing.DefaultMetadataIndexingField;
056import org.ametys.cms.contenttype.indexing.IndexingField;
057import org.ametys.cms.contenttype.indexing.IndexingModel;
058import org.ametys.cms.contenttype.indexing.MetadataIndexingField;
059import org.ametys.cms.data.Binary;
060import org.ametys.cms.data.ContentValue;
061import org.ametys.cms.data.ExplorerFile;
062import org.ametys.cms.data.File;
063import org.ametys.cms.data.type.ModelItemTypeConstants;
064import org.ametys.cms.repository.Content;
065import org.ametys.cms.search.SearchField;
066import org.ametys.cms.search.model.SystemProperty;
067import org.ametys.cms.search.model.SystemPropertyExtensionPoint;
068import org.ametys.cms.search.solr.field.BooleanSearchField;
069import org.ametys.cms.search.solr.field.ContentSearchField;
070import org.ametys.cms.search.solr.field.DateSearchField;
071import org.ametys.cms.search.solr.field.DoubleSearchField;
072import org.ametys.cms.search.solr.field.JoinedSystemSearchField;
073import org.ametys.cms.search.solr.field.LongSearchField;
074import org.ametys.cms.search.solr.field.MultilingualStringSearchField;
075import org.ametys.cms.search.solr.field.StringSearchField;
076import org.ametys.cms.transformation.xslt.ResolveURIComponent;
077import org.ametys.cms.workflow.EditContentFunction;
078import org.ametys.core.ui.Callable;
079import org.ametys.core.user.UserIdentity;
080import org.ametys.core.util.DateUtils;
081import org.ametys.plugins.core.user.UserHelper;
082import org.ametys.plugins.repository.AmetysObjectResolver;
083import org.ametys.plugins.repository.data.holder.ModelAwareDataHolder;
084import org.ametys.plugins.repository.data.holder.group.impl.ModelAwareComposite;
085import org.ametys.plugins.repository.data.holder.group.impl.ModelAwareRepeater;
086import org.ametys.plugins.repository.data.holder.group.impl.ModelAwareRepeaterEntry;
087import org.ametys.plugins.repository.metadata.MultilingualString;
088import org.ametys.plugins.repository.metadata.MultilingualStringHelper;
089import org.ametys.runtime.i18n.I18nizableText;
090import org.ametys.runtime.model.ElementDefinition;
091import org.ametys.runtime.model.ModelItem;
092import org.ametys.runtime.model.type.ElementType;
093import org.ametys.runtime.model.type.ModelItemType;
094import org.ametys.runtime.plugin.component.AbstractLogEnabled;
095
096/**
097 * Component which helps content searching by providing a simple way to access
098 * content properties (either metadata or system properties).
099 */
100public class ContentSearchHelper extends AbstractLogEnabled implements Component, Serviceable, Contextualizable
101{
102
103    /** The component role. */
104    public static final String ROLE = ContentSearchHelper.class.getName();
105    
106    /** The content type extension point. */
107    protected ContentTypeExtensionPoint _cTypeEP;
108    
109    /** The content type helper. */
110    protected ContentTypesHelper _cTypeHelper;
111    
112    /** The content helper. */
113    protected ContentHelper _contentHelper;
114    
115    /** The system property extension point. */
116    protected SystemPropertyExtensionPoint _sysPropEP;
117    
118    /** The ametys object resolver. */
119    protected AmetysObjectResolver _resolver;
120
121    /** The user helper */
122    protected UserHelper _userHelper;
123
124    /** Content Types helper */
125    protected ContentTypesHelper _contentTypesHelper;
126
127    /** Avalon service manager */
128    protected ServiceManager _manager;
129    
130    private Context _context;
131    
132    @Override
133    public void contextualize(Context context) throws ContextException
134    {
135        _context = context;
136    }
137    
138    @Override
139    public void service(ServiceManager manager) throws ServiceException
140    {
141        _manager = manager;
142        _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
143        _cTypeHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE);
144        _contentHelper = (ContentHelper) manager.lookup(ContentHelper.ROLE);
145        _sysPropEP = (SystemPropertyExtensionPoint) manager.lookup(SystemPropertyExtensionPoint.ROLE);
146        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
147        _userHelper = (UserHelper) manager.lookup(UserHelper.ROLE);
148        _contentTypesHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE);
149    }
150    
151    /**
152     * Get the metadata indexing field for the "title" standard metadata.
153     * @return The standard title metadata indexing field.
154     */
155    public MetadataIndexingField getTitleMetadataIndexingField()
156    {
157        return new DefaultMetadataIndexingField("title", ContentTypesHelper.getTitleMetadataDefinition(), "title");
158    }
159    
160    /**
161     * Get a {@link SearchField} corresponding to a metadata.
162     * @param metadataPath The metadata path.
163     * @param metadataType The metadata type.
164     * @param isTypeContentWithMultilingualTitle <code>true</code> if the type is Content and the linked contents have multilingual titles
165     * @return the search field.
166     */
167    public SearchField getMetadataSearchField(String metadataPath, MetadataType metadataType, boolean isTypeContentWithMultilingualTitle)
168    {
169        return getMetadataSearchField(null, metadataPath, metadataType, isTypeContentWithMultilingualTitle);
170    }
171    
172    /**
173     * Get a {@link SearchField} corresponding to a metadata.
174     * @param joinPaths The join paths
175     * @param metadataPath The metadata path.
176     * @param metadataType The metadata type.
177     * @param isTypeContentWithMultilingualTitle <code>true</code> if the type is Content and the linked contents have multilingual titles
178     * @return the search field.
179     */
180    public SearchField getMetadataSearchField(List<String> joinPaths, String metadataPath, MetadataType metadataType, boolean isTypeContentWithMultilingualTitle)
181    {
182        switch (metadataType)
183        {
184            case STRING:
185            case USER:
186            case REFERENCE:
187                return new StringSearchField(joinPaths, metadataPath);
188            case CONTENT:
189            case SUB_CONTENT:
190                return new ContentSearchField(joinPaths, metadataPath, isTypeContentWithMultilingualTitle, Optional.ofNullable(_context));
191            case LONG:
192                return new LongSearchField(joinPaths, metadataPath);
193            case DOUBLE:
194                return new DoubleSearchField(joinPaths, metadataPath);
195            case BOOLEAN:
196                return new BooleanSearchField(joinPaths, metadataPath);
197            case DATE:
198            case DATETIME:
199                return new DateSearchField(joinPaths, metadataPath);
200            case MULTILINGUAL_STRING:
201                return new MultilingualStringSearchField(joinPaths, metadataPath, Optional.ofNullable(_context));
202            case COMPOSITE:
203            case BINARY:
204            case FILE:
205            case RICH_TEXT:
206            default:
207                return null;
208        }
209    }
210    
211    /**
212     * Get a {@link SearchField} from a field name in a batch of content types.
213     * @param contentTypes The content types, can be empty to search on any content type.
214     * In that case, only the title metadata and system properties will be usable in sort and facets specs.
215     * @param fieldPath The field path, can be either a system property ID or a indexing field name or path.
216     * @return The {@link SearchField} corresponding to the field path, or an {@link Optional#empty() empty optional} if not found
217     */
218    public Optional<SearchField> getSearchField(Collection<String> contentTypes, String fieldPath)
219    {
220        SearchField searchField = null;
221        
222        String[] pathSegments = StringUtils.split(fieldPath, ModelItem.ITEM_PATH_SEPARATOR);
223        String fieldName = pathSegments[0];
224        
225        // System property ?
226        if (_sysPropEP.hasExtension(fieldName) && pathSegments.length == 1)
227        {
228            return Optional.ofNullable(_sysPropEP.getExtension(fieldName).getSearchField());
229        }
230        
231        Set<String> commonContentTypeIds = _cTypeHelper.getCommonAncestors(contentTypes);
232        
233        MetadataType type = null;
234        MetadataDefinition def = null;
235        
236        
237        if (!commonContentTypeIds.isEmpty())
238        {
239            IndexingField indexingField = null;
240            Iterator<String> commonContentTypeIdsIt = commonContentTypeIds.iterator();
241            while (commonContentTypeIdsIt.hasNext() && indexingField == null)
242            {
243                ContentType cType = _cTypeEP.getExtension(commonContentTypeIdsIt.next());
244                indexingField = cType.getIndexingModel().getField(fieldName);
245            }
246            
247            if (indexingField == null)
248            {
249                throw new IllegalArgumentException("Search field with path '" + fieldPath + "' refers to an unknown indexing field: " + fieldName);
250            }
251            
252            if (indexingField instanceof MetadataIndexingField)
253            {
254                List<String> joinPaths = new ArrayList<>();
255                String[] remainingPathSegments = pathSegments.length > 1 ? (String[]) ArrayUtils.subarray(pathSegments, 1, pathSegments.length) : new String[0];
256                
257                searchField = _getSearchField((MetadataIndexingField) indexingField, remainingPathSegments, joinPaths, true);
258                def = ((MetadataIndexingField) indexingField).getMetadataDefinition();
259            }
260            else if (indexingField instanceof CustomIndexingField)
261            {
262                type = indexingField.getType();
263            }
264        }
265        else if (fieldPath.equals(Content.ATTRIBUTE_TITLE))
266        {
267            // No specific content type: allow only title.
268            type = ContentTypesHelper.getTitleMetadataDefinition().getType();
269        }
270        
271        if (searchField == null)
272        {
273            searchField = type != null ? getMetadataSearchField(fieldPath, type, isTitleMultilingual(def)) : null;
274        }
275        
276        return Optional.ofNullable(searchField);
277    }
278    
279    /**
280     * Get the search field from the indexing field and compute the join paths. Can be null if the last indexing field is a custom indexing field.
281     * @param indexingField The initial indexing field
282     * @param remainingPathSegments The path to access the metadata or an another indexing field from the initial indexing field
283     * @param joinPaths The consecutive's path in case of joint to access the field/metadata
284     * @param addLast <code>true</code> to add the last join path element to the list, <code>false</code> otherwise.
285     * @return The search field or null if not found
286     */
287    protected SearchField _getSearchField(MetadataIndexingField indexingField, String[] remainingPathSegments, List<String> joinPaths, boolean addLast)
288    {
289        StringBuilder currentMetaPath = new StringBuilder();
290        currentMetaPath.append(indexingField.getName());
291        
292        MetadataDefinition definition = indexingField.getMetadataDefinition();
293        
294        for (int i = 0; i < remainingPathSegments.length && definition != null; i++)
295        {
296            String currentPathSegment =  remainingPathSegments[i];
297            if (definition.getType() == MetadataType.CONTENT || definition.getType() == MetadataType.SUB_CONTENT)
298            {
299                // Add path to content from current content type to join paths.
300                // Join paths are the consecutive metadata paths (separated with '/') to access
301                // the searched content, for instance [address/city, links/department].
302                joinPaths.add(currentMetaPath.toString());
303                
304                String refCTypeId = definition.getContentType();
305                if (refCTypeId != null)
306                {
307                    if (!_cTypeEP.hasExtension(refCTypeId))
308                    {
309                        throw new IllegalArgumentException("Search criterion with path '" + StringUtils.join(remainingPathSegments, ModelItem.ITEM_PATH_SEPARATOR) + "' references an unknown content type:" + refCTypeId); 
310                    }
311                    
312                    ContentType refCType = _cTypeEP.getExtension(refCTypeId);
313                    IndexingModel refIndexingModel = refCType.getIndexingModel();
314                    
315                    IndexingField refIndexingField = refIndexingModel.getField(currentPathSegment);
316                    if (refIndexingField == null && _sysPropEP.hasExtension(currentPathSegment))
317                    {
318                        SystemProperty sysProperty = _sysPropEP.getExtension(currentPathSegment);
319                        return new JoinedSystemSearchField(joinPaths, sysProperty.getSearchField());
320                    }
321                    else if (refIndexingField == null)
322                    {
323                        throw new IllegalArgumentException("Search criterion with path '" + StringUtils.join(remainingPathSegments, ModelItem.ITEM_PATH_SEPARATOR) + "' refers to an unknown indexing field: " + currentPathSegment);
324                    }
325                    
326                    return _getSearchField((MetadataIndexingField) refIndexingField, ArrayUtils.subarray(remainingPathSegments, i + 1, remainingPathSegments.length), joinPaths, addLast);
327                }
328                else if ("title".equals(currentPathSegment))
329                {
330                    // No specific content type: allow only title.
331                    return _getSearchField(ContentTypesHelper.getTitleMetadataDefinition(), joinPaths);
332                }
333            }
334            else
335            {
336                if (definition instanceof RepeaterDefinition)
337                {
338                    // Add path to repeater from current content type or last repeater to join paths
339                    joinPaths.add(currentMetaPath.toString());
340                    currentMetaPath = new StringBuilder();
341                    currentMetaPath.append(currentPathSegment);
342                }
343                else
344                {
345                    currentMetaPath.append(ModelItem.ITEM_PATH_SEPARATOR).append(currentPathSegment);
346                }
347                definition = definition.getMetadataDefinition(currentPathSegment);
348            }
349        }
350        
351        if (addLast)
352        {
353            joinPaths.add(currentMetaPath.toString());
354        }
355        
356        return _getSearchField(definition, joinPaths);
357    }
358    
359    private SearchField _getSearchField(MetadataDefinition def, List<String> joinPaths)
360    {
361        MetadataType type = def.getType();
362        
363        String metadataPath = joinPaths.remove(joinPaths.size() - 1);
364        return type != null ? getMetadataSearchField(joinPaths, metadataPath, type, isTitleMultilingual(def)) : null;
365    }
366    
367    /**
368     * Determines if the given metadata definition represents a CONTENT metadata with contents with multilingual titles
369     * @param def The metadata definition
370     * @return <code>true</code> if the given metadata definition represents a CONTENT metadata with contents with multilingual titles
371     */
372    public boolean isTitleMultilingual(MetadataDefinition def)
373    {
374        boolean isTitleMultilingual = Optional.ofNullable(def)
375                .map(MetadataDefinition::getContentType)
376                .map(_cTypeEP::getExtension)
377                .map(cType -> cType.getMetadataDefinition(Content.ATTRIBUTE_TITLE))
378                .map(MetadataDefinition::getType)
379                .map(MetadataType.MULTILINGUAL_STRING::equals)
380                .orElse(false);
381        return isTitleMultilingual;
382    }
383    
384    /**
385     * Determines if the given metadata definition represents a CONTENT metadata with contents with multilingual titles
386     * @param modelItem The metadata definition
387     * @return <code>true</code> if the given metadata definition represents a CONTENT metadata with contents with multilingual titles
388     */
389    public boolean isTitleMultilingual(ModelItem modelItem)
390    {
391        return Optional.ofNullable(modelItem)
392                .filter(ContentAttributeDefinition.class::isInstance)
393                .map(ContentAttributeDefinition.class::cast)
394                .map(ContentAttributeDefinition::getContentTypeId)
395                .map(_cTypeEP::getExtension)
396                .filter(cType -> cType.hasModelItem(Content.ATTRIBUTE_TITLE))
397                .map(cType -> cType.getModelItem(Content.ATTRIBUTE_TITLE))
398                .map(ModelItem::getType)
399                .map(ModelItemType::getId)
400                .map(ModelItemTypeConstants.MULTILINGUAL_STRING_ELEMENT_TYPE_ID::equals)
401                .orElse(false);
402    }
403    
404    /**
405     * Get metadata values by their path
406     * @param contentId The id of content
407     * @param metadataPath The path of metadata to retrieve
408     * @param defaultLocale The default locale to resolve multilingual values if the content's language is null. Can be null.
409     * @return The metadata values
410     */
411    @Callable
412    public Map<String, Object> getAttributeValues(String contentId, Collection<String> metadataPath, Locale defaultLocale)
413    {
414        Content content = _resolver.resolveById(contentId);
415        return getAttributeValues(content, metadataPath, defaultLocale);
416    }
417    
418    /**
419     * Get metadata values by their path
420     * @param content The initial content
421     * @param dataPaths The path of metadata to retrieve, slash-separated.
422     * @param defaultLocale The default locale to resolve multilingual values if the content's language is null. Can be null.
423     * @return The metadata values
424     */
425    public Map<String, Object> getAttributeValues(Content content, Collection<String> dataPaths, Locale defaultLocale)
426    {
427        Map<String, Object> values = new HashMap<>();
428        
429        for (String path : dataPaths)
430        {
431            Object value = getAttributeValue(content, path, defaultLocale);
432            values.put(path, value);
433        }
434        
435        return values;
436    }
437    
438    /**
439     * Get the value(s) of a metadata - transformed into JSONified value - of a content at given path.
440     * 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'.
441     * The returned value is typed.
442     * @param content The content
443     * @param dataPath The path to the metadata, separated by '/'
444     * @param defaultLocale The default locale to resolve multilingual values if the content's language is null. Can be null.
445     * @return The final value.
446     */
447    public Object getAttributeValue(Content content, String dataPath, Locale defaultLocale)
448    {
449        return getAttributeValue(content, dataPath, defaultLocale, false);
450    }
451    
452    /**
453     * Get the value(s) of a metadata - transformed into JSONified value - of a content at given path.
454     * 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'.
455     * @param content The content
456     * @param dataPath The path to the metadata, separated by '/'
457     * @param defaultLocale The default locale to resolve multilingual values if the content's language is null. Can be null.
458     * @param resolveReferences <code>true</code> true to resolve references
459     * @return The final value, transformed for search.
460     */
461    public Object getAttributeValue(Content content, String dataPath, Locale defaultLocale, boolean resolveReferences)
462    {
463        ModelItem modelItem = content.getDefinition(dataPath);
464        return getAttributeValue(content, dataPath, modelItem, defaultLocale, resolveReferences);
465    }
466    
467    /**
468     * Get the value(s) of an attribute - transformed into JSONified value - of a content at given path.
469     * The path can represent a path of an attribute into the content or an attribute on one or more linked contents, such as 'composite/linkedContent/secondContent/composite/attribute'.
470     * @param content The initial content.
471     * @param dataPath The path to the attribute, separated by '/'
472     * @param modelItem The attribute definition.
473     * @param defaultLocale The default locale to resolve multilingual values if the content's language is null. Can be null.
474     * @param resolveReferences <code>true</code> to generate full representation of attribute's values : the references will be resolved and the label of enumerated values will be returned.
475     * @return The final value, transformed for search.
476     */
477    public Object getAttributeValue(Content content, String dataPath, ModelItem modelItem, Locale defaultLocale, boolean resolveReferences)
478    {
479        Locale locale = content.getLanguage() != null ? new Locale(content.getLanguage()) : defaultLocale;
480        if (modelItem instanceof ElementDefinition)
481        {
482            // Get attribute's values allowing multi-valued path segments inside the data path (not necessarily at the last segment)
483            Object rawValues = content.getValue(dataPath, true);
484            
485            // If full, extract content or resource values as Maps. If not, the values are kept as content/resources IDs.
486            // If full, extract enumerated values as Maps of <key,label>. If not, the values are kept as entry IDs.
487            return _transformValue(content, (ElementDefinition) modelItem, rawValues, locale, resolveReferences);
488        }
489        else if (modelItem instanceof org.ametys.plugins.repository.model.RepeaterDefinition)
490        {
491            Object repeater = content.getValue(dataPath, true);
492            return _getRepeaterValues(content, (org.ametys.plugins.repository.model.RepeaterDefinition) modelItem, repeater, locale, resolveReferences);
493        }
494        else
495        {
496            throw new IllegalArgumentException();
497        }
498    }
499    
500    @SuppressWarnings("unchecked")
501    private Map<String, Object> _getRepeaterValues(Content content, org.ametys.plugins.repository.model.RepeaterDefinition repeaterDefinition, Object repeater, Locale locale, boolean full)
502    {
503        if (repeater == null)
504        {
505            return null;
506        }
507        
508        if (repeater instanceof ModelAwareRepeater[])
509        {
510            I18nizableText label = null;
511            String headerLabel = null;
512            List<Object> entries = new ArrayList<>();
513
514            for (ModelAwareRepeater singleRepeater : (ModelAwareRepeater[]) repeater)
515            {
516                Map<String, Object> singleRepeaterValues = _getSingleRepeaterValues(content, repeaterDefinition, singleRepeater, locale, full);
517                if (singleRepeaterValues != null)
518                {
519                    entries.addAll((List<Object>) singleRepeaterValues.get("entries"));
520                    label = (I18nizableText) singleRepeaterValues.get("label");
521                    headerLabel = (String) singleRepeaterValues.get("header-label");
522                }
523            }
524
525            Map<String, Object> repeaterValues = new HashMap<>();
526            repeaterValues.put("entries", entries);
527            repeaterValues.put("label",  label);
528            
529            if (headerLabel != null)
530            {
531                repeaterValues.put("header-label", headerLabel);
532            }
533
534            return repeaterValues;
535        }
536        
537        return _getSingleRepeaterValues(content, repeaterDefinition, (ModelAwareRepeater) repeater, locale, full);
538    }
539    
540    private Map<String, Object> _getSingleRepeaterValues(Content content, org.ametys.plugins.repository.model.RepeaterDefinition repeaterDefinition, ModelAwareRepeater repeater, Locale locale, boolean full)
541    {
542        List<Map<String, Object>> entriesValues = new ArrayList<>();
543        
544        for (ModelAwareRepeaterEntry entry : repeater.getEntries())
545        {
546            Map<String, Object> values = new HashMap<>();
547            for (String dataName : entry.getDataNames())
548            {
549                values.putAll(_getDataHolderValues(content, entry, dataName, dataName, locale, full));
550            }
551                
552            Map<String, Object> entryValues = new HashMap<>();
553            entryValues.put("position", entry.getPosition());
554            entryValues.put("values", values);
555
556            entriesValues.add(entryValues);
557        }
558        
559        Map<String, Object> repeaterValues = new HashMap<>();
560        repeaterValues.put("entries", entriesValues);
561        repeaterValues.put("label", repeaterDefinition.getLabel());
562        String headerLabel = repeaterDefinition.getHeaderLabel();
563        if (headerLabel != null)
564        {
565            repeaterValues.put("header-label", headerLabel);
566        }
567        
568        return repeaterValues;
569    }
570    
571    private Map<String, Object> _getCompositeValues(Content content, ModelAwareComposite composite, String prefix, Locale locale, boolean full)
572    {
573        Map<String, Object> compositeValues = new HashMap<>();
574        
575        for (String dataName : composite.getDataNames())
576        {
577            String dataAbsolutePathFromFirstRepeater = prefix + ModelItem.ITEM_PATH_SEPARATOR + dataName;
578            compositeValues.putAll(_getDataHolderValues(content, composite, dataName, dataAbsolutePathFromFirstRepeater, locale, full));
579        }
580        
581        return compositeValues;
582    }
583    
584    private Map<String, Object> _getDataHolderValues(Content content, ModelAwareDataHolder dataHolder, String dataName, String dataAbsolutePathFromFirstRepeater, Locale locale, boolean full)
585    {
586        Map<String, Object> values = new HashMap<>();
587        
588        ModelItem modelItem = dataHolder.getDefinition(dataName);
589        if (modelItem instanceof ElementDefinition)
590        {
591            Object value = dataHolder.getValue(dataName);
592            Object transformedValue = _transformValue(content, (ElementDefinition) modelItem, value, locale, full);
593            values.put(dataAbsolutePathFromFirstRepeater, transformedValue);
594        }
595        else if (modelItem instanceof org.ametys.plugins.repository.model.RepeaterDefinition)
596        {
597            ModelAwareRepeater subRepeater = dataHolder.getRepeater(dataName);
598            Map<String, Object> subRepeaterValues = _getSingleRepeaterValues(content, (org.ametys.plugins.repository.model.RepeaterDefinition) modelItem, subRepeater, locale, full);
599            values.put(dataAbsolutePathFromFirstRepeater, subRepeaterValues);
600        }
601        else
602        {
603            ModelAwareComposite subComposite = dataHolder.getComposite(dataName);
604            Map<String, Object> subCompositeValues = _getCompositeValues(content, subComposite, dataAbsolutePathFromFirstRepeater, locale, full);
605            values.putAll(subCompositeValues);
606        }
607        
608        return values;
609    }
610    
611    /**
612     * Transform the raw value into a JSONified value, understandable for search UI
613     * @param content the content
614     * @param definition definition of the attribute to transform
615     * @param value The typed values to transform
616     * @param locale Locale to use for localized values if content's language is null.
617     * @param resolveReferences <code>true</code> to generate full representation of attribute's values
618     * @return The transformed values
619     */
620    private Object _transformValue(Content content, ElementDefinition definition, Object value, Locale locale, boolean resolveReferences)
621    {
622        if (value == null)
623        {
624            return null;
625        }
626        
627        if (value.getClass().isArray())
628        {
629            List<Object> transformedValues = new ArrayList<>();
630            for (int i = 0; i < Array.getLength(value); i++)
631            {
632                Object singleValue = Array.get(value, i);
633                Object transformedSingleValue = _transformValue(content, definition, singleValue, locale, resolveReferences);
634                transformedValues.add(transformedSingleValue);
635            }
636            return transformedValues;
637        }
638
639        Object valueAsJSON = _transformSingleValue(content, definition, value, locale, resolveReferences);
640            
641        org.ametys.runtime.model.Enumerator enumerator = definition.getEnumerator();
642        if (resolveReferences && enumerator != null)
643        {
644            Map<String, Object> transformedValue = new HashMap<>();
645            transformedValue.put("value", valueAsJSON);
646            
647            try
648            {
649                @SuppressWarnings("unchecked")
650                I18nizableText label = enumerator.getEntry(value);
651                transformedValue.put("label", label);
652            }
653            catch (Exception e)
654            {
655                transformedValue.put("label", valueAsJSON);
656            }
657            
658            return transformedValue;
659        }
660        else
661        {
662            return valueAsJSON;
663        }
664    }
665
666    @SuppressWarnings("unchecked")
667    private Object _transformSingleValue(Content content, ElementDefinition definition, Object value, Locale locale, boolean resolveReferences)
668    {
669        ElementType type = definition.getType();
670
671        switch (type.getId())
672        {
673            case ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID:
674                return _transformValue((ContentValue) value, locale, resolveReferences);
675            case ModelItemTypeConstants.FILE_ELEMENT_TYPE_ID:
676                return value instanceof ExplorerFile ? _transformValue((ExplorerFile) value, resolveReferences) : _transformValue((Binary) value, definition.getPath(), content.getId());
677            case ModelItemTypeConstants.BINARY_ELEMENT_TYPE_ID:
678                return _transformValue((Binary) value, definition.getPath(), content.getId());
679            case ModelItemTypeConstants.RICH_TEXT_ELEMENT_TYPE_ID:
680                return _transformValue((org.ametys.cms.data.RichText) value);
681            case org.ametys.runtime.model.type.ModelItemTypeConstants.DATE_TYPE_ID:
682                return _transformValue((LocalDate) value);
683            case org.ametys.runtime.model.type.ModelItemTypeConstants.DATETIME_TYPE_ID:
684                return _transformValue((ZonedDateTime) value);
685            case ModelItemTypeConstants.USER_ELEMENT_TYPE_ID:
686                return _transformValue((UserIdentity) value);
687            case ModelItemTypeConstants.MULTILINGUAL_STRING_ELEMENT_TYPE_ID:
688                return _transformValue((MultilingualString) value, type, locale, resolveReferences);
689            default:
690                return type.valueToJSONForClient(value);
691        }
692    }
693    
694    private Object _transformValue(ContentValue contentValue, Locale locale, boolean resolveReferences)
695    {
696        if (resolveReferences)
697        {
698            Map<String, Object> info = new HashMap<>();
699            info.put("id", contentValue.getContentId());
700
701            Optional<? extends Content> optionalContent = contentValue.getContentIfExists();
702            if (!optionalContent.isEmpty())
703            {
704                Content value = optionalContent.get();
705                info.put("title", value.getTitle(locale));
706                info.put("isSimple", _contentHelper.isSimple(value));
707            }
708            
709            return info;
710        }
711        else
712        {
713            return contentValue.getContentId();
714        }
715    }
716    
717    private Object _transformValue(ExplorerFile file, boolean resolveReferences)
718    {
719        
720        if (resolveReferences)
721        {
722            Map<String, Object> info = new HashMap<>();
723            
724            info.put("type", "explorer");
725            info.put("id", file.getResourceId());
726            info.put("viewUrl", ResolveURIComponent.resolve("explorer", file.getResourceId(), false));
727            info.put("downloadUrl", ResolveURIComponent.resolve("explorer", file.getResourceId(), true));
728            
729            info.putAll(_getFileTransformedInfo(file));
730
731            return info;
732        }
733        else
734        {
735            return file.getResourceId();
736        }
737        
738    }
739    
740    private Map<String, Object> _transformValue(Binary binary, String attributePath, String contentId)
741    {
742        Map<String, Object> info = new HashMap<>();
743        
744        info.put("type", "metadata");
745        info.put("id", EditContentFunction.UNTOUCHED_BINARY);
746        info.put("viewUrl", ResolveURIComponent.resolve("metadata", attributePath + "?objectId=" + contentId, false));
747        info.put("downloadUrl", ResolveURIComponent.resolve("metadata", attributePath + "?objectId=" + contentId, true));
748        
749        info.putAll(_getFileTransformedInfo(binary));
750        
751        return info;
752    }
753    
754    private Map<String, Object> _getFileTransformedInfo(File file)
755    {
756        Map<String, Object> info = new HashMap<>();
757        
758        info.put("filename", file.getName());
759        info.put("mime-type", file.getMimeType());
760        info.put("size", file.getLength());
761        info.put("lastModified", file.getLastModificationDate());
762        
763        return info;
764    }
765    
766    private Map<String, Object> _transformValue(org.ametys.cms.data.RichText richText)
767    {
768        Map<String, Object> info = new HashMap<>();
769        
770        info.put("type", "metadata");
771        info.put("mime-type", richText.getMimeType());
772        info.put("size", richText.getLength());
773        info.put("lastModified", richText.getLastModificationDate());
774
775        SAXParser saxParser = null;
776        try (InputStream is = richText.getInputStream())
777        {
778            RichTextHandler txtHandler = new RichTextHandler(100);
779            saxParser = (SAXParser) _manager.lookup(SAXParser.ROLE);
780            saxParser.parse(new InputSource(is), txtHandler);
781            
782            String excerpt = txtHandler.getValue();
783            info.put("content", excerpt);
784        }
785        catch (Exception e)
786        {
787            getLogger().error("Cannot extract excerpt of a RichText value ", e);
788            info.put("content", "");
789        }
790        finally
791        {
792            _manager.release(saxParser);
793        }
794        
795        return info;
796    }
797    
798    private String _transformValue(LocalDate date)
799    {
800        ZonedDateTime zdt = date.atStartOfDay(ZoneId.systemDefault());
801        return _transformValue(zdt);
802    }
803    
804    private String _transformValue(ZonedDateTime dateTime)
805    {
806        return DateUtils.zonedDateTimeToString(dateTime);
807    }
808    
809    private Object _transformValue(UserIdentity user)
810    {
811        return _userHelper.user2json(user, true);
812    }
813    
814    private Object _transformValue(MultilingualString multilingualString, ElementType<MultilingualString> type, Locale locale, boolean resolveReferences)
815    {
816        return resolveReferences ? type.valueToJSONForClient(multilingualString) : MultilingualStringHelper.getValue(multilingualString, locale);
817    }
818    
819}