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