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.util.ArrayList;
019import java.util.Date;
020import java.util.HashMap;
021import java.util.HashSet;
022import java.util.List;
023import java.util.Map;
024import java.util.Set;
025import java.util.concurrent.ExecutionException;
026import java.util.concurrent.Future;
027
028import javax.jcr.Node;
029import javax.jcr.RepositoryException;
030import javax.jcr.Session;
031
032import org.apache.avalon.framework.component.Component;
033import org.apache.avalon.framework.context.Context;
034import org.apache.avalon.framework.context.ContextException;
035import org.apache.avalon.framework.context.Contextualizable;
036import org.apache.avalon.framework.logger.AbstractLogEnabled;
037import org.apache.avalon.framework.service.ServiceException;
038import org.apache.avalon.framework.service.ServiceManager;
039import org.apache.avalon.framework.service.Serviceable;
040import org.apache.cocoon.components.ContextHelper;
041import org.apache.cocoon.environment.Request;
042import org.apache.commons.lang3.StringUtils;
043
044import org.ametys.cms.ObservationConstants;
045import org.ametys.cms.content.ContentHelper;
046import org.ametys.cms.content.archive.ArchiveConstants;
047import org.ametys.cms.content.referencetable.HierarchicalReferenceTablesHelper;
048import org.ametys.cms.contenttype.ContentType;
049import org.ametys.cms.contenttype.ContentTypeExtensionPoint;
050import org.ametys.cms.contenttype.ContentTypesHelper;
051import org.ametys.cms.contenttype.MetadataSet;
052import org.ametys.cms.lock.LockContentManager;
053import org.ametys.cms.repository.ReactionableObject.ReactionType;
054import org.ametys.cms.tag.CMSTag;
055import org.ametys.cms.tag.TagProviderExtensionPoint;
056import org.ametys.cms.workflow.AbstractContentWorkflowComponent;
057import org.ametys.cms.workflow.ContentWorkflowHelper;
058import org.ametys.core.observation.Event;
059import org.ametys.core.observation.ObservationManager;
060import org.ametys.core.right.RightManager;
061import org.ametys.core.right.RightManager.RightResult;
062import org.ametys.core.ui.Callable;
063import org.ametys.core.user.CurrentUserProvider;
064import org.ametys.core.user.UserIdentity;
065import org.ametys.core.user.UserManager;
066import org.ametys.core.util.DateUtils;
067import org.ametys.plugins.core.user.UserHelper;
068import org.ametys.plugins.explorer.ExplorerNode;
069import org.ametys.plugins.explorer.resources.ModifiableResourceCollection;
070import org.ametys.plugins.explorer.resources.Resource;
071import org.ametys.plugins.repository.AmetysObject;
072import org.ametys.plugins.repository.AmetysObjectResolver;
073import org.ametys.plugins.repository.AmetysRepositoryException;
074import org.ametys.plugins.repository.ModifiableAmetysObject;
075import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
076import org.ametys.plugins.repository.RemovableAmetysObject;
077import org.ametys.plugins.repository.TraversableAmetysObject;
078import org.ametys.plugins.repository.UnknownAmetysObjectException;
079import org.ametys.plugins.repository.lock.LockAwareAmetysObject;
080import org.ametys.plugins.repository.lock.LockHelper;
081import org.ametys.plugins.repository.lock.LockableAmetysObject;
082import org.ametys.plugins.repository.metadata.ModifiableCompositeMetadata;
083import org.ametys.plugins.repository.provider.RequestAttributeWorkspaceSelector;
084import org.ametys.plugins.repository.version.ModifiableMetadataAwareVersionableAmetysObject;
085import org.ametys.plugins.repository.version.VersionableAmetysObject;
086import org.ametys.plugins.workflow.store.AbstractJackrabbitWorkflowStore;
087import org.ametys.plugins.workflow.store.AmetysObjectWorkflowStore;
088import org.ametys.plugins.workflow.support.WorkflowProvider;
089import org.ametys.plugins.workflow.support.WorkflowProvider.AmetysObjectWorkflow;
090
091import com.opensymphony.workflow.WorkflowException;
092import com.opensymphony.workflow.spi.Step;
093
094/**
095 * DAO for manipulating contents
096 *
097 */
098public class ContentDAO extends AbstractLogEnabled implements Serviceable, Component, Contextualizable
099{
100    /** Avalon Role */
101    public static final String ROLE = ContentDAO.class.getName();
102    
103    /** Ametys resolver */
104    protected AmetysObjectResolver _resolver;
105    /** Ametys observation manger */
106    protected ObservationManager _observationManager;
107    /** Component to get current user */
108    protected CurrentUserProvider _currentUserProvider;
109    /** Component to get tags */
110    protected TagProviderExtensionPoint _tagProvider;
111
112    /** Workflow component */
113    protected WorkflowProvider _workflowProvider;
114    /** Workflow helper component */
115    protected ContentWorkflowHelper _contentWorkflowHelper;
116    /** Component to manager lock */
117    protected LockContentManager _lockManager;
118    /** Content-type extension point */
119    protected ContentTypeExtensionPoint _contentTypeEP;
120    /** Content helper */
121    protected ContentHelper _contentHelper;
122    /** Content types helper */
123    protected ContentTypesHelper _cTypesHelper;
124    /** Rights manager */
125    protected RightManager _rightManager;
126    /** Cocoon context */
127    protected Context _context;
128    /** The user manager */
129    protected UserManager _usersManager;
130    /** Helper for users */
131    protected UserHelper _userHelper;
132    /** The helper component for hierarchical simple contents */
133    protected HierarchicalReferenceTablesHelper _hierarchicalSimpleContentsHelper;
134    /** The modifiable content helper */
135    protected ModifiableContentHelper _modifiableContentHelper;
136    
137    /** The mode for tag edition */
138    public enum TagMode 
139    {
140        /** Value will replace existing one */
141        REPLACE,
142        /** Value will be added to existing one */
143        INSERT,
144        /** Value will be removed from existing one */
145        REMOVE
146    }
147    
148    public void contextualize(Context context) throws ContextException
149    {
150        _context = context;
151    }
152    
153    @Override
154    public void service(ServiceManager smanager) throws ServiceException
155    {
156        _resolver = (AmetysObjectResolver) smanager.lookup(AmetysObjectResolver.ROLE);
157        _observationManager = (ObservationManager) smanager.lookup(ObservationManager.ROLE);
158        _currentUserProvider = (CurrentUserProvider) smanager.lookup(CurrentUserProvider.ROLE);
159        _usersManager = (UserManager) smanager.lookup(UserManager.ROLE);
160        _userHelper = (UserHelper) smanager.lookup(UserHelper.ROLE);
161        _tagProvider = (TagProviderExtensionPoint) smanager.lookup(TagProviderExtensionPoint.ROLE);
162        _workflowProvider = (WorkflowProvider) smanager.lookup(WorkflowProvider.ROLE);
163        _rightManager = (RightManager) smanager.lookup(RightManager.ROLE);
164        _contentTypeEP = (ContentTypeExtensionPoint) smanager.lookup(ContentTypeExtensionPoint.ROLE);
165        _lockManager = (LockContentManager) smanager.lookup(LockContentManager.ROLE);
166        _contentWorkflowHelper = (ContentWorkflowHelper) smanager.lookup(ContentWorkflowHelper.ROLE);
167        _cTypesHelper = (ContentTypesHelper) smanager.lookup(ContentTypesHelper.ROLE);
168        _contentHelper = (ContentHelper) smanager.lookup(ContentHelper.ROLE);
169        _modifiableContentHelper = (ModifiableContentHelper) smanager.lookup(ModifiableContentHelper.ROLE); 
170        _hierarchicalSimpleContentsHelper = (HierarchicalReferenceTablesHelper) smanager.lookup(HierarchicalReferenceTablesHelper.ROLE);
171    }
172    
173    /**
174     * Delete contents
175     * @param contentsId The ids of contents to delete
176     * @return the deleted and undeleted contents
177     */
178    @Callable
179    public Map<String, Object> deleteContents(List<String> contentsId)
180    {
181        return deleteContents(contentsId, false);
182    }
183    
184    /**
185     * Delete contents
186     * @param contentsId The ids of contents to delete
187     * @param ignoreRights true to ignore user rights
188     * @return the deleted and undeleted contents
189     */
190    public Map<String, Object> deleteContents(List<String> contentsId, boolean ignoreRights)
191    {
192        Map<String, Object> results = new HashMap<>();
193        
194        results.put("deleted-contents", new ArrayList<Map<String, Object>>());
195        results.put("undeleted-contents", new ArrayList<Map<String, Object>>());
196        results.put("referenced-contents", new ArrayList<Map<String, Object>>());
197        results.put("unauthorized-contents", new ArrayList<Map<String, Object>>());
198        results.put("locked-contents", new ArrayList<Map<String, Object>>());
199        
200        for (String contentId : contentsId)
201        {
202            Content content = _resolver.resolveById(contentId);
203            String contentName = content.getName();
204            String contentTitle = StringUtils.defaultString(_contentHelper.getTitle(content), contentName);
205            
206            Map<String, Object> contentParams = new HashMap<>();
207            contentParams.put("id", content.getId());
208            contentParams.put("title", contentTitle);
209            contentParams.put("name", contentName);
210            
211            if (!(content instanceof RemovableAmetysObject))
212            {
213                throw new IllegalArgumentException("The content [" + content.getId() + "] is not a RemovableAmetysObject, it can't be deleted.");
214            }
215            
216            try
217            {
218                if (!ignoreRights && !canDelete(content))
219                {
220                    // User has no sufficient right
221                    @SuppressWarnings("unchecked")
222                    List<Map<String, Object>> unauthorizedContents = (List<Map<String, Object>>) results.get("unauthorized-contents");
223                    unauthorizedContents.add(contentParams);
224                    continue;
225                }
226                
227                if (content instanceof LockableAmetysObject)
228                {
229                    // If the content is locked, try to unlock it.
230                    LockableAmetysObject lockableContent = (LockableAmetysObject) content;
231                    if (lockableContent.isLocked())
232                    {
233                        boolean canUnlockAll = _rightManager.hasRight(_currentUserProvider.getUser(), "CMS_Rights_UnlockAll", "/cms") == RightResult.RIGHT_ALLOW;
234                        if (LockHelper.isLockOwner(lockableContent, _currentUserProvider.getUser()) || canUnlockAll)
235                        {
236                            lockableContent.unlock();
237                        }
238                        else
239                        {
240                            @SuppressWarnings("unchecked")
241                            List<Map<String, Object>> lockedContents = (List<Map<String, Object>>) results.get("locked-contents");
242                            lockedContents.add(contentParams);
243                            continue;
244                        }
245                    }
246                }
247                
248                if (_isContentReferenced(content))
249                {
250                    // Indicate that the content is referenced.
251                    @SuppressWarnings("unchecked")
252                    List<Map<String, Object>> referencedContents = (List<Map<String, Object>>) results.get("referenced-contents");
253                    referencedContents.add(contentParams);
254                }
255                else
256                {
257                    // All checks have been done, the content can be deleted
258                    Map<String, Object> eventParams = _getEventParametersForDeletion(content);
259                    
260                    _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETING, _currentUserProvider.getUser(), eventParams));
261                    
262                    RemovableAmetysObject removableContent = (RemovableAmetysObject) content;
263                    ModifiableAmetysObject parent = removableContent.getParent();
264                    
265                    // Remove the content.
266                    removableContent.remove();
267                    
268                    parent.saveChanges();
269                    
270                    _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_DELETED, _currentUserProvider.getUser(), eventParams));
271                    
272                    @SuppressWarnings("unchecked")
273                    List<Map<String, Object>> deletedContents = (List<Map<String, Object>>) results.get("deleted-contents");
274                    deletedContents.add(contentParams);
275                }
276            }
277            catch (AmetysRepositoryException e)
278            {
279                getLogger().error("Unable to delete content '" + contentId + "'", e);
280                
281                @SuppressWarnings("unchecked")
282                List<Map<String, Object>> undeletedContents = (List<Map<String, Object>>) results.get("undeleted-contents");
283                undeletedContents.add(contentParams);
284            }
285        }
286        
287        return results;
288    }
289    
290    /**
291     * Test if content is still referenced before removing it
292     * @param content The content to remove
293     * @return true if content is still referenced
294     */
295    protected boolean _isContentReferenced (Content content)
296    {
297        return content.hasReferencingContents();
298    }
299    
300    /**
301     * Get parameters for content deleted {@link Event}
302     * @param content the removed content
303     * @return the event's parameters
304     */
305    protected Map<String, Object> _getEventParametersForDeletion (Content content)
306    {
307        Map<String, Object> eventParams = new HashMap<>();
308        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
309        eventParams.put(ObservationConstants.ARGS_CONTENT_NAME, content.getName());
310        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
311        return eventParams;
312    }
313    
314    /**
315     * Get the contents properties
316     * @param contentIds The ids of contents
317     * @param workspaceName The workspace name. Can be null to get contents in current workspace.
318     * @return The contents' properties
319     */
320    @Callable
321    public Map<String, Object> getContentsProperties (List<String> contentIds, String workspaceName)
322    {
323        Map<String, Object> result = new HashMap<>();
324        
325        List<Map<String, Object>> contents = new ArrayList<>();
326        List<String> contentsNotFound = new ArrayList<>();
327        
328        Request request = ContextHelper.getRequest(_context);
329        String currentWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
330        try
331        {
332            if (StringUtils.isNotEmpty(workspaceName))
333            {
334                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, workspaceName);
335            }
336            
337            for (String contentId : contentIds)
338            {
339                try
340                {
341                    Content content = _resolver.resolveById(contentId);
342                    contents.add(getContentProperties(content));
343                }
344                catch (UnknownAmetysObjectException e)
345                {
346                    contentsNotFound.add(contentId);
347                }
348            }
349            
350            result.put("contents", contents);
351            result.put("contentsNotFound", contentsNotFound);
352        }
353        finally
354        {
355            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWorkspace);
356        }
357        
358        return result;
359    }
360    
361    /**
362     * Get the content properties
363     * @param contentId The id of content
364     * @param workspaceName The workspace name. Can be null to get content in current workspace.
365     * @return The content's properties
366     */
367    @Callable
368    public Map<String, Object> getContentProperties (String contentId, String workspaceName)
369    {
370        Request request = ContextHelper.getRequest(_context);
371        String currentWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
372        try
373        {
374            if (StringUtils.isNotEmpty(workspaceName))
375            {
376                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, workspaceName);
377            }
378            
379            Content content = _resolver.resolveById(contentId);
380            return getContentProperties(content);
381        }
382        finally
383        {
384            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWorkspace);
385        }
386    }
387    
388    /**
389     * Get the content properties
390     * @param content The content
391     * @return The content properties
392     */
393    public Map<String, Object> getContentProperties (Content content)
394    {
395        Map<String, Object> infos = new HashMap<>();
396        
397        infos.put("id", content.getId());
398        infos.put("name", content.getName());
399        infos.put("title", _contentHelper.getTitle(content));
400        
401        if (content.getMetadataHolder().getType(DefaultContent.METADATA_TITLE) == org.ametys.plugins.repository.metadata.CompositeMetadata.MetadataType.MULTILINGUAL_STRING)
402        {
403            infos.put("titleVariants", _contentHelper.getTitleVariants(content));
404        }
405        infos.put("path", content.getPath());
406        infos.put("types", content.getTypes());
407        infos.put("mixins", content.getMixinTypes());
408        String lang = content.getLanguage();
409        if (lang != null)
410        {
411            infos.put("lang", lang);
412        }
413        infos.put("creator", _userHelper.user2json(content.getCreator()));
414        infos.put("lastContributor", _userHelper.user2json(content.getLastContributor()));
415        infos.put("creationDate", DateUtils.dateToString(content.getCreationDate()));
416        infos.put("lastModified", DateUtils.dateToString(content.getLastModified()));
417        infos.put("isSimple", _contentHelper.isSimple(content));
418        infos.put("isReferenceTable", _contentHelper.isReferenceTable(content));
419        infos.put("parent", _hierarchicalSimpleContentsHelper.getParent(content));
420        
421        if (content instanceof WorkflowAwareContent)
422        {
423            WorkflowAwareContent waContent = (WorkflowAwareContent) content;
424            AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(waContent);
425            
426            infos.put("workflowName", workflow.getWorkflowName(waContent.getWorkflowId()));
427            
428            List<Integer> workflowSteps = new ArrayList<>();
429            
430            List<Step> currentSteps = workflow.getCurrentSteps(waContent.getWorkflowId());
431            for (Step step : currentSteps)
432            {
433                workflowSteps.add(step.getStepId());
434            }
435            infos.put("workflowSteps", workflowSteps);
436            
437            int[] availableActions = _contentWorkflowHelper.getAvailableActions(waContent);
438            infos.put("availableActions", availableActions);
439        }
440        
441        if (content instanceof ModifiableContent)
442        {
443            infos.put("isModifiable", true);
444        }
445        
446        if (content instanceof LockAwareAmetysObject)
447        {
448            LockAwareAmetysObject lockableContent = (LockAwareAmetysObject) content;
449            if (lockableContent.isLocked())
450            {
451                infos.put("locked", true);
452                infos.put("lockOwner", _userHelper.user2json(lockableContent.getLockOwner()));
453                infos.put("canUnlock", _lockManager.canUnlock(lockableContent));
454            }
455        }
456        
457        infos.put("rights", getUserRights(content));
458        
459        Map<String, Object> additionalData = new HashMap<>();
460        
461        String[] contenttypes = content.getTypes();
462        for (String cTypeId : contenttypes)
463        {
464            ContentType cType = _contentTypeEP.getExtension(cTypeId);
465            if (cType != null)
466            {
467                additionalData.putAll(cType.getAdditionalData(content));
468            }
469        }
470        
471        if (!additionalData.isEmpty())
472        {
473            infos.put("additionalData", additionalData); 
474        }
475
476        infos.put("isTaggable", content instanceof TaggableAmetysObject);
477        
478        if (content instanceof ModifiableMetadataAwareVersionableAmetysObject)
479        {
480            ModifiableCompositeMetadata unversionedMetadataHolder = ((ModifiableMetadataAwareVersionableAmetysObject) content).getUnversionedMetadataHolder();
481            if (unversionedMetadataHolder.hasMetadata(ArchiveConstants.META_ARCHIVE_SCHEDULED_DATE))
482            {
483                Date scheduledDate = unversionedMetadataHolder.getDate(ArchiveConstants.META_ARCHIVE_SCHEDULED_DATE);
484                infos.put("scheduledArchivingDate", DateUtils.dateToString(scheduledDate));
485            }
486        }
487
488        return infos;
489    }
490    
491    /**
492     * Get the content's properties for description
493     * @param contentId The id of content
494     * @param workspaceName The workspace name. Can be null to get content in current workspace.
495     * @return The content's properties for description
496     */
497    @Callable
498    public Map<String, Object> getContentDescription (String contentId, String workspaceName)
499    {
500        Request request = ContextHelper.getRequest(_context);
501        String currentWorkspace = RequestAttributeWorkspaceSelector.getForcedWorkspace(request);
502        try
503        {
504            if (StringUtils.isNotEmpty(workspaceName))
505            {
506                RequestAttributeWorkspaceSelector.setForcedWorkspace(request, workspaceName);
507            }
508            
509            Content content = _resolver.resolveById(contentId);
510            return getContentDescription(content);
511        }
512        finally
513        {
514            RequestAttributeWorkspaceSelector.setForcedWorkspace(request, currentWorkspace);
515        }
516    }
517    
518    /**
519     *Get the content's properties for description
520     * @param content The content
521     * @return The content's properties for description
522     */
523    public Map<String, Object> getContentDescription (Content content)
524    {
525        Map<String, Object> infos = new HashMap<>();
526        
527        infos.put("id", content.getId());
528        infos.put("name", content.getName());
529        infos.put("title", _contentHelper.getTitle(content));
530        infos.put("types", content.getTypes());
531        infos.put("mixins", content.getMixinTypes());
532        infos.put("lang", content.getLanguage());
533        infos.put("creator", _userHelper.user2json(content.getCreator()));
534        infos.put("lastContributor", _userHelper.user2json(content.getLastContributor()));
535        infos.put("lastModified", DateUtils.dateToString(content.getLastModified()));
536        infos.put("iconGlyph", _cTypesHelper.getIconGlyph(content));
537        infos.put("iconDecorator", _cTypesHelper.getIconDecorator(content));
538        infos.put("smallIcon", _cTypesHelper.getSmallIcon(content));
539        infos.put("mediumIcon", _cTypesHelper.getMediumIcon(content));
540        infos.put("largeIcon", _cTypesHelper.getLargeIcon(content));
541        
542        return infos;
543    }
544    
545    /**
546     * Get the metadata sets of a content
547     * @param contentId the content's id
548     * @param edition Set to true to get edition metadata set. False otherwise.
549     * @param includeInternal Set to true to include internal metadata sets.
550     * @return the metadata sets
551     */
552    @Callable
553    public List<Map<String, Object>> getContentMetadataSets (String contentId, boolean edition, boolean includeInternal)
554    {
555        List<Map<String, Object>> metadataSets = new ArrayList<>();
556        
557        Content content = _resolver.resolveById(contentId);
558        String contentTypeId = _cTypesHelper.getContentTypeIdForRendering(content);
559        
560        ContentType cType = _contentTypeEP.getExtension(contentTypeId);
561        
562        Set<String> metadataSetNames = edition ? cType.getEditionMetadataSetNames(includeInternal) : cType.getViewMetadataSetNames(includeInternal);
563        for (String metadataSetName : metadataSetNames)
564        {
565            MetadataSet metadataSet = edition ? cType.getMetadataSetForEdition(metadataSetName) : cType.getMetadataSetForView(metadataSetName);
566            
567            Map<String, Object> viewInfos = new HashMap<>();
568            viewInfos.put("name", metadataSetName);
569            viewInfos.put("label", metadataSet.getLabel());
570            viewInfos.put("description", metadataSet.getDescription());
571            metadataSets.add(viewInfos);
572        }
573        
574        return metadataSets;
575    }
576    
577    /**
578     * Get the user rights on content
579     * @param content The content
580     * @return The user's rights
581     */
582    protected Set<String> getUserRights (Content content)
583    {
584        UserIdentity user = _currentUserProvider.getUser();
585        return _rightManager.getUserRights(user, content);
586    }
587    
588    /**
589     * Get the tags of contents
590     * @param contentIds The content's ids
591     * @return the tags
592     */
593    @Callable
594    public Set<String> getTags (List<String> contentIds)
595    {
596        Set<String> tags = new HashSet<>();
597        
598        for (String contentId : contentIds)
599        {
600            Content content = _resolver.resolveById(contentId);
601            tags.addAll(content.getTags());
602        }
603        
604        return tags;
605    }
606    
607    /**
608     * Tag a list of contents with the given tags
609     * @param contentIds The ids of contents to tag
610     * @param tagNames The tags
611     * @param contextualParameters The contextual parameters
612     * @return the result
613     */
614    @Callable
615    public Map<String, Object> tag (List<String> contentIds, List<String> tagNames, Map<String, Object> contextualParameters)
616    {
617        return tag(contentIds, tagNames, TagMode.REPLACE.toString(), contextualParameters);
618    }
619    
620    /**
621     * Tag a list of contents
622     * @param contentIds The ids of contents to tag
623     * @param tagNames The tags
624     * @param mode The mode for updating tags: 'REPLACE' to replace tags, 'INSERT' to add tags or 'REMOVE' to remove tags.
625     * @param contextualParameters The contextual parameters
626     * @return the result
627     */
628    @Callable
629    public Map<String, Object> tag (List<String> contentIds, List<String> tagNames, String mode, Map<String, Object> contextualParameters)
630    {
631        Map<String, Object> result = new HashMap<>();
632        
633        result.put("notaggable-contents", new ArrayList<Map<String, Object>>());
634        result.put("invalid-tags", new ArrayList<String>());
635        result.put("allright-contents", new ArrayList<Map<String, Object>>());
636        result.put("locked-contents", new ArrayList<Map<String, Object>>());
637        
638        for (String contentId : contentIds)
639        {
640            Content content = _resolver.resolveById(contentId);
641            
642            Map<String, Object> content2json = new HashMap<>();
643            content2json.put("id", content.getId());
644            content2json.put("title", _contentHelper.getTitle(content));
645            
646            if (content instanceof TaggableAmetysObject)
647            {
648                TaggableAmetysObject mContent = (TaggableAmetysObject) content;
649                
650                boolean wasLocked = false;
651                
652                if (content instanceof LockableAmetysObject)
653                {
654                    LockableAmetysObject lockableContent = (LockableAmetysObject) content;
655                    UserIdentity user = _currentUserProvider.getUser();
656                    if (lockableContent.isLocked() && !LockHelper.isLockOwner(lockableContent, user))
657                    {
658                        @SuppressWarnings("unchecked")
659                        List<Map<String, Object>> lockedContents = (List<Map<String, Object>>) result.get("locked-contents");
660                        content2json.put("lockOwner", lockableContent.getLockOwner());
661                        lockedContents.add(content2json);
662                        
663                        // Stop process
664                        continue;
665                    }
666                    
667                    if (lockableContent.isLocked())
668                    {
669                        wasLocked = true;
670                        lockableContent.unlock();
671                    }
672                }
673                
674                TagMode tagMode = TagMode.valueOf(mode);
675                
676                Set<String> oldTags = mContent.getTags();
677                _removeAllTagsInReplaceMode(mContent, tagMode, oldTags);
678                
679                // Then set new tags
680                for (String tagName : tagNames)
681                {
682                    if (_isTagValid(tagName, contextualParameters))
683                    {
684                        if (TagMode.REMOVE.equals(tagMode))
685                        {
686                            mContent.untag(tagName);
687                        }
688                        else if (TagMode.REPLACE.equals(tagMode) || !oldTags.contains(tagName))
689                        {
690                            mContent.tag(tagName);
691                        }
692                        
693                    }
694                    else
695                    {
696                        @SuppressWarnings("unchecked")
697                        List<String> invalidTags = (List<String>) result.get("invalid-tags");
698                        invalidTags.add(tagName);
699                    }
700                }
701                
702                ((ModifiableAmetysObject) content).saveChanges();
703                
704                if (wasLocked)
705                {
706                    // Relock content if it was locked before tagging
707                    ((LockableAmetysObject) content).lock();
708                }
709                
710                content2json.put("tags", content.getTags());
711                @SuppressWarnings("unchecked")
712                List<Map<String, Object>> allRightPages = (List<Map<String, Object>>) result.get("allright-contents");
713                allRightPages.add(content2json);
714                
715                if (!oldTags.equals(content.getTags()))
716                {
717                    // Notify observers that the content has been tagged
718                    Map<String, Object> eventParams = new HashMap<>();
719                    eventParams.put(org.ametys.cms.ObservationConstants.ARGS_CONTENT, content);
720                    eventParams.put(org.ametys.cms.ObservationConstants.ARGS_CONTENT_ID, content.getId());
721                    eventParams.put("content.tags", content.getTags());
722                    eventParams.put("content.old.tags", oldTags);
723                    _observationManager.notify(new Event(org.ametys.cms.ObservationConstants.EVENT_CONTENT_TAGGED, _currentUserProvider.getUser(), eventParams));
724                }
725            }
726            else
727            {
728                @SuppressWarnings("unchecked")
729                List<Map<String, Object>> notaggableContents = (List<Map<String, Object>>) result.get("notaggable-contents");
730                notaggableContents.add(content2json);
731            }
732        }
733        
734        return result;
735    }
736
737    private void _removeAllTagsInReplaceMode(TaggableAmetysObject mContent, TagMode tagMode, Set<String> oldTags)
738    {
739        if (TagMode.REPLACE.equals(tagMode))
740        {
741            // First delete old tags
742            for (String tagName : oldTags)
743            {
744                mContent.untag(tagName);
745            }
746        }
747    }
748    
749    /**
750     * Is the tag a content tag
751     * @param tagName The tag name
752     * @param contextualParameters The contextual parameters
753     * @return true if the tag is a valid content tag
754     */
755    public boolean _isTagValid (String tagName, Map<String, Object> contextualParameters)
756    {
757        CMSTag tag = _tagProvider.getTag(tagName, contextualParameters);
758        return tag.getTarget().getName().equals("CONTENT");
759    }
760    
761    /**
762     * Copy a content.
763     * @param originalContent the original content.
764     * @param parent the object in which to create a content.
765     * @param name the content name.
766     * @param initWorkflowActionId The initial workflow action id
767     * @return the copied content.
768     * @throws AmetysRepositoryException If an error occured
769     */
770    public ModifiableContent copy(DefaultContent originalContent, ModifiableTraversableAmetysObject parent, String name, int initWorkflowActionId) throws AmetysRepositoryException
771    {
772        return copy(originalContent, parent, name, null, initWorkflowActionId);
773    }
774    
775    /**
776     * Copy a content.
777     * @param originalContent the original content.
778     * @param parent the object in which to create a content.
779     * @param name the content name.
780     * @param lang the content language. If null, the content language will be the same of the original content 
781     * @param initWorkflowActionId The initial workflow action id
782     * @return the copied content.
783     * @throws AmetysRepositoryException If an error occured
784     */
785    public ModifiableContent copy(DefaultContent originalContent, ModifiableTraversableAmetysObject parent, String name, String lang, int initWorkflowActionId) throws AmetysRepositoryException
786    {
787        return copy(originalContent, parent, name, lang, initWorkflowActionId, true, false, false);
788    }
789    
790    /**
791     * Copy a content.
792     * @param originalContent the original content.
793     * @param parent the object in which to create a content.
794     * @param name the content name.
795     * @param lang the content language. If null, the content language will be the same of the original content 
796     * @param initWorkflowActionId The initial workflow action id
797     * @param notifyObservers Set to false to do not fire observer events
798     * @param waitAsyncObservers true to wait for asynchronous observers to complete
799     * @param copyACL true to copy ACL of source content
800     * @return the copied content.
801     * @throws AmetysRepositoryException If an error occured
802     */
803    public ModifiableContent copy(DefaultContent originalContent, ModifiableTraversableAmetysObject parent, String name, String lang, int initWorkflowActionId, boolean notifyObservers, boolean waitAsyncObservers, boolean copyACL) throws AmetysRepositoryException
804    {
805        try
806        {
807            String originalName = name == null ? originalContent.getName() : name;
808            String contentName = originalName;
809            int index = 2;
810            while (parent.hasChild(contentName))
811            {
812                contentName = originalName + "-" + (index++);
813            }
814            
815            String originalContentType = originalContent.getNode().getPrimaryNodeType().getName();
816            
817            ModifiableContent content = parent.createChild(contentName, originalContentType);
818            
819            String targetLanguage = lang == null ? originalContent.getLanguage() : lang;
820            if (targetLanguage != null)
821            {
822                content.setLanguage(targetLanguage);
823            }
824            
825            content.setTypes(originalContent.getTypes());
826            
827            _modifiableContentHelper.copyTitle(originalContent, content);
828            
829            if (originalContent instanceof WorkflowAwareContent)
830            {
831                WorkflowAwareContent waOriginalContent = (WorkflowAwareContent) originalContent;
832                AmetysObjectWorkflow originalContentWorkflow = _workflowProvider.getAmetysObjectWorkflow(waOriginalContent);
833                String workflowName = originalContentWorkflow.getWorkflowName(waOriginalContent.getWorkflowId());
834                
835                // Initialize new content workflow
836                WorkflowAwareContent waContent = (WorkflowAwareContent) content;
837                AmetysObjectWorkflow workflow = _workflowProvider.getAmetysObjectWorkflow(waContent);
838                
839                HashMap<String, Object> inputs = new HashMap<>();
840                // Provide the content key
841                inputs.put(AbstractContentWorkflowComponent.CONTENT_KEY, waContent);
842                
843                long workflowId = workflow.initialize(workflowName, initWorkflowActionId, inputs);
844                waContent.setWorkflowId(workflowId);
845                
846                // Set the current step ID property
847                Step currentStep = (Step) workflow.getCurrentSteps(workflowId).iterator().next();
848                waContent.setCurrentStepId(currentStep.getStepId());
849                
850                Node workflowEntryNode = null;
851                Node node = waContent.getNode();
852                Session session = node.getSession();
853                
854                
855                try
856                {
857                    AbstractJackrabbitWorkflowStore workflowStore = (AbstractJackrabbitWorkflowStore) workflow.getConfiguration().getWorkflowStore();
858                    
859                    if (workflowStore instanceof AmetysObjectWorkflowStore)
860                    {
861                        AmetysObjectWorkflowStore ametysObjectWorkflowStore = (AmetysObjectWorkflowStore) workflowStore;
862                        ametysObjectWorkflowStore.bindAmetysObject(waContent);
863                    }
864                    
865                    workflowEntryNode = workflowStore.getEntryNode(session, workflowId);
866                    workflowEntryNode.setProperty("ametys-internal:initialActionId", initWorkflowActionId);
867                }
868                catch (RepositoryException e)
869                {
870                    throw new AmetysRepositoryException("Unable to link the workflow to the content", e);
871                }
872            }
873            
874            // Copy metadata
875            originalContent.getMetadataHolder().copyTo(content.getMetadataHolder());
876            
877            if (copyACL)
878            {
879                _copyACL(originalContent, content);
880            }
881            
882            if (_currentUserProvider.getUser() != null)
883            {
884                content.setCreator(_currentUserProvider.getUser());
885                content.setLastModified(new Date());
886                content.setCreationDate(new Date());
887            }
888            
889            parent.saveChanges();
890            
891            // Create a new version
892            if (content instanceof VersionableAmetysObject)
893            {
894                ((VersionableAmetysObject) content).checkpoint();
895            }
896
897            if (notifyObservers)
898            {
899                _notifyContentCopied(content, waitAsyncObservers);
900            }
901            
902            return content;
903        }
904        catch (WorkflowException e)
905        {
906            throw new AmetysRepositoryException(e);
907        }
908        catch (RepositoryException e)
909        {
910            throw new AmetysRepositoryException(e);
911        }
912    }
913    
914    /**
915     * Copy the ACL of a content
916     * @param srcContent The source content
917     * @param targetContent The target content
918     */
919    protected void _copyACL(Content srcContent, Content targetContent)
920    {
921        if (srcContent instanceof DefaultContent && targetContent instanceof DefaultContent)
922        {
923            Node srcNode = ((DefaultContent) srcContent).getNode();
924            Node targetNode = ((DefaultContent) targetContent).getNode();
925            
926            try
927            {
928                String aclNodeName = "ametys-internal:acl";
929                if (srcNode.hasNode(aclNodeName))
930                {
931                    Node aclNode = srcNode.getNode(aclNodeName);
932                    aclNode.getSession().getWorkspace().copy(aclNode.getPath(), targetNode.getPath() + "/" + aclNodeName);
933                }
934            }
935            catch (RepositoryException e)
936            {
937                getLogger().error("Failed to copy ACL from content " + srcContent + " to content " + targetContent, e);
938            }
939        }
940    }
941    
942    /**
943     * Notify observers that the content has been created
944     * @param content The content added
945     * @param waitAsyncObservers true to wait for asynchonous observers to finish
946     * @throws WorkflowException If an error occurred
947     */
948    protected void _notifyContentCopied(Content content, boolean waitAsyncObservers) throws WorkflowException
949    {
950        Map<String, Object> eventParams = new HashMap<>();
951        eventParams.put(ObservationConstants.ARGS_CONTENT, content);
952        eventParams.put(ObservationConstants.ARGS_CONTENT_ID, content.getId());
953
954        List<Future> futures = _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_ADDED, _currentUserProvider.getUser(), eventParams));
955        futures.addAll(_observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_WORKFLOW_CHANGED, _currentUserProvider.getUser(), eventParams)));
956        
957        if (waitAsyncObservers)
958        {
959            // Wait for asynchonous observers to finish
960            for (Future future : futures)
961            {
962                try
963                {
964                    future.get();
965                }
966                catch (ExecutionException | InterruptedException e)
967                {
968                    getLogger().error(String.format("Error while waiting for async observer to complete"));
969                }
970            }
971        }
972    }
973    
974    /**
975     * Returns the content's attachments root node
976     * @param id the content's id
977     * @return The attachments' root node informations
978     */
979    @Callable
980    public Map<String, Object> getAttachmentsRootNode (String id)
981    {
982        Map<String, Object> result = new HashMap<>();
983        
984        Content content = _resolver.resolveById(id);
985        
986        result.put("title", _contentHelper.getTitle(content));
987        result.put("contentId", content.getId());
988        
989        TraversableAmetysObject attachments = content.getRootAttachments();
990        
991        if (attachments != null)
992        {
993            result.put("id", attachments.getId());
994            if (attachments instanceof ModifiableAmetysObject)
995            {
996                result.put("isModifiable", true);
997            }
998            if (attachments instanceof ModifiableResourceCollection)
999            {
1000                result.put("canCreateChild", true);
1001            }
1002            
1003            boolean hasChildNodes = false;
1004            boolean hasResources = false;
1005
1006            for (AmetysObject child : attachments.getChildren())
1007            {
1008                if (child instanceof Resource)
1009                {
1010                    hasResources = true;
1011                }
1012                else if (child instanceof ExplorerNode)
1013                {
1014                    hasChildNodes = true;
1015                }
1016            }
1017
1018            if (hasChildNodes)
1019            {
1020                result.put("hasChildNodes", true);
1021            }
1022
1023            if (hasResources)
1024            {
1025                result.put("hasResources", true);
1026            }
1027            
1028            return result;
1029        }
1030        
1031        throw new IllegalArgumentException("Content with id '" + id + "' does not support attachments.");
1032    }
1033    
1034    /**
1035     * Dertermines if the current user has right to delete the content
1036     * @param content The content
1037     * @return true if current user is authorized to delete the content
1038     */
1039    public boolean canDelete(Content content)
1040    {
1041        UserIdentity user = _currentUserProvider.getUser();
1042        if (_rightManager.hasRight(user, "CMS_Rights_DeleteContent", content) == RightResult.RIGHT_ALLOW)
1043        {
1044            return true;
1045        }
1046        
1047        return false;
1048    }
1049    
1050    /**
1051     * Add or remove a reaction on a content
1052     * @param contentId The content id
1053     * @param reactionName the reaction name (ex: LIKE)
1054     * @param remove true to remove the reaction, false to add reaction
1055     * @return the result with the current actors of this reaction
1056     */
1057    @Callable
1058    public Map<String, Object> react(String contentId, String reactionName, boolean remove)
1059    {
1060        Map<String, Object> result = new HashMap<>();
1061        
1062        Content content = _resolver.resolveById(contentId);
1063        
1064        if (_rightManager.currentUserHasReadAccess(content))
1065        {
1066            ReactionType reactionType = ReactionType.valueOf(reactionName);
1067            UserIdentity actor = _currentUserProvider.getUser();
1068            
1069            boolean updated = remove ? unreact(content, actor, reactionType) : react(content, actor, reactionType);
1070            result.put("updated", updated);
1071            result.put("contentId", contentId);
1072            result.put("actors", _userHelper.userIdentities2json(((ReactionableObject) content).getReactionUsers(reactionType)));
1073        }
1074        else
1075        {
1076            result.put("unauthorized", true);
1077            result.put("updated", false);
1078        }
1079
1080        return result;
1081    }
1082    
1083    /**
1084     * Add a reaction on a {@link Content}.
1085     * @param content the content
1086     * @param userIdentity the issuer of reaction
1087     * @param reactionType the reaction type
1088     * @return true if a change was made
1089     */
1090    public boolean react(Content content, UserIdentity userIdentity, ReactionType reactionType)
1091    {
1092        return _addOrRemoveReaction(content, userIdentity, reactionType, false);
1093    }
1094    
1095    /**
1096     * Remove reaction if exists on a {@link Content}.
1097     * @param content the content
1098     * @param userIdentity the issuer of reaction
1099     * @param reactionType the reaction type
1100     * @return true if a change was made
1101     */
1102    public boolean unreact(Content content, UserIdentity userIdentity, ReactionType reactionType)
1103    {
1104        return _addOrRemoveReaction(content, userIdentity, reactionType, true);
1105    }
1106    
1107    private boolean _addOrRemoveReaction(Content content, UserIdentity userIdentity, ReactionType reactionType, boolean remove)
1108    {
1109        if (content instanceof ReactionableObject)
1110        {
1111            boolean hasChanges = false;
1112            
1113            List<UserIdentity> reactionIssuers = ((ReactionableObject) content).getReactionUsers(reactionType);
1114            if (!remove && !reactionIssuers.contains(userIdentity))
1115            {
1116                ((ReactionableObject) content).addReaction(userIdentity, reactionType);
1117                hasChanges = true;
1118            }
1119            else if (remove && reactionIssuers.contains(userIdentity))
1120            {
1121                ((ReactionableObject) content).removeReaction(userIdentity, reactionType);
1122                hasChanges = true;
1123            }
1124            
1125            if (hasChanges)
1126            {
1127                ((DefaultContent) content).saveChanges();
1128                
1129                Map<String, Object> eventParams = new HashMap<>();
1130                eventParams.put(ObservationConstants.ARGS_CONTENT, content);
1131                eventParams.put(ObservationConstants.ARGS_REACTION_TYPE, reactionType);
1132                eventParams.put(ObservationConstants.ARGS_REACTION_ISSUER, userIdentity);
1133                _observationManager.notify(new Event(ObservationConstants.EVENT_CONTENT_REACTION_CHANGED, userIdentity, eventParams));
1134                
1135                return true;
1136            }
1137        }
1138        
1139        return false;
1140    }
1141}