001/*
002 *  Copyright 2014 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.workflow;
017
018import java.lang.reflect.Array;
019import java.time.ZonedDateTime;
020import java.util.ArrayList;
021import java.util.Arrays;
022import java.util.Collection;
023import java.util.Collections;
024import java.util.HashMap;
025import java.util.HashSet;
026import java.util.List;
027import java.util.Map;
028import java.util.Map.Entry;
029import java.util.Optional;
030import java.util.Set;
031
032import org.apache.avalon.framework.activity.Initializable;
033import org.apache.commons.collections4.CollectionUtils;
034import org.apache.commons.lang3.ArrayUtils;
035import org.apache.commons.lang3.StringUtils;
036import org.apache.commons.lang3.tuple.Pair;
037
038import org.ametys.cms.ObservationConstants;
039import org.ametys.cms.content.references.OutgoingReferences;
040import org.ametys.cms.content.references.OutgoingReferencesExtractor;
041import org.ametys.cms.contenttype.AttributeDefinition;
042import org.ametys.cms.contenttype.ContentAttributeDefinition;
043import org.ametys.cms.contenttype.ContentType;
044import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
045import org.ametys.cms.contenttype.ContentTypesHelper;
046import org.ametys.cms.contenttype.ContentValidator;
047import org.ametys.cms.data.ContentDataHelper;
048import org.ametys.cms.data.ContentSynchronizationContext;
049import org.ametys.cms.data.ContentSynchronizationResult;
050import org.ametys.cms.data.ContentValue;
051import org.ametys.cms.data.ReferencedContents;
052import org.ametys.cms.model.restrictions.RestrictedModelItem;
053import org.ametys.cms.repository.Content;
054import org.ametys.cms.repository.ModifiableContent;
055import org.ametys.cms.repository.WorkflowAwareContent;
056import org.ametys.core.observation.Event;
057import org.ametys.core.observation.ObservationManager;
058import org.ametys.core.user.User;
059import org.ametys.core.user.UserIdentity;
060import org.ametys.core.user.UserManager;
061import org.ametys.core.util.DateUtils;
062import org.ametys.plugins.repository.AmetysRepositoryException;
063import org.ametys.plugins.repository.data.DataComment;
064import org.ametys.plugins.repository.data.external.ExternalizableDataProvider.ExternalizableDataStatus;
065import org.ametys.plugins.repository.data.external.ExternalizableDataProviderExtensionPoint;
066import org.ametys.plugins.repository.data.holder.group.Repeater;
067import org.ametys.plugins.repository.data.holder.impl.DataHolderHelper;
068import org.ametys.plugins.repository.data.holder.values.SynchronizableRepeater;
069import org.ametys.plugins.repository.data.holder.values.SynchronizableValue;
070import org.ametys.plugins.repository.data.holder.values.SynchronizableValue.Mode;
071import org.ametys.plugins.repository.data.holder.values.SynchronizationResult;
072import org.ametys.plugins.repository.data.holder.values.UntouchedValue;
073import org.ametys.plugins.repository.lock.LockHelper;
074import org.ametys.plugins.repository.lock.LockableAmetysObject;
075import org.ametys.plugins.repository.model.CompositeDefinition;
076import org.ametys.plugins.repository.model.RepeaterDefinition;
077import org.ametys.plugins.workflow.component.CheckRightsCondition;
078import org.ametys.runtime.authentication.AccessDeniedException;
079import org.ametys.runtime.config.Config;
080import org.ametys.runtime.i18n.I18nizableText;
081import org.ametys.runtime.i18n.I18nizableTextParameter;
082import org.ametys.runtime.model.ElementDefinition;
083import org.ametys.runtime.model.ModelHelper;
084import org.ametys.runtime.model.ModelItem;
085import org.ametys.runtime.model.ModelItemContainer;
086import org.ametys.runtime.model.ModelViewItemGroup;
087import org.ametys.runtime.model.View;
088import org.ametys.runtime.model.ViewHelper;
089import org.ametys.runtime.model.ViewItemContainer;
090import org.ametys.runtime.model.type.DataContext;
091import org.ametys.runtime.model.type.ElementType;
092import org.ametys.runtime.parameter.Errors;
093import org.ametys.runtime.parameter.Validator;
094
095import com.opensymphony.module.propertyset.PropertySet;
096import com.opensymphony.workflow.FunctionProvider;
097import com.opensymphony.workflow.WorkflowException;
098
099/**
100 * OSWorkflow function to edit a content.<br>
101 * <br>
102 * Values are set either programmatically, or parsed from form submission by their {@link ElementType}s according to the {@link Content} model.<br>
103 * <br>
104 * The required transient variables:<br>
105 * - AbstractContentWorkflowComponent.RESULT_MAP_KEY - Map&lt;String, Object&gt; The map containing the results of the function.<br>
106 * - AbstractContentWorkflowComponent.RESULT_MAP_KEY.result - String "true" when everything goes fine. Missing in other case.<br>
107 * - AbstractContentWorkflowComponent.RESULT_MAP_KEY.&lt;MetadataPath&gt; - Errors Each error during edition will be set here. Key will be the metadata path (with '.' separator). Value will be the error message.<br>
108 * - AbstractContentWorkflowComponent.CONTENT_KEY - WorkflowAwareContent The content that will be edited. Should have the lock token.<br>
109 * - AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY - Map&lt;String, Object&gt; Contains the following parameters:<br>
110 * - AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY.QUIT - boolean True to specify edition mode will be quit, this imply to unlock the content.<br>
111 * - AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY.VIEW_PARAM The name of the view to use and to check attributes. If missing a view will be created from values. <br>
112 * - AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY.FALLBACK_VIEW_PARAM The name of the view to use if the initial view does not exist on the Content's model. <br>
113 * - AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY.VALUES_KEY - Map&lt;String, Object&gt; The typed values. If present, raw values must not be present.<br>
114 * - AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY.FORM_RAW_VALUES - Map&lt;String, Object&gt; The values of the submitted form. If present, types values must not be present.<br>
115 * - AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY.FORM_RAW_VALUES.&lt;MetadataPath&gt; Object Key is the path of the metadata ('.' separated) prefixed by FORM_ELEMENTS_PREFIX. Value is a depending on the type of metadata.
116 *                                                                                         Sometimes types require additional information. In that case : Key is a metadata path ('.' separated) prefixed by INTERNAL_FORM_ELEMENTS_PREFIX and suffixed by '.' + an additional information name.<br>
117 * - AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY.FORM_RAW_COMMENTS - Map&lt;String, List&lt;Map&lt;String, String&gt;&gt;&gt; The comments of the metadata of the submitted form :<br>
118 * - AbstractContentWorkflowComponent.RESULT_MAP_KEY.&lt;MetadataPath&gt; - List&lt;Map&lt;String, String&gt;&gt; Key is the path of the metadata ('.' separated) prefixed by FORM_ELEMENTS_PREFIX. Value is the list of comments.<br>
119 * - AbstractContentWorkflowComponent.RESULT_MAP_KEY.&lt;MetadataPath&gt;.&lt;X&gt; - &lt;Map&lt;String, String&gt; A comment with the following parameters<br>
120 * - AbstractContentWorkflowComponent.RESULT_MAP_KEY.&lt;MetadataPath&gt;.&lt;X&gt;.author String The login of the author of the comment<br>
121 * - AbstractContentWorkflowComponent.RESULT_MAP_KEY.&lt;MetadataPath&gt;.&lt;X&gt;.text String The text of the comment<br>
122 * - AbstractContentWorkflowComponent.RESULT_MAP_KEY.&lt;MetadataPath&gt;.&lt;X&gt;.date String The date of the comment using the ISODateTimeFormat (See DateUtils.parse)<br>
123 * 
124 * Where &lt;MetadataPath&gt; is the path of a metadata (using a '.' separator). In some cases it is prefixed by FORM_ELEMENTS_PREFIX. A metadata path with in a repeater include the number of the repeated instance (1 based).<br>
125 * Where &lt;X&gt; Is an element of the parent list.<br>
126 */
127public class EditContentFunction extends AbstractContentWorkflowComponent implements FunctionProvider, Initializable
128{
129    /** Constant for storing the action id for editing revert relations. */
130    public static final String INVERT_RELATION_EDIT_WORKFLOW_ACTION_ID = EditContentFunction.class.getName() + "$invertEditActionId";
131    
132    /** Prefix for HTML form elements. */
133    public static final String FORM_ELEMENTS_PREFIX = "content.input.";
134    /** The key for global errors */
135    public static final String GLOBAL_ERROR_KEY = "_global";
136    /** Prefix for internal HTML form elements. */
137    public static final String INTERNAL_FORM_ELEMENTS_PREFIX = "_" + FORM_ELEMENTS_PREFIX;
138    /** Inputs key for typed values. */
139    public static final String VALUES_KEY = "typedValues";
140    /** Request parameter key for the field values. */
141    public static final String FORM_RAW_VALUES = "values";
142    /** Request parameter key for the field comments. */
143    public static final String FORM_RAW_COMMENTS = "comments";
144    /** View parameter. */
145    public static final String VIEW = "view";
146    /** View items parameter. */
147    public static final String VIEW_ITEMS = "view.items";
148    /** View name parameter. */
149    public static final String VIEW_NAME = "content.view";
150    /** Fallback view name parameter. */
151    public static final String FALLBACK_VIEW_NAME = "content.fallback.view";
152    /** Set to false to deactivate global validation, default value is true */
153    public static final String GLOBAL_VALIDATION = "content.validation.global";
154    /** Quit edition mode parameter. */
155    public static final String QUIT = "quit";
156    /** Local only parameter. */
157    public static final String LOCAL_ONLY = "local.only";
158    /** Optional previous synchronization result */
159    public static final String SYNCHRONIZATION_RESULT = "synchronization.result";
160    /** Default action id of editing revert relations. */
161    public static final int INVERT_EDIT_ACTION_ID = 2;
162    
163    /** Content type extension point. */
164    protected ContentTypeExtensionPoint _contentTypeExtensionPoint;
165    /** Helper for content types */
166    protected ContentTypesHelper _contentTypesHelper;
167    /** Observation manager available to subclasses. */
168    protected ObservationManager _observationManager;
169    /** The content workflow helper. */
170    protected ContentWorkflowHelper _workflowHelper;
171    /** The outgoing references extractor */
172    protected OutgoingReferencesExtractor _outgoingReferencesExtractor;
173    /** The user manager */
174    protected UserManager _userManager;
175    /** Provider for externalizable data */
176    protected ExternalizableDataProviderExtensionPoint _externalizableDataProviderEP;
177    /** Helper for collecting content references */
178    protected ContentDataHelper _contentDataHelper;
179
180    @Override
181    public void initialize() throws Exception
182    {
183        _contentTypeExtensionPoint = (ContentTypeExtensionPoint) _manager.lookup(ContentTypeExtensionPoint.ROLE);
184        _observationManager = (ObservationManager) _manager.lookup(ObservationManager.ROLE);
185        _workflowHelper = (ContentWorkflowHelper) _manager.lookup(ContentWorkflowHelper.ROLE);
186        _contentTypesHelper = (ContentTypesHelper) _manager.lookup(ContentTypesHelper.ROLE);
187        _outgoingReferencesExtractor = (OutgoingReferencesExtractor) _manager.lookup(OutgoingReferencesExtractor.ROLE);
188        _userManager = (UserManager) _manager.lookup(UserManager.ROLE);
189        _externalizableDataProviderEP = (ExternalizableDataProviderExtensionPoint) _manager.lookup(ExternalizableDataProviderExtensionPoint.ROLE);
190        _contentDataHelper = (ContentDataHelper) _manager.lookup(ContentDataHelper.ROLE);
191    }
192    
193    @SuppressWarnings("unchecked")
194    @Override
195    public void execute(Map transientVars, Map args, PropertySet ps) throws WorkflowException
196    {
197        _logger.info("Performing edit workflow function");
198
199        // Retrieve current content
200        WorkflowAwareContent content = getContent(transientVars);
201        UserIdentity user = getUser(transientVars);
202        
203        if (!(content instanceof ModifiableContent))
204        {
205            throw new IllegalArgumentException("The provided content " + content.getId() + " is not a ModifiableContent.");
206        }
207        
208        ModifiableContent modifiableContent = (ModifiableContent) content;
209        
210        try
211        {
212            LockableAmetysObject lockableContent = _checkLock(content, user);
213            
214            AllErrors errors = new AllErrors();
215            
216            Map<String, Object> parameters = getContextParameters(transientVars);
217            
218            long time_0 = System.currentTimeMillis();
219            
220            // get inputs, either typed values (eg. set programmatically)
221            // or raw values (eg. from request parameters)
222            
223            Map<String, Object> typedValues = (Map<String, Object>) parameters.get(VALUES_KEY);
224            Map<String, Object> rawValues = (Map<String, Object>) parameters.get(FORM_RAW_VALUES);
225            
226            if (typedValues != null && rawValues != null)
227            {
228                throw new WorkflowException("Cannot have both typed values and raw values for EditContentFunction");
229            }
230            
231            if (typedValues == null && rawValues == null)
232            {
233                typedValues = Collections.EMPTY_MAP;
234            }
235            
236            // get the view, either set from inputs or computed from values
237            View view = getView(parameters, typedValues, rawValues, modifiableContent, transientVars);
238            
239            long time_1 = System.currentTimeMillis();
240            
241            // get the attributes comments
242            Map<String, List<Map<String, String>>> rawComments = (Map<String, List<Map<String, String>>>) parameters.get(FORM_RAW_COMMENTS);
243            
244            boolean localOnly = (boolean) parameters.getOrDefault(LOCAL_ONLY, false);
245            
246            // get values
247            Map<String, Object> values = getValues(view, modifiableContent, typedValues, rawValues, rawComments, localOnly, transientVars);
248            
249            // validate values
250            validateValues(view, modifiableContent, values, errors, transientVars);
251            if ((boolean) parameters.getOrDefault(GLOBAL_VALIDATION, true))
252            {
253                globalValidate(view, modifiableContent, values, errors);
254            }
255            
256            // prepare synchronize
257            // FIXME CMS-10952: find external invert relations 
258            Collection<ReferencedContents> referencedContents = prepareSynchronize(modifiableContent, view, values, user, errors, transientVars);
259
260            _handleErrors(transientVars, modifiableContent, errors);
261
262            long time_2 = System.currentTimeMillis();
263            
264            // Notify the observers of the upcoming modification.
265            notifyContentModifying(content, values, transientVars);
266            
267            // actually write changes
268            SynchronizationResult synchronizationResult = synchronize(modifiableContent, view, values, referencedContents, transientVars);
269            
270            // Aggregate the synchronization result with the one in transient var if any
271            synchronizationResult.aggregateResult((SynchronizationResult) parameters.getOrDefault(SYNCHRONIZATION_RESULT, new SynchronizationResult()));
272            
273            updateCommonMetadata(modifiableContent, user, synchronizationResult);
274            
275            extractOutgoingReferences(modifiableContent, synchronizationResult);
276            
277            long time_3 = System.currentTimeMillis();
278            
279            // Commit changes
280            modifiableContent.saveChanges();
281            
282            long time_4 = System.currentTimeMillis();
283            
284            // Notify the observers of the modification.
285            notifyContentModified(content, transientVars, synchronizationResult);
286            
287            long time_5 = System.currentTimeMillis();
288            
289            // Unlock content if we are not in save & quit mode
290            Boolean quit = (Boolean) parameters.get(QUIT);
291            if (Boolean.TRUE.equals(quit) && lockableContent != null && lockableContent.isLocked())
292            {
293                lockableContent.unlock();
294            }
295            
296            long time_6 = System.currentTimeMillis();
297            
298            boolean logAbnormalTime = Config.getInstance().getValue("runtime.log.abnormal.time");
299            if (time_6 - time_0 > 5000 && logAbnormalTime)
300            {
301                _logger.warn("Edit content action has taken an abnormally long time : get view in " + (time_1 - time_0) + " ms / bind attributes in " + (time_2 - time_1) + " ms / build consistencies in " + (time_3 - time_2) + " ms / save in " + (time_4 - time_3) + " / notify listeners in " + (time_5 - time_4) + " / total in " + (time_6 - time_0) + " ms");
302            }
303            else if (_logger.isDebugEnabled())
304            {
305                _logger.debug("Edit timers : get view in " + (time_1 - time_0) + " ms / bind attributes in " + (time_2 - time_1) + " ms / build consistencies in " + (time_3 - time_2) + " ms / save in " + (time_4 - time_3) + " / notify listeners in " + (time_5 - time_4) + " / total in " + (time_6 - time_0) + " ms");
306            }
307            
308            Map<String, Object> resultsMap = getResultsMap(transientVars);
309            resultsMap.put("result", "ok");
310            resultsMap.put(HAS_CHANGED_KEY, synchronizationResult.hasChanged());
311        }
312        catch (AmetysRepositoryException | AccessDeniedException e)
313        {
314            throw new WorkflowException("Unable to edit content " + modifiableContent + " from the repository", e);
315        }
316    }
317    
318    private LockableAmetysObject _checkLock(Content content, UserIdentity user) throws WorkflowException
319    {
320        LockableAmetysObject lockableContent = null;
321        
322        if (content instanceof LockableAmetysObject)
323        {
324            lockableContent = (LockableAmetysObject) content;
325            if (lockableContent.isLocked() && !LockHelper.isLockOwner(lockableContent, user))
326            {
327                throw new WorkflowException("User '" + user + "' try to save content '" + content.getName() + "' but it is locked by another user");
328            }
329        }
330        
331        return lockableContent;
332    }
333
334    private void _handleErrors(Map transientVars, ModifiableContent modifiableContent, AllErrors errors) throws WorkflowException, InvalidInputWorkflowException
335    {
336        if (errors.hasErrors())
337        {
338            // Populate the map to render
339            Map<String, Object> result = getResultsMap(transientVars);
340            
341            Map<String, I18nizableText> errorFieldLabels = new HashMap<>();
342            
343            for (Map.Entry<String, Errors> entry : errors.getAllErrors().entrySet())
344            {
345                String dataPath = entry.getKey();
346                String canonicalMetadataPath = entry.getKey().replace('/', '.');
347                
348                result.put(canonicalMetadataPath, entry.getValue());
349                
350                if (modifiableContent.hasDefinition(dataPath))
351                {
352                    ModelItem definition = modifiableContent.getDefinition(dataPath);
353                    errorFieldLabels.put(canonicalMetadataPath, definition.getLabel());
354                }
355            }
356            
357            result.put("errorFieldLabels", errorFieldLabels);
358            
359            throw new InvalidInputWorkflowException("At least one validation error is preventing from saving the modifications", errors);
360        }
361    }
362    
363    /**
364     * Get the identifier of the invert edit action
365     * @param transientVars The workflow vars
366     * @param referencedContent the content concerned by the invert relation
367     * @return the identifier of the invert edit action
368     */
369    protected int getInvertEditActionId(Map transientVars, Content referencedContent)
370    {
371        return transientVars.containsKey(INVERT_RELATION_EDIT_WORKFLOW_ACTION_ID) ? (Integer) transientVars.get(INVERT_RELATION_EDIT_WORKFLOW_ACTION_ID) : INVERT_EDIT_ACTION_ID;
372    }
373    
374    /**
375     * Notify observers that the content is being modified
376     * @param content The content being modified
377     * @param values the new values being set to the content
378     * @param transientVars The workflow vars
379     * @throws WorkflowException If an error occurred
380     */
381    protected void notifyContentModifying(Content content, Map<String, Object> values, Map transientVars) throws WorkflowException
382    {
383        Map<String, Object> eventParams = new HashMap<>();
384        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
385        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
386        eventParams.put(ObservationConstants.ARGS_CONTENT_VALUES, values);
387        
388        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_MODIFYING, getUser(transientVars), eventParams));
389    }
390    
391    /**
392     * Notify observers that the content has been modified
393     * @param content The content modified
394     * @param transientVars The workflow vars
395     * @param synchronizationResult The result of the content values synchronization
396     * @throws WorkflowException If an error occurred
397     */
398    protected void notifyContentModified(Content content, Map transientVars, SynchronizationResult synchronizationResult) throws WorkflowException
399    {
400        Map<String, Object> eventParams = new HashMap<>();
401        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
402        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
403        
404        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_MODIFIED, getUser(transientVars), eventParams));
405    }
406
407    /**
408     * Get the view for the content
409     * @param parameters The parameters
410     * @param values Typed values from inputs
411     * @param rawValues The raw values of the form
412     * @param content The content
413     * @param transientVars the parameters from the call
414     * @return The view asked in the request or a built-in view
415     * @throws WorkflowException If an error occurred while getting the view
416     */
417    @SuppressWarnings("unchecked")
418    protected View getView(Map<String, Object> parameters, Map<String, Object> values, Map<String, Object> rawValues, Content content, Map transientVars) throws WorkflowException
419    {
420        View view = (View) parameters.get(VIEW);
421        if (view == null)
422        {
423            List<String> viewItems = (List<String>) parameters.get(VIEW_ITEMS);
424            if (viewItems != null)
425            {
426                view = ViewHelper.createViewItemAccessor(content.getModel(), viewItems.toArray(String[]::new));
427            }
428            else
429            {
430                String viewName = (String) parameters.get(VIEW_NAME);
431                String fallbackViewName = (String) parameters.get(FALLBACK_VIEW_NAME);
432                if (viewName != null)
433                {
434                    view = _contentTypesHelper.getViewWithFallback(viewName, fallbackViewName, content.getTypes(), content.getMixinTypes());
435                }
436            }
437        }
438
439        if (view != null)
440        {
441            if (ViewHelper.areItemsPresentsOnlyOnce(view))
442            {
443                return ViewHelper.getTruncatedView(view);
444            }
445            else
446            {
447                throw new WorkflowException("The view '" + view.getName() + "' of content '" + content + "'cannot be used to edit the content because one or more items do not appear only once.");
448            }
449        }
450        
451        // Compute a view from input values
452        String[] computedViewItems;
453        Collection<? extends ModelItemContainer> model = content.getModel();
454        if (values != null)
455        {
456            Set<String> items;
457            items = new HashSet<>();
458            _getViewItems(values, model, content, items, transientVars);
459            computedViewItems = items.toArray(String[]::new);
460        }
461        else
462        {
463            computedViewItems = rawValues.keySet().stream()
464                                                  .filter(path -> path.startsWith(FORM_ELEMENTS_PREFIX))
465                                                  .map(path -> path.substring(FORM_ELEMENTS_PREFIX.length()))
466                                                  .map(ModelHelper::getDefinitionPathFromDataPath)
467                                                  .toArray(String[]::new);
468        }
469        
470        return ViewHelper.createViewItemAccessor(model, computedViewItems);
471    }
472    
473    @SuppressWarnings("unchecked")
474    private void _getViewItems(Map<String, Object> values, Collection<? extends ModelItemContainer> parent, Content content, Set<String> viewItems, Map transientVars)
475    {
476        for (String name : values.keySet())
477        {
478            ModelItem modelItem = ModelHelper.getModelItem(name, parent);
479            String path = modelItem.getPath();
480            
481            if (_canWriteModelItem(modelItem, content, transientVars))
482            {
483                if (modelItem instanceof AttributeDefinition)
484                {
485                    viewItems.add(path);
486                }
487                else if (modelItem instanceof CompositeDefinition)
488                {
489                    Object value = values.get(name);
490                    
491                    if (!(value instanceof Map))
492                    {
493                        throw new IllegalArgumentException("CompositeDefinition should correspond to a Map<String, Object> value.");
494                    }
495                    
496                    Map<String, Object> composite = (Map<String, Object>) value;
497                    
498                    if (composite.isEmpty())
499                    {
500                        // composite is empty, we should add it anyway so that the corresponding storage could also be emptied
501                        viewItems.add(path);
502                    }
503                    else
504                    {
505                        _getViewItems(composite, Collections.singleton((CompositeDefinition) modelItem), content, viewItems, transientVars);
506                    }
507                }
508                else if (modelItem instanceof RepeaterDefinition)
509                {
510                    Object value = values.get(name);
511                    
512                    if (!(value instanceof List) && !(value instanceof SynchronizableRepeater))
513                    {
514                        throw new IllegalArgumentException("RepeaterDefinition should correspond to a SynchronizableRepeater or List<Map<String, Object>> value.");
515                    }
516                    
517                    List<Map<String, Object>> entries = value instanceof SynchronizableRepeater ? ((SynchronizableRepeater) value).getEntries() : (List<Map<String, Object>>) value;
518                    
519                    if (entries.isEmpty())
520                    {
521                        // repeater is empty, we should add it anyway so that the corresponding storage could also be emptied
522                        viewItems.add(path);
523                    }
524                    else
525                    {
526                        for (int i = 0; i < entries.size(); i++)
527                        {
528                            Map<String, Object> entry = entries.get(i);
529                            _getViewItems(entry, Collections.singleton((RepeaterDefinition) modelItem), content, viewItems, transientVars);
530                        }
531                    }
532                }
533            }
534        }
535    }
536    
537    /**
538     * Computes the actual typed values from the input. 
539     * @param view the current {@link View}
540     * @param content the current Content
541     * @param typedValues typed values, if any
542     * @param rawValues raw values from form, if any
543     * @param rawComments the form comments, if any
544     * @param localOnly if the form values are local only or may include external values
545     * @param transientVars the parameters from the call.
546     * @return the actual values to be set
547     * @throws WorkflowException If an error occurred
548     */
549    protected Map<String, Object> getValues(View view, ModifiableContent content, Map<String, Object> typedValues, Map<String, Object> rawValues, Map<String, List<Map<String, String>>> rawComments, boolean localOnly, Map transientVars) throws WorkflowException
550    {
551        Map<String, Object> values = typedValues;
552        if (values == null)
553        {
554            values = _parseValues(view, StringUtils.EMPTY, Optional.of(StringUtils.EMPTY), content, rawValues, rawComments, localOnly, transientVars);
555        }
556        else
557        {
558            values = _convertValues(content, view, values, transientVars);
559        }
560        
561        // TODO CMS-10082: Evaluate disable conditions
562        // values = _processDisableConditions(view, values, values, "");
563        
564        return values;
565    }
566
567    @SuppressWarnings("unchecked")
568    private Map<String, Object> _parseValues(ViewItemContainer viewItemContainer, String prefix, Optional<String> oldPrefix, ModifiableContent content, Map<String, Object> rawValues, Map<String, List<Map<String, String>>> rawComments, boolean localOnly, Map transientVars)
569    {
570        Map<String, Object> values = new HashMap<>();
571
572        org.ametys.plugins.repository.model.ViewHelper.visitView(viewItemContainer, 
573            (element, definition) -> {
574                // simple element
575                String name = definition.getName();
576                ElementType type = definition.getType();
577                
578                Object value;
579                if (!_canWriteModelItem(definition, content, transientVars))
580                {
581                    value = new UntouchedValue();
582                }
583                else
584                {
585                    String dataPath = prefix + name;
586                    
587                    // For the fromJSONForClient method, the context needs the path of the data as it is in the repository
588                    // So we compute the old data path, i.e. with the repeater entries previous positions
589                    Optional<String> oldDataPath = oldPrefix.map(p -> p + name);
590                    DataContext dataContext = DataContext.newInstance()
591                                                         .withObjectId(content.getId());
592                    
593                    // If the entry did not exist, the optional prefix is empty
594                    if (oldDataPath.isPresent())
595                    {
596                        dataContext.withDataPath(oldDataPath.get());
597                    }
598                    
599                    Object initialValue = rawValues.get(FORM_ELEMENTS_PREFIX + dataPath);
600                    Object rawValue = initialValue;
601                    ExternalizableDataStatus status = null;
602                    Object externalValue = null;
603                   
604                    // if the value is externalizable, rawValue is actually a Map {local:<value>, external:<value>, status:<local or external>}
605                    if (!localOnly && _externalizableDataProviderEP.isDataExternalizable(content, definition))
606                    {
607                        Map<String, Object> externalizableValue = (Map<String, Object>) initialValue;
608                        
609                        status = ExternalizableDataStatus.valueOf(((String) externalizableValue.get("status")).toUpperCase());
610                        rawValue = externalizableValue.get("local");
611                        
612                        Object rawExternalValue = externalizableValue.get("external");
613                        externalValue = type.fromJSONForClient(rawExternalValue, dataContext);
614                    }
615                    
616                    // get the typed value
617                    Object typedValue = type.fromJSONForClient(rawValue, dataContext);
618                    
619                    // retrieve the associated comments
620                    List<Map<String, String>> comments = rawComments == null ? null : rawComments.get(FORM_ELEMENTS_PREFIX + prefix + name);
621                    
622                    value = _getSynchronizableValue(typedValue, status, externalValue, comments);
623                }
624                
625                values.put(name, value);
626            }, 
627            (group, definition) -> {
628                // composite
629                String name = definition.getName();
630                String updatedPrefix = prefix + name + ModelItem.ITEM_PATH_SEPARATOR;
631                Optional<String> updatedOldPrefix = oldPrefix.map(p -> p + name + ModelItem.ITEM_PATH_SEPARATOR);
632                values.put(name, _parseValues(group, updatedPrefix, updatedOldPrefix, content, rawValues, rawComments, localOnly, transientVars));
633            }, 
634            (group, definition) -> {
635                // repeater
636                String name = definition.getName();
637
638                if (!_canWriteModelItem(definition, content, transientVars))
639                {
640                    values.put(name, new UntouchedValue());
641                }
642                else
643                {
644                    int size = (int) rawValues.get(INTERNAL_FORM_ELEMENTS_PREFIX + prefix + name + "/size");
645                    
646                    List<Map<String, Object>> entries = new ArrayList<>();
647                    Map<Integer, Integer> mapping = new HashMap<>();
648                    for (int i = 1; i <= size; i++)
649                    {
650                        String updatedPrefix = prefix + name + "[" + i + "]" + ModelItem.ITEM_PATH_SEPARATOR;
651                        Optional<String> updatedOldPrefix = Optional.empty();
652                        int previousPosition = (int) rawValues.get(INTERNAL_FORM_ELEMENTS_PREFIX + prefix + name + "[" + i + "]/previous-position");
653                        if (previousPosition > 0)
654                        {
655                            updatedOldPrefix = oldPrefix.map(p -> p + name + "[" + previousPosition + "]" + ModelItem.ITEM_PATH_SEPARATOR);
656                            mapping.put(previousPosition, i);
657                        }
658    
659                        entries.add(_parseValues(group, updatedPrefix, updatedOldPrefix, content, rawValues, rawComments, localOnly, transientVars));
660                    }
661                    
662                    values.put(name, SynchronizableRepeater.replaceAll(entries, mapping));
663                }
664
665            }, 
666            group -> values.putAll(_parseValues(group, prefix, oldPrefix, content, rawValues, rawComments, localOnly, transientVars)));
667        
668        return values;
669    }
670    
671    private Object _getSynchronizableValue(Object localValue, ExternalizableDataStatus status, Object externalValue, List<Map<String, String>> rawComments)
672    {
673        SynchronizableValue result = new SynchronizableValue(localValue);
674        result.setExternalizableStatus(status != null ? status : ExternalizableDataStatus.LOCAL);
675        result.setExternalValue(externalValue);
676        
677        if (rawComments != null)
678        {
679            List<DataComment> comments = new ArrayList<>();
680            
681            for (Map<String, String> rawComment : rawComments)
682            {
683                String author = rawComment.get("author");
684                String text = rawComment.get("text");
685                String rawDate = rawComment.get("date");
686                
687                DataComment comment = new DataComment(text, DateUtils.parseZonedDateTime(rawDate), author);
688                
689                comments.add(comment);
690            }
691            
692            result.setComments(comments);
693        }
694        
695        return result;
696    }
697    
698    @SuppressWarnings("unchecked")
699    private Map<String, Object> _convertValues(ModifiableContent content, ViewItemContainer viewItemContainer, Map<String, Object> values, Map transientVars)
700    {
701        if (values == null)
702        {
703            return null;
704        }
705        
706        Map<String, Object> result = new HashMap<>();
707        
708        org.ametys.plugins.repository.model.ViewHelper.visitView(viewItemContainer, 
709            (element, definition) -> {
710                // simple element
711                String name = definition.getName();
712                
713                if (values.containsKey(name))
714                {
715                    Object value = values.get(name);
716                    if (value instanceof SynchronizableValue)
717                    {
718                        SynchronizableValue syncValue = (SynchronizableValue) value;
719                        syncValue.setLocalValue(_convertValue(definition, syncValue.getLocalValue(), transientVars));
720                        syncValue.setExternalValue(_convertValue(definition, syncValue.getExternalValue(), transientVars));
721                        result.put(name, syncValue);
722                    }
723                    else
724                    {
725                        value = _convertValue(definition, value, transientVars);
726                        SynchronizableValue syncValue = new SynchronizableValue(value, getValueExternalizableDataStatus(content, definition, transientVars));
727                        result.put(name, syncValue);
728                    }
729                }
730            }, 
731            (group, definition) -> {
732                // composite
733                String name = definition.getName();
734                if (values.containsKey(name))
735                {
736                    result.put(name, _convertValues(content, group, (Map<String, Object>) values.get(name), transientVars));
737                }
738            }, 
739            (group, definition) -> {
740                // repeater
741                String name = definition.getName();
742                if (values.containsKey(name))
743                {
744                    Object value = values.get(name);
745                    SynchronizableRepeater syncRepeater = value instanceof SynchronizableRepeater ? (SynchronizableRepeater) value : null;
746                    List<Map<String, Object>> entries = value == null ? null : value instanceof List ? (List<Map<String, Object>>) value : ((SynchronizableRepeater) value).getEntries();
747                    
748                    Object newValue = null;
749                    if (entries != null)
750                    {
751                        List<Map<String, Object>> newEntries = new ArrayList<>();
752                        
753                        for (int i = 0; i < entries.size(); i++)
754                        {
755                            newEntries.add(_convertValues(content, group, entries.get(i), transientVars));
756                        }
757                        
758                        newValue = newEntries;
759                        
760                        if (syncRepeater != null)
761                        {
762                            newValue = SynchronizableRepeater.copy(syncRepeater, newEntries);
763                        }
764                    }
765                    
766                    result.put(name, newValue);
767                }
768            }, 
769            group -> result.putAll(_convertValues(content, group, values, transientVars)));
770        
771        return result;
772    }
773    
774    /**
775     * Converts the given value according to the given definition
776     * If the value is multiple, an array is retrieved with each value converted to the right type
777     * @param definition the definition
778     * @param value the value to convert
779     * @param transientVars the parameters from the call.
780     * @return the converted value
781     */
782    protected Object _convertValue(ElementDefinition definition, Object value, Map transientVars)
783    {
784        return DataHolderHelper.convertValue(definition, value);
785    }
786    
787    /**
788     * Get the status of the value to modify
789     * @param content the content
790     * @param definition the definition of the value
791     * @param transientVars the parameters from the call
792     * @return the status of the value
793     */
794    protected ExternalizableDataStatus getValueExternalizableDataStatus(Content content, ModelItem definition, Map transientVars)
795    {
796        return ExternalizableDataStatus.LOCAL;
797    }
798    
799    /*private Map<String, Object> _processDisableConditions(ViewItemContainer viewItemContainer, Map<String, Object> values, Map<String, Object> rootValues, String prefix)
800    {
801        org.ametys.plugins.repository.model.ViewHelper.visitView(viewItemContainer, 
802            (element, definition) -> {
803                // attribute
804                if (ModelHelper.evaluateDisableConditions(definition.getDisableConditions(), null, _logger))
805                {
806                    values.put(definition.getName(), new UntouchedValue());
807                }
808            },
809            (group, definition) -> {
810                // composite
811            },
812            (group, definition) -> {
813                // repeater
814            },
815            group -> {
816                // group
817            }
818        );
819        
820        return values;
821    }*/
822    
823    /**
824     * Validates all input values.
825     * @param view the model's view corresponding to the values
826     * @param content the current content
827     * @param values the actual input values
828     * @param allErrors object to be populated with validation errors
829     * @param transientVars the parameters from the call
830     * @throws WorkflowException If an error occurred
831     */
832    protected void validateValues(View view, ModifiableContent content, Map<String, Object> values, AllErrors allErrors, Map transientVars) throws WorkflowException
833    {
834        _validateValues(view, StringUtils.EMPTY, Optional.of(StringUtils.EMPTY), content, Optional.of(values), allErrors, transientVars);
835    }
836
837    private void _validateValues(ViewItemContainer viewItemContainer, String prefix, Optional<String> oldPrefix, ModifiableContent content, Optional<Map<String, Object>> values, AllErrors allErrors, Map transientVars)
838    {
839        org.ametys.plugins.repository.model.ViewHelper.visitView(viewItemContainer, 
840            (element, definition) -> {
841                // simple element
842                String name = definition.getName();
843                
844                String dataPath = prefix + name;
845                Optional<String> oldDataPath = oldPrefix.map(p -> p + name);
846                
847                Object value = values.map(v -> v.get(name)).orElse(null);
848                validateValue(definition, dataPath, oldDataPath, content, value, allErrors, transientVars);
849            }, 
850            (group, definition) -> {
851                // composite
852                String name = definition.getName();
853
854                String updatedPrefix = prefix + name + ModelItem.ITEM_PATH_SEPARATOR;
855                Optional<String> updatedOldPrefix = oldPrefix.map(p -> p + name + ModelItem.ITEM_PATH_SEPARATOR);
856                
857                Optional<Map<String, Object>> value = values.map(v -> v.get(name)).filter(Map.class::isInstance).map(Map.class::cast);
858                _validateValues(group, updatedPrefix, updatedOldPrefix, content, value, allErrors, transientVars);
859            }, 
860            (group, definition) -> {
861                // repeater
862                String name = definition.getName();
863                
864                String dataPath = prefix + name;
865                Optional<String> oldDataPath = oldPrefix.map(p -> p + name);
866                
867                Object value = values.map(v -> v.get(name)).orElse(null);
868                validateRepeaterValue(group, definition, dataPath, oldDataPath, content, value, allErrors, transientVars);
869            }, 
870            group -> _validateValues(group, prefix, oldPrefix, content, values, allErrors, transientVars));
871    }
872    
873    /**
874     * Validate an attribute value.
875     * @param definition the attribute definition.
876     * @param dataPath the attribute path.
877     * @param oldDataPath the old data path, i.e. with the repeater entries previous positions. Used to know the current status for externalizable data 
878     * @param content the Content being edited.
879     * @param value the value.
880     * @param allErrors the errors.
881     * @param transientVars the parameters from the call.
882     */
883    protected void validateValue(ElementDefinition definition, String dataPath, Optional<String> oldDataPath, ModifiableContent content, Object value, AllErrors allErrors, Map transientVars)
884    {
885        Object actualValue = DataHolderHelper.getValueFromSynchronizableValue(value, content, definition, oldDataPath, getSynchronizationContext(transientVars));
886        Mode mode = value instanceof SynchronizableValue ? ((SynchronizableValue) value).getMode() : null;
887
888        if (mode == null)
889        {
890            mode = Mode.REPLACE;
891        }
892        
893        if (actualValue instanceof UntouchedValue)
894        {
895            // don't validate UntouchedValue, either they correspond to non-writable or previously stored data
896            return;
897        }
898
899        if (!_canWriteModelItem(definition, content, transientVars))
900        {
901            throw new EditContentAccessDeniedException(content, definition);
902        }
903        
904        Validator validator = definition.getValidator();
905        Object valueToValidate = actualValue;
906        if (validator != null && validator.getClass().isAnnotationPresent(NeedAllValues.class))
907        {
908            // the validator need all attribute values
909            Object oldValue = content.getValue(dataPath);
910            if (definition.isMultiple())
911            {
912                Object[] oldValuesArray = (Object[]) oldValue;
913                Object[] newValuesArray = (Object[]) actualValue;
914                valueToValidate = mode == Mode.REPLACE ? actualValue : mode == Mode.APPEND ? ArrayUtils.addAll(oldValuesArray, newValuesArray) : CollectionUtils.disjunction(Arrays.asList(oldValuesArray), Arrays.asList(newValuesArray)).toArray(i -> (Object[]) Array.newInstance(definition.getType().getManagedClass(), i));
915            }
916            else
917            {
918                valueToValidate = mode != Mode.REMOVE ? actualValue : null;
919            }
920        }
921
922        List<I18nizableText> errors = ModelHelper.validateValue(definition, valueToValidate);
923        
924        if (errors != null && !errors.isEmpty())
925        {
926            Errors e = new Errors();
927            e.addErrors(errors);
928            allErrors.addError(dataPath, e);
929        }
930    }
931
932    /**
933     * Validate repeater values.
934     * @param viewItem the view item referencing the repeater
935     * @param definition the repeater definition.
936     * @param dataPath the repeater path.
937     * @param oldDataPath the old repeater data path, i.e. with the repeater entries previous positions. Used to know the current status for externalizable data 
938     * @param content the Content being edited.
939     * @param value the value.
940     * @param allErrors the errors.
941     * @param transientVars the parameters from the call.
942     */
943    @SuppressWarnings("unchecked")
944    protected void validateRepeaterValue(ModelViewItemGroup viewItem, RepeaterDefinition definition, String dataPath, Optional<String> oldDataPath, ModifiableContent content, Object value, AllErrors allErrors, Map transientVars)
945    {
946        if (value instanceof UntouchedValue)
947        {
948            // don't validate UntouchedValue, either they correspond to non-writable or previously stored data
949            return;
950        }
951
952        if (!_canWriteModelItem(definition, content, transientVars))
953        {
954            throw new EditContentAccessDeniedException(content, definition);
955        }
956                
957        List<Map<String, Object>> entries = value == null ? null : value instanceof List ? (List<Map<String, Object>>) value : ((SynchronizableRepeater) value).getEntries();
958        SynchronizableRepeater.Mode mode = value instanceof SynchronizableRepeater ? ((SynchronizableRepeater) value).getMode() : SynchronizableRepeater.Mode.REPLACE_ALL;
959        
960        int oldRepeaterSize = 0;
961        if (mode != SynchronizableRepeater.Mode.REPLACE_ALL)
962        {
963            
964            Repeater repeater = oldDataPath.map(path -> content.getRepeater(path)).orElse(null);
965            if (repeater != null)
966            {
967                oldRepeaterSize = repeater.getSize();
968            }
969        }
970        
971        int repeaterSize = entries != null ? entries.size() : 0;
972        
973        if (mode == SynchronizableRepeater.Mode.APPEND)
974        {
975            SynchronizableRepeater repeater = (SynchronizableRepeater) value;
976            assert repeater != null;
977            repeaterSize = oldRepeaterSize + repeaterSize - repeater.getRemovedEntries().size();
978        }
979        else if (mode == SynchronizableRepeater.Mode.REPLACE)
980        {
981            repeaterSize = oldRepeaterSize;
982        }
983        
984        _validateRepeaterSize(definition, dataPath, repeaterSize, allErrors);
985        
986        if (entries != null)
987        {
988            for (int i = 0; i < entries.size(); i++)
989            {
990                Map<String, Object> entry = entries.get(i);
991                String prefix = dataPath + "[" + (i + 1)  + "]" + ModelItem.ITEM_PATH_SEPARATOR;
992                Optional<String> oldPrefix = _getRepeaterEntryOldPrefix(oldDataPath, value, i + 1);
993                _validateValues(viewItem, prefix, oldPrefix, content, Optional.of(entry), allErrors, transientVars);
994            }
995        }
996    }
997
998    private void _validateRepeaterSize(RepeaterDefinition definition, String dataPath, int repeaterSize, AllErrors allErrors)
999    {
1000        int minSize = definition.getMinSize();
1001        int maxSize = definition.getMaxSize();
1002        
1003        if (repeaterSize < minSize)
1004        {
1005            Errors errors = new Errors();
1006            
1007            List<String> parameters = new ArrayList<>();
1008            parameters.add(definition.getName());
1009            parameters.add(Integer.toString(minSize));
1010            errors.addError(new I18nizableText("plugin.cms", "CONTENT_EDITION_VALIDATION_ERRORS_REPEATER_MINSIZE", parameters));
1011            allErrors.addError(dataPath, errors);
1012        }
1013
1014        if (maxSize > 0 && repeaterSize > maxSize)
1015        {
1016            Errors errors = new Errors();
1017
1018            List<String> parameters = new ArrayList<>();
1019            parameters.add(definition.getName());
1020            parameters.add(Integer.toString(maxSize));
1021            errors.addError(new I18nizableText("plugin.cms", "CONTENT_EDITION_VALIDATION_ERRORS_REPEATER_MAXSIZE", parameters));
1022            allErrors.addError(dataPath, errors);
1023        }
1024    }
1025    
1026    private Optional<String> _getRepeaterEntryOldPrefix(Optional<String> oldDataPath, Object value, int currentPosition)
1027    {
1028        Optional<String> oldPrefix = Optional.empty();
1029        if (value instanceof SynchronizableRepeater)
1030        {
1031            Optional<Integer> previousPosition = ((SynchronizableRepeater) value).getPreviousPosition(currentPosition);
1032            if (previousPosition.isPresent())
1033            {
1034                oldPrefix = oldDataPath.map(path -> path + "[" + previousPosition.get()  + "]" + ModelItem.ITEM_PATH_SEPARATOR);
1035            }
1036        }
1037        return oldPrefix;
1038    }
1039    
1040    /**
1041     * Performs a global validation of the Content, based on declared {@link ContentValidator}s. 
1042     * @param view the current {@link View}
1043     * @param content the current {@link Content}.
1044     * @param values the values being set
1045     * @param allErrors object to be populated with validation errors
1046     */
1047    protected void globalValidate(View view, Content content, Map<String, Object> values, AllErrors allErrors)
1048    {
1049        Errors errors = new Errors();
1050        
1051        String[] allContentTypes = ArrayUtils.addAll(content.getTypes(), content.getMixinTypes());
1052        
1053        for (String cTypeId : allContentTypes)
1054        {
1055            ContentType contentType = _contentTypeExtensionPoint.getExtension(cTypeId);
1056            
1057            for (ContentValidator validator : contentType.getGlobalValidators())
1058            {
1059                validator.validate(content, values, view, errors);
1060            }
1061        }
1062        
1063        if (errors.hasErrors())
1064        {
1065            // Global error
1066            allErrors.addError(GLOBAL_ERROR_KEY, errors);
1067        }
1068    }
1069    
1070    /**
1071     * Prepares the write process by checking remote contents concerned by invert relations.
1072     * @param content the current content.
1073     * @param view the current View.
1074     * @param values the new values.
1075     * @param user the current user
1076     * @param allErrors the collected errors
1077     * @param transientVars the parameters from the call.
1078     * @return the {@link ReferencedContents}
1079     */
1080    protected Collection<ReferencedContents> prepareSynchronize(ModifiableContent content, View view, Map<String, Object> values, UserIdentity user, AllErrors allErrors, Map transientVars)
1081    {
1082        if (!invertRelationEnabled())
1083        {
1084            return null;
1085        }
1086        
1087        Collection<ReferencedContents> referencedContents = _contentDataHelper.collectReferencedContents(view, content, values, getSynchronizationContext(transientVars));
1088        
1089        Map<ContentValue, Pair<Boolean, String>> refContents = new HashMap<>();
1090        
1091        // "flatten" the data, so that we only lock each content once
1092        // for each ref content, we only keep the first dataPath (for error reporting) and the weakest value for forceInvert (ie. false if any)
1093        for (ReferencedContents referencedContent : referencedContents)
1094        {
1095            ContentAttributeDefinition definition = referencedContent.getDefinition();
1096            boolean forceInvert = definition.getForceInvert();
1097            
1098            _flattenCollectedReferencedContents(referencedContent.getAddedContentsWithPaths(), refContents, forceInvert);
1099            _flattenCollectedReferencedContents(referencedContent.getRemovedContentsWithPaths(), refContents, forceInvert);
1100        }
1101        
1102        for (Entry<ContentValue, Pair<Boolean, String>> value : refContents.entrySet()) 
1103        {
1104            ContentValue refContentValue = value.getKey();
1105            ModifiableContent refContent = refContentValue.getContentIfExists().orElse(null);
1106            
1107            if (refContent != null)
1108            {
1109                // Check if edit action is available on referenced contents
1110                int invertEditActionId = getInvertEditActionId(transientVars, refContent);
1111                if (_isEditRefContentAvailable(invertEditActionId, refContent, value.getValue().getLeft(), value.getValue().getRight(), user, allErrors))
1112                {
1113                    if (refContent instanceof LockableAmetysObject && !((LockableAmetysObject) refContent).isLocked())
1114                    {
1115                        // Get lock on referenced content
1116                        ((LockableAmetysObject) refContent).lock();
1117                    }
1118                }
1119            }
1120        }
1121        
1122        return referencedContents;
1123    }
1124    
1125    private void _flattenCollectedReferencedContents(Map<ContentValue, List<String>> references, Map<ContentValue, Pair<Boolean, String>> refContents, boolean forceInvert)
1126    {
1127        for (Entry<ContentValue, List<String>> value : references.entrySet())
1128        {
1129            ContentValue refContentValue = value.getKey();
1130            List<String> dataPaths = value.getValue();
1131            
1132            Pair<Boolean, String> invertData = refContents.get(refContentValue);
1133            if (invertData == null)
1134            {
1135                String firstData = dataPaths.isEmpty() ? "" : dataPaths.get(0);
1136                refContents.put(refContentValue, Pair.of(forceInvert, firstData));
1137            }
1138            else if (!forceInvert && invertData.getLeft()) 
1139            {
1140                String firstData = dataPaths.isEmpty() ? "" : dataPaths.get(0);
1141                refContents.put(refContentValue, Pair.of(forceInvert, firstData));
1142            }
1143        }
1144    }
1145    
1146    private boolean _isEditRefContentAvailable(int editActionId, Content refContent, boolean forceInvert, String currentMetadataPath, UserIdentity user, AllErrors allErrors)
1147    {
1148        if (refContent instanceof WorkflowAwareContent)
1149        {
1150            Map<String, Object> inputs = new HashMap<>();
1151            if (forceInvert)
1152            {
1153                // do not check user's right
1154                inputs.put(CheckRightsCondition.FORCE, true);
1155            }
1156            
1157            int[] availableActions = _workflowHelper.getAvailableActions((WorkflowAwareContent) refContent, inputs);
1158            if (!ArrayUtils.contains(availableActions, editActionId))
1159            {
1160                Errors errors = new Errors();
1161                Map<String, I18nizableTextParameter> params = new HashMap<>();
1162                
1163                // Check lock
1164                if (refContent instanceof LockableAmetysObject)
1165                {
1166                    LockableAmetysObject lockableContent = (LockableAmetysObject) refContent;
1167                    if (lockableContent.isLocked() && !LockHelper.isLockOwner(lockableContent, user))
1168                    {
1169                        User lockOwner = _userManager.getUser(lockableContent.getLockOwner().getPopulationId(), lockableContent.getLockOwner().getLogin());
1170                        
1171                        params.put("content", new I18nizableText(_contentHelper.getTitle(refContent)));
1172                        params.put("lockOwner", new I18nizableText(lockOwner != null ? lockOwner.getFullName() + " (" + lockOwner.getIdentity().getLogin() + ")" : lockableContent.getLockOwner().getLogin()));
1173                        errors.addError(new I18nizableText("plugin.cms", "CONTENT_EDITION_VALIDATION_ERRORS_MUTUALRELATION_REFERENCED_CONTENT_LOCKED", params));
1174                        allErrors.addError(currentMetadataPath, errors);
1175                        
1176                        return false;
1177                    }
1178                }
1179                
1180                // Action in unavailable
1181                params.put("content", new I18nizableText(_contentHelper.getTitle(refContent)));
1182                errors.addError(new I18nizableText("plugin.cms", "CONTENT_EDITION_VALIDATION_ERRORS_MUTUALRELATION_UNAVAILABLE_ACTION", params));
1183                allErrors.addError(currentMetadataPath, errors);
1184                
1185                return false;
1186            }
1187            else
1188            {
1189                return true;
1190            }
1191        }
1192        else
1193        {
1194            Errors errors = new Errors();
1195            Map<String, I18nizableTextParameter> params = new HashMap<>();
1196            params.put("content", new I18nizableText(_contentHelper.getTitle(refContent)));
1197            errors.addError(new I18nizableText("plugin.cms", "CONTENT_EDITION_VALIDATION_ERRORS_MUTUALRELATION_NOWORKFLOWAWARE_CONTENT", params));
1198            allErrors.addError(currentMetadataPath, errors);
1199            return false;
1200        }
1201    }
1202    
1203    /**
1204     * Synchronize the values of the given content
1205     * @param content the content to synchronize
1206     * @param view the content's view to use for synchronization
1207     * @param values the values to synchronize
1208     * @param referencedContents the contents referenced by invert relations
1209     * @param transientVars the parameters from the call.
1210     * @return The result of the synchronization
1211     * @throws WorkflowException if an error occurs while triggering the edition workflow action for related contents
1212     */
1213    protected SynchronizationResult synchronize(ModifiableContent content, View view, Map<String, Object> values, Collection<ReferencedContents> referencedContents, Map transientVars) throws WorkflowException
1214    {
1215        ContentSynchronizationContext context = getSynchronizationContext(transientVars)
1216                .withInvertRelations(invertRelationEnabled())
1217                .withReferencedContents(referencedContents);
1218        
1219        ContentSynchronizationResult synchronizationResult = content.synchronizeValues(view, values, context);
1220        
1221        ContentSynchronizationResult additionalOperationsResult = additionalOperations(content, transientVars);
1222        synchronizationResult.aggregateResult(additionalOperationsResult);
1223        
1224        // trigger edit workflow action on contents modified due to invert relations
1225        for (ModifiableContent refContent : synchronizationResult.getModifiedContents())
1226        {
1227            refContent.saveChanges();
1228            int invertEditActionId = getInvertEditActionId(transientVars, refContent);
1229            triggerInvertWorkflowAction(refContent, invertEditActionId);
1230        }
1231        
1232        
1233        return synchronizationResult;
1234    }
1235
1236    /**
1237     * Retrieves the synchronization context
1238     * @param transientVars the parameters from the call
1239     * @return the synchronization context
1240     */
1241    protected ContentSynchronizationContext getSynchronizationContext(Map transientVars)
1242    {
1243        return ContentSynchronizationContext.newInstance();
1244    }
1245    
1246    /**
1247     * Allow to do some other modifications on the given content before saving changes
1248     * @param content the content
1249     * @param transientVars the parameters from the call
1250     * @return The synchronization result od additional operations
1251     * @throws WorkflowException If an error occurred
1252     */
1253    protected ContentSynchronizationResult additionalOperations(ModifiableContent content, Map transientVars) throws WorkflowException
1254    {
1255        // do nothing by default
1256        return new ContentSynchronizationResult();
1257    }
1258    
1259    /**
1260     * Updates common metadata (last contributor, last modification date, ...).
1261     * @param content the content.
1262     * @param user the user.
1263     * @param synchronizationResult The result of the content values synchronization
1264     * @throws WorkflowException if an error occurs.
1265     */
1266    protected void updateCommonMetadata(ModifiableContent content, UserIdentity user, SynchronizationResult synchronizationResult) throws WorkflowException
1267    {
1268        if (user != null)
1269        {
1270            content.setLastContributor(user);
1271        }
1272        
1273        content.setLastModified(ZonedDateTime.now());
1274        
1275        if (content instanceof WorkflowAwareContent)
1276        {
1277            // Remove the proposal date.
1278            ((WorkflowAwareContent) content).setProposalDate(null);
1279        }
1280    }
1281
1282    /**
1283     * Analyze the content to extract outgoing references and store them
1284     * @param content The content to analyze
1285     * @param synchronizationResult The result of the content values synchronization
1286     */
1287    protected void extractOutgoingReferences(ModifiableContent content, SynchronizationResult synchronizationResult)
1288    {
1289        Map<String, OutgoingReferences> outgoingReferencesByPath = _outgoingReferencesExtractor.getOutgoingReferences(content);
1290        content.setOutgoingReferences(outgoingReferencesByPath);
1291    }
1292    
1293    /**
1294     * Template method to indicates if invert relation should be taken into account during the whole edition.
1295     * Override and return false to disabled invert relation management.
1296     * @return true if invert relation are enabled
1297     */
1298    protected boolean invertRelationEnabled()
1299    {
1300        return true;
1301    }
1302    
1303    /**
1304     * Trigger a 'edit content' workflow action (if the content is workflow-aware).
1305     * @param content The content.
1306     * @param actionId The current 'edit content' action ID.
1307     * @throws WorkflowException if an error occurs.
1308     */
1309    protected void triggerInvertWorkflowAction(Content content, int actionId) throws WorkflowException
1310    {
1311        if (content instanceof WorkflowAwareContent)
1312        {
1313            // The content has already been modified by this function
1314            SynchronizationResult synchronizationResult = new SynchronizationResult();
1315            synchronizationResult.setHasChanged(true);
1316
1317            Map<String, Object> parameters = new HashMap<>();
1318            parameters.put(ValidateContentFunction.IS_MAJOR, false);
1319            parameters.put(SYNCHRONIZATION_RESULT, synchronizationResult);
1320            parameters.put(QUIT, true);
1321
1322            Map<String, Object> inputs = new HashMap<>();
1323            inputs.put(CONTEXT_PARAMETERS_KEY, parameters);
1324            
1325            _workflowHelper.doAction((WorkflowAwareContent) content, actionId, inputs);
1326        }
1327    }
1328
1329    /**
1330     * Returns <code>true</code> if the current model item is writable for this content in the current context.
1331     * @param modelItem The model item to check
1332     * @param content The content
1333     * @param transientVars The parameters from the call
1334     * @return <code>true</code> if the current model item is writable
1335     */
1336    @SuppressWarnings("unchecked")
1337    protected boolean _canWriteModelItem(ModelItem modelItem, Content content, Map transientVars)
1338    {
1339        return !(modelItem instanceof RestrictedModelItem) || ((RestrictedModelItem) modelItem).canWrite(content);
1340    }
1341}