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