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}