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.calendars;
017
018import java.text.DateFormat;
019import java.text.SimpleDateFormat;
020import java.time.ZonedDateTime;
021import java.time.temporal.ChronoUnit;
022import java.util.ArrayList;
023import java.util.Collections;
024import java.util.Comparator;
025import java.util.Date;
026import java.util.HashMap;
027import java.util.List;
028import java.util.Map;
029import java.util.Objects;
030import java.util.Optional;
031import java.util.Set;
032import java.util.stream.Stream;
033
034import org.apache.avalon.framework.configuration.Configurable;
035import org.apache.avalon.framework.configuration.Configuration;
036import org.apache.avalon.framework.configuration.ConfigurationException;
037import org.apache.avalon.framework.service.ServiceException;
038import org.apache.avalon.framework.service.ServiceManager;
039import org.apache.cocoon.components.ContextHelper;
040import org.apache.cocoon.environment.Request;
041import org.apache.commons.collections.ListUtils;
042
043import org.ametys.core.util.DateUtils;
044import org.ametys.plugins.explorer.resources.ModifiableResourceCollection;
045import org.ametys.plugins.explorer.resources.jcr.JCRResourcesCollectionFactory;
046import org.ametys.plugins.repository.AmetysObjectIterable;
047import org.ametys.plugins.repository.AmetysObjectIterator;
048import org.ametys.plugins.repository.TraversableAmetysObject;
049import org.ametys.plugins.repository.UnknownAmetysObjectException;
050import org.ametys.plugins.repository.data.holder.ModifiableModelAwareDataHolder;
051import org.ametys.plugins.repository.query.QueryHelper;
052import org.ametys.plugins.repository.query.SortCriteria;
053import org.ametys.plugins.repository.query.expression.AndExpression;
054import org.ametys.plugins.repository.query.expression.DateExpression;
055import org.ametys.plugins.repository.query.expression.Expression;
056import org.ametys.plugins.repository.query.expression.Expression.Operator;
057import org.ametys.plugins.repository.query.expression.OrExpression;
058import org.ametys.plugins.repository.query.expression.StringExpression;
059import org.ametys.plugins.workspaces.AbstractWorkspaceModule;
060import org.ametys.plugins.workspaces.calendars.Calendar.CalendarVisibility;
061import org.ametys.plugins.workspaces.calendars.events.CalendarEvent;
062import org.ametys.plugins.workspaces.calendars.events.CalendarEventJSONHelper;
063import org.ametys.plugins.workspaces.calendars.events.CalendarEventOccurrence;
064import org.ametys.plugins.workspaces.calendars.jcr.JCRCalendarEvent;
065import org.ametys.plugins.workspaces.calendars.jcr.JCRCalendarEventFactory;
066import org.ametys.plugins.workspaces.project.objects.Project;
067import org.ametys.plugins.workspaces.util.StatisticColumn;
068import org.ametys.plugins.workspaces.util.StatisticsColumnType;
069import org.ametys.runtime.i18n.I18nizableText;
070import org.ametys.web.repository.page.ModifiablePage;
071import org.ametys.web.repository.page.ModifiableZone;
072import org.ametys.web.repository.page.ModifiableZoneItem;
073import org.ametys.web.repository.page.Page;
074import org.ametys.web.repository.page.ZoneItem.ZoneType;
075import org.ametys.web.repository.site.Site;
076import org.ametys.web.repository.sitemap.Sitemap;
077
078import com.google.common.collect.ImmutableSet;
079
080/**
081 * Helper component for managing calendars
082 */
083public class CalendarWorkspaceModule extends AbstractWorkspaceModule implements Configurable
084{
085    /** The id of calendar module */
086    public static final String CALENDAR_MODULE_ID = CalendarWorkspaceModule.class.getName();
087    
088    /** Workspaces calendars node name */
089    private static final String __WORKSPACES_CALENDARS_NODE_NAME = "calendars";
090
091    /** Workspaces root tasks node name */
092    private static final String __WORKSPACES_CALENDARS_ROOT_NODE_NAME = "calendars-root";
093    
094    /** Workspaces root tasks node name */
095    private static final String __WORKSPACES_CALENDAR_RESOURCES_ROOT_NODE_NAME = "calendar-resources-root";
096    
097    /** Workspaces root tasks node name */
098    private static final String __WORKSPACES_RESOURCE_CALENDAR_ROOT_NODE_NAME = "resource-calendar-root";
099    
100    private static final String __CALENDAR_CACHE_REQUEST_ATTR = CalendarWorkspaceModule.class.getName() + "$calendarCache";
101
102    private static final String __EVENT_NUMBER_HEADER_ID = __WORKSPACES_CALENDARS_NODE_NAME + "$event_number";
103    
104    private CalendarDAO _calendarDAO;
105    private CalendarEventJSONHelper _calendarEventJSONHelper;
106
107    private I18nizableText _defaultCalendarTemplateDesc;
108    private String _defaultCalendarColor;
109    private String _defaultCalendarVisibility;
110    private String _defaultCalendarWorkflowName;
111    private I18nizableText _defaultCalendarTitle;
112    private I18nizableText _defaultCalendarDescription;
113    
114    private I18nizableText _resourceCalendarTemplateDesc;
115    private String _resourceCalendarColor;
116    private String _resourceCalendarVisibility;
117    private String _resourceCalendarWorkflowName;
118    private I18nizableText _resourceCalendarTitle;
119    private I18nizableText _resourceCalendarDescription;
120
121    @Override
122    public void service(ServiceManager manager) throws ServiceException
123    {
124        super.service(manager);
125        _calendarDAO = (CalendarDAO) manager.lookup(CalendarDAO.ROLE);
126        _calendarEventJSONHelper = (CalendarEventJSONHelper) manager.lookup(CalendarEventJSONHelper.ROLE);
127    }
128    
129    public void configure(Configuration configuration) throws ConfigurationException
130    {
131        _defaultCalendarTemplateDesc = I18nizableText.parseI18nizableText(configuration.getChild("template-desc"), "plugin." + _pluginName, "");
132        _defaultCalendarColor = configuration.getChild("color").getValue("col1");
133        _defaultCalendarVisibility = configuration.getChild("visibility").getValue(CalendarVisibility.PRIVATE.name());
134        _defaultCalendarWorkflowName = configuration.getChild("workflow").getValue("calendar-default");
135        _defaultCalendarTitle = I18nizableText.parseI18nizableText(configuration.getChild("title"), "plugin." + _pluginName);
136        _defaultCalendarDescription = I18nizableText.parseI18nizableText(configuration.getChild("description"), "plugin." + _pluginName, "");
137        
138        _resourceCalendarTemplateDesc = I18nizableText.parseI18nizableText(configuration.getChild("resource-template-desc"), "plugin." + _pluginName, "");
139        _resourceCalendarColor = configuration.getChild("resource-color").getValue("resourcecol0");
140        _resourceCalendarVisibility = configuration.getChild("resource-visibility").getValue(CalendarVisibility.PRIVATE.name());
141        _resourceCalendarWorkflowName = configuration.getChild("resource-workflow").getValue("calendar-default");
142        _resourceCalendarTitle = I18nizableText.parseI18nizableText(configuration.getChild("resource-title"), "plugin." + _pluginName);
143        _resourceCalendarDescription = I18nizableText.parseI18nizableText(configuration.getChild("resource-description"), "plugin." + _pluginName, "");
144
145    }
146    
147    @Override
148    public String getId()
149    {
150        return CALENDAR_MODULE_ID;
151    }
152    
153    public int getOrder()
154    {
155        return ORDER_CALENDAR;
156    }
157    
158    public String getModuleName()
159    {
160        return __WORKSPACES_CALENDARS_NODE_NAME;
161    }
162    
163    @Override
164    protected String getModulePageName()
165    {
166        return "calendars";
167    }
168    
169    public I18nizableText getModuleTitle()
170    {
171        return new I18nizableText("plugin." + _pluginName, "PLUGINS_WORKSPACES_PROJECT_SERVICE_MODULE_CALENDAR_LABEL");
172    }
173    public I18nizableText getModuleDescription()
174    {
175        return new I18nizableText("plugin." + _pluginName, "PLUGINS_WORKSPACES_PROJECT_SERVICE_MODULE_CALENDAR_DESCRIPTION");
176    }
177    @Override
178    protected I18nizableText getModulePageTitle()
179    {
180        return new I18nizableText("plugin." + _pluginName, "PLUGINS_WORKSPACES_PROJECT_WORKSPACE_PAGE_CALENDARS_TITLE");
181    }
182    
183    @Override
184    protected void initializeModulePage(ModifiablePage calendarPage)
185    {
186        ModifiableZone defaultZone = calendarPage.createZone("default");
187        
188        String serviceId = "org.ametys.plugins.workspaces.module.Calendar";
189        ModifiableZoneItem defaultZoneItem = defaultZone.addZoneItem();
190        defaultZoneItem.setType(ZoneType.SERVICE);
191        defaultZoneItem.setServiceId(serviceId);
192        
193        ModifiableModelAwareDataHolder serviceDataHolder = defaultZoneItem.getServiceParameters();
194        serviceDataHolder.setValue("xslt", _getDefaultXslt(serviceId));
195    }
196    
197    /**
198     * Get the calendars of a project
199     * @param project The project
200     * @return The list of calendar
201     */
202    public AmetysObjectIterable<Calendar> getCalendars(Project project)
203    {
204        ModifiableResourceCollection calendarRoot = getCalendarsRoot(project, true);
205        return calendarRoot != null ? calendarRoot.getChildren() : null;
206    }
207    
208    /**
209     * Get the URI of a thread in project'site
210     * @param project The project
211     * @param calendarId The id of calendar
212     * @param eventId The id of event
213     * @return The thread uri
214     */
215    public String getEventUri(Project project, String calendarId, String eventId)
216    {
217        String moduleUrl = getModuleUrl(project);
218        if (moduleUrl != null)
219        {
220            StringBuilder sb = new StringBuilder();
221            sb.append(moduleUrl);
222            
223            try
224            {
225                CalendarEvent event = _resolver.resolveById(eventId);
226                
227                if (event.getStartDate() != null)
228                {
229                    DateFormat df = new SimpleDateFormat("yyyy-MM-dd");
230                    sb.append("?date=").append(df.format(DateUtils.asDate(event.getStartDate())));
231                }
232            }
233            catch (UnknownAmetysObjectException e)
234            {
235                // Nothing
236            }
237            
238            sb.append("#").append(calendarId);
239            
240            return sb.toString();
241        }
242        
243        return null;
244    }
245    
246    /**
247     * Add additional information on project and parent calendar
248     * @param event The event
249     * @param eventData The event data to complete
250     */
251    @SuppressWarnings("unchecked")
252    protected void _addAdditionalEventData(CalendarEvent event, Map<String, Object> eventData)
253    {
254        Request request = ContextHelper.getRequest(_context);
255        
256        Calendar calendar = event.getParent();
257        Project project = _projectManager.getParentProject(calendar);
258        
259        // Try to get calendar from cache if request is not null
260        if (request.getAttribute(__CALENDAR_CACHE_REQUEST_ATTR) == null)
261        {
262            request.setAttribute(__CALENDAR_CACHE_REQUEST_ATTR, new HashMap<>());
263        }
264        
265        Map<String, Object> calendarCache = (Map<String, Object>) request.getAttribute(__CALENDAR_CACHE_REQUEST_ATTR);
266        
267        if (!calendarCache.containsKey(calendar.getId()))
268        {
269            Map<String, Object> calendarInfo = new HashMap<>();
270            
271            calendarInfo.put("calendarName", calendar.getName());
272            calendarInfo.put("calendarIsPublic", CalendarVisibility.PUBLIC.equals(calendar.getVisibility()));
273            calendarInfo.put("calendarHasViewRight", canView(calendar));
274            
275            calendarInfo.put("projectId", project.getId());
276            calendarInfo.put("projectTitle", project.getTitle());
277            
278            Set<Page> calendarModulePages = _projectManager.getModulePages(project, this);
279            if (!calendarModulePages.isEmpty())
280            {
281                Page calendarModulePage = calendarModulePages.iterator().next();
282                calendarInfo.put("calendarModulePageId", calendarModulePage.getId());
283            }
284            
285            calendarCache.put(calendar.getId(), calendarInfo);
286        }
287        
288        eventData.putAll((Map<String, Object>) calendarCache.get(calendar.getId()));
289       
290        eventData.put("eventUrl", getEventUri(project, calendar.getId(), event.getId()));
291    }
292    
293    /**
294     * Get the upcoming events of the calendars on which the user has a right
295     * @param months the amount of months from today in which look for upcoming events
296     * @param maxResults the maximum results to display
297     * @param calendarIds the ids of the calendars to gather events from, null for all calendars
298     * @param tagIds the ids of the valid tags for the events, null for any tag 
299     * @return the upcoming events
300     */
301    public List<Map<String, Object>> getUpcomingEvents(int months, int maxResults, List<String> calendarIds, List<String> tagIds)
302    {
303        List<Map<String, Object>> basicEventList = new ArrayList<> ();
304        List<Map<String, Object>> recurrentEventList = new ArrayList<> ();
305        
306        java.util.Calendar cal = java.util.Calendar.getInstance();
307        cal.set(java.util.Calendar.HOUR_OF_DAY, 0);
308        cal.set(java.util.Calendar.MINUTE, 0);
309        cal.set(java.util.Calendar.SECOND, 0);
310        cal.set(java.util.Calendar.MILLISECOND, 0);
311        ZonedDateTime startDate = ZonedDateTime.now().truncatedTo(ChronoUnit.DAYS);
312
313        ZonedDateTime endDate = startDate.plusMonths(months);
314        
315        Expression nonRecurrentExpr = new StringExpression(JCRCalendarEvent.ATTRIBUTE_RECURRENCE_TYPE, Operator.EQ, "NEVER");
316        Expression startDateExpr = new DateExpression(JCRCalendarEvent.ATTRIBUTE_START_DATE, Operator.GE, DateUtils.asDate(startDate));
317        Expression endDateExpr = new DateExpression(JCRCalendarEvent.ATTRIBUTE_START_DATE, Operator.LE, DateUtils.asDate(endDate));
318        
319        Expression keywordsExpr = null;
320        
321        if (tagIds != null && !tagIds.isEmpty())
322        {
323            List<Expression> orExpr = new ArrayList<>();
324            for (String tagId : tagIds)
325            {
326                orExpr.add(new StringExpression(JCRCalendarEvent.ATTRIBUTE_KEYWORDS, Operator.EQ, tagId));
327            }
328            keywordsExpr = new OrExpression(orExpr.toArray(new Expression[orExpr.size()]));
329        }
330        
331        // Get the non recurrent events sorted by ascending date and within the configured range
332        Expression eventExpr = new AndExpression(nonRecurrentExpr, startDateExpr, endDateExpr, keywordsExpr);
333        SortCriteria sortCriteria = new SortCriteria();
334        sortCriteria.addCriterion(JCRCalendarEvent.ATTRIBUTE_START_DATE, true, false);
335        
336        String basicEventQuery = QueryHelper.getXPathQuery(null, JCRCalendarEventFactory.CALENDAR_EVENT_NODETYPE, eventExpr, sortCriteria);
337        AmetysObjectIterable<CalendarEvent> basicEvents = _resolver.query(basicEventQuery);
338        AmetysObjectIterator<CalendarEvent> basicEventIt = basicEvents.iterator();
339        
340        int processed = 0;
341        while (basicEventIt.hasNext() && processed < maxResults)
342        {
343            CalendarEvent event = basicEventIt.next();
344            Calendar holdingCalendar = (Calendar) event.getParent();
345            
346            if (_filterEvent(calendarIds, event) && _hasAccess(holdingCalendar))
347            {
348                // The event is in the list of selected calendars and has the appropriate tags (can be none if tagIds == null)
349                
350                // FIXME should use something like an EventInfo object with some data + calendar, project name
351                // And use a function to process the transformation...
352                // Function<EventInfo, Map<String, Object>> fn. eventData.putAll(fn.apply(info));
353                
354                // standard set of event data
355                Map<String, Object> eventData = _calendarEventJSONHelper.eventAsJson(event, false, false);
356                basicEventList.add(eventData);
357                processed++;
358                
359                // add additional info
360                _addAdditionalEventData(event, eventData);
361            }
362        }
363        
364        Expression recurrentExpr = new StringExpression(JCRCalendarEvent.ATTRIBUTE_RECURRENCE_TYPE, Operator.NE, "NEVER");
365        eventExpr = new AndExpression(recurrentExpr, keywordsExpr);
366        
367        String recurrentEventQuery = QueryHelper.getXPathQuery(null, JCRCalendarEventFactory.CALENDAR_EVENT_NODETYPE, eventExpr, sortCriteria);
368        AmetysObjectIterable<CalendarEvent> recurrentEvents = _resolver.query(recurrentEventQuery);
369        AmetysObjectIterator<CalendarEvent> recurrentEventIt = recurrentEvents.iterator();
370        
371        // FIXME cannot count processed here...
372        processed = 0;
373        while (recurrentEventIt.hasNext() /*&& processed < maxResultsAsInt*/)
374        {
375            CalendarEvent event = recurrentEventIt.next();
376            Optional<CalendarEventOccurrence> nextOccurrence = event.getNextOccurrence(new CalendarEventOccurrence(event, startDate));
377            
378            // The recurrent event first occurrence is within the range
379            if (nextOccurrence.isPresent() && nextOccurrence.get().before(endDate))
380            {
381                // FIXME calculate occurrences only if keep event...
382                List<CalendarEventOccurrence> occurrences = event.getOccurrences(nextOccurrence.get().getStartDate(), endDate);
383                Calendar holdingCalendar = (Calendar) event.getParent();
384                
385                if (_filterEvent(calendarIds, event) && _hasAccess(holdingCalendar))
386                {
387                    // The event is in the list of selected calendars and has the appropriate tags (can be none if tagIds == null)
388                    
389                    // Add all its occurrences that are within the range
390                    for (CalendarEventOccurrence occurrence : occurrences)
391                    {
392                        Map<String, Object> eventData = _calendarEventJSONHelper.eventAsJsonWithOccurrence(event, occurrence.getStartDate(), false);
393                        recurrentEventList.add(eventData);
394                        processed++;
395                        
396                        _addAdditionalEventData(event, eventData);
397                        
398                    }
399                }
400            }
401        }
402        
403        // Re-sort chronologically the events' union
404        List<Map<String, Object>> allEvents = ListUtils.union(basicEventList, recurrentEventList);
405        Collections.sort(allEvents, new StartDateComparator());
406
407        // Return the first maxResults events
408        return allEvents.size() <= maxResults ? allEvents : allEvents.subList(0, maxResults);
409    }
410    
411    /**
412     * Determine whether the given event has to be kept or not depending on the given calendars
413     * @param calendarIds the ids of the calendars
414     * @param event the event
415     * @return true if the event can be kept, false otherwise
416     */
417    private boolean _filterEvent(List<String> calendarIds, CalendarEvent event)
418    {
419        Calendar holdingCalendar = (Calendar) event.getParent();
420        // FIXME calendarIds.get(0) == null means "All calendars" selected in the select calendar widget ??
421        // need cleaner code
422        return calendarIds == null || calendarIds.get(0) == null || calendarIds.contains(holdingCalendar.getId());
423    }
424    
425    private boolean _hasAccess(Calendar calendar)
426    {
427        return CalendarVisibility.PUBLIC.equals(calendar.getVisibility()) || canView(calendar);
428    }
429        
430    /**
431     * Indicates if the current user can view the calendar
432     * @param calendar The calendar to test
433     * @return true if the calendar can be viewed 
434     */
435    public boolean canView(Calendar calendar)
436    {
437        return _rightManager.currentUserHasReadAccess(calendar);
438    }
439    
440    /**
441     * Indicates if the current user can view the event
442     * @param event The event to test
443     * @return true if the event can be viewed 
444     */
445    public boolean canView(CalendarEvent event)
446    {
447        return _rightManager.currentUserHasReadAccess(event.getParent());
448    }
449    
450    /**
451     * Compares events on their starting date
452     */
453    protected static class StartDateComparator implements Comparator<Map<String, Object>>
454    {
455        @Override
456        public int compare(Map<String, Object> calendarEventInfo1, Map<String, Object> calendarEventInfo2)
457        {
458            String startDate1asString = (String) calendarEventInfo1.get("startDate");
459            String startDate2asString = (String) calendarEventInfo2.get("startDate");
460            
461            Date startDate1 = DateUtils.parse(startDate1asString);
462            Date startDate2 = DateUtils.parse(startDate2asString);
463            
464            // The start date is before if 
465            return startDate1.compareTo(startDate2);
466        }
467    }
468    
469    @Override
470    public Set<String> getAllowedEventTypes()
471    {
472        return ImmutableSet.of("calendar.event.created", "calendar.event.updated");
473    }
474
475    @Override
476    protected void _internalActivateModule(Project project, Map<String, Object> additionalValues)
477    {
478        createResourceCalendar(project, additionalValues);
479        _createDefaultCalendar(project, additionalValues);
480    }
481
482    /**
483     * Create a calendar to store resources if needed
484     * @param project the project
485     * @param additionalValues A list of optional additional values. Accepted values are : description, mailingList, inscriptionStatus, defaultProfile, tags, categoryTags, keywords and language
486     * @return The resource calendar
487     */
488    public Calendar createResourceCalendar(Project project, Map<String, Object> additionalValues)
489    {
490        ModifiableResourceCollection resourceCalendarRoot = getResourceCalendarRoot(project, true);
491        
492        String lang;
493        if (additionalValues.containsKey("language"))
494        {
495            lang = (String) additionalValues.get("language");
496        }
497        else
498        {
499            lang = getLang(project);
500        }
501
502        Calendar resourceCalendar = resourceCalendarRoot.getChildren()
503                .stream()
504                .filter(Calendar.class::isInstance)
505                .map(Calendar.class::cast)
506                .findFirst()
507                .orElse(null);
508        
509        if (resourceCalendar == null)
510        {
511            Boolean renameIfExists = false;
512            Boolean checkRights = false;
513            String description = _i18nUtils.translate(_resourceCalendarDescription, lang);
514            String inputName = _i18nUtils.translate(_resourceCalendarTitle, lang);
515            String templateDesc = _i18nUtils.translate(_resourceCalendarTemplateDesc, lang);
516            try
517            {
518                Map result = _calendarDAO.addCalendar(resourceCalendarRoot, inputName, description, templateDesc, _resourceCalendarColor, _resourceCalendarVisibility, _resourceCalendarWorkflowName, renameIfExists, checkRights, false);
519
520                resourceCalendar = _resolver.resolveById((String) result.get("id"));
521            }
522            catch (Exception e)
523            {
524                getLogger().error("Error while trying to create the first calendar in a newly created project", e);
525            }
526        }
527        return resourceCalendar;
528    }
529    
530    private void _createDefaultCalendar(Project project, Map<String, Object> additionalValues)
531    {
532        ModifiableResourceCollection moduleRoot = getCalendarsRoot(project, true);
533        
534        if (moduleRoot != null && !_hasOtherCalendar(project))
535        {
536            Boolean renameIfExists = false;
537            Boolean checkRights = false;
538
539            String lang;
540            if (additionalValues.containsKey("language"))
541            {
542                lang = (String) additionalValues.get("language");
543            }
544            else
545            {
546                lang = getLang(project);
547            }
548            
549            String description = _i18nUtils.translate(_defaultCalendarDescription, lang);
550            String inputName = _i18nUtils.translate(_defaultCalendarTitle, lang);
551            String templateDesc = _i18nUtils.translate(_defaultCalendarTemplateDesc, lang);
552            try
553            {
554                _calendarDAO.addCalendar(moduleRoot, inputName, description, templateDesc, _defaultCalendarColor, _defaultCalendarVisibility, _defaultCalendarWorkflowName, renameIfExists, checkRights, false);
555            }
556            catch (Exception e)
557            {
558                getLogger().error("Error while trying to create the first calendar in a newly created project", e);
559            }
560            
561        }
562    }
563    
564    private boolean _hasOtherCalendar(Project project)
565    {
566        AmetysObjectIterable<Calendar> calendars = getCalendars(project);
567        
568        return calendars != null && calendars.getSize() > 0;
569    }
570    
571    /**
572     * Get the lang for this project, or fr if none found
573     * @param project the project to check
574     * @return a lang in this project
575     */
576    protected String getLang(Project project)
577    {
578        /*
579         * get the site, then the sitemaps (transformed back to a stream of sitemap)
580         * then get the name
581         * return the 1st one, or "en" by default if none is available, or if the site could not be found
582         */
583        
584        Site site = project.getSite();
585        
586        if (site != null)
587        {
588            return site.getSitemaps()
589                       .stream()
590                       .filter(Objects::nonNull)
591                       .map(Sitemap::getName)
592                       .findFirst()
593                       .orElse("en");
594        }
595        else
596        {
597            return "en";
598        }
599    }
600
601    /**
602     * Get the calendars of a project
603     * @param project The project
604     * @return The list of calendar
605     */
606    public Calendar getResourceCalendar(Project project)
607    {
608        ModifiableResourceCollection resourceCalendarRoot = getResourceCalendarRoot(project, true);
609        return resourceCalendarRoot.getChildren()
610                .stream()
611                .filter(Calendar.class::isInstance)
612                .map(Calendar.class::cast)
613                .findFirst()
614                .orElse(createResourceCalendar(project, new HashMap<>()));
615    }
616
617    /**
618     * Get the root for tasks
619     * @param project The project
620     * @param create true to create root if not exists
621     * @return The root for tasks
622     */
623    public ModifiableResourceCollection getCalendarResourcesRoot(Project project, boolean create)
624    {
625        ModifiableResourceCollection moduleRoot = getModuleRoot(project, create);
626        return _getAmetysObject(moduleRoot, __WORKSPACES_CALENDAR_RESOURCES_ROOT_NODE_NAME, JCRResourcesCollectionFactory.RESOURCESCOLLECTION_NODETYPE, create);
627    }
628
629    /**
630     * Get the root for tasks
631     * @param project The project
632     * @param create true to create root if not exists
633     * @return The root for tasks
634     */
635    public ModifiableResourceCollection getCalendarsRoot(Project project, boolean create)
636    {
637        ModifiableResourceCollection moduleRoot = getModuleRoot(project, create);
638        return _getAmetysObject(moduleRoot, __WORKSPACES_CALENDARS_ROOT_NODE_NAME, JCRResourcesCollectionFactory.RESOURCESCOLLECTION_NODETYPE, create);
639    }
640
641    /**
642     * Get the root for tasks
643     * @param project The project
644     * @param create true to create root if not exists
645     * @return The root for tasks
646     */
647    public ModifiableResourceCollection getResourceCalendarRoot(Project project, boolean create)
648    {
649        ModifiableResourceCollection moduleRoot = getModuleRoot(project, create);
650        return _getAmetysObject(moduleRoot, __WORKSPACES_RESOURCE_CALENDAR_ROOT_NODE_NAME, JCRResourcesCollectionFactory.RESOURCESCOLLECTION_NODETYPE, create);
651    }
652    
653    @Override
654    public Map<String, Object> _getInternalStatistics(Project project, boolean isActive)
655    { 
656        if (isActive)
657        {
658            AmetysObjectIterable<Calendar> calendars = getCalendars(project);
659            Calendar ressourceCalendar = getResourceCalendar(project);
660            
661            // concatenate both type of calendars
662            long eventNumber = Stream.concat(calendars.stream(), Stream.of(ressourceCalendar))
663                    // get all events for each calendar (events are children of calendar node)
664                    .map(TraversableAmetysObject::getChildren)
665                    // use flatMap to have a stream with all events from all calendars
666                    .flatMap(AmetysObjectIterable::stream)
667                    // count the number of events
668                    .count();
669            
670            return Map.of(__EVENT_NUMBER_HEADER_ID, eventNumber);
671        }
672        else
673        {
674            return Map.of(__EVENT_NUMBER_HEADER_ID, __SIZE_INACTIVE);
675        }
676    }
677
678    @Override
679    public List<StatisticColumn> _getInternalStatisticModel()
680    {
681        return List.of(new StatisticColumn(__EVENT_NUMBER_HEADER_ID, new I18nizableText("plugin." + _pluginName, "PLUGINS_WORKSPACES_PROJECT_STATISTICS_TOOL_COLUMN_EVENT_NUMBER"))
682                .withRenderer("Ametys.plugins.workspaces.project.tool.ProjectsGridHelper.renderElements")
683                .withType(StatisticsColumnType.LONG)
684                .withGroup(GROUP_HEADER_ELEMENTS_ID));
685    }
686
687    @Override
688    public Set<String> getAllEventTypes()
689    {
690        return Set.of(ObservationConstants.EVENT_CALENDAR_CREATED, 
691                      ObservationConstants.EVENT_CALENDAR_DELETED, 
692                      ObservationConstants.EVENT_CALENDAR_EVENT_CREATED,
693                      ObservationConstants.EVENT_CALENDAR_EVENT_DELETED,
694                      ObservationConstants.EVENT_CALENDAR_EVENT_UPDATED,
695                      ObservationConstants.EVENT_CALENDAR_MOVED,
696                      ObservationConstants.EVENT_CALENDAR_RESOURCE_CREATED,
697                      ObservationConstants.EVENT_CALENDAR_RESOURCE_DELETED,
698                      ObservationConstants.EVENT_CALENDAR_RESOURCE_UPDATED,
699                      ObservationConstants.EVENT_CALENDAR_UPDATED);
700    }
701}
702