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