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