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.ModelAwareRepeater; 054import org.ametys.plugins.repository.data.holder.group.ModelAwareRepeaterEntry; 055import org.ametys.plugins.repository.data.holder.group.ModifiableModelAwareComposite; 056import org.ametys.plugins.repository.data.holder.group.ModifiableModelAwareRepeater; 057import org.ametys.plugins.repository.data.holder.group.ModifiableModelAwareRepeaterEntry; 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 ModelAwareRepeater repeater = currentDataHolder.map(v -> v.getRepeater(name)).orElse(null); 525 526 if (repeater != null) 527 { 528 for (ModelAwareRepeaterEntry 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 // Continue check only on content attributes with an invert relation path 680 if (StringUtils.isNotBlank(definition.getInvertRelationPath())) 681 { 682 Map<ContentValue, List<String>> contentsToAdd = new HashMap<>(); 683 Map<ContentValue, ContentValue> thirdPartyContents = new HashMap<>(); 684 685 if (value instanceof ContentValue contentValue) 686 { 687 Pair<Boolean, Optional<ContentValue>> isBroken = _isInvertRelationBroken(content, contentValue, definition); 688 if (isBroken.getLeft()) 689 { 690 contentsToAdd.computeIfAbsent(contentValue, __ -> new ArrayList<>()).add(dataPath); 691 isBroken.getRight() 692 .ifPresent(v -> thirdPartyContents.put(contentValue, v)); 693 } 694 } 695 else if (value instanceof ContentValue[]) 696 { 697 for (ContentValue contentValue : (ContentValue[]) value) 698 { 699 Pair<Boolean, Optional<ContentValue>> isBroken = _isInvertRelationBroken(content, contentValue, definition); 700 if (isBroken.getLeft()) 701 { 702 contentsToAdd.computeIfAbsent(contentValue, __ -> new ArrayList<>()).add(dataPath); 703 isBroken.getRight() 704 .ifPresent(v -> thirdPartyContents.put(contentValue, v)); 705 } 706 } 707 } 708 709 // Add a ReferencedContents object if there is at least one broken relation 710 // No need to check the third part content, this map can't have entries if the first one is empty 711 if (!contentsToAdd.isEmpty()) 712 { 713 ReferencedContents brokenInvertRelation = new ReferencedContents(definition, contentsToAdd, Map.of(), thirdPartyContents); 714 brokenInvertRelations.add(brokenInvertRelation); 715 if (fix) 716 { 717 if (allowThirdPartyRemoval || brokenInvertRelation.getThirdPartyContents().isEmpty()) 718 { 719 // Add relation on contents referenced by the given one 720 String invertRelationPath = brokenInvertRelation.getDefinition().getInvertRelationPath(); 721 manageInvertRelations(invertRelationPath, brokenInvertRelation.getAddedContents(), content.getId(), ContentDataHelper::addInvertRelation, Map.of(), Map.of()); 722 723 // Remove relations on third party contents 724 Map<ContentValue, Collection<String>> thirdPartyCons = new HashMap<>(); 725 for (Map.Entry<ContentValue, ContentValue> thirdPartyContent : brokenInvertRelation.getThirdPartyContents().entrySet()) 726 { 727 thirdPartyCons.computeIfAbsent(thirdPartyContent.getValue(), __ -> new ArrayList<>()).add(thirdPartyContent.getKey().getContentId()); 728 } 729 manageInvertRelations(brokenInvertRelation.getDefinition().getPath(), thirdPartyCons, ContentDataHelper::removeInvertRelation, Map.of(), Map.of()); 730 } 731 // remove the relation 732 else if (content instanceof ModifiableContent mContent) 733 { 734 if (value instanceof ContentValue) 735 { 736 mContent.setValue(dataPath, null); 737 } 738 else if (value instanceof ContentValue[] contentValues) 739 { 740 ContentValue[] filteredValues = Stream.of(contentValues) 741 .filter(v -> brokenInvertRelation.getAddedContents().contains(v)) 742 .toArray(ContentValue[]::new); 743 mContent.setValue(dataPath, filteredValues); 744 } 745 } 746 } 747 } 748 } 749 } 750 751 return brokenInvertRelations; 752 } 753 754 /** 755 * Check if the invert relation between the given contents is broken 756 * @param source the source content of the invert relation 757 * @param destinationValue the destination content of the invert relation 758 * @param definition the definition concerned by the checked invert relation 759 * @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 760 */ 761 private static Pair<Boolean, Optional<ContentValue>> _isInvertRelationBroken(Content source, ContentValue destinationValue, ContentAttributeDefinition definition) 762 { 763 Boolean isBroken = Boolean.FALSE; 764 Optional<ContentValue> thirdPartContent = Optional.empty(); 765 766 // Get the target content - ignore nonexistent contents, the checkBrokenReferences dealt with it 767 Optional<? extends Content> optDestination = destinationValue.getContentIfExists(); 768 if (optDestination.isPresent()) 769 { 770 // Get the value of the target content's attribute 771 Content destination = optDestination.get(); 772 Object value = destination.getValue(definition.getInvertRelationPath(), true); 773 if (value == null) 774 { 775 isBroken = true; 776 } 777 else if (value instanceof ContentValue) 778 { 779 // If the value does not correspond to the current data holder 780 if (!((ContentValue) value).getContentId().equals(source.getId())) 781 { 782 isBroken = true; 783 784 // If the value of the destination is in relation to the destination too, this third part content will have to be modified 785 Object thirdPartValue = ((ContentValue) value).getValue(definition.getPath(), true); 786 if (thirdPartValue instanceof ContentValue) 787 { 788 if (((ContentValue) thirdPartValue).getContentId().equals(destination.getId())) 789 { 790 thirdPartContent = Optional.of((ContentValue) value); 791 } 792 } 793 else if (thirdPartValue instanceof ContentValue[]) 794 { 795 for (ContentValue thridPartSingleValue : (ContentValue[]) thirdPartValue) 796 { 797 if (thridPartSingleValue.getContentId().equals(destination.getId())) 798 { 799 thirdPartContent = Optional.of((ContentValue) value); 800 break; 801 } 802 } 803 } 804 } 805 } 806 else if (value instanceof ContentValue[]) 807 { 808 // Search for the source content in the values of the destination 809 boolean foundSource = false; 810 for (ContentValue contentValue : (ContentValue[]) value) 811 { 812 if (contentValue.getContentId().equals(source.getId())) 813 { 814 foundSource = true; 815 break; 816 } 817 } 818 819 isBroken = Boolean.valueOf(!foundSource); 820 } 821 } 822 823 return new ImmutablePair<>(isBroken, thirdPartContent); 824 } 825 826 /** 827 * Manages the invert relations concerned by the given path, through the given {@link InvertRelationManager} 828 * @param invertRelationPath the concerned invert relation path 829 * @param referencedContents a {@link Set} containing the referenced contents to manage (add or remove the relation) 830 * @param referencingContentId the id of the content referencing the given contents 831 * @param invertRelationManager the {@link InvertRelationManager} to use (to add or remove the invert relations) 832 * @param alreadyModifiedContents a {@link Map} of contents (indexed by their identifiers) that have already been modified. 833 * 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) 834 * @param externalizableDataContext the context {@link Map} that is used to determine if a data is externalizable 835 * @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 836 */ 837 public static Map<String, ModifiableContent> manageInvertRelations(String invertRelationPath, Set<ContentValue> referencedContents, String referencingContentId, InvertRelationManager invertRelationManager, Map<String, ModifiableContent> alreadyModifiedContents, Map<String, Object> externalizableDataContext) 838 { 839 Map<ContentValue, Collection<String>> referencedContentsWithReferencing = referencedContents.stream() 840 .collect(Collectors.toMap(value -> value, value -> Set.of(referencingContentId))); 841 842 return manageInvertRelations(invertRelationPath, referencedContentsWithReferencing, invertRelationManager, alreadyModifiedContents, externalizableDataContext); 843 } 844 845 /** 846 * Manages the invert relations concerned by the given path, through the given {@link InvertRelationManager} 847 * @param invertRelationPath the concerned invert relation path 848 * @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 849 * @param invertRelationManager the {@link InvertRelationManager} to use (to add or remove the invert relations) 850 * @param alreadyModifiedContents a {@link Map} of contents (indexed by their identifiers) that have already been modified. 851 * 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) 852 * @param externalizableDataContext the context {@link Map} that is used to determine if a data is externalizable 853 * @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 854 */ 855 public static Map<String, ModifiableContent> manageInvertRelations(String invertRelationPath, Map<ContentValue, Collection<String>> referencedContents, InvertRelationManager invertRelationManager, Map<String, ModifiableContent> alreadyModifiedContents, Map<String, Object> externalizableDataContext) 856 { 857 Map<String, ModifiableContent> allModifiedContents = new HashMap<>(alreadyModifiedContents); 858 859 for (ContentValue referencedContent : referencedContents.keySet()) 860 { 861 String contentId = referencedContent.getContentId(); 862 Optional<ModifiableContent> optContent = allModifiedContents.containsKey(contentId) 863 ? Optional.of(allModifiedContents.get(contentId)) 864 : referencedContent.getContentIfExists(); 865 866 if (optContent.isPresent()) 867 { 868 ModifiableContent content = optContent.get(); 869 870 ModelItem modelItem = content.getDefinition(invertRelationPath); 871 ValueContext valueContext = ValueContext.newInstance(); 872 if (DataHolderHelper.getExternalizableDataProviderExtensionPoint().isDataExternalizable(content, modelItem)) 873 { 874 if (ExternalizableDataStatus.EXTERNAL.equals(content.getStatus(invertRelationPath))) 875 { 876 throw new IllegalStateException("Unable to manage the invert relation on distant content '" + content + "', the data at path '" + invertRelationPath + "' is external."); 877 } 878 else 879 { 880 valueContext.withStatus(ExternalizableDataStatus.LOCAL); 881 } 882 } 883 884 if (invertRelationManager.manageInvertRelation(content, invertRelationPath, referencedContents.get(referencedContent), valueContext)) 885 { 886 allModifiedContents.put(contentId, content); 887 } 888 } 889 } 890 891 return allModifiedContents; 892 } 893 894 /** 895 * Adds the invert relation to the given {@link DataHolder} 896 * @param dataHolder the data holder where to add the invert relation 897 * @param invertRelationPath the path of the invert relation 898 * @param referencingContentIds the id of the contents that reference the data holder 899 * @param context the value's context 900 * @return <code>true</code> if the given data holder has been modified, <code>false</code> otherwise 901 */ 902 public static boolean addInvertRelation(ModifiableModelAwareDataHolder dataHolder, String invertRelationPath, Collection<String> referencingContentIds, ValueContext context) 903 { 904 String[] pathSegments = StringUtils.split(invertRelationPath, ModelItem.ITEM_PATH_SEPARATOR); 905 String dataName = pathSegments[0]; 906 907 if (pathSegments.length == 1) 908 { 909 ContentAttributeDefinition invertRelationDefinition = (ContentAttributeDefinition) dataHolder.getDefinition(invertRelationPath); 910 if (invertRelationDefinition.isMultiple()) 911 { 912 List<String> newValues = new ArrayList<>(); 913 914 if (DataHolderHelper.hasValue(dataHolder, invertRelationPath, context)) 915 { 916 ContentValue[] oldValues = DataHolderHelper.getValue(dataHolder, invertRelationPath, context); 917 918 newValues = Arrays.stream(oldValues) 919 .map(ContentValue::getContentId) 920 .collect(Collectors.toList()); 921 } 922 923 newValues.addAll(referencingContentIds); 924 DataHolderHelper.setValue(dataHolder, invertRelationPath, newValues.toArray(new String[newValues.size()]), context); 925 return true; 926 } 927 else 928 { 929 assert referencingContentIds.size() == 1; 930 DataHolderHelper.setValue(dataHolder, invertRelationPath, referencingContentIds.iterator().next(), context); 931 return true; 932 } 933 } 934 else 935 { 936 ModelItem modelItem = dataHolder.getDefinition(dataName); 937 String subInvertRelationPath = StringUtils.join(pathSegments, ModelItem.ITEM_PATH_SEPARATOR, 1, pathSegments.length); 938 if (modelItem instanceof RepeaterDefinition) 939 { 940 ModifiableModelAwareRepeaterEntry repeaterEntry = dataHolder.getRepeater(dataName, true).addEntry(); 941 return addInvertRelation(repeaterEntry, subInvertRelationPath, referencingContentIds, context); 942 } 943 else 944 { 945 ModifiableModelAwareComposite composite = dataHolder.getComposite(dataName, true); 946 return addInvertRelation(composite, subInvertRelationPath, referencingContentIds, context); 947 } 948 } 949 } 950 951 /** 952 * Removes the invert relation to the given {@link DataHolder} 953 * @param referencedContent the content where to remove the invert relation 954 * @param invertRelationPath the path of the invert relation 955 * @param referencingContentIds the id of the contents that do not reference the given content anymore 956 * @param context the value's context 957 * @return <code>true</code> if the referenced content has been modified, <code>false</code> otherwise 958 */ 959 public static boolean removeInvertRelation(ModifiableContent referencedContent, String invertRelationPath, Collection<String> referencingContentIds, ValueContext context) 960 { 961 ContentAttributeDefinition invertRelationDefinition = (ContentAttributeDefinition) referencedContent.getDefinition(invertRelationPath); 962 if (invertRelationDefinition.isMultiple()) 963 { 964 if (DataHolderHelper.hasValue(referencedContent, invertRelationPath, context)) 965 { 966 ContentValue[] oldValues = DataHolderHelper.getValue(referencedContent, invertRelationPath, context); 967 968 String[] newValues = Arrays.stream(oldValues) 969 .map(ContentValue::getContentId) 970 .filter(id -> !referencingContentIds.contains(id)) 971 .toArray(size -> new String[size]); 972 973 DataHolderHelper.setValue(referencedContent, invertRelationPath, newValues, context); 974 return oldValues.length > newValues.length; 975 } 976 else 977 { 978 return false; 979 } 980 } 981 else 982 { 983 return _removeSingleInvertRelation(referencedContent, invertRelationPath, referencingContentIds, context); 984 } 985 } 986 987 private static boolean _removeSingleInvertRelation(ModifiableModelAwareDataHolder dataHolder, String invertRelationPath, Collection<String> referencingContentIds, ValueContext context) 988 { 989 String[] pathSegments = StringUtils.split(invertRelationPath, ModelItem.ITEM_PATH_SEPARATOR); 990 String dataName = pathSegments[0]; 991 992 if (pathSegments.length == 1) 993 { 994 ContentValue value = DataHolderHelper.getValue(dataHolder, dataName, context); 995 if (value != null && referencingContentIds.contains(value.getContentId())) 996 { 997 DataHolderHelper.removeValue(dataHolder, dataName, context); 998 return true; 999 } 1000 else 1001 { 1002 return false; 1003 } 1004 } 1005 else 1006 { 1007 ModelItem modelItem = dataHolder.getDefinition(dataName); 1008 String subInvertRelationPath = StringUtils.join(pathSegments, ModelItem.ITEM_PATH_SEPARATOR, 1, pathSegments.length); 1009 if (modelItem instanceof RepeaterDefinition) 1010 { 1011 boolean modified = false; 1012 1013 ModifiableModelAwareRepeater repeater = dataHolder.getRepeater(dataName); 1014 if (repeater != null) 1015 { 1016 for (ModifiableModelAwareRepeaterEntry repeaterEntry : repeater.getEntries()) 1017 { 1018 modified = _removeSingleInvertRelation(repeaterEntry, subInvertRelationPath, referencingContentIds, context) || modified; 1019 } 1020 } 1021 1022 return modified; 1023 } 1024 else 1025 { 1026 ModifiableModelAwareComposite composite = dataHolder.getComposite(dataName); 1027 return _removeSingleInvertRelation(composite, subInvertRelationPath, referencingContentIds, context); 1028 } 1029 } 1030 } 1031 1032 /** 1033 * Invert relation manager (to add or remove a relation on a content 1034 */ 1035 @FunctionalInterface 1036 public interface InvertRelationManager 1037 { 1038 /** 1039 * Manages the invert relation to the given {@link DataHolder} 1040 * @param referencedContent the content where to manage the invert relation 1041 * @param invertRelationPath the path of the invert relation 1042 * @param referencingContentIds the id of the contents that reference.d the given content 1043 * @param context the value's context 1044 * @return <code>true</code> if the referenced content has been modified, <code>false</code> otherwise 1045 */ 1046 public boolean manageInvertRelation(ModifiableContent referencedContent, String invertRelationPath, Collection<String> referencingContentIds, ValueContext context); 1047 } 1048}