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