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