001/* 002 * Copyright 2024 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.odf; 017 018import java.util.ArrayList; 019import java.util.Collections; 020import java.util.HashMap; 021import java.util.List; 022import java.util.Map; 023import java.util.Map.Entry; 024import java.util.Optional; 025import java.util.Set; 026import java.util.stream.Collectors; 027import java.util.stream.Stream; 028 029import javax.jcr.Node; 030import javax.jcr.NodeIterator; 031import javax.jcr.Repository; 032import javax.jcr.RepositoryException; 033import javax.jcr.Session; 034import javax.jcr.query.InvalidQueryException; 035import javax.jcr.query.Query; 036 037import org.apache.avalon.framework.component.Component; 038import org.apache.avalon.framework.service.ServiceException; 039import org.apache.avalon.framework.service.ServiceManager; 040import org.apache.avalon.framework.service.Serviceable; 041import org.apache.commons.lang.StringUtils; 042import org.apache.commons.lang3.tuple.Pair; 043 044import org.ametys.cms.data.holder.group.IndexableRepeaterEntry; 045import org.ametys.cms.data.holder.group.ModifiableIndexableRepeater; 046import org.ametys.cms.repository.Content; 047import org.ametys.cms.repository.ModifiableContent; 048import org.ametys.cms.repository.WorkflowAwareContent; 049import org.ametys.cms.workflow.ContentWorkflowHelper; 050import org.ametys.cms.workflow.EditContentFunction; 051import org.ametys.cms.workflow.ValidateContentFunction; 052import org.ametys.odf.data.EducationalPath; 053import org.ametys.odf.data.type.EducationalPathRepositoryElementType; 054import org.ametys.plugins.repository.AmetysObjectResolver; 055import org.ametys.plugins.repository.RepositoryConstants; 056import org.ametys.plugins.repository.data.holder.impl.DataHolderHelper; 057import org.ametys.plugins.repository.data.holder.values.SynchronizableValue; 058import org.ametys.plugins.repository.data.holder.values.SynchronizationResult; 059import org.ametys.plugins.repository.model.RepeaterDefinition; 060import org.ametys.plugins.repository.provider.AbstractRepository; 061import org.ametys.plugins.repository.query.QueryHelper; 062import org.ametys.plugins.repository.query.expression.AndExpression; 063import org.ametys.plugins.repository.query.expression.Expression; 064import org.ametys.plugins.repository.query.expression.Expression.Operator; 065import org.ametys.plugins.repository.query.expression.StringExpression; 066import org.ametys.plugins.workflow.AbstractWorkflowComponent; 067import org.ametys.plugins.workflow.component.CheckRightsCondition; 068import org.ametys.runtime.model.ModelItem; 069import org.ametys.runtime.plugin.component.AbstractLogEnabled; 070 071import com.opensymphony.workflow.WorkflowException; 072 073/** 074 * Helper for manipulating {@link EducationalPath} 075 */ 076public class EducationalPathHelper extends AbstractLogEnabled implements Component, Serviceable 077{ 078 /** The avalon role */ 079 public static final String ROLE = EducationalPathHelper.class.getName(); 080 081 /** Constant to get/set the ancestor path (may be partial) of a program item in request attribute */ 082 public static final String PROGRAM_ITEM_ANCESTOR_PATH_REQUEST_ATTR = EducationalPathHelper.class.getName() + "$ancestorPath"; 083 084 /** Constant to get/set the root program item in request attribute */ 085 public static final String ROOT_PROGRAM_ITEM_REQUEST_ATTR = EducationalPathHelper.class.getName() + "$rootProgramItem"; 086 087 private AmetysObjectResolver _resolver; 088 private Repository _repository; 089 090 private ContentWorkflowHelper _workflowHelper; 091 092 public void service(ServiceManager manager) throws ServiceException 093 { 094 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 095 _repository = (Repository) manager.lookup(AbstractRepository.ROLE); 096 _workflowHelper = (ContentWorkflowHelper) manager.lookup(ContentWorkflowHelper.ROLE); 097 } 098 099 /** 100 * Determines if program items is part of a same educational path value 101 * @param programItems the program items to search into a educational path value 102 * @return true if the program items is part of a educational path value 103 * @throws RepositoryException if failed to get educational path nodes 104 */ 105 public boolean isPartOfEducationalPath(ProgramItem... programItems) throws RepositoryException 106 { 107 String[] programItemIds = Stream.of(programItems) 108 .map(ProgramItem::getId) 109 .toArray(String[]::new); 110 return isPartOfEducationalPath(programItemIds); 111 } 112 113 /** 114 * Determines if program items is part of a same educational path value 115 * @param programItemIds the id of program items to search into a educational path value 116 * @return true if the program items is part of a educational path value 117 * @throws RepositoryException if failed to get educational path nodes 118 */ 119 public boolean isPartOfEducationalPath(String... programItemIds) throws RepositoryException 120 { 121 NodeIterator nodeIterator = _getEducationalPathNodes(programItemIds); 122 return nodeIterator.hasNext(); 123 } 124 125 /** 126 * Get the educational paths that reference the given program items 127 * @param programItems the referenced program items to search into a educational path 128 * @return a Map with program items referencing the given program items in a educational path attribute as key and the list of educational path references as value 129 * @throws RepositoryException if an error occurred while retrieving educational path references 130 */ 131 public Map<ProgramItem, List<EducationalPathReference>> getEducationalPathReferences(ProgramItem... programItems) throws RepositoryException 132 { 133 String[] programItemIds = Stream.of(programItems) 134 .map(ProgramItem::getId) 135 .toArray(String[]::new); 136 return getEducationalPathReferences(programItemIds); 137 } 138 139 /** 140 * Get the educational paths that reference the given program items 141 * @param programItemIds the id of program items to search into a educational path 142 * @return a Map with program items referencing the given program items in a educational path attribute as key and the list of educational path references as value 143 * @throws RepositoryException if an error occurred while retrieving educational path references 144 */ 145 public Map<ProgramItem, List<EducationalPathReference>> getEducationalPathReferences(String... programItemIds) throws RepositoryException 146 { 147 Map<ProgramItem, List<EducationalPathReference>> references = new HashMap<>(); 148 149 NodeIterator educationalPathNodesIterator = _getEducationalPathNodes(programItemIds); 150 151 while (educationalPathNodesIterator.hasNext()) 152 { 153 Node educationalPathNode = educationalPathNodesIterator.nextNode(); 154 String educationalPathAttributeName = StringUtils.substringAfter(educationalPathNode.getName(), RepositoryConstants.NAMESPACE_PREFIX + ":"); 155 156 Node repeaterEntryNode = educationalPathNode.getParent(); 157 158 Pair<ProgramItem, String> resolvedEntryPath = _resolveEntryPath(repeaterEntryNode); 159 160 ProgramItem refProgramItem = resolvedEntryPath.getLeft(); 161 if (!references.containsKey(refProgramItem)) 162 { 163 references.put(refProgramItem, new ArrayList<>()); 164 } 165 166 EducationalPath value = ((Content) refProgramItem).getValue(resolvedEntryPath.getRight() + "/" + educationalPathAttributeName); 167 references.get(refProgramItem).add(new EducationalPathReference(refProgramItem, resolvedEntryPath.getRight(), educationalPathAttributeName, value)); 168 } 169 170 return references; 171 } 172 173 /** 174 * Remove all repeater entries with a educational path that reference the given program items 175 * @param programItems the referenced program items to search into a educational path 176 * @throws RepositoryException if an error occurred while retrieving educational path references 177 * @throws WorkflowException if failed to do workflow action on modified contents 178 */ 179 public void removeEducationalPathReferences(ProgramItem... programItems) throws RepositoryException, WorkflowException 180 { 181 String[] programItemIds = Stream.of(programItems) 182 .map(ProgramItem::getId) 183 .toArray(String[]::new); 184 removeEducationalPathReferences(2, programItemIds); 185 } 186 187 /** 188 * Remove all repeater entries with a educational path that reference the given program items 189 * @param programItemIds the id program items to search into a educational path 190 * @throws RepositoryException if an error occurred while retrieving educational path references 191 * @throws WorkflowException if failed to do workflow action on modified contents 192 */ 193 public void removeEducationalPathReferences(List<String> programItemIds) throws RepositoryException, WorkflowException 194 { 195 removeEducationalPathReferences(2, programItemIds.toArray(String[]::new)); 196 } 197 198 /** 199 * Remove all repeater entries with a educational path that reference the given program items 200 * @param workflowActionId The id of the workflow action to do 201 * @param programItemIds the ids of program items to search into a educational path 202 * @throws RepositoryException if an error occurred while retrieving educational path references 203 * @throws WorkflowException if failed to do workflow action on modified contents 204 */ 205 public void removeEducationalPathReferences(int workflowActionId, String... programItemIds) throws RepositoryException, WorkflowException 206 { 207 Map<ProgramItem, List<EducationalPathReference>> educationalPathReferences = getEducationalPathReferences(programItemIds); 208 209 for (Entry<ProgramItem, List<EducationalPathReference>> entry : educationalPathReferences.entrySet()) 210 { 211 ProgramItem refProframItem = entry.getKey(); 212 213 List<EducationalPathReference> references = entry.getValue(); 214 for (EducationalPathReference educationalPathReference : references) 215 { 216 String attributeName = educationalPathReference.attributeName(); 217 EducationalPath value = educationalPathReference.value(); 218 String repeaterEntryPath = educationalPathReference.repeaterEntryPath(); // (ex: rep1[2]/comp/rep1[1]/rep3[2]) 219 String repeaterPath = StringUtils.substringBeforeLast(repeaterEntryPath, "["); // rep1[2]/comp/rep1[1]/rep3 220 221 // Do NOT use repeater entry path to remove it as entry position can change during removing of entries 222 // Find entry with same educational path value 223 ModifiableIndexableRepeater repeater = ((ModifiableContent) refProframItem).getRepeater(repeaterPath); 224 Optional<Integer> entryPos = repeater.getEntries() 225 .stream() 226 .filter(e -> value.equals(e.getValue(attributeName))) 227 .map(e -> e.getPosition()) 228 .findFirst(); 229 230 if (entryPos.isPresent()) 231 { 232 String realEntryPath = repeaterPath + "[" + entryPos.get() + "]"; 233 ((ModifiableContent) refProframItem).removeValue(realEntryPath); 234 } 235 } 236 237 _triggerWorkflowAction((Content) refProframItem, 2); 238 } 239 } 240 241 242 /** 243 * Remove all repeaters with a educational path in their model 244 * @param programItem the program item 245 * @throws WorkflowException if failed to do workflow action on modified contents 246 */ 247 public void removeAllRepeatersWithEducationalPath(ProgramItem programItem) throws WorkflowException 248 { 249 ModifiableContent content = (ModifiableContent) programItem; 250 251 boolean needSave = false; 252 253 Map<String, Object> educationPaths = DataHolderHelper.findItemsByType(content, EducationalPathRepositoryElementType.EDUCATIONAL_PATH_ELEMENT_TYPE_ID); 254 255 // Get path of repeaters with a education path value 256 Set<String> repeaterDataPaths = educationPaths.entrySet().stream() 257 .filter(e -> e.getValue() instanceof EducationalPath) 258 .map(Entry::getKey) // data path 259 .map(dataPath -> StringUtils.substring(dataPath, 0, dataPath.lastIndexOf("/"))) // parent data path 260 .filter(DataHolderHelper::isRepeaterEntryPath) 261 .map(DataHolderHelper::getRepeaterNameAndEntryPosition) // get repeater path 262 .map(Pair::getLeft) 263 .collect(Collectors.toSet()); 264 265 for (String repeaterDataPath : repeaterDataPaths) 266 { 267 RepeaterDefinition repeaterDef = (RepeaterDefinition) content.getDefinition(repeaterDataPath); 268 if (content.hasValue(repeaterDataPath) && !_isInRepeaterWithCommonAttribute(repeaterDef)) // do not remove repeaters with common model item ! 269 { 270 content.removeValue(repeaterDataPath); 271 needSave = true; 272 } 273 } 274 275 if (needSave) 276 { 277 _triggerWorkflowAction(content, 2); 278 } 279 } 280 281 private boolean _isInRepeaterWithCommonAttribute(ModelItem modelItem) 282 { 283 ModelItem parentItem = modelItem; 284 while (parentItem != null) 285 { 286 if (parentItem instanceof RepeaterDefinition repeaterDef) 287 { 288 if (repeaterDef.hasModelItem("common")) 289 { 290 return true; 291 } 292 } 293 parentItem = parentItem.getParent(); 294 } 295 return false; 296 } 297 298 299 /** 300 * Determines if this repeater is a repeater with a educational path 301 * @param repeaterDefinition the repeater definition 302 * @return true if the repeater contains data related to specific an education path 303 */ 304 public boolean isRepeaterWithEducationalPath(RepeaterDefinition repeaterDefinition) 305 { 306 for (ModelItem childItem : repeaterDefinition.getModelItems()) 307 { 308 if (EducationalPathRepositoryElementType.EDUCATIONAL_PATH_ELEMENT_TYPE_ID.equals(childItem.getType().getId())) 309 { 310 return true; 311 } 312 } 313 return false; 314 } 315 316 /** 317 * Browse entries of a repeater with educational path to get their values by educational path 318 * @param content The content 319 * @param repeaterDefinition The repeater definition 320 * @return a Map of {@link EducationalPath} with related values by names 321 */ 322 public Map<EducationalPath, Map<String, Object>> getValuesByEducationalPath(Content content, RepeaterDefinition repeaterDefinition) 323 { 324 Map<EducationalPath, Map<String, Object>> valuesByEducationalPath = new HashMap<>(); 325 326 String repeaterPath = repeaterDefinition.getPath(); 327 if (content.hasValue(repeaterPath) && isRepeaterWithEducationalPath(repeaterDefinition)) 328 { 329 List< ? extends IndexableRepeaterEntry> entries = content.getRepeater(repeaterPath).getEntries(); 330 for (IndexableRepeaterEntry entry : entries) 331 { 332 EducationalPath path = null; 333 Map<String, Object> values = new HashMap<>(); 334 for (ModelItem childItem : repeaterDefinition.getModelItems()) 335 { 336 if (EducationalPathRepositoryElementType.EDUCATIONAL_PATH_ELEMENT_TYPE_ID.equals(childItem.getType().getId())) 337 { 338 path = entry.getValue(childItem.getName()); 339 } 340 else if (entry.hasValue(childItem.getName())) 341 { 342 values.put(childItem.getName(), entry.getValue(childItem.getName())); 343 } 344 } 345 346 if (path != null) 347 { 348 valuesByEducationalPath.put(path, values); 349 } 350 } 351 } 352 return valuesByEducationalPath; 353 } 354 355 /** 356 * Browse a repeater entries' values to reorganize values by educational path 357 * @param repeaterValues The repeater entries' values 358 * @param repeaterDefinition The repeater definition 359 * @return a Map of {@link EducationalPath} with related values by names 360 */ 361 public Map<EducationalPath, Map<String, Object>> getValuesByEducationalPath(List<Map<String, Object>> repeaterValues, RepeaterDefinition repeaterDefinition) 362 { 363 Map<EducationalPath, Map<String, Object>> valuesByEducationalPath = new HashMap<>(); 364 365 for (Map<String, Object> entryValues : repeaterValues) 366 { 367 EducationalPath path = null; 368 Map<String, Object> values = new HashMap<>(); 369 370 for (ModelItem childItem : repeaterDefinition.getModelItems()) 371 { 372 if (entryValues.containsKey(childItem.getName())) 373 { 374 Object value = entryValues.get(childItem.getName()); 375 value = value instanceof SynchronizableValue synchronizableValue ? synchronizableValue.getLocalValue() : value; 376 377 if (value instanceof EducationalPath educationPath) 378 { 379 path = educationPath; 380 } 381 else 382 { 383 values.put(childItem.getName(), value); 384 } 385 } 386 } 387 388 if (path != null) 389 { 390 valuesByEducationalPath.put(path, values); 391 } 392 } 393 394 return valuesByEducationalPath; 395 } 396 397 private void _triggerWorkflowAction(Content content, int actionId) throws WorkflowException 398 { 399 if (content instanceof WorkflowAwareContent) 400 { 401 // The content has already been modified by this function 402 SynchronizationResult synchronizationResult = new SynchronizationResult(); 403 synchronizationResult.setHasChanged(true); 404 405 Map<String, Object> parameters = new HashMap<>(); 406 parameters.put(ValidateContentFunction.IS_MAJOR, false); 407 parameters.put(EditContentFunction.SYNCHRONIZATION_RESULT, synchronizationResult); 408 parameters.put(EditContentFunction.QUIT, true); 409 410 Map<String, Object> inputs = new HashMap<>(); 411 inputs.put(AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY, parameters); 412 413 // Do not check right 414 inputs.put(CheckRightsCondition.FORCE, true); 415 416 _workflowHelper.doAction((WorkflowAwareContent) content, actionId, inputs); 417 } 418 } 419 420 private Pair<ProgramItem, String> _resolveEntryPath(Node entryNode) throws RepositoryException 421 { 422 List<String> entryPath = new ArrayList<>(); 423 424 String entryPos = StringUtils.substringAfter(entryNode.getName(), RepositoryConstants.NAMESPACE_PREFIX + ":"); 425 Node repeaterNode = entryNode.getParent(); 426 String repeaterName = StringUtils.substringAfter(repeaterNode.getName(), RepositoryConstants.NAMESPACE_PREFIX + ":"); 427 428 entryPath.add(repeaterName + "[" + entryPos + "]"); 429 430 Node currentNode = repeaterNode.getParent(); 431 432 while (!currentNode.isNodeType("ametys:content")) 433 { 434 String currentNodeName = StringUtils.substringAfter(currentNode.getName(), RepositoryConstants.NAMESPACE_PREFIX + ":"); 435 Node parentNode = currentNode.getParent(); 436 437 if (_isRepeaterNode(parentNode, currentNodeName)) 438 { 439 entryPath.add(StringUtils.substringAfter(parentNode.getName(), RepositoryConstants.NAMESPACE_PREFIX + ":") + "[" + currentNodeName + "]"); 440 currentNode = parentNode.getParent(); 441 } 442 else 443 { 444 entryPath.add(currentNodeName); 445 currentNode = parentNode; 446 } 447 } 448 449 ProgramItem programItem = _resolver.resolve(currentNode, false); 450 451 Collections.reverse(entryPath); 452 return Pair.of(programItem, StringUtils.join(entryPath, "/")); 453 } 454 455 private boolean _isRepeaterNode(Node node, String childName) throws RepositoryException 456 { 457 return node.isNodeType("ametys:repeater") 458 || node.isNodeType("ametys:compositeMetadata") && StringUtils.isNumeric(childName); // legacy mode : old repeater nodes are of type ametys:compositeMetadata 459 } 460 461 private NodeIterator _getEducationalPathNodes(String... programItemIds) throws InvalidQueryException, RepositoryException 462 { 463 List<Expression> exprs = new ArrayList<>(); 464 for (String programItemId : programItemIds) 465 { 466 exprs.add(new StringExpression(EducationalPath.PATH_SEGMENTS_IDENTIFIER, Operator.EQ, programItemId)); 467 } 468 469 String xPathQuery = QueryHelper.getXPathQuery(null, EducationalPathRepositoryElementType.EDUCATIONAL_PATH_NODETYPE, new AndExpression(exprs.toArray(Expression[]::new))); 470 471 Session session = _repository.login(); 472 Query query = session.getWorkspace().getQueryManager().createQuery(xPathQuery, Query.XPATH); 473 474 return query.execute().getNodes(); 475 } 476 477 /** 478 * Record for a {@link EducationalPath} references 479 * @param programItem The program item holding the educational path 480 * @param repeaterEntryPath The path of repeater entry holding the educational path 481 * @param attributeName The name of attribute holding the educational path 482 * @param value The value of the educational path 483 */ 484 public record EducationalPathReference(ProgramItem programItem, String repeaterEntryPath, String attributeName, EducationalPath value) { /* empty */ } 485}