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