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