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