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.contenttype.MetadataSet;
056import org.ametys.cms.data.ContentDataHelper;
057import org.ametys.cms.data.type.ModelItemTypeConstants;
058import org.ametys.cms.lock.LockContentManager;
059import org.ametys.cms.repository.ReactionableObject.ReactionType;
060import org.ametys.cms.tag.CMSTag;
061import org.ametys.cms.tag.TagProviderExtensionPoint;
062import org.ametys.cms.workflow.AbstractContentWorkflowComponent;
063import org.ametys.cms.workflow.ContentWorkflowHelper;
064import org.ametys.cms.workflow.EditContentFunction;
065import org.ametys.core.observation.Event;
066import org.ametys.core.observation.ObservationManager;
067import org.ametys.core.right.RightManager;
068import org.ametys.core.right.RightManager.RightResult;
069import org.ametys.core.ui.Callable;
070import org.ametys.core.user.CurrentUserProvider;
071import org.ametys.core.user.UserIdentity;
072import org.ametys.core.user.UserManager;
073import org.ametys.core.util.DateUtils;
074import org.ametys.plugins.core.user.UserHelper;
075import org.ametys.plugins.explorer.ExplorerNode;
076import org.ametys.plugins.explorer.resources.ModifiableResourceCollection;
077import org.ametys.plugins.explorer.resources.Resource;
078import org.ametys.plugins.explorer.resources.ResourceCollection;
079import org.ametys.plugins.repository.AmetysObject;
080import org.ametys.plugins.repository.AmetysObjectIterable;
081import org.ametys.plugins.repository.AmetysObjectResolver;
082import org.ametys.plugins.repository.AmetysRepositoryException;
083import org.ametys.plugins.repository.CopiableAmetysObject;
084import org.ametys.plugins.repository.ModifiableAmetysObject;
085import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
086import org.ametys.plugins.repository.RemovableAmetysObject;
087import org.ametys.plugins.repository.TraversableAmetysObject;
088import org.ametys.plugins.repository.UnknownAmetysObjectException;
089import org.ametys.plugins.repository.data.holder.ModifiableModelLessDataHolder;
090import org.ametys.plugins.repository.lock.LockAwareAmetysObject;
091import org.ametys.plugins.repository.lock.LockHelper;
092import org.ametys.plugins.repository.lock.LockableAmetysObject;
093import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector;
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;
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.dateToString(content.getCreationDate()));
646        infos.put("lastModified", DateUtils.dateToString(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.hasNonEmptyValue(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.dateToString(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 metadata sets of a content
782     * @param contentId the content's id
783     * @param edition Set to true to get edition metadata set. False otherwise.
784     * @param includeInternal Set to true to include internal metadata sets.
785     * @return the metadata sets
786     */
787    @Callable
788    public List<Map<String, Object>> getContentMetadataSets (String contentId, boolean edition, boolean includeInternal)
789    {
790        List<Map<String, Object>> metadataSets = 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> metadataSetNames = edition ? cType.getEditionMetadataSetNames(includeInternal) : cType.getViewMetadataSetNames(includeInternal);
798        for (String metadataSetName : metadataSetNames)
799        {
800            MetadataSet metadataSet = edition ? cType.getMetadataSetForEdition(metadataSetName) : cType.getMetadataSetForView(metadataSetName);
801            
802            Map<String, Object> viewInfos = new HashMap<>();
803            viewInfos.put("name", metadataSetName);
804            viewInfos.put("label", metadataSet.getLabel());
805            viewInfos.put("description", metadataSet.getDescription());
806            metadataSets.add(viewInfos);
807        }
808        
809        return metadataSets;
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 metadata
1116            originalContent.getMetadataHolder().copyTo(content.getMetadataHolder());
1117            
1118            // Copy attachments 
1119            _copyAttachments(originalContent, content);
1120            
1121            if (copyACL)
1122            {
1123                _copyACL(originalContent, content);
1124            }
1125            
1126            if (_currentUserProvider.getUser() != null)
1127            {
1128                content.setCreator(_currentUserProvider.getUser());
1129                content.setLastModified(new Date());
1130                content.setCreationDate(new Date());
1131            }
1132            
1133            parent.saveChanges();
1134            
1135            // Create a new version
1136            if (content instanceof VersionableAmetysObject)
1137            {
1138                ((VersionableAmetysObject) content).checkpoint();
1139            }
1140
1141            if (notifyObservers)
1142            {
1143                _notifyContentCopied(content, waitAsyncObservers);
1144            }
1145            
1146            return content;
1147        }
1148        catch (WorkflowException e)
1149        {
1150            throw new AmetysRepositoryException(e);
1151        }
1152        catch (RepositoryException e)
1153        {
1154            throw new AmetysRepositoryException(e);
1155        }
1156    }
1157    
1158    /**
1159     * Copy the attachments of a content
1160     * @param srcContent The source content
1161     * @param targetContent The target content
1162     */
1163    protected void _copyAttachments(Content srcContent, Content targetContent)
1164    {
1165        ResourceCollection srcRootAttachments = srcContent.getRootAttachments();
1166        ResourceCollection targetRootAttachments = targetContent.getRootAttachments();
1167        
1168        AmetysObjectIterable<AmetysObject> children = srcRootAttachments.getChildren();
1169        for (AmetysObject child : children)
1170        {
1171            if (child instanceof CopiableAmetysObject)
1172            {
1173                try
1174                {
1175                    ((CopiableAmetysObject) child).copyTo((ModifiableTraversableAmetysObject) targetRootAttachments, child.getName());
1176                }
1177                catch (AmetysRepositoryException e)
1178                {
1179                    getLogger().error("Failed to copy attachments at path " + child.getPath() + " from content " + srcContent + " to content " + targetContent, e);
1180                }
1181            }
1182        }
1183    }
1184        
1185    /**
1186     * Copy the ACL of a content
1187     * @param srcContent The source content
1188     * @param targetContent The target content
1189     */
1190    protected void _copyACL(Content srcContent, Content targetContent)
1191    {
1192        if (srcContent instanceof DefaultContent && targetContent instanceof DefaultContent)
1193        {
1194            Node srcNode = ((DefaultContent) srcContent).getNode();
1195            Node targetNode = ((DefaultContent) targetContent).getNode();
1196            
1197            try
1198            {
1199                String aclNodeName = "ametys-internal:acl";
1200                if (srcNode.hasNode(aclNodeName))
1201                {
1202                    Node aclNode = srcNode.getNode(aclNodeName);
1203                    aclNode.getSession().getWorkspace().copy(aclNode.getPath(), targetNode.getPath() + "/" + aclNodeName);
1204                }
1205            }
1206            catch (RepositoryException e)
1207            {
1208                getLogger().error("Failed to copy ACL from content " + srcContent + " to content " + targetContent, e);
1209            }
1210        }
1211    }
1212    
1213    /**
1214     * Notify observers that the content has been created
1215     * @param content The content added
1216     * @param waitAsyncObservers true to wait for asynchonous observers to finish
1217     * @throws WorkflowException If an error occurred
1218     */
1219    protected void _notifyContentCopied(Content content, boolean waitAsyncObservers) throws WorkflowException
1220    {
1221        Map<String, Object> eventParams = new HashMap<>();
1222        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
1223        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
1224
1225        List<Future> futures = _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_ADDED, _currentUserProvider.getUser(), eventParams));
1226        futures.addAll(_observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_WORKFLOW_CHANGED, _currentUserProvider.getUser(), eventParams)));
1227        
1228        if (waitAsyncObservers)
1229        {
1230            // Wait for asynchonous observers to finish
1231            for (Future future : futures)
1232            {
1233                try
1234                {
1235                    future.get();
1236                }
1237                catch (ExecutionException | InterruptedException e)
1238                {
1239                    getLogger().error(String.format("Error while waiting for async observer to complete"));
1240                }
1241            }
1242        }
1243    }
1244    
1245    /**
1246     * Returns the content's attachments root node
1247     * @param id the content's id
1248     * @return The attachments' root node informations
1249     */
1250    @Callable
1251    public Map<String, Object> getAttachmentsRootNode (String id)
1252    {
1253        Map<String, Object> result = new HashMap<>();
1254        
1255        Content content = _resolver.resolveById(id);
1256        
1257        result.put("title", _contentHelper.getTitle(content));
1258        result.put("contentId", content.getId());
1259        
1260        TraversableAmetysObject attachments = content.getRootAttachments();
1261        
1262        if (attachments != null)
1263        {
1264            result.put("id", attachments.getId());
1265            if (attachments instanceof ModifiableAmetysObject)
1266            {
1267                result.put("isModifiable", true);
1268            }
1269            if (attachments instanceof ModifiableResourceCollection)
1270            {
1271                result.put("canCreateChild", true);
1272            }
1273            
1274            boolean hasChildNodes = false;
1275            boolean hasResources = false;
1276
1277            for (AmetysObject child : attachments.getChildren())
1278            {
1279                if (child instanceof Resource)
1280                {
1281                    hasResources = true;
1282                }
1283                else if (child instanceof ExplorerNode)
1284                {
1285                    hasChildNodes = true;
1286                }
1287            }
1288
1289            if (hasChildNodes)
1290            {
1291                result.put("hasChildNodes", true);
1292            }
1293
1294            if (hasResources)
1295            {
1296                result.put("hasResources", true);
1297            }
1298            
1299            return result;
1300        }
1301        
1302        throw new IllegalArgumentException("Content with id '" + id + "' does not support attachments.");
1303    }
1304    
1305    /**
1306     * Determines if the current user has right to delete the content
1307     * @param content The content
1308     * @return true if current user is authorized to delete the content
1309     */
1310    public boolean canDelete(Content content)
1311    {
1312        return canDelete(content, getRightToDelete());
1313    }
1314    
1315    /**
1316     * Determines if the current user has right to delete the content
1317     * @param content The content
1318     * @param deleteRightId The right's id to check for deletion
1319     * @return true if current user is authorized to delete the content
1320     */
1321    public boolean canDelete(Content content, String deleteRightId)
1322    {
1323        UserIdentity user = _currentUserProvider.getUser();
1324        if (_rightManager.hasRight(user, deleteRightId, content) == RightResult.RIGHT_ALLOW)
1325        {
1326            return true;
1327        }
1328        
1329        return false;
1330    }
1331    
1332    /**
1333     * Add or remove a reaction on a content
1334     * @param contentId The content id
1335     * @param reactionName the reaction name (ex: LIKE)
1336     * @param remove true to remove the reaction, false to add reaction
1337     * @return the result with the current actors of this reaction
1338     */
1339    @Callable
1340    public Map<String, Object> react(String contentId, String reactionName, boolean remove)
1341    {
1342        Map<String, Object> result = new HashMap<>();
1343        
1344        Content content = _resolver.resolveById(contentId);
1345        
1346        if (_rightManager.currentUserHasReadAccess(content))
1347        {
1348            ReactionType reactionType = ReactionType.valueOf(reactionName);
1349            UserIdentity actor = _currentUserProvider.getUser();
1350            
1351            boolean updated = remove ? unreact(content, actor, reactionType) : react(content, actor, reactionType);
1352            result.put("updated", updated);
1353            result.put("contentId", contentId);
1354            result.put("actors", _userHelper.userIdentities2json(((ReactionableObject) content).getReactionUsers(reactionType)));
1355        }
1356        else
1357        {
1358            result.put("unauthorized", true);
1359            result.put("updated", false);
1360        }
1361
1362        return result;
1363    }
1364    
1365    /**
1366     * Add a reaction on a {@link Content}.
1367     * @param content the content
1368     * @param userIdentity the issuer of reaction
1369     * @param reactionType the reaction type
1370     * @return true if a change was made
1371     */
1372    public boolean react(Content content, UserIdentity userIdentity, ReactionType reactionType)
1373    {
1374        return _addOrRemoveReaction(content, userIdentity, reactionType, false);
1375    }
1376    
1377    /**
1378     * Remove reaction if exists on a {@link Content}.
1379     * @param content the content
1380     * @param userIdentity the issuer of reaction
1381     * @param reactionType the reaction type
1382     * @return <code>true</code> if a change was made
1383     */
1384    public boolean unreact(Content content, UserIdentity userIdentity, ReactionType reactionType)
1385    {
1386        return _addOrRemoveReaction(content, userIdentity, reactionType, true);
1387    }
1388    
1389    /**
1390     * Add or remove reaction if exists on the given content.
1391     * @param content the content
1392     * @param userIdentity the issuer of reaction
1393     * @param reactionType the reaction type
1394     * @param remove <code>true</code> if it's to remove the reaction
1395     * @return <code>true</code> if a change was made
1396     */
1397    protected boolean _addOrRemoveReaction(Content content, UserIdentity userIdentity, ReactionType reactionType, boolean remove)
1398    {
1399        if (content instanceof ReactionableObject)
1400        {
1401            boolean hasChanges = false;
1402            
1403            List<UserIdentity> reactionIssuers = ((ReactionableObject) content).getReactionUsers(reactionType);
1404            if (!remove && !reactionIssuers.contains(userIdentity))
1405            {
1406                ((ReactionableObject) content).addReaction(userIdentity, reactionType);
1407                hasChanges = true;
1408            }
1409            else if (remove && reactionIssuers.contains(userIdentity))
1410            {
1411                ((ReactionableObject) content).removeReaction(userIdentity, reactionType);
1412                hasChanges = true;
1413            }
1414            
1415            if (hasChanges)
1416            {
1417                ((DefaultContent) content).saveChanges();
1418                
1419                Map<String, Object> eventParams = new HashMap<>();
1420                eventParams.put(ObservationConstants.ARGS_CONTENT, content);
1421                eventParams.put(ObservationConstants.ARGS_REACTION_TYPE, reactionType);
1422                eventParams.put(ObservationConstants.ARGS_REACTION_ISSUER, userIdentity);
1423                _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_REACTION_CHANGED, userIdentity, eventParams));
1424                
1425                return true;
1426            }
1427        }
1428        
1429        return false;
1430    }
1431    
1432    /**
1433     * Add a report on a content
1434     * @param content The content
1435     * @throws IllegalArgumentException if the content is not a {@link ReportableObject}
1436     * @throws AccessDeniedException if the current user has not read access on the given content
1437     */
1438    public void report(Content content) throws IllegalArgumentException, AccessDeniedException
1439    {
1440        if (_rightManager.currentUserHasReadAccess(content))
1441        {
1442            if (content instanceof ReportableObject)
1443            {
1444                ((ReportableObject) content).addReport();
1445                ((DefaultContent) content).saveChanges();
1446            }
1447            else
1448            {
1449                throw new IllegalArgumentException("Unable to report the content '" + content.getId() + "'. Current user is not authorized to see this content.");
1450            }
1451        }
1452        else
1453        {
1454            throw new AccessDeniedException("Unable to report the content '" + content.getId() + "'. Current user is not authorized to see this content.");
1455        }
1456    }
1457
1458    /**
1459     * Delete contents and force the deletion of invert relations, then log the result.
1460     * @param contents The contents to delete
1461     * @param deleteRightId The deletion right's id to check. Can be null to ignore rights
1462     * @param logger The logger
1463     * @return the number of deleted contents
1464     */
1465    public int forceDeleteContentsWithLog(List<Content> contents, String deleteRightId, Logger logger)
1466    {
1467        return _logResult(forceDeleteContentsObj(contents, deleteRightId), logger);
1468    }
1469
1470    @SuppressWarnings("unchecked")
1471    private int _logResult(Map<String, Object> result, Logger logger)
1472    {
1473        List<Map<String, Object>> referencedContents = (List<Map<String, Object>>) result.get(_CONTENT_DELETION_STATUS_REFERENCED + "-contents");
1474        if (referencedContents.size() > 0)
1475        {
1476            logger.info("The following contents cannot be deleted because they are referenced: {}", referencedContents.stream().map(m -> m.get("id")).collect(Collectors.toList()));
1477        }
1478        
1479        List<Map<String, Object>> lockedContents = (List<Map<String, Object>>) result.get(_CONTENT_DELETION_STATUS_LOCKED + "-contents");
1480        if (lockedContents.size() > 0)
1481        {
1482            logger.info("The following contents cannot be deleted because they are locked: {}", lockedContents.stream().map(m -> m.get("id")).collect(Collectors.toList()));
1483        }
1484        
1485        List<Map<String, Object>> unauthorizedContents = (List<Map<String, Object>>) result.get(_CONTENT_DELETION_STATUS_UNAUTHORIZED + "-contents");
1486        if (unauthorizedContents.size() > 0)
1487        {
1488            logger.info("The following contents cannot be deleted because they are no authorization: {}", unauthorizedContents.stream().map(m -> m.get("id")).collect(Collectors.toList()));
1489        }
1490        
1491        List<Map<String, Object>> undeletedContents = (List<Map<String, Object>>) result.get(_CONTENT_DELETION_STATUS_UNDELETED + "-contents");
1492        if (undeletedContents.size() > 0)
1493        {
1494            logger.info("{} contents were not deleted. See previous logs for more information.", undeletedContents.size());
1495        }
1496
1497        List<Map<String, Object>> deletedContents = (List<Map<String, Object>>) result.get(_CONTENT_DELETION_STATUS_DELETED + "-contents");
1498        return deletedContents.size();
1499    }
1500}