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