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