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