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.Map;
023
024import org.apache.avalon.framework.component.Component;
025import org.apache.avalon.framework.service.ServiceException;
026import org.apache.avalon.framework.service.ServiceManager;
027import org.apache.avalon.framework.service.Serviceable;
028import org.apache.commons.lang3.ArrayUtils;
029import org.apache.commons.lang3.StringUtils;
030
031import org.ametys.cms.content.ContentHelper;
032import org.ametys.cms.contenttype.ContentConstants;
033import org.ametys.cms.contenttype.ContentType;
034import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
035import org.ametys.cms.contenttype.ContentTypesHelper;
036import org.ametys.cms.contenttype.MetadataDefinition;
037import org.ametys.cms.contenttype.MetadataType;
038import org.ametys.cms.contenttype.RepeaterDefinition;
039import org.ametys.cms.contenttype.indexing.CustomIndexingField;
040import org.ametys.cms.contenttype.indexing.DefaultMetadataIndexingField;
041import org.ametys.cms.contenttype.indexing.IndexingField;
042import org.ametys.cms.contenttype.indexing.IndexingModel;
043import org.ametys.cms.contenttype.indexing.MetadataIndexingField;
044import org.ametys.cms.repository.Content;
045import org.ametys.cms.search.SearchField;
046import org.ametys.cms.search.model.SystemProperty;
047import org.ametys.cms.search.model.SystemPropertyExtensionPoint;
048import org.ametys.cms.search.solr.field.BooleanSearchField;
049import org.ametys.cms.search.solr.field.DateSearchField;
050import org.ametys.cms.search.solr.field.DoubleSearchField;
051import org.ametys.cms.search.solr.field.LongSearchField;
052import org.ametys.cms.search.solr.field.StringSearchField;
053import org.ametys.plugins.repository.AmetysObjectResolver;
054import org.ametys.plugins.repository.UnknownAmetysObjectException;
055import org.ametys.plugins.repository.metadata.UnknownMetadataException;
056import org.ametys.runtime.parameter.Enumerator;
057import org.ametys.runtime.plugin.component.AbstractLogEnabled;
058
059/**
060 * Component which helps content searching by providing a simple way to access
061 * content properties (either metadata or system properties).
062 */
063public class ContentSearchHelper extends AbstractLogEnabled implements Component, Serviceable
064{
065
066    /** The component role. */
067    public static final String ROLE = ContentSearchHelper.class.getName();
068    
069    /** The content type extension point. */
070    protected ContentTypeExtensionPoint _cTypeEP;
071    
072    /** The content type helper. */
073    protected ContentTypesHelper _cTypeHelper;
074    
075    /** The content helper. */
076    protected ContentHelper _contentHelper;
077    
078    /** The system property extension point. */
079    protected SystemPropertyExtensionPoint _sysPropEP;
080    
081    /** The ametys object resolver. */
082    protected AmetysObjectResolver _resolver;
083    
084    @Override
085    public void service(ServiceManager manager) throws ServiceException
086    {
087        _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
088        _cTypeHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE);
089        _contentHelper = (ContentHelper) manager.lookup(ContentHelper.ROLE);
090        _sysPropEP = (SystemPropertyExtensionPoint) manager.lookup(SystemPropertyExtensionPoint.ROLE);
091        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
092    }
093    
094    /**
095     * Get the metadata indexing field for the "title" standard metadata.
096     * @return The standard title metadata indexing field.
097     */
098    public MetadataIndexingField getTitleMetadataIndexingField()
099    {
100        return new DefaultMetadataIndexingField("title", ContentTypesHelper.getTitleMetadataDefinition(), "title");
101    }
102    
103    /**
104     * Get a {@link SearchField} corresponding to a metadata.
105     * @param metadataPath The metadata path.
106     * @param metadataType The metadata type.
107     * @return the search field.
108     */
109    public SearchField getMetadataSearchField(String metadataPath, MetadataType metadataType)
110    {
111        switch (metadataType)
112        {
113            case STRING:
114            case CONTENT:
115            case SUB_CONTENT:
116            case USER:
117                return new StringSearchField(metadataPath);
118            case LONG:
119                return new LongSearchField(metadataPath);
120            case DOUBLE:
121                return new DoubleSearchField(metadataPath);
122            case BOOLEAN:
123                return new BooleanSearchField(metadataPath);
124            case DATE:
125            case DATETIME:
126                return new DateSearchField(metadataPath);
127            case COMPOSITE:
128            case BINARY:
129            case FILE:
130            case RICH_TEXT:
131            case REFERENCE:
132            default:
133                return null;
134        }
135    }
136    
137    /**
138     * Get a {@link SearchField} from a field name in a batch of content types.
139     * @param contentTypes The content types, can be empty to search on any content type.
140     * In that case, only the title metadata and system properties will be usable in sort and facets specs.
141     * @param fieldPath The field path, can be either a system property ID or a indexing field name or path (not joined).
142     * @return The {@link SearchField} corresponding to the 
143     */
144    public SearchField getSearchField(Collection<String> contentTypes, String fieldPath)
145    {
146        SearchField searchField = null;
147        
148        if (_sysPropEP.hasExtension(fieldPath))
149        {
150            SystemProperty property = _sysPropEP.getExtension(fieldPath);
151            searchField = property.getSearchField();
152        }
153        else
154        {
155            searchField = getIndexingModelSearchField(fieldPath, contentTypes);
156        }
157        
158        if (searchField == null)
159        {
160            throw new IllegalArgumentException("The field '" + fieldPath + "' can't be found in the selected content types.");
161        }
162        
163        return searchField;
164    }
165    
166    /**
167     * Get the {@link SearchField} corresponding to the indexing field at the given path in the indexing model.
168     * @param fieldPath The field path.
169     * @param contentTypes The target content types.
170     * @return The corresponding search field.
171     */
172    protected SearchField getIndexingModelSearchField(String fieldPath, Collection<String> contentTypes)
173    {
174        String commonContentTypeId = _cTypeHelper.getCommonAncestor(contentTypes);
175        
176        MetadataType type = null;
177        
178        if (commonContentTypeId != null)
179        {
180            String[] pathSegments = StringUtils.split(fieldPath, ContentConstants.METADATA_PATH_SEPARATOR);
181            String fieldName = pathSegments[0];
182            
183            // Indexing field.
184            ContentType cType = _cTypeEP.getExtension(commonContentTypeId);
185            IndexingField indexingField = cType.getIndexingModel().getField(fieldName);
186            
187            if (indexingField == null)
188            {
189                throw new IllegalArgumentException("Search field with path '" + fieldPath + "' refers to an unknown indexing field: " + fieldName);
190            }
191            
192            if (indexingField instanceof MetadataIndexingField)
193            {
194                List<String> joinPaths = new ArrayList<>();
195                String[] remainingPathSegments = pathSegments.length > 1 ? (String[]) ArrayUtils.subarray(pathSegments, 1, pathSegments.length) : new String[0];
196                MetadataDefinition def = getMetadataDefinition((MetadataIndexingField) indexingField, remainingPathSegments, joinPaths, false);
197                
198                if (!joinPaths.isEmpty())
199                {
200                    throw new IllegalArgumentException("The metadata '" + fieldPath + "' can't be used as it is joined.");
201                }
202                
203                type = def.getType();
204            }
205            else if (indexingField instanceof CustomIndexingField)
206            {
207                type = indexingField.getType();
208            }
209        }
210        else if (fieldPath.equals("title"))
211        {
212            // No specific content type: allow only title.
213            type = ContentTypesHelper.getTitleMetadataDefinition().getType();
214        }
215        
216        return type != null ? getMetadataSearchField(fieldPath, type) : null;
217    }
218    
219    /**
220     * Get the metadata definition from the indexing field and compute the join paths. Can be null if the last indexing field is a custom indexing field.
221     * @param indexingField The initial indexing field
222     * @param remainingPathSegments The path to access the metadata or an another indexing field from the initial indexing field
223     * @param joinPaths The consecutive's path in case of joint to access the field/metadata
224     * @param addLast <code>true</code> to add the last join path element to the list, <code>false</code> otherwise.
225     * @return The metadata definition or null if not found
226     */
227    public MetadataDefinition getMetadataDefinition(MetadataIndexingField indexingField, String[] remainingPathSegments, List<String> joinPaths, boolean addLast)
228    {
229        StringBuilder currentMetaPath = new StringBuilder();
230        currentMetaPath.append(indexingField.getName());
231        
232        MetadataDefinition definition = indexingField.getMetadataDefinition();
233        
234        for (int i = 0; i < remainingPathSegments.length && definition != null; i++)
235        {
236            if (definition.getType() == MetadataType.CONTENT || definition.getType() == MetadataType.SUB_CONTENT)
237            {
238                // Add path to content from current content type to join paths.
239                // Join paths are the consecutive metadata paths (separated with '/') to access
240                // the searched content, for instance [address/city, links/department].
241                joinPaths.add(currentMetaPath.toString());
242                
243                String refCTypeId = definition.getContentType();
244                if (refCTypeId != null)
245                {
246                    if (!_cTypeEP.hasExtension(refCTypeId))
247                    {
248                        throw new IllegalArgumentException("Search criterion with path '" + StringUtils.join(remainingPathSegments, ContentConstants.METADATA_PATH_SEPARATOR) + "' references an unknown content type:" + refCTypeId); 
249                    }
250                    
251                    ContentType refCType = _cTypeEP.getExtension(refCTypeId);
252                    IndexingModel refIndexingModel = refCType.getIndexingModel();
253                    
254                    IndexingField refIndexingField = refIndexingModel.getField(remainingPathSegments[i]);
255                    if (refIndexingField == null)
256                    {
257                        throw new IllegalArgumentException("Search criterion with path '" + StringUtils.join(remainingPathSegments, ContentConstants.METADATA_PATH_SEPARATOR) + "' refers to an unknown indexing field: " + remainingPathSegments[i]);
258                    }
259                    if (refIndexingField instanceof MetadataIndexingField)
260                    {
261                        throw new IllegalArgumentException("Search criterion with path '" + StringUtils.join(remainingPathSegments, ContentConstants.METADATA_PATH_SEPARATOR) + "' refers to an unknown indexing field: " + remainingPathSegments[i]);
262                    }
263                    
264                    return getMetadataDefinition((MetadataIndexingField) refIndexingField, ArrayUtils.subarray(remainingPathSegments, i + 1, remainingPathSegments.length), joinPaths, addLast);
265                }
266                else if ("title".equals(remainingPathSegments[i]))
267                {
268                    // No specific content type: allow only title.
269                    return ContentTypesHelper.getTitleMetadataDefinition();
270                }
271            }
272            else
273            {
274                if (definition instanceof RepeaterDefinition)
275                {
276                    // Add path to repeater from current content type or last repeater to join paths
277                    joinPaths.add(currentMetaPath.toString());
278                    currentMetaPath = new StringBuilder();
279                    currentMetaPath.append(remainingPathSegments[i]);
280                }
281                else
282                {
283                    currentMetaPath.append(ContentConstants.METADATA_PATH_SEPARATOR).append(remainingPathSegments[i]);
284                }
285                definition = definition.getMetadataDefinition(remainingPathSegments[i]);
286            }
287        }
288        
289        if (addLast)
290        {
291            joinPaths.add(currentMetaPath.toString());
292        }
293        
294        return definition;
295    }
296    
297    /**
298     * Get the values of a Content's metadata (can be in another content).
299     * @param content The Content.
300     * @param fullMetadataPath The full metadata path, can represent a metadata in a joined content.
301     * @param type The metadata type.
302     * @param multiple If the metadata is multiple.
303     * @param enumerator The metadata enumerator.
304     * @param full True to generate full representation of metadata, false otherwise.
305     * @return The values of the content metadata.
306     */
307    public Object getMetadataValues(Content content, String fullMetadataPath, MetadataType type, boolean multiple, Enumerator enumerator, boolean full)
308    {
309        Object value = null;
310        
311        try
312        {
313            List<Object> values = _contentHelper.getMetadataValues(content, fullMetadataPath);
314            
315            if (!values.isEmpty())
316            {
317                if (full)
318                {
319                    if (type == MetadataType.CONTENT || type == MetadataType.SUB_CONTENT)
320                    {
321                        // If full, extract content values as Maps. If not, the values are kept as content IDs.
322                        List<Map<String, Object>> contentValues = getContentValues(values);
323                        values = new ArrayList<>(contentValues);
324                    }
325                    else if (enumerator != null)
326                    {
327                        // If full, extract enumerated values as Maps. If not, the values are kept as entry IDs.
328                        List<Map<String, Object>> enumValues = getEnumeratedValues(values, enumerator);
329                        values = new ArrayList<>(enumValues);
330                    }
331                }
332                
333                if (multiple)
334                {
335                    value = values;
336                }
337                else
338                {
339                    value = values.get(0);
340                }
341            }
342        }
343        catch (UnknownMetadataException e)
344        {
345            // Ignore, just return a null value.
346        }
347        
348        return value;
349    }
350    
351    /**
352     * Transform the list of enumerated values into a list of info (as Maps).
353     * @param values The original list of values.
354     * @param enumerator The enumerator.
355     * @return The list of info (value + label) for each value.
356     */
357    protected List<Map<String, Object>> getEnumeratedValues(List<Object> values, Enumerator enumerator)
358    {
359        List<Map<String, Object>> enumValues = new ArrayList<>();
360        
361        for (Object value : values)
362        {
363            Map<String, Object> entryInfo = new HashMap<>();
364            entryInfo.put("value", value);
365            
366            try
367            {
368                entryInfo.put("label", enumerator.getEntry(value.toString()));
369            }
370            catch (Exception e)
371            {
372                entryInfo.put("label", value);
373            }
374            
375            enumValues.add(entryInfo);
376        }
377        
378        return enumValues;
379    }
380    
381    /**
382     * Get information for UI on a content metadata
383     * @param values The list of content IDs.
384     * @return The informations
385     */
386    protected List<Map<String, Object>> getContentValues(List<Object> values)
387    {
388        List<Map<String, Object>> contents = new ArrayList<>();
389        for (Object contentId : values)
390        {
391            Map<String, Object> contentInfo = new HashMap<>();
392            contentInfo.put("id", contentId);
393            
394            try
395            {
396                Content refContent = _resolver.resolveById((String) contentId);
397                contentInfo.put("title", refContent.getTitle());
398                contentInfo.put("isSimple", _isSimple(refContent));
399            }
400            catch (UnknownAmetysObjectException e)
401            {
402                // Nothing
403            }
404            
405            contents.add(contentInfo);
406        }
407        
408        return contents;
409    }
410    
411    private boolean _isSimple(Content content)
412    {
413        for (String cTypeId : content.getTypes())
414        {
415            ContentType cType = _cTypeEP.getExtension(cTypeId);
416            if (!cType.isSimple())
417            {
418                return false;
419            }
420        }
421        return true;
422    }
423    
424}