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}