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