001/*
002 *  Copyright 2013 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.model;
017
018import java.util.Collection;
019import java.util.HashSet;
020import java.util.List;
021import java.util.Map;
022import java.util.Optional;
023import java.util.Set;
024import java.util.stream.Collectors;
025
026import org.apache.avalon.framework.component.Component;
027import org.apache.avalon.framework.configuration.Configuration;
028import org.apache.avalon.framework.context.Context;
029import org.apache.avalon.framework.context.ContextException;
030import org.apache.avalon.framework.context.Contextualizable;
031import org.apache.avalon.framework.service.ServiceException;
032import org.apache.avalon.framework.service.ServiceManager;
033import org.apache.avalon.framework.service.Serviceable;
034import org.apache.cocoon.ProcessingException;
035import org.apache.commons.lang3.StringUtils;
036
037import org.ametys.cms.contenttype.ContentTypeEnumerator;
038import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
039import org.ametys.cms.contenttype.ContentTypesHelper;
040import org.ametys.cms.languages.Language;
041import org.ametys.cms.languages.LanguagesManager;
042import org.ametys.cms.search.model.impl.ContentTypesAwareReferencingCriterionDefinition;
043import org.ametys.cms.search.model.impl.ReferencingSearchModelCriterionDefinition;
044import org.ametys.cms.search.model.impl.SolrFilterCriterionDefinition;
045import org.ametys.cms.search.query.ContentTypeQuery;
046import org.ametys.cms.search.query.MixinTypeQuery;
047import org.ametys.cms.search.query.OrQuery;
048import org.ametys.cms.search.query.Query;
049import org.ametys.cms.search.query.Query.Operator;
050import org.ametys.cms.search.ui.model.SearchModelCriterionViewItem;
051import org.ametys.cms.search.ui.model.SearchUIModel;
052import org.ametys.cms.search.ui.model.SearchUIModelExtensionPoint;
053import org.ametys.cms.search.ui.model.impl.DefaultSearchUIModel;
054import org.ametys.core.ui.Callable;
055import org.ametys.runtime.model.Enumerator;
056import org.ametys.runtime.model.ModelViewItem;
057import org.ametys.runtime.model.View;
058import org.ametys.runtime.model.ViewItem;
059import org.ametys.runtime.model.ViewItemAccessor;
060import org.ametys.runtime.model.ViewItemContainer;
061import org.ametys.runtime.plugin.component.AbstractLogEnabled;
062import org.ametys.runtime.plugin.component.ThreadSafeComponentManager;
063
064/**
065 * Helper for {@link SearchModel}.
066 */
067public class SearchModelHelper extends AbstractLogEnabled implements Component, Contextualizable, Serviceable
068{
069    /** The component role. */
070    public static final String ROLE = SearchModelHelper.class.getName();
071    
072    /** The query default language. */
073    public static final String DEFAULT_LANGUAGE = "en";
074    
075    private static final String __SOLR_REQUEST_PARAMETER_NAME = "solrRequest";
076    private static final String __RESTRICTED_CONTENT_TYPE_PARAMETER_NAME = "restrictedContentType";
077    private static final String __SOLR_REQUEST_CRITERION_ID = "solr-filter-criterion";
078    
079    private Context _context;
080    private ServiceManager _manager;
081    
082    private SearchUIModelExtensionPoint _searchUIModelExtensionPoint;
083    private ContentTypeExtensionPoint _contentTypeExtensionPoint;
084    private ContentTypesHelper _contentTypesHelper;
085    private SearchModelCriterionDefinitionHelper _searchModelCriterionDefinitionHelper;
086    private LanguagesManager _languagesManager;
087    
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        _manager = manager;
097        
098        _searchUIModelExtensionPoint = (SearchUIModelExtensionPoint) manager.lookup(SearchUIModelExtensionPoint.ROLE);
099        _contentTypeExtensionPoint = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
100        _contentTypesHelper = (ContentTypesHelper) manager.lookup(ContentTypesHelper.ROLE);
101        _searchModelCriterionDefinitionHelper = (SearchModelCriterionDefinitionHelper) manager.lookup(SearchModelCriterionDefinitionHelper.ROLE);
102        _languagesManager = (LanguagesManager) manager.lookup(LanguagesManager.ROLE);
103    }
104    
105    /**
106     * Get the search model configuration as JSON object
107     * @param modelId The id of search model
108     * @param contextualParameters the contextual parameters
109     * @return The search model configuration in a Map
110     * @throws ProcessingException if an error occurred
111     */
112    @Callable(rights = Callable.NO_CHECK_REQUIRED) // Search model definition are public
113    public Map<String, Object> getSearchModelConfiguration(String modelId, Map<String, Object> contextualParameters) throws ProcessingException
114    {
115        SearchUIModel model = getSearchUIModel(modelId, contextualParameters);
116        return model.toJSON(contextualParameters);
117    }
118    
119    /**
120     * Get the search model configuration as JSON object.
121     * Add some restrictions on the search model, due to the given contextual parameters (content types / solr request)
122     * @param modelId The id of search model
123     * @param contextualParameters the contextual parameters
124     * @return The search model configuration in a Map
125     * @throws ProcessingException if an error occurred while converting the model to JSON
126     */
127    @Callable(rights = Callable.NO_CHECK_REQUIRED) // Search model definition are public
128    public Map<String, Object> getRestrictedSearchModelConfiguration(String modelId, Map<String, Object> contextualParameters) throws ProcessingException
129    {
130        SearchModel model = getRestrictedSearchModel(modelId, contextualParameters);
131        return model.toJSON(contextualParameters);
132    }
133    
134    /**
135     * Get the search model with the given identifier
136     * Add some restrictions on the search model, due to the given contextual parameters (content types / solr request)
137     * @param modelId The id of search model
138     * @param contextualParameters the contextual parameters
139     * @return The search model
140     */
141    public SearchModel getRestrictedSearchModel(String modelId, Map<String, Object> contextualParameters)
142    {
143        SearchUIModel model = getSearchUIModel(modelId, contextualParameters);
144
145        // Copy is needed because some modifications will be made on the search model
146        DefaultSearchModel copy = copySearchModel(model, contextualParameters);
147        
148        String restrictedContentTypeId = (String) contextualParameters.get(__RESTRICTED_CONTENT_TYPE_PARAMETER_NAME);
149        if (StringUtils.isNotEmpty(restrictedContentTypeId))
150        {
151            addContentTypeRestrictions(copy, restrictedContentTypeId, contextualParameters);
152        }
153        
154        String solrRequest = (String) contextualParameters.get(__SOLR_REQUEST_PARAMETER_NAME);
155        if (StringUtils.isNotEmpty(solrRequest))
156        {
157            addSolrFilterCriterion(copy, solrRequest, contextualParameters);
158        }
159        
160        return copy;
161    }
162    
163    /**
164     * Get the column configurations of search model as JSON object
165     * @param modelId The id of search model
166     * @param contextualParameters the contextual parameters
167     * @return The column configurations in a List
168     * @throws ProcessingException if an error occurred
169     */
170    @Callable(rights = Callable.NO_CHECK_REQUIRED) // Search model definition are public
171    public List<Object> getColumnConfigurations(String modelId, Map<String, Object> contextualParameters) throws ProcessingException
172    {
173        SearchUIModel model = getSearchUIModel(modelId, contextualParameters);
174        return model.resultItemsToJSON(contextualParameters);
175    }
176    
177    /**
178     * Retrieves the {@link SearchUIModel} with the given model identifier, with restrictions on content types
179     * @param modelId the model identifier
180     * @param contextualParameters the contextual parameters
181     * @return the {@link SearchUIModel}
182     */
183    public SearchUIModel getSearchUIModel(String modelId, Map<String, Object> contextualParameters)
184    {
185        return Optional.ofNullable(_searchUIModelExtensionPoint.getExtension(modelId))
186                       .orElseThrow(() -> new IllegalArgumentException("The search model '" + modelId + "' does not exist"));
187    }
188    
189    /**
190     * Copy the given search model
191     * @param model the model to copy
192     * @param contextualParameters the contextual parameters
193     * @return the copy of the model
194     */
195    public DefaultSearchModel copySearchModel(SearchModel model, Map<String, Object> contextualParameters)
196    {
197        return model instanceof SearchUIModel uiModel
198                ? new DefaultSearchUIModel(uiModel, contextualParameters)
199                : new DefaultSearchModel(model, contextualParameters);
200    }
201    
202    /**
203     * Add content type restrictions on the given search model
204     * Add content types to the model and restrict enumerator values of its content types criteria
205     * @param model the model
206     * @param restrictedContentTypeId the selected content type identifier
207     * @param contextualParameters the contextual parameters
208     */
209    public void addContentTypeRestrictions(DefaultSearchModel model, String restrictedContentTypeId, Map<String, Object> contextualParameters)
210    {
211        // Get the content types to set on the model - combination of the types configured in the model and the restricted type
212        Set<String> contentTypeIds = _getRestrictedSearchModelContentTypeIds(model, restrictedContentTypeId);
213        model.setContentTypes(contentTypeIds);
214
215        ViewItemContainer criteria = model.getCriteria(contextualParameters);
216        Set<String> contentTypeAndSubTypeIds = contentTypeIds.stream()
217                                                             .map(this::_getSelfAndSubTypeIds)
218                                                             .flatMap(Set::stream)
219                                                             .collect(Collectors.toSet());
220        _addContentTypeRestrictions(criteria, contentTypeAndSubTypeIds, contextualParameters);
221    }
222    
223    private Set<String> _getRestrictedSearchModelContentTypeIds(DefaultSearchModel model, String restrictedContentTypeId)
224    {
225        Set<String> contentTypeIds = new HashSet<>();
226        contentTypeIds.add(restrictedContentTypeId);
227        
228        for (String modelContentTypeId : model.getContentTypes(null))
229        {
230            if (_isSubTypeOf(modelContentTypeId, restrictedContentTypeId))
231            {
232                // If the content type from the model is more specific that the one used for restriction, keep it
233                contentTypeIds.add(modelContentTypeId);
234                contentTypeIds.remove(restrictedContentTypeId);
235            }
236        }
237        
238        return contentTypeIds;
239    }
240    
241    private boolean _isSubTypeOf(String contentTypeId, String parentContentTypeId)
242    {
243        for (String supertypeId : _contentTypeExtensionPoint.getExtension(contentTypeId).getSupertypeIds())
244        {
245            if (supertypeId.equals(parentContentTypeId) || _isSubTypeOf(supertypeId, parentContentTypeId))
246            {
247                return true;
248            }
249        }
250        return false;
251    }
252    
253    private Set<String> _getSelfAndSubTypeIds(String contentTypeId)
254    {
255        Set<String> contentTypeIds = new HashSet<>();
256
257        contentTypeIds.add(contentTypeId);
258        contentTypeIds.addAll(_contentTypeExtensionPoint.getSubTypes(contentTypeId));
259        
260        return contentTypeIds;
261    }
262    
263    @SuppressWarnings("unchecked")
264    private void _addContentTypeRestrictions(ViewItemContainer criteria, Set<String> restrictedContentTypeIds, Map<String, Object> contextualParameters)
265    {
266        for (ViewItem viewItem : criteria.getViewItems())
267        {
268            if (viewItem instanceof ModelViewItem modelViewItem
269                    && modelViewItem.getDefinition() instanceof ContentTypesAwareCriterionDefinition criterion)
270            {
271                Enumerator<String> enumerator = _getContentTypesEnumerator(restrictedContentTypeIds.toArray(String[]::new), criterion.includeContentTypes(), criterion.includeMixins());
272                if (enumerator != null)
273                {
274                    SearchModelCriterionDefinition<String> criterionCopy = new ContentTypesAwareReferencingCriterionDefinition();
275                    criterion.copyTo(criterionCopy, contextualParameters);
276
277                    criterionCopy.setEnumerator(enumerator);
278                    modelViewItem.setDefinition(criterionCopy);
279                }
280            }
281            
282            if (viewItem instanceof ViewItemContainer viewItemContainer)
283            {
284                _addContentTypeRestrictions(viewItemContainer, restrictedContentTypeIds, contextualParameters);
285            }
286        }
287    }
288    
289    private Enumerator<String> _getContentTypesEnumerator(String[] contentTypeIds, boolean includeContentTypes, boolean incudeMixins)
290    {
291        ThreadSafeComponentManager<Enumerator> enumeratorManager = new ThreadSafeComponentManager<>();
292        try
293        {
294            enumeratorManager.setLogger(getLogger());
295            enumeratorManager.contextualize(_context);
296            enumeratorManager.service(_manager);
297            
298            String role = "enumerator";
299            Configuration configuration = _contentTypesHelper.getContentTypesEnumeratorConfiguration(contentTypeIds, includeContentTypes, incudeMixins);
300            enumeratorManager.addComponent("cms", null, role, ContentTypeEnumerator.class, configuration);
301            
302            enumeratorManager.initialize();
303            return enumeratorManager.lookup(role);
304        }
305        catch (Exception e)
306        {
307            getLogger().error("Unable to create a content types enumerator on types '" + StringUtils.join(contentTypeIds, ",") + "'", e);
308            return null;
309        }
310        finally
311        {
312            enumeratorManager.dispose();
313            enumeratorManager = null;
314        }
315    }
316    
317    /**
318     * Add a solr filter criterion to the given model
319     * @param model the model
320     * @param solrRequest the solr request
321     * @param contextualParameters the contextual parameters
322     */
323    public void addSolrFilterCriterion(SearchModel model, String solrRequest, Map<String, Object> contextualParameters)
324    {
325        // Create a criterion with solr request
326        SolrFilterCriterionDefinition criterion = new SolrFilterCriterionDefinition();
327        criterion.setName(__SOLR_REQUEST_CRITERION_ID);
328        criterion.setQuery(solrRequest);
329        criterion.setModel(model);
330
331        // Add the criterion to the search model
332        model.addCriterion(criterion, contextualParameters);
333        
334        // Hide the criterion
335        ModelViewItem viewItem = model.getCriterion(__SOLR_REQUEST_CRITERION_ID, contextualParameters);
336        if (viewItem instanceof SearchModelCriterionViewItem criterionViewItem)
337        {
338            criterionViewItem.setHidden(true);
339        }
340    }
341    
342    /**
343     * Set faceted criteria to the given search model from the given reference paths
344     * If there is no reference path, the faceted criteria of the model are not changed
345     * @param model the search model
346     * @param referencePaths the reference paths
347     * @param contextualParameters the contextual parameters
348     */
349    @SuppressWarnings("unchecked")
350    public void setFacetedCriteria(DefaultSearchModel model, Collection<String> referencePaths, Map<String, Object> contextualParameters)
351    {
352        if (referencePaths != null && !referencePaths.isEmpty())
353        {
354            ViewItemContainer originalFacetedCriteria = model.getFacetedCriteria(contextualParameters);
355            model.setFacetedCriteria(new View());
356            
357            for (String referencePath : referencePaths)
358            {
359                SearchModelCriterionDefinition referenceCriterion = _findCriterionByReferencePath(originalFacetedCriteria, referencePath);
360                if (referenceCriterion != null)
361                {
362                    model.addFacetedCriterion(referenceCriterion, contextualParameters);
363                }
364                else
365                {
366                    SearchModelCriterionDefinition criterion = _searchModelCriterionDefinitionHelper.createReferencingCriterionDefinition(model, referencePath, model.getContentTypes(contextualParameters));
367                    if (criterion != null && criterion.getSolrFacetFieldName(contextualParameters) != null)
368                    {
369                        model.addFacetedCriterion(criterion, contextualParameters);
370                    }
371                    else
372                    {
373                        getLogger().warn("The declared facet '{}' is not facetable. Thus, it will not be added to the facets.", referencePath);
374                    }
375                }
376            }
377            
378        }
379    }
380    
381    private SearchModelCriterionDefinition _findCriterionByReferencePath(ViewItemContainer viewItemContainer, String referencePath)
382    {
383        for (ViewItem viewItem : viewItemContainer.getViewItems())
384        {
385            if (viewItem instanceof ModelViewItem modelViewItem
386                    && modelViewItem.getDefinition() instanceof ReferencingSearchModelCriterionDefinition criterion
387                    && criterion.getReferencePath().equals(referencePath))
388            {
389                return criterion;
390            }
391            
392            if (viewItem instanceof ViewItemContainer newViewItemContainer)
393            {
394                return _findCriterionByReferencePath(newViewItemContainer, referencePath);
395            }
396        }
397        return null;
398    }
399    
400    /**
401     * Get the language.
402     * @param model the search model
403     * @param searchMode The search mode (advanced or simple)
404     * @param values The user values.
405     * @param contextualParameters The search contextual parameters.
406     * @return the query language.
407     */
408    public String getCriteriaLanguage(SearchModel model, String searchMode, Map<String, Object> values, Map<String, Object> contextualParameters)
409    {
410        ViewItemContainer criteria = "advanced".equals(searchMode) && model instanceof SearchUIModel uiModel
411                ? uiModel.getAdvancedCriteria(contextualParameters)
412                : model.getCriteria(contextualParameters);
413        
414        // First search language in criteria
415        String langValue = _getLanguageFromContentLanguageCriterion(criteria, values, contextualParameters);
416        
417        if (StringUtils.isEmpty(langValue))
418        {
419            // If empty, get language from the search contextual parameters (for instance, sent by the tool).
420            langValue = (String) contextualParameters.get("language");
421        }
422
423        if (StringUtils.isEmpty(langValue))
424        {
425            // If no language found: fall back to default.
426            langValue = _getDefaultLanguage();
427        }
428
429        return langValue;
430    }
431    
432    private String _getLanguageFromContentLanguageCriterion(ViewItemAccessor criteria, Map<String, Object> values, Map<String, Object> contextualParameters)
433    {
434        if (criteria != null)
435        {
436            for (ViewItem viewItem : criteria.getViewItems())
437            {
438                if (viewItem instanceof ModelViewItem modelViewItem
439                        && modelViewItem.getDefinition() instanceof LanguageAwareCriterionDefinition criterion)
440                {
441                    Object value = modelViewItem instanceof SearchModelCriterionViewItem criterionViewItem && criterionViewItem.isHidden()
442                            ? criterion.getDefaultValue()
443                            : values.get(criterion.getName());
444                    
445                    return criterion.getLanguage(value, values, contextualParameters);
446                }
447                else if (viewItem instanceof ViewItemContainer itemContainer)
448                {
449                    return _getLanguageFromContentLanguageCriterion(itemContainer, values, contextualParameters);
450                }
451            }
452        }
453        
454        return null;
455    }
456    
457    private String _getDefaultLanguage()
458    {
459        Map<String, Language> availableLanguages = _languagesManager.getAvailableLanguages();
460        if (availableLanguages.containsKey(DEFAULT_LANGUAGE))
461        {
462            return DEFAULT_LANGUAGE;
463        }
464
465        return availableLanguages.size() > 0 ? availableLanguages.keySet().iterator().next() : DEFAULT_LANGUAGE;
466    }
467    
468    /**
469     * Create a content type or mixin query.
470     * @param contentTypes the content types or mixins to search on.
471     * @return the content type {@link Query}.
472     */
473    public Query createContentTypeOrMixinQuery(Collection<String> contentTypes)
474    {
475        return createContentTypeOrMixinQuery(contentTypes, Operator.EQ);
476    }
477    
478    /**
479     * Create a content type or mixin query.
480     * @param contentTypes the content types or mixins to search on.
481     * @param operator The operator to use in created query
482     * @return the content type {@link Query}.
483     */
484    public Query createContentTypeOrMixinQuery(Collection<String> contentTypes, Operator operator)
485    {
486        if (contentTypes == null || contentTypes.isEmpty()) // empty and non-empty model contentTypes
487        {
488            return null;
489        }
490
491        List<String> onlyMixins = contentTypes.stream()
492                                              .filter(ct -> _contentTypeExtensionPoint.getExtension(ct).isMixin())
493                                              .toList();
494        List<String> onlyContentTypes = contentTypes.stream()
495                                                    .filter(ct -> !_contentTypeExtensionPoint.getExtension(ct).isMixin())
496                                                    .toList();
497        if (onlyMixins.isEmpty())
498        {
499            return new ContentTypeQuery(operator, contentTypes);
500        }
501        else if (onlyContentTypes.isEmpty())
502        {
503            return new MixinTypeQuery(operator, contentTypes);
504        }
505        else
506        {
507            return new OrQuery(new ContentTypeQuery(operator, onlyContentTypes), new MixinTypeQuery(operator, onlyMixins));
508        }
509    }
510    
511    /**
512     * Retrieves the criterion with the given name
513     * @param criteria the criteria
514     * @param criterionName the name of the searched criterion
515     * @return the criterion with the given name, or <code>null</code> if no corresponding criterion has been found
516     */
517    public static ModelViewItem getCriterion(ViewItemContainer criteria, String criterionName)
518    {
519        if (criteria != null)
520        {
521            for (ViewItem viewItem : criteria.getViewItems())
522            {
523                if (viewItem instanceof ModelViewItem modelViewItem
524                        && modelViewItem.getDefinition() instanceof CriterionDefinition criterion
525                        && criterionName.equals(criterion.getName()))
526                {
527                    return modelViewItem;
528                }
529                
530                if (viewItem instanceof ViewItemContainer itemContainer)
531                {
532                    ModelViewItem criterion = getCriterion(itemContainer, criterionName);
533                    if (criterion != null)
534                    {
535                        return criterion;
536                    }
537                }
538            }
539        }
540        
541        // No corresponding criterion has been found
542        return null;
543    }
544}