001/*
002 *  Copyright 2025 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.trash.model;
017
018import java.util.ArrayList;
019import java.util.HashMap;
020import java.util.LinkedHashMap;
021import java.util.List;
022import java.util.Map;
023import java.util.Optional;
024import java.util.Set;
025import java.util.stream.Collectors;
026
027import org.apache.avalon.framework.component.Component;
028import org.apache.avalon.framework.service.ServiceException;
029import org.apache.avalon.framework.service.ServiceManager;
030import org.apache.avalon.framework.service.Serviceable;
031import org.apache.commons.lang3.StringUtils;
032
033import org.ametys.cms.content.indexing.solr.SolrFieldNames;
034import org.ametys.cms.data.type.indexing.IndexableElementType;
035import org.ametys.cms.data.type.indexing.SortableIndexableElementType;
036import org.ametys.cms.search.SortOrder;
037import org.ametys.cms.search.model.impl.AbstractCriterionDefinition;
038import org.ametys.cms.search.query.AndQuery;
039import org.ametys.cms.search.query.BooleanQuery;
040import org.ametys.cms.search.query.DocumentTypeQuery;
041import org.ametys.cms.search.query.Query;
042import org.ametys.cms.search.query.Query.Operator;
043import org.ametys.cms.search.solr.SearcherFactory.FacetDefinition;
044import org.ametys.cms.search.solr.SearcherFactory.SortDefinition;
045import org.ametys.cms.search.ui.model.SearchUIColumn;
046import org.ametys.cms.search.ui.model.SearchUIColumnHelper;
047import org.ametys.cms.trash.ContentTrashElementType;
048import org.ametys.cms.trash.TrashManager;
049import org.ametys.core.util.JSONUtils;
050import org.ametys.runtime.i18n.I18nizableText;
051import org.ametys.runtime.model.DefinitionContext;
052import org.ametys.runtime.model.ElementDefinition;
053import org.ametys.runtime.model.Model;
054import org.ametys.runtime.model.ModelItem;
055import org.ametys.runtime.model.type.DataContext;
056
057/**
058 * This class represent the search model for trash elements.
059 */
060public class TrashSearchModel implements Component, Serviceable
061{
062    /** The avalon role */
063    public static final String ROLE = TrashSearchModel.class.getName();
064    
065    private static final Set<String> __HIDDEN_COLUMNS = Set.of(TrashElementModel.PARENT_PATH, TrashElementModel.DELETED_OBJECT, TrashElementModel.LINKED_OBJECTS);
066    
067    private JSONUtils _jsonUtils;
068    private Model _model;
069    
070    private Map<String, TrashElementCriterionDefinition> _criteria;
071    private Map<String, TrashElementFacetDefinition> _facets;
072    private Map<String, Object> _json;
073
074    
075    public void service(ServiceManager serviceManager) throws ServiceException
076    {
077        _jsonUtils = (JSONUtils) serviceManager.lookup(JSONUtils.ROLE);
078        _model = (TrashElementModel) serviceManager.lookup(TrashElementModel.ROLE);
079    }
080    
081    /**
082     * Get the JSON representation of the {@link TrashSearchModel}.
083     * @return the search model as JSON
084     */
085    public Map<String, Object> toJSON()
086    {
087        if (_json == null)
088        {
089            _json = new HashMap<>();
090            _json.put("searchRole", TrashManager.ROLE);
091            _json.put("searchMethodName", "search");
092            _json.put("criteria", _getCriteria().values().stream().collect(Collectors.toMap(TrashElementCriterionDefinition::getCriterionName, c -> c.toJSON(DefinitionContext.newInstance()), (a, b) -> b, LinkedHashMap::new)));
093            _json.put("columns", _model.getModelItems().stream().filter(item -> !TrashElementModel.HIDDEN.equals(item.getName())).map(this::modelItem2Column).map(col -> col.toJSON(DefinitionContext.newInstance())).toList());
094        }
095        return _json;
096    }
097    
098    /**
099     * The filter queries to retrieve the trash element
100     * @return a list of queries to apply
101     */
102    public List<Query> getFilterQueries()
103    {
104        List<Query> queries = new ArrayList<>();
105        
106        queries.add(new DocumentTypeQuery(SolrFieldNames.TYPE_TRASH_ELEMENT));
107        queries.add(new BooleanQuery(TrashElementModel.HIDDEN, false));
108        
109        return queries;
110    }
111    
112    /**
113     * Get the query from search criteria.
114     * @param criteria the search criteria
115     * @return a Solr query
116     */
117    @SuppressWarnings("unchecked")
118    public Query getQuery(Map<String, Object> criteria)
119    {
120        List<Query> queries = new ArrayList<>();
121        
122        for (Map.Entry<String, Object> criterionValue : criteria.entrySet())
123        {
124            TrashElementCriterionDefinition criterion = _getCriteria().get(criterionValue.getKey());
125            
126            Optional.ofNullable(criterionValue.getValue())
127                .map(v -> criterion.convertQueryValue(v, Map.of()))
128                .map(v -> criterion.getQuery(v, (Operator) null, null, Map.of()))
129                .ifPresent(queries::add);
130        }
131        
132        return new AndQuery(queries);
133    }
134    
135    /**
136     * Get the facet definitions as list.
137     * @return a list of facet definitions
138     */
139    public List<FacetDefinition> getFacetDefinitions()
140    {
141        return _getFacets().values().stream().map(TrashElementFacetDefinition::getFacetDefinition).toList();
142    }
143    
144    /**
145     * Get the facets values for the search tool.
146     * @param facetResults the facets results from the search
147     * @return the facets values
148     */
149    public List<Map<String, Object>> getFacetsValues(Map<String, Map<String, Integer>> facetResults)
150    {
151        return facetResults.entrySet()
152            .stream()
153            .filter(entry -> _getFacets().containsKey(entry.getKey()))
154            .map(entry -> _getFacets().get(entry.getKey()).toJSON(entry.getValue()))
155            .toList();
156    }
157    
158    /**
159     * Get the sort definitions depending on search sorts and groups.
160     * @param sortString the sorts
161     * @param groupString the groups
162     * @return the list of sort definitions.
163     */
164    @SuppressWarnings("unchecked")
165    public List<SortDefinition> getSortDefinitions(String sortString, String groupString)
166    {
167        List<Object> sortList = new ArrayList<>(_jsonUtils.convertJsonToList(sortString));
168        if (StringUtils.isNotEmpty(groupString))
169        {
170            // Grouping will be treated server side as a sort. It just needs to be before all the sorters
171            sortList.add(0, _jsonUtils.convertJsonToMap(groupString));
172        }
173        
174        return sortList.stream()
175            .map(obj -> (Map<String, String>) obj)
176            .map(this::getSortDefinition)
177            .toList();
178    }
179    
180    private SortDefinition getSortDefinition(Map<String, String> map)
181    {
182        ModelItem modelItem = _model.getModelItem(map.get("property"));
183        String sortFieldName = modelItem.getName() + ((SortableIndexableElementType) modelItem.getType()).getSortFieldSuffix(DataContext.newInstance());
184        SortOrder sortOrder = "ASC".equals(map.get("direction")) ? SortOrder.ASC : SortOrder.DESC;
185        return new SortDefinition(sortFieldName, sortOrder);
186    }
187    
188    private Map<String, TrashElementCriterionDefinition> _getCriteria()
189    {
190        if (_criteria == null)
191        {
192            _criteria = new LinkedHashMap<>();
193            _addCriterion(TrashElementModel.TITLE, Operator.SEARCH, null);
194            _addCriterion(TrashElementModel.TRASH_TYPE, Operator.EQ, null);
195            // add a criteria for the content type to make it user friendly
196            _addCriterion(ContentTrashElementType.CONTENT_TYPES, Operator.EQ, null);
197            _addCriterion(TrashElementModel.DATE, Operator.GT, new I18nizableText("plugin.cms", "UITOOL_TRASH_CRITERION_DELETION_DATE_AFTER"));
198            _addCriterion(TrashElementModel.DATE, Operator.LT, new I18nizableText("plugin.cms", "UITOOL_TRASH_CRITERION_DELETION_DATE_BEFORE"));
199            _addCriterion(TrashElementModel.AUTHOR, Operator.EQ, null);
200        }
201        return _criteria;
202    }
203    
204    private void _addCriterion(String name, Operator operator, I18nizableText label)
205    {
206        TrashElementCriterionDefinition criterion = new TrashElementCriterionDefinition((ElementDefinition) _model.getModelItem(name), name, operator);
207        if (label != null)
208        {
209            criterion.setLabel(label);
210        }
211        _criteria.put(criterion.getCriterionName(), criterion);
212    }
213    
214    private Map<String, TrashElementFacetDefinition> _getFacets()
215    {
216        if (_facets == null)
217        {
218            _facets = new LinkedHashMap<>();
219            _addFacet(TrashElementModel.TRASH_TYPE);
220            _addFacet(TrashElementModel.AUTHOR);
221        }
222        return _facets;
223    }
224    
225    private void _addFacet(String name)
226    {
227        TrashElementFacetDefinition facet = new TrashElementFacetDefinition(_model.getModelItem(name));
228        _facets.put(name, facet);
229    }
230    
231    /**
232     * Convert the model item to search ui column
233     * @param modelItem the model item
234     * @return the column
235     */
236    protected SearchUIColumn modelItem2Column(ModelItem modelItem)
237    {
238        SearchUIColumn<?> column = SearchUIColumnHelper.createModelItemColumn(modelItem);
239        column.setHidden(__HIDDEN_COLUMNS.contains(modelItem.getName()));
240        
241        if (TrashElementModel.TITLE.equals(modelItem.getPath()))
242        {
243            column.setRenderer(Optional.of("Ametys.grid.GridColumnHelper.renderTextWithIcon"));
244        }
245        return column;
246    }
247    
248    private static class TrashElementFacetDefinition
249    {
250        private ModelItem _modelItem;
251        private FacetDefinition _facetDefinition;
252        
253        public TrashElementFacetDefinition(ModelItem item)
254        {
255            _modelItem = item;
256            _facetDefinition = new FacetDefinition(_modelItem.getName(), _modelItem.getName() + ((IndexableElementType) _modelItem.getType()).getFacetFieldSuffix(DataContext.newInstance()));
257        }
258        
259        public Map<String, Object> toJSON(Map<String, Integer> facetValues)
260        {
261            return Map.of(
262                "name", _modelItem.getName(),
263                "label", _modelItem.getLabel(),
264                "type", "criterion",
265                "children", _getValues(facetValues)
266            );
267        }
268        
269        private List<Map<String, Object>> _getValues(Map<String, Integer> facetValues)
270        {
271            IndexableElementType type = (IndexableElementType) _modelItem.getType();
272            
273            DataContext context = DataContext.newInstance().withModelItem(_modelItem);
274            
275            return facetValues
276                .entrySet()
277                .stream()
278                .map(
279                    facetValue ->
280                        Map.of(
281                            "value", facetValue.getKey(),
282                            "count", facetValue.getValue(),
283                            "label", type.getFacetLabel(facetValue.getKey(), context),
284                            "type", "facet"
285                        )
286                )
287                .toList();
288        }
289        
290        public FacetDefinition getFacetDefinition()
291        {
292            return _facetDefinition;
293        }
294    }
295    
296    private static class TrashElementCriterionDefinition<T> extends AbstractCriterionDefinition<T>
297    {
298        private String _criterionName;
299        private Operator _operator;
300        
301        public TrashElementCriterionDefinition(ElementDefinition reference, String referencePath, Operator operator)
302        {
303            this.setName(reference.getName());
304            this.setLabel(reference.getLabel());
305            this.setDescription(reference.getDescription());
306            this.setEnumerator(reference.getEnumerator());
307            this.setModel(reference.getModel());
308            this.setSolrFacetFieldName(reference.getName() + ((IndexableElementType) reference.getType()).getIndexingFieldSuffix(DataContext.newInstance()));
309            this.setType(reference.getType());
310            this.setMultiple(reference.isMultiple());
311            _operator = operator;
312            
313            _criterionName = referencePath + "-" + operator.toString().toLowerCase();
314        }
315        
316        @Override
317        public Query getQuery(Object value, Operator operator, Map<String, Object> allValues, String language, Map<String, Object> contextualParameters)
318        {
319            return super.getQuery(value,  operator != null ? operator : _operator, allValues, language, contextualParameters);
320        }
321        
322        public String getCriterionName()
323        {
324            return _criterionName;
325        }
326    }
327}