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 contents.add(new ReferencedContents(definition, addedValues, removedValues, thirdPartyContents)); 286 } 287 288 return contents; 289 } 290 291 @SuppressWarnings("unchecked") 292 private void _collectReferencedContents(Content content, 293 ViewItemAccessor viewItemAccessor, 294 Optional<ModelAwareDataHolder> currentDataHolder, 295 String dataPathPrefix, 296 Optional<String> oldDataPathPrefix, 297 Optional<Map<String, Object>> values, 298 boolean insideKeptDataHolder, 299 SynchronizationContext context, 300 Map<ContentAttributeDefinition, Pair<Map<ContentValue, List<String>>, Map<ContentValue, List<String>>>> references) 301 { 302 ViewHelper.visitView(viewItemAccessor, 303 (element, definition) -> { 304 // simple element 305 String name = definition.getName(); 306 307 if (definition instanceof ContentAttributeDefinition) 308 { 309 ContentAttributeDefinition contentDefinition = (ContentAttributeDefinition) definition; 310 String invert = contentDefinition.getInvertRelationPath(); 311 if (invert != null) 312 { 313 _collectInvertRelation(content, currentDataHolder, dataPathPrefix, oldDataPathPrefix, values, insideKeptDataHolder, name, contentDefinition, context, references); 314 } 315 } 316 }, 317 (group, definition) -> { 318 // composite 319 String name = definition.getName(); 320 String updatedPrefix = dataPathPrefix + name + ModelItem.ITEM_PATH_SEPARATOR; 321 Optional<String> updatedOldPrefix = oldDataPathPrefix.map(p -> p + name + ModelItem.ITEM_PATH_SEPARATOR); 322 _collectReferencedContents(content, group, currentDataHolder.map(v -> v.getComposite(name)), updatedPrefix, updatedOldPrefix, values.map(v -> (Map<String, Object>) v.get(name)), insideKeptDataHolder, context, 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 _collectRepeater(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 Object value = values.map(v -> v.get(name)).orElse(null); 445 List<Map<String, Object>> newEntries = null; 446 Map<Integer, Integer> mapping = null; 447 SynchronizableRepeater.Mode mode = SynchronizableRepeater.Mode.REPLACE_ALL; 448 if (value instanceof List) 449 { 450 newEntries = (List<Map<String, Object>>) value; 451 mapping = IntStream.rangeClosed(1, newEntries.size()).boxed().collect(Collectors.toMap(Function.identity(), Function.identity())); 452 } 453 else if (value instanceof SynchronizableRepeater) 454 { 455 newEntries = ((SynchronizableRepeater) value).getEntries(); 456 mapping = ((SynchronizableRepeater) value).getPositionsMapping(); 457 mode = ((SynchronizableRepeater) value).getMode(); 458 } 459 460 // first collect data for actually existing entries 461 Set<Integer> alreadyHandledValues = new HashSet<>(); 462 ModelAwareRepeater repeater = currentDataHolder.map(v -> v.getRepeater(name)).orElse(null); 463 if (repeater != null) 464 { 465 for (ModelAwareRepeaterEntry entry : repeater.getEntries()) 466 { 467 int position = entry.getPosition(); 468 469 Optional<Map<String, Object>> newValues = Optional.empty(); 470 String newPositionSuffix = ""; 471 boolean newKeptValue = insideKeptDataHolder; 472 473 if (!insideKeptDataHolder) 474 { 475 if (mode == SynchronizableRepeater.Mode.REPLACE_ALL) 476 { 477 Integer newPosition = mapping != null ? mapping.get(position) : null; 478 479 if (newPosition != null) 480 { 481 // not removed entry 482 newValues = newEntries != null ? Optional.ofNullable(newEntries.get(newPosition - 1)) : Optional.empty(); 483 newPositionSuffix = "[" + newPosition + "]"; 484 alreadyHandledValues.add(newPosition); 485 } 486 } 487 else if (mode == SynchronizableRepeater.Mode.REPLACE) 488 { 489 List<Integer> replacePositions = ((SynchronizableRepeater) value).getReplacePositions(); 490 int index = replacePositions.indexOf(position); 491 492 if (index >= 0) 493 { 494 assert newEntries != null; 495 newValues = Optional.ofNullable(newEntries.get(index)); 496 newPositionSuffix = "[" + position + "]"; 497 alreadyHandledValues.add(position); 498 } 499 else 500 { 501 // entry kept as is 502 newKeptValue = true; 503 } 504 } 505 else 506 { 507 Set<Integer> removedEntries = ((SynchronizableRepeater) value).getRemovedEntries(); 508 newKeptValue = !removedEntries.contains(position); 509 } 510 } 511 512 String updatePrefix = dataPathPrefix + name + newPositionSuffix + ModelItem.ITEM_PATH_SEPARATOR; 513 Optional<String> updatedOldPrefix = oldDataPathPrefix.map(p -> dataPathPrefix + name + "[" + position + "]" + ModelItem.ITEM_PATH_SEPARATOR); 514 _collectReferencedContents(content, group, Optional.of(entry), updatePrefix, updatedOldPrefix, newValues, newKeptValue, context, references); 515 } 516 } 517 518 // then collect data for newly created entries 519 if (newEntries != null) 520 { 521 for (int i = 1; i <= newEntries.size(); i++) 522 { 523 if (!alreadyHandledValues.contains(i)) 524 { 525 String updatedPrefix = dataPathPrefix + name + "[" + i + "]" + ModelItem.ITEM_PATH_SEPARATOR; 526 _collectReferencedContents(content, group, Optional.empty(), updatedPrefix, Optional.empty(), Optional.of(newEntries.get(i - 1)), insideKeptDataHolder, context, references); 527 } 528 } 529 } 530 } 531 532 /** 533 * Find any broken reference in data holder's content attributes. 534 * A reference is considered as broken if the referenced content does not exist 535 * @param dataHolder the {@link ModelAwareDataHolder} to inspect. 536 * @param fix if <code>true</code>, and the dataHolder is modifiable, broken references will be removed 537 * Warning: if modifications are made on the given data holder, the modifications won't be saved and the workflow won't change 538 * @return all broken references, by attribute path 539 */ 540 public static Map<String, List<String>> checkBrokenReferences(ModelAwareDataHolder dataHolder, boolean fix) 541 { 542 Map<String, List<String>> result = new HashMap<>(); 543 Map<String, Object> contentAttributes = DataHolderHelper.findEditableItemsByType(dataHolder, ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID); 544 for (Map.Entry<String, Object> entry : contentAttributes.entrySet()) 545 { 546 String dataPath = entry.getKey(); 547 Object value = entry.getValue(); 548 if (value instanceof ContentValue) 549 { 550 ContentValue contentValue = (ContentValue) value; 551 if (contentValue.getContentIfExists().isEmpty()) 552 { 553 result.computeIfAbsent(dataPath, __ -> new ArrayList<>()).add(contentValue.getContentId()); 554 if (fix && dataHolder instanceof ModifiableModelAwareDataHolder) 555 { 556 ((ModifiableModelAwareDataHolder) dataHolder).setValue(dataPath, null); 557 } 558 } 559 } 560 else if (value instanceof ContentValue[]) 561 { 562 List<ContentValue> newValues = new ArrayList<>(); 563 for (ContentValue contentValue : (ContentValue[]) value) 564 { 565 if (contentValue.getContentIfExists().isEmpty()) 566 { 567 result.computeIfAbsent(dataPath, __ -> new ArrayList<>()).add(contentValue.getContentId()); 568 } 569 else if (fix) 570 { 571 newValues.add(contentValue); 572 } 573 } 574 if (fix && dataHolder instanceof ModifiableModelAwareDataHolder) 575 { 576 ((ModifiableModelAwareDataHolder) dataHolder).setValue(dataPath, newValues.toArray(ContentValue[]::new)); 577 } 578 } 579 } 580 return result; 581 } 582 583 /** 584 * Find any broken invert relation in a content attributes. 585 * 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 586 * This check does not take nonexistent referenced contents into account. First call the {@link ContentDataHelper#checkBrokenReferences(ModelAwareDataHolder, boolean)} method 587 * @param content the content to inspect. 588 * @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 589 * 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. 590 * Warning: if modifications are made on some contents, the modifications won't be saved and the workflows won't change 591 * @return All broken invert relations in a collection of {@link ReferencedContents}. 592 * Call {@link ReferencedContents#getAddedContents()} to get all contents referenced by the given content that does not reference it in return 593 * Call {@link ReferencedContents#getThirdPartyContents()} to get contents referenced by content in added contents that reference it in return 594 */ 595 public static Collection<ReferencedContents> checkBrokenInvertRelations(Content content, boolean fix) 596 { 597 // Search for all content attribute's values 598 Map<String, Object> contentAttributes = DataHolderHelper.findEditableItemsByType(content, ModelItemTypeConstants.CONTENT_ELEMENT_TYPE_ID); 599 600 // Collect broken invert relations 601 Collection<ReferencedContents> brokenInvertRelations = new ArrayList<>(); 602 for (Map.Entry<String, Object> entry : contentAttributes.entrySet()) 603 { 604 String dataPath = entry.getKey(); 605 Object value = entry.getValue(); 606 ContentAttributeDefinition definition = (ContentAttributeDefinition) content.getDefinition(dataPath); 607 608 // Continue check only on content attributes with an invert relation path 609 if (StringUtils.isNotBlank(definition.getInvertRelationPath())) 610 { 611 Map<ContentValue, List<String>> contentsToAdd = new HashMap<>(); 612 Map<ContentValue, ContentValue> thirdPartyContents = new HashMap<>(); 613 614 if (value instanceof ContentValue) 615 { 616 Pair<Boolean, Optional<ContentValue>> isBroken = _isInvertRelationBroken(content, (ContentValue) value, definition); 617 if (isBroken.getLeft()) 618 { 619 contentsToAdd.put((ContentValue) value, List.of(dataPath)); 620 isBroken.getRight() 621 .ifPresent(v -> thirdPartyContents.put((ContentValue) value, v)); 622 } 623 } 624 else if (value instanceof ContentValue[]) 625 { 626 for (ContentValue contentValue : (ContentValue[]) value) 627 { 628 Pair<Boolean, Optional<ContentValue>> isBroken = _isInvertRelationBroken(content, contentValue, definition); 629 if (isBroken.getLeft()) 630 { 631 contentsToAdd.computeIfAbsent(contentValue, __ -> new ArrayList<>()).add(dataPath); 632 isBroken.getRight() 633 .ifPresent(v -> thirdPartyContents.put(contentValue, v)); 634 } 635 } 636 } 637 638 // Add a ReferencedContents object if there is at least one broken relation 639 // No need to check the third part content, this map can't have entries if the first one is empty 640 if (!contentsToAdd.isEmpty()) 641 { 642 ReferencedContents brokenInvertRelation = new ReferencedContents(definition, contentsToAdd, Map.of(), thirdPartyContents); 643 brokenInvertRelations.add(brokenInvertRelation); 644 } 645 } 646 } 647 648 if (fix) 649 { 650 Map<String, ModifiableContent> modifiedContents = new HashMap<>(); 651 652 for (ReferencedContents brokenInvertRelation : brokenInvertRelations) 653 { 654 // Add relation on contents referenced by the given one 655 String invertRelationPath = brokenInvertRelation.getDefinition().getInvertRelationPath(); 656 modifiedContents.putAll(manageInvertRelations(invertRelationPath, brokenInvertRelation.getAddedContents(), content.getId(), ContentDataHelper::addInvertRelation, modifiedContents, Map.of())); 657 658 // Remove relations on third party contents 659 Map<ContentValue, Collection<String>> thirdPartyContents = new HashMap<>(); 660 for (Map.Entry<ContentValue, ContentValue> thirdPartyContent : brokenInvertRelation.getThirdPartyContents().entrySet()) 661 { 662 thirdPartyContents.computeIfAbsent(thirdPartyContent.getValue(), __ -> new ArrayList<>()).add(thirdPartyContent.getKey().getContentId()); 663 } 664 modifiedContents.putAll(manageInvertRelations(brokenInvertRelation.getDefinition().getPath(), thirdPartyContents, ContentDataHelper::removeInvertRelation, modifiedContents, Map.of())); 665 } 666 } 667 668 return brokenInvertRelations; 669 } 670 671 /** 672 * Check if the invert relation between the given contents is broken 673 * @param source the source content of the invert relation 674 * @param destinationValue the destination content of the invert relation 675 * @param definition the definition concerned by the checked invert relation 676 * @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 677 */ 678 private static Pair<Boolean, Optional<ContentValue>> _isInvertRelationBroken(Content source, ContentValue destinationValue, ContentAttributeDefinition definition) 679 { 680 Boolean isBroken = Boolean.FALSE; 681 Optional<ContentValue> thirdPartContent = Optional.empty(); 682 683 // Get the target content - ignore nonexistent contents, the checkBrokenReferences dealt with it 684 Optional<? extends Content> optDestination = destinationValue.getContentIfExists(); 685 if (optDestination.isPresent()) 686 { 687 // Get the value of the target content's attribute 688 Content destination = optDestination.get(); 689 Object value = destination.getValue(definition.getInvertRelationPath(), true); 690 if (value == null) 691 { 692 isBroken = true; 693 } 694 else if (value instanceof ContentValue) 695 { 696 // If the value does not correspond to the current data holder 697 if (!((ContentValue) value).getContentId().equals(source.getId())) 698 { 699 isBroken = true; 700 701 // If the value of the destination is in relation to the destination too, this third part content will have to be modified 702 Object thirdPartValue = ((ContentValue) value).getValue(definition.getPath(), true); 703 if (thirdPartValue instanceof ContentValue) 704 { 705 if (((ContentValue) thirdPartValue).getContentId().equals(destination.getId())) 706 { 707 thirdPartContent = Optional.of((ContentValue) value); 708 } 709 } 710 else if (thirdPartValue instanceof ContentValue[]) 711 { 712 for (ContentValue thridPartSingleValue : (ContentValue[]) thirdPartValue) 713 { 714 if (thridPartSingleValue.getContentId().equals(destination.getId())) 715 { 716 thirdPartContent = Optional.of((ContentValue) value); 717 break; 718 } 719 } 720 } 721 } 722 } 723 else if (value instanceof ContentValue[]) 724 { 725 // Search for the source content in the values of the destination 726 boolean foundSource = false; 727 for (ContentValue contentValue : (ContentValue[]) value) 728 { 729 if (contentValue.getContentId().equals(source.getId())) 730 { 731 foundSource = true; 732 break; 733 } 734 } 735 736 isBroken = Boolean.valueOf(!foundSource); 737 } 738 } 739 740 return new ImmutablePair<>(isBroken, thirdPartContent); 741 } 742 743 /** 744 * Manages the invert relations concerned by the given path, through the given {@link InvertRelationManager} 745 * @param invertRelationPath the concerned invert relation path 746 * @param referencedContents a {@link Set} containing the referenced contents to manage (add or remove the relation) 747 * @param referencingContentId the id of the content referencing the given contents 748 * @param invertRelationManager the {@link InvertRelationManager} to use (to add or remove the invert relations) 749 * @param alreadyModifiedContents a {@link Map} of contents (indexed by their identifiers) that have already been modified. 750 * 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) 751 * @param externalizableDataContext the context {@link Map} that is used to determine if a data is externalizable 752 * @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 753 */ 754 public static Map<String, ModifiableContent> manageInvertRelations(String invertRelationPath, Set<ContentValue> referencedContents, String referencingContentId, InvertRelationManager invertRelationManager, Map<String, ModifiableContent> alreadyModifiedContents, Map<String, Object> externalizableDataContext) 755 { 756 Map<ContentValue, Collection<String>> referencedContentsWithReferencing = referencedContents.stream() 757 .collect(Collectors.toMap(value -> value, value -> Set.of(referencingContentId))); 758 759 return manageInvertRelations(invertRelationPath, referencedContentsWithReferencing, invertRelationManager, alreadyModifiedContents, externalizableDataContext); 760 } 761 762 /** 763 * Manages the invert relations concerned by the given path, through the given {@link InvertRelationManager} 764 * @param invertRelationPath the concerned invert relation path 765 * @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 766 * @param invertRelationManager the {@link InvertRelationManager} to use (to add or remove the invert relations) 767 * @param alreadyModifiedContents a {@link Map} of contents (indexed by their identifiers) that have already been modified. 768 * 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) 769 * @param externalizableDataContext the context {@link Map} that is used to determine if a data is externalizable 770 * @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 771 */ 772 public static Map<String, ModifiableContent> manageInvertRelations(String invertRelationPath, Map<ContentValue, Collection<String>> referencedContents, InvertRelationManager invertRelationManager, Map<String, ModifiableContent> alreadyModifiedContents, Map<String, Object> externalizableDataContext) 773 { 774 Map<String, ModifiableContent> allModifiedContents = new HashMap<>(alreadyModifiedContents); 775 776 for (ContentValue referencedContent : referencedContents.keySet()) 777 { 778 String contentId = referencedContent.getContentId(); 779 Optional<ModifiableContent> optContent = allModifiedContents.containsKey(contentId) 780 ? Optional.of(allModifiedContents.get(contentId)) 781 : referencedContent.getContentIfExists(); 782 783 if (optContent.isPresent()) 784 { 785 ModifiableContent content = optContent.get(); 786 787 ModelItem modelItem = content.getDefinition(invertRelationPath); 788 ValueContext valueContext = ValueContext.newInstance(); 789 if (DataHolderHelper.getExternalizableDataProviderExtensionPoint().isDataExternalizable(content, modelItem)) 790 { 791 if (ExternalizableDataStatus.EXTERNAL.equals(content.getStatus(invertRelationPath))) 792 { 793 throw new IllegalStateException("Unable to manage the invert relation on distant content '" + content + "', the data at path '" + invertRelationPath + "' is external."); 794 } 795 else 796 { 797 valueContext.withStatus(ExternalizableDataStatus.LOCAL); 798 } 799 } 800 801 if (invertRelationManager.manageInvertRelation(content, invertRelationPath, referencedContents.get(referencedContent), valueContext)) 802 { 803 allModifiedContents.put(contentId, content); 804 } 805 } 806 } 807 808 return allModifiedContents; 809 } 810 811 /** 812 * Adds the invert relation to the given {@link DataHolder} 813 * @param dataHolder the data holder where to add the invert relation 814 * @param invertRelationPath the path of the invert relation 815 * @param referencingContentIds the id of the contents that reference the data holder 816 * @param context the value's context 817 * @return <code>true</code> if the given data holder has been modified, <code>false</code> otherwise 818 */ 819 public static boolean addInvertRelation(ModifiableModelAwareDataHolder dataHolder, String invertRelationPath, Collection<String> referencingContentIds, ValueContext context) 820 { 821 String[] pathSegments = StringUtils.split(invertRelationPath, ModelItem.ITEM_PATH_SEPARATOR); 822 String dataName = pathSegments[0]; 823 824 if (pathSegments.length == 1) 825 { 826 ContentAttributeDefinition invertRelationDefinition = (ContentAttributeDefinition) dataHolder.getDefinition(invertRelationPath); 827 if (invertRelationDefinition.isMultiple()) 828 { 829 List<String> newValues = new ArrayList<>(); 830 831 if (DataHolderHelper.hasValue(dataHolder, invertRelationPath, context)) 832 { 833 ContentValue[] oldValues = DataHolderHelper.getValue(dataHolder, invertRelationPath, context); 834 835 newValues = Arrays.stream(oldValues) 836 .map(ContentValue::getContentId) 837 .collect(Collectors.toList()); 838 } 839 840 newValues.addAll(referencingContentIds); 841 DataHolderHelper.setValue(dataHolder, invertRelationPath, newValues.toArray(new String[newValues.size()]), context); 842 return true; 843 } 844 else 845 { 846 assert referencingContentIds.size() == 1; 847 DataHolderHelper.setValue(dataHolder, invertRelationPath, referencingContentIds.iterator().next(), context); 848 return true; 849 } 850 } 851 else 852 { 853 ModelItem modelItem = dataHolder.getDefinition(dataName); 854 String subInvertRelationPath = StringUtils.join(pathSegments, ModelItem.ITEM_PATH_SEPARATOR, 1, pathSegments.length); 855 if (modelItem instanceof RepeaterDefinition) 856 { 857 ModifiableModelAwareRepeaterEntry repeaterEntry = dataHolder.getRepeater(dataName, true).addEntry(); 858 return addInvertRelation(repeaterEntry, subInvertRelationPath, referencingContentIds, context); 859 } 860 else 861 { 862 ModifiableModelAwareComposite composite = dataHolder.getComposite(dataName, true); 863 return addInvertRelation(composite, subInvertRelationPath, referencingContentIds, context); 864 } 865 } 866 } 867 868 /** 869 * Removes the invert relation to the given {@link DataHolder} 870 * @param referencedContent the content where to remove the invert relation 871 * @param invertRelationPath the path of the invert relation 872 * @param referencingContentIds the id of the contents that do not reference the given content anymore 873 * @param context the value's context 874 * @return <code>true</code> if the referenced content has been modified, <code>false</code> otherwise 875 */ 876 public static boolean removeInvertRelation(ModifiableContent referencedContent, String invertRelationPath, Collection<String> referencingContentIds, ValueContext context) 877 { 878 ContentAttributeDefinition invertRelationDefinition = (ContentAttributeDefinition) referencedContent.getDefinition(invertRelationPath); 879 if (invertRelationDefinition.isMultiple()) 880 { 881 if (DataHolderHelper.hasValue(referencedContent, invertRelationPath, context)) 882 { 883 ContentValue[] oldValues = DataHolderHelper.getValue(referencedContent, invertRelationPath, context); 884 885 String[] newValues = Arrays.stream(oldValues) 886 .map(ContentValue::getContentId) 887 .filter(id -> !referencingContentIds.contains(id)) 888 .toArray(size -> new String[size]); 889 890 DataHolderHelper.setValue(referencedContent, invertRelationPath, newValues, context); 891 return oldValues.length > newValues.length; 892 } 893 else 894 { 895 return false; 896 } 897 } 898 else 899 { 900 return _removeSingleInvertRelation(referencedContent, invertRelationPath, referencingContentIds, context); 901 } 902 } 903 904 private static boolean _removeSingleInvertRelation(ModifiableModelAwareDataHolder dataHolder, String invertRelationPath, Collection<String> referencingContentIds, ValueContext context) 905 { 906 String[] pathSegments = StringUtils.split(invertRelationPath, ModelItem.ITEM_PATH_SEPARATOR); 907 String dataName = pathSegments[0]; 908 909 if (pathSegments.length == 1) 910 { 911 ContentValue value = DataHolderHelper.getValue(dataHolder, dataName, context); 912 if (value != null && referencingContentIds.contains(value.getContentId())) 913 { 914 DataHolderHelper.removeValue(dataHolder, dataName, context); 915 return true; 916 } 917 else 918 { 919 return false; 920 } 921 } 922 else 923 { 924 ModelItem modelItem = dataHolder.getDefinition(dataName); 925 String subInvertRelationPath = StringUtils.join(pathSegments, ModelItem.ITEM_PATH_SEPARATOR, 1, pathSegments.length); 926 if (modelItem instanceof RepeaterDefinition) 927 { 928 boolean modified = false; 929 930 ModifiableModelAwareRepeater repeater = dataHolder.getRepeater(dataName); 931 if (repeater != null) 932 { 933 for (ModifiableModelAwareRepeaterEntry repeaterEntry : repeater.getEntries()) 934 { 935 modified = _removeSingleInvertRelation(repeaterEntry, subInvertRelationPath, referencingContentIds, context) || modified; 936 } 937 } 938 939 return modified; 940 } 941 else 942 { 943 ModifiableModelAwareComposite composite = dataHolder.getComposite(dataName); 944 return _removeSingleInvertRelation(composite, subInvertRelationPath, referencingContentIds, context); 945 } 946 } 947 } 948 949 /** 950 * Invert relation manager (to add or remove a relation on a content 951 */ 952 @FunctionalInterface 953 public interface InvertRelationManager 954 { 955 /** 956 * Manages the invert relation to the given {@link DataHolder} 957 * @param referencedContent the content where to manage the invert relation 958 * @param invertRelationPath the path of the invert relation 959 * @param referencingContentIds the id of the contents that reference.d the given content 960 * @param context the value's context 961 * @return <code>true</code> if the referenced content has been modified, <code>false</code> otherwise 962 */ 963 public boolean manageInvertRelation(ModifiableContent referencedContent, String invertRelationPath, Collection<String> referencingContentIds, ValueContext context); 964 } 965}