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}