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
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
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 = StringUtils.defaultString(_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
558    public Map<String, Object> getContentsProperties (List<String> contentIds, String workspaceName)
559    {
560        Map<String, Object> result = new HashMap<>();
561        
562        List<Map<String, Object>> contents = new ArrayList<>();
563        List<String> contentsNotFound = new ArrayList<>();
564        
565        Request request = ContextHelper.getRequest(_context);
566        String currentWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
567        try
568        {
569            if (StringUtils.isNotEmpty(workspaceName))
570            {
571                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, workspaceName);
572            }
573            
574            for (String contentId : contentIds)
575            {
576                try
577                {
578                    Content content = _resolver.resolveById(contentId);
579                    contents.add(getContentProperties(content));
580                }
581                catch (UnknownAmetysObjectException e)
582                {
583                    contentsNotFound.add(contentId);
584                }
585            }
586            
587            result.put("contents", contents);
588            result.put("contentsNotFound", contentsNotFound);
589        }
590        finally
591        {
592            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWorkspace);
593        }
594        
595        return result;
596    }
597    
598    /**
599     * Get the content properties
600     * @param contentId The id of content
601     * @param workspaceName The workspace name. Can be null to get content in current workspace.
602     * @return The content's properties
603     */
604    @Callable
605    public Map<String, Object> getContentProperties (String contentId, String workspaceName)
606    {
607        Request request = ContextHelper.getRequest(_context);
608        String currentWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
609        try
610        {
611            if (StringUtils.isNotEmpty(workspaceName))
612            {
613                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, workspaceName);
614            }
615            
616            Content content = _resolver.resolveById(contentId);
617            return getContentProperties(content);
618        }
619        finally
620        {
621            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWorkspace);
622        }
623    }
624    
625    /**
626     * Get the content properties
627     * @param content The content
628     * @return The content properties
629     */
630    public Map<String, Object> getContentProperties (Content content)
631    {
632        Map<String, Object> infos = new HashMap<>();
633        
634        infos.put("id", content.getId());
635        infos.put("name", content.getName());
636        infos.put("title", _contentHelper.getTitle(content));
637        
638        if (ModelItemTypeConstants.MULTILINGUAL_STRING_ELEMENT_TYPE_ID.equals(content.getType(Content.ATTRIBUTE_TITLE).getId()))
639        {
640            infos.put("titleVariants", _contentHelper.getTitleVariants(content));
641        }
642        infos.put("path", content.getPath());
643        infos.put("types", content.getTypes());
644        infos.put("mixins", content.getMixinTypes());
645        String lang = content.getLanguage();
646        if (lang != null)
647        {
648            infos.put("lang", lang);
649        }
650        infos.put("creator", _userHelper.user2json(content.getCreator()));
651        infos.put("lastContributor", _userHelper.user2json(content.getLastContributor()));
652        infos.put("creationDate", DateUtils.zonedDateTimeToString(content.getCreationDate()));
653        infos.put("lastModified", DateUtils.zonedDateTimeToString(content.getLastModified()));
654        infos.put("isSimple", _contentHelper.isSimple(content));
655        infos.put("isReferenceTable", _contentHelper.isReferenceTable(content));
656        infos.put("parent", _hierarchicalSimpleContentsHelper.getParent(content));
657        
658        if (content instanceof WorkflowAwareContent)
659        {
660            WorkflowAwareContent waContent = (WorkflowAwareContent) content;
661            AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(waContent);
662            
663            infos.put("workflowName", workflow.getWorkflowName(waContent.getWorkflowId()));
664            
665            List<Integer> workflowSteps = new ArrayList<>();
666            
667            List<Step> currentSteps = workflow.getCurrentSteps(waContent.getWorkflowId());
668            for (Step step : currentSteps)
669            {
670                workflowSteps.add(step.getStepId());
671            }
672            infos.put("workflowSteps", workflowSteps);
673            
674            int[] availableActions = _contentWorkflowHelper.getAvailableActions(waContent);
675            infos.put("availableActions", availableActions);
676        }
677        
678        if (content instanceof ModifiableContent)
679        {
680            infos.put("isModifiable", true);
681        }
682        
683        if (content instanceof LockAwareAmetysObject)
684        {
685            LockAwareAmetysObject lockableContent = (LockAwareAmetysObject) content;
686            if (lockableContent.isLocked())
687            {
688                infos.put("locked", true);
689                infos.put("lockOwner", _userHelper.user2json(lockableContent.getLockOwner()));
690                infos.put("canUnlock", _lockManager.canUnlock(lockableContent));
691            }
692        }
693        
694        infos.put("rights", getUserRights(content));
695        
696        Map<String, Object> additionalData = new HashMap<>();
697        
698        String[] contenttypes = content.getTypes();
699        for (String cTypeId : contenttypes)
700        {
701            ContentType cType = _contentTypeEP.getExtension(cTypeId);
702            if (cType != null)
703            {
704                additionalData.putAll(cType.getAdditionalData(content));
705            }
706        }
707        
708        if (!additionalData.isEmpty())
709        {
710            infos.put("additionalData", additionalData);
711        }
712
713        infos.put("isTaggable", content instanceof TaggableAmetysObject);
714        
715        if (content instanceof ModifiableDataAwareVersionableAmetysObject)
716        {
717            ModifiableModelLessDataHolder unversionedDataHolder = ((ModifiableDataAwareVersionableAmetysObject) content).getUnversionedDataHolder();
718            if (unversionedDataHolder.hasValue(ArchiveConstants.META_ARCHIVE_SCHEDULED_DATE))
719            {
720                ZonedDateTime scheduledDate = unversionedDataHolder.getValue(ArchiveConstants.META_ARCHIVE_SCHEDULED_DATE);
721                infos.put("scheduledArchivingDate", DateUtils.zonedDateTimeToString(scheduledDate));
722            }
723        }
724        
725        if (content instanceof ReportableObject)
726        {
727            infos.put("reportsCount", ((ReportableObject) content).getReportsCount());
728        }
729
730        return infos;
731    }
732    
733    /**
734     * Get the content's properties for description
735     * @param contentId The id of content
736     * @param workspaceName The workspace name. Can be null to get content in current workspace.
737     * @return The content's properties for description
738     */
739    @Callable
740    public Map<String, Object> getContentDescription (String contentId, String workspaceName)
741    {
742        Request request = ContextHelper.getRequest(_context);
743        String currentWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
744        try
745        {
746            if (StringUtils.isNotEmpty(workspaceName))
747            {
748                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, workspaceName);
749            }
750            
751            Content content = _resolver.resolveById(contentId);
752            return getContentDescription(content);
753        }
754        finally
755        {
756            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWorkspace);
757        }
758    }
759    
760    /**
761     *Get the content's properties for description
762     * @param content The content
763     * @return The content's properties for description
764     */
765    public Map<String, Object> getContentDescription (Content content)
766    {
767        Map<String, Object> infos = new HashMap<>();
768        
769        infos.put("id", content.getId());
770        infos.put("name", content.getName());
771        infos.put("title", _contentHelper.getTitle(content));
772        infos.put("types", content.getTypes());
773        infos.put("mixins", content.getMixinTypes());
774        infos.put("lang", content.getLanguage());
775        infos.put("creator", _userHelper.user2json(content.getCreator()));
776        infos.put("lastContributor", _userHelper.user2json(content.getLastContributor()));
777        infos.put("lastModified", DateUtils.zonedDateTimeToString(content.getLastModified()));
778        infos.put("iconGlyph", _cTypesHelper.getIconGlyph(content));
779        infos.put("iconDecorator", _cTypesHelper.getIconDecorator(content));
780        infos.put("smallIcon", _cTypesHelper.getSmallIcon(content));
781        infos.put("mediumIcon", _cTypesHelper.getMediumIcon(content));
782        infos.put("largeIcon", _cTypesHelper.getLargeIcon(content));
783        
784        return infos;
785    }
786    
787    /**
788     * Get the views of a content plus a view of all the content's data
789     * @param contentId the content's id
790     * @param includeInternal Set to true to include internal views.
791     * @return the views
792     */
793    @Callable
794    public List<Map<String, Object>> getContentViewsAndAllData(String contentId, boolean includeInternal)
795    {
796        List<Map<String, Object>> views = getContentViews(contentId, includeInternal);
797        views.add(_getAllDataView());
798        return views;
799    }
800
801    private Map<String, Object> _getAllDataView()
802    {
803        Map<String, Object> viewInfos = new HashMap<>();
804        viewInfos.put("name", ContentTypesHelper.ALL_DATA);
805        viewInfos.put("label", new I18nizableText("plugin.cms", "PLUGINS_CMS_VIEW_ALL_DATA"));
806        viewInfos.put("description", new I18nizableText("plugin.cms", "PLUGINS_CMS_VIEW_ALL_DATA_DESC"));
807        return viewInfos;
808    }
809    
810    /**
811     * Get the views of a content
812     * @param contentId the content's id
813     * @param includeInternal Set to true to include internal views.
814     * @return the views
815     */
816    @Callable
817    public List<Map<String, Object>> getContentViews(String contentId, boolean includeInternal)
818    {
819        List<Map<String, Object>> views = new ArrayList<>();
820        
821        Content content = _resolver.resolveById(contentId);
822        String contentTypeId = _cTypesHelper.getContentTypeIdForRendering(content);
823        
824        ContentType cType = _contentTypeEP.getExtension(contentTypeId);
825        
826        Set<String> viewNames = cType.getViewNames(includeInternal);
827        for (String viewName : viewNames)
828        {
829            View view = cType.getView(viewName);
830            
831            Map<String, Object> viewInfos = new HashMap<>();
832            viewInfos.put("name", viewName);
833            viewInfos.put("label", view.getLabel());
834            viewInfos.put("description", view.getDescription());
835            views.add(viewInfos);
836        }
837        
838        return views;
839    }
840    
841    /**
842     * Get the user rights on content
843     * @param content The content
844     * @return The user's rights
845     */
846    protected Set<String> getUserRights (Content content)
847    {
848        UserIdentity user = _currentUserProvider.getUser();
849        return _rightManager.getUserRights(user, content);
850    }
851    
852    /**
853     * Get the tags of contents
854     * @param contentIds The content's ids
855     * @return the tags
856     */
857    @Callable(rights = Callable.SKIP_BUILTIN_CHECK)
858    public Set<String> getTags (List<String> contentIds)
859    {
860        Set<String> tags = new HashSet<>();
861        
862        for (String contentId : contentIds)
863        {
864            Content content = _resolver.resolveById(contentId);
865            if (_rightManager.currentUserHasReadAccess(content))
866            {
867                tags.addAll(content.getTags());
868            }
869        }
870        
871        return tags;
872    }
873    
874    /**
875     * Tag a list of contents with the given tags
876     * @param contentIds The ids of contents to tag
877     * @param tagNames The tags
878     * @param contextualParameters The contextual parameters
879     * @return the result
880     */
881    @Callable(rights = Callable.SKIP_BUILTIN_CHECK)
882    public Map<String, Object> tag (List<String> contentIds, List<String> tagNames, Map<String, Object> contextualParameters)
883    {
884        return tag(contentIds, tagNames, TagMode.REPLACE, contextualParameters, false);
885    }
886    
887    /**
888     * Tag a list of contents
889     * @param contentIds The ids of contents to tag
890     * @param tagNames The tags
891     * @param mode The mode for updating tags: 'REPLACE' to replace tags, 'INSERT' to add tags or 'REMOVE' to remove tags.
892     * @param contextualParameters The contextual parameters
893     * @return the result
894     */
895    @Callable(rights = Callable.SKIP_BUILTIN_CHECK)
896    public Map<String, Object> tag (List<String> contentIds, List<String> tagNames, String mode, Map<String, Object> contextualParameters)
897    {
898        return tag(contentIds, tagNames, TagMode.valueOf(mode), contextualParameters, false);
899    }
900    
901    /**
902     * Tag a list of contents
903     * @param contentIds The ids of contents to tag
904     * @param tagNames The tags
905     * @param mode The mode for updating tags: 'REPLACE' to replace tags, 'INSERT' to add tags or 'REMOVE' to remove tags.
906     * @param contextualParameters The contextual parameters
907     * @param ignoreRights <code>true</code> to ignore the rights on tag
908     * @return the result
909     */
910    public Map<String, Object> tag (List<String> contentIds, List<String> tagNames, TagMode mode, Map<String, Object> contextualParameters, boolean ignoreRights)
911    {
912        Map<String, Object> result = new HashMap<>();
913        
914        result.put("notaggable-contents", new ArrayList<>());
915        result.put("invalid-tags", new ArrayList<>());
916        result.put("allright-contents", new ArrayList<>());
917        result.put("locked-contents", new ArrayList<>());
918        result.put("noright-contents", new ArrayList<>());
919        
920        for (String contentId : contentIds)
921        {
922            Content content = _resolver.resolveById(contentId);
923            
924            Map<String, Object> content2json = new HashMap<>();
925            content2json.put("id", content.getId());
926            content2json.put("title", _contentHelper.getTitle(content));
927
928            if (!ignoreRights && !_hasTagRights(content, tagNames, mode, contextualParameters))
929            {
930                @SuppressWarnings("unchecked")
931                List<Map<String, Object>> noRightContents = (List<Map<String, Object>>) result.get("noright-contents");
932                noRightContents.add(content2json);
933            }
934            else if (content instanceof TaggableAmetysObject)
935            {
936                TaggableAmetysObject mContent = (TaggableAmetysObject) content;
937                
938                boolean wasLocked = false;
939                
940                if (content instanceof LockableAmetysObject)
941                {
942                    LockableAmetysObject lockableContent = (LockableAmetysObject) content;
943                    UserIdentity user = _currentUserProvider.getUser();
944                    if (lockableContent.isLocked() && !LockHelper.isLockOwner(lockableContent, user))
945                    {
946                        @SuppressWarnings("unchecked")
947                        List<Map<String, Object>> lockedContents = (List<Map<String, Object>>) result.get("locked-contents");
948                        content2json.put("lockOwner", lockableContent.getLockOwner());
949                        lockedContents.add(content2json);
950                        
951                        // Stop process
952                        continue;
953                    }
954                    
955                    if (lockableContent.isLocked())
956                    {
957                        wasLocked = true;
958                        lockableContent.unlock();
959                    }
960                }
961                
962                Set<String> oldTags = mContent.getTags();
963                _removeAllTagsInReplaceMode(mContent, mode, oldTags);
964                
965                // Then set new tags
966                for (String tagName : tagNames)
967                {
968                    if (_isTagValid(tagName, contextualParameters))
969                    {
970                        if (TagMode.REMOVE.equals(mode))
971                        {
972                            mContent.untag(tagName);
973                        }
974                        else if (TagMode.REPLACE.equals(mode) || !oldTags.contains(tagName))
975                        {
976                            mContent.tag(tagName);
977                        }
978                        
979                    }
980                    else
981                    {
982                        @SuppressWarnings("unchecked")
983                        List<String> invalidTags = (List<String>) result.get("invalid-tags");
984                        invalidTags.add(tagName);
985                    }
986                }
987                
988                ((ModifiableAmetysObject) content).saveChanges();
989                
990                if (wasLocked)
991                {
992                    // Relock content if it was locked before tagging
993                    ((LockableAmetysObject) content).lock();
994                }
995                
996                content2json.put("tags", content.getTags());
997                @SuppressWarnings("unchecked")
998                List<Map<String, Object>> allRightPages = (List<Map<String, Object>>) result.get("allright-contents");
999                allRightPages.add(content2json);
1000                
1001                if (!oldTags.equals(content.getTags()))
1002                {
1003                    // Notify observers that the content has been tagged
1004                    Map<String, Object> eventParams = new HashMap<>();
1005                    eventParams.put(org.ametys.cms.ObservationConstants.ARGS_CONTENT, content);
1006                    eventParams.put(org.ametys.cms.ObservationConstants.ARGS_CONTENT_ID, content.getId());
1007                    eventParams.put("content.tags", content.getTags());
1008                    eventParams.put("content.old.tags", oldTags);
1009                    _observationManager.notify(new Event(org.ametys.cms.ObservationConstants.EVENT_CONTENT_TAGGED, _currentUserProvider.getUser(), eventParams));
1010                }
1011            }
1012            else
1013            {
1014                @SuppressWarnings("unchecked")
1015                List<Map<String, Object>> notaggableContents = (List<Map<String, Object>>) result.get("notaggable-contents");
1016                notaggableContents.add(content2json);
1017            }
1018        }
1019        
1020        return result;
1021    }
1022
1023    private boolean _hasTagRights(Content content, List<String> tagNames, TagMode mode, Map<String, Object> contextualParameters)
1024    {
1025        List<CMSTag> tags = tagNames.stream()
1026            .map(t -> _tagProvider.getTag(t, contextualParameters))
1027            .filter(Objects::nonNull)
1028            .toList();
1029        
1030        // In case of replace, only check the right on tag that are modified, ie added or removed tags
1031        if (TagMode.REPLACE.equals(mode))
1032        {
1033            List<CMSTag> existingTags = content.getTags().stream()
1034                    .map(t -> _tagProvider.getTag(t, contextualParameters))
1035                    .filter(Objects::nonNull)
1036                    .toList();
1037            
1038            tags = new ArrayList<>(CollectionUtils.disjunction(tags, existingTags));
1039        }
1040        
1041        boolean hasPublicTagRight = _rightManager.currentUserHasRight("CMS_Rights_Content_Tag", content) == RightResult.RIGHT_ALLOW;
1042        boolean hasPrivateTagRight = _rightManager.currentUserHasRight("CMS_Rights_Content_Private_Tag", content) == RightResult.RIGHT_ALLOW;
1043        
1044        // Test if the current user has the right to tag public tag on content only if there are at least one public tag
1045        boolean hasRight = TagHelper.filterTags(tags, TagVisibility.PUBLIC, "CONTENT").isEmpty() || hasPublicTagRight || hasPrivateTagRight;
1046        
1047        // Test if the current user has the right to tag private tag on content only if there are at least one private tag
1048        return hasRight && (TagHelper.filterTags(tags, TagVisibility.PRIVATE, "CONTENT").isEmpty() || hasPrivateTagRight);
1049    }
1050
1051    /**
1052     * Remove all tags from the given content if tagMode is equals to REPLACE.
1053     * @param mContent The content
1054     * @param tagMode The tag
1055     * @param oldTags Tags to remove
1056     */
1057    protected void _removeAllTagsInReplaceMode(TaggableAmetysObject mContent, TagMode tagMode, Set<String> oldTags)
1058    {
1059        if (TagMode.REPLACE.equals(tagMode))
1060        {
1061            // First delete old tags
1062            for (String tagName : oldTags)
1063            {
1064                mContent.untag(tagName);
1065            }
1066        }
1067    }
1068    
1069    /**
1070     * Is the tag a content tag
1071     * @param tagName The tag name
1072     * @param contextualParameters The contextual parameters
1073     * @return true if the tag is a valid content tag
1074     */
1075    protected boolean _isTagValid (String tagName, Map<String, Object> contextualParameters)
1076    {
1077        CMSTag tag = _tagProvider.getTag(tagName, contextualParameters);
1078        return tag != null && tag.getTarget().getName().equals("CONTENT");
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 initWorkflowActionId The initial workflow action id
1087     * @param context The context of the data to copy
1088     * @return the copied content.
1089     * @throws AmetysRepositoryException If an error occured
1090     */
1091    public ModifiableContent copy(DefaultContent originalContent, ModifiableTraversableAmetysObject parent, String name, int initWorkflowActionId, DataContext context) throws AmetysRepositoryException
1092    {
1093        return copy(originalContent, parent, name, null, initWorkflowActionId, context);
1094    }
1095    
1096    /**
1097     * Copy a content.
1098     * @param originalContent the original content.
1099     * @param parent the object in which to create a content.
1100     * @param name the content name.
1101     * @param lang the content language. If null, the content language will be the same of the original content
1102     * @param initWorkflowActionId The initial workflow action id
1103     * @param context The context of the data to copy
1104     * @return the copied content.
1105     * @throws AmetysRepositoryException If an error occured
1106     */
1107    public ModifiableContent copy(DefaultContent originalContent, ModifiableTraversableAmetysObject parent, String name, String lang, int initWorkflowActionId, DataContext context) throws AmetysRepositoryException
1108    {
1109        return copy(originalContent, parent, name, lang, initWorkflowActionId, true, true, false, false, context);
1110    }
1111    
1112    /**
1113     * Copy a content.
1114     * @param originalContent the original content.
1115     * @param parent the object in which to create a content.
1116     * @param name the content name.
1117     * @param lang the content language. If null, the content language will be the same of the original content
1118     * @param initWorkflowActionId The initial workflow action id
1119     * @param notifyObservers Set to false to do not fire observer events
1120     * @param checkpoint true to check the content in if it is versionable
1121     * @param waitAsyncObservers true to wait for asynchronous observers to complete
1122     * @param copyACL true to copy ACL of source content
1123     * @param context The context of the data to copy
1124     * @return the copied content.
1125     * @throws AmetysRepositoryException If an error occured
1126     */
1127    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
1128    {
1129        try
1130        {
1131            String originalName = name == null ? originalContent.getName() : name;
1132            String contentName = originalName;
1133            int index = 2;
1134            while (parent.hasChild(contentName))
1135            {
1136                contentName = originalName + "-" + (index++);
1137            }
1138            
1139            String originalContentType = originalContent.getNode().getPrimaryNodeType().getName();
1140            
1141            ModifiableContent content = parent.createChild(contentName, originalContentType);
1142            
1143            String targetLanguage = lang == null ? originalContent.getLanguage() : lang;
1144            if (targetLanguage != null)
1145            {
1146                content.setLanguage(targetLanguage);
1147            }
1148            
1149            content.setTypes(originalContent.getTypes());
1150            
1151            _modifiableContentHelper.copyTitle(originalContent, content);
1152            
1153            if (originalContent instanceof WorkflowAwareContent)
1154            {
1155                WorkflowAwareContent waOriginalContent = (WorkflowAwareContent) originalContent;
1156                AmetysObjectWorkflow originalContentWorkflow = _workflowProvider.getAmetysObjectWorkflow(waOriginalContent);
1157                String workflowName = originalContentWorkflow.getWorkflowName(waOriginalContent.getWorkflowId());
1158                
1159                // Initialize new content workflow
1160                WorkflowAwareContent waContent = (WorkflowAwareContent) content;
1161                AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(waContent);
1162                
1163                HashMap<String, Object> inputs = new HashMap<>();
1164                // Provide the content key
1165                inputs.put(AbstractWorkflowComponent.FAIL_CONDITIONS_KEY, new ArrayList<>());
1166                inputs.put(AbstractContentWorkflowComponent.CONTENT_KEY, waContent);
1167                
1168                long workflowId = workflow.initialize(workflowName, initWorkflowActionId, inputs);
1169                
1170                // Remove workflow id if exists before updating it
1171                WorkflowAwareContentHelper.removeWorkflowId(waContent);
1172                waContent.setWorkflowId(workflowId);
1173                
1174                // Set the current step ID property
1175                Step currentStep = (Step) workflow.getCurrentSteps(workflowId).iterator().next();
1176                waContent.setCurrentStepId(currentStep.getStepId());
1177                
1178                Node workflowEntryNode = null;
1179                Node node = waContent.getNode();
1180                Session session = node.getSession();
1181                
1182                
1183                try
1184                {
1185                    AbstractJackrabbitWorkflowStore workflowStore = (AbstractJackrabbitWorkflowStore) workflow.getConfiguration().getWorkflowStore();
1186                    
1187                    if (workflowStore instanceof AmetysObjectWorkflowStore)
1188                    {
1189                        AmetysObjectWorkflowStore ametysObjectWorkflowStore = (AmetysObjectWorkflowStore) workflowStore;
1190                        ametysObjectWorkflowStore.bindAmetysObject(waContent);
1191                    }
1192                    
1193                    workflowEntryNode = workflowStore.getEntryNode(session, workflowId);
1194                    workflowEntryNode.setProperty("ametys-internal:initialActionId", initWorkflowActionId);
1195                }
1196                catch (RepositoryException e)
1197                {
1198                    throw new AmetysRepositoryException("Unable to link the workflow to the content", e);
1199                }
1200            }
1201            
1202            // Copy attributes
1203            originalContent.copyTo(content, context);
1204            
1205            // Copy attachments
1206            _copyAttachments(originalContent, content);
1207            
1208            if (copyACL)
1209            {
1210                _copyACL(originalContent, content);
1211            }
1212            
1213            UserIdentity currentUser = _currentUserProvider.getUser();
1214            if (currentUser != null)
1215            {
1216                content.setCreator(currentUser);
1217                content.setLastContributor(currentUser);
1218                content.setLastModified(ZonedDateTime.now());
1219                content.setCreationDate(ZonedDateTime.now());
1220            }
1221            
1222            parent.saveChanges();
1223            
1224            // Create a new version
1225            if (checkpoint && content instanceof VersionableAmetysObject versionableContent)
1226            {
1227                versionableContent.checkpoint();
1228            }
1229
1230            if (notifyObservers)
1231            {
1232                notifyContentCopied(content, waitAsyncObservers);
1233            }
1234            
1235            return content;
1236        }
1237        catch (WorkflowException e)
1238        {
1239            throw new AmetysRepositoryException(e);
1240        }
1241        catch (RepositoryException e)
1242        {
1243            throw new AmetysRepositoryException(e);
1244        }
1245    }
1246    
1247    /**
1248     * Copy the attachments of a content
1249     * @param srcContent The source content
1250     * @param targetContent The target content
1251     */
1252    protected void _copyAttachments(Content srcContent, Content targetContent)
1253    {
1254        ResourceCollection srcRootAttachments = srcContent.getRootAttachments();
1255        if (srcRootAttachments == null)
1256        {
1257            // There are no attachments to copy
1258            return;
1259        }
1260        
1261        ResourceCollection targetRootAttachments = targetContent.getRootAttachments();
1262        if (targetRootAttachments == null)
1263        {
1264            // The target is an (unmodifiable) old version and the attachments root is missing
1265            return;
1266        }
1267        
1268        AmetysObjectIterable<AmetysObject> children = srcRootAttachments.getChildren();
1269        for (AmetysObject child : children)
1270        {
1271            if (child instanceof CopiableAmetysObject)
1272            {
1273                try
1274                {
1275                    ((CopiableAmetysObject) child).copyTo((ModifiableTraversableAmetysObject) targetRootAttachments, child.getName());
1276                }
1277                catch (AmetysRepositoryException e)
1278                {
1279                    getLogger().error("Failed to copy attachments at path " + child.getPath() + " from content " + srcContent + " to content " + targetContent, e);
1280                }
1281            }
1282        }
1283    }
1284        
1285    /**
1286     * Copy the ACL of a content
1287     * @param srcContent The source content
1288     * @param targetContent The target content
1289     */
1290    protected void _copyACL(Content srcContent, Content targetContent)
1291    {
1292        if (srcContent instanceof DefaultContent && targetContent instanceof DefaultContent)
1293        {
1294            Node srcNode = ((DefaultContent) srcContent).getNode();
1295            Node targetNode = ((DefaultContent) targetContent).getNode();
1296            
1297            try
1298            {
1299                String aclNodeName = "ametys-internal:acl";
1300                if (srcNode.hasNode(aclNodeName))
1301                {
1302                    Node aclNode = srcNode.getNode(aclNodeName);
1303                    aclNode.getSession().getWorkspace().copy(aclNode.getPath(), targetNode.getPath() + "/" + aclNodeName);
1304                }
1305            }
1306            catch (RepositoryException e)
1307            {
1308                getLogger().error("Failed to copy ACL from content " + srcContent + " to content " + targetContent, e);
1309            }
1310        }
1311    }
1312    
1313    /**
1314     * Notify observers that the content has been created
1315     * @param content The content added
1316     * @param waitAsyncObservers true to wait for asynchonous observers to finish
1317     * @throws WorkflowException If an error occurred
1318     */
1319    public void notifyContentCopied(Content content, boolean waitAsyncObservers) throws WorkflowException
1320    {
1321        Map<String, Object> eventParams = new HashMap<>();
1322        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
1323        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
1324
1325        List<Future> futures = _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_ADDED, _currentUserProvider.getUser(), eventParams));
1326        futures.addAll(_observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_WORKFLOW_CHANGED, _currentUserProvider.getUser(), eventParams)));
1327        
1328        if (waitAsyncObservers)
1329        {
1330            // Wait for asynchonous observers to finish
1331            for (Future future : futures)
1332            {
1333                try
1334                {
1335                    future.get();
1336                }
1337                catch (ExecutionException | InterruptedException e)
1338                {
1339                    getLogger().error(String.format("Error while waiting for async observer to complete"));
1340                }
1341            }
1342        }
1343    }
1344    
1345    /**
1346     * Returns the content's attachments root node
1347     * @param id the content's id
1348     * @return The attachments' root node informations
1349     */
1350    @Callable
1351    public Map<String, Object> getAttachmentsRootNode (String id)
1352    {
1353        Map<String, Object> result = new HashMap<>();
1354        
1355        Content content = _resolver.resolveById(id);
1356        
1357        result.put("title", _contentHelper.getTitle(content));
1358        result.put("contentId", content.getId());
1359        
1360        TraversableAmetysObject attachments = content.getRootAttachments();
1361        
1362        if (attachments != null)
1363        {
1364            result.put("id", attachments.getId());
1365            if (attachments instanceof ModifiableAmetysObject)
1366            {
1367                result.put("isModifiable", true);
1368            }
1369            if (attachments instanceof ModifiableResourceCollection)
1370            {
1371                result.put("canCreateChild", true);
1372            }
1373            
1374            boolean hasChildNodes = false;
1375            boolean hasResources = false;
1376
1377            for (AmetysObject child : attachments.getChildren())
1378            {
1379                if (child instanceof Resource)
1380                {
1381                    hasResources = true;
1382                }
1383                else if (child instanceof ExplorerNode)
1384                {
1385                    hasChildNodes = true;
1386                }
1387            }
1388
1389            if (hasChildNodes)
1390            {
1391                result.put("hasChildNodes", true);
1392            }
1393
1394            if (hasResources)
1395            {
1396                result.put("hasResources", true);
1397            }
1398            
1399            return result;
1400        }
1401        
1402        throw new IllegalArgumentException("Content with id '" + id + "' does not support attachments.");
1403    }
1404    
1405    /**
1406     * Determines if the current user has right to delete the content
1407     * @param content The content
1408     * @return true if current user is authorized to delete the content
1409     */
1410    public boolean canDelete(Content content)
1411    {
1412        return canDelete(content, getRightToDelete());
1413    }
1414    
1415    /**
1416     * Determines if the current user has right to delete the content
1417     * @param content The content
1418     * @param deleteRightId The right's id to check for deletion
1419     * @return true if current user is authorized to delete the content
1420     */
1421    public boolean canDelete(Content content, String deleteRightId)
1422    {
1423        UserIdentity user = _currentUserProvider.getUser();
1424        if (_rightManager.hasRight(user, deleteRightId, content) == RightResult.RIGHT_ALLOW)
1425        {
1426            return true;
1427        }
1428        
1429        return false;
1430    }
1431    
1432    /**
1433     * Add or remove a reaction on a content
1434     * @param contentId The content id
1435     * @param reactionName the reaction name (ex: LIKE)
1436     * @param remove true to remove the reaction, false to add reaction
1437     * @return the result with the current actors of this reaction
1438     */
1439    @Callable
1440    public Map<String, Object> react(String contentId, String reactionName, boolean remove)
1441    {
1442        Map<String, Object> result = new HashMap<>();
1443        
1444        Content content = _resolver.resolveById(contentId);
1445        
1446        if (_rightManager.currentUserHasReadAccess(content))
1447        {
1448            ReactionType reactionType = ReactionType.valueOf(reactionName);
1449            UserIdentity actor = _currentUserProvider.getUser();
1450            
1451            boolean updated = remove ? unreact(content, actor, reactionType) : react(content, actor, reactionType);
1452            result.put("updated", updated);
1453            result.put("contentId", contentId);
1454            result.put("actors", _userHelper.userIdentities2json(((ReactionableObject) content).getReactionUsers(reactionType)));
1455        }
1456        else
1457        {
1458            result.put("unauthorized", true);
1459            result.put("updated", false);
1460        }
1461
1462        return result;
1463    }
1464    
1465    /**
1466     * Get the list of users who react to content
1467     * @param contentId The content id
1468     * @param reactionName the reaction name (ex: LIKE)
1469     * @return the list of users
1470     */
1471    @Callable (rights = Callable.READ_ACCESS, rightContext = ContentRightAssignmentContext.ID, paramIndex = 0)
1472    public List<Map<String, Object>> getReactionUsers(String contentId, String reactionName)
1473    {
1474        Content content = _resolver.resolveById(contentId);
1475        if (content instanceof ReactionableObject reactionableContent)
1476        {
1477            ReactionType reactionType = ReactionType.valueOf(reactionName);
1478            List<UserIdentity> reactionUsers = reactionableContent.getReactionUsers(reactionType);
1479            
1480            return reactionUsers.stream().map(_userHelper::user2json).toList();
1481        }
1482        
1483        return List.of();
1484    }
1485    
1486    /**
1487     * Add a reaction on a {@link Content}.
1488     * @param content the content
1489     * @param userIdentity the issuer of reaction
1490     * @param reactionType the reaction type
1491     * @return true if a change was made
1492     */
1493    public boolean react(Content content, UserIdentity userIdentity, ReactionType reactionType)
1494    {
1495        return _addOrRemoveReaction(content, userIdentity, reactionType, false);
1496    }
1497    
1498    /**
1499     * Remove reaction if exists on a {@link Content}.
1500     * @param content the content
1501     * @param userIdentity the issuer of reaction
1502     * @param reactionType the reaction type
1503     * @return <code>true</code> if a change was made
1504     */
1505    public boolean unreact(Content content, UserIdentity userIdentity, ReactionType reactionType)
1506    {
1507        return _addOrRemoveReaction(content, userIdentity, reactionType, true);
1508    }
1509    
1510    /**
1511     * Add or remove reaction if exists on the given content.
1512     * @param content the content
1513     * @param userIdentity the issuer of reaction
1514     * @param reactionType the reaction type
1515     * @param remove <code>true</code> if it's to remove the reaction
1516     * @return <code>true</code> if a change was made
1517     */
1518    protected boolean _addOrRemoveReaction(Content content, UserIdentity userIdentity, ReactionType reactionType, boolean remove)
1519    {
1520        if (content instanceof ReactionableObject)
1521        {
1522            boolean hasChanges = false;
1523            
1524            List<UserIdentity> reactionIssuers = ((ReactionableObject) content).getReactionUsers(reactionType);
1525            if (!remove && !reactionIssuers.contains(userIdentity))
1526            {
1527                ((ReactionableObject) content).addReaction(userIdentity, reactionType);
1528                hasChanges = true;
1529            }
1530            else if (remove && reactionIssuers.contains(userIdentity))
1531            {
1532                ((ReactionableObject) content).removeReaction(userIdentity, reactionType);
1533                hasChanges = true;
1534            }
1535            
1536            if (hasChanges)
1537            {
1538                ((DefaultContent) content).saveChanges();
1539                
1540                Map<String, Object> eventParams = new HashMap<>();
1541                eventParams.put(ObservationConstants.ARGS_CONTENT, content);
1542                eventParams.put(ObservationConstants.ARGS_REACTION_TYPE, reactionType);
1543                eventParams.put(ObservationConstants.ARGS_REACTION_ISSUER, userIdentity);
1544                _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_REACTION_CHANGED, userIdentity, eventParams));
1545                
1546                return true;
1547            }
1548        }
1549        
1550        return false;
1551    }
1552    
1553    /**
1554     * Add a report on a content
1555     * @param content The content
1556     * @throws IllegalArgumentException if the content is not a {@link ReportableObject}
1557     * @throws AccessDeniedException if the current user has not read access on the given content
1558     */
1559    public void report(Content content) throws IllegalArgumentException, AccessDeniedException
1560    {
1561        if (_rightManager.currentUserHasReadAccess(content))
1562        {
1563            if (content instanceof ReportableObject)
1564            {
1565                ((ReportableObject) content).addReport();
1566                ((DefaultContent) content).saveChanges();
1567            }
1568            else
1569            {
1570                throw new IllegalArgumentException("Unable to report the content '" + content.getId() + "'. Current user is not authorized to see this content.");
1571            }
1572        }
1573        else
1574        {
1575            throw new AccessDeniedException("Unable to report the content '" + content.getId() + "'. Current user is not authorized to see this content.");
1576        }
1577    }
1578
1579    /**
1580     * Delete contents and force the deletion of invert relations, then log the result.
1581     * @param contents The contents to delete
1582     * @param deleteRightId The deletion right's id to check. Can be null to ignore rights
1583     * @param logger The logger
1584     * @return the number of deleted contents
1585     */
1586    public int forceDeleteContentsWithLog(List<Content> contents, String deleteRightId, Logger logger)
1587    {
1588        return _logResult(forceDeleteContentsObj(contents, deleteRightId), logger);
1589    }
1590
1591    @SuppressWarnings("unchecked")
1592    private int _logResult(Map<String, Object> result, Logger logger)
1593    {
1594        List<Map<String, Object>> referencedContents = (List<Map<String, Object>>) result.get(_CONTENT_DELETION_STATUS_REFERENCED + "-contents");
1595        if (referencedContents.size() > 0)
1596        {
1597            logger.info("The following contents cannot be deleted because they are referenced: {}", referencedContents.stream().map(m -> m.get("id")).collect(Collectors.toList()));
1598        }
1599        
1600        List<Map<String, Object>> lockedContents = (List<Map<String, Object>>) result.get(_CONTENT_DELETION_STATUS_LOCKED + "-contents");
1601        if (lockedContents.size() > 0)
1602        {
1603            logger.info("The following contents cannot be deleted because they are locked: {}", lockedContents.stream().map(m -> m.get("id")).collect(Collectors.toList()));
1604        }
1605        
1606        List<Map<String, Object>> unauthorizedContents = (List<Map<String, Object>>) result.get(_CONTENT_DELETION_STATUS_UNAUTHORIZED + "-contents");
1607        if (unauthorizedContents.size() > 0)
1608        {
1609            logger.info("The following contents cannot be deleted because they are no authorization: {}", unauthorizedContents.stream().map(m -> m.get("id")).collect(Collectors.toList()));
1610        }
1611        
1612        List<Map<String, Object>> undeletedContents = (List<Map<String, Object>>) result.get(_CONTENT_DELETION_STATUS_UNDELETED + "-contents");
1613        if (undeletedContents.size() > 0)
1614        {
1615            logger.info("{} contents were not deleted. See previous logs for more information.", undeletedContents.size());
1616        }
1617
1618        List<Map<String, Object>> deletedContents = (List<Map<String, Object>>) result.get(_CONTENT_DELETION_STATUS_DELETED + "-contents");
1619        return deletedContents.size();
1620    }
1621}