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