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