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.Date;
021import java.util.HashMap;
022import java.util.HashSet;
023import java.util.List;
024import java.util.Map;
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.lang3.StringUtils;
045import org.apache.commons.lang3.tuple.Pair;
046import org.slf4j.Logger;
047
048import org.ametys.cms.ObservationConstants;
049import org.ametys.cms.content.ContentHelper;
050import org.ametys.cms.content.archive.ArchiveConstants;
051import org.ametys.cms.content.referencetable.HierarchicalReferenceTablesHelper;
052import org.ametys.cms.contenttype.ContentType;
053import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
054import org.ametys.cms.contenttype.ContentTypesHelper;
055import org.ametys.cms.data.ContentDataHelper;
056import org.ametys.cms.data.type.ModelItemTypeConstants;
057import org.ametys.cms.lock.LockContentManager;
058import org.ametys.cms.repository.ReactionableObject.ReactionType;
059import org.ametys.cms.tag.CMSTag;
060import org.ametys.cms.tag.TagProviderExtensionPoint;
061import org.ametys.cms.workflow.AbstractContentWorkflowComponent;
062import org.ametys.cms.workflow.ContentWorkflowHelper;
063import org.ametys.cms.workflow.EditContentFunction;
064import org.ametys.core.observation.Event;
065import org.ametys.core.observation.ObservationManager;
066import org.ametys.core.right.RightManager;
067import org.ametys.core.right.RightManager.RightResult;
068import org.ametys.core.ui.Callable;
069import org.ametys.core.user.CurrentUserProvider;
070import org.ametys.core.user.UserIdentity;
071import org.ametys.core.user.UserManager;
072import org.ametys.core.util.DateUtils;
073import org.ametys.plugins.core.user.UserHelper;
074import org.ametys.plugins.explorer.ExplorerNode;
075import org.ametys.plugins.explorer.resources.ModifiableResourceCollection;
076import org.ametys.plugins.explorer.resources.Resource;
077import org.ametys.plugins.explorer.resources.ResourceCollection;
078import org.ametys.plugins.repository.AmetysObject;
079import org.ametys.plugins.repository.AmetysObjectIterable;
080import org.ametys.plugins.repository.AmetysObjectResolver;
081import org.ametys.plugins.repository.AmetysRepositoryException;
082import org.ametys.plugins.repository.CopiableAmetysObject;
083import org.ametys.plugins.repository.ModifiableAmetysObject;
084import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
085import org.ametys.plugins.repository.RemovableAmetysObject;
086import org.ametys.plugins.repository.TraversableAmetysObject;
087import org.ametys.plugins.repository.UnknownAmetysObjectException;
088import org.ametys.plugins.repository.data.holder.ModifiableModelLessDataHolder;
089import org.ametys.plugins.repository.lock.LockAwareAmetysObject;
090import org.ametys.plugins.repository.lock.LockHelper;
091import org.ametys.plugins.repository.lock.LockableAmetysObject;
092import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector;
093import org.ametys.plugins.repository.tag.TaggableAmetysObject;
094import org.ametys.plugins.repository.version.ModifiableDataAwareVersionableAmetysObject;
095import org.ametys.plugins.repository.version.VersionableAmetysObject;
096import org.ametys.plugins.workflow.AbstractWorkflowComponent;
097import org.ametys.plugins.workflow.component.CheckRightsCondition;
098import org.ametys.plugins.workflow.store.AbstractJackrabbitWorkflowStore;
099import org.ametys.plugins.workflow.store.AmetysObjectWorkflowStore;
100import org.ametys.plugins.workflow.support.WorkflowProvider;
101import org.ametys.plugins.workflow.support.WorkflowProvider.AmetysObjectWorkflow;
102import org.ametys.runtime.authentication.AccessDeniedException;
103import org.ametys.runtime.model.View;
104
105import com.opensymphony.workflow.WorkflowException;
106import com.opensymphony.workflow.spi.Step;
107
108/**
109 * DAO for manipulating contents
110 *
111 */
112public class ContentDAO extends AbstractLogEnabled implements Serviceable, Component, Contextualizable
113{
114    /** Avalon Role */
115    public static final String ROLE = ContentDAO.class.getName();
116
117    /** Deletion status : deleted */
118    protected static final String _CONTENT_DELETION_STATUS_DELETED = "deleted";
119
120    /** Deletion status : undeleted */
121    protected static final String _CONTENT_DELETION_STATUS_UNDELETED = "undeleted";
122
123    /** Deletion status : referenced */
124    protected static final String _CONTENT_DELETION_STATUS_REFERENCED = "referenced";
125
126    /** Deletion status : unauthorized */
127    protected static final String _CONTENT_DELETION_STATUS_UNAUTHORIZED = "unauthorized";
128
129    /** Deletion status : locked */
130    protected static final String _CONTENT_DELETION_STATUS_LOCKED = "locked";
131    
132    /** Ametys resolver */
133    protected AmetysObjectResolver _resolver;
134    /** Ametys observation manger */
135    protected ObservationManager _observationManager;
136    /** Component to get current user */
137    protected CurrentUserProvider _currentUserProvider;
138    /** Component to get tags */
139    protected TagProviderExtensionPoint _tagProvider;
140
141    /** Workflow component */
142    protected WorkflowProvider _workflowProvider;
143    /** Workflow helper component */
144    protected ContentWorkflowHelper _contentWorkflowHelper;
145    /** Component to manager lock */
146    protected LockContentManager _lockManager;
147    /** Content-type extension point */
148    protected ContentTypeExtensionPoint _contentTypeEP;
149    /** Content helper */
150    protected ContentHelper _contentHelper;
151    /** Content types helper */
152    protected ContentTypesHelper _cTypesHelper;
153    /** Rights manager */
154    protected RightManager _rightManager;
155    /** Cocoon context */
156    protected Context _context;
157    /** The user manager */
158    protected UserManager _usersManager;
159    /** Helper for users */
160    protected UserHelper _userHelper;
161    /** The helper component for hierarchical simple contents */
162    protected HierarchicalReferenceTablesHelper _hierarchicalSimpleContentsHelper;
163    /** The modifiable content helper */
164    protected ModifiableContentHelper _modifiableContentHelper;
165    
166    /** The mode for tag edition */
167    public enum TagMode 
168    {
169        /** Value will replace existing one */
170        REPLACE,
171        /** Value will be added to existing one */
172        INSERT,
173        /** Value will be removed from existing one */
174        REMOVE
175    }
176    
177    public void contextualize(Context context) throws ContextException
178    {
179        _context = context;
180    }
181    
182    @Override
183    public void service(ServiceManager smanager) throws ServiceException
184    {
185        _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
186        _observationManager = (ObservationManager) smanager.lookup(ObservationManager.ROLE);
187        _currentUserProvider = (CurrentUserProvider) smanager.lookup(CurrentUserProvider.ROLE);
188        _usersManager = (UserManager) smanager.lookup(UserManager.ROLE);
189        _userHelper = (UserHelper) smanager.lookup(UserHelper.ROLE);
190        _tagProvider = (TagProviderExtensionPoint) smanager.lookup(TagProviderExtensionPoint.ROLE);
191        _workflowProvider = (WorkflowProvider) smanager.lookup(WorkflowProvider.ROLE);
192        _rightManager = (RightManager) smanager.lookup(RightManager.ROLE);
193        _contentTypeEP = (ContentTypeExtensionPoint) smanager.lookup(ContentTypeExtensionPoint.ROLE);
194        _lockManager = (LockContentManager) smanager.lookup(LockContentManager.ROLE);
195        _contentWorkflowHelper = (ContentWorkflowHelper) smanager.lookup(ContentWorkflowHelper.ROLE);
196        _cTypesHelper = (ContentTypesHelper) smanager.lookup(ContentTypesHelper.ROLE);
197        _contentHelper = (ContentHelper) smanager.lookup(ContentHelper.ROLE);
198        _modifiableContentHelper = (ModifiableContentHelper) smanager.lookup(ModifiableContentHelper.ROLE); 
199        _hierarchicalSimpleContentsHelper = (HierarchicalReferenceTablesHelper) smanager.lookup(HierarchicalReferenceTablesHelper.ROLE);
200    }
201
202    /**
203     * Delete contents and force the deletion of invert relations.
204     * @param contentsId The ids of contents to delete
205     * @return the deleted and undeleted contents
206     */
207    @Callable
208    public Map<String, Object> forceDeleteContents(List<String> contentsId)
209    {
210        return forceDeleteContentsObj(
211            contentsId.stream()
212                .map(_resolver::<Content>resolveById)
213                .collect(Collectors.toList()),
214            getRightToDelete()
215        );
216    }
217
218    /**
219     * Delete contents and force the deletion of invert relations.
220     * @param contents The contents to delete
221     * @param deleteRightId The deletion right's id to check. Can be null to ignore rights
222     * @return the deleted and undeleted contents
223     */
224    public Map<String, Object> forceDeleteContentsObj(List<Content> contents, String deleteRightId)
225    {
226        Map<String, Object> results = _initializeResultsMap();
227        
228        for (Content content : contents)
229        {
230            Map<String, Object> contentParams = _transformContentToParams(content);
231            
232            String contentDeletionStatus = _getContentDeletionStatus(content, deleteRightId);
233            
234            // The content is referenced, remove referencies
235            // Then remove referencies if you can
236            // Then check that the content is not referenced any more (should not happen)
237            if (contentDeletionStatus != null && contentDeletionStatus.equals(_CONTENT_DELETION_STATUS_REFERENCED) && _removeReferences(content) && !_isContentReferenced(content))
238            {
239                contentDeletionStatus = null;
240            }
241            
242            // The content has no constraints
243            if (contentDeletionStatus == null)
244            {
245                contentDeletionStatus = _reallyDeleteContent(content);
246            }
247            
248            String key = contentDeletionStatus + "-contents";
249            @SuppressWarnings("unchecked")
250            List<Map<String, Object>> statusedContents = (List<Map<String, Object>>) results.get(key);
251            statusedContents.add(contentParams);
252        }
253        
254        return results;
255    }
256    
257    /**
258     * Get the invert action id (used for forced deletion).
259     * @return The invert action id
260     */
261    protected int _getInvertActionId()
262    {
263        return EditContentFunction.INVERT_EDIT_ACTION_ID;
264    }
265    
266    /**
267     * Get the right to delete a content.
268     * @return The right ID to delete a content
269     */
270    protected String getRightToDelete()
271    {
272        return "CMS_Rights_DeleteContent";
273    }
274    
275    /**
276     * Remove all the references to the given content.
277     * @param content The content
278     * @return <code>true</code> if references have been all removed successfully
279     */
280    protected boolean _removeReferences(Content content)
281    {
282        int actionId = _getInvertActionId();
283        
284        // Group references by referencer
285        Map<Content, Set<String>> referencesByContent = new HashMap<>();
286        for (Pair<String, Content> referencingPair : _contentHelper.getReferencingContents(content))
287        {
288            Set<String> references = referencesByContent.computeIfAbsent(referencingPair.getValue(), __ -> new HashSet<>());
289            references.add(referencingPair.getKey());
290        }
291        
292        // Control that each referencer has the invert action available
293        for (Content referencingContent : referencesByContent.keySet())
294        {
295            if (referencingContent instanceof WorkflowAwareContent 
296                    && !_contentWorkflowHelper.isAvailableAction((WorkflowAwareContent) referencingContent, actionId))
297            {
298                getLogger().warn("The action " + actionId + " is not available for the referencing content " + referencingContent.getId() + ". Impossible to delete the content " + content.getId() + ".");
299                return false;
300            }
301        }
302        
303        // Get the references
304        for (Content referencingContent : referencesByContent.keySet())
305        {
306            // Break the references
307            for (String referencingPath : referencesByContent.get(referencingContent))
308            {
309                if (referencingContent instanceof ModifiableContent)
310                {
311                    if (referencingContent.isMultiple(referencingPath))
312                    {
313                        String[] values = ContentDataHelper.getContentIdsStreamFromMultipleContentData(referencingContent, referencingPath)
314                            .filter(id -> !id.equals(content.getId()))
315                            .toArray(String[]::new);
316                        ((ModifiableContent) referencingContent).setValue(referencingPath, values);
317                    }
318                    else
319                    {
320                        ((ModifiableContent) referencingContent).removeValue(referencingPath);
321                    }
322                }
323            }
324            
325            // edit
326            Map<String, Object> contextParameters = new HashMap<>();
327            contextParameters.put("quit", true);
328            
329            Map<String, Object> inputs = new HashMap<>();
330            inputs.put(AbstractWorkflowComponent.CONTEXT_PARAMETERS_KEY, contextParameters);
331            inputs.put(CheckRightsCondition.FORCE, true);
332            
333            if (referencingContent instanceof WorkflowAwareContent)
334            {
335                try
336                {
337                    _contentWorkflowHelper.doAction((WorkflowAwareContent) referencingContent, actionId, inputs);
338                }
339                catch (WorkflowException e)
340                {
341                    getLogger().error("An error occured while trying to update the workflow of the referencer " + referencingContent.toString(), e);
342                }
343            }
344        }
345        
346        return true;
347    }
348    
349    /**
350     * Delete contents
351     * @param contentsId The ids of contents to delete
352     * @return the deleted and undeleted contents
353     */
354    @Callable
355    public Map<String, Object> deleteContents(List<String> contentsId)
356    {
357        return deleteContents(contentsId, false);
358    }
359    
360    /**
361     * Delete contents
362     * @param contentsId The ids of contents to delete
363     * @param ignoreRights true to ignore user rights
364     * @return the deleted and undeleted contents
365     */
366    public Map<String, Object> deleteContents(List<String> contentsId, boolean ignoreRights)
367    {
368        return deleteContents(contentsId, ignoreRights ? null : getRightToDelete());
369    }
370    
371    /**
372     * Delete contents
373     * @param contentsId The ids of contents to delete
374     * @param deleteRightId The deletion right's id to check. Can be null to ignore rights
375     * @return the deleted and undeleted contents
376     */
377    public Map<String, Object> deleteContents(List<String> contentsId, String deleteRightId)
378    {
379        Map<String, Object> results = _initializeResultsMap();
380        
381        for (String contentId : contentsId)
382        {
383            Content content = _resolver.resolveById(contentId);
384            Map<String, Object> contentParams = _transformContentToParams(content);
385            
386            String contentDeletionStatus = _getContentDeletionStatus(content, deleteRightId);
387            
388            // The content has no constraints
389            if (contentDeletionStatus == null)
390            {
391                contentDeletionStatus = _reallyDeleteContent(content);
392            }
393            
394            String key = contentDeletionStatus + "-contents";
395            @SuppressWarnings("unchecked")
396            List<Map<String, Object>> statusedContents = (List<Map<String, Object>>) results.get(key);
397            statusedContents.add(contentParams);
398        }
399        
400        return results;
401    }
402    
403    /**
404     * Initialize the result map.
405     * @return The empty result map.
406     */
407    protected Map<String, Object> _initializeResultsMap()
408    {
409        Map<String, Object> results = new HashMap<>();
410
411        results.put(_CONTENT_DELETION_STATUS_DELETED + "-contents", new ArrayList<Map<String, Object>>());
412        results.put(_CONTENT_DELETION_STATUS_UNDELETED + "-contents", new ArrayList<Map<String, Object>>());
413        results.put(_CONTENT_DELETION_STATUS_REFERENCED + "-contents", new ArrayList<Map<String, Object>>());
414        results.put(_CONTENT_DELETION_STATUS_UNAUTHORIZED + "-contents", new ArrayList<Map<String, Object>>());
415        results.put(_CONTENT_DELETION_STATUS_LOCKED + "-contents", new ArrayList<Map<String, Object>>());
416        
417        return results;
418    }
419    
420    /**
421     * Delete the content and notify observers.
422     * @param content The content to delete
423     * @return The deletion status "deleted" or "undeleted" if an exception occurs
424     */
425    protected String _reallyDeleteContent(Content content)
426    {
427        try
428        {
429            // All checks have been done, the content can be deleted
430            Map<String, Object> eventParams = _getEventParametersForDeletion(content);
431            
432            _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETING, _currentUserProvider.getUser(), eventParams));
433            
434            RemovableAmetysObject removableContent = (RemovableAmetysObject) content;
435            ModifiableAmetysObject parent = removableContent.getParent();
436            
437            // Remove the content.
438            removableContent.remove();
439            
440            parent.saveChanges();
441            
442            _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETED, _currentUserProvider.getUser(), eventParams));
443            
444            return _CONTENT_DELETION_STATUS_DELETED;
445        }
446        catch (AmetysRepositoryException e)
447        {
448            getLogger().error("Unable to delete content '" + content.getId() + "'", e);
449            
450            return _CONTENT_DELETION_STATUS_UNDELETED;
451        }
452    }
453    
454    /**
455     * Transform the content to a {@link Map} with id, title and name.
456     * @param content The content to transform
457     * @return A {@link Map} with essentials informations of the content
458     */
459    protected Map<String, Object> _transformContentToParams(Content content)
460    {
461        String contentName = content.getName();
462        String contentTitle = StringUtils.defaultString(_contentHelper.getTitle(content), contentName);
463        
464        Map<String, Object> contentParams = new HashMap<>();
465        contentParams.put("id", content.getId());
466        contentParams.put("title", contentTitle);
467        contentParams.put("name", contentName);
468        
469        return contentParams;
470    }
471    
472    /**
473     * Get the deletion status of the content :
474     *  - unauthorized: The content can't be deleted because of rights
475     *  - locked: The content is locked
476     *  - referenced: The content has ingoing references
477     * @param content The content
478     * @param deleteRightId The right ID
479     * @return <code>null</code> if content deletion can be done or the status if there is something wrong for deletion 
480     */
481    protected String _getContentDeletionStatus(Content content, String deleteRightId)
482    {
483        if (!(content instanceof RemovableAmetysObject))
484        {
485            throw new IllegalArgumentException("The content [" + content.getId() + "] is not a RemovableAmetysObject, it can't be deleted.");
486        }
487        
488        if (deleteRightId != null && !canDelete(content, deleteRightId))
489        {
490            // User has no sufficient right
491            return _CONTENT_DELETION_STATUS_UNAUTHORIZED;
492        }
493        
494        if (content instanceof LockableAmetysObject)
495        {
496            // If the content is locked, try to unlock it.
497            LockableAmetysObject lockableContent = (LockableAmetysObject) content;
498            if (lockableContent.isLocked())
499            {
500                boolean canUnlockAll = _rightManager.hasRight(_currentUserProvider.getUser(), "CMS_Rights_UnlockAll", "/cms") == RightResult.RIGHT_ALLOW;
501                if (LockHelper.isLockOwner(lockableContent, _currentUserProvider.getUser()) || canUnlockAll)
502                {
503                    lockableContent.unlock();
504                }
505                else
506                {
507                    return _CONTENT_DELETION_STATUS_LOCKED;
508                }
509            }
510        }
511
512        if (_isContentReferenced(content))
513        {
514            // Indicate that the content is referenced.
515            return _CONTENT_DELETION_STATUS_REFERENCED;
516        }
517        
518        return null;
519    }
520    
521    /**
522     * Test if content is still referenced before removing it
523     * @param content The content to remove
524     * @return true if content is still referenced
525     */
526    protected boolean _isContentReferenced (Content content)
527    {
528        return content.hasReferencingContents();
529    }
530    
531    /**
532     * Get parameters for content deleted {@link Event}
533     * @param content the removed content
534     * @return the event's parameters
535     */
536    protected Map<String, Object> _getEventParametersForDeletion (Content content)
537    {
538        Map<String, Object> eventParams = new HashMap<>();
539        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
540        eventParams.put(ObservationConstants.ARGS_CONTENT_NAME, content.getName());
541        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
542        return eventParams;
543    }
544    
545    /**
546     * Get the contents properties
547     * @param contentIds The ids of contents
548     * @param workspaceName The workspace name. Can be null to get contents in current workspace.
549     * @return The contents' properties
550     */
551    @Callable
552    public Map<String, Object> getContentsProperties (List<String> contentIds, String workspaceName)
553    {
554        Map<String, Object> result = new HashMap<>();
555        
556        List<Map<String, Object>> contents = new ArrayList<>();
557        List<String> contentsNotFound = new ArrayList<>();
558        
559        Request request = ContextHelper.getRequest(_context);
560        String currentWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
561        try
562        {
563            if (StringUtils.isNotEmpty(workspaceName))
564            {
565                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, workspaceName);
566            }
567            
568            for (String contentId : contentIds)
569            {
570                try
571                {
572                    Content content = _resolver.resolveById(contentId);
573                    contents.add(getContentProperties(content));
574                }
575                catch (UnknownAmetysObjectException e)
576                {
577                    contentsNotFound.add(contentId);
578                }
579            }
580            
581            result.put("contents", contents);
582            result.put("contentsNotFound", contentsNotFound);
583        }
584        finally
585        {
586            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWorkspace);
587        }
588        
589        return result;
590    }
591    
592    /**
593     * Get the content properties
594     * @param contentId The id of content
595     * @param workspaceName The workspace name. Can be null to get content in current workspace.
596     * @return The content's properties
597     */
598    @Callable
599    public Map<String, Object> getContentProperties (String contentId, String workspaceName)
600    {
601        Request request = ContextHelper.getRequest(_context);
602        String currentWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
603        try
604        {
605            if (StringUtils.isNotEmpty(workspaceName))
606            {
607                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, workspaceName);
608            }
609            
610            Content content = _resolver.resolveById(contentId);
611            return getContentProperties(content);
612        }
613        finally
614        {
615            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWorkspace);
616        }
617    }
618    
619    /**
620     * Get the content properties
621     * @param content The content
622     * @return The content properties
623     */
624    public Map<String, Object> getContentProperties (Content content)
625    {
626        Map<String, Object> infos = new HashMap<>();
627        
628        infos.put("id", content.getId());
629        infos.put("name", content.getName());
630        infos.put("title", _contentHelper.getTitle(content));
631        
632        if (ModelItemTypeConstants.MULTILINGUAL_STRING_ELEMENT_TYPE_ID.equals(content.getType(Content.ATTRIBUTE_TITLE).getId()))
633        {
634            infos.put("titleVariants", _contentHelper.getTitleVariants(content));
635        }
636        infos.put("path", content.getPath());
637        infos.put("types", content.getTypes());
638        infos.put("mixins", content.getMixinTypes());
639        String lang = content.getLanguage();
640        if (lang != null)
641        {
642            infos.put("lang", lang);
643        }
644        infos.put("creator", _userHelper.user2json(content.getCreator()));
645        infos.put("lastContributor", _userHelper.user2json(content.getLastContributor()));
646        infos.put("creationDate", DateUtils.dateToString(content.getCreationDate()));
647        infos.put("lastModified", DateUtils.dateToString(content.getLastModified()));
648        infos.put("isSimple", _contentHelper.isSimple(content));
649        infos.put("isReferenceTable", _contentHelper.isReferenceTable(content));
650        infos.put("parent", _hierarchicalSimpleContentsHelper.getParent(content));
651        
652        if (content instanceof WorkflowAwareContent)
653        {
654            WorkflowAwareContent waContent = (WorkflowAwareContent) content;
655            AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(waContent);
656            
657            infos.put("workflowName", workflow.getWorkflowName(waContent.getWorkflowId()));
658            
659            List<Integer> workflowSteps = new ArrayList<>();
660            
661            List<Step> currentSteps = workflow.getCurrentSteps(waContent.getWorkflowId());
662            for (Step step : currentSteps)
663            {
664                workflowSteps.add(step.getStepId());
665            }
666            infos.put("workflowSteps", workflowSteps);
667            
668            int[] availableActions = _contentWorkflowHelper.getAvailableActions(waContent);
669            infos.put("availableActions", availableActions);
670        }
671        
672        if (content instanceof ModifiableContent)
673        {
674            infos.put("isModifiable", true);
675        }
676        
677        if (content instanceof LockAwareAmetysObject)
678        {
679            LockAwareAmetysObject lockableContent = (LockAwareAmetysObject) content;
680            if (lockableContent.isLocked())
681            {
682                infos.put("locked", true);
683                infos.put("lockOwner", _userHelper.user2json(lockableContent.getLockOwner()));
684                infos.put("canUnlock", _lockManager.canUnlock(lockableContent));
685            }
686        }
687        
688        infos.put("rights", getUserRights(content));
689        
690        Map<String, Object> additionalData = new HashMap<>();
691        
692        String[] contenttypes = content.getTypes();
693        for (String cTypeId : contenttypes)
694        {
695            ContentType cType = _contentTypeEP.getExtension(cTypeId);
696            if (cType != null)
697            {
698                additionalData.putAll(cType.getAdditionalData(content));
699            }
700        }
701        
702        if (!additionalData.isEmpty())
703        {
704            infos.put("additionalData", additionalData); 
705        }
706
707        infos.put("isTaggable", content instanceof TaggableAmetysObject);
708        
709        if (content instanceof ModifiableDataAwareVersionableAmetysObject)
710        {
711            ModifiableModelLessDataHolder unversionedDataHolder = ((ModifiableDataAwareVersionableAmetysObject) content).getUnversionedDataHolder();
712            if (unversionedDataHolder.hasValue(ArchiveConstants.META_ARCHIVE_SCHEDULED_DATE))
713            {
714                ZonedDateTime scheduledDate = unversionedDataHolder.getValue(ArchiveConstants.META_ARCHIVE_SCHEDULED_DATE);
715                infos.put("scheduledArchivingDate", DateUtils.zonedDateTimeToString(scheduledDate));
716            }
717        }
718        
719        if (content instanceof ReportableObject)
720        {
721            infos.put("reportsCount", ((ReportableObject) content).getReportsCount());
722        }
723
724        return infos;
725    }
726    
727    /**
728     * Get the content's properties for description
729     * @param contentId The id of content
730     * @param workspaceName The workspace name. Can be null to get content in current workspace.
731     * @return The content's properties for description
732     */
733    @Callable
734    public Map<String, Object> getContentDescription (String contentId, String workspaceName)
735    {
736        Request request = ContextHelper.getRequest(_context);
737        String currentWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
738        try
739        {
740            if (StringUtils.isNotEmpty(workspaceName))
741            {
742                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, workspaceName);
743            }
744            
745            Content content = _resolver.resolveById(contentId);
746            return getContentDescription(content);
747        }
748        finally
749        {
750            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWorkspace);
751        }
752    }
753    
754    /**
755     *Get the content's properties for description
756     * @param content The content
757     * @return The content's properties for description
758     */
759    public Map<String, Object> getContentDescription (Content content)
760    {
761        Map<String, Object> infos = new HashMap<>();
762        
763        infos.put("id", content.getId());
764        infos.put("name", content.getName());
765        infos.put("title", _contentHelper.getTitle(content));
766        infos.put("types", content.getTypes());
767        infos.put("mixins", content.getMixinTypes());
768        infos.put("lang", content.getLanguage());
769        infos.put("creator", _userHelper.user2json(content.getCreator()));
770        infos.put("lastContributor", _userHelper.user2json(content.getLastContributor()));
771        infos.put("lastModified", DateUtils.dateToString(content.getLastModified()));
772        infos.put("iconGlyph", _cTypesHelper.getIconGlyph(content));
773        infos.put("iconDecorator", _cTypesHelper.getIconDecorator(content));
774        infos.put("smallIcon", _cTypesHelper.getSmallIcon(content));
775        infos.put("mediumIcon", _cTypesHelper.getMediumIcon(content));
776        infos.put("largeIcon", _cTypesHelper.getLargeIcon(content));
777        
778        return infos;
779    }
780    
781    /**
782     * Get the views of a content
783     * @param contentId the content's id
784     * @param includeInternal Set to true to include internal views.
785     * @return the views
786     */
787    @Callable
788    public List<Map<String, Object>> getContentViews(String contentId, boolean includeInternal)
789    {
790        List<Map<String, Object>> views = new ArrayList<>();
791        
792        Content content = _resolver.resolveById(contentId);
793        String contentTypeId = _cTypesHelper.getContentTypeIdForRendering(content);
794        
795        ContentType cType = _contentTypeEP.getExtension(contentTypeId);
796        
797        Set<String> viewNames = cType.getViewNames(includeInternal);
798        for (String viewName : viewNames)
799        {
800            View view = cType.getView(viewName);
801            
802            Map<String, Object> viewInfos = new HashMap<>();
803            viewInfos.put("name", viewName);
804            viewInfos.put("label", view.getLabel());
805            viewInfos.put("description", view.getDescription());
806            views.add(viewInfos);
807        }
808        
809        return views;
810    }
811    
812    /**
813     * Get the user rights on content
814     * @param content The content
815     * @return The user's rights
816     */
817    protected Set<String> getUserRights (Content content)
818    {
819        UserIdentity user = _currentUserProvider.getUser();
820        return _rightManager.getUserRights(user, content);
821    }
822    
823    /**
824     * Get the tags of contents
825     * @param contentIds The content's ids
826     * @return the tags
827     */
828    @Callable
829    public Set<String> getTags (List<String> contentIds)
830    {
831        Set<String> tags = new HashSet<>();
832        
833        for (String contentId : contentIds)
834        {
835            Content content = _resolver.resolveById(contentId);
836            tags.addAll(content.getTags());
837        }
838        
839        return tags;
840    }
841    
842    /**
843     * Tag a list of contents with the given tags
844     * @param contentIds The ids of contents to tag
845     * @param tagNames The tags
846     * @param contextualParameters The contextual parameters
847     * @return the result
848     */
849    @Callable
850    public Map<String, Object> tag (List<String> contentIds, List<String> tagNames, Map<String, Object> contextualParameters)
851    {
852        return tag(contentIds, tagNames, TagMode.REPLACE.toString(), contextualParameters);
853    }
854    
855    /**
856     * Tag a list of contents
857     * @param contentIds The ids of contents to tag
858     * @param tagNames The tags
859     * @param mode The mode for updating tags: 'REPLACE' to replace tags, 'INSERT' to add tags or 'REMOVE' to remove tags.
860     * @param contextualParameters The contextual parameters
861     * @return the result
862     */
863    @Callable
864    public Map<String, Object> tag (List<String> contentIds, List<String> tagNames, String mode, Map<String, Object> contextualParameters)
865    {
866        Map<String, Object> result = new HashMap<>();
867        
868        result.put("notaggable-contents", new ArrayList<Map<String, Object>>());
869        result.put("invalid-tags", new ArrayList<String>());
870        result.put("allright-contents", new ArrayList<Map<String, Object>>());
871        result.put("locked-contents", new ArrayList<Map<String, Object>>());
872        
873        for (String contentId : contentIds)
874        {
875            Content content = _resolver.resolveById(contentId);
876            
877            Map<String, Object> content2json = new HashMap<>();
878            content2json.put("id", content.getId());
879            content2json.put("title", _contentHelper.getTitle(content));
880            
881            if (content instanceof TaggableAmetysObject)
882            {
883                TaggableAmetysObject mContent = (TaggableAmetysObject) content;
884                
885                boolean wasLocked = false;
886                
887                if (content instanceof LockableAmetysObject)
888                {
889                    LockableAmetysObject lockableContent = (LockableAmetysObject) content;
890                    UserIdentity user = _currentUserProvider.getUser();
891                    if (lockableContent.isLocked() && !LockHelper.isLockOwner(lockableContent, user))
892                    {
893                        @SuppressWarnings("unchecked")
894                        List<Map<String, Object>> lockedContents = (List<Map<String, Object>>) result.get("locked-contents");
895                        content2json.put("lockOwner", lockableContent.getLockOwner());
896                        lockedContents.add(content2json);
897                        
898                        // Stop process
899                        continue;
900                    }
901                    
902                    if (lockableContent.isLocked())
903                    {
904                        wasLocked = true;
905                        lockableContent.unlock();
906                    }
907                }
908                
909                TagMode tagMode = TagMode.valueOf(mode);
910                
911                Set<String> oldTags = mContent.getTags();
912                _removeAllTagsInReplaceMode(mContent, tagMode, oldTags);
913                
914                // Then set new tags
915                for (String tagName : tagNames)
916                {
917                    if (_isTagValid(tagName, contextualParameters))
918                    {
919                        if (TagMode.REMOVE.equals(tagMode))
920                        {
921                            mContent.untag(tagName);
922                        }
923                        else if (TagMode.REPLACE.equals(tagMode) || !oldTags.contains(tagName))
924                        {
925                            mContent.tag(tagName);
926                        }
927                        
928                    }
929                    else
930                    {
931                        @SuppressWarnings("unchecked")
932                        List<String> invalidTags = (List<String>) result.get("invalid-tags");
933                        invalidTags.add(tagName);
934                    }
935                }
936                
937                ((ModifiableAmetysObject) content).saveChanges();
938                
939                if (wasLocked)
940                {
941                    // Relock content if it was locked before tagging
942                    ((LockableAmetysObject) content).lock();
943                }
944                
945                content2json.put("tags", content.getTags());
946                @SuppressWarnings("unchecked")
947                List<Map<String, Object>> allRightPages = (List<Map<String, Object>>) result.get("allright-contents");
948                allRightPages.add(content2json);
949                
950                if (!oldTags.equals(content.getTags()))
951                {
952                    // Notify observers that the content has been tagged
953                    Map<String, Object> eventParams = new HashMap<>();
954                    eventParams.put(org.ametys.cms.ObservationConstants.ARGS_CONTENT, content);
955                    eventParams.put(org.ametys.cms.ObservationConstants.ARGS_CONTENT_ID, content.getId());
956                    eventParams.put("content.tags", content.getTags());
957                    eventParams.put("content.old.tags", oldTags);
958                    _observationManager.notify(new Event(org.ametys.cms.ObservationConstants.EVENT_CONTENT_TAGGED, _currentUserProvider.getUser(), eventParams));
959                }
960            }
961            else
962            {
963                @SuppressWarnings("unchecked")
964                List<Map<String, Object>> notaggableContents = (List<Map<String, Object>>) result.get("notaggable-contents");
965                notaggableContents.add(content2json);
966            }
967        }
968        
969        return result;
970    }
971
972    /**
973     * Remove all tags from the given content if tagMode is equals to REPLACE.
974     * @param mContent The content
975     * @param tagMode The tag
976     * @param oldTags Tags to remove
977     */
978    protected void _removeAllTagsInReplaceMode(TaggableAmetysObject mContent, TagMode tagMode, Set<String> oldTags)
979    {
980        if (TagMode.REPLACE.equals(tagMode))
981        {
982            // First delete old tags
983            for (String tagName : oldTags)
984            {
985                mContent.untag(tagName);
986            }
987        }
988    }
989    
990    /**
991     * Is the tag a content tag
992     * @param tagName The tag name
993     * @param contextualParameters The contextual parameters
994     * @return true if the tag is a valid content tag
995     */
996    protected boolean _isTagValid (String tagName, Map<String, Object> contextualParameters)
997    {
998        CMSTag tag = _tagProvider.getTag(tagName, contextualParameters);
999        return tag.getTarget().getName().equals("CONTENT");
1000    }
1001    
1002    /**
1003     * Copy a content.
1004     * @param originalContent the original content.
1005     * @param parent the object in which to create a content.
1006     * @param name the content name.
1007     * @param initWorkflowActionId The initial workflow action id
1008     * @return the copied content.
1009     * @throws AmetysRepositoryException If an error occured
1010     */
1011    public ModifiableContent copy(DefaultContent originalContent, ModifiableTraversableAmetysObject parent, String name, int initWorkflowActionId) throws AmetysRepositoryException
1012    {
1013        return copy(originalContent, parent, name, null, initWorkflowActionId);
1014    }
1015    
1016    /**
1017     * Copy a content.
1018     * @param originalContent the original content.
1019     * @param parent the object in which to create a content.
1020     * @param name the content name.
1021     * @param lang the content language. If null, the content language will be the same of the original content 
1022     * @param initWorkflowActionId The initial workflow action id
1023     * @return the copied content.
1024     * @throws AmetysRepositoryException If an error occured
1025     */
1026    public ModifiableContent copy(DefaultContent originalContent, ModifiableTraversableAmetysObject parent, String name, String lang, int initWorkflowActionId) throws AmetysRepositoryException
1027    {
1028        return copy(originalContent, parent, name, lang, initWorkflowActionId, true, false, false);
1029    }
1030    
1031    /**
1032     * Copy a content.
1033     * @param originalContent the original content.
1034     * @param parent the object in which to create a content.
1035     * @param name the content name.
1036     * @param lang the content language. If null, the content language will be the same of the original content 
1037     * @param initWorkflowActionId The initial workflow action id
1038     * @param notifyObservers Set to false to do not fire observer events
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 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(new Date());
1132                content.setCreationDate(new Date());
1133            }
1134            
1135            parent.saveChanges();
1136            
1137            // Create a new version
1138            if (content instanceof VersionableAmetysObject)
1139            {
1140                ((VersionableAmetysObject) content).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    protected 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}