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.util.ArrayList;
019import java.util.HashMap;
020import java.util.Map;
021
022import org.apache.avalon.framework.component.Component;
023import org.apache.avalon.framework.context.Context;
024import org.apache.avalon.framework.context.ContextException;
025import org.apache.avalon.framework.context.Contextualizable;
026import org.apache.avalon.framework.logger.AbstractLogEnabled;
027import org.apache.avalon.framework.service.ServiceException;
028import org.apache.avalon.framework.service.ServiceManager;
029import org.apache.avalon.framework.service.Serviceable;
030import org.apache.cocoon.components.ContextHelper;
031import org.apache.cocoon.environment.ObjectModelHelper;
032import org.apache.cocoon.environment.Request;
033import org.apache.commons.lang3.ArrayUtils;
034import org.apache.commons.lang3.StringUtils;
035import org.apache.commons.lang3.tuple.Pair;
036
037import org.ametys.cms.ObservationConstants;
038import org.ametys.cms.repository.Content;
039import org.ametys.cms.repository.WorkflowAwareContent;
040import org.ametys.core.observation.Event;
041import org.ametys.core.observation.ObservationManager;
042import org.ametys.core.user.CurrentUserProvider;
043import org.ametys.core.user.UserIdentity;
044import org.ametys.plugins.repository.AmetysRepositoryException;
045import org.ametys.plugins.repository.RepositoryConstants;
046import org.ametys.plugins.repository.data.holder.impl.DataHolderHelper;
047import org.ametys.plugins.repository.metadata.ModifiableCompositeMetadata;
048import org.ametys.plugins.repository.version.VersionableAmetysObject;
049import org.ametys.plugins.workflow.AbstractWorkflowComponent;
050import org.ametys.plugins.workflow.support.WorkflowProvider;
051import org.ametys.plugins.workflow.support.WorkflowProvider.AmetysObjectWorkflow;
052import org.ametys.runtime.model.ModelItem;
053
054import com.opensymphony.workflow.InvalidActionException;
055import com.opensymphony.workflow.WorkflowException;
056 
057/**
058 * A component to do workflow actions on Content
059 */
060public class ContentWorkflowHelper extends AbstractLogEnabled implements Serviceable, Contextualizable, Component
061{
062    /** The component role */
063    public static final String ROLE = ContentWorkflowHelper.class.getName();
064    
065    /** Component to get the current user */
066    protected CurrentUserProvider _userProvider;
067    
068    /** Workflow instance. */
069    protected WorkflowProvider _workflowProvider;
070
071    /** The observation manager */
072    protected ObservationManager _observationManager;
073    
074    private Context _context;
075
076
077    @Override
078    public void contextualize(Context context) throws ContextException
079    {
080        _context = context;
081    }
082    
083    @Override
084    public void service(ServiceManager manager) throws ServiceException
085    {
086        _userProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
087        _workflowProvider = (WorkflowProvider) manager.lookup(WorkflowProvider.ROLE);
088        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
089    }
090    
091    /**
092     * Creates a content using the workflow (with the CreateContentFunction).
093     * @param workflowName The name of the workflow to create
094     * @param initialActionId The workflow action id that creates content
095     * @param contentName The new name
096     * @param contentTitle The new title
097     * @param contentTypes The new content types. Cannot be null. Cannot be empty.
098     * @param mixins The new mixins. Can be null. Can be empty.
099     * @param languageCode The language code of the new content (such as 'fr', 'en'...)
100     * @return The workflow result map. See the create content function used to get the new content. Can be under the key CreateContentFunction.CONTENT_KEY, and the id under the key "contentId"
101     * @throws WorkflowException If an error occurred while doing the action on the workflow
102     * @throws AmetysRepositoryException If cannot get the workflow identifier of the content
103     */
104    public Map<String, Object> createContent(String workflowName, int initialActionId, String contentName, String contentTitle, String[] contentTypes, String[] mixins, String languageCode) throws AmetysRepositoryException, WorkflowException
105    {
106        return createContent(workflowName, initialActionId, contentName, contentTitle, contentTypes, mixins, languageCode, null, null);
107    }
108    
109    /**
110     * Creates a multilingual content with a multilingual title using the workflow (with the CreateContentFunction).
111     * @param workflowName The name of the workflow to create
112     * @param initialActionId The workflow action id that creates content
113     * @param contentName The new name
114     * @param titleVariants The title's variants
115     * @param contentTypes The new content types. Cannot be null. Cannot be empty.
116     * @param mixins The new mixins. Can be null. Can be empty.
117     * @return The workflow result map. See the create content function used to get the new content. Can be under the key CreateContentFunction.CONTENT_KEY, and the id under the key "contentId"
118     * @throws WorkflowException If an error occurred while doing the action on the workflow
119     * @throws AmetysRepositoryException If cannot get the workflow identifier of the content
120     */
121    public Map<String, Object> createContent(String workflowName, int initialActionId, String contentName, Map<String, String> titleVariants, String[] contentTypes, String[] mixins) throws AmetysRepositoryException, WorkflowException
122    {
123        return createContent(workflowName, initialActionId, contentName, titleVariants, contentTypes, mixins, null, null);
124    }
125
126    /**
127     * Creates a content using the workflow (with the CreateContentFunction).
128     * @param workflowName The name of the workflow to create
129     * @param initialActionId The workflow action id that creates content
130     * @param contentName The new name
131     * @param contentTitle The new title
132     * @param contentTypes The new content types. Cannot be null. Cannot be empty.
133     * @param mixins The new mixins. Can be null. Can be empty.
134     * @param languageCode The language code of the new content (such as 'fr', 'en'...)
135     * @param parentContentId If the new content is a subcontent, the parent content identifier.
136     * @param parentContentMetadatapath If the new content is a subcontent, the path of the metadata where the new content will take place in its parent
137     * @return The workflow result map. See the create content function used to get the new content. Can be under the key CreateContentFunction.CONTENT_KEY, and the id under the key "contentId"
138     * @throws WorkflowException If an error occurred while doing the action on the workflow
139     * @throws AmetysRepositoryException If cannot get the workflow identifier of the content
140
141     */
142    public Map<String, Object> createContent(String workflowName, int initialActionId, String contentName, String contentTitle, String[] contentTypes, String[] mixins, String languageCode, String parentContentId, String parentContentMetadatapath) throws AmetysRepositoryException, WorkflowException
143    {
144        Map<String, Object> inputs = new HashMap<>();
145        return createContent(workflowName, initialActionId, contentName, contentTitle, contentTypes, mixins, languageCode, parentContentId, parentContentMetadatapath, inputs);
146    }
147    
148    /**
149     * Creates a multilingual content with a multilingual title using the workflow (with the CreateContentFunction).
150     * @param workflowName The name of the workflow to create
151     * @param initialActionId The workflow action id that creates content
152     * @param contentName The new name
153     * @param titleVariants The title's variants
154     * @param contentTypes The new content types. Cannot be null. Cannot be empty.
155     * @param mixins The new mixins. Can be null. Can be empty.
156     * @return The workflow result map. See the create content function used to get the new content. Can be under the key CreateContentFunction.CONTENT_KEY, and the id under the key "contentId"
157     * @param parentContentId If the new content is a subcontent, the parent content identifier.
158     * @param parentContentMetadatapath If the new content is a subcontent, the path of the metadata where the new content will take place in its parent
159     * @throws WorkflowException If an error occurred while doing the action on the workflow
160     * @throws AmetysRepositoryException If cannot get the workflow identifier of the content
161     */
162    public Map<String, Object> createContent(String workflowName, int initialActionId, String contentName, Map<String, String> titleVariants, String[] contentTypes, String[] mixins, String parentContentId, String parentContentMetadatapath) throws AmetysRepositoryException, WorkflowException
163    {
164        Map<String, Object> inputs = new HashMap<>();
165        return createContent(workflowName, initialActionId, contentName, titleVariants, contentTypes, mixins, parentContentId, parentContentMetadatapath, inputs);
166    }
167    
168    /**
169     * Creates a content using the workflow (with the CreateContentFunction).
170     * @param workflowName The name of the workflow to create
171     * @param initialActionId The workflow action id that creates content
172     * @param contentName The new name
173     * @param contentTitle The new title
174     * @param contentTypes The new content types. Cannot be null. Cannot be empty.
175     * @param mixins The new mixins. Can be null. Can be empty.
176     * @param languageCode The language code of the new content (such as 'fr', 'en'...)
177     * @param parentContentId If the new content is a subcontent, the parent content identifier.
178     * @param parentContentMetadatapath If the new content is a subcontent, the path of the metadata where the new content will take place in its parent
179     * @param inputs The parameters to transmit to the workflow functions. Cannot be null. 
180     * @return The workflow result map. See the create content function used to get the new content. Can be under the key CreateContentFunction.CONTENT_KEY, and the id under the key "contentId"
181     * @throws WorkflowException If an error occurred while doing the action on the workflow
182     * @throws AmetysRepositoryException If cannot get the workflow identifier of the content
183
184     */
185    public Map<String, Object> createContent(String workflowName, int initialActionId, String contentName, String contentTitle, String[] contentTypes, String[] mixins, String languageCode, String parentContentId, String parentContentMetadatapath, Map<String, Object> inputs) throws AmetysRepositoryException, WorkflowException 
186    {
187        _getCommonInputsForCreation(inputs, contentName, contentTypes, mixins, parentContentId, parentContentMetadatapath);
188        inputs.put(CreateContentFunction.CONTENT_TITLE_KEY, contentTitle);
189        inputs.put(CreateContentFunction.CONTENT_LANGUAGE_KEY, languageCode);
190        
191        return _doInitialize(workflowName, contentName, initialActionId, inputs);
192    }
193    
194    /**
195     * Creates a multilingual content with a multilingual title using the workflow (with the CreateContentFunction).
196     * @param workflowName The name of the workflow to create
197     * @param initialActionId The workflow action id that creates content
198     * @param contentName The new name
199     * @param titleVariants The title's variants
200     * @param contentTypes The new content types. Cannot be null. Cannot be empty.
201     * @param mixins The new mixins. Can be null. Can be empty.
202     * @return The workflow result map. See the create content function used to get the new content. Can be under the key CreateContentFunction.CONTENT_KEY, and the id under the key "contentId"
203     * @param parentContentId If the new content is a subcontent, the parent content identifier.
204     * @param parentContentMetadatapath If the new content is a subcontent, the path of the metadata where the new content will take place in its parent
205     * @param inputs The parameters to transmit to the workflow functions. Cannot be null. 
206     * @throws WorkflowException If an error occurred while doing the action on the workflow
207     * @throws AmetysRepositoryException If cannot get the workflow identifier of the content
208     */
209    public Map<String, Object> createContent(String workflowName, int initialActionId, String contentName, Map<String, String> titleVariants, String[] contentTypes, String[] mixins, String parentContentId, String parentContentMetadatapath, Map<String, Object> inputs) throws AmetysRepositoryException, WorkflowException
210    {
211        _getCommonInputsForCreation(inputs, contentName, contentTypes, mixins, parentContentId, parentContentMetadatapath);
212        inputs.put(CreateContentFunction.CONTENT_TITLE_VARIANTS_KEY, titleVariants);
213        
214        return _doInitialize(workflowName, contentName, initialActionId, inputs);
215    }
216    
217    @SuppressWarnings("unchecked")
218    private Map<String, Object> _doInitialize(String workflowName, String contentName, int initialActionId, Map<String, Object> inputs) throws WorkflowException
219    {
220        try
221        {
222            AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow();
223            workflow.initialize(workflowName, initialActionId, inputs);
224            
225            return (Map<String, Object>) inputs.get(AbstractWorkflowComponent.RESULT_MAP_KEY);
226        }
227        catch (WorkflowException e)
228        {
229            getLogger().error("An error occured while creating workflow '" + workflowName + "' with action '" + initialActionId + "' to creates content '" + contentName + "'", e);
230            throw e;
231        }
232    }
233    
234    private void _getCommonInputsForCreation(Map<String, Object> inputs, String contentName, String[] contentTypes, String[] mixins, String parentContentId, String parentContentMetadatapath)
235    {
236        inputs.put(CreateContentFunction.CONTENT_NAME_KEY, contentName);
237        inputs.put(CreateContentFunction.CONTENT_TYPES_KEY, contentTypes);
238        inputs.put(CreateContentFunction.CONTENT_MIXINS_KEY, mixins);
239        inputs.put(CreateContentFunction.PARENT_CONTENT_ID_KEY, parentContentId);
240        inputs.put(CreateContentFunction.PARENT_CONTENT_METADATA_PATH_KEY, parentContentMetadatapath);
241        
242        Map<String, Object> results = new HashMap<>();
243        inputs.put(AbstractWorkflowComponent.RESULT_MAP_KEY, results);
244        inputs.put(AbstractWorkflowComponent.FAIL_CONDITIONS_KEY, new ArrayList<String>());
245    }
246    
247    /**
248     * Determines if the workflow action is available
249     * @param content the content to consider.
250     * @param actionId the workflow action id to check
251     * @return <code>true</code> if the wortkflow action is available
252     */
253    public boolean isAvailableAction(WorkflowAwareContent content, int actionId)
254    {
255        int[] actionIds = getAvailableActions(content);
256        return ArrayUtils.contains(actionIds, actionId);
257    }
258    
259    /**
260     * Get the available workflow actions for the content
261     * @param content The content to consider. Cannot be null.
262     * @return The array of actions ids that are available now
263     */
264    public int[] getAvailableActions(WorkflowAwareContent content)
265    {
266        Map<String, Object> inputs = new HashMap<>();
267        return getAvailableActions(content, inputs);
268    }
269    
270    /**
271     * Get the available workflow actions for the content
272     * @param content The content to consider. Cannot be null.
273     * @param inputs The parameters to transmit to the workflow functions. Cannot be null. 
274     * @return The array of actions ids that are available now
275     */
276    public int[] getAvailableActions(WorkflowAwareContent content, Map<String, Object> inputs)
277    {
278        AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(content);
279        long wId = content.getWorkflowId();
280        
281        inputs.put(AbstractContentWorkflowComponent.CONTENT_KEY, content);
282        inputs.put(AbstractWorkflowComponent.FAIL_CONDITIONS_KEY, new ArrayList<String> ());
283        
284        return workflow.getAvailableActions(wId, inputs);
285    }
286    
287    /**
288     * Do a workflow action on a content.
289     * @param content The content to act on. Cannot be null.
290     * @param actionId The id of the workflow action to do
291     * @return The results of the functions
292     * @throws WorkflowException If an error occurred while doing the action on the workflow
293     * @throws AmetysRepositoryException If cannot get the workflow identifier of the content
294     */
295    public Map<String, Object> doAction(WorkflowAwareContent content, int actionId) throws AmetysRepositoryException, WorkflowException
296    {
297        Map<String, Object> inputs = new HashMap<>();
298        return doAction(content, actionId, inputs);
299    }
300    
301    /**
302     * Do a workflow action on a content.
303     * @param content The content to act on. Cannot be null.
304     * @param actionId The id of the workflow action to do
305     * @param inputs The parameters to transmit to the workflow functions. Cannot be null. The special key AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY will be filled with the parent context if null (this means that if your are in a request dispatched, you will automatically get the js parameters).
306     * @return The results of the functions
307     * @throws WorkflowException If an error occurred while doing the action on the workflow
308     * @throws AmetysRepositoryException If cannot get the workflow identifier of the content
309     */
310    public Map<String, Object> doAction(WorkflowAwareContent content, int actionId, Map<String, Object> inputs) throws AmetysRepositoryException, WorkflowException
311    {
312        if (getLogger().isInfoEnabled())
313        {
314            getLogger().info("User " + _getUser() + " try to perform action " + actionId + " on content " + content.getId());
315        }
316
317        Map<String, Object> results = new HashMap<>();
318        inputs.put(AbstractWorkflowComponent.RESULT_MAP_KEY, results);
319        inputs.put(AbstractContentWorkflowComponent.CONTENT_KEY, content);
320        inputs.put(AbstractWorkflowComponent.FAIL_CONDITIONS_KEY, new ArrayList<String>());
321        
322        if (inputs.get(AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY) == null)
323        {
324            Map objectModel = ContextHelper.getObjectModel(_context);
325            @SuppressWarnings("unchecked")
326            Map<String, Object> jsParameters = (Map<String, Object>) objectModel.get(ObjectModelHelper.PARENT_CONTEXT);
327            inputs.put(AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY, jsParameters);
328        }
329
330        try
331        {
332            Request request = ContextHelper.getRequest(_context);
333            request.setAttribute(Content.class.getName(), content);
334            
335            AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(content);
336            workflow.doAction(content.getWorkflowId(), actionId, inputs);
337        }
338        catch (InvalidActionException e)
339        {
340            getLogger().error("An error occured while do workflow action '" + actionId + "' on content '" + content.getId() + "'", e);
341            throw e; 
342        }
343        
344        return results;
345    }
346
347    private UserIdentity _getUser()
348    {
349        return _userProvider.getUser();
350    }
351    
352    /**
353     * Remove metadata from content and apply changes
354     * @param contentToEdit the content to edit
355     * @param valuePath the value path of the metadata to remove
356     * @param actionId the workflow action id to execute if changes have been made
357     * @param comment the comment of the action. Can be null.
358     * @return true if changes are made
359     * @throws WorkflowException if failed to remove metadata
360     */
361    public boolean removeMetadata(WorkflowAwareContent contentToEdit, String valuePath, int actionId, String comment) throws WorkflowException
362    {
363        ModifiableCompositeMetadata metadataHolder = contentToEdit.getMetadataHolder();
364        String metadataName = valuePath;
365        if (StringUtils.contains(valuePath, ModelItem.ITEM_PATH_SEPARATOR))
366        {
367            metadataName = StringUtils.substringAfterLast(valuePath, ModelItem.ITEM_PATH_SEPARATOR);
368            String compositeValuePath = StringUtils.substringBeforeLast(valuePath, ModelItem.ITEM_PATH_SEPARATOR);
369            metadataHolder = _getCompositeFromValuePath(contentToEdit, compositeValuePath);
370        }
371        
372        if (metadataHolder.hasMetadata(metadataName))
373        {
374            if (!isAvailableAction(contentToEdit, actionId))
375            {
376                throw new InvalidActionException("Invalid workflow action '" + actionId + "' on content '" + contentToEdit.getId() + "': unable to remove metadata '" + valuePath + "'");
377            }
378            
379            metadataHolder.removeMetadata(metadataName);
380
381            _applyChanges(contentToEdit, actionId, comment);
382            return true;
383        }
384
385        return false;
386    }
387    
388    /**
389     * Remove string value of multiple metadata from content and apply changes
390     * @param contentToEdit the content to edit
391     * @param valuePath the value path of the metadata
392     * @param valueToRemove the value to remove
393     * @param actionId the workflow action id to execute if changes have been made
394     * @param comment the comment of the action. Can be null.
395     * @return true if changes are made
396     * @throws WorkflowException if an error occurred
397     */
398    public boolean removeStringValueFromMultipleMetadata(WorkflowAwareContent contentToEdit, String valuePath, String valueToRemove, int actionId, String comment) throws WorkflowException
399    {
400        ModifiableCompositeMetadata metadataHolder = contentToEdit.getMetadataHolder();
401        String metadataName = valuePath;
402        if (StringUtils.contains(valuePath, ModelItem.ITEM_PATH_SEPARATOR))
403        {
404            metadataName = StringUtils.substringAfterLast(valuePath, ModelItem.ITEM_PATH_SEPARATOR);
405            String compositeValuePath = StringUtils.substringBeforeLast(valuePath, ModelItem.ITEM_PATH_SEPARATOR);
406            metadataHolder = _getCompositeFromValuePath(contentToEdit, compositeValuePath);
407        }
408        
409        if (!metadataHolder.isMultiple(metadataName))
410        {
411            throw new IllegalArgumentException("The value path " + valuePath + " is invalid because it's not refered to multiple data.");
412        }
413        
414        String[] values = metadataHolder.getStringArray(metadataName, new String[0]);
415        if (ArrayUtils.contains(values, valueToRemove))
416        {
417            if (!isAvailableAction(contentToEdit, actionId))
418            {
419                throw new InvalidActionException("Invalid workflow action '" + actionId + "' on content '" + contentToEdit.getId() + "': unable to remove value '" + valueToRemove + "' from '" + valuePath + "'");
420            }
421            
422            String[] newValues = ArrayUtils.removeElement(values, valueToRemove);
423            metadataHolder.setMetadata(metadataName, newValues);
424            
425            _applyChanges(contentToEdit, actionId, comment);
426            return true;
427        }
428        
429        return false;
430    }
431    
432    /**
433     * Remove repeater entry from content and apply changes
434     * @param contentToEdit the content to edit
435     * @param entryPath the path of the entry to remove
436     * @param actionId the workflow action id to execute if changes have been made
437     * @param comment the comment of the action
438     * @return true if changes are made
439     * @throws WorkflowException if an error occurred
440     */
441    public boolean removeRepeaterEntry(WorkflowAwareContent contentToEdit, String entryPath, int actionId, String comment) throws WorkflowException
442    {
443        if (!DataHolderHelper.isRepeaterEntryPath(entryPath))
444        {
445            throw new IllegalArgumentException("The entry path " + entryPath + " is invalid. It must end by the entry name of the repeater as path[1]");
446        }
447        
448        ModifiableCompositeMetadata metadataHolder = contentToEdit.getMetadataHolder();
449        String lastEntryName = entryPath;
450        if (StringUtils.contains(entryPath, ModelItem.ITEM_PATH_SEPARATOR))
451        {
452            lastEntryName = StringUtils.substringAfterLast(entryPath, ModelItem.ITEM_PATH_SEPARATOR);
453            String compositeValuePath = StringUtils.substringBeforeLast(entryPath, ModelItem.ITEM_PATH_SEPARATOR);
454            metadataHolder = _getCompositeFromValuePath(contentToEdit, compositeValuePath);
455        }
456        
457        Pair<String, Integer> lastEntryNameAndPosition = DataHolderHelper.getRepeaterNameAndEntryPosition(lastEntryName);
458        
459        if (!isAvailableAction(contentToEdit, actionId))
460        {
461            throw new InvalidActionException("Invalid workflow action '" + actionId + "' on content '" + contentToEdit.getId() + "'. Unable to remove repeater entry " + entryPath);
462        }
463        
464        _removeEntry(lastEntryNameAndPosition.getValue(), metadataHolder.getCompositeMetadata(lastEntryNameAndPosition.getKey()));
465
466        _applyChanges(contentToEdit, actionId, comment);
467        return true;
468    }
469    
470    /**
471     * Removes the repeater entry at the given position. The position starts at index 1.
472     * The position can be an integer between 1 and the repeater size to remove an entry from the beginning
473     * Or the position can an integer between 0 and - the repeater size to remove an entry from the end (0 means at the end, -1 means before the last one and so on)
474     * @param position The position of the entry to remove
475     * @param repositoryComposite the repository composite of the repeater
476     * @throws IllegalArgumentException if the position is not between the negative and positive repeater size
477     */
478    protected void _removeEntry(int position, ModifiableCompositeMetadata repositoryComposite) //TODO already exist in the new API
479    {
480        int size = repositoryComposite.getMetadataNames().length;
481        if (1 <= position && position <= size)
482        {
483            // remove the entry
484            repositoryComposite.removeMetadata(String.valueOf(position));
485        
486            // rename all entries after the removed one
487            for (int currentEntryPosition = position + 1; currentEntryPosition <= size; currentEntryPosition++)
488            {
489                ModifiableCompositeMetadata entryRepositoryComposite = repositoryComposite.getCompositeMetadata(String.valueOf(currentEntryPosition));
490                entryRepositoryComposite.rename(RepositoryConstants.NAMESPACE_PREFIX + ":" + String.valueOf(currentEntryPosition - 1));
491            }
492        }
493        else if (-size < position && position <= 0)
494        {
495            // Find the positive equivalent position and call the removeEntry method with this position
496            _removeEntry(size + position, repositoryComposite);
497        }
498        else
499        {
500            throw new IllegalArgumentException("Illegal entry position: the entry index '" + position + "' is either negative or exceed the size of the repeater [" + size + "]");
501        }
502    }
503    
504    /**
505     * Get the last composite and the metadata name from a value path.
506     * For example, values[1]/value returns the composite of the first data of values repeater and the metadata name value
507     * @param contentToEdit the content to edit
508     * @param valuePath the value path
509     * @return the pair of composite and metadata name
510     */
511    protected ModifiableCompositeMetadata _getCompositeFromValuePath(WorkflowAwareContent contentToEdit, String valuePath)
512    {
513        ModifiableCompositeMetadata metadataHolder = contentToEdit.getMetadataHolder();
514        String[] pathSegments = valuePath.split(ModelItem.ITEM_PATH_SEPARATOR);
515        for (String pathSegment : pathSegments)
516        {
517            Pair<String, Integer> repeaterNameAndEntryPosition = DataHolderHelper.getRepeaterNameAndEntryPosition(pathSegment);
518            if (repeaterNameAndEntryPosition != null)
519            {
520                String repeaterMetadataName = repeaterNameAndEntryPosition.getKey();
521                String repeaterEntryName = String.valueOf(repeaterNameAndEntryPosition.getValue());
522                
523                metadataHolder = metadataHolder.getCompositeMetadata(repeaterMetadataName).getCompositeMetadata(repeaterEntryName);
524            }
525            else
526            {
527                metadataHolder = metadataHolder.getCompositeMetadata(pathSegment);
528            }
529        }
530        
531        return metadataHolder;
532    }
533    
534    
535    /**
536     * Apply changes on content: create a new version and notify listeners.
537     * @param content the content to apply changes
538     * @param actionId the id of workflow action to execute
539     * @param comment the action comment. Can be null.
540     * @throws WorkflowException if an error occurred
541     */
542    protected void _applyChanges(WorkflowAwareContent content, int actionId, String comment) throws WorkflowException
543    {
544        // Create a new version
545        if (content instanceof VersionableAmetysObject)
546        {
547            content.saveChanges();
548            ((VersionableAmetysObject) content).checkpoint();
549        }
550        
551        // Notify listeners
552        Map<String, Object> eventParams = new HashMap<>();
553        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
554        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
555        _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_MODIFIED, _getUser(), eventParams));
556       
557        Map<String, Object> input = new HashMap<>();
558        if (StringUtils.isNotEmpty(comment))
559        {
560            input.put("comment", comment);
561        }
562        
563        // Do workflow action
564        doAction(content, actionId, input);
565    }
566}