001/*
002 *  Copyright 2023 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.content.consistency;
017
018import java.util.ArrayList;
019import java.util.HashMap;
020import java.util.List;
021import java.util.Map;
022import java.util.Map.Entry;
023import java.util.Objects;
024
025import org.apache.avalon.framework.component.Component;
026import org.apache.avalon.framework.service.ServiceException;
027import org.apache.avalon.framework.service.ServiceManager;
028import org.apache.avalon.framework.service.Serviceable;
029import org.apache.cocoon.ProcessingException;
030import org.apache.commons.lang3.StringUtils;
031
032import org.ametys.cms.contenttype.ContentType;
033import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
034import org.ametys.core.ui.Callable;
035import org.ametys.core.user.UserIdentity;
036import org.ametys.core.util.JSONUtils;
037import org.ametys.plugins.core.user.UserHelper;
038import org.ametys.plugins.repository.AmetysObjectIterable;
039import org.ametys.plugins.repository.AmetysObjectResolver;
040import org.ametys.plugins.repository.UnknownAmetysObjectException;
041import org.ametys.plugins.repository.query.QueryHelper;
042import org.ametys.plugins.repository.query.SortCriteria;
043import org.ametys.plugins.repository.query.expression.AndExpression;
044import org.ametys.plugins.repository.query.expression.Expression;
045import org.ametys.plugins.repository.query.expression.Expression.Operator;
046import org.ametys.plugins.repository.query.expression.LongExpression;
047import org.ametys.plugins.repository.query.expression.OrExpression;
048import org.ametys.plugins.repository.query.expression.StringExpression;
049import org.ametys.plugins.repository.query.expression.UserExpression;
050import org.ametys.plugins.workflow.support.WorkflowHelper;
051import org.ametys.runtime.i18n.I18nizableText;
052import org.ametys.runtime.plugin.component.AbstractLogEnabled;
053
054import com.opensymphony.workflow.loader.StepDescriptor;
055import com.opensymphony.workflow.loader.WorkflowDescriptor;
056
057/**
058 * Execute JCR query to search for content consistency result
059 */
060public class ContentConsistencySearcher extends AbstractLogEnabled implements Serviceable, Component
061{
062    /** right id to access global consistency tool */
063    public static final String CMS_RIGHTS_TOOLS_GLOBAL_CONSISTENCY = "CMS_Rights_Tools_GlobalConsistency";
064
065    /** the avalon role */
066    public static final String ROLE = ContentConsistencySearcher.class.getName();
067    
068    private ContentTypeExtensionPoint _cTypeEP;
069    private JSONUtils _jsonUtils;
070    private AmetysObjectResolver _resolver;
071    private ContentConsistencySearchModel _searchModel;
072    private UserHelper _userHelper;
073    private WorkflowHelper _workflowHelper;
074
075    private ContentConsistencyManager _contentConsistencyManager;
076
077    public void service(ServiceManager manager) throws ServiceException
078    {
079        _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
080        _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE);
081        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
082        _searchModel = (ContentConsistencySearchModel) manager.lookup(ContentConsistencySearchModel.ROLE);
083        _userHelper = (UserHelper) manager.lookup(UserHelper.ROLE);
084        _workflowHelper = (WorkflowHelper) manager.lookup(WorkflowHelper.ROLE);
085        _contentConsistencyManager = (ContentConsistencyManager) manager.lookup(ContentConsistencyManager.ROLE);
086    }
087    
088    /**
089     * Get the search model for content consistency result.
090     * 
091     * @implNote this method is a wrapper for {@link ContentConsistencySearchModel#getModel()}
092     * with the addition of being a {@link Callable}
093     * 
094     * @return the search model
095     * @throws ProcessingException if an error occurs
096     */
097    @Callable(rights = "CMS_Rights_Tools_GlobalConsistency")
098    public Map<String, Object> getModel() throws ProcessingException
099    {
100        return _searchModel.getModel();
101    }
102    
103    /**
104     * Execute a search based on the provided parameters.
105     * Only results with failure will be searched
106     * 
107     * Parameters must include :
108     * <ul>
109     * <li><code>start</code> and <code>limit</code> for pagination</li>
110     * <li><code>sort</code> for sort criteria definition</li>
111     * <li><code>values</code> for the criteria definition</li>
112     * </ul>
113     * 
114     * @param jsonParams the json params
115     * @return json representation of the results based on {@link ContentConsistencySearchModel}
116     * @throws ProcessingException if an error occurs while processing search model
117     */
118    @Callable(rights = CMS_RIGHTS_TOOLS_GLOBAL_CONSISTENCY)
119    public Map<String, Object> searchResults(Map<String, Object> jsonParams) throws ProcessingException
120    {
121        int offset = (int) jsonParams.getOrDefault("start", 0);
122        int limit = (int) jsonParams.getOrDefault("limit", Integer.MAX_VALUE);
123        List<Object> sorters = _jsonUtils.convertJsonToList((String) jsonParams.getOrDefault("sort", "[]"));
124        SortCriteria sortCriteria = _getSortCriteria(sorters);
125        
126        @SuppressWarnings("unchecked")
127        Map<String, Object> criteria = (Map<String, Object>) jsonParams.getOrDefault("values", Map.of());
128        List<Expression> criteriaExpressions = _getCriteriaExpressions(criteria);
129        
130        @SuppressWarnings("unchecked")
131        Map<String, Object> facetValues = (Map<String, Object>) jsonParams.getOrDefault("facetValues", Map.of());
132        criteriaExpressions.addAll(_getCriteriaExpressions(facetValues));
133        
134        Expression expression = getExpression(criteriaExpressions);
135        String xPathQuery = QueryHelper.getXPathQuery(null, "ametys:consistencyResult", expression, sortCriteria);
136        try (AmetysObjectIterable<ContentConsistencyResult> results = _resolver.query(xPathQuery))
137        {
138            return buildSearchResults(results, offset, limit);
139        }
140    }
141    
142    /**
143     * Get the final expression based on the list of criteria expression
144     * @param criteriaExpressions a list of expressions
145     * @return an expression or null if the list is empty
146     */
147    protected Expression getExpression(List<Expression> criteriaExpressions)
148    {
149        return criteriaExpressions.isEmpty() ? null : new AndExpression(criteriaExpressions.toArray(new Expression[0]));
150    }
151
152    private List<Expression> _getCriteriaExpressions(Map<String, Object> criteria)
153    {
154        List<Expression> expressions = new ArrayList<>();
155        for (Entry<String, Object> criterion : criteria.entrySet())
156        {
157            String dataPath = criterion.getKey();
158            Object value = criterion.getValue();
159            if (value instanceof List list)
160            {
161                List<Expression> orExpressions = list.stream()
162                    .map(v -> _getCriterionExpression(dataPath, v))
163                    .filter(Objects::nonNull)
164                    .toList();
165                if (!orExpressions.isEmpty())
166                {
167                    expressions.add(new OrExpression(orExpressions.toArray(new Expression[0])));
168                }
169            }
170            else
171            {
172                Expression criterionExpression = _getCriterionExpression(dataPath, value);
173                if (criterionExpression != null)
174                {
175                    expressions.add(criterionExpression);
176                }
177            }
178        }
179        return expressions;
180    }
181
182    private Expression _getCriterionExpression(String dataPath, Object value)
183    {
184        Expression expression = null;
185        switch (dataPath)
186        {
187            case "workflowStep":
188                Integer step = (Integer) value;
189                if (step != null && step != 0)
190                {
191                    expression = new LongExpression(dataPath, Operator.EQ, step);
192                }
193                break;
194            case "contentTypes":
195                String str = (String) value;
196                if (StringUtils.isNotBlank(str))
197                {
198                    expression = new StringExpression(dataPath, Operator.EQ, str);
199                }
200                break;
201            case "contributor":
202            case ContentConsistencyModel.CREATOR:
203            case ContentConsistencyModel.LAST_VALIDATOR:
204            case ContentConsistencyModel.LAST_MAJOR_VALIDATOR:
205                if (value instanceof String strValue)
206                {
207                    expression = new UserExpression(dataPath, Operator.EQ, UserIdentity.stringToUserIdentity(strValue));
208                }
209                else
210                {
211                    @SuppressWarnings("unchecked") Map<String, Object> json = (Map<String, Object>) value;
212                    if (json != null)
213                    {
214                        expression = new UserExpression(dataPath, Operator.EQ, _userHelper.json2userIdentity(json));
215                    }
216                }
217                break;
218            case "title":
219                str = (String) value;
220                if (StringUtils.isNotBlank(str))
221                {
222                    expression = new StringExpression(dataPath, Operator.WD, str);
223                }
224                break;
225            default :
226                throw new UnsupportedOperationException("datapath " + dataPath + " is not a supported criterion.");
227        }
228        return expression;
229    }
230
231    private SortCriteria _getSortCriteria(List<Object> sorters)
232    {
233        SortCriteria sortCriteria = new SortCriteria();
234        for (Object sorter : sorters)
235        {
236            if (sorter instanceof Map sorterMap)
237            {
238                sortCriteria.addCriterion((String) sorterMap.get("property"), StringUtils.equals("ASC", (String) sorterMap.get("direction")), false);
239            }
240        }
241        return sortCriteria;
242    }
243    
244    private Map<String, Object> buildSearchResults(AmetysObjectIterable<ContentConsistencyResult> results, int offset, int limit) throws ProcessingException
245    {
246        ArrayList<Map<String, Object>> searchResults = new ArrayList<>((int) results.getSize());
247        Map<String, Object> model = _searchModel.getModel();
248        @SuppressWarnings("unchecked")
249        List<Map<String, Object>> columns = (List<Map<String, Object>>) model.get("columns");
250        @SuppressWarnings("unchecked")
251        List<Map<String, Object>> facets = (List<Map<String, Object>>) model.get("facets");
252        Map<String, Map<Object, Map<String, Object>>> computedFacets = new HashMap<>();
253        
254        int resultIdx = -1;
255        for (ContentConsistencyResult result : results)
256        {
257            try
258            {
259                // add the results if they are inside the pagination interval
260                if (offset <= ++resultIdx && resultIdx < offset + limit) // increment index before anything to ensure that the value is consistent inside the loop
261                {
262                    searchResults.add(_contentConsistencyManager.resultToJSON(result, columns));
263                }
264                
265                // Compute facets
266                for (Map<String, Object> facet : facets)
267                {
268                    String facetId = (String) facet.get("name");
269                    Map<Object, Map<String, Object>> computedFacet = computedFacets.computeIfAbsent(facetId, __ -> new HashMap<>());
270                    switch (facetId)
271                    {
272                        case ContentConsistencyModel.CONTENT_TYPES:
273                            _updateContentTypesFacet(facetId, computedFacet, result);
274                            break;
275                        case ContentConsistencyModel.CREATOR :
276                        case ContentConsistencyModel.LAST_VALIDATOR :
277                        case ContentConsistencyModel.LAST_MAJOR_VALIDATOR :
278                        case ContentConsistencyModel.LAST_CONTRIBUTOR :
279                            _updateUserFacet(facetId, computedFacet, result);
280                            break;
281                        case ContentConsistencyModel.WORKFLOW_STEP :
282                            _updateWorkflowStepFacet(facetId, computedFacet, result);
283                            break;
284                        default :
285                            throw new UnsupportedOperationException("facet '" + facetId + "' is not a supported facets");
286                    }
287                }
288            }
289            catch (UnknownAmetysObjectException e)
290            {
291                getLogger().info("A consistency result was describing the result of the inexisting content '{}' and was ignored", result.getContentId(), e);
292                // decrease the number of included content as the result was not actually included
293                resultIdx--;
294            }
295        }
296        
297        // inject computed value in facets
298        for (Map<String, Object> facet : facets)
299        {
300            @SuppressWarnings("unchecked")
301            List<Map<String, Object>> facetValues = (List<Map<String, Object>>) facet.get("children");
302            Map<Object, Map<String, Object>> facetValue = computedFacets.get(facet.get("name"));
303            // will be the case when there is no results
304            if (facetValue != null)
305            {
306                facetValues.addAll(facetValue.values());
307            }
308        }
309        
310        return  Map.of(
311                "consistencyResults", searchResults,
312                "facets", facets,
313                "total", resultIdx + 1); // use result index here to take into account ignored results
314    }
315    
316    private void _updateWorkflowStepFacet(String facetId, Map<Object, Map<String, Object>> computedFacet, ContentConsistencyResult result)
317    {
318        Long value = result.getValue(facetId);
319        if (value != null)
320        {
321            computedFacet.compute(value, this::_incrementWorkflowStepFacetValue);
322        }
323    }
324    
325    private Map<String, Object> _incrementWorkflowStepFacetValue(Object value, Map<String, Object> existingFacetValue)
326    {
327        if (existingFacetValue == null)
328        {
329            // Create a new facet value and return it
330            Map<String, Object> newValue = new HashMap<>();
331            newValue.put("value", value);
332            Long stepId = (Long) value;
333            
334            WorkflowDescriptor defaultWorkflow = _workflowHelper.getWorkflowDescriptor("content");
335            // Use the default 'content' workflow to retrieve the label.
336            // We can only have one label for a step id. So we try to take it from content.
337            // Even if the content might actually use a different workflow.
338            // If the step is not available in the 'content' workflow, use the value as a fallback
339            StepDescriptor step = defaultWorkflow != null ? defaultWorkflow.getStep(stepId.intValue()) : null;
340            if (step != null)
341            {
342                newValue.put("label", new I18nizableText(null, step.getName()));
343            }
344            else
345            {
346                newValue.put("label", value);
347            }
348
349            newValue.put("count", 1L);
350            newValue.put("type", "facet");
351            return newValue;
352        }
353        else
354        {
355            existingFacetValue.compute("count", (k, v) -> ((Long) v) + 1);
356            return existingFacetValue;
357        }
358    }
359    
360    private void _updateContentTypesFacet(String facetId, Map<Object, Map<String, Object>> computedFacet, ContentConsistencyResult result)
361    {
362        String[] typeIds = result.getValue(facetId);
363        for (String typeId : typeIds)
364        {
365            computedFacet.compute(typeId, this::_incrementContentTypeFacetValue);
366        }
367    }
368    
369    private Map<String, Object> _incrementContentTypeFacetValue(Object value, Map<String, Object> existingFacetValue)
370    {
371        if (existingFacetValue == null)
372        {
373            // Create a new facet value and return it
374            Map<String, Object> newValue = new HashMap<>();
375            newValue.put("value", value);
376            ContentType contentType = _cTypeEP.getExtension((String) value);
377            newValue.put("label", contentType != null ? contentType.getLabel() : value);
378            newValue.put("count", 1L);
379            newValue.put("type", "facet");
380            return newValue;
381        }
382        else
383        {
384            existingFacetValue.compute("count", (k, v) -> ((Long) v) + 1);
385            return existingFacetValue;
386        }
387    }
388    
389    private void _updateUserFacet(String facetId, Map<Object, Map<String, Object>> computedFacet, ContentConsistencyResult result)
390    {
391        UserIdentity user = result.getValue(facetId);
392        if (user != null)
393        {
394            computedFacet.compute(user, this::_incrementUserFacetValue);
395        }
396    }
397    
398    private Map<String, Object> _incrementUserFacetValue(Object value, Map<String, Object> existingFacetValue)
399    {
400        if (existingFacetValue == null)
401        {
402            // Create a new facet value and return it
403            Map<String, Object> newValue = new HashMap<>();
404            newValue.put("value", UserIdentity.userIdentityToString((UserIdentity) value));
405            newValue.put("label", _userHelper.getUserFullName((UserIdentity) value));
406            newValue.put("count", 1L);
407            newValue.put("type", "facet");
408            return newValue;
409        }
410        else
411        {
412            existingFacetValue.compute("count", (k, v) -> ((Long) v) + 1);
413            return existingFacetValue;
414        }
415    }
416}