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
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
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
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    
302    /**
303     * Set faceted criteria to the given search model from the given reference paths
304     * If there is no reference path, the faceted criteria of the model are not changed
305     * @param model the search model
306     * @param referencePaths the reference paths
307     * @param contextualParameters the contextual parameters
308     */
309    @SuppressWarnings("unchecked")
310    public void setFacetedCriteria(DefaultSearchModel model, Collection<String> referencePaths, Map<String, Object> contextualParameters)
311    {
312        if (referencePaths != null && !referencePaths.isEmpty())
313        {
314            ViewItemContainer originalFacetedCriteria = model.getFacetedCriteria(contextualParameters);
315            model.setFacetedCriteria(new View());
316            
317            for (String referencePath : referencePaths)
318            {
319                SearchModelCriterionDefinition referenceCriterion = _findCriterionByReferencePath(originalFacetedCriteria, referencePath);
320                if (referenceCriterion != null)
321                {
322                    model.addFacetedCriterion(referenceCriterion, contextualParameters);
323                }
324                else
325                {
326                    SearchModelCriterionDefinition criterion = _searchModelCriterionDefinitionHelper.createReferencingCriterionDefinition(model, referencePath, model.getContentTypes(contextualParameters));
327                    if (criterion != null && criterion.getSolrFacetFieldName(contextualParameters) != null)
328                    {
329                        model.addFacetedCriterion(criterion, contextualParameters);
330                    }
331                    else
332                    {
333                        getLogger().warn("The declared facet '{}' is not facetable. Thus, it will not be added to the facets.", referencePath);
334                    }
335                }
336            }
337            
338        }
339    }
340    
341    private SearchModelCriterionDefinition _findCriterionByReferencePath(ViewItemContainer viewItemContainer, String referencePath)
342    {
343        for (ViewItem viewItem : viewItemContainer.getViewItems())
344        {
345            if (viewItem instanceof ModelViewItem modelViewItem
346                    && modelViewItem.getDefinition() instanceof ReferencingSearchModelCriterionDefinition criterion
347                    && criterion.getReferencePath().equals(referencePath))
348            {
349                return criterion;
350            }
351            
352            if (viewItem instanceof ViewItemContainer newViewItemContainer)
353            {
354                return _findCriterionByReferencePath(newViewItemContainer, referencePath);
355            }
356        }
357        return null;
358    }
359    
360    /**
361     * Get the language.
362     * @param model the search model
363     * @param searchMode The search mode (advanced or simple)
364     * @param values The user values.
365     * @param contextualParameters The search contextual parameters.
366     * @return the query language.
367     */
368    public String getCriteriaLanguage(SearchModel model, String searchMode, Map<String, Object> values, Map<String, Object> contextualParameters)
369    {
370        ViewItemContainer criteria = "advanced".equals(searchMode) && model instanceof SearchUIModel uiModel
371                ? uiModel.getAdvancedCriteria(contextualParameters)
372                : model.getCriteria(contextualParameters);
373        
374        // First search language in criteria
375        String langValue = _getLanguageFromContentLanguageCriterion(criteria, values, contextualParameters);
376        
377        if (StringUtils.isEmpty(langValue))
378        {
379            // If empty, get language from the search contextual parameters (for instance, sent by the tool).
380            langValue = (String) contextualParameters.get("language");
381        }
382
383        if (StringUtils.isEmpty(langValue))
384        {
385            // If no language found: fall back to default.
386            langValue = _getDefaultLanguage();
387        }
388
389        return langValue;
390    }
391    
392    private String _getLanguageFromContentLanguageCriterion(ViewItemAccessor criteria, Map<String, Object> values, Map<String, Object> contextualParameters)
393    {
394        for (ViewItem viewItem : criteria.getViewItems())
395        {
396            if (viewItem instanceof ModelViewItem modelViewItem
397                    && modelViewItem.getDefinition() instanceof LanguageAwareCriterionDefinition criterion)
398            {
399                Object value = modelViewItem instanceof SearchModelCriterionViewItem criterionViewItem && criterionViewItem.isHidden()
400                        ? criterion.getDefaultValue()
401                        : values.get(criterion.getName());
402                
403                return criterion.getLanguage(value, values, contextualParameters);
404            }
405            else if (viewItem instanceof ViewItemContainer itemContainer)
406            {
407                return _getLanguageFromContentLanguageCriterion(itemContainer, values, contextualParameters);
408            }
409        }
410        
411        return null;
412    }
413    
414    private String _getDefaultLanguage()
415    {
416        Map<String, Language> availableLanguages = _languagesManager.getAvailableLanguages();
417        if (availableLanguages.containsKey(DEFAULT_LANGUAGE))
418        {
419            return DEFAULT_LANGUAGE;
420        }
421
422        return availableLanguages.size() > 0 ? availableLanguages.keySet().iterator().next() : DEFAULT_LANGUAGE;
423    }
424    
425    /**
426     * Create a content type or mixin query.
427     * @param contentTypes the content types or mixins to search on.
428     * @return the content type {@link Query}.
429     */
430    public Query createContentTypeOrMixinQuery(Collection<String> contentTypes)
431    {
432        return createContentTypeOrMixinQuery(contentTypes, Operator.EQ);
433    }
434    
435    /**
436     * Create a content type or mixin query.
437     * @param contentTypes the content types or mixins to search on.
438     * @param operator The operator to use in created query
439     * @return the content type {@link Query}.
440     */
441    public Query createContentTypeOrMixinQuery(Collection<String> contentTypes, Operator operator)
442    {
443        if (contentTypes == null || contentTypes.isEmpty()) // empty and non-empty model contentTypes
444        {
445            return null;
446        }
447
448        List<String> onlyMixins = contentTypes.stream()
449                                              .filter(ct -> _contentTypeExtensionPoint.getExtension(ct).isMixin())
450                                              .toList();
451        List<String> onlyContentTypes = contentTypes.stream()
452                                                    .filter(ct -> !_contentTypeExtensionPoint.getExtension(ct).isMixin())
453                                                    .toList();
454        if (onlyMixins.isEmpty())
455        {
456            return new ContentTypeQuery(operator, contentTypes);
457        }
458        else if (onlyContentTypes.isEmpty())
459        {
460            return new MixinTypeQuery(operator, contentTypes);
461        }
462        else
463        {
464            return new OrQuery(new ContentTypeQuery(operator, onlyContentTypes), new MixinTypeQuery(operator, onlyMixins));
465        }
466    }
467    
468    /**
469     * Retrieves the criterion with the given name
470     * @param criteria the criteria
471     * @param criterionName the name of the searched criterion
472     * @return the criterion with the given name, or <code>null</code> if no corresponding criterion has been found 
473     */
474    public static ModelViewItem getCriterion(ViewItemContainer criteria, String criterionName)
475    {
476        for (ViewItem viewItem : criteria.getViewItems())
477        {
478            if (viewItem instanceof ModelViewItem modelViewItem
479                    && modelViewItem.getDefinition() instanceof CriterionDefinition criterion
480                    && criterionName.equals(criterion.getName()))
481            {
482                return modelViewItem;
483            }
484            
485            if (viewItem instanceof ViewItemContainer itemContainer)
486            {
487                ModelViewItem criterion = getCriterion(itemContainer, criterionName);
488                if (criterion != null)
489                {
490                    return criterion;
491                }
492            }
493        }
494        
495        // No corresponding criterion has been found
496        return null;
497    }
498}