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