001/*
002 *  Copyright 2017 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.io.IOException;
019import java.io.InputStream;
020import java.time.ZonedDateTime;
021import java.util.ArrayList;
022import java.util.Arrays;
023import java.util.Collections;
024import java.util.HashMap;
025import java.util.HashSet;
026import java.util.Iterator;
027import java.util.List;
028import java.util.Map;
029import java.util.Objects;
030import java.util.Set;
031import java.util.function.Predicate;
032import java.util.stream.Collectors;
033import java.util.stream.Stream;
034
035import org.apache.avalon.framework.component.Component;
036import org.apache.avalon.framework.context.Context;
037import org.apache.avalon.framework.context.ContextException;
038import org.apache.avalon.framework.context.Contextualizable;
039import org.apache.avalon.framework.logger.AbstractLogEnabled;
040import org.apache.avalon.framework.service.ServiceException;
041import org.apache.avalon.framework.service.ServiceManager;
042import org.apache.avalon.framework.service.Serviceable;
043import org.apache.cocoon.servlet.multipart.Part;
044import org.apache.cocoon.xml.AttributesImpl;
045import org.apache.cocoon.xml.XMLUtils;
046import org.apache.commons.collections.CollectionUtils;
047import org.apache.commons.lang.StringUtils;
048import org.apache.commons.lang3.tuple.Pair;
049import org.apache.excalibur.source.Source;
050import org.apache.excalibur.source.SourceResolver;
051import org.apache.tika.io.FilenameUtils;
052import org.xml.sax.ContentHandler;
053import org.xml.sax.SAXException;
054
055import org.ametys.cms.languages.LanguagesManager;
056import org.ametys.cms.transformation.xslt.ResolveURIComponent;
057import org.ametys.core.group.GroupDirectoryContextHelper;
058import org.ametys.core.observation.Event;
059import org.ametys.core.observation.ObservationManager;
060import org.ametys.core.right.ProfileAssignmentStorageExtensionPoint;
061import org.ametys.core.right.RightManager;
062import org.ametys.core.right.RightManager.RightResult;
063import org.ametys.core.ui.Callable;
064import org.ametys.core.user.CurrentUserProvider;
065import org.ametys.core.user.User;
066import org.ametys.core.user.UserIdentity;
067import org.ametys.core.user.UserManager;
068import org.ametys.core.user.population.PopulationContextHelper;
069import org.ametys.core.util.I18nUtils;
070import org.ametys.core.util.URIUtils;
071import org.ametys.core.util.mail.SendMailHelper;
072import org.ametys.plugins.core.user.UserHelper;
073import org.ametys.plugins.repository.AmetysObjectResolver;
074import org.ametys.plugins.repository.data.holder.ModelAwareDataHolder;
075import org.ametys.plugins.repository.jcr.NameHelper;
076import org.ametys.plugins.workspaces.about.AboutWorkspaceModule;
077import org.ametys.plugins.workspaces.alert.AlertWorkspaceModule;
078import org.ametys.plugins.workspaces.categories.Category;
079import org.ametys.plugins.workspaces.categories.CategoryHelper;
080import org.ametys.plugins.workspaces.categories.CategoryProviderExtensionPoint;
081import org.ametys.plugins.workspaces.keywords.KeywordProviderExtensionPoint;
082import org.ametys.plugins.workspaces.keywords.KeywordsDAO;
083import org.ametys.plugins.workspaces.members.MembersWorkspaceModule;
084import org.ametys.plugins.workspaces.members.ProjectMemberManager;
085import org.ametys.plugins.workspaces.members.ProjectMemberManager.ProjectMember;
086import org.ametys.plugins.workspaces.news.NewsWorkspaceModule;
087import org.ametys.plugins.workspaces.project.favorites.FavoritesHelper;
088import org.ametys.plugins.workspaces.project.modules.WorkspaceModuleExtensionPoint;
089import org.ametys.plugins.workspaces.project.notification.preferences.NotificationPreferencesHelper;
090import org.ametys.plugins.workspaces.project.objects.Project;
091import org.ametys.plugins.workspaces.project.objects.Project.InscriptionStatus;
092import org.ametys.plugins.workspaces.project.rights.ProjectRightHelper;
093import org.ametys.plugins.workspaces.tags.ProjectTagProviderExtensionPoint;
094import org.ametys.runtime.config.Config;
095import org.ametys.runtime.i18n.I18nizableText;
096import org.ametys.runtime.i18n.I18nizableTextParameter;
097import org.ametys.web.ObservationConstants;
098import org.ametys.web.repository.page.Page;
099import org.ametys.web.repository.page.SitemapElement;
100import org.ametys.web.repository.page.ZoneItem;
101import org.ametys.web.repository.site.Site;
102import org.ametys.web.repository.site.SiteDAO;
103import org.ametys.web.site.SiteConfigurationManager;
104
105import jakarta.mail.MessagingException;
106
107/**
108 * Manager for the Projects Catalogue service
109 */
110public class ProjectsCatalogueManager extends AbstractLogEnabled implements Serviceable, Component, Contextualizable
111{
112    /** Avalon Role */
113    public static final String ROLE = ProjectsCatalogueManager.class.getName();
114    
115    /** The identifier of modules that are always active */
116    public static final Set<String> DEFAULT_MODULES = Set.of(MembersWorkspaceModule.MEMBERS_MODULE_ID,
117                                                             AboutWorkspaceModule.ABOUT_MODULE_ID,
118                                                             NewsWorkspaceModule.NEWS_MODULE_ID,
119                                                             AlertWorkspaceModule.ALERT_MODULE_ID);
120
121    /** List of allowed field received from the front */
122    private static final String[] __ALLOWED_FORM_DATA = {"description", "emailList", "inscriptionStatus", "defaultProfile", "tags", "categoryTags", "keywords"};
123
124    /** Ametys Object Resolver */
125    protected AmetysObjectResolver _resolver;
126    /** Current user provider */
127    protected CurrentUserProvider _currentUserProvider;
128    /** The project members' manager */
129    protected ProjectMemberManager _projectMemberManager;
130
131    /** The right manager */
132    protected RightManager _rightManager;
133    /** The project manager */
134    protected ProjectManager _projectManager;
135    /** Helper for project's rights */
136    protected ProjectRightHelper _projectRightHelper;
137    /** The language manager */
138    protected LanguagesManager _languagesManager;
139    /** The source resolver */
140    protected SourceResolver _sourceResolver;
141    /** The site dao */
142    protected SiteDAO _siteDAO;
143    /** The site's configuration manager */
144    protected SiteConfigurationManager _siteConfigurationManager;
145    /** Helper for user population */
146    protected PopulationContextHelper _populationContextHelper;
147    /** The extension point for workspace's modules */
148    protected WorkspaceModuleExtensionPoint _moduleManagerEP;
149    /** The extension point for profiles' storage */
150    protected ProfileAssignmentStorageExtensionPoint _profileAssignmentStorageExtensionPoint;
151    /** The user manager */
152    protected UserManager _userManager;
153    /** Utils for i18n */
154    protected I18nUtils _i18nUtils;
155    /** The observation manager */
156    protected ObservationManager _observationManager;
157    /** Helper for group directory's context */
158    protected GroupDirectoryContextHelper _groupDirectoryContextHelper;
159    /** The extension point for project's tags */
160    protected ProjectTagProviderExtensionPoint _projectTagProviderEP;
161    /** The extension point for project's categories */
162    protected CategoryProviderExtensionPoint _categoryProviderEP;
163    /** The extension point for project's keywords */
164    protected KeywordProviderExtensionPoint _keywordProviderEP;
165    /** To handle favorites */
166    protected FavoritesHelper _favoritesHelper;
167    /** The preference helper for notifications */
168    protected NotificationPreferencesHelper _notificationPreferenceHelper;
169    
170    /** The avalon context */
171    protected Context _context;
172    
173    private CategoryHelper _categoryHelper;
174    
175    private ProjectMemberManager _projectMembers;
176
177    private UserHelper _userHelper;
178
179    private KeywordsDAO _keywordsDAO;
180
181
182    public void service(ServiceManager manager) throws ServiceException
183    {
184        _userHelper = (UserHelper) manager.lookup(UserHelper.ROLE);
185        _rightManager = (RightManager) manager.lookup(RightManager.ROLE);
186        _projectManager = (ProjectManager) manager.lookup(ProjectManager.ROLE);
187        _projectMemberManager = (ProjectMemberManager) manager.lookup(ProjectMemberManager.ROLE);
188        _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE);
189        _projectRightHelper = (ProjectRightHelper) manager.lookup(ProjectRightHelper.ROLE);
190        _resolver = (AmetysObjectResolver) manager.lookup(AmetysObjectResolver.ROLE);
191        _languagesManager = (LanguagesManager) manager.lookup(LanguagesManager.ROLE);
192        _sourceResolver = (SourceResolver) manager.lookup(SourceResolver.ROLE);
193        _siteConfigurationManager = (SiteConfigurationManager) manager.lookup(SiteConfigurationManager.ROLE);
194        _siteDAO = (SiteDAO) manager.lookup(SiteDAO.ROLE);
195        _populationContextHelper = (PopulationContextHelper) manager.lookup(PopulationContextHelper.ROLE);
196        _moduleManagerEP = (WorkspaceModuleExtensionPoint) manager.lookup(WorkspaceModuleExtensionPoint.ROLE);
197        _profileAssignmentStorageExtensionPoint = (ProfileAssignmentStorageExtensionPoint) manager.lookup(ProfileAssignmentStorageExtensionPoint.ROLE);
198        _userManager = (UserManager) manager.lookup(UserManager.ROLE);
199        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
200        _observationManager = (ObservationManager) manager.lookup(ObservationManager.ROLE);
201        _groupDirectoryContextHelper = (GroupDirectoryContextHelper) manager.lookup(GroupDirectoryContextHelper.ROLE);
202        _projectTagProviderEP = (ProjectTagProviderExtensionPoint) manager.lookup(ProjectTagProviderExtensionPoint.ROLE);
203        _categoryProviderEP = (CategoryProviderExtensionPoint) manager.lookup(CategoryProviderExtensionPoint.ROLE);
204        _keywordProviderEP = (KeywordProviderExtensionPoint) manager.lookup(KeywordProviderExtensionPoint.ROLE);
205        _categoryHelper = (CategoryHelper) manager.lookup(CategoryHelper.ROLE);
206        _projectMembers = (ProjectMemberManager) manager.lookup(ProjectMemberManager.ROLE);
207        _keywordsDAO = (KeywordsDAO) manager.lookup(KeywordsDAO.ROLE);
208        _favoritesHelper = (FavoritesHelper) manager.lookup(FavoritesHelper.ROLE);
209        _notificationPreferenceHelper = (NotificationPreferencesHelper) manager.lookup(NotificationPreferencesHelper.ROLE);
210    }
211    
212    public void contextualize(Context context) throws ContextException
213    {
214        _context = context;
215    }
216    
217    private Pair<List<String>, List<Map<String, Object>>>  _createMissingKeywords(List<Object> keywords) throws IllegalAccessException
218    {
219        List<Map<String, Object>> newKeywordsInfo = Collections.emptyList();
220        String[] keywordsToCreate = keywords.stream().filter(t -> t instanceof Map).map(t -> (String) ((Map) t).get("text")).toArray(String[]::new);
221        if (keywordsToCreate.length > 0)
222        {
223            // check right
224            if (_rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_HANDLE_PROJECTKEYWORDS, "/cms") != RightResult.RIGHT_ALLOW)
225            {
226                throw new IllegalAccessException("User " + _currentUserProvider.getUser() + " tried to create a project tag without the convinient rights");
227            }
228            
229            newKeywordsInfo = _keywordsDAO.addTags(keywordsToCreate); 
230        }
231        
232        Iterator<Map<String, Object>> newKeywordsInfoIterator = newKeywordsInfo.iterator();
233        List<String> keywordsToSet = keywords.stream().map(t -> t instanceof String ? (String) t : (String) newKeywordsInfoIterator.next().get("name")).collect(Collectors.toList());
234        return Pair.of(keywordsToSet, newKeywordsInfo);
235    }
236    
237    /**
238     * Create a project
239     * @param zoneItemId The id of the zoneitem holding the catalog service
240     * @param title The title
241     * @param description The description (can be empty)
242     * @param illustration The illustration (can be a File or a local path)
243     * @param category The category
244     * @param keywords The project keywords
245     * @param visibility The visibility
246     * @param defaultProfile For public projects, profile for self registered users
247     * @param language The language code
248     * @param managers The managers url
249     * @param modules The selected modules
250     * @return Information about the new project
251     * @throws IllegalAccessException If user has no right to create tags and ask to
252     */
253    @Callable
254    public Map<String, Object> createProject(String zoneItemId, String title, String description, Object illustration, String category, List<Object> keywords, Integer visibility, String defaultProfile, String language, List<String> managers, List<String> modules) throws IllegalAccessException
255    {
256        ZoneItem zoneItem = _resolver.resolveById(zoneItemId);
257
258        InscriptionStatus inscriptionStatus = visibility == 1 
259                ? InscriptionStatus.PRIVATE
260                : (visibility == 2 
261                    ? InscriptionStatus.MODERATED
262                    : InscriptionStatus.OPEN);
263        _projectManager.checkRightsForProjectCreation(inscriptionStatus, zoneItem.getZone().getSitemapElement());
264        
265        // Get service parameters
266        ModelAwareDataHolder serviceDataHolder = zoneItem.getServiceParameters();
267        String titlePrefix = serviceDataHolder.getValue("titlePrefix", false, "");
268        String urlPrefix = serviceDataHolder.getValue("urlPrefix", false, "");
269        String[] availableLanguages = serviceDataHolder.getValue("availableLanguages");
270        String skin = serviceDataHolder.getValue("skin", false, "");
271        String forceAcceptCookie = serviceDataHolder.getValue("force_accept_cookies", false, "");
272        String[] populationIds = serviceDataHolder.getValue("populationIds") != null ? serviceDataHolder.getValue("populationIds") : new String[0];
273
274        Site catalogSite = zoneItem.getZone().getSitemapElement().getSite();
275
276        // Check language
277        if (Arrays.stream(availableLanguages).filter(language::equals).findFirst().isEmpty())
278        {
279            throw new IllegalArgumentException("Cannot create project with language '" + language + "' since it is not part of the available languages " + availableLanguages);
280        }
281        // Check profile
282        String defaultManagerProfile = Config.getInstance().getValue("workspaces.profile.managerdefault", false, null);
283        if (StringUtils.isBlank(defaultManagerProfile))
284        {
285            throw new IllegalArgumentException("The general configuration parameter 'workspaces.profile.managerdefault' cannot be empty");
286        }
287        
288        // Create tags if necessary
289        Pair<List<String>, List<Map<String, Object>>> keywordsInfo = _createMissingKeywords(keywords); 
290        List<String> keywordsToSet = keywordsInfo.getLeft();
291        List<Map<String, Object>> newKeywordsInfo = keywordsInfo.getRight();
292
293        // Create project object in repo
294        Map<String, Object> additionalValues = new HashMap<>();
295        additionalValues.put("description", StringUtils.defaultString(description));
296        additionalValues.put("categoryTags", Collections.singletonList(category));
297        additionalValues.put("inscriptionStatus", inscriptionStatus.toString());
298        additionalValues.put("defaultProfile", defaultProfile);
299        additionalValues.put("keywords", keywordsToSet); 
300        additionalValues.put("language", language); 
301
302        List<String> errors = new ArrayList<>();
303        String prefixedTitle = (titlePrefix + " " + title).trim();
304        Project project = _projectManager.createProject(_findName(prefixedTitle), 
305                                                        prefixedTitle, 
306                                                        additionalValues, 
307                                                        _withDefaultModules(modules), 
308                                                        errors);
309                                                        
310        if (!CollectionUtils.isEmpty(errors))
311        {
312            Map<String, Object> result = new HashMap<>();
313            result.put("success", false);
314            
315            return result;
316        }
317        
318        // Add site infos
319        _updateSiteInfos(project, urlPrefix, skin, forceAcceptCookie, catalogSite, illustration, language);
320        
321        // Assign populations and manager
322        _assignPopulations(project, catalogSite, Set.of(populationIds));
323        _assignManagers(project, managers, defaultManagerProfile);
324        project.saveChanges();
325
326        Map<String, Object> result = new HashMap<>();
327        result.put("success", true);
328        result.put("project", _detailedMyProject2json(project, zoneItem.getZone().getSitemapElement(), false, _notificationPreferenceHelper.getPausedProjects(_currentUserProvider.getUser()) == null ? null : false));
329        result.put("keywords", newKeywordsInfo);
330        return result;
331    }
332    
333    private void _assignPopulations(Project project, Site catalogSite, Set<String> serviceParameterPopulationIds)
334    {
335        Site site = project.getSite();
336        
337        // By default, copy populations from the catalog
338        Set<String> populations = _populationContextHelper.getUserPopulationsOnContext("/sites/" + catalogSite.getName(), false);
339        Set<String> frontPopulations = _populationContextHelper.getUserPopulationsOnContext("/sites-fo/" + catalogSite.getName(), false);
340
341        // If populations have been specified in the service, use them
342        if (serviceParameterPopulationIds.size() > 0)
343        {
344            populations = populations.stream()
345                    .filter(populationId -> serviceParameterPopulationIds.contains(populationId))
346                    .collect(Collectors.toSet());
347            frontPopulations = frontPopulations.stream()
348                    .filter(populationId -> serviceParameterPopulationIds.contains(populationId))
349                    .collect(Collectors.toSet());
350        }
351        
352        _populationContextHelper.link("/sites/" + site.getName(), populations);
353        _populationContextHelper.link("/sites-fo/" + site.getName(), frontPopulations);
354            
355
356        Set<String> groupDirectories = _groupDirectoryContextHelper.getGroupDirectoriesOnContext("/sites/" + catalogSite.getName());
357        Set<String> frontGroupDirectories = _groupDirectoryContextHelper.getGroupDirectoriesOnContext("/sites-fo/" + catalogSite.getName());
358        
359        _groupDirectoryContextHelper.link("/sites/" + site.getName(), new ArrayList<>(groupDirectories));
360        _groupDirectoryContextHelper.link("/sites-fo/" + site.getName(), new ArrayList<>(frontGroupDirectories));
361    }
362    
363    private void _assignManagers(Project project, List<String> managers, String defaultManagerProfile)
364    {
365        List<UserIdentity> projectManagers = managers.stream()
366                .map(UserIdentity::stringToUserIdentity)
367                .filter(Objects::nonNull)
368                .filter(user -> _projectManager.isUserInProjectPopulations(project, user))
369                .collect(Collectors.toList());
370        
371        _projectMemberManager.setProjectManager(project.getName(), defaultManagerProfile, projectManagers);
372    }
373    
374    private void _updateSiteInfos(Project project, String urlPrefix, String skin, String forceAcceptCookie, Site catalogSite, Object illustration, String language)
375    {
376        Site site = project.getSite();
377        
378        site.setUrl(urlPrefix + "/" + project.getName());
379        site.setValue("skin", skin);
380        if (site.hasDefinition("force-accept-cookies"))
381        {
382            site.setValue("force-accept-cookies", forceAcceptCookie);
383        }
384
385        site.setValue("display-restricted-pages", false);
386        site.setValue("ping_activated", false);
387        site.setValue("site-mail-from", catalogSite.getValue("site-mail-from"));
388        site.setValue("site-contents-comments-postvalidation", catalogSite.getValue("site-contents-comments-postvalidation"));
389        site.setValue("color", catalogSite.getValue("color"));
390        
391        _setIllustration(site, illustration);
392
393        _siteDAO.setLanguages(site, Collections.singletonList(language));
394        
395        site.saveChanges();
396        
397        Map<String, Object> eventParams = new HashMap<>();
398        eventParams = new HashMap<>();
399        eventParams.put(org.ametys.web.ObservationConstants.ARGS_SITE, site);
400        _observationManager.notify(new Event(org.ametys.web.ObservationConstants.EVENT_SITE_UPDATED, _currentUserProvider.getUser(), eventParams));
401    }
402
403    private void _setIllustration(Site site, Object illustration)
404    {
405        Object illustrationObject = null;
406        try
407        {
408            illustrationObject = _getIllustrationSource(illustration);
409            if (illustrationObject instanceof Source)
410            {
411                Source illustrationSource = (Source) illustrationObject;
412                try (InputStream is = illustrationSource.getInputStream())
413                {
414                    site.setIllustration(is, illustrationSource.getMimeType(), FilenameUtils.getName(illustrationSource.getURI()), ZonedDateTime.now());
415                }
416            }
417            else if (illustrationObject instanceof Part)
418            {
419                Part illustrationPart = (Part) illustrationObject;
420                try (InputStream is = illustrationPart.getInputStream())
421                {
422                    site.setIllustration(is, illustrationPart.getMimeType(), illustrationPart.getUploadName(), ZonedDateTime.now());
423                }
424            }
425        }
426        catch (IOException e)
427        {
428            throw new IllegalArgumentException("Cannot not get illustration", e);
429        }
430        finally 
431        {
432            if (illustrationObject instanceof Source)
433            {
434                _sourceResolver.release((Source) illustrationObject);
435            }
436        }
437
438    }
439
440    private Object _getIllustrationSource(Object illustration) throws IOException
441    {
442        if (illustration instanceof String)
443        {
444            String illustrationAsString = (String) illustration;
445            if (illustrationAsString.contains("/") || illustrationAsString.contains("\\"))
446            {
447                throw new IllegalArgumentException("Cannot choose an illustration outside the library directory");
448            }
449            
450            return _sourceResolver.resolveURI("plugin:workspaces://resources/img/catalog/library/" + illustrationAsString);
451        }
452        else if (illustration instanceof Part)
453        {
454            Part illustrationAsFile = (Part) illustration;
455            return illustrationAsFile;
456        }
457        else // boolean => unchanged
458        {
459            return null;
460        }
461    }
462
463    private Set<String> _withDefaultModules(List<String> modules)
464    {
465        Set<String> modulesToActivate = new HashSet<>();
466        
467        modulesToActivate.addAll(DEFAULT_MODULES);
468        modulesToActivate.addAll(modules);
469        
470        return modulesToActivate;
471    }
472
473    private String _findName(String title)
474    {
475        String originalName = NameHelper.filterName(title);
476        String name = originalName;
477        
478        int index = 2;
479        while (_projectManager.hasProject(name))
480        {
481            name = originalName + "-" + (index++);
482        }
483
484        return name;
485    }
486
487    /**
488     * Edit an existing project
489     * @param zoneItemId The id of the zoneitem holding the catalog service
490     * @param projectId The id of the project
491     * @param title New title
492     * @param description New description
493     * @param illustration New illustration
494     * @param category New category
495     * @param keywords The project keywords
496     * @param visibility New visibility
497     * @param defaultProfile New default profile
498     * @param managers New managers
499     * @param modules  New modules
500     * @return The success map with project description
501     * @throws IllegalAccessException if user has not the convenient rights
502     */    
503    @Callable
504    public Map<String, Object> editProject(String zoneItemId, String projectId, String title, String description, Object illustration, String category, List<Object> keywords, Integer visibility, String defaultProfile, List<String> managers, List<String> modules) throws IllegalAccessException
505    {
506        Project project = _resolver.resolveById(projectId);
507        if (project == null)
508        {
509            throw new IllegalArgumentException("Unable to edit a project, invalid project id received '" + projectId + "'");
510        }
511
512        ZoneItem zoneItem = _resolver.resolveById(zoneItemId);
513        InscriptionStatus inscriptionStatus = visibility == 1 
514                ? InscriptionStatus.PRIVATE
515                        : (visibility == 2 
516                        ? InscriptionStatus.MODERATED
517                                : InscriptionStatus.OPEN);
518        _projectManager.checkRightsForProjectEdition(project, inscriptionStatus, zoneItem.getZone().getSitemapElement());
519        
520        // Check profile
521        String defaultManagerProfile = Config.getInstance().getValue("workspaces.profile.managerdefault", false, null);
522        if (StringUtils.isBlank(defaultManagerProfile))
523        {
524            throw new IllegalArgumentException("The general configuration parameter 'workspaces.profile.managerdefault' cannot be empty");
525        }
526
527        boolean canEdit = _rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_EDIT, project) == RightResult.RIGHT_ALLOW
528                || _rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_EDIT, zoneItem.getZone().getSitemapElement()) == RightResult.RIGHT_ALLOW;
529        if (!canEdit)
530        {
531            throw new IllegalAccessException("User '" + _currentUserProvider.getUser() + "' tried to edit the project '" + projectId + "' without sufficient rights");
532        }
533        
534        // Create keywords if necessary
535        Pair<List<String>, List<Map<String, Object>>> keywordsInfo = _createMissingKeywords(keywords); 
536        List<String> keywordsToSet = keywordsInfo.getLeft();
537        List<Map<String, Object>> newKeywordsInfo = keywordsInfo.getRight();
538        
539        project.setTitle(title);
540        project.setDescription(StringUtils.defaultString(description));
541        project.setInscriptionStatus(inscriptionStatus.toString());
542        project.setDefaultProfile(defaultProfile);
543        project.setCategoryTags(Collections.singletonList(category));
544        project.setKeywords(keywordsToSet.toArray(new String[keywordsToSet.size()]));
545
546        Site site = project.getSite();
547
548        _projectManager.setProjectSiteTitle(site, project.getTitle());
549        _setIllustration(site, illustration);
550        if (site.needsSave())
551        {
552            site.saveChanges();
553                    
554            Map<String, Object> eventParams = new HashMap<>();
555            eventParams.put(ObservationConstants.ARGS_SITE, site);
556            _observationManager.notify(new Event(ObservationConstants.EVENT_SITE_UPDATED, _currentUserProvider.getUser(), eventParams));
557        }
558        
559        _updateModules(project, _withDefaultModules(modules));
560        _assignManagers(project, managers, defaultManagerProfile);
561
562        if (project.needsSave())
563        {
564            project.saveChanges();
565            
566            // Notify observers
567            Map<String, Object> eventParams = new HashMap<>();
568            eventParams.put(org.ametys.plugins.workspaces.ObservationConstants.ARGS_PROJECT, project);
569            _observationManager.notify(new Event(org.ametys.plugins.workspaces.ObservationConstants.EVENT_PROJECT_UPDATED, _currentUserProvider.getUser(), eventParams));
570        }
571
572        UserIdentity user = _currentUserProvider.getUser();
573        
574        Map<String, Object> result = new HashMap<>();
575        result.put("success", true);
576        result.put("project", _detailedMyProject2json(project, zoneItem.getZone().getSitemapElement(), _favoritesHelper.getFavorites(user).contains(project.getName()), _notificationPreferenceHelper.getPausedProjects(user).contains(project.getName())));
577        result.put("keywords", newKeywordsInfo);
578        return result;
579    }
580    
581    private void _updateModules(Project project, Set<String> modules)
582    {
583        Set<String> modulesToActivate = new HashSet<>(modules);
584        Set<String> modulesToDeactivate = new HashSet<>(Arrays.asList(project.getModules()));
585        modulesToActivate.removeAll(new HashSet<>(Arrays.asList(project.getModules())));
586        modulesToDeactivate.removeAll(modules);
587
588        if (!modulesToActivate.isEmpty())
589        {
590            Map<String, Object> additionalValues = new HashMap<>();
591            additionalValues.put("description", project.getDescription());
592            additionalValues.put("inscriptionStatus", project.getInscriptionStatus());
593            additionalValues.put("emailList", project.getMailingList());
594            additionalValues.put("defaultProfile", project.getDefaultProfile());
595            additionalValues.put("categoryTags", project.getCategories());
596            additionalValues.put("keywords", project.getKeywords());
597            
598            Site site = project.getSite();
599            if (site != null)
600            {
601                additionalValues.put("language", site.getSitemaps().iterator().next().getName()); 
602            }
603            
604            _projectManager.activateModules(project, modulesToActivate, additionalValues);
605        }
606        if (!modulesToDeactivate.isEmpty())
607        {
608            _projectManager.deactivateModules(project, modulesToDeactivate);
609        }
610    }
611    
612    /**
613     * Delete a project
614     * @param zoneItemId The id of the zoneitem holding the catalog service
615     * @param projectId The project id
616     * @return The result
617     * @throws IllegalAccessException  if an error occurred
618     */
619    @Callable
620    public Map<String, Object> deleteProject(String zoneItemId, String projectId) throws IllegalAccessException
621    {
622        Project project = _resolver.resolveById(projectId);
623        
624        if (project == null)
625        {
626            throw new IllegalArgumentException("Unable to delete a project, invalid project id received '" + projectId + "'");
627        }
628        
629        ZoneItem zoneItem = _resolver.resolveById(zoneItemId);
630        boolean canDelete = _rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_DELETE, project) == RightResult.RIGHT_ALLOW
631            || _rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_DELETE, zoneItem.getZone().getSitemapElement()) == RightResult.RIGHT_ALLOW;
632        if (!canDelete)
633        {
634            throw new IllegalAccessException("User '" + _currentUserProvider.getUser() + "' tried to delete the project '" + projectId + "' without sufficient rights");
635        }
636        
637        Map<String, Object> result = new HashMap<>();
638        result.put("sites", _projectManager.deleteProject(project));
639        result.put("success", true);
640        return result;
641    }
642    
643    /**
644     * Add the current user to the project, if the project's inscriptions are opened
645     * @param projectId The project id
646     * @return The result
647     * @throws MessagingException If an error occurred sending a notification mail to the project manager
648     * @throws IOException If an error occurred sending the email to the project's manager
649     */
650    @Callable
651    public Map<String, Object> joinProject(String projectId) throws MessagingException, IOException
652    {
653        Map<String, Object> result = new HashMap<>();
654        Project project = _resolver.resolveById(projectId);
655        
656        if (project == null)
657        {
658            throw new IllegalArgumentException("Unable to join a project, invalid project id received '" + projectId + "'");
659        }
660
661        UserIdentity currentUser = _currentUserProvider.getUser();
662        
663        
664        Site site = project.getSite();
665        try
666        {
667            if (!_projectManager.isUserInProjectPopulations(project, currentUser))
668            {
669                //  User is not in the project site populations, cannot be added
670                result.put("success", false);
671                return result;
672            }
673        }
674        catch (IllegalArgumentException e)
675        {
676            result.put("success", false);
677            return result;
678        }
679        
680
681        
682        boolean success = _projectMemberManager.addProjectMember(project, currentUser);
683        
684        result.put("success", success);
685        if (success)
686        {
687            String url = site.getUrl();
688            result.put("url", url);
689            
690            String mailFrom = Config.getInstance().getValue("smtp.mail.from");
691            
692            List<String> managersEmails = Arrays.stream(project.getManagers())
693                    .map(manager -> _userManager.getUser(manager))
694                    .filter(Objects::nonNull)
695                    .map(User::getEmail)
696                    .filter(StringUtils::isNotEmpty)
697                    .collect(Collectors.toList());
698            
699            if (managersEmails.size() > 0 && mailFrom != null)
700            {
701                Map<String, I18nizableTextParameter> params = new HashMap<>();
702                User current = _userManager.getUser(currentUser);
703                params.put("user", new I18nizableText(current != null ? current.getFullName() : currentUser.getLogin()));
704                params.put("project", new I18nizableText(project.getTitle()));
705                params.put("url", new I18nizableText(url != null ? url : ""));
706                String subject = _i18nUtils.translate(new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_CATALOGUE_JOINPROJECT_MAIL_TITLE", params));
707                String textBody = _i18nUtils.translate(new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_CATALOGUE_JOINPROJECT_MAIL_BODY", params));
708                
709                for (String managerMail : managersEmails)
710                {
711                    SendMailHelper.newMail()
712                                  .withSubject(subject)
713                                  .withTextBody(textBody)
714                                  .withSender(mailFrom)
715                                  .withRecipient(managerMail)
716                                  .sendMail();
717                }
718            }
719        }
720        return result;
721    }
722    
723    /**
724     * Send a demand to join a project to the project's manager, if the project's inscriptions are moderated 
725     * @param projectId The project to join
726     * @param message A message to send to the project's manager.
727     * @return The result
728     * @throws MessagingException If an error occurred sending the email to the project's manager
729     * @throws IOException If an error occurred sending the email to the project's manager
730     */
731    @Callable
732    public Map<String, Object> askToJoinProject(String projectId, String message) throws MessagingException, IOException
733    {
734        Map<String, Object> result = new HashMap<>();
735
736        Project project = _resolver.resolveById(projectId);
737        if (project == null)
738        {
739            throw new IllegalArgumentException("Unable to join a project, invalid project id received '" + projectId + "'");
740        }
741
742        UserIdentity currentUser = _currentUserProvider.getUser();
743
744        if (!_projectManager.isUserInProjectPopulations(project, currentUser))
745        {
746            //  User is not in the project site populations, cannot be added
747            result.put("success", false);
748            return result;
749        }
750        
751        _sendAskToJoinMail(message, project, currentUser);
752        result.put("success", true);
753        result.put("added-notification", Config.getInstance().getValue("workspaces.member.added.send.notification"));
754        return result;
755    }
756
757    private void _sendAskToJoinMail(String message, Project project, UserIdentity joiningUser) throws MessagingException, IOException
758    {
759        String url = getAddUserUrl(project, joiningUser);
760        
761        String mailFrom = Config.getInstance().getValue("smtp.mail.from");
762        
763        List<String> managersEmails = Arrays.stream(project.getManagers())
764                .map(manager -> _userManager.getUser(manager))
765                .filter(Objects::nonNull)
766                .map(User::getEmail)
767                .filter(StringUtils::isNotEmpty)
768                .collect(Collectors.toList());
769        
770        if (managersEmails.size() > 0 && mailFrom != null)
771        {
772            String escapedMessage = StringUtils.isEmpty(message) ? null : message.replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll("\n", "<br/>");
773            
774            Map<String, I18nizableTextParameter> params = new HashMap<>();
775            User current = _userManager.getUser(joiningUser);
776            params.put("user", new I18nizableText(current != null ? current.getFullName() : joiningUser.getLogin()));
777            params.put("project", new I18nizableText(project.getTitle()));
778            params.put("url", new I18nizableText(url));
779            params.put("message", new I18nizableText(escapedMessage != null ? escapedMessage : ""));
780            String subject = _i18nUtils.translate(new I18nizableText("plugin.workspaces", "PLUGINS_WORKSPACES_CATALOGUE_JOINPROJECT_ASK_MAIL_TITLE", params));
781            String htmlBody = _i18nUtils.translate(new I18nizableText("plugin.workspaces", escapedMessage != null ? "PLUGINS_WORKSPACES_CATALOGUE_JOINPROJECT_ASK_MAIL_BODY" : "PLUGINS_WORKSPACES_CATALOGUE_JOINPROJECT_ASK_MAIL_BODY_EMPTY" , params));
782            
783            SendMailHelper.newMail()
784                          .withSubject(subject)
785                          .withHTMLBody(htmlBody)
786                          .withSender(mailFrom)
787                          .withRecipients(managersEmails)
788                          .sendMail();
789        }
790    }
791    
792    /**
793     * Get the absolute url to add a user to a project
794     * @param project The project
795     * @param user the identity of user to add
796     * @return the absolute page url
797     */
798    protected String getAddUserUrl(Project project, UserIdentity user)
799    {
800        String memberPage = "";
801        
802        Set<Page> membersPages = _projectManager.getModulePages(project, MembersWorkspaceModule.MEMBERS_MODULE_ID);
803        if (!membersPages.isEmpty())
804        {
805            memberPage = ResolveURIComponent.resolve("page", membersPages.iterator().next().getId(), false, true);
806        }
807
808        Site site = project.getSite();
809        String siteURL = site.getUrl();
810        String urlWithoutScheme = StringUtils.substringAfter(siteURL, "://");
811        String relativeURL = StringUtils.contains(urlWithoutScheme, "/") ? "/" + StringUtils.substringAfter(urlWithoutScheme, "/") : "";
812        return siteURL + "/_authenticate?requestedURL=" + URIUtils.encodeParameter(relativeURL + "/plugins/workspaces/add-member?redirect=" + URIUtils.encodeParameter(memberPage + "?added=" + URIUtils.encodeParameter(UserIdentity.userIdentityToString(user))) + "&user=" + URIUtils.encodeParameter(UserIdentity.userIdentityToString(user)) + "&project=" + URIUtils.encodeParameter(project.getName()));
813    }
814    
815    /**
816     * Get the list of allowed data in the form
817     * @return the list of allowed data in the form
818     */
819    protected String[] getAllowedFormData()
820    {
821        return __ALLOWED_FORM_DATA;
822    }
823
824
825    /**
826     * Callable to get projects of the user and the public projects he can subscribe.
827     * @param zoneItemId the zoneItemId of the catalog service, used to get allowed populations
828     * @return A map with three entries an entry for user projects, another one for public projects and finally one for the project's creation right
829     */
830    @Callable
831    public Map<String, Object> getUserAndPublicProjects(String zoneItemId)
832    {
833        Map<String, Object> result = new HashMap<>();
834     
835        ZoneItem zoneItem = _resolver.resolveById(zoneItemId);
836        ModelAwareDataHolder serviceDataHolder = zoneItem.getServiceParameters();
837        String[] populationIds = serviceDataHolder.getValue("populationIds") != null ? serviceDataHolder.getValue("populationIds") : new String[0];
838        
839        UserIdentity user = _currentUserProvider.getUser();
840        
841        // Check if the user is inside populations of future workspaces (only if at least one population have been selected)
842        boolean inPopulation = populationIds.length == 0 || Arrays.asList(populationIds).contains(user.getPopulationId());
843
844        SitemapElement sitemapElement = zoneItem.getZone().getSitemapElement();
845        boolean canCreatePrivateProjet = inPopulation && (_rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_CREATE_PRIVATE, "/cms") == RightResult.RIGHT_ALLOW 
846              || _rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_CREATE_PRIVATE, sitemapElement) == RightResult.RIGHT_ALLOW);
847        boolean canCreatePublicProjetWithModeration = inPopulation && (_rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_CREATE_PUBLIC_MODERATED, "/cms") == RightResult.RIGHT_ALLOW 
848              || _rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_CREATE_PUBLIC_MODERATED, sitemapElement) == RightResult.RIGHT_ALLOW);
849        boolean canCreatePublicProjet = inPopulation && (_rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_CREATE_PUBLIC_OPENED, "/cms") == RightResult.RIGHT_ALLOW 
850              || _rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_CREATE_PUBLIC_OPENED, sitemapElement) == RightResult.RIGHT_ALLOW);
851        
852        result.put("canCreate", canCreatePrivateProjet || canCreatePublicProjetWithModeration || canCreatePublicProjet);
853        result.put("canCreatePrivateProject", canCreatePrivateProjet);
854        result.put("canCreatePublicProjectWithModeration", canCreatePublicProjetWithModeration);
855        result.put("canCreatePublicProject", canCreatePublicProjet);
856        
857        List<Map<String, Object>> userProjects = new ArrayList<>();
858        List<Map<String, Object>> publicProjects = new ArrayList<>();
859        
860        Set<String> favorites = _favoritesHelper.getFavorites(user);
861        Set<String> pausedProjects = _notificationPreferenceHelper.getPausedProjects(user);
862        for (Project project : _projectManager.getProjects())
863        {
864            if (_projectMembers.isProjectMember(project, user))
865            {
866                Map<String, Object> json = _detailedMyProject2json(project, sitemapElement, favorites.contains(project.getName()), pausedProjects != null ? pausedProjects.contains(project.getName()) : null);
867                userProjects.add(json);
868            }
869            else if (project.getInscriptionStatus() != InscriptionStatus.PRIVATE && _projectManager.isUserInProjectPopulations(project, user))
870            {
871                Map<String, Object> json = detailedProject2json(project);
872                publicProjects.add(json);
873            }
874        }
875
876        result.put("userProjects", userProjects);
877        result.put("availablePublicProjects", publicProjects);
878        
879        return result;
880    }
881   
882    /**
883     * Callable to get projects of the user.
884     * @return A map with the user projects
885     */
886    @Callable
887    public List<Map<String, Object>> getUserProjects()
888    {
889        UserIdentity user = _currentUserProvider.getUser();
890       
891        List<Map<String, Object>> userProjects = new ArrayList<>();
892       
893        Set<String> favorites = _favoritesHelper.getFavorites(user);
894        Set<String> pausedProjects = _notificationPreferenceHelper.getPausedProjects(user);
895        for (Project project : _projectManager.getProjects())
896        {
897            if (_projectMembers.isProjectMember(project, user))
898            {
899                // Put null for the catalog page because this method is only used for mobile app and mobile app ignore canEdit and canDelete rights 
900                Map<String, Object> json = _detailedMyProject2json(project, null, favorites.contains(project.getName()), pausedProjects != null ? pausedProjects.contains(project.getName()) : null);
901                userProjects.add(json);
902            }
903        }
904
905        return userProjects;
906    }
907    
908    private Map<String, Object> _detailedMyProject2json(Project project, SitemapElement catalogPage, boolean isFavorite, Boolean isPaused)
909    {
910        Map<String, Object> json = detailedProject2json(project);
911        json.put("favorite", isFavorite);
912        json.put("notification", isPaused != null ? !isPaused : null);
913        boolean canEdit = _rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_EDIT, project) == RightResult.RIGHT_ALLOW
914            || catalogPage != null && _rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_EDIT, catalogPage) == RightResult.RIGHT_ALLOW;
915        json.put("canEdit", canEdit);
916        boolean canDelete = _rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_DELETE, project) == RightResult.RIGHT_ALLOW
917            || catalogPage != null && _rightManager.currentUserHasRight(ProjectConstants.RIGHT_PROJECT_DELETE, catalogPage) == RightResult.RIGHT_ALLOW;
918        json.put("canDelete", canDelete);
919        json.put("canAccessBO", _projectManager.canAccessBO(project));
920        return json;
921    }
922    
923    /**
924     * Transform a {@link Project} into a json map
925     * @param project the project to parse
926     * @return a json map
927     */
928    public Map<String, Object> detailedProject2json(Project project)
929    {
930        Map<String, Object> json = new HashMap<>();
931        
932        json.put("id", project.getId());
933        json.put("name", project.getName());
934        json.put("title", project.getTitle());
935        json.put("url", _projectManager.getProjectUrl(project, StringUtils.EMPTY));
936        
937        json.put("defaultProfile", project.getDefaultProfile());
938        
939        Set<String> categories = project.getCategories();
940        if (categories.size() != 1)
941        {
942            getLogger().warn("Project " + project.getTitle() + " (" + project.getId() + ") should have one and only one category");
943        }
944        
945        if (!categories.isEmpty())
946        {
947            String c = categories.iterator().next();
948            Category category = _categoryProviderEP.getTag(c, new HashMap<>());
949            
950            if (category != null)
951            {
952                Map<String, Object> map = new HashMap<>();
953                map.put("id", category.getId());
954                map.put("name", category.getName());
955                map.put("title", category.getTitle());            
956                map.put("color", _categoryHelper.getCategoryColor(category).get("main"));
957    
958                json.put("category", map);
959            }
960        }
961        
962        String[] keywords = Stream.of(project.getKeywords())
963            .filter(k -> _keywordProviderEP.hasTag(k, new HashMap<>()))
964            .toArray(String[]::new);
965        json.put("keywords", keywords);
966
967        UserIdentity[] managers = project.getManagers();
968        if (managers.length > 0)
969        {
970            json.put("managers", Arrays.stream(managers)
971                                       .map(_userHelper::user2json)
972                                       .filter(userAsJson -> !userAsJson.equals(Collections.EMPTY_MAP))
973                                       .collect(Collectors.toList()));
974        }
975        
976        List<Map<String, Object>> members = new ArrayList<>();
977        for (int i = 1; i < Math.min(managers.length, 4); i++)
978        {
979            members.add(_userHelper.user2json(managers[i]));
980        }
981        
982        if (members.size() < 3)
983        {
984            List<UserIdentity> managersList = Arrays.asList(managers);
985            
986            members.addAll(
987                _projectMembers.getProjectMembers(project, true)
988                    .stream()
989                    .map(ProjectMember::getUser)
990                    .map(User::getIdentity)
991                    .filter(Predicate.not(managersList::contains))
992                    .limit(3 - members.size())
993                    .map(_userHelper::user2json)
994                    .collect(Collectors.toList())
995            );
996        }
997        
998        json.put("members", members);
999        json.put("membersCount", _projectMembers.getMembersCount(project));
1000        
1001        json.put("modules", Arrays.asList(project.getModules()));
1002
1003        switch (project.getInscriptionStatus())
1004        {
1005            case PRIVATE: json.put("visibility", 1); break;
1006            case MODERATED: json.put("visibility", 2); break;
1007            case OPEN:
1008                // fallthrought
1009            default: 
1010                json.put("visibility", 3); break;
1011        }
1012        
1013        json.put("description", project.getDescription());
1014
1015        Site site = project.getSite();
1016        if (site != null)
1017        {
1018            json.put("site", site.getName());
1019            json.put("language", site.getSitemaps().iterator().next().getName());
1020            
1021            if (site.getIllustration() != null)
1022            {
1023                String illustration  = ResolveURIComponent.resolveCroppedImage("site-parameter", site.getName() + ";illustration", 252, 389, false, true);
1024                json.put("illustration", illustration);
1025            }
1026        }
1027        
1028        return json;
1029    }
1030    
1031    /**
1032     * SAX a project
1033     * @param contentHandler The content handler to sax into
1034     * @param project the project
1035     * @throws SAXException if an error occurred while saxing
1036     */
1037    public void saxProject(ContentHandler contentHandler, Project project) throws SAXException
1038    {
1039        AttributesImpl attrs = new AttributesImpl();
1040        attrs.addCDATAAttribute("id", project.getId());
1041        attrs.addCDATAAttribute("name", project.getName());
1042        
1043        XMLUtils.startElement(contentHandler, "project", attrs);
1044        
1045        XMLUtils.createElement(contentHandler, "title", project.getTitle());
1046        
1047        XMLUtils.createElement(contentHandler, "inscriptionStatus", project.getInscriptionStatus().name());
1048        
1049        String description = project.getDescription();
1050        if (description != null)
1051        {
1052            XMLUtils.createElement(contentHandler, "description", description);
1053        }
1054        XMLUtils.createElement(contentHandler, "url", _projectManager.getProjectUrl(project, StringUtils.EMPTY));
1055        
1056        saxCategory(contentHandler, project);
1057        
1058        for (String keyword : project.getKeywords())
1059        {
1060            XMLUtils.createElement(contentHandler, "keyword", keyword);
1061        }
1062        
1063        UserIdentity[] managers = project.getManagers();
1064        if (managers.length > 0)
1065        {
1066            for (UserIdentity userIdentity : managers)
1067            {
1068                _userHelper.saxUserIdentity(userIdentity, contentHandler, "manager");
1069            }
1070        }
1071        
1072        Site site = project.getSite();
1073        if (site != null)
1074        {
1075            attrs.clear();
1076            attrs.addCDATAAttribute("name", site.getName());
1077            attrs.addCDATAAttribute("language", site.getSitemaps().iterator().next().getName());
1078            
1079            XMLUtils.createElement(contentHandler, "site", attrs);
1080        }
1081        
1082        XMLUtils.endElement(contentHandler, "project");
1083    }
1084    
1085    /**
1086     * SAX the project's category
1087     * @param contentHandler the content handler to sax into
1088     * @param project the project
1089     * @throws SAXException if an error occurred while saxing
1090     */
1091    public void saxCategory(ContentHandler contentHandler, Project project) throws SAXException
1092    {
1093        saxCategory(contentHandler, project, "category");
1094    }
1095    
1096    /**
1097     * SAX the project's category
1098     * @param contentHandler the content handler to sax into
1099     * @param project the project
1100     * @param tagName the tag name for category
1101     * @throws SAXException if an error occurred while saxing
1102     */
1103    public void saxCategory(ContentHandler contentHandler, Project project, String tagName) throws SAXException
1104    {
1105        Set<String> categories = project.getCategories();
1106        if (!categories.isEmpty())
1107        {
1108            String c = categories.iterator().next();
1109            Category category = _categoryProviderEP.getTag(c, new HashMap<>());
1110            
1111            if (category != null)
1112            {
1113                AttributesImpl attrs = new AttributesImpl();
1114                attrs.addCDATAAttribute("id", category.getId());
1115                attrs.addCDATAAttribute("name", category.getName());
1116                attrs.addCDATAAttribute("color", _categoryHelper.getCategoryColor(category).get("main"));
1117                
1118                XMLUtils.startElement(contentHandler, tagName, attrs);
1119                category.getTitle().toSAX(contentHandler);
1120                XMLUtils.endElement(contentHandler, tagName);
1121            }
1122        }
1123    }
1124}