001/*
002 *  Copyright 2016 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.project;
017
018import java.time.ZonedDateTime;
019import java.util.ArrayList;
020import java.util.Arrays;
021import java.util.Collection;
022import java.util.HashMap;
023import java.util.HashSet;
024import java.util.Iterator;
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;
032import java.util.stream.Stream;
033import java.util.stream.StreamSupport;
034
035import javax.jcr.Node;
036import javax.jcr.PathNotFoundException;
037import javax.jcr.Property;
038import javax.jcr.RepositoryException;
039import javax.jcr.Session;
040import javax.jcr.Value;
041
042import org.apache.avalon.framework.component.Component;
043import org.apache.avalon.framework.context.Context;
044import org.apache.avalon.framework.context.ContextException;
045import org.apache.avalon.framework.context.Contextualizable;
046import org.apache.avalon.framework.logger.AbstractLogEnabled;
047import org.apache.avalon.framework.service.ServiceException;
048import org.apache.avalon.framework.service.ServiceManager;
049import org.apache.avalon.framework.service.Serviceable;
050import org.apache.commons.collections.CollectionUtils;
051import org.apache.commons.lang.ArrayUtils;
052import org.apache.commons.lang3.StringUtils;
053
054import org.ametys.cms.FilterNameHelper;
055import org.ametys.core.observation.Event;
056import org.ametys.core.observation.ObservationManager;
057import org.ametys.core.ui.Callable;
058import org.ametys.core.user.CurrentUserProvider;
059import org.ametys.core.user.UserIdentity;
060import org.ametys.core.util.I18nUtils;
061import org.ametys.core.util.LambdaUtils;
062import org.ametys.plugins.explorer.ExplorerNode;
063import org.ametys.plugins.repository.AmetysObject;
064import org.ametys.plugins.repository.AmetysObjectIterable;
065import org.ametys.plugins.repository.AmetysObjectResolver;
066import org.ametys.plugins.repository.AmetysRepositoryException;
067import org.ametys.plugins.repository.ModifiableAmetysObject;
068import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
069import org.ametys.plugins.repository.RemovableAmetysObject;
070import org.ametys.plugins.repository.RepositoryConstants;
071import org.ametys.plugins.repository.TraversableAmetysObject;
072import org.ametys.plugins.repository.UnknownAmetysObjectException;
073import org.ametys.plugins.repository.jcr.JCRAmetysObject;
074import org.ametys.plugins.repository.jcr.JCRTraversableAmetysObject;
075import org.ametys.plugins.repository.jcr.NodeTypeHelper;
076import org.ametys.plugins.repository.query.expression.Expression;
077import org.ametys.plugins.repository.query.expression.Expression.Operator;
078import org.ametys.plugins.workspaces.ObservationConstants;
079import org.ametys.plugins.workspaces.members.ProjectMemberManager;
080import org.ametys.plugins.workspaces.project.modules.WorkspaceModule;
081import org.ametys.plugins.workspaces.project.modules.WorkspaceModuleExtensionPoint;
082import org.ametys.plugins.workspaces.project.objects.Project;
083import org.ametys.plugins.workspaces.project.objects.Project.InscriptionStatus;
084import org.ametys.plugins.workspaces.project.objects.ProjectCategory;
085import org.ametys.plugins.workspaces.project.rights.ProjectRightHelper;
086import org.ametys.runtime.i18n.I18nizableText;
087import org.ametys.runtime.plugin.component.PluginAware;
088import org.ametys.web.repository.page.Page;
089import org.ametys.web.repository.page.PageQueryHelper;
090import org.ametys.web.repository.site.Site;
091import org.ametys.web.repository.site.SiteDAO;
092import org.ametys.web.repository.site.SiteManager;
093import org.ametys.web.repository.sitemap.Sitemap;
094import org.ametys.web.site.SiteConfigurationExtensionPoint;
095import org.ametys.web.tags.TagExpression;
096
097import com.google.common.collect.ImmutableMap;
098import com.google.common.collect.Iterables;
099
100/**
101 * Helper component for managing project workspaces
102 */
103public class ProjectManager extends AbstractLogEnabled implements Serviceable, Component, Contextualizable, PluginAware
104{
105    /** Avalon Role */
106    public static final String ROLE = ProjectManager.class.getName();
107    
108    /** Workspaces plugin node name */
109    private static final String __WORKSPACES_PLUGIN_NODE_NAME = "workspaces";
110    
111    /** Workspaces plugin node name */
112    private static final String __WORKSPACES_PLUGIN_NODE_TYPE = RepositoryConstants.NAMESPACE_PREFIX + ":unstructured";
113    
114    /** The name of the projects root node */
115    private static final String __PROJECTS_ROOT_NODE_NAME = "projects";
116    
117    /** The type of the projects root node */
118    private static final String __PROJECTS_ROOT_NODE_TYPE = RepositoryConstants.NAMESPACE_PREFIX + ":unstructured";
119    
120    /** Constants for tags metadata */
121    private static final String __PROJECTS_TAGS_PROPERTY = RepositoryConstants.NAMESPACE_PREFIX_INTERNAL + ":tags";
122    
123    /** Constants for places metadata */
124    private static final String __PROJECTS_PLACES_PROPERTY = RepositoryConstants.NAMESPACE_PREFIX_INTERNAL + ":places";
125    
126    /** Ametys object resolver */
127    protected AmetysObjectResolver _resolver;
128    
129    /** The i18n utils. */
130    protected I18nUtils _i18nUtils;
131    
132    /** Site manager */
133    protected SiteManager _siteManager;
134    
135    /** Site DAO */
136    protected SiteDAO _siteDao;
137    
138    /** Site configuration EP */
139    protected SiteConfigurationExtensionPoint _siteConfiguration;
140    
141    /** Module Managers EP */
142    protected WorkspaceModuleExtensionPoint _moduleManagerEP;
143    
144    /** Avalon context */
145    protected Context _context;
146
147    private ObservationManager _observationManager;
148    
149    private CurrentUserProvider _currentUserProvider;
150
151    private ProjectMemberManager _projectMembers;
152
153    private ProjectMemberManager _projectMemberManager;
154
155    private String _pluginName;
156
157    private ProjectRightHelper _projectRightHelper;
158
159    @Override
160    public void contextualize(Context context) throws ContextException
161    {
162        _context = context;
163    }
164    
165    @Override
166    public void service(ServiceManager manager) throws ServiceException
167    {
168        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
169        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
170        _siteManager = (SiteManager) manager.lookup(SiteManager.ROLE);
171        _siteDao = (SiteDAO) manager.lookup(SiteDAO.ROLE);
172        _siteConfiguration = (SiteConfigurationExtensionPoint) manager.lookup(SiteConfigurationExtensionPoint.ROLE);
173        _projectMembers = (ProjectMemberManager) manager.lookup(ProjectMemberManager.ROLE);
174        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
175        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
176        _projectMemberManager = (ProjectMemberManager) manager.lookup(ProjectMemberManager.ROLE);
177        _moduleManagerEP = (WorkspaceModuleExtensionPoint) manager.lookup(WorkspaceModuleExtensionPoint.ROLE);
178        _projectRightHelper = (ProjectRightHelper) manager.lookup(ProjectRightHelper.ROLE);
179    }
180    
181    @Override
182    public void setPluginInfo(String pluginName, String featureName, String id)
183    {
184        _pluginName = pluginName;
185    }
186    
187    /**
188     * Retrieves all projects
189     * @return the projects
190     */
191    public AmetysObjectIterable<Project> getProjects()
192    {
193        String jcrQuery = "//element(*, ametys:project)";
194        return _resolver.query(jcrQuery);
195    }
196    
197    /**
198     * Retrieves a project by its name
199     * @param name The project name
200     * @return the project or <code>null</code> if not found
201     */
202    public Project getProject(String name)
203    {
204        String jcrQuery = "//element(" + name + ", ametys:project)";
205        AmetysObjectIterable<Project> sites = _resolver.query(jcrQuery);
206        Iterator<Project> it = sites.iterator();
207        
208        if (it.hasNext())
209        {
210            return it.next();
211        }
212        
213        return null; 
214    }
215    
216    /**
217     * Get the user's projects
218     * @param user the user
219     * @return the user's projects
220     */
221    public List<Project> getUserProjects(UserIdentity user)
222    {
223        List<Project> userProjects = new ArrayList<>();
224        
225        AmetysObjectIterable<Project> projects = getProjects();
226        
227        for (Project project : projects)
228        {
229            if (_projectMembers.isProjectMember(project, user))
230            {
231                userProjects.add(project);
232            }
233        }
234        
235        return userProjects;
236    }
237    
238    
239    /**
240     * Returns true if the given project exists.
241     * @param projectName the project name.
242     * @return true if the given project exists.
243     */
244    public boolean hasProject(String projectName)
245    {
246        return getProject(projectName) != null;
247    }
248    
249    /**
250     * Retrieves the mapping of all the projects name with their title on which the current user has access
251     * @return the map (projectName, projectTitle) for all projects
252     */
253    @Callable
254    public List<Map<String, String>> getUserProjectsData()
255    {
256        return getUserProjects(_currentUserProvider.getUser())
257                .stream()
258                .map(p -> ImmutableMap.of("title", p.getTitle(), "name", p.getName()))
259                .collect(Collectors.toList());
260    }
261    
262    /**
263     * Retrieves the mapping of all the projects name with their title (regarless user rights)
264     * @return the map (projectName, projectTitle) for all projects
265     */
266    @Callable
267    public List<Map<String, String>> getProjectsData()
268    {
269        return getProjects()
270                .stream()
271                .map(p -> ImmutableMap.of("title", p.getTitle(), "name", p.getName()))
272                .collect(Collectors.toList());
273    }
274    
275    /**
276     * Retrieves the project names
277     * @return the project names
278     */
279    @Callable
280    public Collection<String> getProjectNames()
281    {
282        return getProjects()
283                .stream()
284                .map(Project::getName)
285                .collect(Collectors.toList());
286    }
287    
288    /**
289     * Retrieves the project paths of all projects.
290     * @return  A list of project paths
291     */
292    @Callable
293    public Collection<String> getProjectPaths()
294    {
295        return getProjects()
296                .stream()
297                .map(Project::getProjectsTreePath)
298                .collect(Collectors.toList());
299    }
300    
301    /**
302     * Return the root for projects
303     * The root node will be created if necessary
304     * @return The root for projects
305     */
306    public ModifiableTraversableAmetysObject getProjectsRoot()
307    {
308        try
309        {
310            ModifiableTraversableAmetysObject pluginsNode = _resolver.resolveByPath("/ametys:plugins");
311            ModifiableTraversableAmetysObject workspacesPluginNode = _getOrCreateObject(pluginsNode, __WORKSPACES_PLUGIN_NODE_NAME, __WORKSPACES_PLUGIN_NODE_TYPE);
312            return _getOrCreateObject(workspacesPluginNode, __PROJECTS_ROOT_NODE_NAME, __PROJECTS_ROOT_NODE_TYPE);
313        }
314        catch (AmetysRepositoryException e)
315        {
316            throw new AmetysRepositoryException("Error getting the projects root node.", e);
317        }
318    }
319    
320    /**
321     * Get the tree of a project which contains information about the projects
322     * and categories in the tree.
323     * @param treeRootId The desired root node of the tree
324     * @param maxDepth The maximal depth of the tree. Set to a value of zero or
325     *            less to get the whole tree.
326     * @param contextOptions An optional map of context options. Valid context
327     *            options are: "user", which holds an user identity, the project
328     *            tree will be filtered given that user rights.
329     * @return The project tree which is a list of map. Each map is a node of
330     *         the tree structure.
331     */
332    public List<Map<String, Object>> getProjectTree(String treeRootId, int maxDepth, Map<String, Object> contextOptions)
333    {    
334        return getProjectTree(treeRootId, maxDepth, true, false, contextOptions);
335    }
336
337    /**
338     * Get the tree of a project which contains information about the projects
339     * and categories in the tree.
340     * @param treeRootId The desired root node of the tree
341     * @param maxDepth The maximal depth of the tree. Set to a value of zero or
342     *            less to get the whole tree.
343     * @param includeProjects False to only return the categories tree.
344     * @param memberOnly Only return projects for which the current user is a member
345     * @param contextOptions An optional map of context options. Valid context
346     *            options are: "user", which holds an user identity, the project
347     *            tree will be filtered given that user rights.
348     * @return The project tree which is a list of map. Each map is a node of
349     *         the tree structure.
350     */
351    public List<Map<String, Object>> getProjectTree(String treeRootId, int maxDepth, boolean includeProjects, boolean memberOnly, Map<String, Object> contextOptions)
352    {
353        List<Map<String, Object>> projectTree = new ArrayList<>();
354        
355        // FIXME WORKSPACES RIGHTS user context options is currently ignored
356        
357        TraversableAmetysObject node = null;
358        if (StringUtils.isNotEmpty(treeRootId))
359        {
360            node = _resolver.resolveById(treeRootId);
361        }
362        else
363        {
364            node = getProjectsRoot();
365        }
366        
367        // traverse the project tree and populate the result list.
368        for (AmetysObject child: node.getChildren())
369        {
370            _projectTreeAccumulator(projectTree, child, 1, maxDepth, includeProjects, memberOnly);
371        }
372        
373        return projectTree;
374    }
375    
376    private void _projectTreeAccumulator(List<Map<String, Object>> projectTree, AmetysObject node, int depth, int maxDepth, boolean includeProjects, boolean memberOnly)
377    {
378        Map<String, Object> nodeInfo = null;
379        
380        if (node instanceof Project)
381        {
382            if (includeProjects && (!memberOnly || _projectMemberManager.isProjectMember((Project) node, _currentUserProvider.getUser())))
383            {
384                nodeInfo = getProjectProperties((Project) node);
385            }
386        }
387        else if (node instanceof ProjectCategory)
388        {
389            ProjectCategory projectCategory = (ProjectCategory) node;
390            nodeInfo = getCategoryProperties(projectCategory);
391            
392            if (maxDepth <= 0 || depth < maxDepth)
393            {
394                // recurse through the children of the category.
395                List<Map<String, Object>> children = new ArrayList<>();
396                nodeInfo.put("children", children);
397                
398                for (AmetysObject child: projectCategory.getChildren())
399                {
400                    _projectTreeAccumulator(children, child, depth + 1, maxDepth, includeProjects, memberOnly);
401                }
402            }
403        }
404        else
405        {
406            // logging unexpected ametys object
407            String warningMsg = String.format("Unexpected ametys object type while traversing the project tree.\nAmetys object identifier is '%s'.", node.getId()); 
408            getLogger().warn(warningMsg);
409        }
410        
411        if (nodeInfo != null)
412        {
413            projectTree.add(nodeInfo);
414        }
415    }
416    
417    /**
418     * Retrieves the standard information of a project
419     * @param projectId Identifier of the project
420     * @return The map of information
421     */
422    @Callable
423    public Map<String, Object> getProjectProperties(String projectId)
424    {
425        return getProjectProperties((Project) _resolver.resolveById(projectId));
426    }
427    
428    /**
429     * Retrieves the standard information of a project
430     * @param project The project
431     * @return The map of information
432     */
433    public Map<String, Object> getProjectProperties(Project project)
434    {
435        Map<String, Object> info = new HashMap<>();
436
437        info.put("id", project.getId());
438        info.put("name", project.getName());
439        info.put("type", "project");
440        info.put("path", project.getProjectsTreePath());
441
442        AmetysObject parent = project.getParent();
443        if (parent instanceof ProjectCategory)
444        {
445            info.put("parentId", parent.getId());
446        }
447
448        info.put("title", project.getTitle());
449        info.put("description", project.getDescription());
450        info.put("mailingList", project.getMailingList());
451        info.put("inscriptionStatus", project.getInscriptionStatus().toString());
452        info.put("defaultProfile", project.getDefaultProfile());
453
454        info.put("creationDate", project.getCreationDate());
455
456        // check if the project workspace configuration is valid
457        Collection<Site> sites = project.getSites();
458        boolean valid = sites.size() > 0;
459        if (valid)
460        {
461            Iterator<Site> siteIterator = sites.iterator();
462            while (valid && siteIterator.hasNext())
463            {
464                Site site = siteIterator.next();
465                valid = _siteConfiguration.isValid(site.getName());
466            }
467        }
468
469        info.put("valid", valid);
470
471        // sites is a list of map entry with id ,name, title and url property
472        // { id: site id, name: site name, title: site title, url: site url }
473        info.put("sites", sites.stream().map(site ->
474        {
475            Map<String, String> siteProps = new HashMap<>();
476            siteProps.put("id", site.getId());
477            siteProps.put("name", site.getName());
478            siteProps.put("title", site.getTitle());
479            siteProps.put("url", site.getUrl());
480            return siteProps;
481        }).collect(Collectors.toList()));
482
483        return info;
484    }
485    
486    /**
487     * Get the availables project URLs.
488     * @param project The project
489     * @return The availables project URLs, can be empty.
490     */
491    public Set<String> getProjectUrls(Project project)
492    {
493        return _getProjectNonEmptyElements(project, Site::getUrl);
494    }
495    
496    /**
497     * Get the availables project names.
498     * @param project The project
499     * @return The availables project names, can be empty.
500     */
501    public Set<String> getProjectNames(Project project)
502    {
503        return _getProjectNonEmptyElements(project, Site::getName);
504    }
505    
506    private Set<String> _getProjectNonEmptyElements(Project project, Function<? super Site, ? extends String> function)
507    {
508        return project.getSites()                       // Get the sites of the project
509                      .stream()                         // Build it as a stream
510                      .map(function)                    // Get the element of each site
511                      .filter(StringUtils::isNotEmpty)  // Filter empty strings
512                      .collect(Collectors.toSet());     // Get only the first value
513    }
514    
515    /**
516     * Get the tree of a project.
517     * @param treeRootId The desired root node of the tree
518     * @param maxDepth The maximal depth of the tree. Set to a value of zero or
519     *            less to get the whole tree.
520     * @return The project tree which is a list of projects and categories. 
521     *         Each category is a map with the category node and a list of 
522     *         children.
523     */
524    public List<Object> getProjectTreeNodes(String treeRootId, int maxDepth)
525    {
526        return getProjectTreeNodes(treeRootId, maxDepth, true, false);
527    }
528    
529    /**
530     * Get the tree of a project.
531     * @param treeRootId The desired root node of the tree
532     * @param maxDepth The maximal depth of the tree. Set to a value of zero or
533     *            less to get the whole tree.
534     * @param includeProjects False to only return the categories tree. 
535     * @param memberOnly Only return projects for which the current user is a member
536     * @return The project tree which is a list of projects and categories. 
537     *         Each category is a map with the category node and a list of 
538     *         children.
539     */
540    public List<Object> getProjectTreeNodes(String treeRootId, int maxDepth, boolean includeProjects, boolean memberOnly)
541    {
542        return getProjectTreeNodes(treeRootId, maxDepth, 0, includeProjects, memberOnly, null);
543    }
544
545    /**
546     * Get the tree of a project.
547     * @param treeRootId The desired root node of the tree
548     * @param maxDepth The maximal depth of the tree. Set to a value of zero or
549     *            less to get the whole tree.
550     * @param maxResult Limit the number of projects returned
551     * @param includeProjects False to only return the categories tree. 
552     * @param memberOnly Only return projects for which the current user is a member
553     * @param filterCategories The list of categories to filter. Can be null to ignore
554     * @return The project tree which is a list of projects and categories. 
555     *         Each category is a map with the category node and a list of 
556     *         children.
557     */
558    public List<Object> getProjectTreeNodes(String treeRootId, int maxDepth, int maxResult, boolean includeProjects, boolean memberOnly, List<String> filterCategories)
559    {
560        List<Object> projectTree = new ArrayList<>();
561        
562        TraversableAmetysObject node = null;
563        if (StringUtils.isNotEmpty(treeRootId))
564        {
565            node = _resolver.resolveById(treeRootId);
566        }
567        else
568        {
569            node = getProjectsRoot();
570        }
571        
572        // traverse the project tree and populate the result list.
573        for (AmetysObject child: node.getChildren())
574        {
575            _projectTreeNodeAccumulator(projectTree, child, 1, maxDepth, includeProjects, memberOnly);
576        }
577        
578        if (filterCategories != null || maxResult != 0)
579        {
580            Map<String, Object> filters = new HashMap<>();
581            filters.put("categories", filterCategories);
582            filters.put("max", maxResult <= 0 ? null : new Integer(maxResult));
583            projectTree = _filterProjectTree(projectTree, filters, StringUtils.isNotEmpty(treeRootId) ? treeRootId : "category-root", 1, maxDepth);
584        }
585        
586        return Optional.ofNullable(projectTree).orElse(new ArrayList<>());
587    }
588
589    private void _projectTreeNodeAccumulator(List<Object> projectTree, AmetysObject node, int depth, int maxDepth, boolean includeProjects, boolean memberOnly)
590    {
591        if (node instanceof Project)
592        {
593            Project project = (Project) node;
594            if (includeProjects && (_projectMemberManager.isProjectMember(project, _currentUserProvider.getUser())) || (!memberOnly && !project.getInscriptionStatus().equals(InscriptionStatus.PRIVATE)))
595            {
596                projectTree.add(node);
597            }
598        }
599        else if (node instanceof ProjectCategory)
600        {
601            ProjectCategory projectCategory = (ProjectCategory) node;
602            Map<String, Object> categoryData = new HashMap<>();
603            categoryData.put("category", projectCategory);
604            
605            if (maxDepth <= 0 || depth < maxDepth)
606            {
607                // recurse through the children of the category.
608                List<Object> children = new ArrayList<>();
609                for (AmetysObject child: projectCategory.getChildren())
610                {
611                    _projectTreeNodeAccumulator(children, child, depth + 1, maxDepth, includeProjects, memberOnly);
612                }
613                categoryData.put("children", children);
614            }
615            
616            projectTree.add(categoryData);
617        }
618        else
619        {
620            // logging unexpected ametys object
621            String warningMsg = String.format("Unexpected ametys object type while traversing the project tree.\nAmetys object identifier is '%s'.", node.getId()); 
622            getLogger().warn(warningMsg);
623        }
624    }
625    
626    @SuppressWarnings("unchecked")
627    private List<Object> _filterProjectTree(List<Object> projectTree, Map<String, Object> filters, String categoryId, int depth, int maxDepth)
628    {
629        List<String> filterCategories = (List<String>) filters.get("categories"); 
630        boolean isCategoryInFilters = filterCategories == null ? true : filterCategories.contains(categoryId);
631
632        // filtered project tree stays null unless a sub-category is in the filterCategories list
633        List<Object> filteredProjectTree = isCategoryInFilters ? new ArrayList<>() : null;
634        
635        for (Object treeNode : projectTree)
636        {
637            if (isCategoryInFilters && treeNode instanceof Project && filteredProjectTree != null && (filters.get("max") == null || ((Integer) filters.get("max")) > 0))
638            {
639                filteredProjectTree.add(treeNode);
640                if (filters.get("max") != null)
641                {
642                    filters.put("max", ((Integer) filters.get("max")) - 1);
643                }
644            }
645            else if (treeNode instanceof Map)
646            {
647                Map<String, Object> categoryData = (Map<String, Object>) treeNode;
648                filteredProjectTree = _filterProjectTreeCategory(categoryData, filteredProjectTree, filters, isCategoryInFilters, depth, maxDepth);
649            }
650        }
651        
652        return filteredProjectTree;
653    }
654
655    @SuppressWarnings("unchecked")
656    private List<Object> _filterProjectTreeCategory(Map<String, Object> categoryData, List<Object> filteredProjectTree, Map<String, Object> filters, boolean isCategoryInFilters, int depth, int maxDepth)
657    {
658        List<Object> resultProjectTree = filteredProjectTree;
659        
660        boolean addCategoryToTree = isCategoryInFilters;
661        
662        if (maxDepth > 0 && depth >= maxDepth && !addCategoryToTree)
663        {
664            // resolve and parse the category children to find at least one children not filtered
665            List<String> filterCategories = (List<String>) filters.get("categories"); 
666            String categoryId = ((ProjectCategory) categoryData.get("category")).getId();
667            addCategoryToTree = isCategoryInFilters(categoryId, filterCategories, false);
668        }
669        
670        if (categoryData.containsKey("children"))
671        {
672            // will return null if no child passes the filters, otherwise will return the list of filtered children
673            List<Object> categoryChildren = _filterProjectTree((List<Object>) categoryData.get("children"), filters, ((ProjectCategory) categoryData.get("category")).getId(), depth + 1, maxDepth);
674            
675            if (categoryChildren != null)
676            {
677                addCategoryToTree = true;
678            }
679            categoryData.put("children", categoryChildren);
680        }
681        
682        if (addCategoryToTree)
683        {
684            if (resultProjectTree == null)
685            {
686                resultProjectTree = new ArrayList<>();
687            }
688            resultProjectTree.add(categoryData);
689        }
690        return resultProjectTree;
691    }
692    
693    /**
694     * Test if a category child is contained in the list of filters
695     * @param categoryId The category id
696     * @param filterCategories The list of category id that one of the children must match
697     * @param includeProjects True to test filters on all category children, false to only test on sub-categories
698     * @return True if the child is in the filters
699     */
700    public boolean isCategoryInFilters(String categoryId, List<String> filterCategories, boolean includeProjects)
701    {
702        if (filterCategories.contains(categoryId))
703        {
704            return true;
705        }
706        
707        AmetysObject object = _resolver.resolveById(categoryId);
708        
709        if (object instanceof ProjectCategory)
710        {
711            
712            for (AmetysObject categoryChild : ((ProjectCategory) object).getChildren())
713            {
714                if (includeProjects || categoryChild instanceof ProjectCategory)
715                {
716                    if (isCategoryInFilters(categoryChild.getId(), filterCategories, includeProjects))
717                    {
718                        return true;
719                    }
720                }
721            }
722        }
723        return false;
724    }
725    
726    /**
727     * Create a project from the projects root node.
728     * @param name The project name
729     * @param title The project title
730     * @param description The project description
731     * @param mailingList Project mailing list
732     * @param inscriptionStatus The inscription status of the project
733     * @param defaultProfileId The default profile for new members
734     * @return A map containing the id of the new project or an error key.
735     */
736    @Callable
737    public Map<String, Object> createProject(String name, String title, String description, String mailingList, String inscriptionStatus, String defaultProfileId)
738    {
739        return createProject((String) null, name, title, description, mailingList, inscriptionStatus, defaultProfileId);
740    }
741    
742    /**
743     * Create a project
744     * @param parentId Identifier of the parent of the project to create 
745     * @param name The project name
746     * @param title The project title
747     * @param description The project description
748     * @param mailingList Project mailing list
749     * @param inscriptionStatus The inscription status of the project
750     * @param defaultProfileId The default profile for new members
751     * @return A map containing the id of the new project or an error key.
752     */
753    @Callable
754    public Map<String, Object> createProject(String parentId, String name, String title, String description, String mailingList, String inscriptionStatus, String defaultProfileId)
755    {
756        Map<String, Object> result = new HashMap<>();
757        List<String> errors = new ArrayList<>();
758        
759        Map<String, Object> additionalValues = new HashMap<>();
760        additionalValues.put("description", description);
761        additionalValues.put("mailingList", mailingList);
762        additionalValues.put("inscriptionStatus", inscriptionStatus);
763        additionalValues.put("defaultProfileId", defaultProfileId);
764        
765        String projectId = createProject(parentId, name, title, additionalValues, null, errors);
766        
767        if (CollectionUtils.isEmpty(errors))
768        {
769            result.put("id", projectId);
770        }
771        else
772        {
773            result.put("error", errors.get(0));
774        }
775        
776        return result;
777    }
778    
779    /**
780     * Create a project
781     * @param parentId Identifier of the parent of the project to create 
782     * @param name The project name
783     * @param title The project title
784     * @param additionalValues A list of optional additional values. Accepted values are : description, mailingList, inscriptionStatus, defaultProfile, tags, categoryTags and keywords
785     * @param modulesIds The list of modules to activate. Can be null to activate all modules
786     * @param errors A list that will be populated with the encountered errors. If null, errors will not be tracked.
787     * @return The id of the new project
788     */
789    public String createProject(String parentId, String name, String title, Map<String, Object> additionalValues, Set<String> modulesIds, List<String> errors)
790    {
791        ModifiableTraversableAmetysObject parent = null;
792        if (StringUtils.isNotEmpty(parentId))
793        {
794            parent = _resolver.resolveById(parentId);
795        }
796        else
797        {
798            parent = getProjectsRoot();
799        }
800        
801        Project project = createProject(parent, name, title, additionalValues, modulesIds, errors);
802        
803        return project != null ? project.getId() : null;
804    }
805    
806    /**
807     * Create a project
808     * @param parent the parent of the project to create 
809     * @param name The project name
810     * @param title The project title
811     * @param additionalValues A list of optional additional values. Accepted values are : description, mailingList, inscriptionStatus, defaultProfile, tags, categoryTags and keywords
812     * @param modulesIds The list of modules to activate. Can be null to activate all modules
813     * @param errors A list that will be populated with the encountered errors. If null, errors will not be tracked.
814     * @return The id of the new project
815     */
816    public Project createProject(ModifiableTraversableAmetysObject parent, String name, String title, Map<String, Object> additionalValues, Set<String> modulesIds, List<String> errors)
817    {
818        if (StringUtils.isEmpty(title))
819        {
820            throw new IllegalArgumentException(String.format("Cannot create the project for parent id '%s'. Title metadata is mandatory", parent.getId()));
821        }
822        
823        // Project name should be unique
824        if (hasProject(name))
825        {
826            if (getLogger().isWarnEnabled())
827            {
828                getLogger().warn(String.format("A project with the name '%s' already exists", name));
829            }
830            
831            if (errors != null)
832            {
833                errors.add("project-exists");
834            }
835            
836            return null;
837        }
838        
839        Project project = parent.createChild(name, Project.NODE_TYPE);
840        project.setTitle(title);
841        String description = (String) additionalValues.getOrDefault("description", null);
842        if (StringUtils.isNotEmpty(description))
843        {
844            project.setDescription(description);
845        }
846        String mailingList = (String) additionalValues.getOrDefault("emailList", null);
847        if (StringUtils.isNotEmpty(mailingList))
848        {
849            project.setMailingList(mailingList);
850        }
851        String inscriptionStatus = (String) additionalValues.getOrDefault("inscriptionStatus", null);
852        if (StringUtils.isNotEmpty(inscriptionStatus))
853        {
854            project.setInscriptionStatus(inscriptionStatus);
855        }
856        String defaultProfile = (String) additionalValues.getOrDefault("defaultProfile", null);
857        if (StringUtils.isNotEmpty(defaultProfile))
858        {
859            project.setDefaultProfile(defaultProfile);
860        }
861        
862        project.setCreationDate(ZonedDateTime.now());
863        
864        // Create the project workspace = a site + a set of pages
865        _createProjectWorkspace(project, errors);
866        
867        activateModules(project, modulesIds);
868        
869        if (CollectionUtils.isEmpty(errors))
870        {
871            project.saveChanges();
872         
873            // Notify observers
874            Map<String, Object> eventParams = new HashMap<>();
875            eventParams.put(ObservationConstants.ARGS_PROJECT, project);
876            _observationManager.notify(new Event(ObservationConstants.EVENT_PROJECT_ADDED, _currentUserProvider.getUser(), eventParams));
877            
878        }
879        else
880        {
881            deleteProject(project);
882        }
883        
884        return project;
885    }
886    
887    /**
888     * Edit a project
889     * @param id The project identifier
890     * @param title The title to set
891     * @param description The description to set
892     * @param mailingList Project mailing list
893     * @param inscriptionStatus The inscription status of the project
894     * @param defaultProfile The default profile for new members
895     */
896    @Callable
897    public void editProject(String id, String title, String description, String mailingList, String inscriptionStatus, String defaultProfile)
898    {
899        Project project = _resolver.resolveById(id);
900        editProject(project, title, description, mailingList, inscriptionStatus, defaultProfile);
901    }
902    
903    /**
904     * Edit a project
905     * @param project The project
906     * @param title The title to set
907     * @param description The description to set
908     * @param mailingList Project mailing list
909     * @param inscriptionStatus The inscription status of the project
910     * @param defaultProfile The default profile for new members
911     */
912    public void editProject(Project project, String title, String description, String mailingList, String inscriptionStatus, String defaultProfile)
913    {
914        project.setTitle(title);
915        
916        if (StringUtils.isNotEmpty(description))
917        {
918            project.setDescription(description);
919        }
920        else
921        {
922            project.removeDescription();
923        }
924        
925        if (StringUtils.isNotEmpty(mailingList))
926        {
927            project.setMailingList(mailingList);
928        }
929        else
930        {
931            project.removeMailingList();
932        }
933
934        project.setInscriptionStatus(inscriptionStatus);
935        project.setDefaultProfile(defaultProfile);
936        
937        project.saveChanges();
938        
939        // Notify observers
940        Map<String, Object> eventParams = new HashMap<>();
941        eventParams.put(ObservationConstants.ARGS_PROJECT, project);
942        _observationManager.notify(new Event(ObservationConstants.EVENT_PROJECT_UPDATED, _currentUserProvider.getUser(), eventParams));
943    }
944    
945    /**
946     * Delete a list of project.
947     * @param ids The ids of projects to delete
948     * @return The ids of the deleted projects, unknowns projects and the deleted sites
949     */
950    @Callable
951    public Map<String, Object> deleteProjectsByIds(List<String> ids)
952    {
953        Map<String, Object> result = new HashMap<>();
954        List<Map<String, Object>> deleted = new ArrayList<>();
955        List<String> unknowns = new ArrayList<>();
956        
957        for (String id : ids)
958        {
959            try
960            {
961                Project project = _resolver.resolveById(id);
962                
963                Map<String, Object> projectInfo = new HashMap<>();
964                projectInfo.put("id", id);
965                projectInfo.put("title", project.getTitle());
966                projectInfo.put("sites", deleteProject(project));
967                
968                deleted.add(projectInfo);
969            }
970            catch (UnknownAmetysObjectException e)
971            {
972                getLogger().warn(String.format("Unable to delete the definition of id '%s', because it does not exist.", id), e);
973                unknowns.add(id);
974            }
975        }
976        
977        result.put("deleted", deleted);
978        result.put("unknowns", unknowns);
979        
980        return result;
981    }
982    
983    /**
984     * Delete a project.
985     * @param projects The list of projects to delete
986     * @return list of deleted sites (each list entry contains a data map with
987     *         the id and the name of the delete site).
988     */
989    public List<Map<String, String>> deleteProject(List<Project> projects)
990    {
991        List<Map<String, String>> deletedSitesInfo = new ArrayList<>();
992        
993        for (Project project : projects)
994        {
995            deletedSitesInfo.addAll(deleteProject(project));
996        }
997        
998        return deletedSitesInfo;
999    }
1000    
1001    /**
1002     * Delete a project and its sites
1003     * @param project The project to delete
1004     * @return list of deleted sites (each list entry contains a data map with
1005     *         the id and the name of the delete site).
1006     */
1007    public List<Map<String, String>> deleteProject(Project project)
1008    {
1009        ModifiableAmetysObject parent = project.getParent();
1010        
1011        Collection<Site> sites = project.getSites();
1012        
1013        // list of map entry with id, name and title property
1014        // { id: site id, name: site name }
1015        List<Map<String, String>> deletedSitesInfo = new ArrayList<>();
1016        
1017        sites.forEach(site -> 
1018        {
1019            try
1020            {
1021                Map<String, String> siteProps = new HashMap<>();
1022                siteProps.put("id", site.getId());
1023                siteProps.put("name", site.getName());
1024
1025                _siteDao.deleteSite(site.getId());
1026                deletedSitesInfo.add(siteProps);
1027            }
1028            catch (RepositoryException e)
1029            {
1030                String errorMsg = String.format("Error while trying to delete the site '%s' for the project '%s'.", site.getName(), project.getName());
1031                getLogger().error(errorMsg, e);
1032            }
1033        });
1034        
1035        String projectId = project.getId();
1036        project.remove();
1037        parent.saveChanges();
1038        
1039        // Notify observers
1040        Map<String, Object> eventParams = new HashMap<>();
1041        eventParams.put(ObservationConstants.ARGS_PROJECT, project);
1042        eventParams.put(ObservationConstants.ARGS_PROJECT_ID, projectId);
1043        _observationManager.notify(new Event(ObservationConstants.EVENT_PROJECT_DELETED, _currentUserProvider.getUser(), eventParams));
1044        
1045        return deletedSitesInfo;
1046    }
1047    
1048    /**
1049     * Retrieves the standard information of a project category
1050     * @param categoryId Identifier of the project category
1051     * @return The map of information
1052     */
1053    @Callable
1054    public Map<String, Object> getCategoryProperties(String categoryId)
1055    {
1056        return getCategoryProperties((ProjectCategory) _resolver.resolveById(categoryId));
1057    }
1058    
1059    /**
1060     * Retrieves the standard information of a project category
1061     * @param projectCategory The project category
1062     * @return The map of information
1063     */
1064    public Map<String, Object> getCategoryProperties(ProjectCategory projectCategory)
1065    {
1066        Map<String, Object> info = new HashMap<>();
1067        
1068        info.put("id", projectCategory.getId());
1069        info.put("name", projectCategory.getName());
1070        info.put("type", "category");
1071        info.put("path", projectCategory.getProjectsTreePath());
1072        
1073        info.put("title", projectCategory.getTitle());
1074        info.put("description", projectCategory.getDescription());
1075        
1076        return info;
1077    }
1078    
1079    /**
1080     * Create a project category from the projects root node.
1081     * @param title The category title
1082     * @param description The category description
1083     * @return The id of the new category
1084     */
1085    @Callable
1086    public String createCategory(String title, String description)
1087    {
1088        return createCategory((String) null, title, description);
1089    }
1090    
1091    /**
1092     * Create a project category
1093     * @param parentId Identifier of the parent of the category to create 
1094     * @param title The category title
1095     * @param description The category description
1096     * @return The id of the new category
1097     */
1098    @Callable
1099    public String createCategory(String parentId, String title, String description)
1100    {
1101        ModifiableTraversableAmetysObject parent = null;
1102        if (StringUtils.isNotEmpty(parentId))
1103        {
1104            parent = _resolver.resolveById(parentId);
1105        }
1106        else
1107        {
1108            parent = getProjectsRoot();
1109        }
1110        
1111        ProjectCategory category = createCategory(parent, title, description);
1112        
1113        return category.getId();
1114    }
1115    
1116    /**
1117     * Create a project category
1118     * @param parent the parent of the category to create 
1119     * @param title The category title
1120     * @param description The category description
1121     * @return The id of the new category
1122     */
1123    public ProjectCategory createCategory(ModifiableTraversableAmetysObject parent, String title, String description)
1124    {
1125        if (StringUtils.isEmpty(title))
1126        {
1127            throw new IllegalArgumentException(String.format("Cannot create the project category for parent id '%s'. Title metadata is mandatory", parent.getId()));
1128        }
1129        
1130        // Find unique name
1131        String originalName = FilterNameHelper.filterName(title);
1132        String name = originalName;
1133        int index = 2;
1134        while (parent.hasChild(name))
1135        {
1136            name = originalName + "-" + (index++);
1137        }
1138        
1139        ProjectCategory category = parent.createChild(name, ProjectCategory.NODE_TYPE);
1140        category.setTitle(title);
1141        if (StringUtils.isNotEmpty(description))
1142        {
1143            category.setDescription(description);
1144        }
1145        
1146        parent.saveChanges();
1147        
1148        return category;
1149    }
1150    
1151    /**
1152     * Edit a project category
1153     * @param id The category identifier
1154     * @param title The title to set
1155     * @param description The description to set
1156     */
1157    @Callable
1158    public void editCategory(String id, String title, String description)
1159    {
1160        ProjectCategory category = _resolver.resolveById(id);
1161        editCategory(category, title, description);
1162    }
1163    
1164    /**
1165     * Edit a project category
1166     * @param category The project category
1167     * @param title The title to set
1168     * @param description The description to set
1169     */
1170    public void editCategory(ProjectCategory category, String title, String description)
1171    {
1172        category.setTitle(title);
1173        if (StringUtils.isNotEmpty(description))
1174        {
1175            category.setDescription(description);
1176        }
1177        
1178        category.saveChanges();
1179    }
1180    
1181    /**
1182     * Delete a list of project categories.
1183     * @param ids The ids of categories to delete
1184     * @return The ids of the deleted categories, unknowns categories and the deleted sites
1185     */
1186    @Callable
1187    public Map<String, Object> deleteCategoryByIds(List<String> ids)
1188    {
1189        Map<String, Object> result = new HashMap<>();
1190        List<String> deleted = new ArrayList<>();
1191        List<String> unknowns = new ArrayList<>();
1192        
1193        List<Map<String, String>> deletedSitesInfo = new ArrayList<>();
1194        
1195        for (String id : ids)
1196        {
1197            try
1198            {
1199                ProjectCategory projectCategory = _resolver.resolveById(id);
1200                deletedSitesInfo.addAll(deleteCategory(projectCategory));
1201                deleted.add(id);
1202            }
1203            catch (UnknownAmetysObjectException e)
1204            {
1205                getLogger().warn(String.format("Unable to delete the definition of id '%s', because it does not exist.", id), e);
1206                unknowns.add(id);
1207            }
1208        }
1209        
1210        result.put("deleted", deleted);
1211        result.put("unknowns", unknowns);
1212        result.put("deletedSites", deletedSitesInfo);
1213        
1214        return result;
1215    }
1216    
1217    /**
1218     * Delete a list of project categories.
1219     * @param categories The list of categories to delete
1220     * @return list of deleted sites (each list entry contains a data map with
1221     *         the id and the name of the delete site).
1222     */
1223    public List<Map<String, String>> deleteCategory(List<ProjectCategory> categories)
1224    {
1225        List<Map<String, String>> deletedSitesInfo = new ArrayList<>();
1226        
1227        for (ProjectCategory category : categories)
1228        {
1229            deletedSitesInfo.addAll(deleteCategory(category));
1230        }
1231        
1232        return deletedSitesInfo;
1233    }
1234    
1235    /**
1236     * Delete a list of project category
1237     * @param category The category to delete
1238     * @return list of deleted sites (each list entry contains a data map with
1239     *         the id and the name of the delete site).
1240     */
1241    public List<Map<String, String>> deleteCategory(ProjectCategory category)
1242    {
1243        ModifiableAmetysObject parent = category.getParent();
1244        
1245        List<Map<String, String>> deletedSitesInfo = new ArrayList<>();
1246        
1247        _removeDescendants(category, deletedSitesInfo);
1248        category.remove();
1249        
1250        parent.saveChanges();
1251        
1252        return deletedSitesInfo;
1253    }
1254    
1255    /**
1256     * Remove descendant ametys objects.
1257     * Descendants should be a project or a category
1258     * @param object The project or category for which descendants must be removed
1259     * @param deletedSitesInfo list of deleted sites to be populated (each list entry contains a data map with
1260     *         the id and the name of the delete site).
1261     */
1262    private void _removeDescendants(AmetysObject object, List<Map<String, String>> deletedSitesInfo)
1263    {
1264        if (object instanceof TraversableAmetysObject)
1265        {
1266            for (AmetysObject child : ((TraversableAmetysObject) object).getChildren())
1267            {
1268                // remove descendants of child
1269                _removeDescendants(child, deletedSitesInfo);
1270                
1271                // then remove child itself
1272                if (child instanceof Project)
1273                {
1274                    deletedSitesInfo.addAll(deleteProject((Project) child));
1275                }
1276                else if (child instanceof RemovableAmetysObject)
1277                {
1278                    ((RemovableAmetysObject) child).remove();
1279                }
1280            }
1281        }
1282    }
1283    
1284    /**
1285     * Move a node of the project tree into another node.
1286     * Node can be a project or a category
1287     * @param nodeId The identifier of the node to move
1288     * @param targetId Identifier of the category which is the target of the move operation. Can be null if the target is the projects root node.
1289     * @return A map containing a possible error.
1290     * @throws RepositoryException if a repository error occurs.
1291     */
1292    @Callable
1293    public Map<String, Object> moveProjectTreeNode(String nodeId, String targetId) throws RepositoryException
1294    {
1295        JCRAmetysObject objectNode = (JCRAmetysObject) _resolver.resolveById(nodeId);
1296        
1297        JCRTraversableAmetysObject targetNode = null;
1298        if (StringUtils.isNotEmpty(targetId))
1299        {
1300            targetNode = _resolver.resolveById(targetId);
1301        }
1302        
1303        return moveProjectTreeNodeObject(objectNode, targetNode);
1304    }
1305    
1306    /**
1307     * Move a node of the project tree into another node.
1308     * Node can be a project or a category
1309     * @param objectNode The node to move (must be a project or a category)
1310     * @param targetNode The category which is the target of the move operation. Can be null if the target is the projects root node.
1311     * @return A map containing a possible error.
1312     * @throws RepositoryException if a repository error occurs.
1313     */
1314    public Map<String, Object> moveProjectTreeNodeObject(JCRAmetysObject objectNode, JCRTraversableAmetysObject targetNode) throws RepositoryException
1315    {
1316        Map<String, Object> result = new HashMap<>();
1317        
1318        if (!(objectNode instanceof Project || objectNode instanceof ProjectCategory) || !(targetNode == null || targetNode instanceof ProjectCategory))
1319        {
1320            if (getLogger().isWarnEnabled())
1321            {
1322                String warnMsg = String.format("The object '%s' cannot be moved to '%s'. Object and/or target of the move operation are of wrong type",
1323                        objectNode.getName(), targetNode == null ? "projects root" : targetNode.getName());
1324                getLogger().warn(warnMsg);
1325            }
1326            
1327            result.put("error", "invalid");
1328        }
1329        
1330        JCRTraversableAmetysObject actualTargetNode = targetNode;
1331        if (actualTargetNode == null)
1332        {
1333            actualTargetNode = (JCRTraversableAmetysObject) getProjectsRoot();
1334        }
1335        
1336        if (actualTargetNode.hasChild(objectNode.getName()))
1337        {
1338            if (getLogger().isWarnEnabled())
1339            {
1340                String warnMsg = String.format("The object '%s' cannot be moved. An object with the same name already exists in the target collection.", objectNode.getName());
1341                getLogger().warn(warnMsg);
1342            }
1343            
1344            result.put("error", "already-exists");
1345        }
1346        else
1347        {
1348            Session session = objectNode.getNode().getSession();
1349            session.move(objectNode.getNode().getPath(), actualTargetNode.getNode().getPath() + "/" + objectNode.getNode().getName());
1350            session.save();
1351        }
1352        
1353        return result;
1354    }
1355    
1356    /**
1357     * Utility method to get or create an ametys object
1358     * @param <A> A sub class of AmetysObject
1359     * @param parent The parent object
1360     * @param name The ametys object name
1361     * @param type The ametys object type
1362     * @return ametys object
1363     * @throws AmetysRepositoryException if an repository error occurs
1364     */
1365    private <A extends AmetysObject> A _getOrCreateObject(ModifiableTraversableAmetysObject parent, String name, String type) throws AmetysRepositoryException
1366    {
1367        A object;
1368        
1369        if (parent.hasChild(name))
1370        {
1371            object = parent.getChild(name);
1372        }
1373        else
1374        {
1375            object = parent.createChild(name, type);
1376            parent.saveChanges();
1377        }
1378        
1379        return object;
1380    }
1381    
1382    /**
1383     * Get the project of an ametys object inside a project.
1384     * It can be an explorer node, or any type of resource in a module.
1385     * @param id The identifier of the ametys object
1386     * @return the project or null if not found
1387     */
1388    public Project getParentProject(String id)
1389    {
1390        return getParentProject(_resolver.<AmetysObject>resolveById(id));
1391    }
1392    
1393    /**
1394     * Get the project of an ametys object inside a project.
1395     * It can be an explorer node, or any type of resource in a module.
1396     * @param object The ametys object
1397     * @return the project or null if not found
1398     */
1399    public Project getParentProject(AmetysObject object)
1400    {
1401        AmetysObject ametysObject = object;
1402        // Go back to the local explorer root.
1403        do
1404        {
1405            ametysObject = ametysObject.getParent();
1406        }
1407        while (ametysObject instanceof ExplorerNode);
1408        
1409        if (!(ametysObject instanceof Project))
1410        {
1411            getLogger().warn(String.format("No project found for ametys object with id '%s'", ametysObject.getId()));
1412            return null;
1413        }
1414        
1415        return (Project) ametysObject; 
1416    }
1417    
1418    /**
1419     * Get the list of project names for a given site
1420     * @param siteName The site name
1421     * @return the list of project names
1422     */
1423    @Callable
1424    public List<String> getProjectsForSite(String siteName)
1425    {
1426        List<String> projectNames = new ArrayList<>();
1427        
1428        if (_siteManager.hasSite(siteName))
1429        {
1430            Site site = _siteManager.getSite(siteName);
1431            getProjectsForSite(site)
1432                .stream()
1433                .map(Project::getName)
1434                .forEach(projectNames::add);
1435        }
1436        
1437        return projectNames;
1438    }
1439    
1440    /**
1441     * Get the list of project for a given site
1442     * @param site The site
1443     * @return the list of project
1444     */
1445    public List<Project> getProjectsForSite(Site site)
1446    {
1447        try
1448        {
1449            // Stream over the weak reference properties pointing to this
1450            // node to find referencing projects 
1451            Iterator<Property> propertyIterator = site.getNode().getWeakReferences();
1452            Iterable<Property> propertyIterable = () -> propertyIterator;
1453            
1454            return StreamSupport.stream(propertyIterable.spliterator(), false)
1455                    .map(p -> 
1456                    {
1457                        try
1458                        {
1459                            // Parent should be a composite with name "ametys:sites"
1460                            Node parent = p.getParent();
1461                            if (NodeTypeHelper.isNodeType(parent, "ametys:compositeMetadata") && (RepositoryConstants.NAMESPACE_PREFIX + ":" + Project.DATA_SITES).equals(parent.getName()))
1462                            {
1463                                // Parent should be the project
1464                                parent = parent.getParent();
1465                                if (NodeTypeHelper.isNodeType(parent, "ametys:project"))
1466                                {
1467                                    Project project = _resolver.resolve(parent, false);
1468                                    return project;
1469                                }
1470                            }
1471                        }
1472                        catch (Exception e)
1473                        {
1474                            if (getLogger().isWarnEnabled())
1475                            {
1476                                // this weak reference is not from a project
1477                                String propertyPath = null;
1478                                try
1479                                {
1480                                    propertyPath = p.getPath();
1481                                }
1482                                catch (Exception e2)
1483                                {
1484                                    // ignore
1485                                }
1486                                
1487                                String warnMsg = String.format("Site '%s' is pointed by a weak reference '%s' which is not representing a relation with project. This reference is ignored.", site.getName(), propertyPath);
1488                                getLogger().warn(warnMsg);
1489                            }
1490                        }
1491                        
1492                        return null;
1493                    })
1494                    .filter(Objects::nonNull)
1495                    .collect(Collectors.toList());
1496        }
1497        catch (RepositoryException e)
1498        {
1499            getLogger().error(String.format("Unable to find projects for site '%s'", site.getName()), e);
1500        }
1501        
1502        return new ArrayList<>();
1503    }
1504    
1505    /**
1506     * Create the project workspace for a given project.
1507     * @param project The project for which the workspace must be created
1508     * @param errors A list of possible errors to populate. Can be null if the caller is not interested in error tracking.
1509     * @return The site created for this workspace
1510     */
1511    protected Site _createProjectWorkspace(Project project, List<String> errors)
1512    {
1513        String initialSiteName = project.getName();
1514        Site site = null;
1515        
1516        Map<String, Object> result = _siteDao.createSite(null, initialSiteName, ProjectWorkspaceSiteType.TYPE_ID, true);
1517        
1518        String siteId = (String) result.get("id");
1519        String siteName = (String) result.get("name");
1520        if (StringUtils.isNotEmpty(siteId)) 
1521        {
1522            // Creation success
1523            site = _siteManager.getSite(siteName);
1524            
1525            I18nizableText i18nSiteTitle = new I18nizableText("plugin." + _pluginName, "PLUGINS_WORKSPACES_PROJECT_DEFAULT_PROJECT_WORKSPACE_TITLE", Arrays.asList(project.getTitle()));
1526            site.setTitle(_i18nUtils.translate(i18nSiteTitle));
1527            
1528            // Add site to project
1529            project.setSites(Arrays.asList(site.getName()));
1530            
1531            site.saveChanges();
1532        }
1533        
1534        return site;
1535    }
1536    
1537    /**
1538     * Get the project's tags
1539     * @return The project's tags 
1540     */
1541    @Callable
1542    public List<String> getTags()
1543    {
1544        AmetysObject projectsRootNode = getProjectsRoot();
1545        if (projectsRootNode instanceof JCRAmetysObject)
1546        {
1547            Node node = ((JCRAmetysObject) projectsRootNode).getNode();
1548            
1549            try
1550            {
1551                return Arrays.stream(node.getProperty(__PROJECTS_TAGS_PROPERTY).getValues())
1552                    .map(LambdaUtils.wrap(Value::getString))
1553                    .collect(Collectors.toList());
1554            }
1555            catch (PathNotFoundException e)
1556            {
1557                // property is not set, empty list will be returned.
1558            }
1559            catch (RepositoryException e)
1560            {
1561                throw new AmetysRepositoryException(e);
1562            }
1563        }
1564        
1565        return new ArrayList<>();
1566    }
1567    
1568    /**
1569     * Set the tags
1570     * @param tags The tags to set
1571     */
1572    @Callable
1573    public synchronized void setTags(List<String> tags)
1574    {
1575        AmetysObject projectsRootNode = getProjectsRoot();
1576        if (projectsRootNode instanceof JCRAmetysObject)
1577        {
1578            JCRAmetysObject jcrProjectsRootNode = (JCRAmetysObject) projectsRootNode;
1579            
1580            if (CollectionUtils.isNotEmpty(tags))
1581            {
1582                String[] tagsArray = tags.stream()
1583                        .map(String::trim)
1584                        .map(String::toLowerCase)
1585                        .filter(StringUtils::isNotEmpty)
1586                        .distinct()
1587                        .toArray(String[]::new);
1588                
1589                try
1590                {
1591                    jcrProjectsRootNode.getNode().setProperty(__PROJECTS_TAGS_PROPERTY, tagsArray);
1592                    jcrProjectsRootNode.saveChanges();
1593                }
1594                catch (RepositoryException e)
1595                {
1596                    throw new AmetysRepositoryException(e);
1597                }
1598            }
1599            else
1600            {
1601                Node node = jcrProjectsRootNode.getNode();
1602                try
1603                {
1604                    if (node.hasProperty(__PROJECTS_TAGS_PROPERTY))
1605                    {
1606                        node.getProperty(__PROJECTS_TAGS_PROPERTY).remove();
1607                        jcrProjectsRootNode.saveChanges();
1608                    }
1609                }
1610                catch (RepositoryException e)
1611                {
1612                    throw new AmetysRepositoryException(e);
1613                }
1614            }
1615        }
1616    }
1617    
1618    /**
1619     * Add project's tags
1620     * @param newTags The new tags to add
1621     */
1622    public synchronized void addTags(Collection<String> newTags)
1623    {
1624        if (CollectionUtils.isNotEmpty(newTags))
1625        {
1626            AmetysObject projectsRootNode = getProjectsRoot();
1627            if (projectsRootNode instanceof JCRAmetysObject)
1628            {
1629                // Concat existing tags with new lowercased tags
1630                String[] tags = Stream.concat(getTags().stream(), newTags.stream().map(String::trim).map(String::toLowerCase).filter(StringUtils::isNotEmpty))
1631                        .distinct()
1632                        .toArray(String[]::new);
1633                
1634                try
1635                {
1636                    ((JCRAmetysObject) projectsRootNode).getNode().setProperty(__PROJECTS_TAGS_PROPERTY, tags);
1637                }
1638                catch (RepositoryException e)
1639                {
1640                    throw new AmetysRepositoryException(e);
1641                }
1642            }
1643        }
1644    }
1645    
1646    /**
1647     * Get the project's places
1648     * @return The project's places 
1649     */
1650    @Callable
1651    public List<String> getPlaces()
1652    {
1653        AmetysObject projectsRootNode = getProjectsRoot();
1654        if (projectsRootNode instanceof JCRAmetysObject)
1655        {
1656            Node node = ((JCRAmetysObject) projectsRootNode).getNode();
1657            
1658            try
1659            {
1660                return Arrays.stream(node.getProperty(__PROJECTS_PLACES_PROPERTY).getValues())
1661                    .map(LambdaUtils.wrap(Value::getString))
1662                    .collect(Collectors.toList());
1663            }
1664            catch (PathNotFoundException e)
1665            {
1666                // property is not set, empty list will be returned.
1667            }
1668            catch (RepositoryException e)
1669            {
1670                throw new AmetysRepositoryException(e);
1671            }
1672        }
1673        
1674        return new ArrayList<>();
1675    }
1676    
1677    /**
1678     * Add project's places
1679     * @param newPlaces The new places to add
1680     */
1681    public synchronized void addPlaces(Collection<String> newPlaces)
1682    {
1683        if (CollectionUtils.isNotEmpty(newPlaces))
1684        {
1685            AmetysObject projectsRootNode = getProjectsRoot();
1686            if (projectsRootNode instanceof JCRAmetysObject)
1687            {
1688                Set<String> lowercasedPlaces = new HashSet<>();
1689                
1690                // Concat existing places with new places
1691                String[] places = Stream.concat(getPlaces().stream(), newPlaces.stream().map(String::trim).filter(StringUtils::isNotEmpty))
1692                        // duplicates are filtered out
1693                        .filter(p -> lowercasedPlaces.add(p.toLowerCase()))
1694                        .toArray(String[]::new);
1695                
1696                try
1697                {
1698                    ((JCRAmetysObject) projectsRootNode).getNode().setProperty(__PROJECTS_PLACES_PROPERTY, places);
1699                }
1700                catch (RepositoryException e)
1701                {
1702                    throw new AmetysRepositoryException(e);
1703                }
1704            }
1705        }
1706    }
1707    
1708    /**
1709     * Set the places
1710     * @param places The places to set
1711     */
1712    @Callable
1713    public synchronized void setPlaces(List<String> places)
1714    {
1715        AmetysObject projectsRootNode = getProjectsRoot();
1716        if (projectsRootNode instanceof JCRAmetysObject)
1717        {
1718            JCRAmetysObject jcrProjectsRootNode = (JCRAmetysObject) projectsRootNode;
1719            
1720            if (CollectionUtils.isNotEmpty(places))
1721            {
1722                Set<String> lowercasedPlaces = new HashSet<>();
1723                
1724                String[] placesArray = places.stream()
1725                        .map(String::trim)
1726                        .filter(StringUtils::isNotEmpty)
1727                        // duplicates are filtered out
1728                        .filter(p -> lowercasedPlaces.add(p.toLowerCase()))
1729                        .toArray(String[]::new);
1730                
1731                try
1732                {
1733                    jcrProjectsRootNode.getNode().setProperty(__PROJECTS_PLACES_PROPERTY, placesArray);
1734                    jcrProjectsRootNode.saveChanges();
1735                }
1736                catch (RepositoryException e)
1737                {
1738                    throw new AmetysRepositoryException(e);
1739                }
1740            }
1741            else
1742            {
1743                Node node = jcrProjectsRootNode.getNode();
1744                try
1745                {
1746                    if (node.hasProperty(__PROJECTS_PLACES_PROPERTY))
1747                    {
1748                        node.getProperty(__PROJECTS_PLACES_PROPERTY).remove();
1749                        jcrProjectsRootNode.saveChanges();
1750                    }
1751                }
1752                catch (RepositoryException e)
1753                {
1754                    throw new AmetysRepositoryException(e);
1755                }
1756            }
1757        }
1758    }
1759    
1760    /**
1761     * Get the list of activated modules for a project
1762     * @param project The project
1763     * @return The list of activated modules
1764     */
1765    public List<WorkspaceModule> getModules(Project project)
1766    {
1767        return _moduleManagerEP.getModules().stream()
1768                .filter(module -> isModuleActivated(project, module.getId()))
1769                .collect(Collectors.toList());
1770    }
1771    
1772    /**
1773     * Retrieves the page of the module for all available languages
1774     * @param project The project
1775     * @param moduleId The project module id
1776     * @param language the sitemap language or <code>null</code> for all sitemap languages.
1777     * @return the page or null if not found
1778     */
1779    public AmetysObjectIterable<Page> getModulePages(Project project, String moduleId, String language)
1780    {
1781        if (_moduleManagerEP.hasExtension(moduleId))
1782        {
1783            WorkspaceModule moduleManager = _moduleManagerEP.getExtension(moduleId);
1784            return moduleManager.getModulePages(project, language);
1785        }
1786        return null;
1787    }
1788    
1789    /**
1790     * Get a page in the site of a given project with a specific tag
1791     * @param project The project
1792     * @param tagName The name of the tag
1793     * @param language the sitemap language or <code>null</code> for all sitemap languages.
1794     * @return The module's pages
1795     */
1796    public AmetysObjectIterable<Page> getProjectPages(Project project, String tagName, String language)
1797    {
1798        String siteName = Iterables.getFirst(getProjectNames(project), null);
1799        if (StringUtils.isEmpty(siteName))
1800        {
1801            return null;
1802        }
1803        
1804        Expression expression = new TagExpression(Operator.EQ, tagName);
1805        String query = PageQueryHelper.getPageXPathQuery(siteName, language, null, expression, null);
1806
1807        return _resolver.query(query);
1808    }
1809    
1810    
1811    /**
1812     * Get the dashboard page in the site of a given project
1813     * @param project The project
1814     * @param language the sitemap language or <code>null</code> for all sitemap languages.
1815     * @return The module's dashboard pages
1816     */
1817    public AmetysObjectIterable<Page> getProjectDashboardPage(Project project, String language)
1818    {
1819        String siteName = Iterables.getFirst(getProjectNames(project), null);
1820        if (StringUtils.isEmpty(siteName))
1821        {
1822            return null;
1823        }
1824        
1825        String query = "//element(" + siteName + ", ametys:site)/ametys-internal:sitemaps/" 
1826                + (language == null ? "*" : language)
1827                + "//element(index, ametys:page)";
1828                
1829        return _resolver.query(query);
1830    }
1831    
1832    /**
1833     * Activate the list of module of the project
1834     * @param project The project
1835     * @param moduleIds The list of modules. Can be null to activate all modules 
1836     */
1837    public void activateModules(Project project, Set<String> moduleIds)
1838    {
1839        Set<String> modules = moduleIds == null ? _moduleManagerEP.getExtensionsIds() : moduleIds;
1840        
1841        for (String moduleId : modules)
1842        {
1843            WorkspaceModule module = _moduleManagerEP.getModule(moduleId);
1844            if (module != null && !isModuleActivated(project, moduleId))
1845            {
1846                module.activateModule(project);
1847                project.addModule(moduleId);
1848            }
1849        }
1850        
1851        project.saveChanges();
1852    }    
1853    
1854    /**
1855     * Initialize the sitemap with the active module of the project
1856     * @param project The project
1857     * @param sitemap The sitemap
1858     */
1859    public void initializeModulesSitemap(Project project, Sitemap sitemap)
1860    {
1861        Set<String> modules = _moduleManagerEP.getExtensionsIds();
1862        
1863        for (String moduleId : modules)
1864        {
1865            if (_moduleManagerEP.hasExtension(moduleId))
1866            {
1867                WorkspaceModule module = _moduleManagerEP.getExtension(moduleId);
1868                
1869                if (isModuleActivated(project, moduleId))
1870                {
1871                    module.initializeSitemap(sitemap);
1872                }
1873            }
1874        }
1875    }
1876    
1877    /**
1878     * Determines if a module is activated
1879     * @param project The project
1880     * @param moduleId The id of module
1881     * @return true if the module the currently activated
1882     */
1883    public boolean isModuleActivated(Project project, String moduleId)
1884    {
1885        return ArrayUtils.contains(project.getModules(), moduleId);
1886    }
1887    
1888    /**
1889     * Remove the explorer root node of the project module, remove all events 
1890     * related to that module and set it to deactivated
1891     * @param project The project
1892     * @param moduleIds The id of module to activate
1893     */
1894    public void deactivateModules(Project project, Set<String> moduleIds)
1895    {
1896        for (String moduleId : moduleIds)
1897        {
1898            WorkspaceModule module = _moduleManagerEP.getModule(moduleId);
1899            if (module != null && isModuleActivated(project, moduleId))
1900            {
1901                module.deactivateModule(project);
1902                project.removeModule(moduleId);
1903            }
1904        }
1905        
1906        project.saveChanges();
1907    }
1908    
1909    
1910    /**
1911     * Get the list of profiles configured for the workspaces' projects
1912     * @return The list of profiles as JSON
1913     */
1914    @Callable
1915    public Map<String, Object> getProjectProfiles()
1916    {
1917        Map<String, Object> result = new HashMap<>();
1918        List<Map<String, Object>> profiles = _projectRightHelper.getProfileList().stream().map(p -> p.toJSON()).collect(Collectors.toList());
1919        result.put("profiles", profiles);
1920        return result;
1921    }
1922}