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.plugins.workspaces.documents;
017
018import java.io.IOException;
019import java.io.InputStream;
020import java.util.ArrayList;
021import java.util.Collection;
022import java.util.Collections;
023import java.util.HashMap;
024import java.util.LinkedList;
025import java.util.List;
026import java.util.Map;
027import java.util.Objects;
028import java.util.Optional;
029import java.util.Set;
030import java.util.function.Function;
031import java.util.stream.Collectors;
032
033import javax.jcr.RepositoryException;
034
035import org.apache.avalon.framework.service.ServiceException;
036import org.apache.avalon.framework.service.ServiceManager;
037import org.apache.cocoon.components.ContextHelper;
038import org.apache.cocoon.environment.Request;
039import org.apache.cocoon.servlet.multipart.Part;
040import org.apache.commons.io.IOUtils;
041import org.apache.commons.lang.IllegalClassException;
042import org.apache.commons.lang3.StringUtils;
043import org.apache.excalibur.source.Source;
044import org.apache.excalibur.source.SourceResolver;
045
046import org.ametys.cms.content.indexing.solr.SolrFieldNames;
047import org.ametys.cms.search.query.DocumentTypeQuery;
048import org.ametys.cms.search.query.FilenameQuery;
049import org.ametys.cms.search.query.FullTextQuery;
050import org.ametys.cms.search.query.MatchAllQuery;
051import org.ametys.cms.search.query.MimeTypeGroupQuery;
052import org.ametys.cms.search.query.OrQuery;
053import org.ametys.cms.search.query.Query;
054import org.ametys.cms.search.query.Query.Operator;
055import org.ametys.cms.search.query.StringQuery;
056import org.ametys.cms.search.solr.SearcherFactory;
057import org.ametys.cms.tag.Tag;
058import org.ametys.cms.transformation.xslt.ResolveURIComponent;
059import org.ametys.core.right.RightManager.RightResult;
060import org.ametys.core.ui.Callable;
061import org.ametys.core.user.UserIdentity;
062import org.ametys.core.util.DateUtils;
063import org.ametys.core.util.FilenameUtils;
064import org.ametys.core.util.URIUtils;
065import org.ametys.plugins.explorer.ExplorerNode;
066import org.ametys.plugins.explorer.ModifiableExplorerNode;
067import org.ametys.plugins.explorer.cmis.CMISRootResourcesCollection;
068import org.ametys.plugins.explorer.resources.ModifiableResource;
069import org.ametys.plugins.explorer.resources.ModifiableResourceCollection;
070import org.ametys.plugins.explorer.resources.Resource;
071import org.ametys.plugins.explorer.resources.ResourceCollection;
072import org.ametys.plugins.explorer.resources.actions.AddOrUpdateResourceHelper;
073import org.ametys.plugins.explorer.resources.actions.AddOrUpdateResourceHelper.ResourceOperationMode;
074import org.ametys.plugins.explorer.resources.actions.AddOrUpdateResourceHelper.ResourceOperationResult;
075import org.ametys.plugins.explorer.resources.actions.ExplorerResourcesDAO;
076import org.ametys.plugins.explorer.resources.jcr.JCRResource;
077import org.ametys.plugins.explorer.resources.jcr.JCRResourcesCollection;
078import org.ametys.plugins.explorer.threads.jcr.JCRPost;
079import org.ametys.plugins.repository.AmetysObject;
080import org.ametys.plugins.repository.AmetysObjectIterable;
081import org.ametys.plugins.repository.AmetysRepositoryException;
082import org.ametys.plugins.repository.ModifiableAmetysObject;
083import org.ametys.plugins.repository.RemovableAmetysObject;
084import org.ametys.plugins.repository.jcr.JCRTraversableAmetysObject;
085import org.ametys.plugins.repository.lock.LockableAmetysObject;
086import org.ametys.plugins.repository.tag.TagAwareAmetysObject;
087import org.ametys.plugins.workspaces.WorkspacesHelper;
088import org.ametys.plugins.workspaces.documents.onlyoffice.OnlyOfficeManager;
089import org.ametys.plugins.workspaces.html.HTMLTransformer;
090import org.ametys.plugins.workspaces.indexing.solr.SolrWorkspacesConstants;
091import org.ametys.plugins.workspaces.project.ProjectManager;
092import org.ametys.plugins.workspaces.project.modules.WorkspaceModuleExtensionPoint;
093import org.ametys.plugins.workspaces.project.objects.Project;
094import org.ametys.plugins.workspaces.search.query.KeywordQuery;
095import org.ametys.plugins.workspaces.search.query.ProjectQuery;
096import org.ametys.plugins.workspaces.tags.ProjectTagProviderExtensionPoint;
097import org.ametys.plugins.workspaces.tags.ProjectTagsDAO;
098import org.ametys.runtime.authentication.AccessDeniedException;
099import org.ametys.runtime.plugin.component.PluginAware;
100import org.ametys.web.WebConstants;
101
102/**
103 * DAO for resources of a project
104 */
105public class WorkspaceExplorerResourceDAO extends ExplorerResourcesDAO implements PluginAware
106{
107    /** Avalon Role */
108    @SuppressWarnings("hiding")
109    public static final String ROLE = WorkspaceExplorerResourceDAO.class.getName();
110    
111    /**
112     * Enumeration for resource type
113     */
114    public static enum ResourceType
115    {
116        /** Folder */
117        FOLDER,
118        /** File */
119        FILE
120    }
121    
122    /** resource operation helper */
123    protected AddOrUpdateResourceHelper _addOrUpdateResourceHelper;
124    
125    private ProjectManager _projectManager;
126    private SearcherFactory _searcherFactory;
127    private WorkspaceModuleExtensionPoint _moduleEP;
128    private WorkspacesHelper _workspaceHelper;
129    private OnlyOfficeManager _onlyOfficeManager;
130    private HTMLTransformer _htmlTransformer;
131    private SourceResolver _sourceResolver;
132    
133    private String _pluginName;
134    
135    private ProjectTagProviderExtensionPoint _tagProviderExtensionPoint;
136    private ProjectTagsDAO _projectTagsDAO;
137    
138    @Override
139    public void service(ServiceManager manager) throws ServiceException
140    {
141        super.service(manager);
142        _projectManager = (ProjectManager) manager.lookup(ProjectManager.ROLE);
143        _htmlTransformer = (HTMLTransformer) manager.lookup(HTMLTransformer.ROLE);
144        _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE);
145        _addOrUpdateResourceHelper = (AddOrUpdateResourceHelper) manager.lookup(AddOrUpdateResourceHelper.ROLE);
146        _searcherFactory = (SearcherFactory) manager.lookup(SearcherFactory.ROLE);
147        _moduleEP = (WorkspaceModuleExtensionPoint) manager.lookup(WorkspaceModuleExtensionPoint.ROLE);
148        _workspaceHelper = (WorkspacesHelper) manager.lookup(WorkspacesHelper.ROLE);
149        _onlyOfficeManager = (OnlyOfficeManager) manager.lookup(OnlyOfficeManager.ROLE);
150        _tagProviderExtensionPoint = (ProjectTagProviderExtensionPoint) manager.lookup(ProjectTagProviderExtensionPoint.ROLE);
151        _projectTagsDAO = (ProjectTagsDAO) manager.lookup(ProjectTagsDAO.ROLE);
152    }
153    
154    @Override
155    public void setPluginInfo(String pluginName, String featureName, String id)
156    {
157        _pluginName = pluginName;
158    }
159    
160    /**
161     * Add a folder
162     * @param parentId Identifier of the parent collection. Can be null to add folder to root folder.
163     * @param inputName The desired name
164     * @param description The folder description
165     * @return The created folder or an error if a folder with same name already exists.
166     * @throws IllegalAccessException If the user has no sufficient rights
167     */
168    @Callable
169    public Map<String, Object> addFolder(String parentId, String inputName, String description) throws IllegalAccessException
170    {
171        return addFolder(parentId, inputName, description, false);
172    }
173    
174    /**
175     * Add a folder
176     * @param parentId Identifier of the parent collection. Can be null to add folder to root folder.
177     * @param inputName The desired name
178     * @param description The folder description
179     * @param renameIfExists True to rename if existing
180     * @return The result map with id, parentId and name keys
181     */
182    @Callable
183    public Map<String, Object> addFolder(String parentId, String inputName, String description, Boolean renameIfExists)
184    {
185        ResourceCollection document = _getRootIfNull(parentId);
186        if (document == null)
187        {
188            throw new IllegalArgumentException("Unable to add folder: parent folder not found");
189        }
190        
191        if (_rightManager.hasRight(_currentUserProvider.getUser(), RIGHTS_COLLECTION_ADD, document) != RightResult.RIGHT_ALLOW)
192        {
193            throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to add folder without convenient right [" + RIGHTS_COLLECTION_ADD + "]");
194        }
195        
196        if (!(document instanceof ModifiableResourceCollection))
197        {
198            throw new IllegalClassException(ModifiableResourceCollection.class, document.getClass());
199        }
200        
201        List<String> errors = new LinkedList<>();
202        ResourceCollection collection = addResourceCollection((ModifiableResourceCollection) document, inputName, renameIfExists, errors);
203        
204        Map<String, Object> result = new HashMap<>();
205        if (!errors.isEmpty())
206        {
207            result.put("message", errors.get(0));
208            result.put("error", true);
209        }
210        
211        if (collection != null)
212        {
213            ((ModifiableResourceCollection) collection).setDescription(description);
214            ((ModifiableResourceCollection) collection).saveChanges();
215            
216            result.putAll(_extractFolderData(collection));
217        }
218        
219        return result;
220    }
221    
222    /**
223     * Rename a folder
224     * @param id Identifier of the folder to edit
225     * @param name The new name
226     * @return The result map with id and name keys
227     */
228    @Callable
229    public Map<String, Object> renameFolder(String id, String name)
230    {
231        Map<String, Object> result = new HashMap<>();
232        
233        JCRResourcesCollection folder = (JCRResourcesCollection) _resolver.resolveById(id);
234        
235        if (_rightManager.hasRight(_currentUserProvider.getUser(), RIGHTS_COLLECTION_EDIT, folder) != RightResult.RIGHT_ALLOW)
236        {
237            throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to edit folder without convenient right [" + RIGHTS_COLLECTION_EDIT + "]");
238        }
239        
240        List<String> errors = new LinkedList<>();
241        JCRResourcesCollection newFolder = null;
242        try
243        {
244            newFolder = (JCRResourcesCollection) renameObject(folder, name, errors);
245            if (!errors.isEmpty())
246            {
247                result.put("success", false);
248                result.put("message", errors.get(0));
249            }
250            else
251            {
252                newFolder.saveChanges();
253
254                result.put("success", true);
255                result.putAll(_extractFolderData(newFolder));
256            }
257        }
258        catch (RepositoryException e)
259        {
260            getLogger().error("Repository exception during folder edition.", e);
261            errors.add("repository");
262        }
263        
264        return result;
265    }
266    
267    /**
268     * Edit a folder
269     * @param id Identifier of the folder to edit
270     * @param inputName The desired name
271     * @param description The folder description
272     * @return The result map with id and name keys
273     */
274    @Callable
275    public Map<String, Object> editFolder(String id, String inputName, String description)
276    {
277        JCRResourcesCollection folder = (JCRResourcesCollection) _resolver.resolveById(id);
278        
279        if (_rightManager.hasRight(_currentUserProvider.getUser(), RIGHTS_COLLECTION_EDIT, folder) != RightResult.RIGHT_ALLOW)
280        {
281            throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to edit folder without convenient right [" + RIGHTS_COLLECTION_EDIT + "]");
282        }
283        
284        List<String> errors = new LinkedList<>();
285        JCRResourcesCollection newFolder = null;
286        try
287        {
288            newFolder = (JCRResourcesCollection) renameObject(folder, inputName, errors);
289        }
290        catch (RepositoryException e)
291        {
292            getLogger().error("Repository exception during folder edition.", e);
293            errors.add("repository");
294        }
295        
296        Map<String, Object> result = new HashMap<>();
297        if (!errors.isEmpty())
298        {
299            String error = errors.get(0);
300            
301            // existing node is allowed, the description can still be changed.
302            if (!"already-exist".equals(error))
303            {
304                result.put("message", error);
305            }
306            else
307            {
308                newFolder = folder;
309            }
310        }
311        
312        if (newFolder != null)
313        {
314            newFolder.setDescription(description);
315            newFolder.saveChanges();
316            
317            result.putAll(_extractFolderData(newFolder));
318        }
319        
320        return result;
321    }
322    
323    /**
324     * Delete a folder
325     * @param id Identifier of the folder to delete
326     * @return The result map with the parent id key
327     */
328    @Callable
329    public Map<String, Object> deleteFolder(String id)
330    {
331        RemovableAmetysObject folder = (RemovableAmetysObject) _resolver.resolveById(id);
332        
333        if (_rightManager.hasRight(_currentUserProvider.getUser(), RIGHTS_COLLECTION_DELETE, folder) != RightResult.RIGHT_ALLOW)
334        {
335            throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to delete folder without convenient right [" + RIGHTS_COLLECTION_DELETE + "]");
336        }
337        
338        List<String> errors = new LinkedList<>();
339        String parentId = deleteObject(folder, errors);
340        
341        Map<String, Object> result = new HashMap<>();
342        if (!errors.isEmpty())
343        {
344            String error = errors.get(0);
345            result.put("message", error);
346        }
347        
348        // include parent id except if it is the root
349        ResourceCollection rootCollection = _getRootFromRequest();
350        boolean isRoot = rootCollection != null && rootCollection.getId().equals(parentId);
351        if (StringUtils.isNotEmpty(parentId) && !isRoot)
352        {
353            result.put("parentId", parentId);
354        }
355        
356        return result;
357    }
358    
359    /**
360     * Lock resources
361     * @param ids The id of resources to lock
362     * @return the result.
363     */
364    @Callable
365    public Map<String, Object> lockResources(List<String> ids)
366    {
367        Map<String, Object> result = new HashMap<>();
368        
369        result.put("locked-resources", new ArrayList<String>());
370        result.put("unlocked-resources", new ArrayList<Map<String, Object>>());
371        
372        for (String id : ids)
373        {
374            JCRResource resource = _resolver.resolveById(id);
375            
376            if (!resource.isLocked())
377            {
378                resource.lock();
379                
380                @SuppressWarnings("unchecked")
381                List<String> lockedResources = (List<String>) result.get("locked-resources");
382                lockedResources.add(id);
383            }
384            else if (!resource.getLockOwner().equals(_currentUserProvider.getUser()))
385            {
386                UserIdentity lockOwner = resource.getLockOwner();
387                
388                getLogger().error("Unable to lock resource of id '" + id + "': the resource is already locked by user " + UserIdentity.userIdentityToString(lockOwner));
389                
390                Map<String, Object> info = new HashMap<>();
391                info.put("id", id);
392                info.put("name", resource.getName());
393                info.put("lockOwner", _userHelper.user2json(lockOwner));
394                
395                @SuppressWarnings("unchecked")
396                List<Map<String, Object>> unlockedResources = (List<Map<String, Object>>) result.get("unlocked-resources");
397                unlockedResources.add(info);
398            }
399        }
400        
401        return result;
402    }
403    
404    /**
405     * Unlock resources
406     * @param ids The id of resources to lock
407     * @return the result.
408     */
409    @Callable
410    public Map<String, Object> unlockResources(List<String> ids)
411    {
412        UserIdentity currentUser = _currentUserProvider.getUser();
413        boolean canUnlockAll = _rightManager.hasRight(currentUser, RIGHTS_RESOURCE_UNLOCK_ALL, "/cms") == RightResult.RIGHT_ALLOW;
414        
415        Map<String, Object> result = new HashMap<>();
416        
417        result.put("unlocked-resources", new ArrayList<String>());
418        result.put("still-locked-resources", new ArrayList<Map<String, Object>>());
419        
420        for (String id : ids)
421        {
422            JCRResource resource = _resolver.resolveById(id);
423            
424            if (resource.isLocked())
425            {
426                if (canUnlockAll || resource.getLockOwner().equals(currentUser))
427                {
428                    resource.unlock();
429                    
430                    @SuppressWarnings("unchecked")
431                    List<String> unlockedResources = (List<String>) result.get("unlocked-resources");
432                    unlockedResources.add(id);
433                }
434                else
435                {
436                    UserIdentity lockOwner = resource.getLockOwner();
437                    
438                    getLogger().error("Unable to unlock resource of id '" + id + "': the resource is locked by user " + UserIdentity.userIdentityToString(lockOwner));
439                    
440                    Map<String, Object> info = new HashMap<>();
441                    info.put("id", id);
442                    info.put("name", resource.getName());
443                    info.put("lockOwner", _userHelper.user2json(lockOwner));
444                    
445                    @SuppressWarnings("unchecked")
446                    List<Map<String, Object>> stilllockedResources = (List<Map<String, Object>>) result.get("still-locked-resources");
447                    stilllockedResources.add(info);
448                }
449            }
450            else
451            {
452                @SuppressWarnings("unchecked")
453                List<String> unlockedResources = (List<String>) result.get("unlocked-resources");
454                unlockedResources.add(id);
455            }
456        }
457        
458        return result;
459    }
460    
461    /**
462     * Determines if a resource with given name already exists
463     * @param parentId the id of parent collection. Can be null.
464     * @param name the name of resource
465     * @return true if a resource with same name exists
466     */
467    @Callable
468    @Override
469    public boolean resourceExists(String parentId, String name)
470    {
471        ResourceCollection folder = _getRootIfNull(parentId);
472        return folder != null && resourceExists(folder, name);
473    }
474    
475    /**
476     * Rename a folder
477     * @param id Identifier of the folder to edit
478     * @param name The new name
479     * @return The result map with id and name keys
480     */
481    @Callable
482    public Map<String, Object> renameFile(String id, String name)
483    {
484        Map<String, Object> result = new HashMap<>();
485        
486        JCRResource file = (JCRResource) _resolver.resolveById(id);
487        if (_rightManager.hasRight(_currentUserProvider.getUser(), RIGHTS_RESOURCE_RENAME, file) != RightResult.RIGHT_ALLOW)
488        {
489            throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to rename file without convenient right [" + RIGHTS_RESOURCE_RENAME + "]");
490        }
491        
492        if (StringUtils.isNotEmpty(name) && !StringUtils.equals(file.getName(), name))
493        {
494            List<String> errors = new LinkedList<>();
495            JCRResource renamedFile = null;
496            try
497            {
498                renamedFile = renameResource(file, name, errors);
499                if (!errors.isEmpty())
500                {
501                    result.put("success", false);
502                    result.put("message", errors.get(0));
503                }
504                else
505                {
506                    renamedFile.saveChanges();
507
508                    result.put("success", true);
509                    result.putAll(_extractFileData(renamedFile));
510                }
511            }
512            catch (RepositoryException e)
513            {
514                getLogger().error("Repository exception during file edition.", e);
515                errors.add("repository");
516            }
517            
518        }
519        
520        return result;
521    }
522    
523    
524    /**
525     * Edit a file
526     * @param id Identifier of the file to edit
527     * @param inputName The desired name
528     * @param description The file description
529     * @param tags The file tags 
530     * @return The result map with id and name keys
531     */
532    @Callable
533    public Map<String, Object> editFile(String id, String inputName, String description, Collection<String> tags)
534    {
535        Map<String, Object> result = new HashMap<>();
536        JCRResource file = (JCRResource) _resolver.resolveById(id);
537        
538        // Check lock on resource
539        if (!checkLock(file))
540        {
541            getLogger().warn("User '{}' is trying to edit file '{}' but it is locked by another user", _currentUserProvider.getUser(), file.getName());
542            result.put("message", "locked");
543            return result;
544        }
545        
546        List<String> errors = new LinkedList<>();
547        
548        // Rename
549        JCRResource renamedFile = null;
550        if (StringUtils.isNotEmpty(inputName) && !StringUtils.equals(file.getName(), inputName))
551        {
552            if (_rightManager.hasRight(_currentUserProvider.getUser(), RIGHTS_RESOURCE_RENAME, file) != RightResult.RIGHT_ALLOW)
553            {
554                throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to rename file without convenient right [" + RIGHTS_RESOURCE_RENAME + "]");
555            }
556            
557            try
558            {
559                renamedFile = renameResource(file, inputName, errors);
560                
561                if (errors.isEmpty())
562                {
563                    file = renamedFile;
564                }
565            }
566            catch (RepositoryException e)
567            {
568                getLogger().error("Repository exception during folder edition.", e);
569                errors.add("repository-rename");
570            }
571        }
572        
573        if (!errors.isEmpty())
574        {
575            String error = errors.get(0);
576            result.put("message", error);
577            return result;
578        }
579        
580        // edit description + tags
581        List<String> fileTags = _sanitizeFileTags(tags);
582        
583        if (_rightManager.hasRight(_currentUserProvider.getUser(), RIGHTS_RESOURCE_EDIT_DC, file) != RightResult.RIGHT_ALLOW)
584        {
585            throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to edit dc for file without convenient right [" + RIGHTS_RESOURCE_EDIT_DC + "]");
586        }
587        
588        Map<String, Object> editValues = new HashMap<>();
589        editValues.put("dc_description", StringUtils.defaultIfEmpty(description, null));
590        
591        try
592        {
593            file.setKeywords(fileTags.toArray(new String[fileTags.size()]));
594            setDCMetadata(file, editValues);
595            
596            // Add tags to the project
597            _projectManager.addTags(fileTags);
598            
599            file.saveChanges();
600        }
601        catch (AmetysRepositoryException e)
602        {
603            getLogger().error("Repository exception during folder edition.", e);
604            errors.add("repository-edit");
605        }
606        
607        if (!errors.isEmpty())
608        {
609            String error = errors.get(0);
610            result.put("message", error);
611        }
612        else
613        {
614            result.putAll(_extractFileData(file));
615        }
616        
617        return result;
618    }
619    
620    /**
621     * Set tags to resource
622     * @param resourceId the id of resources
623     * @param tags the file tags to set
624     * @return the file tags
625     */
626    @Callable
627    public Map<String, Object> setTags(String resourceId, List<Object> tags)
628    {
629        List<String> createdTags = new ArrayList<>();
630        List<Map<String, Object>> createdTagsJson = new ArrayList<>();
631        
632        JCRResource file = (JCRResource) _resolver.resolveById(resourceId);
633        if (_rightManager.hasRight(_currentUserProvider.getUser(), RIGHTS_RESOURCE_EDIT_DC, file) != RightResult.RIGHT_ALLOW)
634        {
635            throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to tag file without convenient right [" + RIGHTS_RESOURCE_EDIT_DC + "]");
636        }
637        
638        Set<String> oldTags = file.getTags();
639        for (Object tag : tags)
640        {
641            // Tag doesn't exist so create the tag
642            if (tag instanceof Map)
643            {
644                @SuppressWarnings("unchecked")
645                String tagText = (String) ((Map<String, Object>) tag).get("text");
646                List<Map<String, Object>> newTags = _projectTagsDAO.addTags(new String[] {tagText});
647                String newTag = (String) newTags.get(0).get("name");
648                file.tag(newTag);
649                createdTags.add(newTag);
650                createdTagsJson.addAll(newTags);
651            }
652            else
653            {
654                file.tag((String) tag);
655            }
656        }
657        
658        // Untag unused tags
659        for (String oldTag : oldTags)
660        {
661            if (!tags.contains(oldTag) && !createdTags.contains(oldTag))
662            {
663                file.untag(oldTag);
664            }
665        }
666        
667        file.saveChanges();
668        
669        Map<String, Object> results = new HashMap<>();
670        results.put("fileTags", _tags2json(file));
671        results.put("newTags", createdTagsJson);
672        
673        return results;
674    }
675    
676    /**
677     * Copy file resources
678     * @param ids The list of identifiers for the resources to copy
679     * @param targetId The id of target to copy into. Can be null to copy into root folder.
680     * @return The result map with a message key in case of an error or with the list of uncopied/copied resources
681     * @throws RepositoryException If there is a repository error
682     */
683    @Callable
684    public Map<String, Object> copyFiles(List<String> ids, String targetId) throws RepositoryException
685    {
686        ResourceCollection folder = _getRootIfNull(targetId);
687        if (folder == null)
688        {
689            throw new IllegalArgumentException("Unable to copy files: parent folder not found");
690        }
691        
692        if (!(folder instanceof ModifiableResourceCollection))
693        {
694            throw new IllegalClassException(ModifiableResourceCollection.class, folder.getClass());
695        }
696        
697        return copyResource(ids, (ModifiableResourceCollection) folder);
698    }
699    
700    /**
701     * Move documents (files or folders)
702     * @param ids The list of identifiers for the objects to move
703     * @param targetId The id of target to move into. Can be null to move documents to root folder.
704     * @return The result map with a message key in case of an error or with the list of unmoved/moved objects
705     * @throws RepositoryException If there is a repository error
706     */
707    @Callable
708    public Map<String, Object> moveDocuments(List<String> ids, String targetId) throws RepositoryException
709    {
710        ResourceCollection folder = _getRootIfNull(targetId);
711        if (folder == null)
712        {
713            throw new IllegalArgumentException("Unable to move documents: parent folder not found");
714        }
715        
716        if (!(folder instanceof JCRTraversableAmetysObject))
717        {
718            throw new IllegalClassException(JCRTraversableAmetysObject.class, folder.getClass());
719        }
720        
721        return moveObject(ids, (JCRTraversableAmetysObject) folder);
722    }
723    
724    /**
725     * Search for files in the document module
726     * @param query The search query
727     * @param lang The search language
728     * @return A result map containing a <em>resources</em> entry which is a list of the files data
729     * @throws Exception if an exception occurs
730     */
731    @Callable
732    public Map<String, Object> searchFiles(String query, String lang) throws Exception
733    {
734        // Handle result map
735        Map<String, Object> result = new HashMap<>();
736        
737        String escapedQuery = query.replace("\"", "\\\"");
738        
739        Query solrQuery;
740        if (StringUtils.isEmpty(query))
741        {
742            solrQuery = new MatchAllQuery();
743        }
744        else
745        {
746            List<Query> queries = new ArrayList<>();
747            queries.add(new FilenameQuery(query));
748            queries.add(new StringQuery(SolrFieldNames.TITLE, Operator.LIKE, "*" + query + "*", null));
749            queries.add(new FullTextQuery(escapedQuery, lang));
750            queries.add(new KeywordQuery(escapedQuery.split(" ")));
751            solrQuery = new OrQuery(queries);
752        }
753        
754        AmetysObjectIterable<Resource> results = _searcherFactory.create()
755                .withQuery(solrQuery)
756                .addFilterQuery(new DocumentTypeQuery(SolrWorkspacesConstants.TYPE_PROJECT_RESOURCE))
757                .addFilterQuery(new ProjectQuery(_getProjectFromRequest().getId()))
758                .search();
759        
760        List<Map<String, Object>> resourceData = results.stream()
761            .map(this::_extractFileData)
762            .collect(Collectors.toList());
763        
764        result.put("resources", resourceData);
765        
766        return result;
767    }
768    
769    /**
770     * Search for files by their type
771     * @param type The file type
772     * @return A result map containing a <em>resources</em> entry which is a list of the files data
773     * @throws Exception if an exception occurs
774     */
775    @Callable
776    public Map<String, Object> searchFilesByType(String type) throws Exception
777    {
778        Map<String, Object> result = new HashMap<>();
779        
780        AmetysObjectIterable<Resource> results = _searcherFactory.create()
781                .withQuery(new MimeTypeGroupQuery(type))
782                .addFilterQuery(new DocumentTypeQuery(SolrWorkspacesConstants.TYPE_PROJECT_RESOURCE))
783                .addFilterQuery(new ProjectQuery(_getProjectFromRequest().getId()))
784                .search();
785        
786        List<Map<String, Object>> resourceData = results.stream()
787            .map(this::_extractFileData)
788            .collect(Collectors.toList());
789        
790        result.put("resources", resourceData);
791        
792        return result;
793    }
794    
795    /**
796     * Get the root folder
797     * @return the root folder as JSON object
798     */
799    @Callable
800    public Map<String, Object> getRootFolder()
801    {
802        return getFolder(null);
803    }
804    
805    /**
806     * Get folder
807     * @param folderId the folder id
808     * @return the folder as JSON object
809     */
810    @Callable
811    public Map<String, Object> getFolder(String folderId)
812    {
813        ResourceCollection collection = _getRootIfNull(folderId);
814        return _extractFolderData(collection);
815    }
816    
817    /**
818     * Get file
819     * @param resourceId the resource id
820     * @return the file as JSON object
821     */
822    @Callable
823    public Map<String, Object> getFile(String resourceId)
824    {
825        Resource resource = _resolver.resolveById(resourceId);
826        return _extractFileData(resource);
827    }
828    /**
829     * Get the child folders
830     * @param parentId the parent id. Can be null to get root folders
831     * @return the sub folders
832     */
833    @Callable
834    public List<Map<String, Object>> getFolders(String parentId)
835    {
836        return getChildDocumentsData(parentId, false, true);
837    }
838    
839    /**
840     * Get the child files
841     * @param parentId the parent id. Can be null to get root files
842     * @return the child files
843     */
844    @Callable
845    public List<Map<String, Object>> getFiles(String parentId)
846    {
847        return getChildDocumentsData(parentId, true, false);
848    }
849    
850    /**
851     * Get the child folders and files
852     * @param folderId the parent id. Can be null to get root folders and files
853     * @return the child folders and files
854     */
855    @Callable
856    public Map<String, Object> getFoldersAndFiles(String folderId)
857    {
858        ResourceCollection collection = _getRootIfNull(folderId);
859        
860        Map<String, Object> data = _extractFolderData(collection);
861        
862        data.put("files", getChildDocumentsData(folderId, true, false));
863        data.put("children", getChildDocumentsData(folderId, false, true));
864        
865        return data;
866    }
867    
868    
869    /**
870     * Retrieves the children of a document and extracts its data.
871     * @param parentId Identifier of the parent collection. Can be null to get children of root folder.
872     * @param excludeFolders Folders will be excluded if true
873     * @param excludeFiles Files will be excluded if true
874     * @return The map of information
875     */
876    @Callable
877    public List<Map<String, Object>> getChildDocumentsData(String parentId, boolean excludeFolders, boolean excludeFiles)
878    {
879        ResourceCollection document = _getRootIfNull(parentId);
880        if (document == null)
881        {
882            throw new IllegalArgumentException("Unable to get child documents: parent folder not found");
883        }
884        return getChildDocumentsData(document, excludeFolders, excludeFiles);
885    }
886    
887    /**
888     * Retrieves the children of a document and extracts its data.
889     * @param document the document
890     * @param excludeFolders Folders will be excluded if true
891     * @param excludeFiles Files will be excluded if true
892     * @return The map of information
893     */
894    public List<Map<String, Object>> getChildDocumentsData(ResourceCollection document, boolean excludeFolders, boolean excludeFiles)
895    {
896        ResourceCollection parent = _getRootIfNull(document);
897        if (parent == null)
898        {
899            throw new IllegalArgumentException("Unable to get child documents: parent folder not found");
900        }
901        
902        return parent.getChildren().stream()
903            .map(child -> _extractDocumentData(child, excludeFolders, excludeFiles))
904            .filter(Objects::nonNull)
905            .collect(Collectors.toList());
906    }
907    
908    /**
909     * Retrieves the set of standard data for a document (folder or resource)
910     * @param id the document id or null to get root document
911     * @param excludeFilesInFolderHierarchy Should child files be taken into account when extracting data of a folder
912     * @return The map of data
913     */
914    // TODO To remove not used
915    @Deprecated
916    @Callable
917    public Map<String, Object> getDocumentData(String id, boolean excludeFilesInFolderHierarchy)
918    {
919        AmetysObject document = id == null ? _getRootFromRequest() : _resolver.resolveById(id);
920        if (document == null)
921        {
922            throw new IllegalArgumentException("No project found in request to get root document data");
923        }
924        
925        Map<String, Object> data = _extractDocumentData(document, false, false);
926        return data != null ? data : new HashMap<>();
927    }
928    
929    /**
930     * Retrieves the set of standard data for a list of documents
931     * @param ids The list of document identifiers
932     * @param excludeFilesInFolderHierarchy Should child files be taken into account when extracting data of a folder 
933     * @return The map of data
934     */
935    // TODO To remove not used
936    @Callable
937    public List<Map<String, Object>> getDocumentsData(List<String> ids, boolean excludeFilesInFolderHierarchy)
938    {
939        return ids.stream()
940            .map(id -> getDocumentData(id, excludeFilesInFolderHierarchy))
941            .collect(Collectors.toList());
942    }
943    
944    @Override
945    protected Map<String, Object> _comment2json(JCRPost comment, boolean isEdition)
946    {
947        Map<String, Object> comment2json = super._comment2json(comment, isEdition);
948        
949        String lang = _getCurrentLanguage();
950        String authorImgUrl = _workspaceHelper.getAvatar(comment.getAuthor(), lang, 30);
951        
952        @SuppressWarnings("unchecked")
953        Map<String, Object> author = (Map<String, Object>) comment2json.get("author");
954        author.put("imgUrl", authorImgUrl);
955        
956        return comment2json;
957    }
958    
959    @Override
960    protected Map<String, Object> _version2json(JCRResource resource, VersionInformation versionInformation) throws RepositoryException
961    {
962        Map<String, Object> version2json =  super._version2json(resource, versionInformation);
963        
964        String lang = _getCurrentLanguage();
965        
966        @SuppressWarnings("unchecked")
967        Map<String, Object> author = (Map<String, Object>) version2json.get("author");
968        String authorImgUrl = _workspaceHelper.getAvatar(resource.getLastContributor(), lang, 30);
969        author.put("imgUrl", authorImgUrl);
970        
971        return version2json;
972        
973    }
974    
975    /**
976     * Generates an uri to open a document through webdav
977     * @param documentId The document identifier
978     * @return The generated uri
979     */
980    @Callable
981    public String generateWebdavUri(String documentId)
982    {
983        return ResolveURIComponent.resolve("webdav-project-resource", documentId, false, true);
984    }
985    
986    private ResourceCollection _getRootIfNull(ResourceCollection document)
987    {
988        return document != null ? document : _getRootFromRequest();
989    }
990    
991    private ResourceCollection _getRootIfNull(String documentId)
992    {
993        return StringUtils.isNotEmpty(documentId)
994                ? (ResourceCollection) _resolver.resolveById(documentId)
995                : _getRootFromRequest();
996    }
997    
998    private ResourceCollection _getRootFromRequest()
999    {
1000        Project project = _getProjectFromRequest();
1001        
1002        if (project != null)
1003        {
1004            return _getRootFromProject(project);
1005        }
1006        else
1007        {
1008            return null;
1009        }
1010    }
1011    
1012    private ResourceCollection _getRootFromObject(AmetysObject ametysObject)
1013    {
1014        Project project = _getProjectFomObject(ametysObject);
1015        if (project != null)
1016        {
1017            return _getRootFromProject(project);
1018        }
1019        return null;
1020    }
1021    
1022    private ResourceCollection _getRootFromProject(Project project)
1023    {
1024        if (project != null)
1025        {
1026            DocumentWorkspaceModule module = _moduleEP.getModule(DocumentWorkspaceModule.DOCUMENT_MODULE_ID);
1027            return module.getModuleRoot(project, false);
1028        }
1029        else
1030        {
1031            return null;
1032        }
1033    }
1034    
1035    private Project _getProjectFromRequest()
1036    {
1037        Request request = ContextHelper.getRequest(_context);
1038        
1039        String projectName = (String) request.getAttribute("projectName");
1040        if (projectName != null)
1041        {
1042            return _projectManager.getProject(projectName);
1043        }
1044        else
1045        {
1046            return null;
1047        }
1048    }
1049    
1050    private Project _getProjectFomObject(AmetysObject ametysObject)
1051    {
1052        AmetysObject parent = ametysObject;
1053        
1054        while (parent != null && !(parent instanceof Project))
1055        {
1056            parent = parent.getParent();
1057        }
1058        
1059        if (parent == null)
1060        {
1061            return null;
1062        }
1063        return (Project) parent;
1064    }
1065    
1066    private String _getCurrentLanguage()
1067    {
1068        Request request = ContextHelper.getRequest(_context);
1069        return (String) request.getAttribute(WebConstants.REQUEST_ATTR_SITEMAP_NAME);
1070    }
1071    
1072    /**
1073     * Internal method to extract the valuable data of a document
1074     * @param document The document (file or folder)
1075     * @param excludeFolders Folders will be excluded if true
1076     * @param excludeFiles Files will be excluded if true
1077     * @return the valuable document data
1078     */
1079    protected Map<String, Object> _extractDocumentData(AmetysObject document, boolean excludeFolders, boolean excludeFiles)
1080    {
1081        if (!excludeFiles && document instanceof Resource)
1082        {
1083            Resource file = (Resource) document;
1084            if (_canView(file))
1085            {
1086                return _extractFileData(file);
1087            }
1088        }
1089        else if (!excludeFolders && document instanceof ResourceCollection)
1090        {
1091            ResourceCollection folder = (ResourceCollection) document;
1092            if (_canView(folder))
1093            {
1094                return _extractFolderData(folder);
1095            }
1096        }
1097        
1098        return null;
1099    }
1100    
1101    /**
1102     * Internal method to extract the valuable data of a folder
1103     * @param folder The folder
1104     * @return the valuable folder data
1105     */
1106    protected Map<String, Object> _extractFolderData(ResourceCollection folder)
1107    {
1108        Map<String, Object> data = new HashMap<>();
1109        
1110        data.put("id", folder.getId());
1111        data.put("name", folder.getName());
1112        data.put("path", _getFolderPath(folder));
1113        data.put("type", ResourceType.FOLDER.name().toLowerCase());
1114        data.put("description", StringUtils.defaultString(folder.getDescription()));
1115        
1116        AmetysObject parent = folder.getParent();
1117        if (parent != null && parent instanceof ResourceCollection)
1118        {
1119            data.put("location", ((ResourceCollection) parent).getName());
1120            data.put("parentId", parent.getId());
1121        }
1122        
1123        boolean hasChildren = _hasChildren(folder, true);
1124        if (hasChildren)
1125        {
1126            data.put("children", Collections.EMPTY_LIST);
1127        }
1128        
1129        data.put("modifiable", folder instanceof ModifiableAmetysObject);
1130        data.put("canCreateChild", folder instanceof ModifiableExplorerNode);
1131        data.put("rights", _extractFolderRightData(folder));
1132
1133        data.put("notification", false); // TODO unread notification (not yet supported)
1134        
1135        return data;
1136    }
1137    
1138    private List<String> _getFolderPath(ResourceCollection folder)
1139    {
1140        List<String> paths = new ArrayList<>();
1141        
1142        ResourceCollection rootDocuments = _getRootFromObject(folder);
1143        
1144        if (!rootDocuments.equals(folder))
1145        {
1146            List<ExplorerNode> parents = new ArrayList<>();
1147            
1148            AmetysObject parent = folder.getParent();
1149            while (parent instanceof ExplorerNode && !parent.equals(rootDocuments))
1150            {
1151                parents.add((ExplorerNode) parent);
1152                parent = parent.getParent();
1153            }
1154            
1155            parents.add(rootDocuments);
1156            
1157            Collections.reverse(parents);
1158            
1159            parents.stream().forEach(p -> 
1160            {
1161                paths.add(p.getId());
1162            });
1163        }
1164        
1165        return paths;
1166    }
1167    
1168    /**
1169     * Internal method to detect if a document has child
1170     * @param folder The folder
1171     * @param ignoreFiles Should child files be taken into account to compute the 'hasChildDocuments' data.
1172     * @return the valuable folder data
1173     */
1174    protected boolean _hasChildren(ResourceCollection folder, boolean ignoreFiles)
1175    {
1176        try (AmetysObjectIterable<AmetysObject> children = folder.getChildren())
1177        {
1178            for (AmetysObject child : children)
1179            {
1180                if (child instanceof ResourceCollection && _canView((ResourceCollection) child)
1181                    || !ignoreFiles && child instanceof Resource && _canView((Resource) child))
1182                {
1183                    return true;
1184                }
1185            }
1186            
1187            return false;
1188        }
1189    }
1190    
1191    /**
1192     * Internal method to extract the data concerning the right of the current user for a folder
1193     * @param folder The folder
1194     * @return The map of right data. Keys are the rights id, and values indicates whether the current user has the right or not.
1195     */
1196    protected  Map<String, Object> _extractFolderRightData(ResourceCollection folder)
1197    {
1198        Map<String, Object> rightsData = new HashMap<>();
1199        UserIdentity user = _currentUserProvider.getUser();
1200        
1201        // Add
1202        rightsData.put("add-file", _rightManager.hasRight(user, RIGHTS_RESOURCE_ADD, folder) == RightResult.RIGHT_ALLOW);
1203        rightsData.put("add-folder", _rightManager.hasRight(user, RIGHTS_COLLECTION_ADD, folder) == RightResult.RIGHT_ALLOW);
1204        rightsData.put("add-cmis-folder", _rightManager.hasRight(user, "Plugin_Explorer_CMIS_Add", folder) == RightResult.RIGHT_ALLOW);
1205        
1206        // Rename - Edit
1207        rightsData.put("edit", _rightManager.hasRight(user, RIGHTS_COLLECTION_EDIT, folder) == RightResult.RIGHT_ALLOW);
1208        
1209        // Delete
1210        rightsData.put("delete", _rightManager.hasRight(user, RIGHTS_COLLECTION_DELETE, folder) == RightResult.RIGHT_ALLOW);
1211        // FIXME Delete own?
1212        
1213        return rightsData;
1214    }
1215    
1216    /**
1217     * Add a file
1218     * @param part The uploaded part corresponding to the file
1219     * @param parentId Identifier of the parent collection
1220     * @param unarchive True if the file is an archive that should be unarchived (only available for ZIP file)
1221     * @param allowRename True if the file can be renamed if it already exists
1222     * @param allowUpdate True if the file can be updated if it already exists (and allowRename is false)
1223     * @return The result map with id, parentId and name keys
1224     */
1225    @Callable
1226    public Map<String, Object> addFile(Part part, String parentId, boolean unarchive, boolean allowRename, boolean allowUpdate)
1227    {
1228        ModifiableResourceCollection modifiableFolder = getModifiableResourceCollection(parentId);
1229        _addOrUpdateResourceHelper.checkAddResourceRight(modifiableFolder);
1230        
1231        ResourceOperationMode mode = getOperationMode(unarchive, allowRename, allowUpdate);
1232        
1233        ResourceOperationResult operationResult = _addOrUpdateResourceHelper.performResourceOperation(part, modifiableFolder, mode);
1234        
1235        // Handle result map
1236        return generateActionResult(modifiableFolder, operationResult);
1237    }
1238    /**
1239     * Add a file
1240     * @param inputStream The uploaded input stream
1241     * @param fileName desired file name
1242     * @param parentId Identifier of the parent collection
1243     * @param unarchive True if the file is an archive that should be unarchived (only available for ZIP file)
1244     * @param allowRename True if the file can be renamed if it already exists
1245     * @param allowUpdate True if the file can be updated if it already exists (and allowRename is false)
1246     * @return The result map with id, parentId and name keys
1247     */
1248    public Map<String, Object> addFile(InputStream inputStream, String fileName, String parentId, boolean unarchive, boolean allowRename, boolean allowUpdate)
1249    {
1250        ModifiableResourceCollection modifiableFolder = getModifiableResourceCollection(parentId);
1251        ResourceOperationMode mode = getOperationMode(unarchive, allowRename, allowUpdate);
1252        
1253        ResourceOperationResult operationResult = _addOrUpdateResourceHelper.performResourceOperation(inputStream, fileName, modifiableFolder, mode);
1254        
1255        // Handle result map
1256        return generateActionResult(modifiableFolder, operationResult);
1257    }
1258    
1259    /**
1260     * get a {@link ModifiableResourceCollection} for an ID, or the root folder;
1261     * @param ametysId id of the resource. Can be null to get root folder.
1262     * @return ModifiableResourceCollection
1263     * @throws IllegalClassException if id links to a node which is not a {@link ModifiableResourceCollection}
1264     */
1265    private ModifiableResourceCollection getModifiableResourceCollection(String ametysId)
1266    {
1267        ResourceCollection folder = _getRootIfNull(ametysId);
1268        
1269        if (folder == null)
1270        {
1271            throw new IllegalArgumentException("Root folder not found");
1272        }
1273        
1274        if (!(folder instanceof ModifiableResourceCollection))
1275        {
1276            throw new IllegalClassException(ModifiableResourceCollection.class, folder.getClass());
1277        }
1278        
1279        return (ModifiableResourceCollection) folder;
1280    }
1281    /**
1282     * returns the {@link ResourceOperationMode} according to parameters
1283     * @param unarchive unarchive
1284     * @param allowRename allowRename
1285     * @param allowUpdate allowUpdate
1286     * @return ADD, ADD_UNZIP, ADD_RENAME, ADD_UPDATE
1287     */
1288    private ResourceOperationMode getOperationMode(boolean unarchive, boolean allowRename, boolean allowUpdate)
1289    {
1290        ResourceOperationMode mode = ResourceOperationMode.ADD;
1291        if (unarchive)
1292        {
1293            mode = ResourceOperationMode.ADD_UNZIP;
1294        }
1295        else if (allowRename)
1296        {
1297            mode = ResourceOperationMode.ADD_RENAME;
1298        }
1299        else if (allowUpdate)
1300        {
1301            mode = ResourceOperationMode.UPDATE;
1302        }
1303        return mode;
1304    }
1305    private Map<String, Object> generateActionResult(ResourceCollection folder, ResourceOperationResult operationResult)
1306    {
1307        Map<String, Object> result = new HashMap<>();
1308        
1309        if (operationResult.isSuccess())
1310        {
1311            List<Map<String, Object>> resourceData = operationResult.getResources()
1312                    .stream()
1313                    .filter(r -> r.getParent().equals(folder)) // limit to direct children
1314                    .map(this::_extractFileData)
1315                    .collect(Collectors.toList());
1316            
1317            result.put("resources", resourceData);
1318            result.put("unzip", operationResult.isUnzip());
1319        }
1320        else
1321        {
1322            result.put("message", operationResult.getErrorMessage());
1323        }
1324        
1325        return result;
1326    }
1327    
1328    /**
1329     * Internal method to extract the valuable data of a file
1330     * @param file The file
1331     * @return the valuable file data
1332     */
1333    protected  Map<String, Object> _extractFileData(Resource file)
1334    {
1335        Map<String, Object> data = new HashMap<>();
1336        
1337        data.put("id", file.getId());
1338        data.put("name", file.getName());
1339        data.put("path", _getFilePath(file));
1340        
1341        // Encode path without extension
1342        String resourcePath = file.getResourcePath();
1343        int i = resourcePath.lastIndexOf(".");
1344        resourcePath = i != -1 ? resourcePath.substring(0, i) : resourcePath; 
1345        // Encode twice
1346        String encodedPath = FilenameUtils.encodePath(resourcePath);
1347        data.put("encodedPath", URIUtils.encodeURI(encodedPath, Map.of()));
1348        
1349        data.put("type", ResourceType.FILE.name().toLowerCase());
1350        data.put("fileType", _workspaceHelper.getFileType(file).name().toLowerCase());
1351        data.put("fileExtension", StringUtils.substringAfterLast(file.getName(), "."));
1352        
1353        AmetysObject parent = file.getParent();
1354        if (parent != null && parent instanceof ResourceCollection)
1355        {
1356            data.put("location", ((ResourceCollection) parent).getName());
1357            data.put("parentId", parent.getId());
1358            data.put("parentPath", ((ResourceCollection) parent).getExplorerPath());
1359        }
1360        
1361        data.put("modifiable", file instanceof ModifiableResource);
1362        data.put("canCreateChild", file instanceof ModifiableExplorerNode);
1363        
1364        data.put("description", file.getDCDescription());
1365        data.put("tags", _tags2json(file));
1366        data.put("mimetype", file.getMimeType());
1367        data.put("length", String.valueOf(file.getLength()));
1368        
1369        boolean image = _workspaceHelper.isImage(file);
1370        if (image)
1371        {
1372            data.put("image", true);
1373        }
1374        
1375        data.put("hasOnlyOfficePreview", _onlyOfficeManager.canBePreviewed(file.getId()));
1376        
1377        UserIdentity creatorIdentity = file.getCreator();
1378        data.put("creator", _userHelper.user2json(creatorIdentity));
1379        data.put("creationDate", DateUtils.dateToString(file.getCreationDate()));
1380        
1381        UserIdentity contribIdentity = file.getLastContributor();
1382        data.put("author", _userHelper.user2json(contribIdentity));
1383        data.put("lastModified", DateUtils.dateToString(file.getLastModified()));
1384        
1385        data.put("rights", _extractFileRightData(file));
1386        
1387        data.putAll(_extractFileLockData(file));
1388        
1389        return data;
1390    }
1391    
1392    private List<Map<String, Object>> _tags2json(Resource file)
1393    {
1394        return ((TagAwareAmetysObject) file).getTags()
1395            .stream()
1396            .filter(tag -> _tagProviderExtensionPoint.hasTag(tag, Map.of()))
1397            .map(tag -> _tagProviderExtensionPoint.getTag(tag, Map.of()))
1398            .map(this::_tag2json)
1399            .collect(Collectors.toList());
1400    }
1401    
1402    private Map<String, Object> _tag2json(Tag tag)
1403    {
1404        Map<String, Object> tagMap = new HashMap<>();
1405        tagMap.put("text", tag.getTitle());
1406        tagMap.put("name", tag.getName());
1407        tagMap.put("color", null); // FIXME tag color is not supported yet
1408        
1409        return tagMap;
1410    }
1411    
1412    private List<String> _getFilePath(Resource file)
1413    {
1414        return _getFolderPath(file.getParent());
1415    }
1416    
1417    /**
1418     * Internal method to extract the data concerning the right of the current user for file
1419     * @param file The file
1420     * @return The map of right data. Keys are the rights id, and values indicates whether the current user has the right or not.
1421     */
1422    protected  Map<String, Object> _extractFileRightData(Resource file)
1423    {
1424        Map<String, Object> rightsData = new HashMap<>();
1425        UserIdentity user = _currentUserProvider.getUser();
1426        ResourceCollection folder = file.getParent();
1427        
1428        // Rename - Edit
1429        rightsData.put("rename", _rightManager.hasRight(user, RIGHTS_RESOURCE_RENAME, folder) == RightResult.RIGHT_ALLOW);
1430        rightsData.put("edit", _rightManager.hasRight(user, RIGHTS_RESOURCE_EDIT_DC, folder) == RightResult.RIGHT_ALLOW);
1431        
1432        // Delete
1433        rightsData.put("delete", _rightManager.hasRight(user, RIGHTS_RESOURCE_DELETE, folder) == RightResult.RIGHT_ALLOW);
1434        
1435        // TODO Delete own - no ability to detect document creator currently
1436        // rightsData.put("delete-own", ...);
1437        
1438        // Unlock
1439        rightsData.put("unlock", _rightManager.hasRight(user, RIGHTS_RESOURCE_UNLOCK_ALL, folder) == RightResult.RIGHT_ALLOW);
1440       
1441        // Comments
1442        rightsData.put("comment", _rightManager.hasRight(user, RIGHTS_RESOURCE_COMMENT, folder) == RightResult.RIGHT_ALLOW);
1443        rightsData.put("moderate-comments", _rightManager.hasRight(user, RIGHTS_RESOURCE_MODERATE_COMMENT, folder) == RightResult.RIGHT_ALLOW);
1444        
1445        return rightsData;
1446    }
1447    
1448    /**
1449     * Internal method to extract the data relative to the lock state of a file
1450     * @param file The file
1451     * @return The image specific data
1452     */
1453    protected  Map<String, Object> _extractFileLockData(Resource file)
1454    {
1455        Map<String, Object> lockData = new HashMap<>();
1456        
1457        if (file instanceof LockableAmetysObject)
1458        {
1459            boolean isLocked = ((LockableAmetysObject) file).isLocked();
1460            lockData.put("locked", isLocked);
1461            
1462            if (isLocked)
1463            {
1464                UserIdentity lockOwner = ((LockableAmetysObject) file).getLockOwner();
1465                
1466                lockData.put("isLockOwner", lockOwner.equals(_currentUserProvider.getUser()));
1467                lockData.put("lockOwner", _userHelper.user2json(lockOwner));
1468            }
1469        }
1470        
1471        return lockData;
1472    }
1473    
1474    private List<String> _sanitizeFileTags(Collection<String> tags) throws AmetysRepositoryException
1475    {
1476        // Enforce lowercase and remove possible duplicate tags
1477        return Optional.ofNullable(tags).orElseGet(ArrayList::new).stream()
1478                .map(String::trim)
1479                .map(String::toLowerCase)
1480                .distinct()
1481                .collect(Collectors.toList());
1482    }
1483    
1484    @Override
1485    protected void _setComment(JCRPost comment, String content)
1486    {
1487        try
1488        {
1489            _htmlTransformer.transform(content, comment.getContent());
1490        }
1491        catch (IOException e)
1492        {
1493            throw new AmetysRepositoryException("Failed to transform comment into rich text", e);
1494        }
1495    }
1496    
1497    @Override
1498    protected String _getComment(JCRPost post) throws AmetysRepositoryException
1499    {
1500        Source contentSource = null;
1501        try
1502        {
1503            Map<String, Object> parameters = new HashMap<>();
1504            parameters.put("source", post.getContent().getInputStream());
1505            contentSource = _sourceResolver.resolveURI("cocoon://_plugins/" + _pluginName + "/convert/html2html", null, parameters);
1506            return IOUtils.toString(contentSource.getInputStream(), "UTF-8");
1507        }
1508        catch (IOException e)
1509        {
1510            throw new AmetysRepositoryException("Failed to transform rich text into string", e);
1511        }
1512        finally
1513        {
1514            _sourceResolver.release(contentSource);
1515        }
1516    }
1517    
1518    @Override
1519    protected String _getCommentForEditing(JCRPost post) throws AmetysRepositoryException
1520    {
1521        try
1522        {
1523            StringBuilder sb = new StringBuilder();
1524            _htmlTransformer.transformForEditing(post.getContent(), sb);
1525            return sb.toString();
1526        }
1527        catch (IOException e)
1528        {
1529            throw new AmetysRepositoryException("Failed to transform rich text into string", e);
1530        }
1531    }
1532    
1533    /**
1534     * Indicates if the current user can view the folder
1535     * @param folder The folder to test
1536     * @return true if the folder can be viewed
1537     */
1538    protected boolean _canView(ResourceCollection folder)
1539    {
1540        return _rightManager.currentUserHasReadAccess(folder);
1541    }
1542    
1543    /**
1544     * Indicates if the current user can view the file
1545     * @param file The file to test
1546     * @return true if the file can be viewed 
1547     */
1548    protected boolean _canView(Resource file)
1549    {
1550        return _rightManager.currentUserHasReadAccess(file.getParent());
1551    }
1552    
1553    @Override
1554    @Callable
1555    public Map<String, String> getCMISProperties(String id)
1556    {
1557        // override to allow calls from workspaces
1558        return super.getCMISProperties(id);
1559    }
1560    
1561    @Override
1562    @Callable
1563    public Map<String, Object> addCMISCollection(String parentId, String originalName, String url, String login, String password, String repoId, boolean renameIfExists)
1564    {
1565        String rootId = parentId == null ? _getRootFromRequest().getId() : parentId;
1566        if (rootId == null)
1567        {
1568            throw new IllegalArgumentException("Unable to add CMIS collection: parent folder not found.");
1569        }
1570        return super.addCMISCollection(rootId, originalName, url, login, password, repoId, renameIfExists);
1571    }
1572    
1573    /**
1574     * Edits a CMIS folder (see {@link CMISRootResourcesCollection})
1575     * 
1576     * @param id the id of CMIS folder
1577     * @param name The name of the CMIS folder
1578     * @param url The url of CMIS repository
1579     * @param login The user's login to access CMIS repository
1580     * @param password The user's password to access CMIS repository
1581     * @param repoId The id of CMIS repository
1582     * @return the result map with id of edited node
1583     * @throws RepositoryException If an error occurred
1584     */
1585    @Callable
1586    public Map<String, Object> editCMISCollection(String id, String name, String url, String login, String password, String repoId) throws RepositoryException
1587    {
1588        List<String> errors = new LinkedList<>();
1589        try
1590        {
1591            renameObject(id, name);
1592        }
1593        catch (RepositoryException e)
1594        {
1595            getLogger().error("Repository exception during CMIS folder edition.", e);
1596            errors.add("repository");
1597        }
1598        
1599        // override to allow calls from workspaces
1600        Map<String, Object> result = super.editCMISCollection(id, url, login, password, repoId);
1601        if (errors.size() > 0)
1602        {
1603            result.put("errors", errors);
1604        }
1605        return result;
1606    }
1607    
1608    @Override
1609    @Callable
1610    public boolean isCMISCollection(String id)
1611    {
1612        // override to allow calls from workspaces
1613        return super.isCMISCollection(id);
1614    }
1615    
1616    /**
1617     * Count the total of documents in the project
1618     * @param project The project
1619     * @return The total of documents, or null if the module is not activated
1620     */
1621    public Long getDocumentsCount(Project project)
1622    {
1623        Function<Project, ResourceCollection> getModuleRoot = proj -> _moduleEP.getModule(DocumentWorkspaceModule.DOCUMENT_MODULE_ID).getModuleRoot(proj, false);
1624        return Optional.ofNullable(project)
1625                .map(getModuleRoot)
1626                .map(root -> _getChildDocumentsCount(root))
1627                .orElse(null);
1628    }
1629
1630    private Long _getChildDocumentsCount(ResourceCollection collection)
1631    {
1632        return collection.getChildren().stream()
1633                // Count the number of documents : a document counts as 1, a folder count has the sum of its children. Anything else is ignored (0)
1634                .map(ao -> ao instanceof Resource ? 1L : ao instanceof ResourceCollection ? _getChildDocumentsCount((ResourceCollection) ao) : 0L)
1635                .reduce(0L, Long::sum);
1636    }
1637}