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