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.tasks;
017
018import java.time.LocalDate;
019import java.time.ZonedDateTime;
020import java.time.format.DateTimeFormatter;
021import java.util.ArrayList;
022import java.util.HashMap;
023import java.util.List;
024import java.util.Map;
025import java.util.Optional;
026import java.util.stream.Collectors;
027
028import org.apache.avalon.framework.service.ServiceException;
029import org.apache.avalon.framework.service.ServiceManager;
030import org.apache.cocoon.servlet.multipart.Part;
031import org.apache.commons.lang3.StringUtils;
032
033import org.ametys.cms.fo.ForceDefaultRepositoryWorkspaceCallableDecorator;
034import org.ametys.cms.repository.comment.Comment;
035import org.ametys.cms.repository.mentions.MentionUtils;
036import org.ametys.core.observation.Event;
037import org.ametys.core.ui.Callable;
038import org.ametys.core.user.User;
039import org.ametys.core.user.UserIdentity;
040import org.ametys.plugins.repository.AmetysRepositoryException;
041import org.ametys.plugins.repository.ModifiableTraversableAmetysObject;
042import org.ametys.plugins.workspaces.ObservationConstants;
043import org.ametys.plugins.workspaces.members.ProjectMemberManager;
044import org.ametys.plugins.workspaces.project.objects.Project;
045import org.ametys.plugins.workspaces.tags.ProjectTagProviderExtensionPoint;
046import org.ametys.plugins.workspaces.tasks.Task.CheckItem;
047import org.ametys.plugins.workspaces.tasks.jcr.JCRTask;
048import org.ametys.plugins.workspaces.tasks.jcr.JCRTaskFactory;
049import org.ametys.plugins.workspaces.tasks.json.TaskJSONHelper;
050import org.ametys.runtime.authentication.AccessDeniedException;
051
052/**
053 * DAO for interacting with tasks of a project
054 */
055public class WorkspaceTaskDAO extends AbstractWorkspaceTaskDAO
056{
057    /** The Avalon role */
058    public static final String ROLE = WorkspaceTaskDAO.class.getName();
059    
060    /** The project member manager */
061    protected ProjectMemberManager _projectMemberManager;
062    
063    /** The tag provider extension point */
064    protected ProjectTagProviderExtensionPoint _tagProviderExtPt;
065        
066    /** The task JSON helper */
067    protected TaskJSONHelper _taskJSONHelper;
068    
069    /** The task list DAO */
070    protected WorkspaceTasksListDAO _workspaceTasksListDAO;
071    
072    /** The mentions helper */
073    protected MentionUtils _mentionUtils;
074    
075    @Override
076    public void service(ServiceManager manager) throws ServiceException
077    {
078        super.service(manager);
079        _projectMemberManager = (ProjectMemberManager) manager.lookup(ProjectMemberManager.ROLE);
080        _tagProviderExtPt = (ProjectTagProviderExtensionPoint) manager.lookup(ProjectTagProviderExtensionPoint.ROLE);
081        _taskJSONHelper = (TaskJSONHelper) manager.lookup(TaskJSONHelper.ROLE);
082        _workspaceTasksListDAO = (WorkspaceTasksListDAO) manager.lookup(WorkspaceTasksListDAO.ROLE);
083        _mentionUtils = (MentionUtils) manager.lookup(MentionUtils.ROLE);
084    }
085
086    /**
087     * Get the tasks from project
088     * @return the list of tasks
089     */
090    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID)
091    public List<Map<String, Object>> getTasks()
092    {
093        Project project = _workspaceHelper.getProjectFromRequest();
094        
095        if (!_projectRightHelper.hasReadAccessOnModule(project, TasksWorkspaceModule.TASK_MODULE_ID))
096        {
097            throw new AccessDeniedException("User '" + _currentUserProvider.getUser() + "' tried to get tasks without reader right");
098        }
099        
100        List<Map<String, Object>> tasksInfo = new ArrayList<>();
101        for (Task task : getProjectTasks(project))
102        {
103            tasksInfo.add(_taskJSONHelper.taskAsJSON(task, getSitemapLanguage(), getSiteName()));
104        }
105        
106        return tasksInfo;
107    }
108    
109    /**
110     * Add a new task to the tasks list
111     * @param tasksListId the tasks list id
112     * @param parameters The task parameters
113     * @param newFiles the files to add
114     * @param newFileNames the file names to add
115     * @return The task data
116     */
117    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID)
118    public Map<String, Object> addTask(String tasksListId, Map<String, Object> parameters, List<Part> newFiles, List<String> newFileNames)
119    {
120        Project project = _workspaceHelper.getProjectFromRequest();
121        
122        if (StringUtils.isBlank(tasksListId))
123        {
124            throw new IllegalArgumentException("Tasks list id is mandatory to create a new task");
125        }
126        
127        ModifiableTraversableAmetysObject tasksRoot = _getTasksRoot(project, true);
128        
129        _checkUserRights(tasksRoot, RIGHTS_HANDLE_TASK);
130
131        int index = 1;
132        String name = "task-1";
133        while (tasksRoot.hasChild(name))
134        {
135            index++;
136            name = "task-" + index;
137        }
138        
139        JCRTask task = (JCRTask) tasksRoot.createChild(name, JCRTaskFactory.TASK_NODETYPE);
140        task.setTasksListId(tasksListId);
141        task.setPosition(Long.valueOf(_workspaceTasksListDAO.getChildTask(tasksListId).size()));
142        
143        ZonedDateTime now = ZonedDateTime.now();
144        task.setCreationDate(now);
145        task.setLastModified(now);
146        task.setAuthor(_currentUserProvider.getUser());
147
148        Map<String, Object> attributesResults = _setTaskAttributes(task, parameters, newFiles, newFileNames, new ArrayList<>());
149        
150        tasksRoot.saveChanges();
151        
152        Map<String, Object> eventParams = new HashMap<>();
153        eventParams.put(ObservationConstants.ARGS_TASK, task);
154        eventParams.put(org.ametys.plugins.explorer.ObservationConstants.ARGS_ID, task.getId());
155        
156        _observationManager.notify(new Event(ObservationConstants.EVENT_TASK_CREATED, _currentUserProvider.getUser(), eventParams));
157
158        Map<String, Object> results = new HashMap<>();
159        results.put("task", _taskJSONHelper.taskAsJSON(task, getSitemapLanguage(), getSiteName()));
160        results.putAll(attributesResults);
161        return results;
162    }
163    
164    /**
165     * Edit a task
166     * @param taskId The id of the task to edit
167     * @param parameters The JS parameters
168     * @param newFiles the new file to add
169     * @param newFileNames the file names to add
170     * @param deleteFiles the file to delete
171     * @return The task data
172     */
173    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID)
174    public Map<String, Object> editTask(String taskId, Map<String, Object> parameters, List<Part> newFiles, List<String> newFileNames, List<String> deleteFiles)
175    {
176        JCRTask task = _resolver.resolveById(taskId);
177        
178        ModifiableTraversableAmetysObject tasksRoot = task.getParent();
179        
180        _checkUserRights(tasksRoot, RIGHTS_HANDLE_TASK);
181        
182        Map<String, Object> attributesResults = _setTaskAttributes(task, parameters, newFiles, newFileNames, deleteFiles);
183        task.setLastModified(ZonedDateTime.now());
184        task.saveChanges();
185            
186        Map<String, Object> eventParams = new HashMap<>();
187        eventParams.put(ObservationConstants.ARGS_TASK, task);
188        eventParams.put(org.ametys.plugins.explorer.ObservationConstants.ARGS_ID, taskId);
189        
190        _observationManager.notify(new Event(ObservationConstants.EVENT_TASK_UPDATED, _currentUserProvider.getUser(), eventParams));
191        
192        // Closed status has changed
193        if (attributesResults.containsKey("isClosed"))
194        {
195            _observationManager.notify(new Event(ObservationConstants.EVENT_TASK_CLOSED_STATUS_CHANGED, _currentUserProvider.getUser(), eventParams));
196        }
197
198        // Assigments have changed
199        if (attributesResults.containsKey("changedAssignments"))
200        {
201            _observationManager.notify(new Event(ObservationConstants.EVENT_TASK_ASSIGNED, _currentUserProvider.getUser(), eventParams));
202        }
203         
204        Map<String, Object> results = new HashMap<>();
205        results.put("task", _taskJSONHelper.taskAsJSON(task, getSitemapLanguage(), getSiteName()));
206        results.putAll(attributesResults);
207        return results;
208    }
209    
210    /**
211     * Move task to new position
212     * @param tasksListId the tasks list id
213     * @param taskId the task id to move
214     * @param newPosition the new position
215     * @return The task data
216     */
217    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID)
218    public Map<String, Object> moveTask(String tasksListId, String taskId, long newPosition)
219    {
220        Task task = _resolver.resolveById(taskId);
221        
222        ModifiableTraversableAmetysObject tasksRoot = task.getParent();
223        _checkUserRights(task.getParent(), RIGHTS_HANDLE_TASK);
224        
225        if (tasksListId != task.getTaskListId())
226        {
227            List<Task> childTasks = _workspaceTasksListDAO.getChildTask(task.getTaskListId());
228            long position = 0;
229            for (Task childTask : childTasks)
230            {
231                if (!childTask.getId().equals(taskId))
232                {
233                    childTask.setPosition(position);
234                    position++;
235                }
236            }
237        }
238        
239        task.setTasksListId(tasksListId);
240        List<Task> childTasks = _workspaceTasksListDAO.getChildTask(tasksListId);
241        int size = childTasks.size();
242        if (newPosition > size)
243        {
244            throw new IllegalArgumentException("New position (" + newPosition + ") can't be greater than tasks child size (" + size + ")");
245        }
246        
247        long position = 0;
248        task.setPosition(newPosition);
249        for (Task childTask : childTasks)
250        {
251            if (position == newPosition)
252            {
253                position++;
254            }
255            
256            if (childTask.getId().equals(taskId))
257            {
258                childTask.setPosition(newPosition);
259            }
260            else
261            {
262                childTask.setPosition(position);
263                position++;
264            }
265        }
266        
267        tasksRoot.saveChanges();
268        
269        return _taskJSONHelper.taskAsJSON(task, getSitemapLanguage(), getSiteName());
270    }
271    
272    /**
273     * Remove a task
274     * @param taskId the task id to remove
275     * @return The task data
276     */
277    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID)
278    public Map<String, Object> deleteTask(String taskId)
279    {
280        Task task = _resolver.resolveById(taskId);
281        
282        // Check user right
283        ModifiableTraversableAmetysObject tasksRoot = task.getParent();
284        _checkUserRights(tasksRoot, RIGHTS_DELETE_TASK);
285        
286        Map<String, Object> results = new HashMap<>();
287        
288        Map<String, Object> eventParams = new HashMap<>();
289        eventParams.put(ObservationConstants.ARGS_TASK, task);
290        eventParams.put(org.ametys.plugins.explorer.ObservationConstants.ARGS_ID, taskId);
291        _observationManager.notify(new Event(ObservationConstants.EVENT_TASK_DELETING, _currentUserProvider.getUser(), eventParams));
292        
293        String tasksListId = task.getTaskListId();
294        task.remove();
295        
296        // Reorder tasks position
297        long position = 0;
298        for (Task childTask : _workspaceTasksListDAO.getChildTask(tasksListId))
299        {
300            childTask.setPosition(position);
301            position++;
302        }
303        
304        tasksRoot.saveChanges();
305
306        eventParams = new HashMap<>();
307        eventParams.put(org.ametys.plugins.explorer.ObservationConstants.ARGS_ID, taskId);
308        _observationManager.notify(new Event(ObservationConstants.EVENT_TASK_DELETED, _currentUserProvider.getUser(), eventParams));
309
310        return results;
311    }
312    
313    /**
314     * Comment a task
315     * @param taskId the task id
316     * @param commentText the comment text
317     * @return The task data
318     */
319    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID)
320    public Map<String, Object> commentTask(String taskId, String commentText)
321    {
322        Task task = _resolver.resolveById(taskId);
323        
324        ModifiableTraversableAmetysObject tasksRoot = task.getParent();
325        
326        _checkUserRights(tasksRoot, RIGHTS_COMMENT_TASK);
327        
328        Comment comment = createComment(task, commentText, tasksRoot);
329
330        // Notify listeners
331        Map<String, Object> eventParams = new HashMap<>();
332        eventParams.put(org.ametys.plugins.explorer.ObservationConstants.ARGS_ID, task.getId());
333        eventParams.put(ObservationConstants.ARGS_TASK_COMMENT_ID, comment.getId());
334        eventParams.put(ObservationConstants.ARGS_TASK_COMMENT, _mentionUtils.transformTextToReadableText(commentText, null));
335        
336        eventParams.put(ObservationConstants.ARGS_TASK, task);
337        
338        UserIdentity currentUser = _currentUserProvider.getUser();
339        _observationManager.notify(new Event(ObservationConstants.EVENT_TASK_COMMENTED, currentUser, eventParams));
340
341        
342        return _taskJSONHelper.taskAsJSON(task, getSitemapLanguage(), getSiteName());
343    }
344    
345    /**
346     * Edit a task comment
347     * @param taskId the task id
348     * @param commentId the comment Id
349     * @param commentText the comment text
350     * @return The task data
351     */
352    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID)
353    public Map<String, Object> editCommentTask(String taskId, String commentId, String commentText)
354    {
355        Task task = _resolver.resolveById(taskId);
356        
357        ModifiableTraversableAmetysObject tasksRoot = task.getParent();
358
359        _checkUserRights(tasksRoot, RIGHTS_COMMENT_TASK);
360        
361        editComment(task, commentId, commentText, tasksRoot);
362        
363        return _taskJSONHelper.taskAsJSON(task, getSitemapLanguage(), getSiteName());
364    }
365    
366    /**
367     * Answer to a task's comment
368     * @param taskId the task id
369     * @param commentId the comment id
370     * @param commentText the comment text
371     * @return The task data
372     */
373    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID)
374    public Map<String, Object> answerCommentTask(String taskId, String commentId, String commentText)
375    {
376        Task task = _resolver.resolveById(taskId);
377        
378        ModifiableTraversableAmetysObject tasksRoot = task.getParent();
379        
380        _checkUserRights(tasksRoot, RIGHTS_COMMENT_TASK);
381        
382        Comment comment = answerComment(task, commentId, commentText, tasksRoot);
383
384        // Notify listeners
385        Map<String, Object> eventParams = new HashMap<>();
386        eventParams.put(org.ametys.plugins.explorer.ObservationConstants.ARGS_ID, task.getId());
387        eventParams.put(ObservationConstants.ARGS_TASK_COMMENT_ID, comment.getId());
388        UserIdentity currentUser = _currentUserProvider.getUser();
389        eventParams.put(ObservationConstants.ARGS_TASK_COMMENT, _mentionUtils.transformTextToReadableText(commentText, null));
390        
391        eventParams.put(ObservationConstants.ARGS_TASK, task);
392        _observationManager.notify(new Event(ObservationConstants.EVENT_TASK_COMMENTED, currentUser, eventParams));
393        
394        return _taskJSONHelper.taskAsJSON(task, getSitemapLanguage(), getSiteName());
395    }
396    
397    /**
398     * Delete a task's comment
399     * @param taskId the task id
400     * @param commentId the comment id
401     * @return The task data
402     */
403    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID)
404    public Map<String, Object> deleteCommentTask(String taskId, String commentId)
405    {
406        Task task = _resolver.resolveById(taskId);
407        
408        // Check user right
409        ModifiableTraversableAmetysObject tasksRoot = task.getParent();
410        
411        UserIdentity userIdentity = _currentUserProvider.getUser();
412        User user = _userManager.getUser(userIdentity);
413        
414        Comment comment = task.getComment(commentId);
415        String authorEmail = comment.getAuthorEmail();
416        if (!authorEmail.equals(user.getEmail()))
417        {
418            _checkUserRights(tasksRoot, RIGHTS_COMMENT_TASK);
419        }
420        
421        deleteComment(task, commentId, tasksRoot);
422              
423        return _taskJSONHelper.taskAsJSON(task, getSitemapLanguage(), getSiteName());
424    }
425    
426    /**
427     * Like or unlike a task's comment
428     * @param taskId the task id
429     * @param commentId the comment id
430     * @param liked true if the comment is liked, otherwise the comment is unliked
431     * @return The task data
432     */
433    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID)
434    public Map<String, Object> likeOrUnlikeCommentTask(String taskId, String commentId, Boolean liked)
435    {
436        Task task = _resolver.resolveById(taskId);
437        
438        ModifiableTraversableAmetysObject tasksRoot = task.getParent();
439        
440        _checkUserRights(tasksRoot, RIGHTS_COMMENT_TASK);
441        
442        likeOrUnlikeComment(task, commentId, liked, tasksRoot);
443        
444        return _taskJSONHelper.taskAsJSON(task, getSitemapLanguage(), getSiteName());
445    }
446    
447    /**
448     * Set task's attributes
449     * @param task The task to edit
450     * @param parameters The JS parameters
451     * @param newFiles the new file to add to the task
452     * @param newFileNames the new file names to add to the task
453     * @param deleteFiles the file to remove from the task
454     * @return the map of results
455     */
456    protected Map<String, Object> _setTaskAttributes(JCRTask task, Map<String, Object> parameters, List<Part> newFiles, List<String> newFileNames, List<String> deleteFiles)
457    {
458        Map<String, Object> results = new HashMap<>();
459        
460        String label = (String) parameters.get(JCRTask.ATTRIBUTE_LABEL);
461        task.setLabel(label);
462
463        String description = (String) parameters.get(JCRTask.ATTRIBUTE_DESCRIPTION);
464        task.setDescription(description);
465        
466        _setTaskDates(task, parameters);
467        _setTaskCloseInfo(task, parameters, results);
468        _setAttachments(task, newFiles, newFileNames, deleteFiles);
469        
470        @SuppressWarnings("unchecked")
471        List<Map<String, Object>> assignmentIds = (List<Map<String, Object>>) parameters.getOrDefault(JCRTask.ATTRIBUTE_ASSIGNMENTS, new ArrayList<>());
472        List<UserIdentity> users = assignmentIds.stream()
473            .map(m -> (String) m.get("id"))
474            .map(UserIdentity::stringToUserIdentity)
475            .collect(Collectors.toList());
476        
477        if (!task.getAssignments().equals(users))
478        {
479            task.setAssignments(users);
480            results.put("changedAssignments", true);
481        }
482        
483        @SuppressWarnings("unchecked")
484        List<Map<String, Object>> checkListItems = (List<Map<String, Object>>) parameters.getOrDefault(JCRTask.ATTRIBUTE_CHECKLIST, new ArrayList<>());
485        List<CheckItem> checkItems = checkListItems.stream()
486            .map(e -> new CheckItem((String) e.get(JCRTask.ATTRIBUTE_CHECKLIST_LABEL), (boolean) e.get(JCRTask.ATTRIBUTE_CHECKLIST_ISCHECKED)))
487            .collect(Collectors.toList());
488        task.setCheckListItem(checkItems);
489
490        @SuppressWarnings("unchecked")
491        List<Object> tags = (List<Object>) parameters.getOrDefault(JCRTask.ATTRIBUTE_TAGS, new ArrayList<>());
492
493        String projectName = getProjectName();
494        Project project = _projectManager.getProject(projectName);
495
496        ModifiableTraversableAmetysObject tasksRoot = _getTasksRoot(project, true);
497        
498        List<Map<String, Object>> createdTagsJson = _workspaceHelper.handleTags(task, tags, tasksRoot, projectName);
499        
500        results.put("newTags", createdTagsJson);
501        return results;
502    }
503    
504    private void _setTaskDates(JCRTask task, Map<String, Object> parameters)
505    {
506        String startDateAsStr = (String) parameters.get(JCRTask.ATTRIBUTE_STARTDATE);
507        LocalDate startDate = Optional.ofNullable(startDateAsStr)
508                .map(date -> LocalDate.parse(date, DateTimeFormatter.ISO_LOCAL_DATE))
509                .orElse(null);
510        task.setStartDate(startDate);
511        
512        String dueDateAsStr = (String) parameters.get(JCRTask.ATTRIBUTE_DUEDATE);
513        LocalDate dueDate = Optional.ofNullable(dueDateAsStr)
514                .map(date -> LocalDate.parse(date, DateTimeFormatter.ISO_LOCAL_DATE))
515                .orElse(null);
516        task.setDueDate(dueDate);
517    }
518    
519    private void _setTaskCloseInfo(JCRTask task, Map<String, Object> parameters, Map<String, Object> results)
520    {
521        @SuppressWarnings("unchecked")
522        Map<String, Object> closeInfo = (Map<String, Object>) parameters.get("closeInfo");
523        if (closeInfo != null && !task.isClosed())
524        {
525            task.close(true);
526            task.setCloseAuthor(_currentUserProvider.getUser());
527            task.setCloseDate(LocalDate.now());
528
529            results.put("isClosed", true);
530        }
531        else if (closeInfo == null && task.isClosed())
532        {
533            task.close(false);
534            task.setCloseAuthor(null);
535            task.setCloseDate(null);
536
537            results.put("isClosed", false);
538        }
539    }
540    
541    /**
542     * Get all tasks from given projets
543     * @param project the project
544     * @return All tasks as JSON
545     */
546    public List<Task> getProjectTasks(Project project)
547    {
548        TasksWorkspaceModule taskModule = _workspaceModuleEP.getModule(TasksWorkspaceModule.TASK_MODULE_ID);
549        ModifiableTraversableAmetysObject tasksRoot = taskModule.getTasksRoot(project, false);
550        if (tasksRoot == null)
551        {
552            return new ArrayList<>();
553        }
554        return tasksRoot.getChildren()
555            .stream()
556            .filter(Task.class::isInstance)
557            .map(Task.class::cast)
558            .collect(Collectors.toList());
559    }
560    
561    /**
562     * Get the total number of tasks of the project
563     * @param project The project
564     * @return The number of tasks, or null if the module is not activated
565     */
566    public Long getTasksCount(Project project)
567    {
568        return Long.valueOf(getProjectTasks(project).size());
569    }
570    
571    /**
572     * Get project members
573     * @return the project members
574     * @throws AmetysRepositoryException if an error occurred
575     */
576    @Callable (rights = Callable.CHECKED_BY_IMPLEMENTATION, decorators = ForceDefaultRepositoryWorkspaceCallableDecorator.DECORATOR_ID)
577    public Map<String, Object> getProjectMembers() throws AmetysRepositoryException
578    {
579        String projectName = getProjectName();
580        String lang = getSitemapLanguage();
581        
582        return _projectMemberManager.getProjectMembers(projectName, lang, true);
583    }
584}