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