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