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