001/*
002 *  Copyright 2022 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 */
016
017package org.ametys.plugins.workspaces.calendars.events;
018
019import java.time.LocalDate;
020import java.time.ZoneId;
021import java.time.ZoneOffset;
022import java.time.ZonedDateTime;
023import java.util.ArrayList;
024import java.util.HashMap;
025import java.util.List;
026import java.util.Map;
027import java.util.Optional;
028import java.util.Set;
029import java.util.stream.Collectors;
030import java.util.stream.Stream;
031
032import org.apache.avalon.framework.service.ServiceException;
033import org.apache.avalon.framework.service.ServiceManager;
034import org.apache.commons.lang3.StringUtils;
035
036import org.ametys.core.right.RightManager.RightResult;
037import org.ametys.core.user.User;
038import org.ametys.core.user.UserIdentity;
039import org.ametys.core.util.DateUtils;
040import org.ametys.plugins.explorer.ExplorerNode;
041import org.ametys.plugins.workspaces.calendars.AbstractCalendarDAO;
042import org.ametys.plugins.workspaces.calendars.Calendar;
043import org.ametys.plugins.workspaces.calendars.CalendarDAO;
044import org.ametys.plugins.workspaces.calendars.task.TaskCalendarEvent;
045import org.ametys.plugins.workspaces.project.modules.WorkspaceModuleExtensionPoint;
046import org.ametys.plugins.workspaces.project.objects.Project;
047import org.ametys.plugins.workspaces.tasks.Task;
048import org.ametys.plugins.workspaces.tasks.TasksWorkspaceModule;
049
050/**
051 * Helper to convert events to JSON
052 */
053public class CalendarEventJSONHelper extends AbstractCalendarDAO
054{
055    /** Avalon Role */
056    public static final String ROLE = CalendarEventJSONHelper.class.getName();
057
058    private static final String __HOUR_PATTERN_LOCAL = "yyyyMMdd'T'HHmmss";
059    private static final String __HOUR_PATTERN_UTC = "yyyyMMdd'T'HHmmss'Z'";
060    private static final String __FULL_DAY_PATTERN = "uuuuMMdd";
061    
062    /** Calendar DAO */
063    protected CalendarDAO _calendarDAO;
064
065    private TasksWorkspaceModule _taskModule;
066    
067    @Override
068    public void service(ServiceManager manager) throws ServiceException
069    {
070        super.service(manager);
071        _calendarDAO = (CalendarDAO) manager.lookup(CalendarDAO.ROLE);
072        WorkspaceModuleExtensionPoint moduleManagerEP = (WorkspaceModuleExtensionPoint) manager.lookup(WorkspaceModuleExtensionPoint.ROLE);
073        _taskModule = moduleManagerEP.getModule(TasksWorkspaceModule.TASK_MODULE_ID);
074    }
075        
076    /**
077     * Get event info for a specific occurrence
078     * @param event The event
079     * @param occurrenceDate the occurrence
080     * @return the event data in a map
081     */
082    public Map<String, Object> eventAsJsonWithOccurrence(CalendarEvent event, ZonedDateTime occurrenceDate)
083    {
084        Map<String, Object> eventData = eventAsJson(event, false);
085        
086        Optional<CalendarEventOccurrence> optionalEvent = event.getFirstOccurrence(occurrenceDate);
087        if (optionalEvent.isPresent())
088        {
089            eventData.putAll(optionalEvent.get().toJSON());
090        }
091        
092        return eventData;
093    }
094    
095    /**
096     * Get event info
097     * @param event The event
098     * @param startDate The start date.
099     * @param endDate The end date.
100     * @return the event data in a map
101     */
102    public Map<String, Object> eventAsJsonWithOccurrences(CalendarEvent event, ZonedDateTime startDate, ZonedDateTime endDate)
103    {
104        Map<String, Object> eventData = eventAsJson(event, false);
105
106        List<CalendarEventOccurrence> occurences = event.getOccurrences(startDate, endDate);
107        
108        List<Object> occurrencesDataList = new ArrayList<>();
109        eventData.put("occurrences", occurrencesDataList);
110        
111        for (CalendarEventOccurrence occurence : occurences)
112        {
113            occurrencesDataList.add(occurence.toJSON());
114        }
115        return eventData;
116    }
117    
118    /**
119     * Get event info
120     * @param event The event
121     * @param useICSFormat true to use ICS Format for dates
122     * @return the event data in a map
123     */
124    public Map<String, Object> eventAsJson(CalendarEvent event, boolean useICSFormat)
125    {
126        
127        Calendar calendar = event.getCalendar();
128        Map<String, Object> result = new HashMap<>();
129        
130        result.put("id", event.getId());
131        result.put("calendarId", calendar.getId());
132        result.put("color", calendar.getColor());
133        
134        result.put("title", event.getTitle());
135        result.put("description", event.getDescription());
136        
137        boolean fullDay = event.getFullDay();
138        
139        result.put("fullDay", fullDay);
140        result.put("recurrenceType", event.getRecurrenceType().toString());
141        
142        result.put("location", event.getLocation());
143        result.put("keywords", event.getTags());
144
145        
146        ZoneId eventZone = event.getZone();
147        ZonedDateTime startDate = event.getStartDate();
148        ZonedDateTime endDate = event.getEndDate();
149
150        ZonedDateTime untilDate = event.getRepeatUntil();
151        if (untilDate != null)
152        {
153            result.put("untilDate", formatDate(untilDate, useICSFormat, fullDay, eventZone, true));
154        }
155
156        // the list of excluded event date as ZonedDateTime at midnight in event timezone.
157        List<ZonedDateTime> excludedOccurrences = event.getExcludedOccurences();
158        if (excludedOccurrences != null && !excludedOccurrences.isEmpty())
159        {
160            
161            if (useICSFormat)
162            {
163                List<String> excludedStrings = new ArrayList<>();
164                for (ZonedDateTime excluded : excludedOccurrences)
165                {
166                    if (!fullDay)
167                    {
168                        // Some calendar systems need EXDATE in DATE-TIME and not only DATE, however we only store excluded dates as full days, even if the event is not a full day event.
169                        // The stored value is a ZonedDateTime at midnight in event timezone, that we retrieve as UTC converted ZonedDateTime
170
171                        // Get the date of the excluded occurrence in the event time zone, to avoid issues with daylight saving time change or other edge cases.
172                        LocalDate excludedDateInEventZone = excluded.withZoneSameInstant(eventZone).toLocalDate();
173                        
174                        // Apply the excluded date to the start date and hour of the event, to get the correct time during the excluded occurrence.
175                        ZonedDateTime excludedWithHour = startDate.withZoneSameInstant(eventZone).with(excludedDateInEventZone);
176                        
177                        excludedStrings.add(formatDate(excludedWithHour, true, false, eventZone));
178                    }
179                    else
180                    {
181                        excludedStrings.add(formatDate(excluded, true, true, eventZone));
182                    }
183                }
184                result.put("excludedDates", excludedStrings);
185            }
186            else
187            {
188                result.put("excludedDates", excludedOccurrences.stream()
189                        .map(DateUtils::zonedDateTimeToString)
190                        .collect(Collectors.toList()));
191            }
192            
193        }
194        
195        if (!fullDay)
196        {
197            result.put("zone", eventZone.getId());
198        }
199
200        result.put("startDate", formatDate(startDate, useICSFormat, fullDay, eventZone));
201
202        if (fullDay && useICSFormat)
203        {
204            // iCalendar full-day DTEND is exclusive, so add one day
205            result.put("endDate", formatDate(endDate.plusDays(1), true, fullDay, eventZone));
206        }
207        else
208        {
209            result.put("endDate", formatDate(endDate, useICSFormat, fullDay, eventZone));
210        }
211
212        // creator
213        UserIdentity creatorIdentity = event.getCreator();
214        User creator = _userManager.getUser(creatorIdentity);
215        
216        result.put("creator", creatorIdentity);
217        result.put("creatorFullName", creator != null ? creator.getFullName() : StringUtils.EMPTY);
218
219        UserIdentity user = _currentUserProvider.getUser();
220        result.put("isCreator", creatorIdentity.equals(user));
221        result.put("creationDate", formatDate(event.getCreationDate(), useICSFormat, false, null));
222        
223        // last modification
224        UserIdentity contributorIdentity = event.getLastContributor();
225        User contributor = _userManager.getUser(contributorIdentity);
226        
227        result.put("contributor", contributorIdentity);
228        result.put("contributorFullName", contributor != null ? contributor.getFullName() : contributorIdentity.getLogin());
229        result.put("lastModified", formatDate(event.getLastModified(), useICSFormat, false, null));
230
231        result.put("calendar", _calendarDAO.getCalendarProperties(calendar));
232        
233        // tags and places are expected by the client (respectively keywords and location on the server side)
234        result.put("tags", event.getTags());
235        
236        String location = StringUtils.defaultString(event.getLocation());
237        result.put("location", location);
238        result.put("places", Stream.of(location.split(",")).filter(StringUtils::isNotEmpty).collect(Collectors.toList()));
239        
240        // add event rights
241        result.put("rights", _extractEventRightData(event));
242        
243        result.put("resourceIds", event.getResources());
244        
245        result.put("isModifiable", event instanceof ModifiableCalendarEvent);
246        if (event instanceof TaskCalendarEvent taskCalendarEvent)
247        {
248            Task task = taskCalendarEvent.getTask();
249            Project project = _projectManager.getParentProject(task);
250            result.put("taskURL",  _taskModule.getTaskUri(project, task.getId()));
251           
252        }
253
254        return result;
255    }
256
257    private String formatDate(ZonedDateTime date, boolean useICSFormat, boolean fullDay, ZoneId eventZone)
258    {
259        return formatDate(date, useICSFormat, fullDay, eventZone, false);
260    }
261    
262    private String formatDate(ZonedDateTime date, boolean useICSFormat, boolean fullDay, ZoneId eventZone, boolean forceUTC)
263    {
264        if (useICSFormat)
265        {
266            if (fullDay)
267            {
268                // Convert ZonedDateTime to local date according to event time zone
269                return DateUtils.zonedDateTimeToString(date, eventZone, __FULL_DAY_PATTERN);
270                
271            }
272            else if (eventZone != null && !forceUTC)
273            {
274                // Convert to event zone and format as local time (no offset) for TZID-based iCalendar properties
275                return DateUtils.zonedDateTimeToString(date, eventZone, __HOUR_PATTERN_LOCAL);
276            }
277            else
278            {
279                // Metadata dates (CREATED, LAST-MODIFIED, DTSTAMP) and UNTIL must be in UTC per RFC 5545
280                return DateUtils.zonedDateTimeToString(date, ZoneOffset.UTC, __HOUR_PATTERN_UTC);
281            }
282        }
283        else
284        {
285            return DateUtils.zonedDateTimeToString(date);
286        }
287    }
288    
289    /**
290     * Internal method to extract the data concerning the right of the current user for an event
291     * @param event The event
292     * @return The map of right data. Keys are the rights id, and values indicates whether the current user has the right or not.
293     */
294    protected  Map<String, Object> _extractEventRightData(CalendarEvent event)
295    {
296        Map<String, Object> rightsData = new HashMap<>();
297        UserIdentity user = _currentUserProvider.getUser();
298        Calendar calendar = event.getCalendar();
299        
300        rightsData.put("edit", event instanceof ModifiableCalendarEvent && _rightManager.hasRight(user, AbstractCalendarDAO.RIGHTS_EVENT_EDIT, calendar) == RightResult.RIGHT_ALLOW);
301        rightsData.put("delete", event instanceof ModifiableCalendarEvent && _rightManager.hasRight(user, AbstractCalendarDAO.RIGHTS_EVENT_DELETE, calendar) == RightResult.RIGHT_ALLOW);
302        rightsData.put("delete-own", event instanceof ModifiableCalendarEvent && _rightManager.hasRight(user, AbstractCalendarDAO.RIGHTS_EVENT_DELETE_OWN, calendar) == RightResult.RIGHT_ALLOW);
303        
304        return rightsData;
305    }
306    
307    /**
308     * Get the user rights on the resource collection
309     * @param node The explorer node
310     * @return The user's rights
311     */
312    protected Set<String> _getUserRights(ExplorerNode node)
313    {
314        return _rightManager.getUserRights(_currentUserProvider.getUser(), node);
315    }
316    
317}