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.io.IOException;
019import java.lang.reflect.Array;
020import java.util.ArrayList;
021import java.util.Arrays;
022import java.util.Collection;
023import java.util.Collections;
024import java.util.Date;
025import java.util.HashMap;
026import java.util.HashSet;
027import java.util.List;
028import java.util.Map;
029import java.util.Map.Entry;
030import java.util.Optional;
031import java.util.Set;
032import java.util.stream.Stream;
033
034import org.apache.avalon.framework.activity.Initializable;
035import org.apache.commons.collections4.CollectionUtils;
036import org.apache.commons.lang3.ArrayUtils;
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.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.AbstractWorkflowComponent;
078import org.ametys.plugins.workflow.component.CheckRightsCondition;
079import org.ametys.runtime.authentication.AccessDeniedException;
080import org.ametys.runtime.config.Config;
081import org.ametys.runtime.i18n.I18nizableText;
082import org.ametys.runtime.i18n.I18nizableTextParameter;
083import org.ametys.runtime.model.ElementDefinition;
084import org.ametys.runtime.model.ModelHelper;
085import org.ametys.runtime.model.ModelItem;
086import org.ametys.runtime.model.ModelItemContainer;
087import org.ametys.runtime.model.View;
088import org.ametys.runtime.model.ViewHelper;
089import org.ametys.runtime.model.ViewItemContainer;
090import org.ametys.runtime.model.type.ElementType;
091import org.ametys.runtime.parameter.Errors;
092import org.ametys.runtime.parameter.Validator;
093
094import com.opensymphony.module.propertyset.PropertySet;
095import com.opensymphony.workflow.FunctionProvider;
096import com.opensymphony.workflow.WorkflowException;
097
098/**
099 * OSWorkflow function to edit a content.<br>
100 * <br>
101 * Values are set either programmatically, or parsed from form submission by their {@link ElementType}s according to the {@link Content} model.<br>
102 * <br>
103 * The required transient variables:<br>
104 * - AbstractContentWorkflowComponent.RESULT_MAP_KEY - Map&lt;String, Object&gt; The map containing the results of the function.<br>
105 * - AbstractContentWorkflowComponent.RESULT_MAP_KEY.result - String "true" when everything goes fine. Missing in other case.<br>
106 * - 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>
107 * - AbstractContentWorkflowComponent.CONTENT_KEY - WorkflowAwareContent The content that will be edited. Should have the lock token.<br>
108 * - AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY - Map&lt;String, Object&gt; Contains the following parameters:<br>
109 * - AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY.QUIT - boolean True to specify edition mode will be quit, this imply to unlock the content.<br>
110 * - 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>
111 * - 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>
112 * - AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY.VALUES_KEY - Map&lt;String, Object&gt; The typed values. If present, raw values must not be present.<br>
113 * - 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>
114 * - 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.
115 *                                                                                         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>
116 * - 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>
117 * - 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>
118 * - AbstractContentWorkflowComponent.RESULT_MAP_KEY.&lt;MetadataPath&gt;.&lt;X&gt; - &lt;Map&lt;String, String&gt; A comment with the following parameters<br>
119 * - AbstractContentWorkflowComponent.RESULT_MAP_KEY.&lt;MetadataPath&gt;.&lt;X&gt;.author String The login of the author of the comment<br>
120 * - AbstractContentWorkflowComponent.RESULT_MAP_KEY.&lt;MetadataPath&gt;.&lt;X&gt;.text String The text of the comment<br>
121 * - AbstractContentWorkflowComponent.RESULT_MAP_KEY.&lt;MetadataPath&gt;.&lt;X&gt;.date String The date of the comment using the ISODateTimeFormat (See DateUtils.parse)<br>
122 * 
123 * 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>
124 * Where &lt;X&gt; Is an element of the parent list.<br>
125 */
126public class EditContentFunction extends AbstractContentWorkflowComponent implements FunctionProvider, Initializable
127{
128    /** Constant for storing the action id for editing revert relations. */
129    public static final String INVERT_RELATION_EDIT_WORKFLOW_ACTION_ID = EditContentFunction.class.getName() + "$invertEditActionId";
130    
131    /** Constant for storing the action id for editing revert relations. */
132    public static final String EDIT_MUTUAL_RELATIONSHIP = EditContentFunction.class.getName() + "$mutualRelationship";
133    
134    /** Prefix for HTML form elements. */
135    public static final String FORM_ELEMENTS_PREFIX = "content.input.";
136    /** The key for global errors */
137    public static final String GLOBAL_ERROR_KEY = "_global";
138    /** Prefix for internal HTML form elements. */
139    public static final String INTERNAL_FORM_ELEMENTS_PREFIX = "_" + FORM_ELEMENTS_PREFIX;
140    /** Inputs key for typed values. */
141    public static final String VALUES_KEY = "typedValues";
142    /** Request parameter key for the field values. */
143    public static final String FORM_RAW_VALUES = "values";
144    /** Request parameter key for the field comments. */
145    public static final String FORM_RAW_COMMENTS = "comments";
146    /** View parameter. */
147    public static final String VIEW = "view";
148    /** View items parameter. */
149    public static final String VIEW_ITEMS = "view.items";
150    /** View name parameter. */
151    public static final String VIEW_NAME = "content.view";
152    /** Fallback view name parameter. */
153    public static final String FALLBACK_VIEW_NAME = "content.fallback.view";
154    /** Quit edition mode parameter. */
155    public static final String QUIT = "quit";
156    /** Local only parameter. */
157    public static final String LOCAL_ONLY = "local.only";
158    /** 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        // Get the action id for editing invert relations
202        int invertEditActionId = getInvertEditActionId(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);
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, typedValues, modifiableContent, rawValues, rawComments, localOnly);
249            
250            // validate values
251            validateValues(view, values, modifiableContent, errors);
252            globalValidate(modifiableContent, values, view, errors);
253            
254            // prepare synchronize
255            // FIXME find external invert relations 
256            Collection<ReferencedContents> referencedContents = prepareSynchronize(modifiableContent, view, values, invertEditActionId, user, errors);
257
258            _handleErrors(transientVars, modifiableContent, errors);
259
260            long time_2 = System.currentTimeMillis();
261            
262            // Notify the observers of the upcoming modification.
263            notifyContentModifying(content, values, transientVars);
264            
265            // actually write changes 
266            ContentSynchronizationContext context = ContentSynchronizationContext.newInstance()
267                                                                                 .withStatus(ExternalizableDataStatus.LOCAL)
268                                                                                 .withInvertRelations(invertRelationEnabled())
269                                                                                 .withReferencedContents(referencedContents);
270            
271            SynchronizationResult synchronizationResult = modifiableContent.synchronizeValues(view, values, context);
272            
273            // trigger edit workflow action on contents modified due to invert relations
274            if (synchronizationResult instanceof ContentSynchronizationResult)
275            {
276                ContentSynchronizationResult result = (ContentSynchronizationResult) synchronizationResult;
277                for (ModifiableContent refContent : result.getModifiedContents())
278                {
279                    refContent.saveChanges();
280                    _triggerEditWorkflowAction(refContent, invertEditActionId);
281                }
282            }
283            
284            updateCommonMetadata(modifiableContent, user);
285            
286            extractOutgoingReferences(modifiableContent);
287            
288            long time_3 = System.currentTimeMillis();
289            
290            // Commit changes
291            modifiableContent.saveChanges();
292            
293            long time_4 = System.currentTimeMillis();
294            
295            // Notify the observers of the modification.
296            notifyContentModified(content, transientVars);
297            
298            long time_5 = System.currentTimeMillis();
299            
300            // Unlock content if we are not in save & quit mode
301            Boolean quit = (Boolean) parameters.get(QUIT);
302            if (Boolean.TRUE.equals(quit) && lockableContent != null && lockableContent.isLocked())
303            {
304                lockableContent.unlock();
305            }
306            
307            long time_6 = System.currentTimeMillis();
308            
309            boolean logAbnormalTime = Config.getInstance().getValue("runtime.log.abnormal.time");
310            if (time_6 - time_0 > 5000 && logAbnormalTime)
311            {
312                _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");
313            }
314            else if (_logger.isDebugEnabled())
315            {
316                _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");
317            }
318            
319            getResultsMap(transientVars).put("result", "ok");
320        }
321        catch (AmetysRepositoryException | AccessDeniedException | IOException e)
322        {
323            throw new WorkflowException("Unable to edit content " + modifiableContent + " from the repository", e);
324        }
325    }
326    
327    private LockableAmetysObject _checkLock(Content content, UserIdentity user) throws WorkflowException
328    {
329        LockableAmetysObject lockableContent = null;
330        
331        if (content instanceof LockableAmetysObject)
332        {
333            lockableContent = (LockableAmetysObject) content;
334            if (lockableContent.isLocked() && !LockHelper.isLockOwner(lockableContent, user))
335            {
336                throw new WorkflowException("User '" + user + "' try to save content '" + content.getName() + "' but it is locked by another user");
337            }
338        }
339        
340        return lockableContent;
341    }
342
343    private void _handleErrors(Map transientVars, ModifiableContent modifiableContent, AllErrors errors) throws WorkflowException, InvalidInputWorkflowException
344    {
345        if (errors.hasErrors())
346        {
347            // Populate the map to render
348            Map<String, Object> result = getResultsMap(transientVars);
349            
350            Map<String, I18nizableText> errorFieldLabels = new HashMap<>();
351            
352            for (Map.Entry<String, Errors> entry : errors.getAllErrors().entrySet())
353            {
354                String dataPath = entry.getKey();
355                String canonicalMetadataPath = entry.getKey().replace('/', '.');
356                
357                result.put(canonicalMetadataPath, entry.getValue());
358                
359                if (modifiableContent.hasDefinition(dataPath))
360                {
361                    ModelItem definition = modifiableContent.getDefinition(dataPath);
362                    errorFieldLabels.put(canonicalMetadataPath, definition.getLabel());
363                }
364            }
365            
366            result.put("errorFieldLabels", errorFieldLabels);
367            
368            throw new InvalidInputWorkflowException("At least one validation error is preventing from saving the modifications", errors);
369        }
370    }
371    
372    /**
373     * Get the identifier of the invert edit action
374     * @param transientVars The workflow vars
375     * @return the identifier of the invert edit action
376     */
377    protected int getInvertEditActionId(Map transientVars)
378    {
379        return transientVars.containsKey(INVERT_RELATION_EDIT_WORKFLOW_ACTION_ID) ? (Integer) transientVars.get(INVERT_RELATION_EDIT_WORKFLOW_ACTION_ID) : INVERT_EDIT_ACTION_ID;
380    }
381    
382    /**
383     * Notify observers that the content is being modified
384     * @param content The content being modified
385     * @param values the new values being set to the content
386     * @param transientVars The workflow vars
387     * @throws WorkflowException If an error occurred
388     */
389    protected void notifyContentModifying(Content content, Map<String, Object> values, Map transientVars) throws WorkflowException
390    {
391        Map<String, Object> eventParams = new HashMap<>();
392        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
393        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
394        eventParams.put(ObservationConstants.ARGS_CONTENT_VALUES, values);
395        
396        if (transientVars.containsKey(EditContentFunction.EDIT_MUTUAL_RELATIONSHIP))
397        {
398            eventParams.put(EditContentFunction.EDIT_MUTUAL_RELATIONSHIP, true);
399        }
400        
401        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_MODIFYING, getUser(transientVars), eventParams));
402    }
403    
404    /**
405     * Notify observers that the content has been modified
406     * @param content The content modified
407     * @param transientVars The workflow vars
408     * @throws WorkflowException If an error occurred
409     */
410    protected void notifyContentModified(Content content, Map transientVars) throws WorkflowException
411    {
412        Map<String, Object> eventParams = new HashMap<>();
413        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
414        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
415        
416        if (transientVars.containsKey(EditContentFunction.EDIT_MUTUAL_RELATIONSHIP))
417        {
418            eventParams.put(EditContentFunction.EDIT_MUTUAL_RELATIONSHIP, true);
419        }
420        
421        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_MODIFIED, getUser(transientVars), eventParams));
422    }
423
424    /**
425     * Get the view for the content
426     * @param parameters The parameters
427     * @param values Typed values from inputs
428     * @param rawValues The raw values of the form
429     * @param content The content
430     * @return The view asked in the request or a built-in view
431     * @throws WorkflowException If an error occurred while getting the view
432     */
433    @SuppressWarnings("unchecked")
434    protected View getView(Map<String, Object> parameters, Map<String, Object> values, Map<String, Object> rawValues, Content content) throws WorkflowException
435    {
436        View view = (View) parameters.get(VIEW);
437        if (view == null)
438        {
439            List<String> viewItems = (List<String>) parameters.get(VIEW_ITEMS);
440            if (viewItems != null)
441            {
442                view = ViewHelper.createViewItemAccessor(content.getModel(), viewItems.toArray(String[]::new));
443            }
444            else
445            {
446                String viewName = (String) parameters.get(VIEW_NAME);
447                String fallbackViewName = (String) parameters.get(FALLBACK_VIEW_NAME);
448                if (viewName != null)
449                {
450                    view = _contentTypesHelper.getViewWithFallback(viewName, fallbackViewName, content.getTypes(), content.getMixinTypes());
451                }
452            }
453        }
454
455        if (view != null)
456        {
457            if (ViewHelper.areItemsPresentsOnlyOnce(view))
458            {
459                return ViewHelper.getTruncatedView(view);
460            }
461            else
462            {
463                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.");
464            }
465        }
466        
467        // Compute a view from input values
468        String[] computedViewItems;
469        Collection<? extends ModelItemContainer> model = content.getModel();
470        if (values != null)
471        {
472            Set<String> items;
473            items = new HashSet<>();
474            _getViewItems(values, model, content, items);
475            computedViewItems = items.toArray(String[]::new);
476        }
477        else
478        {
479            computedViewItems = rawValues.keySet().stream()
480                                                  .filter(path -> path.startsWith(FORM_ELEMENTS_PREFIX))
481                                                  .map(path -> path.substring(FORM_ELEMENTS_PREFIX.length()))
482                                                  .map(ModelHelper::getDefinitionPathFromDataPath)
483                                                  .toArray(String[]::new);
484        }
485        
486        return ViewHelper.createViewItemAccessor(model, computedViewItems);
487    }
488    
489    @SuppressWarnings("unchecked")
490    private void _getViewItems(Map<String, Object> values, Collection<? extends ModelItemContainer> parent, Content content, Set<String> viewItems)
491    {
492        for (String name : values.keySet())
493        {
494            ModelItem modelItem = ModelHelper.getModelItem(name, parent);
495            String path = modelItem.getPath();
496            
497            if (modelItem instanceof AttributeDefinition)
498            {
499                if (((AttributeDefinition) modelItem).canWrite(content))
500                {
501                    viewItems.add(path);
502                }
503            }
504            else if (modelItem instanceof CompositeDefinition)
505            {
506                Object value = values.get(name);
507                
508                if (!(value instanceof Map))
509                {
510                    throw new IllegalArgumentException("CompositeDefinition should correspond to a Map<String, Object> value.");
511                }
512                
513                Map<String, Object> composite = (Map<String, Object>) value;
514                
515                if (composite.isEmpty())
516                {
517                    // composite is empty, we should add it anyway so that the corresponding storage could also be emptied
518                    viewItems.add(path);
519                }
520                else
521                {
522                    _getViewItems(composite, Collections.singleton((CompositeDefinition) modelItem), content, viewItems);
523                }
524            }
525            else if (modelItem instanceof RepeaterDefinition)
526            {
527                Object value = values.get(name);
528                
529                if (!(value instanceof List) && !(value instanceof SynchronizableRepeater))
530                {
531                    throw new IllegalArgumentException("RepeaterDefinition should correspond to a SynchronizableRepeater or List<Map<String, Object>> value.");
532                }
533                
534                List<Map<String, Object>> entries = value instanceof SynchronizableRepeater ? ((SynchronizableRepeater) value).getEntries() : (List<Map<String, Object>>) value;
535                
536                if (entries.isEmpty())
537                {
538                    // repeater is empty, we should add it anyway so that the corresponding storage could also be emptied
539                    viewItems.add(path);
540                }
541                else
542                {
543                    for (int i = 0; i < entries.size(); i++)
544                    {
545                        Map<String, Object> entry = entries.get(i);
546                        _getViewItems(entry, Collections.singleton((RepeaterDefinition) modelItem), content, viewItems);
547                    }
548                }
549            }
550        }
551    }
552    
553    /**
554     * Computes the actual typed values from the input. 
555     * @param view the current {@link View}
556     * @param typedValues typed values, if any
557     * @param content the current Content
558     * @param rawValues raw values from form, if any
559     * @param rawComments the form comments, if any
560     * @param localOnly if the form values are local only or may include external values
561     * @return the actual values to be set
562     */
563    protected Map<String, Object> getValues(View view, Map<String, Object> typedValues, ModifiableContent content, Map<String, Object> rawValues, Map<String, List<Map<String, String>>> rawComments, boolean localOnly)
564    {
565        Map<String, Object> values = typedValues;
566        if (values == null)
567        {
568            values = _parseValues(view, "", content, rawValues, rawComments, localOnly);
569        }
570        else
571        {
572            values = _convertValues(view, values);
573        }
574        
575        return values;
576    }
577    
578    @SuppressWarnings("unchecked")
579    private Map<String, Object> _parseValues(ViewItemContainer viewItemContainer, String prefix, ModifiableContent content, Map<String, Object> rawValues, Map<String, List<Map<String, String>>> rawComments, boolean localOnly)
580    {
581        Map<String, Object> values = new HashMap<>();
582
583        org.ametys.plugins.repository.model.ViewHelper.visitView(viewItemContainer, 
584            (element, definition) -> {
585                // simple element
586                String name = definition.getName();
587                ElementType type = definition.getType();
588                
589                Object value;
590                if (!((AttributeDefinition) definition).canWrite(content))
591                {
592                    value = new UntouchedValue();
593                }
594                else
595                {
596                    Object initialValue = rawValues.get(FORM_ELEMENTS_PREFIX + prefix + name);
597                    Object rawValue = initialValue;
598                    ExternalizableDataStatus status = null;
599                    Object externalValue = null;
600                   
601                    // if the value is externalizable, rawValue is actually a Map {local:<value>, external:<value>, status:<local or external>}
602                    if (!localOnly && _externalizableDataProviderEP.isDataExternalizable(content, definition))
603                    {
604                        Map<String, Object> externalizableValue = (Map<String, Object>) initialValue;
605                        
606                        status = ExternalizableDataStatus.valueOf(((String) externalizableValue.get("status")).toUpperCase());
607                        rawValue = externalizableValue.get("local");
608                        
609                        Object rawExternalValue = externalizableValue.get("external");
610                        externalValue = type.fromJSONForClient(rawExternalValue);
611                    }
612                    
613                    // get the typed value
614                    Object typedValue = type.fromJSONForClient(rawValue);
615                    
616                    // retrieve the associated comments
617                    List<Map<String, String>> comments = rawComments == null ? null : rawComments.get(FORM_ELEMENTS_PREFIX + prefix + name);
618                    
619                    value = _getSynchronizableValue(typedValue, status, externalValue, comments);
620                }
621                
622                values.put(name, value);
623            }, 
624            (group, definition) -> {
625                // composite
626                String name = definition.getName();
627                values.put(name, _parseValues(group, prefix + name + "/", content, rawValues, rawComments, localOnly));
628            }, 
629            (group, definition) -> {
630                // repeater
631                String name = definition.getName();
632                int size = (int) rawValues.get(INTERNAL_FORM_ELEMENTS_PREFIX + prefix + name + "/size");
633                
634                List<Map<String, Object>> entries = new ArrayList<>();
635                Map<Integer, Integer> mapping = new HashMap<>();
636                for (int i = 1; i <= size; i++)
637                {
638                    int previousPosition = (int) rawValues.get(INTERNAL_FORM_ELEMENTS_PREFIX + prefix + name + "[" + i + "]/previous-position");
639                    if (previousPosition > 0)
640                    {
641                        mapping.put(previousPosition, i);
642                    }
643
644                    entries.add(_parseValues(group, prefix + name + "[" + i + "]/", content, rawValues, rawComments, localOnly));
645                }
646
647                values.put(name, SynchronizableRepeater.replaceAll(entries, mapping));
648            }, 
649            group -> values.putAll(_parseValues(group, prefix, content, rawValues, rawComments, localOnly)));
650        
651        return values;
652    }
653    
654    private Object _getSynchronizableValue(Object value, ExternalizableDataStatus status, Object externalValue, List<Map<String, String>> rawComments)
655    {
656        SynchronizableValue result = new SynchronizableValue(value);
657        result.setExternalizableStatus(status != null ? status : ExternalizableDataStatus.LOCAL);
658        result.setExternalValue(externalValue);
659        
660        if (rawComments != null)
661        {
662            List<DataComment> comments = new ArrayList<>();
663            
664            for (Map<String, String> rawComment : rawComments)
665            {
666                String author = rawComment.get("author");
667                String text = rawComment.get("text");
668                String rawDate = rawComment.get("date");
669                
670                DataComment comment = new DataComment(text, DateUtils.parseZonedDateTime(rawDate), author);
671                
672                comments.add(comment);
673            }
674            
675            result.setComments(comments);
676        }
677        
678        return result;
679    }
680    
681    private Object _convertValue(ElementDefinition definition, Object value)
682    {
683        if (value == null)
684        {
685            return null;
686        }
687        
688        if (definition.isMultiple())
689        {
690            if (value instanceof Collection)
691            {
692                return ((Collection) value).stream().map(v -> definition.getType().castValue(v)).toArray(i -> Array.newInstance(definition.getType().getManagedClass(), i));
693            }
694            else if (value.getClass().isArray())
695            {
696                Class<?> valueType = value.getClass().getComponentType();
697                Stream<Object> valueStream;
698                if (!valueType.isPrimitive())
699                {
700                    valueStream = Arrays.stream((Object[]) value);
701                }
702                else if (valueType.equals(Boolean.TYPE))
703                {
704                    valueStream = Arrays.stream(ArrayUtils.toObject((boolean[]) value));
705                }
706                else if (valueType.equals(Byte.TYPE))
707                {
708                    valueStream = Arrays.stream(ArrayUtils.toObject((byte[]) value));
709                }
710                else if (valueType.equals(Character.TYPE))
711                {
712                    valueStream = Arrays.stream(ArrayUtils.toObject((char[]) value));
713                }
714                else if (valueType.equals(Short.TYPE))
715                {
716                    valueStream = Arrays.stream(ArrayUtils.toObject((short[]) value));
717                }
718                else if (valueType.equals(Integer.TYPE))
719                {
720                    valueStream = Arrays.stream(ArrayUtils.toObject((int[]) value));
721                }
722                else if (valueType.equals(Long.TYPE))
723                {
724                    valueStream = Arrays.stream(ArrayUtils.toObject((long[]) value));
725                }
726                else if (valueType.equals(Double.TYPE))
727                {
728                    valueStream = Arrays.stream(ArrayUtils.toObject((double[]) value));
729                }
730                else if (valueType.equals(Float.TYPE))
731                {
732                    valueStream = Arrays.stream(ArrayUtils.toObject((float[]) value));
733                }
734                else
735                {
736                    throw new IllegalArgumentException(value + " cannot be converted to array");
737                }
738                
739                return valueStream.map(v -> definition.getType().castValue(v)).toArray(i -> (Object[]) Array.newInstance(definition.getType().getManagedClass(), i));
740            }
741            
742            throw new IllegalArgumentException(value + " cannot be converted to array");
743        }
744        else
745        {
746            return definition.getType().castValue(value);
747        }
748    }
749    
750    @SuppressWarnings("unchecked")
751    private Map<String, Object> _convertValues(ViewItemContainer viewItemContainer, Map<String, Object> values)
752    {
753        if (values == null)
754        {
755            return null;
756        }
757        
758        Map<String, Object> result = new HashMap<>();
759        
760        org.ametys.plugins.repository.model.ViewHelper.visitView(viewItemContainer, 
761            (element, definition) -> {
762                // simple element
763                String name = definition.getName();
764                
765                if (values.containsKey(name))
766                {
767                    Object value = values.get(name);
768                    SynchronizableValue syncValue = value instanceof SynchronizableValue ? (SynchronizableValue) value : null;
769                    value = syncValue == null ? value : syncValue.getValue();
770                    
771                    value = _convertValue(definition, value);
772                    
773                    if (syncValue != null)
774                    {
775                        syncValue.setValue(value);
776                        syncValue.setExternalValue(_convertValue(definition, syncValue.getExternalValue()));
777                    }
778                    
779                    SynchronizableValue newValue = syncValue == null ? new SynchronizableValue(value) : syncValue;
780                    result.put(name, newValue);
781                }
782            }, 
783            (group, definition) -> {
784                // composite
785                String name = definition.getName();
786                if (values.containsKey(name))
787                {
788                    result.put(name, _convertValues(group, (Map<String, Object>) values.get(name)));
789                }
790            }, 
791            (group, definition) -> {
792                // repeater
793                String name = definition.getName();
794                if (values.containsKey(name))
795                {
796                    Object value = values.get(name);
797                    SynchronizableRepeater syncRepeater = value instanceof SynchronizableRepeater ? (SynchronizableRepeater) value : null;
798                    List<Map<String, Object>> entries = value == null ? null : value instanceof List ? (List<Map<String, Object>>) value : ((SynchronizableRepeater) value).getEntries();
799                    
800                    Object newValue = null;
801                    if (entries != null)
802                    {
803                        List<Map<String, Object>> newEntries = new ArrayList<>();
804                        
805                        for (int i = 0; i < entries.size(); i++)
806                        {
807                            newEntries.add(_convertValues(group, entries.get(i)));
808                        }
809                        
810                        newValue = newEntries;
811                        
812                        if (syncRepeater != null)
813                        {
814                            newValue = SynchronizableRepeater.copy(syncRepeater, newEntries);
815                        }
816                    }
817                
818                    result.put(name, newValue);
819                }
820            }, 
821            group -> result.putAll(_convertValues(group, values)));
822        
823        return result;
824    }
825    
826    /**
827     * Validates all input values.
828     * @param view the model's view corresponding to the values
829     * @param values the actual input values
830     * @param content the current content
831     * @param allErrors object to be populated with validation errors
832     * @throws WorkflowException If an error occurred
833     */
834    protected void validateValues(View view, Map<String, Object> values, ModifiableContent content, AllErrors allErrors) throws WorkflowException
835    {
836        _validateValues(view, Optional.of(values), "", content, allErrors);
837    }
838
839    @SuppressWarnings("unchecked")
840    private void _validateValues(ViewItemContainer viewItemContainer, Optional<Map<String, Object>> values, String dataPath, ModifiableContent content, AllErrors allErrors)
841    {
842        org.ametys.plugins.repository.model.ViewHelper.visitView(viewItemContainer, 
843            (element, definition) -> {
844                // simple element
845                String name = definition.getName();
846                validateValue(definition, content, dataPath + name, allErrors, values.map(v -> v.get(name)).orElse(null));
847            }, 
848            (group, definition) -> {
849                // composite
850                String name = definition.getName();
851                Optional<Map<String, Object>> value = values.map(v -> v.get(name)).filter(Map.class::isInstance).map(Map.class::cast);
852                
853                _validateValues(group, value, dataPath + name + "/", content, allErrors);
854            }, 
855            (group, definition) -> {
856                // repeater
857                String name = definition.getName();
858                Object value = values.map(v -> v.get(name)).orElse(null);
859                
860                List<Map<String, Object>> entries = value == null ? null : value instanceof List ? (List<Map<String, Object>>) value : ((SynchronizableRepeater) value).getEntries();
861                SynchronizableRepeater.Mode mode = value instanceof SynchronizableRepeater ? ((SynchronizableRepeater) value).getMode() : SynchronizableRepeater.Mode.REPLACE_ALL;
862                
863                int oldRepeaterSize = 0;
864                if (mode != SynchronizableRepeater.Mode.REPLACE_ALL)
865                {
866                    Repeater repeater = content.getRepeater(dataPath + name);
867                    if (repeater != null)
868                    {
869                        oldRepeaterSize = repeater.getSize();
870                    }
871                }
872                
873                int repeaterSize = entries != null ? entries.size() : 0;
874                
875                if (mode == SynchronizableRepeater.Mode.APPEND)
876                {
877                    SynchronizableRepeater repeater = (SynchronizableRepeater) value;
878                    assert repeater != null;
879                    repeaterSize = oldRepeaterSize + repeaterSize - repeater.getRemovedEntries().size();
880                }
881                else if (mode == SynchronizableRepeater.Mode.REPLACE)
882                {
883                    repeaterSize = oldRepeaterSize;
884                }
885                
886                int minSize = definition.getMinSize();
887                int maxSize = definition.getMaxSize();
888                
889                if (repeaterSize < minSize)
890                {
891                    Errors errors = new Errors();
892                    
893                    List<String> parameters = new ArrayList<>();
894                    parameters.add(name);
895                    parameters.add(Integer.toString(minSize));
896                    errors.addError(new I18nizableText("plugin.cms", "CONTENT_EDITION_VALIDATION_ERRORS_REPEATER_MINSIZE", parameters));
897                    allErrors.addError(dataPath + name, errors);
898                }
899
900                if (maxSize > 0 && repeaterSize > maxSize)
901                {
902                    Errors errors = new Errors();
903
904                    List<String> parameters = new ArrayList<>();
905                    parameters.add(name);
906                    parameters.add(Integer.toString(maxSize));
907                    errors.addError(new I18nizableText("plugin.cms", "CONTENT_EDITION_VALIDATION_ERRORS_REPEATER_MAXSIZE", parameters));
908                    allErrors.addError(dataPath + name, errors);
909                }
910                
911                if (entries != null)
912                {
913                    for (int i = 0; i < entries.size(); i++)
914                    {
915                        Map<String, Object> entry = entries.get(i);
916                        _validateValues(group, Optional.of(entry), dataPath + name + "[" + (i + 1)  + "]/", content, allErrors);
917                    }
918                }
919            }, 
920            group -> _validateValues(group, values, dataPath, content, allErrors));
921    }
922    
923    /**
924     * Validate an attribute value.
925     * @param definition the attribute definition.
926     * @param content the Content being edited.
927     * @param dataPath the attribute path.
928     * @param allErrors the errors.
929     * @param value the value.
930     */
931    protected void validateValue(ElementDefinition definition, ModifiableContent content, String dataPath, AllErrors allErrors, Object value)
932    {
933        Object actualValue = DataHolderHelper.getValueToValidate(value);
934        Mode mode = value instanceof SynchronizableValue ? ((SynchronizableValue) value).getMode() : null;
935
936        if (mode == null)
937        {
938            mode = Mode.REPLACE;
939        }
940        
941        if (actualValue instanceof UntouchedValue)
942        {
943            // don't validate UntouchedValue, either they correspond to non-writable or previously stored data
944            return;
945        }
946        
947        if (!((AttributeDefinition) definition).canWrite(content))
948        {
949            throw new AccessDeniedException("Current user has no right to edit attribute " + definition.getPath());
950        }
951        
952        Validator validator = definition.getValidator();
953        Object valueToValidate = actualValue;
954        if (validator != null && validator.getClass().isAnnotationPresent(NeedAllValues.class))
955        {
956            // the validator need all attribute values
957            Object oldValue = content.getValue(dataPath);
958            if (definition.isMultiple())
959            {
960                Object[] oldValuesArray = (Object[]) oldValue;
961                Object[] newValuesArray = (Object[]) actualValue;
962                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));
963            }
964            else
965            {
966                valueToValidate = mode != Mode.REMOVE ? actualValue : null;
967            }
968        }
969
970        List<I18nizableText> errors = ModelHelper.validateValue(definition, valueToValidate);
971        
972        if (errors != null && !errors.isEmpty())
973        {
974            Errors e = new Errors();
975            e.addErrors(errors);
976            allErrors.addError(dataPath, e);
977        }
978    }
979    
980    /**
981     * Performs a global validation of the Content, based on declared {@link ContentValidator}s. 
982     * @param content the current {@link Content}.
983     * @param values the values being set
984     * @param view the current {@link View}
985     * @param allErrors object to be populated with validation errors
986     */
987    protected void globalValidate(Content content, Map<String, Object> values, View view, AllErrors allErrors)
988    {
989        Errors errors = new Errors();
990        
991        String[] allContentTypes = ArrayUtils.addAll(content.getTypes(), content.getMixinTypes());
992        
993        for (String cTypeId : allContentTypes)
994        {
995            ContentType contentType = _contentTypeExtensionPoint.getExtension(cTypeId);
996            
997            for (ContentValidator validator : contentType.getGlobalValidators())
998            {
999                validator.validate(content, values, view, errors);
1000            }
1001        }
1002        
1003        if (errors.hasErrors())
1004        {
1005            // Global error
1006            allErrors.addError(GLOBAL_ERROR_KEY, errors);
1007        }
1008    }
1009    
1010    /**
1011     * Prepares the write process by checking remote contents concerned by invert relations.
1012     * @param content the current content.
1013     * @param view the current View.
1014     * @param values the new values.
1015     * @param invertEditActionId the action id to check
1016     * @param user the current user
1017     * @param allErrors the collected errors
1018     * @return the {@link ReferencedContents}
1019     */
1020    protected Collection<ReferencedContents> prepareSynchronize(ModifiableContent content, View view, Map<String, Object> values, int invertEditActionId, UserIdentity user, AllErrors allErrors)
1021    {
1022        if (!invertRelationEnabled())
1023        {
1024            return null;
1025        }
1026        
1027        Collection<ReferencedContents> referencedContents = _contentDataHelper.collectReferencedContents(view, content, values);
1028        
1029        Map<ContentValue, Pair<Boolean, String>> refContents = new HashMap<>();
1030        
1031        // "flatten" the data, so that we only lock each content once
1032        // for each ref content, we only keep the first dataPath (for error reporting) and the weakest value for forceInvert (ie. false if any)
1033        for (ReferencedContents referencedContent : referencedContents)
1034        {
1035            ContentAttributeDefinition definition = referencedContent.getDefinition();
1036            boolean forceInvert = definition.getForceInvert();
1037            
1038            _flattenCollectedReferencedContents(referencedContent.getAddedContentsWithPaths(), refContents, forceInvert);
1039            _flattenCollectedReferencedContents(referencedContent.getRemovedContentsWithPaths(), refContents, forceInvert);
1040        }
1041        
1042        for (Entry<ContentValue, Pair<Boolean, String>> value : refContents.entrySet()) 
1043        {
1044            ContentValue refContentValue = value.getKey();
1045            ModifiableContent refContent = refContentValue.getContentIfExists().orElse(null);
1046            
1047            if (refContent != null)
1048            {
1049                // Check if edit action in available on referenced contents
1050                if (_isEditRefContentAvailable(invertEditActionId, refContent, value.getValue().getLeft(), value.getValue().getRight(), user, allErrors))
1051                {
1052                    if (refContent instanceof LockableAmetysObject && !((LockableAmetysObject) refContent).isLocked())
1053                    {
1054                        // Get lock on referenced content
1055                        ((LockableAmetysObject) refContent).lock();
1056                    }
1057                }
1058            }
1059        }
1060        
1061        return referencedContents;
1062    }
1063    
1064    private void _flattenCollectedReferencedContents(Map<ContentValue, List<String>> references, Map<ContentValue, Pair<Boolean, String>> refContents, boolean forceInvert)
1065    {
1066        for (Entry<ContentValue, List<String>> value : references.entrySet())
1067        {
1068            ContentValue refContentValue = value.getKey();
1069            List<String> dataPaths = value.getValue();
1070            
1071            Pair<Boolean, String> invertData = refContents.get(refContentValue);
1072            if (invertData == null)
1073            {
1074                String firstData = dataPaths.isEmpty() ? "" : dataPaths.get(0);
1075                refContents.put(refContentValue, Pair.of(forceInvert, firstData));
1076            }
1077            else if (!forceInvert && invertData.getLeft()) 
1078            {
1079                String firstData = dataPaths.isEmpty() ? "" : dataPaths.get(0);
1080                refContents.put(refContentValue, Pair.of(forceInvert, firstData));
1081            }
1082        }
1083    }
1084    
1085    private boolean _isEditRefContentAvailable(int editActionId, Content refContent, boolean forceInvert, String currentMetadataPath, UserIdentity user, AllErrors allErrors)
1086    {
1087        if (refContent instanceof WorkflowAwareContent)
1088        {
1089            Map<String, Object> inputs = new HashMap<>();
1090            if (forceInvert)
1091            {
1092                // do not check user's right
1093                inputs.put(CheckRightsCondition.FORCE, true);
1094            }
1095            
1096            int[] availableActions = _workflowHelper.getAvailableActions((WorkflowAwareContent) refContent, inputs);
1097            if (!ArrayUtils.contains(availableActions, editActionId))
1098            {
1099                Errors errors = new Errors();
1100                Map<String, I18nizableTextParameter> params = new HashMap<>();
1101                
1102                // Check lock
1103                if (refContent instanceof LockableAmetysObject)
1104                {
1105                    LockableAmetysObject lockableContent = (LockableAmetysObject) refContent;
1106                    if (lockableContent.isLocked() && !LockHelper.isLockOwner(lockableContent, user))
1107                    {
1108                        User lockOwner = _userManager.getUser(lockableContent.getLockOwner().getPopulationId(), lockableContent.getLockOwner().getLogin());
1109                        
1110                        params.put("content", new I18nizableText(_contentHelper.getTitle(refContent)));
1111                        params.put("lockOwner", new I18nizableText(lockOwner != null ? lockOwner.getFullName() + " (" + lockOwner.getIdentity().getLogin() + ")" : lockableContent.getLockOwner().getLogin()));
1112                        errors.addError(new I18nizableText("plugin.cms", "CONTENT_EDITION_VALIDATION_ERRORS_MUTUALRELATION_REFERENCED_CONTENT_LOCKED", params));
1113                        allErrors.addError(currentMetadataPath, errors);
1114                        
1115                        return false;
1116                    }
1117                }
1118                
1119                // Action in unavailable
1120                params.put("content", new I18nizableText(_contentHelper.getTitle(refContent)));
1121                errors.addError(new I18nizableText("plugin.cms", "CONTENT_EDITION_VALIDATION_ERRORS_MUTUALRELATION_UNAVAILABLE_ACTION", params));
1122                allErrors.addError(currentMetadataPath, errors);
1123                
1124                return false;
1125            }
1126            else
1127            {
1128                return true;
1129            }
1130        }
1131        else
1132        {
1133            Errors errors = new Errors();
1134            Map<String, I18nizableTextParameter> params = new HashMap<>();
1135            params.put("content", new I18nizableText(_contentHelper.getTitle(refContent)));
1136            errors.addError(new I18nizableText("plugin.cms", "CONTENT_EDITION_VALIDATION_ERRORS_MUTUALRELATION_NOWORKFLOWAWARE_CONTENT", params));
1137            allErrors.addError(currentMetadataPath, errors);
1138            return false;
1139        }
1140    }
1141
1142    /**
1143     * Analyze the content to extract outgoing references and store them
1144     * @param content The content to analyze
1145     */
1146    protected void extractOutgoingReferences(ModifiableContent content)
1147    {
1148        Map<String, OutgoingReferences> outgoingReferencesByPath = _outgoingReferencesExtractor.getOutgoingReferences(content);
1149        content.setOutgoingReferences(outgoingReferencesByPath);
1150    }
1151    
1152    /**
1153     * Template method to indicates if invert relation should be taken into account during the whole edition.
1154     * Override and return false to disabled invert relation management.
1155     * @return true if invert relation are enabled
1156     */
1157    protected boolean invertRelationEnabled()
1158    {
1159        return true;
1160    }
1161
1162    /**
1163     * Updates common metadata (last contributor, last modification date, ...).
1164     * @param content the content.
1165     * @param user the user.
1166     * @throws WorkflowException if an error occurs.
1167     */
1168    protected void updateCommonMetadata(ModifiableContent content, UserIdentity user) throws WorkflowException
1169    {
1170        if (user != null)
1171        {
1172            content.setLastContributor(user);
1173        }
1174        
1175        content.setLastModified(new Date());
1176        
1177        if (content instanceof WorkflowAwareContent)
1178        {
1179            // Remove the proposal date.
1180            ((WorkflowAwareContent) content).setProposalDate(null);
1181        }
1182    }
1183    
1184    /**
1185     * Trigger a 'edit content' workflow action (if the content is workflow-aware).
1186     * @param content The content.
1187     * @param actionId The current 'edit content' action ID.
1188     * @throws WorkflowException if an error occurs.
1189     */
1190    protected void _triggerEditWorkflowAction(Content content, int actionId) throws WorkflowException
1191    {
1192        if (content instanceof WorkflowAwareContent)
1193        {
1194            Map<String, Object> inputs = new HashMap<>();
1195            Map<String, Object> parameters = new HashMap<>();
1196            
1197            inputs.put(EditContentFunction.EDIT_MUTUAL_RELATIONSHIP, true);
1198            inputs.put(AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY, parameters);
1199            
1200            // Do action regarless of user's rights because user's rights was already checked during preparing process
1201            // 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
1202            inputs.put(CheckRightsCondition.FORCE, true);
1203            
1204            parameters.put(FORM_RAW_VALUES, Collections.EMPTY_MAP); // No values
1205            parameters.put(QUIT, true);
1206            
1207            _workflowHelper.editContent((WorkflowAwareContent) content, null, actionId);
1208        }
1209    }
1210}