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}