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