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.util.ArrayList;
019import java.util.Collection;
020import java.util.HashMap;
021import java.util.List;
022import java.util.Locale;
023import java.util.Map;
024import java.util.Objects;
025import java.util.Optional;
026import java.util.Set;
027import java.util.stream.Collectors;
028
029import org.apache.avalon.framework.component.Component;
030import org.apache.avalon.framework.context.Context;
031import org.apache.avalon.framework.context.ContextException;
032import org.apache.avalon.framework.context.Contextualizable;
033import org.apache.avalon.framework.service.ServiceException;
034import org.apache.avalon.framework.service.ServiceManager;
035import org.apache.avalon.framework.service.Serviceable;
036import org.apache.cocoon.components.ContextHelper;
037import org.apache.commons.lang3.StringUtils;
038
039import org.ametys.cms.contenttype.ContentType;
040import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
041import org.ametys.cms.contenttype.ContentTypesHelper;
042import org.ametys.cms.data.type.ModelItemTypeConstants;
043import org.ametys.cms.data.type.indexing.IndexableElementType;
044import org.ametys.cms.data.type.indexing.SortableIndexableElementType;
045import org.ametys.cms.model.ContentElementDefinition;
046import org.ametys.cms.model.properties.Property;
047import org.ametys.cms.repository.Content;
048import org.ametys.cms.search.SortOrder;
049import org.ametys.cms.search.cocoon.SearchAction;
050import org.ametys.cms.search.content.ContentSearcherFactory.ContentSearchSort;
051import org.ametys.cms.search.model.CriterionDefinition;
052import org.ametys.cms.search.model.CriterionDefinitionAwareElementDefinition;
053import org.ametys.cms.search.model.IndexationAwareElementDefinition;
054import org.ametys.cms.search.model.SearchModel;
055import org.ametys.cms.search.model.SystemPropertyExtensionPoint;
056import org.ametys.cms.search.model.impl.ReferencingCriterionDefinition;
057import org.ametys.cms.search.solr.SearcherFactory.FacetDefinition;
058import org.ametys.cms.search.solr.SearcherFactory.Searcher;
059import org.ametys.cms.search.solr.SearcherFactory.SortDefinition;
060import org.ametys.runtime.model.ElementDefinition;
061import org.ametys.runtime.model.ModelHelper;
062import org.ametys.runtime.model.ModelItem;
063import org.ametys.runtime.model.ModelItemAccessor;
064import org.ametys.runtime.model.ModelViewItem;
065import org.ametys.runtime.model.View;
066import org.ametys.runtime.model.ViewElement;
067import org.ametys.runtime.model.ViewItem;
068import org.ametys.runtime.model.ViewItemAccessor;
069import org.ametys.runtime.model.exception.BadItemTypeException;
070import org.ametys.runtime.model.exception.UndefinedItemPathException;
071import org.ametys.runtime.model.type.DataContext;
072import org.ametys.runtime.model.type.ModelItemType;
073import org.ametys.runtime.plugin.component.AbstractLogEnabled;
074
075/**
076 * Component which helps content searching by providing a simple way to access
077 * content properties (either attributes or properties).
078 */
079public class ContentSearchHelper extends AbstractLogEnabled implements Component, Serviceable, Contextualizable
080{
081    /** The component role. */
082    public static final String ROLE = ContentSearchHelper.class.getName();
083    
084    /** The content type extension point. */
085    private ContentTypeExtensionPoint _contentTypeExtensionPoint;
086    
087    /** The system property extension point. */
088    private SystemPropertyExtensionPoint _systemPropertyExtensionPoint;
089    
090    /** Content Types helper */
091    private ContentTypesHelper _contentTypesHelper;
092    
093    /** The avalon context */
094    private Context _context;
095
096    @Override
097    public void service(ServiceManager manager) throws ServiceException
098    {
099        _contentTypeExtensionPoint = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
100        _systemPropertyExtensionPoint = (SystemPropertyExtensionPoint) manager.lookup(SystemPropertyExtensionPoint.ROLE);
101        _contentTypesHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE);
102    }
103    
104    public void contextualize(Context context) throws ContextException
105    {
106        _context = context;
107    }
108    
109    /**
110     * Transform the given {@link ContentSearchSort}s to compute joins and get {@link SortDefinition}
111     * @param contentSearcherSorts the sorts to transform
112     * @param contentTypeIds the identifiers of the content types where to search the field
113     * @return the sorts with computed joins to give to the {@link Searcher}
114     * @throws IllegalArgumentException if one of the given sort has not been found in the given content types
115     */
116    public List<SortDefinition> transformContentSearcherSorts(List<ContentSearchSort> contentSearcherSorts, Set<String> contentTypeIds) throws IllegalArgumentException
117    {
118        List<SortDefinition> sort = new ArrayList<>();
119        for (ContentSearchSort contentSearcherSort : contentSearcherSorts)
120        {
121            try
122            {
123                SortDefinition sortDefinition = getSortDefinition(contentSearcherSort.sortField(), contentTypeIds, contentSearcherSort.order());
124                if (sortDefinition != null)
125                {
126                    sort.add(sortDefinition);
127                }
128                else
129                {
130                    getLogger().warn("The field '{}' is not sortable. The search will be executed, but without the sort on this field.", contentSearcherSort.sortField());
131                }
132            }
133            catch (UndefinedItemPathException | BadItemTypeException e)
134            {
135                throw new IllegalArgumentException("The field '" + contentSearcherSort.sortField() + "' can't be found in the following content types " + StringUtils.join(contentTypeIds, ",") + ".", e);
136            }
137        }
138
139        return sort;
140    }
141    
142    /**
143     * Retrieves the sort definition for the reference at the given path.
144     * @param referencePath the reference path
145     * @param contentTypeIds the identifiers of content types defining the reference 
146     * @param order The sort order
147     * @return the sort field name for the reference at the given path.
148     * @throws UndefinedItemPathException if there is no item defined at the given path in given content types
149     * @throws BadItemTypeException if the definition of a part of the item path is not an item accessor
150     */
151    public SortDefinition getSortDefinition(String referencePath, Set<String> contentTypeIds, SortOrder order) throws UndefinedItemPathException, BadItemTypeException
152    {
153        ElementDefinition reference = getReferenceFromFieldPath(referencePath, contentTypeIds);
154        if (!_isModelItemSortable(reference))
155        {
156            return null;
157        }
158        
159        JoinedPaths joinedPaths = computeJoinedPaths(referencePath, contentTypeIds);
160        String sortFieldName = joinedPaths.lastSegmentPrefix() + _getModelItemSortFieldName(reference);
161        
162        return new SortDefinition(sortFieldName, joinedPaths.joinedPaths(), order);
163    }
164    
165    private boolean _isModelItemSortable(ModelItem modelItem)
166    {
167        return modelItem instanceof IndexationAwareElementDefinition indexationAwareElementDefinition
168                ? indexationAwareElementDefinition.isSortable()
169                : modelItem instanceof Property
170                    ? false
171                    : modelItem.getType() instanceof SortableIndexableElementType;
172    }
173    
174    private String _getModelItemSortFieldName(ModelItem modelItem)
175    {
176        return modelItem instanceof IndexationAwareElementDefinition indexationAwareElementDefinition
177                ? indexationAwareElementDefinition.getSolrSortFieldName()
178                : modelItem instanceof Property
179                    ? null
180                    : modelItem.getName() + ((SortableIndexableElementType) modelItem.getType()).getSortFieldSuffix(_getDataContext(modelItem));
181    }
182    
183    /**
184     * Retrieves the facet definition for the reference at the given path.
185     * @param referencePath the reference path
186     * @param contentTypeIds the identifiers of content types defining the reference
187     * @return the facet definition for the reference at the given path.
188     * @throws UndefinedItemPathException if there is no item defined at the given path in given content types
189     * @throws BadItemTypeException if the definition of a part of the item path is not an item accessor
190     */
191    public FacetDefinition getFacetDefinition(String referencePath, Set<String> contentTypeIds) throws UndefinedItemPathException, BadItemTypeException
192    {
193        List<FacetDefinition> facetDefinitions = getFacetDefinitions(List.of(referencePath), contentTypeIds);
194        return facetDefinitions.isEmpty() ? null : facetDefinitions.get(0);
195    }
196    
197    /**
198     * Retrieves the facet definitions for the references at the given paths.
199     * @param referencedPaths the references paths
200     * @param contentTypeIds the identifiers of content types defining the references
201     * @return the facet definitions for the references at the given paths.
202     */
203    @SuppressWarnings("unchecked")
204    public List<FacetDefinition> getFacetDefinitions(List<String> referencedPaths, Set<String> contentTypeIds)
205    {
206        View facetedCriteria = new View();
207        for (String facetFieldPath : referencedPaths)
208        {
209            try
210            {
211                ElementDefinition reference = getReferenceFromFieldPath(facetFieldPath, contentTypeIds);
212                if (_isModelItemFacetable(reference))
213                {
214                    ReferencingCriterionDefinition criterion = new ReferencingCriterionDefinition(reference, facetFieldPath);
215                    criterion.setName(facetFieldPath);
216                    criterion.setContentTypeIds(contentTypeIds);
217                    
218                    ModelViewItem modelViewItem = new ViewElement();
219                    modelViewItem.setDefinition(criterion);
220                    
221                    facetedCriteria.addViewItem(modelViewItem);
222                }
223            }
224            catch (UndefinedItemPathException | BadItemTypeException e)
225            {
226                throw new IllegalArgumentException("The field '" + facetFieldPath + "' can't be found in the following content types " + StringUtils.join(contentTypeIds, ",") + ".", e);
227            }
228        }
229        
230        return _getFacetDefinitions(facetedCriteria, new HashMap<>());
231    }
232    
233    private boolean _isModelItemFacetable(ModelItem modelItem)
234    {
235        if (modelItem instanceof Property)
236        {
237            return modelItem instanceof CriterionDefinitionAwareElementDefinition
238                    && modelItem instanceof IndexationAwareElementDefinition indexationAwareElementDefinition && indexationAwareElementDefinition.isFacetable();
239        }
240        else
241        {
242            return ((IndexableElementType) modelItem.getType()).isFacetable(_getDataContext(modelItem));
243        }
244    }
245    
246    /**
247     * Retrieves the facet definitions for the given search model
248     * @param searchModel the search model
249     * @param contextualParameters the contextual parameters
250     * @return the facet definitions for the given search model
251     */
252    public List<FacetDefinition> getFacetDefinitions(SearchModel searchModel, Map<String, Object> contextualParameters)
253    {
254        ViewItemAccessor facetedCriteria = searchModel.getFacetedCriteria(contextualParameters);
255        return _getFacetDefinitions(facetedCriteria, contextualParameters);
256    }
257    
258    @SuppressWarnings("unchecked")
259    private List<FacetDefinition> _getFacetDefinitions(ViewItemAccessor viewItemAccessor, Map<String, Object> contextualParameters)
260    {
261        List<FacetDefinition> facetDefinitions = new ArrayList<>();
262        
263        for (ViewItem viewItem : viewItemAccessor.getViewItems())
264        {
265            if (viewItem instanceof ModelViewItem modelViewItem
266                  && modelViewItem.getDefinition() instanceof CriterionDefinition criterion)
267            {
268                String solrFacetFieldName = criterion.getSolrFacetFieldName(contextualParameters);
269                if (solrFacetFieldName != null)
270                {
271                    String facetId = criterion.getName();
272                    List<String> joinedPaths = criterion.getJoinedPaths(contextualParameters);
273                    facetDefinitions.add(new FacetDefinition(facetId, solrFacetFieldName, joinedPaths));
274                }
275            }
276
277            if (viewItem instanceof ViewItemAccessor itemAccessor)
278            {
279                facetDefinitions.addAll(_getFacetDefinitions(itemAccessor, contextualParameters));
280            }
281        }
282        
283        return facetDefinitions;
284    }
285    
286    private DataContext _getDataContext(ModelItem modelItem)
287    {
288        Locale locale = Optional.ofNullable(_context)
289                                .map(c -> {
290                                    try
291                                    {
292                                        return ContextHelper.getRequest(c);
293                                    }
294                                    catch (RuntimeException e)
295                                    {
296                                        return null;
297                                    }
298                                })
299                                .filter(Objects::nonNull)
300                                .map(r -> r.getAttribute(SearchAction.SEARCH_LOCALE))
301                                .filter(Locale.class::isInstance)
302                                .map(Locale.class::cast)
303                                .orElse(null);
304        
305        return DataContext.newInstance()
306                          .withModelItem(modelItem)
307                          .withLocale(locale);
308    }
309    
310    /**
311     * Retrieves the reference with the given path
312     * @param referencePath the reference's path
313     * @param contentTypeIds the identifiers of the content types defining the reference
314     * @return the reference at the given path
315     * @throws UndefinedItemPathException if there is no model item in the given content types for the given path
316     * @throws BadItemTypeException if the model item at the given path is not an element
317     */
318    public ElementDefinition getReferenceFromFieldPath(String referencePath, Set<String> contentTypeIds) throws UndefinedItemPathException
319    {
320        Set<ContentType> contentTypes = contentTypeIds.stream()
321                                                      .map(_contentTypeExtensionPoint::getExtension)
322                                                      .collect(Collectors.toSet());
323        
324        return _getReferenceFromFieldPath(referencePath, contentTypes);
325    }
326    
327    private ElementDefinition _getReferenceFromFieldPath(String referencePath, Collection<ContentType> contentTypes) throws UndefinedItemPathException
328    {
329        String fieldName = referencePath.contains(ModelItem.ITEM_PATH_SEPARATOR) ? StringUtils.substringAfterLast(referencePath, ModelItem.ITEM_PATH_SEPARATOR) : referencePath;
330        ModelItem modelItem = _systemPropertyExtensionPoint.hasExtension(fieldName)
331                ? _systemPropertyExtensionPoint.getExtension(fieldName)
332                : !contentTypes.isEmpty()
333                        ? ModelHelper.getModelItem(referencePath, contentTypes)
334                        :  referencePath.equals(Content.ATTRIBUTE_TITLE)
335                                ? _contentTypesHelper.getTitleAttributeDefinition()
336                                : null;
337        
338        if (modelItem == null)
339        {
340            throw new UndefinedItemPathException("Unable to retrieve the reference at path '" + referencePath + "' in given content types");
341        }
342        
343        if (!(modelItem instanceof ElementDefinition reference))
344        {
345            throw new BadItemTypeException("Unable to retrieve reference at path '" + referencePath + "'. It does not references an element but a group");
346        }
347        
348        return reference;
349    }
350    
351    /**
352     * Retrieves the join paths from the given data path
353     * @param dataPath the data path
354     * @param contentTypeIds the ids of the content types containing the items of the given path
355     * @return the join paths
356     * @throws UndefinedItemPathException if there is no item defined at the given path in content types
357     * @throws BadItemTypeException if the definition of a part of the data path is not an item accessor
358     */
359    public JoinedPaths computeJoinedPaths(String dataPath, Set<String> contentTypeIds) throws UndefinedItemPathException, BadItemTypeException
360    {
361        Set<ContentType> contentTypes = contentTypeIds.stream()
362                                                      .map(_contentTypeExtensionPoint::getExtension)
363                                                      .collect(Collectors.toSet());
364        
365        return _computeJoinedPaths(dataPath, StringUtils.EMPTY, contentTypes);
366    }
367    
368    /**
369     * Retrieves the join paths from the given data path
370     * @param dataPath the data path
371     * @param prefix the prefix of current join path
372     * @param modelItemAccessors the model items to access the items of the given path
373     * @return the join paths
374     * @throws UndefinedItemPathException if there is no item defined at the given path in given item accessors
375     * @throws BadItemTypeException if the definition of a part of the data path is not an item accessor
376     */
377    private JoinedPaths _computeJoinedPaths(String dataPath, String prefix, Collection<? extends ModelItemAccessor> modelItemAccessors) throws UndefinedItemPathException, BadItemTypeException
378    {
379        String[] pathSegments = StringUtils.split(dataPath, ModelItem.ITEM_PATH_SEPARATOR);
380        if (pathSegments == null || pathSegments.length < 1)
381        {
382            throw new IllegalArgumentException("Unable to retrieve the data at the given path. This path is empty.");
383        }
384        else if (pathSegments.length == 1)
385        {
386            return new JoinedPaths(new ArrayList<>(), prefix);
387        }
388        else
389        {
390            String subDataPath = StringUtils.join(pathSegments, ModelItem.ITEM_PATH_SEPARATOR, 1, pathSegments.length);
391            ModelItem modelItem = ModelHelper.getModelItem(pathSegments[0], modelItemAccessors);
392            if (modelItem instanceof ContentElementDefinition contentElementDefinition)
393            {
394                JoinedPaths joinPaths = _computeJoinedPaths(subDataPath, StringUtils.EMPTY, List.of(contentElementDefinition));
395                joinPaths.joinedPaths().addFirst(prefix + pathSegments[0]);
396                return joinPaths;
397            }
398            else if (modelItem instanceof org.ametys.plugins.repository.model.RepeaterDefinition repeaterDefinition)
399            {
400                JoinedPaths joinPaths = _computeJoinedPaths(subDataPath, StringUtils.EMPTY, List.of(repeaterDefinition));
401                joinPaths.joinedPaths().addFirst(prefix + pathSegments[0]);
402                return joinPaths;
403            }
404            else if (modelItem instanceof ModelItemAccessor modelItemAccessor)
405            {
406                return _computeJoinedPaths(subDataPath, pathSegments[0] + ModelItem.ITEM_PATH_SEPARATOR, List.of(modelItemAccessor));
407            }
408            else
409            {
410                throw new BadItemTypeException("Unable to compute join paths for '" + dataPath + "'. The first segment of this path should be a group or a content element");
411            }
412        }
413    }
414    
415    /**
416     * Determines if the given model item represents an attribute of type content with contents with multilingual titles
417     * @param modelItem The model item
418     * @return <code>true</code> if the given model item represents an attribute of type content with contents with multilingual titles
419     */
420    public boolean isTitleMultilingual(ModelItem modelItem)
421    {
422        return Optional.ofNullable(modelItem)
423                .filter(ContentElementDefinition.class::isInstance)
424                .map(ContentElementDefinition.class::cast)
425                .map(ContentElementDefinition::getContentTypeId)
426                .map(_contentTypeExtensionPoint::getExtension)
427                .filter(cType -> cType.hasModelItem(Content.ATTRIBUTE_TITLE))
428                .map(cType -> cType.getModelItem(Content.ATTRIBUTE_TITLE))
429                .map(ModelItem::getType)
430                .map(ModelItemType::getId)
431                .map(ModelItemTypeConstants.MULTILINGUAL_STRING_ELEMENT_TYPE_ID::equals)
432                .orElse(false);
433    }
434    
435    /**
436     * Record representing the computed joined paths from a reference 
437     * @param joinedPaths the joined path
438     * @param lastSegmentPrefix the prefix of the las segment (used if the reference is in a composite)
439     */
440    public record JoinedPaths(List<String> joinedPaths, String lastSegmentPrefix) { /* empty */ }
441}