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.commons.lang3.StringUtils;
030
031import org.ametys.cms.contenttype.ContentType;
032import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
033import org.ametys.cms.repository.Content;
034import org.ametys.cms.repository.WorkflowAwareContent;
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.model.exception.UndefinedItemPathException;
054import org.ametys.runtime.plugin.component.AbstractLogEnabled;
055
056import com.opensymphony.workflow.loader.StepDescriptor;
057import com.opensymphony.workflow.loader.WorkflowDescriptor;
058
059/**
060 * Execute JCR query to search for content consistency result
061 */
062public class ContentConsistencySearcher extends AbstractLogEnabled implements Serviceable, Component
063{
064    /** the avalon role */
065    public static final String ROLE = ContentConsistencySearcher.class.getName();
066    
067    private ContentTypeExtensionPoint _cTypeEP;
068    private JSONUtils _jsonUtils;
069    private AmetysObjectResolver _resolver;
070    private ContentConstitencySearchModel _searchModel;
071    private UserHelper _userHelper;
072    private WorkflowHelper _workflowHelper;
073
074    public void service(ServiceManager manager) throws ServiceException
075    {
076        _cTypeEP = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE);
077        _jsonUtils = (JSONUtils) manager.lookup(JSONUtils.ROLE);
078        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
079        _searchModel = (ContentConstitencySearchModel) manager.lookup(ContentConstitencySearchModel.ROLE);
080        _userHelper = (UserHelper) manager.lookup(UserHelper.ROLE);
081        _workflowHelper = (WorkflowHelper) manager.lookup(WorkflowHelper.ROLE);
082    }
083    
084    /**
085     * Execute a search based on the provided parameters.
086     * Only results with failure will be searched
087     * 
088     * Parameters must include :
089     * <ul>
090     * <li><code>start</code> and <code>limit</code> for pagination</li>
091     * <li><code>sort</code> for sort criteria definition</li>
092     * <li><code>values</code> for the criteria definition</li>
093     * </ul>
094     * 
095     * @param jsonParams the json params
096     * @return json representation of the results based on {@link ContentConstitencySearchModel}
097     */
098    @Callable
099    public Map<String, Object> searchResults(Map<String, Object> jsonParams)
100    {
101        int offset = (int) jsonParams.getOrDefault("start", 0);
102        int limit = (int) jsonParams.getOrDefault("limit", Integer.MAX_VALUE);
103        List<Object> sorters = _jsonUtils.convertJsonToList((String) jsonParams.getOrDefault("sort", "[]"));
104        SortCriteria sortCriteria = _getSortCriteria(sorters);
105        
106        @SuppressWarnings("unchecked")
107        Map<String, Object> criteria = (Map<String, Object>) jsonParams.getOrDefault("values", Map.of());
108        List<Expression> criteriaExpressions = _getCriteriaExpressions(criteria);
109        
110        @SuppressWarnings("unchecked")
111        Map<String, Object> facetValues = (Map<String, Object>) jsonParams.getOrDefault("facetValues", Map.of());
112        criteriaExpressions.addAll(_getCriteriaExpressions(facetValues));
113        
114        Expression expression = getExpression(criteriaExpressions);
115        String xPathQuery = QueryHelper.getXPathQuery(null, "ametys:consistencyResult", expression, sortCriteria);
116        try (AmetysObjectIterable<ContentConsistencyResult> results = _resolver.query(xPathQuery))
117        {
118            return buildSearchResults(results, offset, limit);
119        }
120    }
121
122    /**
123     * Get the final expression based on the list of criteria expression
124     * @param criteriaExpressions a list of expressions
125     * @return an expression or null if the list is empty
126     */
127    protected Expression getExpression(List<Expression> criteriaExpressions)
128    {
129        return criteriaExpressions.isEmpty() ? null : new AndExpression(criteriaExpressions.toArray(new Expression[0]));
130    }
131
132    private List<Expression> _getCriteriaExpressions(Map<String, Object> criteria)
133    {
134        List<Expression> expressions = new ArrayList<>();
135        for (Entry<String, Object> criterion : criteria.entrySet())
136        {
137            String dataPath = criterion.getKey();
138            Object value = criterion.getValue();
139            if (value instanceof List list)
140            {
141                List<Expression> orExpressions = list.stream()
142                    .map(v -> _getCriterionExpression(dataPath, v))
143                    .filter(Objects::nonNull)
144                    .toList();
145                if (!orExpressions.isEmpty())
146                {
147                    expressions.add(new OrExpression(orExpressions.toArray(new Expression[0])));
148                }
149            }
150            else
151            {
152                Expression criterionExpression = _getCriterionExpression(dataPath, value);
153                if (criterionExpression != null)
154                {
155                    expressions.add(criterionExpression);
156                }
157            }
158        }
159        return expressions;
160    }
161
162    private Expression _getCriterionExpression(String dataPath, Object value)
163    {
164        Expression expression = null;
165        switch (dataPath)
166        {
167            case "workflowStep":
168                Integer step = (Integer) value;
169                if (step != null && step != 0)
170                {
171                    expression = new LongExpression(dataPath, Operator.EQ, step);
172                }
173                break;
174            case "contentTypes":
175                String str = (String) value;
176                if (StringUtils.isNotBlank(str))
177                {
178                    expression = new StringExpression(dataPath, Operator.EQ, str);
179                }
180                break;
181            case "contributor":
182                if (value instanceof String strValue)
183                {
184                    expression = new UserExpression(dataPath, Operator.EQ, UserIdentity.stringToUserIdentity(strValue));
185                }
186                else
187                {
188                    @SuppressWarnings("unchecked") Map<String, Object> json = (Map<String, Object>) value;
189                    if (json != null)
190                    {
191                        expression = new UserExpression(dataPath, Operator.EQ, _userHelper.json2userIdentity(json));
192                    }
193                }
194                break;
195            case "title":
196                str = (String) value;
197                if (StringUtils.isNotBlank(str))
198                {
199                    expression = new StringExpression(dataPath, Operator.WD, str);
200                }
201                break;
202            default :
203                throw new UnsupportedOperationException("datapath " + dataPath + " is not a supported criterion.");
204        }
205        return expression;
206    }
207
208    private SortCriteria _getSortCriteria(List<Object> sorters)
209    {
210        SortCriteria sortCriteria = new SortCriteria();
211        for (Object sorter : sorters)
212        {
213            if (sorter instanceof Map sorterMap)
214            {
215                sortCriteria.addCriterion((String) sorterMap.get("property"), StringUtils.equals("ASC", (String) sorterMap.get("direction")), false);
216            }
217        }
218        return sortCriteria;
219    }
220    
221    private Map<String, Object> buildSearchResults(AmetysObjectIterable<ContentConsistencyResult> results, int offset, int limit)
222    {
223        ArrayList<Map<String, Object>> searchResults = new ArrayList<>((int) results.getSize());
224        Map<String, Object> model = _searchModel.getModel();
225        @SuppressWarnings("unchecked")
226        List<Map<String, Object>> columns = (List<Map<String, Object>>) model.get("columns");
227        @SuppressWarnings("unchecked")
228        List<Map<String, Object>> facets = (List<Map<String, Object>>) model.get("facets");
229        Map<String, Map<Object, Map<String, Object>>> computedFacets = new HashMap<>();
230        
231        int resultIdx = 0;
232        for (ContentConsistencyResult result : results)
233        {
234            try
235            {
236                // add the results if they are inside the pagination interval
237                if (offset <= resultIdx && resultIdx++ < offset + limit)
238                {
239                    Map<String, Object> searchResult = result.toJSON();
240                    
241                    Content content = _resolver.resolveById(result.getContentId());
242                    // add special case to results
243                    searchResult.put("workflowStep", _workflowStepToJSON(result.getWorkflowStep(), content));
244                    searchResult.put("contributor", _userHelper.user2json(content.getLastContributor()));
245                    searchResult.put("contentTypes", _contentTypeToJSON(content.getTypes()));
246                    
247                    String[] resultColumn = searchResult.keySet().toArray(new String[0]);
248                    
249                    for (Map<String, Object> column : columns)
250                    {
251                        String columnId = (String) column.get("id");
252                        if (!StringUtils.equalsAny(columnId, resultColumn))
253                        {
254                            try
255                            {
256                                searchResult.put(columnId, content.getValue(columnId));
257                            }
258                            catch (UndefinedItemPathException e)
259                            {
260                                getLogger().info("item path {} is not defined for content {}. It will be ignored in the result.", columnId, content.getId());
261                            }
262                        }
263                    }
264                    
265                    searchResults.add(searchResult);
266                }
267                
268                // Compute facets
269                for (Map<String, Object> facet : facets)
270                {
271                    String facetId = (String) facet.get("name");
272                    Map<Object, Map<String, Object>> computedFacet = computedFacets.computeIfAbsent(facetId, __ -> new HashMap<>());
273                    switch (facetId)
274                    {
275                        case "contentTypes":
276                            _updateContentTypesFacet(facetId, computedFacet, result);
277                            break;
278                        case "contributor" :
279                            _updateUserFacet(facetId, computedFacet, result);
280                            break;
281                        case "workflowStep" :
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", results.getSize());
314    }
315    
316    private void _updateWorkflowStepFacet(String facetId, Map<Object, Map<String, Object>> computedFacet, ContentConsistencyResult result)
317    {
318        Object value = result.getValue(facetId);
319        computedFacet.compute(value, this::_incrementWorkflowStepFacetValue);
320    }
321    
322    private Map<String, Object> _incrementWorkflowStepFacetValue(Object value, Map<String, Object> existingFacetValue)
323    {
324        if (existingFacetValue == null)
325        {
326            // Create a new facet value and return it
327            Map<String, Object> newValue = new HashMap<>();
328            newValue.put("value", value);
329            Long stepId = (Long) value;
330            
331            WorkflowDescriptor defaultWorkflow = _workflowHelper.getWorkflowDescriptor("content");
332            if (defaultWorkflow == null)
333            {
334                newValue.put("label", value);
335            }
336            else
337            {
338                StepDescriptor step = defaultWorkflow.getStep(stepId.intValue());
339                newValue.put("label", new I18nizableText(null, step.getName()));
340            }
341            newValue.put("count", 1L);
342            newValue.put("type", "facet");
343            return newValue;
344        }
345        else
346        {
347            existingFacetValue.compute("count", (k, v) -> ((Long) v) + 1);
348            return existingFacetValue;
349        }
350    }
351    
352    private void _updateContentTypesFacet(String facetId, Map<Object, Map<String, Object>> computedFacet, ContentConsistencyResult result)
353    {
354        String[] typeIds = result.getValue(facetId);
355        for (String typeId : typeIds)
356        {
357            computedFacet.compute(typeId, this::_incrementContentTypeFacetValue);
358        }
359    }
360    
361    private Map<String, Object> _incrementContentTypeFacetValue(Object value, Map<String, Object> existingFacetValue)
362    {
363        if (existingFacetValue == null)
364        {
365            // Create a new facet value and return it
366            Map<String, Object> newValue = new HashMap<>();
367            newValue.put("value", value);
368            ContentType contentType = _cTypeEP.getExtension((String) value);
369            newValue.put("label", contentType != null ? contentType.getLabel() : value);
370            newValue.put("count", 1L);
371            newValue.put("type", "facet");
372            return newValue;
373        }
374        else
375        {
376            existingFacetValue.compute("count", (k, v) -> ((Long) v) + 1);
377            return existingFacetValue;
378        }
379    }
380    
381    private void _updateUserFacet(String facetId, Map<Object, Map<String, Object>> computedFacet, ContentConsistencyResult result)
382    {
383        computedFacet.compute(result.getValue(facetId), this::_incrementUserFacetValue);
384    }
385    
386    private Map<String, Object> _incrementUserFacetValue(Object value, Map<String, Object> existingFacetValue)
387    {
388        if (existingFacetValue == null)
389        {
390            // Create a new facet value and return it
391            Map<String, Object> newValue = new HashMap<>();
392            newValue.put("value", UserIdentity.userIdentityToString((UserIdentity) value));
393            newValue.put("label", _userHelper.getUserFullName((UserIdentity) value));
394            newValue.put("count", 1L);
395            newValue.put("type", "facet");
396            return newValue;
397        }
398        else
399        {
400            existingFacetValue.compute("count", (k, v) -> ((Long) v) + 1);
401            return existingFacetValue;
402        }
403    }
404    
405    private Object _contentTypeToJSON(String[] typeIds)
406    {
407        List<Map<String, Object>> jsonValues = new ArrayList<>(typeIds.length);
408        for (String id : typeIds)
409        {
410            ContentType contentType = _cTypeEP.getExtension(id);
411            Map<String, Object> jsonValue = new HashMap<>();
412            if (contentType != null)
413            {
414                jsonValue.put("label", contentType.getLabel());
415                jsonValue.put("smallIcon", contentType.getSmallIcon());
416                
417                String iconGlyph = contentType.getIconGlyph();
418                if (iconGlyph != null)
419                {
420                    jsonValue.put("iconGlyph", iconGlyph);
421                    String iconDecorator = contentType.getIconDecorator();
422                    if (iconDecorator != null)
423                    {
424                        jsonValue.put("iconDecorator", iconDecorator);
425                    }
426                }
427            }
428            jsonValues.add(jsonValue);
429        }
430        return jsonValues;
431    }
432
433    private Object _workflowStepToJSON(long value, Content content)
434    {
435        if (content instanceof WorkflowAwareContent)
436        {
437            WorkflowAwareContent waContent = (WorkflowAwareContent) content;
438            int currentStepId = Math.toIntExact(value);
439            
440            StepDescriptor stepDescriptor = _workflowHelper.getStepDescriptor(waContent, currentStepId);
441            
442            if (stepDescriptor != null)
443            {
444                return _workflowHelper.workflowStep2JSON(stepDescriptor);
445            }
446        }
447        
448        return Map.of();
449    }
450}