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