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.List;
021import java.util.Optional;
022import java.util.Set;
023import java.util.stream.Collectors;
024
025import org.apache.avalon.framework.component.Component;
026import org.apache.avalon.framework.context.Context;
027import org.apache.avalon.framework.context.ContextException;
028import org.apache.avalon.framework.context.Contextualizable;
029import org.apache.avalon.framework.service.ServiceException;
030import org.apache.avalon.framework.service.ServiceManager;
031import org.apache.avalon.framework.service.Serviceable;
032import org.apache.commons.lang3.StringUtils;
033
034import org.ametys.cms.contenttype.ContentAttributeDefinition;
035import org.ametys.cms.contenttype.ContentType;
036import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
037import org.ametys.cms.contenttype.ContentTypesHelper;
038import org.ametys.cms.contenttype.MetadataType;
039import org.ametys.cms.data.type.ModelItemTypeConstants;
040import org.ametys.cms.model.ContentElementDefinition;
041import org.ametys.cms.repository.Content;
042import org.ametys.cms.search.SearchField;
043import org.ametys.cms.search.model.SystemProperty;
044import org.ametys.cms.search.model.SystemPropertyExtensionPoint;
045import org.ametys.cms.search.solr.field.BooleanSearchField;
046import org.ametys.cms.search.solr.field.ContentSearchField;
047import org.ametys.cms.search.solr.field.DateSearchField;
048import org.ametys.cms.search.solr.field.DoubleSearchField;
049import org.ametys.cms.search.solr.field.JoinedSystemSearchField;
050import org.ametys.cms.search.solr.field.LongSearchField;
051import org.ametys.cms.search.solr.field.MultilingualStringSearchField;
052import org.ametys.cms.search.solr.field.StringSearchField;
053import org.ametys.plugins.repository.model.RepeaterDefinition;
054import org.ametys.runtime.model.ModelHelper;
055import org.ametys.runtime.model.ModelItem;
056import org.ametys.runtime.model.ModelItemAccessor;
057import org.ametys.runtime.model.ModelItemGroup;
058import org.ametys.runtime.model.ModelViewItem;
059import org.ametys.runtime.model.ViewItem;
060import org.ametys.runtime.model.ViewItemAccessor;
061import org.ametys.runtime.model.exception.UndefinedItemPathException;
062import org.ametys.runtime.model.type.ModelItemType;
063import org.ametys.runtime.plugin.component.AbstractLogEnabled;
064
065/**
066 * Component which helps content searching by providing a simple way to access
067 * content properties (either attributes or properties).
068 */
069public class ContentSearchHelper extends AbstractLogEnabled implements Component, Serviceable, Contextualizable
070{
071    /** The component role. */
072    public static final String ROLE = ContentSearchHelper.class.getName();
073    
074    /** The content type extension point. */
075    private ContentTypeExtensionPoint _cTypeEP;
076    
077    /** The content type helper. */
078    private ContentTypesHelper _cTypeHelper;
079    
080    /** The system property extension point. */
081    private SystemPropertyExtensionPoint _sysPropEP;
082    
083    /** Content Types helper */
084    private ContentTypesHelper _contentTypesHelper;
085
086    private Context _context;
087    
088    @Override
089    public void contextualize(Context context) throws ContextException
090    {
091        _context = context;
092    }
093    
094    @Override
095    public void service(ServiceManager manager) throws ServiceException
096    {
097        _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
098        _cTypeHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE);
099        _sysPropEP = (SystemPropertyExtensionPoint) manager.lookup(SystemPropertyExtensionPoint.ROLE);
100        _contentTypesHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE);
101    }
102    
103    /**
104     * Get a {@link SearchField} corresponding to a metadata.
105     * @param joinPaths The join paths
106     * @param metadataPath The metadata path.
107     * @param metadataType The metadata type.
108     * @param isTypeContentWithMultilingualTitle <code>true</code> if the type is Content and the linked contents have multilingual titles
109     * @return the search field.
110     */
111    public SearchField getMetadataSearchField(List<String> joinPaths, String metadataPath, MetadataType metadataType, boolean isTypeContentWithMultilingualTitle)
112    {
113        switch (metadataType)
114        {
115            case STRING:
116            case USER:
117            case REFERENCE:
118                return new StringSearchField(joinPaths, metadataPath);
119            case CONTENT:
120            case SUB_CONTENT:
121                return new ContentSearchField(joinPaths, metadataPath, isTypeContentWithMultilingualTitle, Optional.ofNullable(_context));
122            case LONG:
123                return new LongSearchField(joinPaths, metadataPath);
124            case DOUBLE:
125                return new DoubleSearchField(joinPaths, metadataPath);
126            case BOOLEAN:
127                return new BooleanSearchField(joinPaths, metadataPath);
128            case DATE:
129            case DATETIME:
130                return new DateSearchField(joinPaths, metadataPath);
131            case MULTILINGUAL_STRING:
132                return new MultilingualStringSearchField(joinPaths, metadataPath, Optional.ofNullable(_context));
133            case COMPOSITE:
134            case BINARY:
135            case FILE:
136            case RICH_TEXT:
137            default:
138                return null;
139        }
140    }
141    
142    /**
143     * Get a {@link SearchField} from a field name in a batch of content types.
144     * @param contentTypes The content types, can be empty to search on any content type.
145     * In that case, only the title metadata and system properties will be usable in sort and facets specs.
146     * @param fieldPath The field path, can be either a system property ID or a indexing field name or path.
147     * @return The {@link SearchField} corresponding to the field path, or an {@link Optional#empty() empty optional} if not found
148     */
149    public Optional<SearchField> getSearchField(Collection<String> contentTypes, String fieldPath)
150    {
151        // Root system property
152        if (!fieldPath.contains(ModelItem.ITEM_PATH_SEPARATOR) && _sysPropEP.hasExtension(fieldPath))
153        {
154            return Optional.ofNullable(_sysPropEP.getExtension(fieldPath).getSearchField());
155        }
156        
157        Set<String> commonContentTypeIds = _cTypeHelper.getCommonAncestors(contentTypes);
158        if (!commonContentTypeIds.isEmpty())
159        {
160            try
161            {
162                List<String> joinPaths = computeJoinPaths(fieldPath, commonContentTypeIds, false);
163
164                String fieldName = fieldPath.contains(ModelItem.ITEM_PATH_SEPARATOR) ? StringUtils.substringAfterLast(fieldPath, ModelItem.ITEM_PATH_SEPARATOR) : fieldPath;
165                if (_sysPropEP.hasExtension(fieldName))
166                {
167                    return Optional.of(new JoinedSystemSearchField(joinPaths, _sysPropEP.getExtension(fieldName).getSearchField()));
168                }
169                else
170                {
171                    Collection<ContentType> commonContentTypes = commonContentTypeIds.stream()
172                                                                                     .map(_cTypeEP::getExtension)
173                                                                                     .collect(Collectors.toList());
174                    ModelItem modelItem = ModelHelper.getModelItem(fieldPath, commonContentTypes);
175                    return Optional.of(_getModelItemSearchField(joinPaths, modelItem, isTitleMultilingual(modelItem)));
176                }
177            }
178            catch (UndefinedItemPathException e)
179            {
180                throw new IllegalArgumentException("Search field with path '" + fieldPath + "' refers to an unknown attribute or property", e);
181            }
182        }
183        else if (fieldPath.equals(Content.ATTRIBUTE_TITLE))
184        {
185            // No specific content type: allow only title.
186            ModelItem titleDefinition = _contentTypesHelper.getTitleAttributeDefinition();
187            return Optional.of(_getModelItemSearchField(null, titleDefinition, false));
188        }
189        
190        return Optional.empty();
191    }
192    
193    /**
194     * Retrieves the join paths from the given data path
195     * @param dataPath the data path
196     * @param contentTypeIds the ids of the content types containing the items of the given path
197     * @param addLast <code>true</code> to add the last join path element to the list, <code>false</code> otherwise.
198     * @return the join paths
199     * @throws UndefinedItemPathException if there is no item defined at the given path in given item accessors
200     */
201    public List<String> computeJoinPaths(String dataPath, Set<String> contentTypeIds, boolean addLast) throws UndefinedItemPathException
202    {
203        Collection<ContentType> contentTypes = contentTypeIds.stream()
204                .map(_cTypeEP::getExtension)
205                .collect(Collectors.toList());
206        
207        return _computeJoinPaths(dataPath, StringUtils.EMPTY, contentTypes, addLast);
208    }
209    
210    /**
211     * Retrieves the join paths from the given data path
212     * @param dataPath the data path
213     * @param prefix the prefix of current join path
214     * @param modelItemAccessor the model item to access the items of the given path
215     * @param addLast <code>true</code> to add the last join path element to the list, <code>false</code> otherwise.
216     * @return the join paths
217     * @throws UndefinedItemPathException if there is no item defined at the given path in given item accessor
218     */
219    protected List<String> _computeJoinPaths(String dataPath, String prefix, ModelItemAccessor modelItemAccessor, boolean addLast) throws UndefinedItemPathException
220    {
221        return _computeJoinPaths(dataPath, prefix, List.of(modelItemAccessor), addLast);
222    }
223    
224    /**
225     * Retrieves the join paths from the given data path
226     * @param dataPath the data path
227     * @param prefix the prefix of current join path
228     * @param modelItemAccessors the model items to access the items of the given path
229     * @param addLast <code>true</code> to add the last join path element to the list, <code>false</code> otherwise.
230     * @return the join paths
231     * @throws UndefinedItemPathException if there is no item defined at the given path in given item accessors
232     */
233    protected List<String> _computeJoinPaths(String dataPath, String prefix, Collection<? extends ModelItemAccessor> modelItemAccessors, boolean addLast) throws UndefinedItemPathException
234    {
235        String[] pathSegments = StringUtils.split(dataPath, ModelItem.ITEM_PATH_SEPARATOR);
236        if (pathSegments == null || pathSegments.length < 1)
237        {
238            throw new IllegalArgumentException("Unable to retrieve the data at the given path. This path is empty.");
239        }
240        else if (pathSegments.length == 1)
241        {
242            return addLast ? List.of(prefix + dataPath) : List.of();
243        }
244        else
245        {
246            List<String> joinPaths = new ArrayList<>();
247            String subDataPath = StringUtils.join(pathSegments, ModelItem.ITEM_PATH_SEPARATOR, 1, pathSegments.length);
248            ModelItem modelItem = ModelHelper.getModelItem(pathSegments[0], modelItemAccessors);
249            if (modelItem instanceof ContentElementDefinition contentElementDefinition)
250            {
251                joinPaths.add(prefix + pathSegments[0]);
252                joinPaths.addAll(_computeJoinPaths(subDataPath, StringUtils.EMPTY, contentElementDefinition, addLast));
253            }
254            else if (modelItem instanceof org.ametys.plugins.repository.model.RepeaterDefinition repeaterDefinition)
255            {
256                joinPaths.add(prefix + pathSegments[0]);
257                joinPaths.addAll(_computeJoinPaths(subDataPath, StringUtils.EMPTY, repeaterDefinition, addLast));
258            }
259            else if (modelItem instanceof ModelItemAccessor modelItemAccessor)
260            {
261                joinPaths.addAll(_computeJoinPaths(subDataPath, pathSegments[0] + ModelItem.ITEM_PATH_SEPARATOR, modelItemAccessor, addLast));
262            }
263            
264            return joinPaths;
265        }
266    }
267    
268    /**
269     * Get a {@link SearchField} from a model view item.
270     * @param modelViewItem The model view item
271     * @return The {@link SearchField} corresponding to given model view item
272     */
273    public SearchField getSearchField(ModelViewItem modelViewItem)
274    {
275        List<String> joinPaths = _computeJoinPaths(modelViewItem);
276        ModelItem modelItem = modelViewItem.getDefinition();
277        
278        if (modelItem instanceof SystemProperty systemProperty)
279        {
280            SearchField systemPropertySearchField = systemProperty.getSearchField();
281            if (joinPaths.isEmpty())
282            {
283                return systemPropertySearchField;
284            }
285            else
286            {
287                return new JoinedSystemSearchField(joinPaths, systemPropertySearchField);
288            }
289        }
290        else
291        {
292            return _getModelItemSearchField(joinPaths, modelItem, false);
293        }
294    }
295    
296    /**
297     * Retrieves the join paths from the given view item
298     * @param viewItem the view item
299     * @return the join paths
300     */
301    public List<String> _computeJoinPaths(ViewItem viewItem)
302    {
303        List<String> joinPaths = new ArrayList<>();
304        String currentJoinPath = StringUtils.EMPTY;
305
306        ViewItemAccessor parent = viewItem.getParent();
307        while (parent != null)
308        {
309            if (parent instanceof ModelViewItem parentModelViewItem)
310            {
311                ModelItem modelItem = parentModelViewItem.getDefinition();
312                if (modelItem instanceof ContentElementDefinition || modelItem instanceof org.ametys.plugins.repository.model.RepeaterDefinition)
313                {
314                    if (StringUtils.isNotEmpty(currentJoinPath))
315                    {
316                        joinPaths.add(0, currentJoinPath);
317                    }
318                    currentJoinPath = modelItem.getName();
319                }
320                else if (StringUtils.isNotEmpty(currentJoinPath))
321                {
322                    currentJoinPath = modelItem.getName() + ModelItem.ITEM_PATH_SEPARATOR + currentJoinPath;
323                }
324            }
325            
326            parent = parent instanceof ViewItem parentViewItem ? parentViewItem.getParent() : null;
327            if (parent == null && StringUtils.isNotEmpty(currentJoinPath))
328            {
329                joinPaths.add(0, currentJoinPath);
330            }
331        }
332            
333        return joinPaths;
334    }
335    
336    /**
337     * Determines if the given model item represents an attribute of type content with contents with multilingual titles
338     * @param modelItem The model item
339     * @return <code>true</code> if the given model item represents an attribute of type content with contents with multilingual titles
340     */
341    public boolean isTitleMultilingual(ModelItem modelItem)
342    {
343        return Optional.ofNullable(modelItem)
344                .filter(ContentAttributeDefinition.class::isInstance)
345                .map(ContentAttributeDefinition.class::cast)
346                .map(ContentAttributeDefinition::getContentTypeId)
347                .map(_cTypeEP::getExtension)
348                .filter(cType -> cType.hasModelItem(Content.ATTRIBUTE_TITLE))
349                .map(cType -> cType.getModelItem(Content.ATTRIBUTE_TITLE))
350                .map(ModelItem::getType)
351                .map(ModelItemType::getId)
352                .map(ModelItemTypeConstants.MULTILINGUAL_STRING_ELEMENT_TYPE_ID::equals)
353                .orElse(false);
354    }
355    
356    @SuppressWarnings("static-access")
357    // FIXME CMS-11713: put the SearchField implementation in types?
358    private SearchField _getModelItemSearchField(List<String> joinPaths, ModelItem modelItem, boolean isTypeContentWithMultilingualTitle)
359    {
360        String fieldname = computeFieldPath(modelItem);
361        
362        switch (modelItem.getType().getId())
363        {
364            case ModelItemTypeConstants.STRING_TYPE_ID:
365            case ModelItemTypeConstants.USER_ELEMENT_TYPE_ID:
366            case ModelItemTypeConstants.REFERENCE_ELEMENT_TYPE_ID:
367                return new StringSearchField(joinPaths, fieldname);
368            case ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID:
369                return new ContentSearchField(joinPaths, fieldname, isTypeContentWithMultilingualTitle, Optional.ofNullable(_context));
370            case ModelItemTypeConstants.LONG_TYPE_ID:
371                return new LongSearchField(joinPaths, fieldname);
372            case ModelItemTypeConstants.DOUBLE_TYPE_ID:
373                return new DoubleSearchField(joinPaths, fieldname);
374            case ModelItemTypeConstants.BOOLEAN_TYPE_ID:
375                return new BooleanSearchField(joinPaths, fieldname);
376            case ModelItemTypeConstants.DATE_TYPE_ID:
377            case ModelItemTypeConstants.DATETIME_TYPE_ID:
378                return new DateSearchField(joinPaths, fieldname);
379            case ModelItemTypeConstants.MULTILINGUAL_STRING_ELEMENT_TYPE_ID:
380                return new MultilingualStringSearchField(joinPaths, fieldname, Optional.ofNullable(_context));
381            case ModelItemTypeConstants.COMPOSITE_TYPE_ID:
382            case ModelItemTypeConstants.REPEATER_TYPE_ID:
383            case ModelItemTypeConstants.BINARY_ELEMENT_TYPE_ID:
384            case ModelItemTypeConstants.FILE_ELEMENT_TYPE_ID:
385            case ModelItemTypeConstants.RICH_TEXT_ELEMENT_TYPE_ID:
386            default:
387                return null;
388        }
389    }
390    
391    /**
392     * Get the field path for the model item.
393     * @param definition the model item
394     * @return the field path
395     */
396    public String computeFieldPath(ModelItem definition)
397    {
398        StringBuilder path = new StringBuilder();
399        
400        ModelItemGroup parent = definition.getParent();
401        if (parent != null && !(parent instanceof RepeaterDefinition))
402        {
403            path.append(computeFieldPath(parent)).append(ModelItem.ITEM_PATH_SEPARATOR);
404        }
405        
406        path.append(definition.getName());
407        return path.toString();
408    }
409
410}