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