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