001/* 002 * Copyright 2019 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.data; 017 018import java.util.ArrayList; 019import java.util.Arrays; 020import java.util.Collection; 021import java.util.Collections; 022import java.util.HashMap; 023import java.util.HashSet; 024import java.util.List; 025import java.util.Map; 026import java.util.Optional; 027import java.util.Set; 028import java.util.function.Function; 029import java.util.stream.Collectors; 030import java.util.stream.IntStream; 031import java.util.stream.Stream; 032 033import org.apache.avalon.framework.component.Component; 034import org.apache.avalon.framework.service.ServiceException; 035import org.apache.avalon.framework.service.ServiceManager; 036import org.apache.avalon.framework.service.Serviceable; 037import org.apache.commons.collections4.SetUtils; 038import org.apache.commons.lang3.StringUtils; 039import org.apache.commons.lang3.tuple.ImmutablePair; 040import org.apache.commons.lang3.tuple.Pair; 041 042import org.ametys.cms.contenttype.ContentAttributeDefinition; 043import org.ametys.cms.contenttype.ContentType; 044import org.ametys.cms.contenttype.ContentTypeExtensionPoint; 045import org.ametys.cms.data.type.ModelItemTypeConstants; 046import org.ametys.cms.repository.Content; 047import org.ametys.cms.repository.ModifiableContent; 048import org.ametys.plugins.repository.AmetysObjectResolver; 049import org.ametys.plugins.repository.data.external.ExternalizableDataProvider.ExternalizableDataStatus; 050import org.ametys.plugins.repository.data.holder.DataHolder; 051import org.ametys.plugins.repository.data.holder.ModelAwareDataHolder; 052import org.ametys.plugins.repository.data.holder.ModifiableModelAwareDataHolder; 053import org.ametys.plugins.repository.data.holder.group.Repeater; 054import org.ametys.plugins.repository.data.holder.group.RepeaterEntry; 055import org.ametys.plugins.repository.data.holder.group.ModifiableModelAwareComposite; 056import org.ametys.plugins.repository.data.holder.group.ModifiableRepeater; 057import org.ametys.plugins.repository.data.holder.group.ModifiableRepeaterEntry; 058import org.ametys.plugins.repository.data.holder.impl.DataHolderHelper; 059import org.ametys.plugins.repository.data.holder.values.SynchronizableRepeater; 060import org.ametys.plugins.repository.data.holder.values.SynchronizableValue; 061import org.ametys.plugins.repository.data.holder.values.SynchronizationContext; 062import org.ametys.plugins.repository.data.holder.values.UntouchedValue; 063import org.ametys.plugins.repository.data.holder.values.ValueContext; 064import org.ametys.plugins.repository.model.RepeaterDefinition; 065import org.ametys.plugins.repository.model.ViewHelper; 066import org.ametys.runtime.model.ModelItem; 067import org.ametys.runtime.model.ModelViewItemGroup; 068import org.ametys.runtime.model.ViewItemAccessor; 069import org.ametys.runtime.model.exception.BadItemTypeException; 070 071/** 072 * Helper for data of type 'content' 073 */ 074public class ContentDataHelper implements Serviceable, Component 075{ 076 /** Avalon role */ 077 public static final String ROLE = ContentDataHelper.class.getName(); 078 079 private AmetysObjectResolver _resolver; 080 private ContentTypeExtensionPoint _cTypeExtensionPoint; 081 082 public void service(ServiceManager manager) throws ServiceException 083 { 084 _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE); 085 _cTypeExtensionPoint = (ContentTypeExtensionPoint) manager.lookup(ContentTypeExtensionPoint.ROLE); 086 } 087 088 /** 089 * Retrieves the content identifier of a content data 090 * @param dataHolder data holder that contains the content data 091 * @param dataPath path to the content data 092 * @param defaultValue The default value to return 093 * @return the content identifier 094 * @throws BadItemTypeException if the data at the given path is not a content data 095 */ 096 public static String getContentIdFromContentData(ModelAwareDataHolder dataHolder, String dataPath, String defaultValue) throws BadItemTypeException 097 { 098 ContentValue value = dataHolder.getValue(dataPath); 099 return Optional.ofNullable(value) 100 .map(ContentValue::getContentId) 101 .orElse(defaultValue); 102 } 103 104 /** 105 * Retrieves the content identifier of a content data 106 * @param dataHolder data holder that contains the content data 107 * @param dataPath path to the content data 108 * @return the content identifier, empty string if it is invalid 109 * @throws BadItemTypeException if the data at the given path is not a content data 110 */ 111 public static String getContentIdFromContentData(ModelAwareDataHolder dataHolder, String dataPath) throws BadItemTypeException 112 { 113 return getContentIdFromContentData(dataHolder, dataPath, StringUtils.EMPTY); 114 } 115 116 /** 117 * Retrieves the content identifiers in an array from a multiple content data 118 * @param dataHolder data holder that contains the multiple content data 119 * @param dataPath path to the multiple content data 120 * @return an array containing the content identifiers 121 * @throws BadItemTypeException if the data at the given path is not a content data 122 */ 123 public static boolean isMultipleContentDataEmpty(ModelAwareDataHolder dataHolder, String dataPath) throws BadItemTypeException 124 { 125 return isMultipleContentDataEmpty(dataHolder, dataPath, ValueContext.newInstance()); 126 } 127 128 /** 129 * Retrieves the content identifiers in an array from a multiple content data 130 * @param dataHolder data holder that contains the multiple content data 131 * @param dataPath path to the multiple content data 132 * @param context the context of the value to retrieve 133 * @return an array containing the content identifiers 134 * @throws BadItemTypeException if the data at the given path is not a content data 135 */ 136 public static boolean isMultipleContentDataEmpty(ModelAwareDataHolder dataHolder, String dataPath, ValueContext context) throws BadItemTypeException 137 { 138 return getContentIdsStreamFromMultipleContentData(dataHolder, dataPath, context).count() <= 0; 139 } 140 141 /** 142 * Retrieves the content identifiers in a {@link List} from a multiple content data 143 * @param dataHolder data holder that contains the multiple content data 144 * @param dataPath path to the multiple content data 145 * @return a {@link List} containing the content identifiers 146 * @throws BadItemTypeException if the data at the given path is not a content data 147 */ 148 public static List<String> getContentIdsListFromMultipleContentData(ModelAwareDataHolder dataHolder, String dataPath) throws BadItemTypeException 149 { 150 return getContentIdsListFromMultipleContentData(dataHolder, dataPath, ValueContext.newInstance()); 151 } 152 153 /** 154 * Retrieves the content identifiers in a {@link List} from a multiple content data 155 * @param dataHolder data holder that contains the multiple content data 156 * @param dataPath path to the multiple content data 157 * @param context the context of the value to retrieve 158 * @return a {@link List} containing the content identifiers 159 * @throws BadItemTypeException if the data at the given path is not a content data 160 */ 161 public static List<String> getContentIdsListFromMultipleContentData(ModelAwareDataHolder dataHolder, String dataPath, ValueContext context) throws BadItemTypeException 162 { 163 return getContentIdsStreamFromMultipleContentData(dataHolder, dataPath, context).collect(Collectors.toList()); 164 } 165 166 /** 167 * Retrieves the content identifiers in an array from a multiple content data 168 * @param dataHolder data holder that contains the multiple content data 169 * @param dataPath path to the multiple content data 170 * @return an array containing the content identifiers 171 * @throws BadItemTypeException if the data at the given path is not a content data 172 */ 173 public static String[] getContentIdsArrayFromMultipleContentData(ModelAwareDataHolder dataHolder, String dataPath) throws BadItemTypeException 174 { 175 return getContentIdsArrayFromMultipleContentData(dataHolder, dataPath, ValueContext.newInstance()); 176 } 177 178 /** 179 * Retrieves the content identifiers in an array from a multiple content data 180 * @param dataHolder data holder that contains the multiple content data 181 * @param dataPath path to the multiple content data 182 * @param context the context of the value to retrieve 183 * @return an array containing the content identifiers 184 * @throws BadItemTypeException if the data at the given path is not a content data 185 */ 186 public static String[] getContentIdsArrayFromMultipleContentData(ModelAwareDataHolder dataHolder, String dataPath, ValueContext context) throws BadItemTypeException 187 { 188 return getContentIdsStreamFromMultipleContentData(dataHolder, dataPath, context).toArray(String[]::new); 189 } 190 191 /** 192 * Retrieves a {@link Stream} of the content identifiers from a multiple content data 193 * @param dataHolder data holder that contains the multiple content data 194 * @param dataPath path to the multiple content data 195 * @return a {@link Stream} of the content identifiers 196 * @throws BadItemTypeException if the data at the given path is not a content data 197 */ 198 public static Stream<String> getContentIdsStreamFromMultipleContentData(ModelAwareDataHolder dataHolder, String dataPath) throws BadItemTypeException 199 { 200 return getContentIdsStreamFromMultipleContentData(dataHolder, dataPath, ValueContext.newInstance()); 201 } 202 203 /** 204 * Retrieves a {@link Stream} of the content identifiers from a multiple content data 205 * @param dataHolder data holder that contains the multiple content data 206 * @param dataPath path to the multiple content data 207 * @param context the context of the value to retrieve 208 * @return a {@link Stream} of the content identifiers 209 * @throws BadItemTypeException if the data at the given path is not a content data 210 */ 211 public static Stream<String> getContentIdsStreamFromMultipleContentData(ModelAwareDataHolder dataHolder, String dataPath, ValueContext context) throws BadItemTypeException 212 { 213 ContentValue[] value = DataHolderHelper.getValue(dataHolder, dataPath, context); 214 return Optional.ofNullable(value) 215 .map(v -> Arrays.stream(v)) 216 .orElse(Stream.empty()) 217 .map(ContentValue::getContentId); 218 } 219 220 /** 221 * Prepares a write operation in the given {@link ModelAwareDataHolder} by traversing a {@link ViewItemAccessor} with the values to be written.<br> 222 * The goal is to find content attributes and to extract added and removed values. 223 * @param viewItemAccessor the view item accessor to walk through 224 * @param content the source {@link ModifiableContent} 225 * @param values the new values 226 * @param context the synchronization context 227 * @return the contents to-be-added and to-be-removed 228 */ 229 public Collection<ReferencedContents> collectReferencedContents(ViewItemAccessor viewItemAccessor, ModifiableContent content, Map<String, Object> values, SynchronizationContext context) 230 { 231 // content ids, grouped by invert attribute paths, instead of definition paths, so that if several attributes share the same invert attribute, they are handled together 232 // data structure is <definition> mapped <previous contents mapped with their data paths, new contents mapped with their data paths> 233 Map<ContentAttributeDefinition, Pair<Map<ContentValue, List<String>>, Map<ContentValue, List<String>>>> references = new HashMap<>(); 234 235 _collectReferencedContents(content, viewItemAccessor, Optional.of(content), StringUtils.EMPTY, Optional.of(StringUtils.EMPTY), Optional.of(values), false, context, references); 236 237 Collection<ReferencedContents> contents = new ArrayList<>(); 238 for (ContentAttributeDefinition definition : references.keySet()) 239 { 240 Pair<Map<ContentValue, List<String>>, Map<ContentValue, List<String>>> allValues = references.get(definition); 241 Map<ContentValue, List<String>> previousValues = allValues.getLeft(); 242 Map<ContentValue, List<String>> newValues = allValues.getRight(); 243 244 Map<ContentValue, List<String>> addedValues = new HashMap<>(); 245 Map<ContentValue, List<String>> removedValues = new HashMap<>(); 246 247 newValues.forEach((value, list) -> { 248 if (!previousValues.containsKey(value)) 249 { 250 addedValues.put(value, list); 251 } 252 }); 253 254 previousValues.forEach((value, list) -> { 255 if (!newValues.containsKey(value)) 256 { 257 removedValues.put(value, list); 258 } 259 }); 260 261 String invert = definition.getInvertRelationPath(); 262 ContentType invertType = _cTypeExtensionPoint.getExtension(definition.getContentTypeId()); 263 ContentAttributeDefinition invertDefinition = (ContentAttributeDefinition) invertType.getModelItem(invert); 264 265 Map<ContentValue, ContentValue> thirdPartyContents = new HashMap<>(); 266 if (!DataHolderHelper.isMultiple(invertDefinition.getModel(), invertDefinition.getPath())) 267 { 268 List<ModifiableContent> refContents = addedValues.keySet().stream().map(v -> v.getContentIfExists()).flatMap(Optional::stream).collect(Collectors.toList()); 269 270 for (ModifiableContent refContent : refContents) 271 { 272 ContentValue previousValue = refContent.getValue(invert); 273 if (previousValue != null) 274 { 275 String previousContentId = previousValue.getContentId(); 276 if (!content.getId().equals(previousContentId)) 277 { 278 // the single-valued content attribute is about to change, we should store the 3rd party content involved in the invert relation 279 thirdPartyContents.put(new ContentValue(refContent), previousValue); 280 } 281 } 282 } 283 } 284 285 if (!addedValues.isEmpty() || !removedValues.isEmpty() || !thirdPartyContents.isEmpty()) 286 { 287 contents.add(new ReferencedContents(definition, addedValues, removedValues, thirdPartyContents)); 288 } 289 } 290 291 return contents; 292 } 293 294 private void _collectReferencedContents(Content content, 295 ViewItemAccessor viewItemAccessor, 296 Optional<ModelAwareDataHolder> currentDataHolder, 297 String dataPathPrefix, 298 Optional<String> oldDataPathPrefix, 299 Optional<Map<String, Object>> values, 300 boolean insideKeptDataHolder, 301 SynchronizationContext context, 302 Map<ContentAttributeDefinition, Pair<Map<ContentValue, List<String>>, Map<ContentValue, List<String>>>> references) 303 { 304 ViewHelper.visitView(viewItemAccessor, 305 (element, definition) -> { 306 // simple element 307 String name = definition.getName(); 308 309 if (definition instanceof ContentAttributeDefinition) 310 { 311 ContentAttributeDefinition contentDefinition = (ContentAttributeDefinition) definition; 312 String invert = contentDefinition.getInvertRelationPath(); 313 if (invert != null) 314 { 315 _collectInvertRelation(content, currentDataHolder, dataPathPrefix, oldDataPathPrefix, values, insideKeptDataHolder, name, contentDefinition, context, references); 316 } 317 } 318 }, 319 (group, definition) -> { 320 // composite 321 String name = definition.getName(); 322 _collectComposite(content, group, currentDataHolder, dataPathPrefix, oldDataPathPrefix, values, insideKeptDataHolder, context, name, references); 323 }, 324 (group, definition) -> { 325 // repeater 326 String name = definition.getName(); 327 _collectRepeater(content, group, currentDataHolder, dataPathPrefix, oldDataPathPrefix, values, insideKeptDataHolder, context, name, references); 328 }, 329 group -> _collectReferencedContents(content, group, currentDataHolder, dataPathPrefix, oldDataPathPrefix, values, insideKeptDataHolder, context, references)); 330 } 331 332 private void _collectInvertRelation(Content content, 333 Optional<ModelAwareDataHolder> currentDataHolder, 334 String dataPathPrefix, 335 Optional<String> oldDataPathPrefix, 336 Optional<Map<String, Object>> values, 337 boolean insideKeptDataHolder, 338 String name, 339 ContentAttributeDefinition definition, 340 SynchronizationContext context, 341 Map<ContentAttributeDefinition, Pair<Map<ContentValue, List<String>>, Map<ContentValue, List<String>>>> references) 342 { 343 String dataPath = dataPathPrefix + name; 344 345 Set<ContentValue> previousContents = currentDataHolder 346 .map(dh -> dh.getValue(name)) 347 .map(this::_getContentRefs) 348 .orElseGet(Collections::emptySet); 349 Set<ContentValue> newContents; 350 351 if (insideKeptDataHolder) 352 { 353 newContents = previousContents; 354 } 355 else 356 { 357 Optional<Object> rawValue = values.map(v -> v.get(name)); 358 359 Optional<String> oldDataPath = oldDataPathPrefix.map(p -> p + name); 360 rawValue = rawValue.map(v -> DataHolderHelper.getValueFromSynchronizableValue(v, content, definition, oldDataPath, context)); 361 362 // If the current value is an untouched value, don't edit the invert relation 363 if (rawValue.filter(UntouchedValue.class::isInstance).isPresent()) 364 { 365 return; 366 } 367 368 newContents = rawValue.map(this::_getContentRefs) 369 .orElseGet(Collections::emptySet); 370 371 SynchronizableValue.Mode mode = values.map(v -> v.get(name)) 372 .filter(SynchronizableValue.class::isInstance) 373 .map(SynchronizableValue.class::cast) 374 .map(v -> v.getMode()) 375 .orElse(SynchronizableValue.Mode.REPLACE); 376 377 if (mode == SynchronizableValue.Mode.REMOVE) 378 { 379 newContents = SetUtils.difference(previousContents, newContents); 380 } 381 else if (mode == SynchronizableValue.Mode.APPEND) 382 { 383 newContents = SetUtils.union(previousContents, newContents); 384 } 385 } 386 387 _addReferences(references, definition, dataPath, previousContents, newContents); 388 } 389 390 private Set<ContentValue> _getContentRefs(Object value) 391 { 392 if (value instanceof ContentValue) 393 { 394 return Set.of((ContentValue) value); 395 } 396 else if (value instanceof ContentValue[]) 397 { 398 return Set.copyOf(Arrays.asList((ContentValue[]) value)); 399 } 400 else if (value instanceof String) 401 { 402 return Set.of(new ContentValue(_resolver, (String) value)); 403 } 404 else if (value instanceof String[]) 405 { 406 return Arrays.stream((String[]) value).map(id -> new ContentValue(_resolver, id)).collect(Collectors.toSet()); 407 } 408 else if (value instanceof ModifiableContent) 409 { 410 return Set.of(new ContentValue((ModifiableContent) value)); 411 } 412 else if (value instanceof ModifiableContent[]) 413 { 414 return Arrays.stream((ModifiableContent[]) value).map(ContentValue::new).collect(Collectors.toSet()); 415 } 416 417 return Collections.emptySet(); 418 } 419 420 private void _addReferences(Map<ContentAttributeDefinition, Pair<Map<ContentValue, List<String>>, Map<ContentValue, List<String>>>> references, ContentAttributeDefinition definition, String dataPath, Set<ContentValue> previousContents, Set<ContentValue> newContents) 421 { 422 Pair<Map<ContentValue, List<String>>, Map<ContentValue, List<String>>> values = references.get(definition); 423 Map<ContentValue, List<String>> previousValues = values == null ? new HashMap<>() : values.getLeft(); 424 Map<ContentValue, List<String>> newValues = values == null ? new HashMap<>() : values.getRight(); 425 426 previousContents.forEach(c -> previousValues.computeIfAbsent(c, __ -> new ArrayList<>()).add(dataPath)); 427 newContents.forEach(c -> newValues.computeIfAbsent(c, __ -> new ArrayList<>()).add(dataPath)); 428 429 references.put(definition, Pair.of(previousValues, newValues)); 430 } 431 432 @SuppressWarnings("unchecked") 433 private void _collectComposite(Content content, 434 ModelViewItemGroup group, 435 Optional<ModelAwareDataHolder> currentDataHolder, 436 String dataPathPrefix, 437 Optional<String> oldDataPathPrefix, 438 Optional<Map<String, Object>> values, 439 boolean insideKeptDataHolder, 440 SynchronizationContext context, 441 String name, 442 Map<ContentAttributeDefinition, Pair<Map<ContentValue, List<String>>, Map<ContentValue, List<String>>>> references) 443 { 444 Optional<?> value = values.map(v -> v.get(name)); 445 446 if (value.isPresent() && value.orElse(null) instanceof UntouchedValue) 447 { 448 return; 449 } 450 else 451 { 452 String updatedPrefix = dataPathPrefix + name + ModelItem.ITEM_PATH_SEPARATOR; 453 Optional<String> updatedOldPrefix = oldDataPathPrefix.map(p -> p + name + ModelItem.ITEM_PATH_SEPARATOR); 454 _collectReferencedContents(content, group, currentDataHolder.map(v -> v.getComposite(name)), updatedPrefix, updatedOldPrefix, (Optional<Map<String, Object>>) value, insideKeptDataHolder, context, references); 455 } 456 } 457 458 @SuppressWarnings("unchecked") 459 private void _collectRepeater(Content content, 460 ModelViewItemGroup group, 461 Optional<ModelAwareDataHolder> currentDataHolder, 462 String dataPathPrefix, 463 Optional<String> oldDataPathPrefix, 464 Optional<Map<String, Object>> values, 465 boolean insideKeptDataHolder, 466 SynchronizationContext context, 467 String name, 468 Map<ContentAttributeDefinition, Pair<Map<ContentValue, List<String>>, Map<ContentValue, List<String>>>> references) 469 { 470 Object value = values.map(v -> v.get(name)).orElse(null); 471 List<Map<String, Object>> newEntries = null; 472 Map<Integer, Integer> mapping = null; 473 SynchronizableRepeater.Mode mode = SynchronizableRepeater.Mode.REPLACE_ALL; 474 475 if (value instanceof UntouchedValue) 476 { 477 // If the current repeater value is an untouched value, don't look after invert relations in its entries 478 return; 479 } 480 else if (value instanceof List) 481 { 482 newEntries = (List<Map<String, Object>>) value; 483 mapping = IntStream.rangeClosed(1, newEntries.size()).boxed().collect(Collectors.toMap(Function.identity(), Function.identity())); 484 } 485 else if (value instanceof SynchronizableRepeater) 486 { 487 newEntries = ((SynchronizableRepeater) value).getEntries(); 488 mapping = ((SynchronizableRepeater) value).getPositionsMapping(); 489 mode = ((SynchronizableRepeater) value).getMode(); 490 } 491 492 // first collect data for actually existing entries 493 Set<Integer> alreadyHandledValues = _collectExistingRepeaterEntries(content, group, currentDataHolder, dataPathPrefix, oldDataPathPrefix, value, newEntries, mapping, mode, insideKeptDataHolder, context, name, references); 494 495 // then collect data for newly created entries 496 if (newEntries != null) 497 { 498 for (int i = 1; i <= newEntries.size(); i++) 499 { 500 if (!alreadyHandledValues.contains(i)) 501 { 502 String updatedPrefix = dataPathPrefix + name + "[" + i + "]" + ModelItem.ITEM_PATH_SEPARATOR; 503 _collectReferencedContents(content, group, Optional.empty(), updatedPrefix, Optional.empty(), Optional.of(newEntries.get(i - 1)), insideKeptDataHolder, context, references); 504 } 505 } 506 } 507 } 508 509 private Set<Integer> _collectExistingRepeaterEntries(Content content, 510 ModelViewItemGroup group, 511 Optional<ModelAwareDataHolder> currentDataHolder, 512 String dataPathPrefix, 513 Optional<String> oldDataPathPrefix, 514 Object value, 515 List<Map<String, Object>> newEntries, 516 Map<Integer, Integer> mapping, 517 SynchronizableRepeater.Mode mode, 518 boolean insideKeptDataHolder, 519 SynchronizationContext context, 520 String name, 521 Map<ContentAttributeDefinition, Pair<Map<ContentValue, List<String>>, Map<ContentValue, List<String>>>> references) 522 { 523 Set<Integer> alreadyHandledValues = new HashSet<>(); 524 Repeater repeater = currentDataHolder.map(v -> v.getRepeater(name)).orElse(null); 525 526 if (repeater != null) 527 { 528 for (RepeaterEntry entry : repeater.getEntries()) 529 { 530 int position = entry.getPosition(); 531 532 Optional<Map<String, Object>> newValues = Optional.empty(); 533 String newPositionSuffix = ""; 534 boolean newKeptValue = insideKeptDataHolder; 535 536 if (!insideKeptDataHolder) 537 { 538 if (mode == SynchronizableRepeater.Mode.REPLACE_ALL) 539 { 540 Integer newPosition = mapping != null ? mapping.get(position) : null; 541 542 if (newPosition != null) 543 { 544 // not removed entry 545 newValues = newEntries != null ? Optional.ofNullable(newEntries.get(newPosition - 1)) : Optional.empty(); 546 newPositionSuffix = "[" + newPosition + "]"; 547 alreadyHandledValues.add(newPosition); 548 } 549 } 550 else if (mode == SynchronizableRepeater.Mode.REPLACE) 551 { 552 List<Integer> replacePositions = ((SynchronizableRepeater) value).getReplacePositions(); 553 int index = replacePositions.indexOf(position); 554 555 if (index >= 0) 556 { 557 assert newEntries != null; 558 newValues = Optional.ofNullable(newEntries.get(index)); 559 newPositionSuffix = "[" + position + "]"; 560 alreadyHandledValues.add(position); 561 } 562 else 563 { 564 // entry kept as is 565 newKeptValue = true; 566 } 567 } 568 else 569 { 570 Set<Integer> removedEntries = ((SynchronizableRepeater) value).getRemovedEntries(); 571 newKeptValue = !removedEntries.contains(position); 572 } 573 } 574 575 String updatePrefix = dataPathPrefix + name + newPositionSuffix + ModelItem.ITEM_PATH_SEPARATOR; 576 Optional<String> updatedOldPrefix = oldDataPathPrefix.map(p -> dataPathPrefix + name + "[" + position + "]" + ModelItem.ITEM_PATH_SEPARATOR); 577 _collectReferencedContents(content, group, Optional.of(entry), updatePrefix, updatedOldPrefix, newValues, newKeptValue, context, references); 578 } 579 } 580 581 return alreadyHandledValues; 582 } 583 584 /** 585 * Find any broken reference in data holder's content attributes. 586 * A reference is considered as broken if the referenced content does not exist 587 * @param dataHolder the {@link ModelAwareDataHolder} to inspect. 588 * @param fix if <code>true</code>, and the dataHolder is modifiable, broken references will be removed 589 * Warning: if modifications are made on the given data holder, the modifications won't be saved and the workflow won't change 590 * @return all broken references, by attribute path 591 */ 592 public static Map<String, List<String>> checkBrokenReferences(ModelAwareDataHolder dataHolder, boolean fix) 593 { 594 Map<String, List<String>> result = new HashMap<>(); 595 Map<String, Object> contentAttributes = DataHolderHelper.findEditableItemsByType(dataHolder, ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID); 596 for (Map.Entry<String, Object> entry : contentAttributes.entrySet()) 597 { 598 String dataPath = entry.getKey(); 599 Object value = entry.getValue(); 600 if (value instanceof ContentValue) 601 { 602 ContentValue contentValue = (ContentValue) value; 603 if (contentValue.getContentIfExists().isEmpty()) 604 { 605 result.computeIfAbsent(dataPath, __ -> new ArrayList<>()).add(contentValue.getContentId()); 606 if (fix && dataHolder instanceof ModifiableModelAwareDataHolder) 607 { 608 ((ModifiableModelAwareDataHolder) dataHolder).setValue(dataPath, null); 609 } 610 } 611 } 612 else if (value instanceof ContentValue[]) 613 { 614 List<ContentValue> newValues = new ArrayList<>(); 615 for (ContentValue contentValue : (ContentValue[]) value) 616 { 617 if (contentValue.getContentIfExists().isEmpty()) 618 { 619 result.computeIfAbsent(dataPath, __ -> new ArrayList<>()).add(contentValue.getContentId()); 620 } 621 else if (fix) 622 { 623 newValues.add(contentValue); 624 } 625 } 626 if (fix && dataHolder instanceof ModifiableModelAwareDataHolder) 627 { 628 ((ModifiableModelAwareDataHolder) dataHolder).setValue(dataPath, newValues.toArray(ContentValue[]::new)); 629 } 630 } 631 } 632 return result; 633 } 634 635 /** 636 * Find any broken invert relation in a content attributes. 637 * The invert relation is considered as broken if the referenced content does not reference the first one by the attribute with the invert relation path 638 * This check does not take nonexistent referenced contents into account. First call the {@link ContentDataHelper#checkBrokenReferences(ModelAwareDataHolder, boolean)} method 639 * @param content the content to inspect. 640 * @param fix if <code>true</code>, and the referenced contents are modifiable, broken invert relations will be fixed by adding the relation on the referenced content 641 * if the referenced content is referencing another content and the attribute is single (and not in a repeater), the third content will be modified too, to remove the relation to the second content. 642 * Warning: if modifications are made on some contents, the modifications won't be saved and the workflows won't change 643 * @return All broken invert relations in a collection of {@link ReferencedContents}. 644 * Call {@link ReferencedContents#getAddedContents()} to get all contents referenced by the given content that does not reference it in return 645 * Call {@link ReferencedContents#getThirdPartyContents()} to get contents referenced by content in added contents that reference it in return 646 */ 647 public static Collection<ReferencedContents> checkBrokenInvertRelations(Content content, boolean fix) 648 { 649 return checkBrokenInvertRelations(content, fix, fix); 650 } 651 652 /** 653 * Find any broken invert relation in a content attributes. 654 * The invert relation is considered as broken if the referenced content does not reference the first one by the attribute with the invert relation path 655 * This check does not take nonexistent referenced contents into account. First call the {@link ContentDataHelper#checkBrokenReferences(ModelAwareDataHolder, boolean)} method 656 * @param content the content to inspect. 657 * @param fix if <code>true</code>, and the referenced contents are modifiable, broken invert relations will be fixed. 658 * @param allowThirdPartyRemoval if <code>true</code> fixing will be done by adding the relation on the referenced content, if the referenced content is referencing 659 * another content and the attribute is single (and not in a repeater), the third content will be modified too, to remove the relation to the second content. 660 * if <code>false</code> the relation will be removed on the referencing content. 661 * Warning: if modifications are made on some contents, the modifications won't be saved and the workflows won't change 662 * @return All broken invert relations in a collection of {@link ReferencedContents}. 663 * Call {@link ReferencedContents#getAddedContents()} to get all contents referenced by the given content that does not reference it in return 664 * Call {@link ReferencedContents#getThirdPartyContents()} to get contents referenced by content in added contents that reference it in return 665 */ 666 public static Collection<ReferencedContents> checkBrokenInvertRelations(Content content, boolean fix, boolean allowThirdPartyRemoval) 667 { 668 // Search for all content attribute's values 669 Map<String, Object> contentAttributes = DataHolderHelper.findEditableItemsByType(content, ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID); 670 671 // Collect broken invert relations 672 Collection<ReferencedContents> brokenInvertRelations = new ArrayList<>(); 673 for (Map.Entry<String, Object> entry : contentAttributes.entrySet()) 674 { 675 String dataPath = entry.getKey(); 676 Object value = entry.getValue(); 677 ContentAttributeDefinition definition = (ContentAttributeDefinition) content.getDefinition(dataPath); 678 679 _checkBrokenInvertRelations(definition, dataPath, value, content, brokenInvertRelations, fix, allowThirdPartyRemoval); 680 } 681 682 return brokenInvertRelations; 683 } 684 685 private static void _checkBrokenInvertRelations(ContentAttributeDefinition definition, String dataPath, Object value, Content content, Collection<ReferencedContents> brokenInvertRelations, boolean fix, boolean allowThirdPartyRemoval) 686 { 687 // Continue check only on content attributes with an invert relation path 688 if (StringUtils.isNotBlank(definition.getInvertRelationPath())) 689 { 690 Map<ContentValue, List<String>> contentsToAdd = new HashMap<>(); 691 Map<ContentValue, ContentValue> thirdPartyContents = new HashMap<>(); 692 693 if (value instanceof ContentValue contentValue) 694 { 695 Pair<Boolean, Optional<ContentValue>> isBroken = _isInvertRelationBroken(content, contentValue, definition); 696 if (isBroken.getLeft()) 697 { 698 contentsToAdd.computeIfAbsent(contentValue, __ -> new ArrayList<>()).add(dataPath); 699 isBroken.getRight() 700 .ifPresent(v -> thirdPartyContents.put(contentValue, v)); 701 } 702 } 703 else if (value instanceof ContentValue[]) 704 { 705 for (ContentValue contentValue : (ContentValue[]) value) 706 { 707 Pair<Boolean, Optional<ContentValue>> isBroken = _isInvertRelationBroken(content, contentValue, definition); 708 if (isBroken.getLeft()) 709 { 710 contentsToAdd.computeIfAbsent(contentValue, __ -> new ArrayList<>()).add(dataPath); 711 isBroken.getRight() 712 .ifPresent(v -> thirdPartyContents.put(contentValue, v)); 713 } 714 } 715 } 716 717 // Add a ReferencedContents object if there is at least one broken relation 718 // No need to check the third part content, this map can't have entries if the first one is empty 719 if (!contentsToAdd.isEmpty()) 720 { 721 ReferencedContents brokenInvertRelation = new ReferencedContents(definition, contentsToAdd, Map.of(), thirdPartyContents); 722 brokenInvertRelations.add(brokenInvertRelation); 723 if (fix) 724 { 725 if (allowThirdPartyRemoval || brokenInvertRelation.getThirdPartyContents().isEmpty()) 726 { 727 // Add relation on contents referenced by the given one 728 String invertRelationPath = brokenInvertRelation.getDefinition().getInvertRelationPath(); 729 manageInvertRelations(invertRelationPath, brokenInvertRelation.getAddedContents(), content.getId(), ContentDataHelper::addInvertRelation, Map.of(), Map.of()); 730 731 // Remove relations on third party contents 732 Map<ContentValue, Collection<String>> thirdPartyCons = new HashMap<>(); 733 for (Map.Entry<ContentValue, ContentValue> thirdPartyContent : brokenInvertRelation.getThirdPartyContents().entrySet()) 734 { 735 thirdPartyCons.computeIfAbsent(thirdPartyContent.getValue(), __ -> new ArrayList<>()).add(thirdPartyContent.getKey().getContentId()); 736 } 737 manageInvertRelations(brokenInvertRelation.getDefinition().getPath(), thirdPartyCons, ContentDataHelper::removeInvertRelation, Map.of(), Map.of()); 738 } 739 // remove the relation 740 else if (content instanceof ModifiableContent mContent) 741 { 742 if (value instanceof ContentValue) 743 { 744 mContent.setValue(dataPath, null); 745 } 746 else if (value instanceof ContentValue[] contentValues) 747 { 748 ContentValue[] filteredValues = Stream.of(contentValues) 749 .filter(v -> brokenInvertRelation.getAddedContents().contains(v)) 750 .toArray(ContentValue[]::new); 751 mContent.setValue(dataPath, filteredValues); 752 } 753 } 754 } 755 } 756 } 757 } 758 759 /** 760 * Check if the invert relation between the given contents is broken 761 * @param source the source content of the invert relation 762 * @param destinationValue the destination content of the invert relation 763 * @param definition the definition concerned by the checked invert relation 764 * @return a {@link Pair} containing the result of the check ({@link Boolean} part left) and an optional third part content, if the relation is broken and the destination content references another content in a single attribute that should be modified 765 */ 766 private static Pair<Boolean, Optional<ContentValue>> _isInvertRelationBroken(Content source, ContentValue destinationValue, ContentAttributeDefinition definition) 767 { 768 Boolean isBroken = Boolean.FALSE; 769 Optional<ContentValue> thirdPartContent = Optional.empty(); 770 771 // Get the target content - ignore nonexistent contents, the checkBrokenReferences dealt with it 772 Optional<? extends Content> optDestination = destinationValue.getContentIfExists(); 773 if (optDestination.isPresent()) 774 { 775 // Get the value of the target content's attribute 776 Content destination = optDestination.get(); 777 Object value = destination.getValueWithMultiValuedPathSegments(definition.getInvertRelationPath()); 778 if (value == null) 779 { 780 isBroken = true; 781 } 782 else if (value instanceof ContentValue contentValue) 783 { 784 // If the value does not correspond to the current data holder 785 if (!contentValue.getContentId().equals(source.getId())) 786 { 787 isBroken = true; 788 789 // If the value of the destination is in relation to the destination too, this third part content will have to be modified 790 Object thirdPartValue = contentValue.getValueWithMultiValuedPathSegments(definition.getPath()); 791 if (thirdPartValue instanceof ContentValue thirdPartContentValue) 792 { 793 if (thirdPartContentValue.getContentId().equals(destination.getId())) 794 { 795 thirdPartContent = Optional.of(contentValue); 796 } 797 } 798 else if (thirdPartValue instanceof ContentValue[] thirdPartContentValue) 799 { 800 for (ContentValue thridPartSingleValue : thirdPartContentValue) 801 { 802 if (thridPartSingleValue.getContentId().equals(destination.getId())) 803 { 804 thirdPartContent = Optional.of(contentValue); 805 break; 806 } 807 } 808 } 809 } 810 } 811 else if (value instanceof ContentValue[]) 812 { 813 // Search for the source content in the values of the destination 814 boolean foundSource = false; 815 for (ContentValue contentValue : (ContentValue[]) value) 816 { 817 if (contentValue.getContentId().equals(source.getId())) 818 { 819 foundSource = true; 820 break; 821 } 822 } 823 824 isBroken = Boolean.valueOf(!foundSource); 825 } 826 } 827 828 return new ImmutablePair<>(isBroken, thirdPartContent); 829 } 830 831 /** 832 * Manages the invert relations concerned by the given path, through the given {@link InvertRelationManager} 833 * @param invertRelationPath the concerned invert relation path 834 * @param referencedContents a {@link Set} containing the referenced contents to manage (add or remove the relation) 835 * @param referencingContentId the id of the content referencing the given contents 836 * @param invertRelationManager the {@link InvertRelationManager} to use (to add or remove the invert relations) 837 * @param alreadyModifiedContents a {@link Map} of contents (indexed by their identifiers) that have already been modified. 838 * This map will be used to initialize the returned map and to search for contents that have already been resolved (to resolve each content only once, even if there are several modifications on a same content) 839 * @param externalizableDataContext the context {@link Map} that is used to determine if a data is externalizable 840 * @return a {@link Map} containing the contents (indexed by their identifiers) that have been modified by this call and the ones that have already been modified 841 */ 842 public static Map<String, ModifiableContent> manageInvertRelations(String invertRelationPath, Set<ContentValue> referencedContents, String referencingContentId, InvertRelationManager invertRelationManager, Map<String, ModifiableContent> alreadyModifiedContents, Map<String, Object> externalizableDataContext) 843 { 844 Map<ContentValue, Collection<String>> referencedContentsWithReferencing = referencedContents.stream() 845 .collect(Collectors.toMap(value -> value, value -> Set.of(referencingContentId))); 846 847 return manageInvertRelations(invertRelationPath, referencedContentsWithReferencing, invertRelationManager, alreadyModifiedContents, externalizableDataContext); 848 } 849 850 /** 851 * Manages the invert relations concerned by the given path, through the given {@link InvertRelationManager} 852 * @param invertRelationPath the concerned invert relation path 853 * @param referencedContents a {@link Map} containing the referenced contents to manage (add or remove the relation) and the id of the content referencing this content 854 * @param invertRelationManager the {@link InvertRelationManager} to use (to add or remove the invert relations) 855 * @param alreadyModifiedContents a {@link Map} of contents (indexed by their identifiers) that have already been modified. 856 * This map will be used to initialize the returned map and to search for contents that have already been resolved (to resolve each content only once, even if there are several modifications on a same content) 857 * @param externalizableDataContext the context {@link Map} that is used to determine if a data is externalizable 858 * @return a {@link Map} containing the contents (indexed by their identifiers) that have been modified by this call and the ones that have already been modified 859 */ 860 public static Map<String, ModifiableContent> manageInvertRelations(String invertRelationPath, Map<ContentValue, Collection<String>> referencedContents, InvertRelationManager invertRelationManager, Map<String, ModifiableContent> alreadyModifiedContents, Map<String, Object> externalizableDataContext) 861 { 862 Map<String, ModifiableContent> allModifiedContents = new HashMap<>(alreadyModifiedContents); 863 864 for (ContentValue referencedContent : referencedContents.keySet()) 865 { 866 String contentId = referencedContent.getContentId(); 867 Optional<ModifiableContent> optContent = allModifiedContents.containsKey(contentId) 868 ? Optional.of(allModifiedContents.get(contentId)) 869 : referencedContent.getContentIfExists(); 870 871 if (optContent.isPresent()) 872 { 873 ModifiableContent content = optContent.get(); 874 875 ModelItem modelItem = content.getDefinition(invertRelationPath); 876 ValueContext valueContext = ValueContext.newInstance(); 877 if (DataHolderHelper.getExternalizableDataProviderExtensionPoint().isDataExternalizable(content, modelItem)) 878 { 879 if (ExternalizableDataStatus.EXTERNAL.equals(content.getStatus(invertRelationPath))) 880 { 881 throw new IllegalStateException("Unable to manage the invert relation on distant content '" + content + "', the data at path '" + invertRelationPath + "' is external."); 882 } 883 else 884 { 885 valueContext.withStatus(ExternalizableDataStatus.LOCAL); 886 } 887 } 888 889 if (invertRelationManager.manageInvertRelation(content, invertRelationPath, referencedContents.get(referencedContent), valueContext)) 890 { 891 allModifiedContents.put(contentId, content); 892 } 893 } 894 } 895 896 return allModifiedContents; 897 } 898 899 /** 900 * Adds the invert relation to the given {@link DataHolder} 901 * @param dataHolder the data holder where to add the invert relation 902 * @param invertRelationPath the path of the invert relation 903 * @param referencingContentIds the id of the contents that reference the data holder 904 * @param context the value's context 905 * @return <code>true</code> if the given data holder has been modified, <code>false</code> otherwise 906 */ 907 public static boolean addInvertRelation(ModifiableModelAwareDataHolder dataHolder, String invertRelationPath, Collection<String> referencingContentIds, ValueContext context) 908 { 909 String[] pathSegments = StringUtils.split(invertRelationPath, ModelItem.ITEM_PATH_SEPARATOR); 910 String dataName = pathSegments[0]; 911 912 if (pathSegments.length == 1) 913 { 914 ContentAttributeDefinition invertRelationDefinition = (ContentAttributeDefinition) dataHolder.getDefinition(invertRelationPath); 915 if (invertRelationDefinition.isMultiple()) 916 { 917 List<String> newValues = new ArrayList<>(); 918 919 if (DataHolderHelper.hasValue(dataHolder, invertRelationPath, context)) 920 { 921 ContentValue[] oldValues = DataHolderHelper.getValue(dataHolder, invertRelationPath, context); 922 923 newValues = Arrays.stream(oldValues) 924 .map(ContentValue::getContentId) 925 .collect(Collectors.toList()); 926 } 927 928 newValues.addAll(referencingContentIds); 929 DataHolderHelper.setValue(dataHolder, invertRelationPath, newValues.toArray(new String[newValues.size()]), context); 930 return true; 931 } 932 else 933 { 934 assert referencingContentIds.size() == 1; 935 DataHolderHelper.setValue(dataHolder, invertRelationPath, referencingContentIds.iterator().next(), context); 936 return true; 937 } 938 } 939 else 940 { 941 ModelItem modelItem = dataHolder.getDefinition(dataName); 942 String subInvertRelationPath = StringUtils.join(pathSegments, ModelItem.ITEM_PATH_SEPARATOR, 1, pathSegments.length); 943 if (modelItem instanceof RepeaterDefinition) 944 { 945 ModifiableRepeaterEntry repeaterEntry = dataHolder.getRepeater(dataName, true).addEntry(); 946 return addInvertRelation(repeaterEntry, subInvertRelationPath, referencingContentIds, context); 947 } 948 else 949 { 950 ModifiableModelAwareComposite composite = dataHolder.getComposite(dataName, true); 951 return addInvertRelation(composite, subInvertRelationPath, referencingContentIds, context); 952 } 953 } 954 } 955 956 /** 957 * Removes the invert relation to the given {@link DataHolder} 958 * @param referencedContent the content where to remove the invert relation 959 * @param invertRelationPath the path of the invert relation 960 * @param referencingContentIds the id of the contents that do not reference the given content anymore 961 * @param context the value's context 962 * @return <code>true</code> if the referenced content has been modified, <code>false</code> otherwise 963 */ 964 public static boolean removeInvertRelation(ModifiableContent referencedContent, String invertRelationPath, Collection<String> referencingContentIds, ValueContext context) 965 { 966 ContentAttributeDefinition invertRelationDefinition = (ContentAttributeDefinition) referencedContent.getDefinition(invertRelationPath); 967 if (invertRelationDefinition.isMultiple()) 968 { 969 if (DataHolderHelper.hasValue(referencedContent, invertRelationPath, context)) 970 { 971 ContentValue[] oldValues = DataHolderHelper.getValue(referencedContent, invertRelationPath, context); 972 973 String[] newValues = Arrays.stream(oldValues) 974 .map(ContentValue::getContentId) 975 .filter(id -> !referencingContentIds.contains(id)) 976 .toArray(size -> new String[size]); 977 978 DataHolderHelper.setValue(referencedContent, invertRelationPath, newValues, context); 979 return oldValues.length > newValues.length; 980 } 981 else 982 { 983 return false; 984 } 985 } 986 else 987 { 988 return _removeSingleInvertRelation(referencedContent, invertRelationPath, referencingContentIds, context); 989 } 990 } 991 992 private static boolean _removeSingleInvertRelation(ModifiableModelAwareDataHolder dataHolder, String invertRelationPath, Collection<String> referencingContentIds, ValueContext context) 993 { 994 String[] pathSegments = StringUtils.split(invertRelationPath, ModelItem.ITEM_PATH_SEPARATOR); 995 String dataName = pathSegments[0]; 996 997 if (pathSegments.length == 1) 998 { 999 ContentValue value = DataHolderHelper.getValue(dataHolder, dataName, context); 1000 if (value != null && referencingContentIds.contains(value.getContentId())) 1001 { 1002 DataHolderHelper.removeValue(dataHolder, dataName, context); 1003 return true; 1004 } 1005 else 1006 { 1007 return false; 1008 } 1009 } 1010 else 1011 { 1012 ModelItem modelItem = dataHolder.getDefinition(dataName); 1013 String subInvertRelationPath = StringUtils.join(pathSegments, ModelItem.ITEM_PATH_SEPARATOR, 1, pathSegments.length); 1014 if (modelItem instanceof RepeaterDefinition) 1015 { 1016 boolean modified = false; 1017 1018 ModifiableRepeater repeater = dataHolder.getRepeater(dataName); 1019 if (repeater != null) 1020 { 1021 for (ModifiableRepeaterEntry repeaterEntry : repeater.getEntries()) 1022 { 1023 modified = _removeSingleInvertRelation(repeaterEntry, subInvertRelationPath, referencingContentIds, context) || modified; 1024 } 1025 } 1026 1027 return modified; 1028 } 1029 else 1030 { 1031 ModifiableModelAwareComposite composite = dataHolder.getComposite(dataName); 1032 return _removeSingleInvertRelation(composite, subInvertRelationPath, referencingContentIds, context); 1033 } 1034 } 1035 } 1036 1037 /** 1038 * Invert relation manager (to add or remove a relation on a content 1039 */ 1040 @FunctionalInterface 1041 public interface InvertRelationManager 1042 { 1043 /** 1044 * Manages the invert relation to the given {@link DataHolder} 1045 * @param referencedContent the content where to manage the invert relation 1046 * @param invertRelationPath the path of the invert relation 1047 * @param referencingContentIds the id of the contents that reference.d the given content 1048 * @param context the value's context 1049 * @return <code>true</code> if the referenced content has been modified, <code>false</code> otherwise 1050 */ 1051 public boolean manageInvertRelation(ModifiableContent referencedContent, String invertRelationPath, Collection<String> referencingContentIds, ValueContext context); 1052 } 1053}