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