001/*
002 *  Copyright 2015 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.repository;
017
018import java.time.ZonedDateTime;
019import java.util.ArrayList;
020import java.util.HashMap;
021import java.util.HashSet;
022import java.util.List;
023import java.util.Map;
024import java.util.Set;
025import java.util.concurrent.ExecutionException;
026import java.util.concurrent.Future;
027import java.util.stream.Collectors;
028
029import javax.jcr.Node;
030import javax.jcr.RepositoryException;
031import javax.jcr.Session;
032
033import org.apache.avalon.framework.component.Component;
034import org.apache.avalon.framework.context.Context;
035import org.apache.avalon.framework.context.ContextException;
036import org.apache.avalon.framework.context.Contextualizable;
037import org.apache.avalon.framework.logger.AbstractLogEnabled;
038import org.apache.avalon.framework.service.ServiceException;
039import org.apache.avalon.framework.service.ServiceManager;
040import org.apache.avalon.framework.service.Serviceable;
041import org.apache.cocoon.components.ContextHelper;
042import org.apache.cocoon.environment.Request;
043import org.apache.commons.lang3.StringUtils;
044import org.apache.commons.lang3.tuple.Pair;
045import org.slf4j.Logger;
046
047import org.ametys.cms.ObservationConstants;
048import org.ametys.cms.content.ContentHelper;
049import org.ametys.cms.content.archive.ArchiveConstants;
050import org.ametys.cms.content.referencetable.HierarchicalReferenceTablesHelper;
051import org.ametys.cms.contenttype.ContentType;
052import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
053import org.ametys.cms.contenttype.ContentTypesHelper;
054import org.ametys.cms.data.ContentDataHelper;
055import org.ametys.cms.data.type.ModelItemTypeConstants;
056import org.ametys.cms.lock.LockContentManager;
057import org.ametys.cms.repository.ReactionableObject.ReactionType;
058import org.ametys.cms.tag.CMSTag;
059import org.ametys.cms.tag.CMSTag.TagVisibility;
060import org.ametys.cms.tag.TagHelper;
061import org.ametys.cms.tag.TagProviderExtensionPoint;
062import org.ametys.cms.workflow.AbstractContentWorkflowComponent;
063import org.ametys.cms.workflow.ContentWorkflowHelper;
064import org.ametys.cms.workflow.EditContentFunction;
065import org.ametys.core.observation.Event;
066import org.ametys.core.observation.ObservationManager;
067import org.ametys.core.right.RightManager;
068import org.ametys.core.right.RightManager.RightResult;
069import org.ametys.core.ui.Callable;
070import org.ametys.core.user.CurrentUserProvider;
071import org.ametys.core.user.UserIdentity;
072import org.ametys.core.user.UserManager;
073import org.ametys.core.util.DateUtils;
074import org.ametys.plugins.core.user.UserHelper;
075import org.ametys.plugins.explorer.ExplorerNode;
076import org.ametys.plugins.explorer.resources.ModifiableResourceCollection;
077import org.ametys.plugins.explorer.resources.Resource;
078import org.ametys.plugins.explorer.resources.ResourceCollection;
079import org.ametys.plugins.repository.AmetysObject;
080import org.ametys.plugins.repository.AmetysObjectIterable;
081import org.ametys.plugins.repository.AmetysObjectResolver;
082import org.ametys.plugins.repository.AmetysRepositoryException;
083import org.ametys.plugins.repository.CopiableAmetysObject;
084import org.ametys.plugins.repository.ModifiableAmetysObject;
085import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
086import org.ametys.plugins.repository.RemovableAmetysObject;
087import org.ametys.plugins.repository.TraversableAmetysObject;
088import org.ametys.plugins.repository.UnknownAmetysObjectException;
089import org.ametys.plugins.repository.data.holder.ModifiableModelLessDataHolder;
090import org.ametys.plugins.repository.lock.LockAwareAmetysObject;
091import org.ametys.plugins.repository.lock.LockHelper;
092import org.ametys.plugins.repository.lock.LockableAmetysObject;
093import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector;
094import org.ametys.plugins.repository.tag.TaggableAmetysObject;
095import org.ametys.plugins.repository.version.ModifiableDataAwareVersionableAmetysObject;
096import org.ametys.plugins.repository.version.VersionableAmetysObject;
097import org.ametys.plugins.workflow.AbstractWorkflowComponent;
098import org.ametys.plugins.workflow.component.CheckRightsCondition;
099import org.ametys.plugins.workflow.store.AbstractJackrabbitWorkflowStore;
100import org.ametys.plugins.workflow.store.AmetysObjectWorkflowStore;
101import org.ametys.plugins.workflow.support.WorkflowProvider;
102import org.ametys.plugins.workflow.support.WorkflowProvider.AmetysObjectWorkflow;
103import org.ametys.runtime.authentication.AccessDeniedException;
104import org.ametys.runtime.i18n.I18nizableText;
105import org.ametys.runtime.model.View;
106import org.ametys.runtime.model.type.DataContext;
107
108import com.opensymphony.workflow.WorkflowException;
109import com.opensymphony.workflow.spi.Step;
110
111/**
112 * DAO for manipulating contents
113 *
114 */
115public class ContentDAO extends AbstractLogEnabled implements Serviceable, Component, Contextualizable
116{
117    /** Avalon Role */
118    public static final String ROLE = ContentDAO.class.getName();
119
120    /** Deletion status : deleted */
121    protected static final String _CONTENT_DELETION_STATUS_DELETED = "deleted";
122
123    /** Deletion status : undeleted */
124    protected static final String _CONTENT_DELETION_STATUS_UNDELETED = "undeleted";
125
126    /** Deletion status : referenced */
127    protected static final String _CONTENT_DELETION_STATUS_REFERENCED = "referenced";
128
129    /** Deletion status : unauthorized */
130    protected static final String _CONTENT_DELETION_STATUS_UNAUTHORIZED = "unauthorized";
131
132    /** Deletion status : locked */
133    protected static final String _CONTENT_DELETION_STATUS_LOCKED = "locked";
134    
135    /** Ametys resolver */
136    protected AmetysObjectResolver _resolver;
137    /** Ametys observation manger */
138    protected ObservationManager _observationManager;
139    /** Component to get current user */
140    protected CurrentUserProvider _currentUserProvider;
141    /** Component to get tags */
142    protected TagProviderExtensionPoint _tagProvider;
143
144    /** Workflow component */
145    protected WorkflowProvider _workflowProvider;
146    /** Workflow helper component */
147    protected ContentWorkflowHelper _contentWorkflowHelper;
148    /** Component to manager lock */
149    protected LockContentManager _lockManager;
150    /** Content-type extension point */
151    protected ContentTypeExtensionPoint _contentTypeEP;
152    /** Content helper */
153    protected ContentHelper _contentHelper;
154    /** Content types helper */
155    protected ContentTypesHelper _cTypesHelper;
156    /** Rights manager */
157    protected RightManager _rightManager;
158    /** Cocoon context */
159    protected Context _context;
160    /** The user manager */
161    protected UserManager _usersManager;
162    /** Helper for users */
163    protected UserHelper _userHelper;
164    /** The helper component for hierarchical simple contents */
165    protected HierarchicalReferenceTablesHelper _hierarchicalSimpleContentsHelper;
166    /** The modifiable content helper */
167    protected ModifiableContentHelper _modifiableContentHelper;
168    
169    /** The mode for tag edition */
170    public enum TagMode 
171    {
172        /** Value will replace existing one */
173        REPLACE,
174        /** Value will be added to existing one */
175        INSERT,
176        /** Value will be removed from existing one */
177        REMOVE
178    }
179    
180    public void contextualize(Context context) throws ContextException
181    {
182        _context = context;
183    }
184    
185    @Override
186    public void service(ServiceManager smanager) throws ServiceException
187    {
188        _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
189        _observationManager = (ObservationManager) smanager.lookup(ObservationManager.ROLE);
190        _currentUserProvider = (CurrentUserProvider) smanager.lookup(CurrentUserProvider.ROLE);
191        _usersManager = (UserManager) smanager.lookup(UserManager.ROLE);
192        _userHelper = (UserHelper) smanager.lookup(UserHelper.ROLE);
193        _tagProvider = (TagProviderExtensionPoint) smanager.lookup(TagProviderExtensionPoint.ROLE);
194        _workflowProvider = (WorkflowProvider) smanager.lookup(WorkflowProvider.ROLE);
195        _rightManager = (RightManager) smanager.lookup(RightManager.ROLE);
196        _contentTypeEP = (ContentTypeExtensionPoint) smanager.lookup(ContentTypeExtensionPoint.ROLE);
197        _lockManager = (LockContentManager) smanager.lookup(LockContentManager.ROLE);
198        _contentWorkflowHelper = (ContentWorkflowHelper) smanager.lookup(ContentWorkflowHelper.ROLE);
199        _cTypesHelper = (ContentTypesHelper) smanager.lookup(ContentTypesHelper.ROLE);
200        _contentHelper = (ContentHelper) smanager.lookup(ContentHelper.ROLE);
201        _modifiableContentHelper = (ModifiableContentHelper) smanager.lookup(ModifiableContentHelper.ROLE); 
202        _hierarchicalSimpleContentsHelper = (HierarchicalReferenceTablesHelper) smanager.lookup(HierarchicalReferenceTablesHelper.ROLE);
203    }
204
205    /**
206     * Delete contents and force the deletion of invert relations.
207     * @param contentsId The ids of contents to delete
208     * @return the deleted and undeleted contents
209     */
210    @Callable
211    public Map<String, Object> forceDeleteContents(List<String> contentsId)
212    {
213        return forceDeleteContentsObj(
214            contentsId.stream()
215                .map(_resolver::<Content>resolveById)
216                .collect(Collectors.toList()),
217            getRightToDelete()
218        );
219    }
220
221    /**
222     * Delete contents and force the deletion of invert relations.
223     * @param contents The contents to delete
224     * @param deleteRightId The deletion right's id to check. Can be null to ignore rights
225     * @return the deleted and undeleted contents
226     */
227    public Map<String, Object> forceDeleteContentsObj(List<Content> contents, String deleteRightId)
228    {
229        Map<String, Object> results = _initializeResultsMap();
230        
231        for (Content content : contents)
232        {
233            Map<String, Object> contentParams = _transformContentToParams(content);
234            
235            String contentDeletionStatus = _getContentDeletionStatus(content, deleteRightId);
236            
237            // The content is referenced, remove referencies
238            // Then remove referencies if you can
239            // Then check that the content is not referenced any more (should not happen)
240            if (contentDeletionStatus != null && contentDeletionStatus.equals(_CONTENT_DELETION_STATUS_REFERENCED) && _removeReferences(content) && !_isContentReferenced(content))
241            {
242                contentDeletionStatus = null;
243            }
244            
245            // The content has no constraints
246            if (contentDeletionStatus == null)
247            {
248                contentDeletionStatus = _reallyDeleteContent(content);
249            }
250            
251            String key = contentDeletionStatus + "-contents";
252            @SuppressWarnings("unchecked")
253            List<Map<String, Object>> statusedContents = (List<Map<String, Object>>) results.get(key);
254            statusedContents.add(contentParams);
255        }
256        
257        return results;
258    }
259    
260    /**
261     * Get the invert action id (used for forced deletion).
262     * @return The invert action id
263     */
264    protected int _getInvertActionId()
265    {
266        return EditContentFunction.INVERT_EDIT_ACTION_ID;
267    }
268    
269    /**
270     * Get the right to delete a content.
271     * @return The right ID to delete a content
272     */
273    protected String getRightToDelete()
274    {
275        return "CMS_Rights_DeleteContent";
276    }
277    
278    /**
279     * Remove all the references to the given content.
280     * @param content The content
281     * @return <code>true</code> if references have been all removed successfully
282     */
283    protected boolean _removeReferences(Content content)
284    {
285        int actionId = _getInvertActionId();
286        
287        // Group references by referencer
288        Map<Content, Set<String>> referencesByContent = new HashMap<>();
289        for (Pair<String, Content> referencingPair : _contentHelper.getReferencingContents(content))
290        {
291            Set<String> references = referencesByContent.computeIfAbsent(referencingPair.getValue(), __ -> new HashSet<>());
292            references.add(referencingPair.getKey());
293        }
294        
295        // Control that each referencer has the invert action available
296        for (Content referencingContent : referencesByContent.keySet())
297        {
298            if (referencingContent instanceof WorkflowAwareContent 
299                    && !_contentWorkflowHelper.isAvailableAction((WorkflowAwareContent) referencingContent, actionId))
300            {
301                getLogger().warn("The action " + actionId + " is not available for the referencing content " + referencingContent.getId() + ". Impossible to delete the content " + content.getId() + ".");
302                return false;
303            }
304        }
305        
306        // Get the references
307        for (Content referencingContent : referencesByContent.keySet())
308        {
309            // Break the references
310            for (String referencingPath : referencesByContent.get(referencingContent))
311            {
312                if (referencingContent instanceof ModifiableContent)
313                {
314                    if (referencingContent.isMultiple(referencingPath))
315                    {
316                        String[] values = ContentDataHelper.getContentIdsStreamFromMultipleContentData(referencingContent, referencingPath)
317                            .filter(id -> !id.equals(content.getId()))
318                            .toArray(String[]::new);
319                        ((ModifiableContent) referencingContent).setValue(referencingPath, values);
320                    }
321                    else
322                    {
323                        ((ModifiableContent) referencingContent).removeValue(referencingPath);
324                    }
325                }
326            }
327            
328            // edit
329            Map<String, Object> contextParameters = new HashMap<>();
330            contextParameters.put("quit", true);
331            
332            Map<String, Object> inputs = new HashMap<>();
333            inputs.put(AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY, contextParameters);
334            inputs.put(CheckRightsCondition.FORCE, true);
335            
336            if (referencingContent instanceof WorkflowAwareContent)
337            {
338                try
339                {
340                    _contentWorkflowHelper.doAction((WorkflowAwareContent) referencingContent, actionId, inputs);
341                }
342                catch (WorkflowException e)
343                {
344                    getLogger().error("An error occured while trying to update the workflow of the referencer " + referencingContent.toString(), e);
345                }
346            }
347        }
348        
349        return true;
350    }
351    
352    /**
353     * Delete contents
354     * @param contentsId The ids of contents to delete
355     * @return the deleted and undeleted contents
356     */
357    @Callable
358    public Map<String, Object> deleteContents(List<String> contentsId)
359    {
360        return deleteContents(contentsId, false);
361    }
362    
363    /**
364     * Delete contents
365     * @param contentsId The ids of contents to delete
366     * @param ignoreRights true to ignore user rights
367     * @return the deleted and undeleted contents
368     */
369    public Map<String, Object> deleteContents(List<String> contentsId, boolean ignoreRights)
370    {
371        return deleteContents(contentsId, ignoreRights ? null : getRightToDelete());
372    }
373    
374    /**
375     * Delete contents
376     * @param contentsId The ids of contents to delete
377     * @param deleteRightId The deletion right's id to check. Can be null to ignore rights
378     * @return the deleted and undeleted contents
379     */
380    public Map<String, Object> deleteContents(List<String> contentsId, String deleteRightId)
381    {
382        Map<String, Object> results = _initializeResultsMap();
383        
384        for (String contentId : contentsId)
385        {
386            Content content = _resolver.resolveById(contentId);
387            Map<String, Object> contentParams = _transformContentToParams(content);
388            
389            String contentDeletionStatus = _getContentDeletionStatus(content, deleteRightId);
390            
391            // The content has no constraints
392            if (contentDeletionStatus == null)
393            {
394                contentDeletionStatus = _reallyDeleteContent(content);
395            }
396            
397            String key = contentDeletionStatus + "-contents";
398            @SuppressWarnings("unchecked")
399            List<Map<String, Object>> statusedContents = (List<Map<String, Object>>) results.get(key);
400            statusedContents.add(contentParams);
401        }
402        
403        return results;
404    }
405    
406    /**
407     * Initialize the result map.
408     * @return The empty result map.
409     */
410    protected Map<String, Object> _initializeResultsMap()
411    {
412        Map<String, Object> results = new HashMap<>();
413
414        results.put(_CONTENT_DELETION_STATUS_DELETED + "-contents", new ArrayList<>());
415        results.put(_CONTENT_DELETION_STATUS_UNDELETED + "-contents", new ArrayList<>());
416        results.put(_CONTENT_DELETION_STATUS_REFERENCED + "-contents", new ArrayList<>());
417        results.put(_CONTENT_DELETION_STATUS_UNAUTHORIZED + "-contents", new ArrayList<>());
418        results.put(_CONTENT_DELETION_STATUS_LOCKED + "-contents", new ArrayList<>());
419        
420        return results;
421    }
422    
423    /**
424     * Delete the content and notify observers.
425     * @param content The content to delete
426     * @return The deletion status "deleted" or "undeleted" if an exception occurs
427     */
428    protected String _reallyDeleteContent(Content content)
429    {
430        try
431        {
432            // All checks have been done, the content can be deleted
433            Map<String, Object> eventParams = _getEventParametersForDeletion(content);
434            
435            _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETING, _currentUserProvider.getUser(), eventParams));
436            
437            RemovableAmetysObject removableContent = (RemovableAmetysObject) content;
438            ModifiableAmetysObject parent = removableContent.getParent();
439            
440            // Remove the content.
441            removableContent.remove();
442            
443            parent.saveChanges();
444            
445            _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETED, _currentUserProvider.getUser(), eventParams));
446            
447            return _CONTENT_DELETION_STATUS_DELETED;
448        }
449        catch (AmetysRepositoryException e)
450        {
451            getLogger().error("Unable to delete content '" + content.getId() + "'", e);
452            
453            return _CONTENT_DELETION_STATUS_UNDELETED;
454        }
455    }
456    
457    /**
458     * Transform the content to a {@link Map} with id, title and name.
459     * @param content The content to transform
460     * @return A {@link Map} with essentials informations of the content
461     */
462    protected Map<String, Object> _transformContentToParams(Content content)
463    {
464        String contentName = content.getName();
465        String contentTitle = StringUtils.defaultString(_contentHelper.getTitle(content), contentName);
466        
467        Map<String, Object> contentParams = new HashMap<>();
468        contentParams.put("id", content.getId());
469        contentParams.put("title", contentTitle);
470        contentParams.put("name", contentName);
471        
472        return contentParams;
473    }
474    
475    /**
476     * Get the deletion status of the content :
477     *  - unauthorized: The content can't be deleted because of rights
478     *  - locked: The content is locked
479     *  - referenced: The content has ingoing references
480     * @param content The content
481     * @param deleteRightId The right ID
482     * @return <code>null</code> if content deletion can be done or the status if there is something wrong for deletion 
483     */
484    protected String _getContentDeletionStatus(Content content, String deleteRightId)
485    {
486        if (!(content instanceof RemovableAmetysObject))
487        {
488            throw new IllegalArgumentException("The content [" + content.getId() + "] is not a RemovableAmetysObject, it can't be deleted.");
489        }
490        
491        if (deleteRightId != null && !canDelete(content, deleteRightId))
492        {
493            // User has no sufficient right
494            return _CONTENT_DELETION_STATUS_UNAUTHORIZED;
495        }
496        
497        if (content instanceof LockableAmetysObject)
498        {
499            // If the content is locked, try to unlock it.
500            LockableAmetysObject lockableContent = (LockableAmetysObject) content;
501            if (lockableContent.isLocked())
502            {
503                boolean canUnlockAll = _rightManager.hasRight(_currentUserProvider.getUser(), "CMS_Rights_UnlockAll", "/cms") == RightResult.RIGHT_ALLOW;
504                if (LockHelper.isLockOwner(lockableContent, _currentUserProvider.getUser()) || canUnlockAll)
505                {
506                    lockableContent.unlock();
507                }
508                else
509                {
510                    return _CONTENT_DELETION_STATUS_LOCKED;
511                }
512            }
513        }
514
515        if (_isContentReferenced(content))
516        {
517            // Indicate that the content is referenced.
518            return _CONTENT_DELETION_STATUS_REFERENCED;
519        }
520        
521        return null;
522    }
523    
524    /**
525     * Test if content is still referenced before removing it
526     * @param content The content to remove
527     * @return true if content is still referenced
528     */
529    protected boolean _isContentReferenced (Content content)
530    {
531        return content.hasReferencingContents();
532    }
533    
534    /**
535     * Get parameters for content deleted {@link Event}
536     * @param content the removed content
537     * @return the event's parameters
538     */
539    protected Map<String, Object> _getEventParametersForDeletion (Content content)
540    {
541        Map<String, Object> eventParams = new HashMap<>();
542        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
543        eventParams.put(ObservationConstants.ARGS_CONTENT_NAME, content.getName());
544        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
545        return eventParams;
546    }
547    
548    /**
549     * Get the contents properties
550     * @param contentIds The ids of contents
551     * @param workspaceName The workspace name. Can be null to get contents in current workspace.
552     * @return The contents' properties
553     */
554    @Callable
555    public Map<String, Object> getContentsProperties (List<String> contentIds, String workspaceName)
556    {
557        Map<String, Object> result = new HashMap<>();
558        
559        List<Map<String, Object>> contents = new ArrayList<>();
560        List<String> contentsNotFound = new ArrayList<>();
561        
562        Request request = ContextHelper.getRequest(_context);
563        String currentWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
564        try
565        {
566            if (StringUtils.isNotEmpty(workspaceName))
567            {
568                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, workspaceName);
569            }
570            
571            for (String contentId : contentIds)
572            {
573                try
574                {
575                    Content content = _resolver.resolveById(contentId);
576                    contents.add(getContentProperties(content));
577                }
578                catch (UnknownAmetysObjectException e)
579                {
580                    contentsNotFound.add(contentId);
581                }
582            }
583            
584            result.put("contents", contents);
585            result.put("contentsNotFound", contentsNotFound);
586        }
587        finally
588        {
589            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWorkspace);
590        }
591        
592        return result;
593    }
594    
595    /**
596     * Get the content properties
597     * @param contentId The id of content
598     * @param workspaceName The workspace name. Can be null to get content in current workspace.
599     * @return The content's properties
600     */
601    @Callable
602    public Map<String, Object> getContentProperties (String contentId, String workspaceName)
603    {
604        Request request = ContextHelper.getRequest(_context);
605        String currentWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
606        try
607        {
608            if (StringUtils.isNotEmpty(workspaceName))
609            {
610                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, workspaceName);
611            }
612            
613            Content content = _resolver.resolveById(contentId);
614            return getContentProperties(content);
615        }
616        finally
617        {
618            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWorkspace);
619        }
620    }
621    
622    /**
623     * Get the content properties
624     * @param content The content
625     * @return The content properties
626     */
627    public Map<String, Object> getContentProperties (Content content)
628    {
629        Map<String, Object> infos = new HashMap<>();
630        
631        infos.put("id", content.getId());
632        infos.put("name", content.getName());
633        infos.put("title", _contentHelper.getTitle(content));
634        
635        if (ModelItemTypeConstants.MULTILINGUAL_STRING_ELEMENT_TYPE_ID.equals(content.getType(Content.ATTRIBUTE_TITLE).getId()))
636        {
637            infos.put("titleVariants", _contentHelper.getTitleVariants(content));
638        }
639        infos.put("path", content.getPath());
640        infos.put("types", content.getTypes());
641        infos.put("mixins", content.getMixinTypes());
642        String lang = content.getLanguage();
643        if (lang != null)
644        {
645            infos.put("lang", lang);
646        }
647        infos.put("creator", _userHelper.user2json(content.getCreator()));
648        infos.put("lastContributor", _userHelper.user2json(content.getLastContributor()));
649        infos.put("creationDate", DateUtils.zonedDateTimeToString(content.getCreationDate()));
650        infos.put("lastModified", DateUtils.zonedDateTimeToString(content.getLastModified()));
651        infos.put("isSimple", _contentHelper.isSimple(content));
652        infos.put("isReferenceTable", _contentHelper.isReferenceTable(content));
653        infos.put("parent", _hierarchicalSimpleContentsHelper.getParent(content));
654        
655        if (content instanceof WorkflowAwareContent)
656        {
657            WorkflowAwareContent waContent = (WorkflowAwareContent) content;
658            AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(waContent);
659            
660            infos.put("workflowName", workflow.getWorkflowName(waContent.getWorkflowId()));
661            
662            List<Integer> workflowSteps = new ArrayList<>();
663            
664            List<Step> currentSteps = workflow.getCurrentSteps(waContent.getWorkflowId());
665            for (Step step : currentSteps)
666            {
667                workflowSteps.add(step.getStepId());
668            }
669            infos.put("workflowSteps", workflowSteps);
670            
671            int[] availableActions = _contentWorkflowHelper.getAvailableActions(waContent);
672            infos.put("availableActions", availableActions);
673        }
674        
675        if (content instanceof ModifiableContent)
676        {
677            infos.put("isModifiable", true);
678        }
679        
680        if (content instanceof LockAwareAmetysObject)
681        {
682            LockAwareAmetysObject lockableContent = (LockAwareAmetysObject) content;
683            if (lockableContent.isLocked())
684            {
685                infos.put("locked", true);
686                infos.put("lockOwner", _userHelper.user2json(lockableContent.getLockOwner()));
687                infos.put("canUnlock", _lockManager.canUnlock(lockableContent));
688            }
689        }
690        
691        infos.put("rights", getUserRights(content));
692        
693        Map<String, Object> additionalData = new HashMap<>();
694        
695        String[] contenttypes = content.getTypes();
696        for (String cTypeId : contenttypes)
697        {
698            ContentType cType = _contentTypeEP.getExtension(cTypeId);
699            if (cType != null)
700            {
701                additionalData.putAll(cType.getAdditionalData(content));
702            }
703        }
704        
705        if (!additionalData.isEmpty())
706        {
707            infos.put("additionalData", additionalData); 
708        }
709
710        infos.put("isTaggable", content instanceof TaggableAmetysObject);
711        
712        if (content instanceof ModifiableDataAwareVersionableAmetysObject)
713        {
714            ModifiableModelLessDataHolder unversionedDataHolder = ((ModifiableDataAwareVersionableAmetysObject) content).getUnversionedDataHolder();
715            if (unversionedDataHolder.hasValue(ArchiveConstants.META_ARCHIVE_SCHEDULED_DATE))
716            {
717                ZonedDateTime scheduledDate = unversionedDataHolder.getValue(ArchiveConstants.META_ARCHIVE_SCHEDULED_DATE);
718                infos.put("scheduledArchivingDate", DateUtils.zonedDateTimeToString(scheduledDate));
719            }
720        }
721        
722        if (content instanceof ReportableObject)
723        {
724            infos.put("reportsCount", ((ReportableObject) content).getReportsCount());
725        }
726
727        return infos;
728    }
729    
730    /**
731     * Get the content's properties for description
732     * @param contentId The id of content
733     * @param workspaceName The workspace name. Can be null to get content in current workspace.
734     * @return The content's properties for description
735     */
736    @Callable
737    public Map<String, Object> getContentDescription (String contentId, String workspaceName)
738    {
739        Request request = ContextHelper.getRequest(_context);
740        String currentWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
741        try
742        {
743            if (StringUtils.isNotEmpty(workspaceName))
744            {
745                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, workspaceName);
746            }
747            
748            Content content = _resolver.resolveById(contentId);
749            return getContentDescription(content);
750        }
751        finally
752        {
753            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWorkspace);
754        }
755    }
756    
757    /**
758     *Get the content's properties for description
759     * @param content The content
760     * @return The content's properties for description
761     */
762    public Map<String, Object> getContentDescription (Content content)
763    {
764        Map<String, Object> infos = new HashMap<>();
765        
766        infos.put("id", content.getId());
767        infos.put("name", content.getName());
768        infos.put("title", _contentHelper.getTitle(content));
769        infos.put("types", content.getTypes());
770        infos.put("mixins", content.getMixinTypes());
771        infos.put("lang", content.getLanguage());
772        infos.put("creator", _userHelper.user2json(content.getCreator()));
773        infos.put("lastContributor", _userHelper.user2json(content.getLastContributor()));
774        infos.put("lastModified", DateUtils.zonedDateTimeToString(content.getLastModified()));
775        infos.put("iconGlyph", _cTypesHelper.getIconGlyph(content));
776        infos.put("iconDecorator", _cTypesHelper.getIconDecorator(content));
777        infos.put("smallIcon", _cTypesHelper.getSmallIcon(content));
778        infos.put("mediumIcon", _cTypesHelper.getMediumIcon(content));
779        infos.put("largeIcon", _cTypesHelper.getLargeIcon(content));
780        
781        return infos;
782    }
783    
784    /**
785     * Get the views of a content plus a view of all the content's data
786     * @param contentId the content's id
787     * @param includeInternal Set to true to include internal views.
788     * @return the views
789     */
790    @Callable
791    public List<Map<String, Object>> getContentViewsAndAllData(String contentId, boolean includeInternal)
792    {
793        List<Map<String, Object>> views = getContentViews(contentId, includeInternal);
794        views.add(_getAllDataView());
795        return views;
796    }
797
798    private Map<String, Object> _getAllDataView()
799    {
800        Map<String, Object> viewInfos = new HashMap<>();
801        viewInfos.put("name", ContentTypesHelper.ALL_DATA);
802        viewInfos.put("label", new I18nizableText("plugin.cms", "CONTENT_OPEN_DATA_SELECT_ALL_DATA_VIEW_LABEL"));
803        viewInfos.put("description", new I18nizableText("plugin.cms", "CONTENT_OPEN_DATA_SELECT_ALL_DATA_VIEW_DESC"));
804        return viewInfos;
805    }
806    
807    /**
808     * Get the views of a content
809     * @param contentId the content's id
810     * @param includeInternal Set to true to include internal views.
811     * @return the views
812     */
813    @Callable
814    public List<Map<String, Object>> getContentViews(String contentId, boolean includeInternal)
815    {
816        List<Map<String, Object>> views = new ArrayList<>();
817        
818        Content content = _resolver.resolveById(contentId);
819        String contentTypeId = _cTypesHelper.getContentTypeIdForRendering(content);
820        
821        ContentType cType = _contentTypeEP.getExtension(contentTypeId);
822        
823        Set<String> viewNames = cType.getViewNames(includeInternal);
824        for (String viewName : viewNames)
825        {
826            View view = cType.getView(viewName);
827            
828            Map<String, Object> viewInfos = new HashMap<>();
829            viewInfos.put("name", viewName);
830            viewInfos.put("label", view.getLabel());
831            viewInfos.put("description", view.getDescription());
832            views.add(viewInfos);
833        }
834        
835        return views;
836    }
837    
838    /**
839     * Get the user rights on content
840     * @param content The content
841     * @return The user's rights
842     */
843    protected Set<String> getUserRights (Content content)
844    {
845        UserIdentity user = _currentUserProvider.getUser();
846        return _rightManager.getUserRights(user, content);
847    }
848    
849    /**
850     * Get the tags of contents
851     * @param contentIds The content's ids
852     * @return the tags
853     */
854    @Callable(rights = Callable.SKIP_BUILTIN_CHECK)
855    public Set<String> getTags (List<String> contentIds)
856    {
857        Set<String> tags = new HashSet<>();
858        
859        for (String contentId : contentIds)
860        {
861            Content content = _resolver.resolveById(contentId);
862            if (_rightManager.currentUserHasReadAccess(content))
863            {
864                tags.addAll(content.getTags());
865            }
866        }
867        
868        return tags;
869    }
870    
871    /**
872     * Tag a list of contents with the given tags
873     * @param contentIds The ids of contents to tag
874     * @param tagNames The tags
875     * @param contextualParameters The contextual parameters
876     * @return the result
877     */
878    @Callable(rights = Callable.SKIP_BUILTIN_CHECK)
879    public Map<String, Object> tag (List<String> contentIds, List<String> tagNames, Map<String, Object> contextualParameters)
880    {
881       return tag(contentIds, tagNames, TagMode.REPLACE, contextualParameters, false);
882    }
883    
884    /**
885     * Tag a list of contents
886     * @param contentIds The ids of contents to tag
887     * @param tagNames The tags
888     * @param mode The mode for updating tags: 'REPLACE' to replace tags, 'INSERT' to add tags or 'REMOVE' to remove tags.
889     * @param contextualParameters The contextual parameters
890     * @return the result
891     */
892    @Callable(rights = Callable.SKIP_BUILTIN_CHECK)
893    public Map<String, Object> tag (List<String> contentIds, List<String> tagNames, String mode, Map<String, Object> contextualParameters)
894    {
895        return tag(contentIds, tagNames, TagMode.valueOf(mode), contextualParameters, false);
896    }
897    
898    /**
899     * Tag a list of contents
900     * @param contentIds The ids of contents to tag
901     * @param tagNames The tags
902     * @param mode The mode for updating tags: 'REPLACE' to replace tags, 'INSERT' to add tags or 'REMOVE' to remove tags.
903     * @param contextualParameters The contextual parameters
904     * @param ignoreRights <code>true</code> to ignore the rights on tag
905     * @return the result
906     */
907    public Map<String, Object> tag (List<String> contentIds, List<String> tagNames, TagMode mode, Map<String, Object> contextualParameters, boolean ignoreRights)
908    {
909        Map<String, Object> result = new HashMap<>();
910        
911        result.put("notaggable-contents", new ArrayList<>());
912        result.put("invalid-tags", new ArrayList<>());
913        result.put("allright-contents", new ArrayList<>());
914        result.put("locked-contents", new ArrayList<>());
915        result.put("noright-contents", new ArrayList<>());
916        
917        for (String contentId : contentIds)
918        {
919            Content content = _resolver.resolveById(contentId);
920            
921            Map<String, Object> content2json = new HashMap<>();
922            content2json.put("id", content.getId());
923            content2json.put("title", _contentHelper.getTitle(content));
924
925            if (!ignoreRights && !_hasTagRights(content, tagNames, contextualParameters))
926            {
927                @SuppressWarnings("unchecked")
928                List<Map<String, Object>> noRightContents = (List<Map<String, Object>>) result.get("noright-contents");
929                noRightContents.add(content2json);
930            }
931            else if (content instanceof TaggableAmetysObject)
932            {
933                TaggableAmetysObject mContent = (TaggableAmetysObject) content;
934                
935                boolean wasLocked = false;
936                
937                if (content instanceof LockableAmetysObject)
938                {
939                    LockableAmetysObject lockableContent = (LockableAmetysObject) content;
940                    UserIdentity user = _currentUserProvider.getUser();
941                    if (lockableContent.isLocked() && !LockHelper.isLockOwner(lockableContent, user))
942                    {
943                        @SuppressWarnings("unchecked")
944                        List<Map<String, Object>> lockedContents = (List<Map<String, Object>>) result.get("locked-contents");
945                        content2json.put("lockOwner", lockableContent.getLockOwner());
946                        lockedContents.add(content2json);
947                        
948                        // Stop process
949                        continue;
950                    }
951                    
952                    if (lockableContent.isLocked())
953                    {
954                        wasLocked = true;
955                        lockableContent.unlock();
956                    }
957                }
958                
959                Set<String> oldTags = mContent.getTags();
960                _removeAllTagsInReplaceMode(mContent, mode, oldTags);
961                
962                // Then set new tags
963                for (String tagName : tagNames)
964                {
965                    if (_isTagValid(tagName, contextualParameters))
966                    {
967                        if (TagMode.REMOVE.equals(mode))
968                        {
969                            mContent.untag(tagName);
970                        }
971                        else if (TagMode.REPLACE.equals(mode) || !oldTags.contains(tagName))
972                        {
973                            mContent.tag(tagName);
974                        }
975                        
976                    }
977                    else
978                    {
979                        @SuppressWarnings("unchecked")
980                        List<String> invalidTags = (List<String>) result.get("invalid-tags");
981                        invalidTags.add(tagName);
982                    }
983                }
984                
985                ((ModifiableAmetysObject) content).saveChanges();
986                
987                if (wasLocked)
988                {
989                    // Relock content if it was locked before tagging
990                    ((LockableAmetysObject) content).lock();
991                }
992                
993                content2json.put("tags", content.getTags());
994                @SuppressWarnings("unchecked")
995                List<Map<String, Object>> allRightPages = (List<Map<String, Object>>) result.get("allright-contents");
996                allRightPages.add(content2json);
997                
998                if (!oldTags.equals(content.getTags()))
999                {
1000                    // Notify observers that the content has been tagged
1001                    Map<String, Object> eventParams = new HashMap<>();
1002                    eventParams.put(org.ametys.cms.ObservationConstants.ARGS_CONTENT, content);
1003                    eventParams.put(org.ametys.cms.ObservationConstants.ARGS_CONTENT_ID, content.getId());
1004                    eventParams.put("content.tags", content.getTags());
1005                    eventParams.put("content.old.tags", oldTags);
1006                    _observationManager.notify(new Event(org.ametys.cms.ObservationConstants.EVENT_CONTENT_TAGGED, _currentUserProvider.getUser(), eventParams));
1007                }
1008            }
1009            else
1010            {
1011                @SuppressWarnings("unchecked")
1012                List<Map<String, Object>> notaggableContents = (List<Map<String, Object>>) result.get("notaggable-contents");
1013                notaggableContents.add(content2json);
1014            }
1015        }
1016        
1017        return result;
1018    }
1019
1020    private boolean _hasTagRights(Content content, List<String> tagNames, Map<String, Object> contextualParameters)
1021    {
1022        List<CMSTag> tags = tagNames.stream()
1023            .map(t -> _tagProvider.getTag(t, contextualParameters))
1024            .toList();
1025        
1026        boolean hasPublicTagRight = _rightManager.currentUserHasRight("CMS_Rights_Content_Tag", content) == RightResult.RIGHT_ALLOW;
1027        boolean hasPrivateTagRight = _rightManager.currentUserHasRight("CMS_Rights_Content_Private_Tag", content) == RightResult.RIGHT_ALLOW;
1028        
1029        // Test if the current user has the right to tag public tag on content only if there are at least one public tag
1030        boolean hasRight = TagHelper.filterTags(tags, TagVisibility.PUBLIC, "CONTENT").isEmpty() || hasPublicTagRight || hasPrivateTagRight;
1031        
1032        // Test if the current user has the right to tag private tag on content only if there are at least one private tag
1033        return hasRight && (TagHelper.filterTags(tags, TagVisibility.PRIVATE, "CONTENT").isEmpty() || hasPrivateTagRight);
1034    }
1035
1036    /**
1037     * Remove all tags from the given content if tagMode is equals to REPLACE.
1038     * @param mContent The content
1039     * @param tagMode The tag
1040     * @param oldTags Tags to remove
1041     */
1042    protected void _removeAllTagsInReplaceMode(TaggableAmetysObject mContent, TagMode tagMode, Set<String> oldTags)
1043    {
1044        if (TagMode.REPLACE.equals(tagMode))
1045        {
1046            // First delete old tags
1047            for (String tagName : oldTags)
1048            {
1049                mContent.untag(tagName);
1050            }
1051        }
1052    }
1053    
1054    /**
1055     * Is the tag a content tag
1056     * @param tagName The tag name
1057     * @param contextualParameters The contextual parameters
1058     * @return true if the tag is a valid content tag
1059     */
1060    protected boolean _isTagValid (String tagName, Map<String, Object> contextualParameters)
1061    {
1062        CMSTag tag = _tagProvider.getTag(tagName, contextualParameters);
1063        return tag.getTarget().getName().equals("CONTENT");
1064    }
1065    
1066    /**
1067     * Copy a content.
1068     * @param originalContent the original content.
1069     * @param parent the object in which to create a content.
1070     * @param name the content name.
1071     * @param initWorkflowActionId The initial workflow action id
1072     * @param context The context of the data to copy
1073     * @return the copied content.
1074     * @throws AmetysRepositoryException If an error occured
1075     */
1076    public ModifiableContent copy(DefaultContent originalContent, ModifiableTraversableAmetysObject parent, String name, int initWorkflowActionId, DataContext context) throws AmetysRepositoryException
1077    {
1078        return copy(originalContent, parent, name, null, initWorkflowActionId, context);
1079    }
1080    
1081    /**
1082     * Copy a content.
1083     * @param originalContent the original content.
1084     * @param parent the object in which to create a content.
1085     * @param name the content name.
1086     * @param lang the content language. If null, the content language will be the same of the original content 
1087     * @param initWorkflowActionId The initial workflow action id
1088     * @param context The context of the data to copy
1089     * @return the copied content.
1090     * @throws AmetysRepositoryException If an error occured
1091     */
1092    public ModifiableContent copy(DefaultContent originalContent, ModifiableTraversableAmetysObject parent, String name, String lang, int initWorkflowActionId, DataContext context) throws AmetysRepositoryException
1093    {
1094        return copy(originalContent, parent, name, lang, initWorkflowActionId, true, true, false, false, context);
1095    }
1096    
1097    /**
1098     * Copy a content.
1099     * @param originalContent the original content.
1100     * @param parent the object in which to create a content.
1101     * @param name the content name.
1102     * @param lang the content language. If null, the content language will be the same of the original content 
1103     * @param initWorkflowActionId The initial workflow action id
1104     * @param notifyObservers Set to false to do not fire observer events
1105     * @param checkpoint true to check the content in if it is versionable
1106     * @param waitAsyncObservers true to wait for asynchronous observers to complete
1107     * @param copyACL true to copy ACL of source content
1108     * @param context The context of the data to copy
1109     * @return the copied content.
1110     * @throws AmetysRepositoryException If an error occured
1111     */
1112    public ModifiableContent copy(DefaultContent originalContent, ModifiableTraversableAmetysObject parent, String name, String lang, int initWorkflowActionId, boolean notifyObservers, boolean checkpoint, boolean waitAsyncObservers, boolean copyACL, DataContext context) throws AmetysRepositoryException
1113    {
1114        try
1115        {
1116            String originalName = name == null ? originalContent.getName() : name;
1117            String contentName = originalName;
1118            int index = 2;
1119            while (parent.hasChild(contentName))
1120            {
1121                contentName = originalName + "-" + (index++);
1122            }
1123            
1124            String originalContentType = originalContent.getNode().getPrimaryNodeType().getName();
1125            
1126            ModifiableContent content = parent.createChild(contentName, originalContentType);
1127            
1128            String targetLanguage = lang == null ? originalContent.getLanguage() : lang;
1129            if (targetLanguage != null)
1130            {
1131                content.setLanguage(targetLanguage);
1132            }
1133            
1134            content.setTypes(originalContent.getTypes());
1135            
1136            _modifiableContentHelper.copyTitle(originalContent, content);
1137            
1138            if (originalContent instanceof WorkflowAwareContent)
1139            {
1140                WorkflowAwareContent waOriginalContent = (WorkflowAwareContent) originalContent;
1141                AmetysObjectWorkflow originalContentWorkflow = _workflowProvider.getAmetysObjectWorkflow(waOriginalContent);
1142                String workflowName = originalContentWorkflow.getWorkflowName(waOriginalContent.getWorkflowId());
1143                
1144                // Initialize new content workflow
1145                WorkflowAwareContent waContent = (WorkflowAwareContent) content;
1146                AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(waContent);
1147                
1148                HashMap<String, Object> inputs = new HashMap<>();
1149                // Provide the content key
1150                inputs.put(AbstractWorkflowComponent.FAIL_CONDITIONS_KEY, new ArrayList<>());
1151                inputs.put(AbstractContentWorkflowComponent.CONTENT_KEY, waContent);
1152                
1153                long workflowId = workflow.initialize(workflowName, initWorkflowActionId, inputs);
1154                
1155                // Remove workflow id if exists before updating it
1156                WorkflowAwareContentHelper.removeWorkflowId(waContent);
1157                waContent.setWorkflowId(workflowId);
1158                
1159                // Set the current step ID property
1160                Step currentStep = (Step) workflow.getCurrentSteps(workflowId).iterator().next();
1161                waContent.setCurrentStepId(currentStep.getStepId());
1162                
1163                Node workflowEntryNode = null;
1164                Node node = waContent.getNode();
1165                Session session = node.getSession();
1166                
1167                
1168                try
1169                {
1170                    AbstractJackrabbitWorkflowStore workflowStore = (AbstractJackrabbitWorkflowStore) workflow.getConfiguration().getWorkflowStore();
1171                    
1172                    if (workflowStore instanceof AmetysObjectWorkflowStore)
1173                    {
1174                        AmetysObjectWorkflowStore ametysObjectWorkflowStore = (AmetysObjectWorkflowStore) workflowStore;
1175                        ametysObjectWorkflowStore.bindAmetysObject(waContent);
1176                    }
1177                    
1178                    workflowEntryNode = workflowStore.getEntryNode(session, workflowId);
1179                    workflowEntryNode.setProperty("ametys-internal:initialActionId", initWorkflowActionId);
1180                }
1181                catch (RepositoryException e)
1182                {
1183                    throw new AmetysRepositoryException("Unable to link the workflow to the content", e);
1184                }
1185            }
1186            
1187            // Copy attributes
1188            originalContent.copyTo(content, context);
1189            
1190            // Copy attachments 
1191            _copyAttachments(originalContent, content);
1192            
1193            if (copyACL)
1194            {
1195                _copyACL(originalContent, content);
1196            }
1197            
1198            UserIdentity currentUser = _currentUserProvider.getUser();
1199            if (currentUser != null)
1200            {
1201                content.setCreator(currentUser);
1202                content.setLastContributor(currentUser);
1203                content.setLastModified(ZonedDateTime.now());
1204                content.setCreationDate(ZonedDateTime.now());
1205            }
1206            
1207            parent.saveChanges();
1208            
1209            // Create a new version
1210            if (checkpoint && content instanceof VersionableAmetysObject versionableContent)
1211            {
1212                versionableContent.checkpoint();
1213            }
1214
1215            if (notifyObservers)
1216            {
1217                notifyContentCopied(content, waitAsyncObservers);
1218            }
1219            
1220            return content;
1221        }
1222        catch (WorkflowException e)
1223        {
1224            throw new AmetysRepositoryException(e);
1225        }
1226        catch (RepositoryException e)
1227        {
1228            throw new AmetysRepositoryException(e);
1229        }
1230    }
1231    
1232    /**
1233     * Copy the attachments of a content
1234     * @param srcContent The source content
1235     * @param targetContent The target content
1236     */
1237    protected void _copyAttachments(Content srcContent, Content targetContent)
1238    {
1239        ResourceCollection srcRootAttachments = srcContent.getRootAttachments();
1240        if (srcRootAttachments == null)
1241        {
1242            // There are no attachments to copy
1243            return;
1244        }
1245        
1246        ResourceCollection targetRootAttachments = targetContent.getRootAttachments();
1247        if (targetRootAttachments == null)
1248        {
1249            // The target is an (unmodifiable) old version and the attachments root is missing
1250            return;
1251        }
1252        
1253        AmetysObjectIterable<AmetysObject> children = srcRootAttachments.getChildren();
1254        for (AmetysObject child : children)
1255        {
1256            if (child instanceof CopiableAmetysObject)
1257            {
1258                try
1259                {
1260                    ((CopiableAmetysObject) child).copyTo((ModifiableTraversableAmetysObject) targetRootAttachments, child.getName());
1261                }
1262                catch (AmetysRepositoryException e)
1263                {
1264                    getLogger().error("Failed to copy attachments at path " + child.getPath() + " from content " + srcContent + " to content " + targetContent, e);
1265                }
1266            }
1267        }
1268    }
1269        
1270    /**
1271     * Copy the ACL of a content
1272     * @param srcContent The source content
1273     * @param targetContent The target content
1274     */
1275    protected void _copyACL(Content srcContent, Content targetContent)
1276    {
1277        if (srcContent instanceof DefaultContent && targetContent instanceof DefaultContent)
1278        {
1279            Node srcNode = ((DefaultContent) srcContent).getNode();
1280            Node targetNode = ((DefaultContent) targetContent).getNode();
1281            
1282            try
1283            {
1284                String aclNodeName = "ametys-internal:acl";
1285                if (srcNode.hasNode(aclNodeName))
1286                {
1287                    Node aclNode = srcNode.getNode(aclNodeName);
1288                    aclNode.getSession().getWorkspace().copy(aclNode.getPath(), targetNode.getPath() + "/" + aclNodeName);
1289                }
1290            }
1291            catch (RepositoryException e)
1292            {
1293                getLogger().error("Failed to copy ACL from content " + srcContent + " to content " + targetContent, e);
1294            }
1295        }
1296    }
1297    
1298    /**
1299     * Notify observers that the content has been created
1300     * @param content The content added
1301     * @param waitAsyncObservers true to wait for asynchonous observers to finish
1302     * @throws WorkflowException If an error occurred
1303     */
1304    public void notifyContentCopied(Content content, boolean waitAsyncObservers) throws WorkflowException
1305    {
1306        Map<String, Object> eventParams = new HashMap<>();
1307        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
1308        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
1309
1310        List<Future> futures = _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_ADDED, _currentUserProvider.getUser(), eventParams));
1311        futures.addAll(_observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_WORKFLOW_CHANGED, _currentUserProvider.getUser(), eventParams)));
1312        
1313        if (waitAsyncObservers)
1314        {
1315            // Wait for asynchonous observers to finish
1316            for (Future future : futures)
1317            {
1318                try
1319                {
1320                    future.get();
1321                }
1322                catch (ExecutionException | InterruptedException e)
1323                {
1324                    getLogger().error(String.format("Error while waiting for async observer to complete"));
1325                }
1326            }
1327        }
1328    }
1329    
1330    /**
1331     * Returns the content's attachments root node
1332     * @param id the content's id
1333     * @return The attachments' root node informations
1334     */
1335    @Callable
1336    public Map<String, Object> getAttachmentsRootNode (String id)
1337    {
1338        Map<String, Object> result = new HashMap<>();
1339        
1340        Content content = _resolver.resolveById(id);
1341        
1342        result.put("title", _contentHelper.getTitle(content));
1343        result.put("contentId", content.getId());
1344        
1345        TraversableAmetysObject attachments = content.getRootAttachments();
1346        
1347        if (attachments != null)
1348        {
1349            result.put("id", attachments.getId());
1350            if (attachments instanceof ModifiableAmetysObject)
1351            {
1352                result.put("isModifiable", true);
1353            }
1354            if (attachments instanceof ModifiableResourceCollection)
1355            {
1356                result.put("canCreateChild", true);
1357            }
1358            
1359            boolean hasChildNodes = false;
1360            boolean hasResources = false;
1361
1362            for (AmetysObject child : attachments.getChildren())
1363            {
1364                if (child instanceof Resource)
1365                {
1366                    hasResources = true;
1367                }
1368                else if (child instanceof ExplorerNode)
1369                {
1370                    hasChildNodes = true;
1371                }
1372            }
1373
1374            if (hasChildNodes)
1375            {
1376                result.put("hasChildNodes", true);
1377            }
1378
1379            if (hasResources)
1380            {
1381                result.put("hasResources", true);
1382            }
1383            
1384            return result;
1385        }
1386        
1387        throw new IllegalArgumentException("Content with id '" + id + "' does not support attachments.");
1388    }
1389    
1390    /**
1391     * Determines if the current user has right to delete the content
1392     * @param content The content
1393     * @return true if current user is authorized to delete the content
1394     */
1395    public boolean canDelete(Content content)
1396    {
1397        return canDelete(content, getRightToDelete());
1398    }
1399    
1400    /**
1401     * Determines if the current user has right to delete the content
1402     * @param content The content
1403     * @param deleteRightId The right's id to check for deletion
1404     * @return true if current user is authorized to delete the content
1405     */
1406    public boolean canDelete(Content content, String deleteRightId)
1407    {
1408        UserIdentity user = _currentUserProvider.getUser();
1409        if (_rightManager.hasRight(user, deleteRightId, content) == RightResult.RIGHT_ALLOW)
1410        {
1411            return true;
1412        }
1413        
1414        return false;
1415    }
1416    
1417    /**
1418     * Add or remove a reaction on a content
1419     * @param contentId The content id
1420     * @param reactionName the reaction name (ex: LIKE)
1421     * @param remove true to remove the reaction, false to add reaction
1422     * @return the result with the current actors of this reaction
1423     */
1424    @Callable
1425    public Map<String, Object> react(String contentId, String reactionName, boolean remove)
1426    {
1427        Map<String, Object> result = new HashMap<>();
1428        
1429        Content content = _resolver.resolveById(contentId);
1430        
1431        if (_rightManager.currentUserHasReadAccess(content))
1432        {
1433            ReactionType reactionType = ReactionType.valueOf(reactionName);
1434            UserIdentity actor = _currentUserProvider.getUser();
1435            
1436            boolean updated = remove ? unreact(content, actor, reactionType) : react(content, actor, reactionType);
1437            result.put("updated", updated);
1438            result.put("contentId", contentId);
1439            result.put("actors", _userHelper.userIdentities2json(((ReactionableObject) content).getReactionUsers(reactionType)));
1440        }
1441        else
1442        {
1443            result.put("unauthorized", true);
1444            result.put("updated", false);
1445        }
1446
1447        return result;
1448    }
1449    
1450    /**
1451     * Add a reaction on a {@link Content}.
1452     * @param content the content
1453     * @param userIdentity the issuer of reaction
1454     * @param reactionType the reaction type
1455     * @return true if a change was made
1456     */
1457    public boolean react(Content content, UserIdentity userIdentity, ReactionType reactionType)
1458    {
1459        return _addOrRemoveReaction(content, userIdentity, reactionType, false);
1460    }
1461    
1462    /**
1463     * Remove reaction if exists on a {@link Content}.
1464     * @param content the content
1465     * @param userIdentity the issuer of reaction
1466     * @param reactionType the reaction type
1467     * @return <code>true</code> if a change was made
1468     */
1469    public boolean unreact(Content content, UserIdentity userIdentity, ReactionType reactionType)
1470    {
1471        return _addOrRemoveReaction(content, userIdentity, reactionType, true);
1472    }
1473    
1474    /**
1475     * Add or remove reaction if exists on the given content.
1476     * @param content the content
1477     * @param userIdentity the issuer of reaction
1478     * @param reactionType the reaction type
1479     * @param remove <code>true</code> if it's to remove the reaction
1480     * @return <code>true</code> if a change was made
1481     */
1482    protected boolean _addOrRemoveReaction(Content content, UserIdentity userIdentity, ReactionType reactionType, boolean remove)
1483    {
1484        if (content instanceof ReactionableObject)
1485        {
1486            boolean hasChanges = false;
1487            
1488            List<UserIdentity> reactionIssuers = ((ReactionableObject) content).getReactionUsers(reactionType);
1489            if (!remove && !reactionIssuers.contains(userIdentity))
1490            {
1491                ((ReactionableObject) content).addReaction(userIdentity, reactionType);
1492                hasChanges = true;
1493            }
1494            else if (remove && reactionIssuers.contains(userIdentity))
1495            {
1496                ((ReactionableObject) content).removeReaction(userIdentity, reactionType);
1497                hasChanges = true;
1498            }
1499            
1500            if (hasChanges)
1501            {
1502                ((DefaultContent) content).saveChanges();
1503                
1504                Map<String, Object> eventParams = new HashMap<>();
1505                eventParams.put(ObservationConstants.ARGS_CONTENT, content);
1506                eventParams.put(ObservationConstants.ARGS_REACTION_TYPE, reactionType);
1507                eventParams.put(ObservationConstants.ARGS_REACTION_ISSUER, userIdentity);
1508                _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_REACTION_CHANGED, userIdentity, eventParams));
1509                
1510                return true;
1511            }
1512        }
1513        
1514        return false;
1515    }
1516    
1517    /**
1518     * Add a report on a content
1519     * @param content The content
1520     * @throws IllegalArgumentException if the content is not a {@link ReportableObject}
1521     * @throws AccessDeniedException if the current user has not read access on the given content
1522     */
1523    public void report(Content content) throws IllegalArgumentException, AccessDeniedException
1524    {
1525        if (_rightManager.currentUserHasReadAccess(content))
1526        {
1527            if (content instanceof ReportableObject)
1528            {
1529                ((ReportableObject) content).addReport();
1530                ((DefaultContent) content).saveChanges();
1531            }
1532            else
1533            {
1534                throw new IllegalArgumentException("Unable to report the content '" + content.getId() + "'. Current user is not authorized to see this content.");
1535            }
1536        }
1537        else
1538        {
1539            throw new AccessDeniedException("Unable to report the content '" + content.getId() + "'. Current user is not authorized to see this content.");
1540        }
1541    }
1542
1543    /**
1544     * Delete contents and force the deletion of invert relations, then log the result.
1545     * @param contents The contents to delete
1546     * @param deleteRightId The deletion right's id to check. Can be null to ignore rights
1547     * @param logger The logger
1548     * @return the number of deleted contents
1549     */
1550    public int forceDeleteContentsWithLog(List<Content> contents, String deleteRightId, Logger logger)
1551    {
1552        return _logResult(forceDeleteContentsObj(contents, deleteRightId), logger);
1553    }
1554
1555    @SuppressWarnings("unchecked")
1556    private int _logResult(Map<String, Object> result, Logger logger)
1557    {
1558        List<Map<String, Object>> referencedContents = (List<Map<String, Object>>) result.get(_CONTENT_DELETION_STATUS_REFERENCED + "-contents");
1559        if (referencedContents.size() > 0)
1560        {
1561            logger.info("The following contents cannot be deleted because they are referenced: {}", referencedContents.stream().map(m -> m.get("id")).collect(Collectors.toList()));
1562        }
1563        
1564        List<Map<String, Object>> lockedContents = (List<Map<String, Object>>) result.get(_CONTENT_DELETION_STATUS_LOCKED + "-contents");
1565        if (lockedContents.size() > 0)
1566        {
1567            logger.info("The following contents cannot be deleted because they are locked: {}", lockedContents.stream().map(m -> m.get("id")).collect(Collectors.toList()));
1568        }
1569        
1570        List<Map<String, Object>> unauthorizedContents = (List<Map<String, Object>>) result.get(_CONTENT_DELETION_STATUS_UNAUTHORIZED + "-contents");
1571        if (unauthorizedContents.size() > 0)
1572        {
1573            logger.info("The following contents cannot be deleted because they are no authorization: {}", unauthorizedContents.stream().map(m -> m.get("id")).collect(Collectors.toList()));
1574        }
1575        
1576        List<Map<String, Object>> undeletedContents = (List<Map<String, Object>>) result.get(_CONTENT_DELETION_STATUS_UNDELETED + "-contents");
1577        if (undeletedContents.size() > 0)
1578        {
1579            logger.info("{} contents were not deleted. See previous logs for more information.", undeletedContents.size());
1580        }
1581
1582        List<Map<String, Object>> deletedContents = (List<Map<String, Object>>) result.get(_CONTENT_DELETION_STATUS_DELETED + "-contents");
1583        return deletedContents.size();
1584    }
1585}