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