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