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 ContentConsistencySearchModel _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 = (ContentConsistencySearchModel) manager.lookup(ContentConsistencySearchModel.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 * Get the search model for content consistency result. 090 * 091 * @implNote this method is a wrapper for {@link ContentConsistencySearchModel#getModel()} 092 * with the addition of being a {@link Callable} 093 * 094 * @return the search model 095 * @throws ProcessingException if an error occurs 096 */ 097 @Callable(rights = "CMS_Rights_Tools_GlobalConsistency") 098 public Map<String, Object> getModel() throws ProcessingException 099 { 100 return _searchModel.getModel(); 101 } 102 103 /** 104 * Execute a search based on the provided parameters. 105 * Only results with failure will be searched 106 * 107 * Parameters must include : 108 * <ul> 109 * <li><code>start</code> and <code>limit</code> for pagination</li> 110 * <li><code>sort</code> for sort criteria definition</li> 111 * <li><code>values</code> for the criteria definition</li> 112 * </ul> 113 * 114 * @param jsonParams the json params 115 * @return json representation of the results based on {@link ContentConsistencySearchModel} 116 * @throws ProcessingException if an error occurs while processing search model 117 */ 118 @Callable(rights = CMS_RIGHTS_TOOLS_GLOBAL_CONSISTENCY) 119 public Map<String, Object> searchResults(Map<String, Object> jsonParams) throws ProcessingException 120 { 121 int offset = (int) jsonParams.getOrDefault("start", 0); 122 int limit = (int) jsonParams.getOrDefault("limit", Integer.MAX_VALUE); 123 List<Object> sorters = _jsonUtils.convertJsonToList((String) jsonParams.getOrDefault("sort", "[]")); 124 SortCriteria sortCriteria = _getSortCriteria(sorters); 125 126 @SuppressWarnings("unchecked") 127 Map<String, Object> criteria = (Map<String, Object>) jsonParams.getOrDefault("values", Map.of()); 128 List<Expression> criteriaExpressions = _getCriteriaExpressions(criteria); 129 130 @SuppressWarnings("unchecked") 131 Map<String, Object> facetValues = (Map<String, Object>) jsonParams.getOrDefault("facetValues", Map.of()); 132 criteriaExpressions.addAll(_getCriteriaExpressions(facetValues)); 133 134 Expression expression = getExpression(criteriaExpressions); 135 String xPathQuery = QueryHelper.getXPathQuery(null, "ametys:consistencyResult", expression, sortCriteria); 136 try (AmetysObjectIterable<ContentConsistencyResult> results = _resolver.query(xPathQuery)) 137 { 138 return buildSearchResults(results, offset, limit); 139 } 140 } 141 142 /** 143 * Get the final expression based on the list of criteria expression 144 * @param criteriaExpressions a list of expressions 145 * @return an expression or null if the list is empty 146 */ 147 protected Expression getExpression(List<Expression> criteriaExpressions) 148 { 149 return criteriaExpressions.isEmpty() ? null : new AndExpression(criteriaExpressions.toArray(new Expression[0])); 150 } 151 152 private List<Expression> _getCriteriaExpressions(Map<String, Object> criteria) 153 { 154 List<Expression> expressions = new ArrayList<>(); 155 for (Entry<String, Object> criterion : criteria.entrySet()) 156 { 157 String dataPath = criterion.getKey(); 158 Object value = criterion.getValue(); 159 if (value instanceof List list) 160 { 161 List<Expression> orExpressions = list.stream() 162 .map(v -> _getCriterionExpression(dataPath, v)) 163 .filter(Objects::nonNull) 164 .toList(); 165 if (!orExpressions.isEmpty()) 166 { 167 expressions.add(new OrExpression(orExpressions.toArray(new Expression[0]))); 168 } 169 } 170 else 171 { 172 Expression criterionExpression = _getCriterionExpression(dataPath, value); 173 if (criterionExpression != null) 174 { 175 expressions.add(criterionExpression); 176 } 177 } 178 } 179 return expressions; 180 } 181 182 private Expression _getCriterionExpression(String dataPath, Object value) 183 { 184 Expression expression = null; 185 switch (dataPath) 186 { 187 case "workflowStep": 188 Integer step = (Integer) value; 189 if (step != null && step != 0) 190 { 191 expression = new LongExpression(dataPath, Operator.EQ, step); 192 } 193 break; 194 case "contentTypes": 195 String str = (String) value; 196 if (StringUtils.isNotBlank(str)) 197 { 198 expression = new StringExpression(dataPath, Operator.EQ, str); 199 } 200 break; 201 case "contributor": 202 case ContentConsistencyModel.CREATOR: 203 case ContentConsistencyModel.LAST_VALIDATOR: 204 case ContentConsistencyModel.LAST_MAJOR_VALIDATOR: 205 if (value instanceof String strValue) 206 { 207 expression = new UserExpression(dataPath, Operator.EQ, UserIdentity.stringToUserIdentity(strValue)); 208 } 209 else 210 { 211 @SuppressWarnings("unchecked") Map<String, Object> json = (Map<String, Object>) value; 212 if (json != null) 213 { 214 expression = new UserExpression(dataPath, Operator.EQ, _userHelper.json2userIdentity(json)); 215 } 216 } 217 break; 218 case "title": 219 str = (String) value; 220 if (StringUtils.isNotBlank(str)) 221 { 222 expression = new StringExpression(dataPath, Operator.WD, str); 223 } 224 break; 225 default : 226 throw new UnsupportedOperationException("datapath " + dataPath + " is not a supported criterion."); 227 } 228 return expression; 229 } 230 231 private SortCriteria _getSortCriteria(List<Object> sorters) 232 { 233 SortCriteria sortCriteria = new SortCriteria(); 234 for (Object sorter : sorters) 235 { 236 if (sorter instanceof Map sorterMap) 237 { 238 sortCriteria.addCriterion((String) sorterMap.get("property"), StringUtils.equals("ASC", (String) sorterMap.get("direction")), false); 239 } 240 } 241 return sortCriteria; 242 } 243 244 private Map<String, Object> buildSearchResults(AmetysObjectIterable<ContentConsistencyResult> results, int offset, int limit) throws ProcessingException 245 { 246 ArrayList<Map<String, Object>> searchResults = new ArrayList<>((int) results.getSize()); 247 Map<String, Object> model = _searchModel.getModel(); 248 @SuppressWarnings("unchecked") 249 List<Map<String, Object>> columns = (List<Map<String, Object>>) model.get("columns"); 250 @SuppressWarnings("unchecked") 251 List<Map<String, Object>> facets = (List<Map<String, Object>>) model.get("facets"); 252 Map<String, Map<Object, Map<String, Object>>> computedFacets = new HashMap<>(); 253 254 int resultIdx = -1; 255 for (ContentConsistencyResult result : results) 256 { 257 try 258 { 259 // add the results if they are inside the pagination interval 260 if (offset <= ++resultIdx && resultIdx < offset + limit) // increment index before anything to ensure that the value is consistent inside the loop 261 { 262 searchResults.add(_contentConsistencyManager.resultToJSON(result, columns)); 263 } 264 265 // Compute facets 266 for (Map<String, Object> facet : facets) 267 { 268 String facetId = (String) facet.get("name"); 269 Map<Object, Map<String, Object>> computedFacet = computedFacets.computeIfAbsent(facetId, __ -> new HashMap<>()); 270 switch (facetId) 271 { 272 case ContentConsistencyModel.CONTENT_TYPES: 273 _updateContentTypesFacet(facetId, computedFacet, result); 274 break; 275 case ContentConsistencyModel.CREATOR : 276 case ContentConsistencyModel.LAST_VALIDATOR : 277 case ContentConsistencyModel.LAST_MAJOR_VALIDATOR : 278 case ContentConsistencyModel.LAST_CONTRIBUTOR : 279 _updateUserFacet(facetId, computedFacet, result); 280 break; 281 case ContentConsistencyModel.WORKFLOW_STEP : 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", resultIdx + 1); // use result index here to take into account ignored results 314 } 315 316 private void _updateWorkflowStepFacet(String facetId, Map<Object, Map<String, Object>> computedFacet, ContentConsistencyResult result) 317 { 318 Long value = result.getValue(facetId); 319 if (value != null) 320 { 321 computedFacet.compute(value, this::_incrementWorkflowStepFacetValue); 322 } 323 } 324 325 private Map<String, Object> _incrementWorkflowStepFacetValue(Object value, Map<String, Object> existingFacetValue) 326 { 327 if (existingFacetValue == null) 328 { 329 // Create a new facet value and return it 330 Map<String, Object> newValue = new HashMap<>(); 331 newValue.put("value", value); 332 Long stepId = (Long) value; 333 334 WorkflowDescriptor defaultWorkflow = _workflowHelper.getWorkflowDescriptor("content"); 335 // Use the default 'content' workflow to retrieve the label. 336 // We can only have one label for a step id. So we try to take it from content. 337 // Even if the content might actually use a different workflow. 338 // If the step is not available in the 'content' workflow, use the value as a fallback 339 StepDescriptor step = defaultWorkflow != null ? defaultWorkflow.getStep(stepId.intValue()) : null; 340 if (step != null) 341 { 342 newValue.put("label", new I18nizableText(null, step.getName())); 343 } 344 else 345 { 346 newValue.put("label", value); 347 } 348 349 newValue.put("count", 1L); 350 newValue.put("type", "facet"); 351 return newValue; 352 } 353 else 354 { 355 existingFacetValue.compute("count", (k, v) -> ((Long) v) + 1); 356 return existingFacetValue; 357 } 358 } 359 360 private void _updateContentTypesFacet(String facetId, Map<Object, Map<String, Object>> computedFacet, ContentConsistencyResult result) 361 { 362 String[] typeIds = result.getValue(facetId); 363 for (String typeId : typeIds) 364 { 365 computedFacet.compute(typeId, this::_incrementContentTypeFacetValue); 366 } 367 } 368 369 private Map<String, Object> _incrementContentTypeFacetValue(Object value, Map<String, Object> existingFacetValue) 370 { 371 if (existingFacetValue == null) 372 { 373 // Create a new facet value and return it 374 Map<String, Object> newValue = new HashMap<>(); 375 newValue.put("value", value); 376 ContentType contentType = _cTypeEP.getExtension((String) value); 377 newValue.put("label", contentType != null ? contentType.getLabel() : value); 378 newValue.put("count", 1L); 379 newValue.put("type", "facet"); 380 return newValue; 381 } 382 else 383 { 384 existingFacetValue.compute("count", (k, v) -> ((Long) v) + 1); 385 return existingFacetValue; 386 } 387 } 388 389 private void _updateUserFacet(String facetId, Map<Object, Map<String, Object>> computedFacet, ContentConsistencyResult result) 390 { 391 UserIdentity user = result.getValue(facetId); 392 if (user != null) 393 { 394 computedFacet.compute(user, this::_incrementUserFacetValue); 395 } 396 } 397 398 private Map<String, Object> _incrementUserFacetValue(Object value, Map<String, Object> existingFacetValue) 399 { 400 if (existingFacetValue == null) 401 { 402 // Create a new facet value and return it 403 Map<String, Object> newValue = new HashMap<>(); 404 newValue.put("value", UserIdentity.userIdentityToString((UserIdentity) value)); 405 newValue.put("label", _userHelper.getUserFullName((UserIdentity) value)); 406 newValue.put("count", 1L); 407 newValue.put("type", "facet"); 408 return newValue; 409 } 410 else 411 { 412 existingFacetValue.compute("count", (k, v) -> ((Long) v) + 1); 413 return existingFacetValue; 414 } 415 } 416}