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 */
016package org.ametys.plugins.calendar.icsreader;
017
018import java.time.Instant;
019import java.time.ZoneOffset;
020import java.time.ZonedDateTime;
021import java.time.temporal.Temporal;
022import java.util.ArrayList;
023import java.util.HashSet;
024import java.util.LinkedHashSet;
025import java.util.List;
026import java.util.Map;
027import java.util.Set;
028import java.util.UUID;
029import java.util.stream.Collectors;
030
031import org.apache.avalon.framework.component.Component;
032import org.apache.avalon.framework.service.ServiceException;
033import org.apache.avalon.framework.service.ServiceManager;
034import org.apache.avalon.framework.service.Serviceable;
035import org.apache.cocoon.xml.AttributesImpl;
036import org.apache.cocoon.xml.XMLUtils;
037import org.apache.commons.lang3.StringUtils;
038import org.apache.commons.lang3.tuple.ImmutablePair;
039import org.apache.commons.lang3.tuple.Pair;
040import org.xml.sax.ContentHandler;
041import org.xml.sax.SAXException;
042
043import org.ametys.cms.tag.Tag;
044import org.ametys.cms.tag.TagProviderExtensionPoint;
045import org.ametys.core.util.DateUtils;
046import org.ametys.plugins.calendar.events.EventsFilterHelper;
047import org.ametys.plugins.calendar.icsreader.IcsReader.IcsEvents;
048import org.ametys.plugins.calendar.search.CalendarSearchService;
049import org.ametys.plugins.repository.data.holder.group.ModifiableModelAwareRepeater;
050import org.ametys.plugins.repository.data.holder.group.ModifiableModelAwareRepeaterEntry;
051import org.ametys.web.repository.page.ZoneItem;
052
053import net.fortuna.ical4j.model.Parameter;
054import net.fortuna.ical4j.model.Property;
055import net.fortuna.ical4j.model.component.VEvent;
056import net.fortuna.ical4j.model.parameter.TzId;
057import net.fortuna.ical4j.model.property.Created;
058import net.fortuna.ical4j.model.property.DtEnd;
059import net.fortuna.ical4j.model.property.DtStamp;
060import net.fortuna.ical4j.model.property.DtStart;
061import net.fortuna.ical4j.model.property.LastModified;
062import net.fortuna.ical4j.model.property.Url;
063
064/**
065 * Helper from ICS events
066 */
067public class IcsEventHelper implements Component, Serviceable
068{
069    /** The component role. */
070    public static final String ROLE = IcsEventHelper.class.getName();
071    
072    /** The ICS reader */
073    protected IcsReader _icsReader;
074    /** The tag provider extention point */
075    protected TagProviderExtensionPoint _tagProviderEP;
076
077    public void service(ServiceManager smanager) throws ServiceException
078    {
079        _icsReader = (IcsReader) smanager.lookup(IcsReader.ROLE);
080        _tagProviderEP = (TagProviderExtensionPoint) smanager.lookup(TagProviderExtensionPoint.ROLE);
081    }
082    
083    /**
084     * Read the configured distant ics sources into a list of {@link IcsEvents}
085     * @param zoneItem zoneItem where the configuration will be fetched
086     * @param siteName name of the current site
087     * @param dateRange range of dates to limit
088     * @return a list of {@link IcsEvents}
089     */
090    public List<IcsEvents> getICSEvents(ZoneItem zoneItem, String siteName, EventsFilterHelper.DateTimeRange dateRange)
091    {
092        List<IcsEvents> icsEvents = new ArrayList<>();
093        if (zoneItem != null && zoneItem.getServiceParameters().hasValue("ics"))
094        {
095            Long nbIcsEvent = zoneItem.getServiceParameters().getValue("nbEvents");
096            Long maxIcsSize = zoneItem.getServiceParameters().getValue("maxSize");
097    
098            icsEvents = getICSEvents(zoneItem, siteName, dateRange, nbIcsEvent, maxIcsSize);
099        }
100        return icsEvents;
101    }
102    
103    /**
104     * Read the configured distant ics sources into a list of {@link IcsEvents}
105     * @param zoneItem zoneItem where the configuration will be fetched
106     * @param siteName name of the current site
107     * @param dateRange range of dates to limit
108     * @param nbEvents number of events to read
109     * @param maxFileSize max ics file size (in bytes)
110     * @return a list of {@link IcsEvents}
111     */
112    public List<IcsEvents> getICSEvents(ZoneItem zoneItem, String siteName, EventsFilterHelper.DateTimeRange dateRange, Long nbEvents, Long maxFileSize)
113    {
114        List<IcsEvents> icsEvents = new ArrayList<>();
115
116        if (zoneItem.getServiceParameters().hasValue("ics"))
117        {
118            ModifiableModelAwareRepeater icsRepeater = zoneItem.getServiceParameters().getValue("ics");
119            
120            for (ModifiableModelAwareRepeaterEntry repeaterEntry : icsRepeater.getEntries())
121            {
122                String url = repeaterEntry.getValue("url");
123                IcsEvents eventList = _icsReader.getEventList(url, dateRange, nbEvents, maxFileSize);
124                
125                String tagName = repeaterEntry.getValue("tag");
126                if (StringUtils.isNotEmpty(tagName))
127                {
128                    Tag tag = _tagProviderEP.getTag(tagName, Map.of("siteName", siteName));
129                    if (tag != null)
130                    {
131                        eventList.setTag(tag);
132                    }
133                }
134
135                icsEvents.add(eventList);
136            }
137        }
138        
139        return icsEvents;
140    }
141    
142    /**
143     * Get the ICS tags from search service
144     * @param zoneItem The zone item id
145     * @param siteName the site name
146     * @return the ICS tags
147     */
148    public Set<Tag> getIcsTags(ZoneItem zoneItem, String siteName)
149    {
150        Set<Tag> tags = new LinkedHashSet<>();
151        
152        if (zoneItem != null && zoneItem.getServiceParameters().hasValue("ics"))
153        {
154            ModifiableModelAwareRepeater icsRepeater = zoneItem.getServiceParameters().getValue("ics");
155            for (ModifiableModelAwareRepeaterEntry repeaterEntry : icsRepeater.getEntries())
156            {
157                String tagName = repeaterEntry.getValue("tag");
158                Tag tag = _tagProviderEP.getTag(tagName, Map.of("siteName", siteName));
159                if (tag != null)
160                {
161                    tags.add(tag);
162                }
163            }
164        }
165        
166        return tags;
167    }
168    
169    /**
170     * Get a list of {@link LocalVEvent} form the list of {@link IcsEvents}
171     * @param icsEventsList the list of {@link IcsEvents}
172     * @param dateRange range of dates to limit
173     * @return a list of {@link LocalVEvent}
174     */
175    public Pair<List<LocalVEvent>, String> toLocalIcsEvent(List<IcsEvents> icsEventsList, EventsFilterHelper.DateTimeRange dateRange)
176    {
177        return toLocalIcsEvent(icsEventsList, dateRange, List.of());
178    }
179    
180    /**
181     * Get a list of {@link LocalVEvent} form the list of {@link IcsEvents}
182     * @param icsEventsList the list of {@link IcsEvents}
183     * @param dateRange range of dates to limit
184     * @param filteredTags A list of tag's name to filter ICS events. Can be empty to no filter on tags.
185     * @return a list of {@link LocalVEvent}
186     */
187    public Pair<List<LocalVEvent>, String> toLocalIcsEvent(List<IcsEvents> icsEventsList, EventsFilterHelper.DateTimeRange dateRange, List<String> filteredTags)
188    {
189        List<LocalVEvent> localICSEvents = new ArrayList<>();
190        String fullICSDistantEvents = "";
191        for (IcsEvents icsEvents : icsEventsList)
192        {
193            if (icsEvents.hasEvents())
194            {
195                Tag tag = icsEvents.getTag();
196                if (filteredTags.isEmpty() || tag != null && filteredTags.contains(tag.getName()))
197                {
198                    for (VEvent calendarComponent : icsEvents.getEvents())
199                    {
200                        List<LocalVEvent> localCalendarComponents = _icsReader.getEventDates(calendarComponent, dateRange, tag);
201                        localICSEvents.addAll(localCalendarComponents);
202                        if (dateRange == null)
203                        {
204                            // To avoid to have the ICS events in double in some exotic non-anticipated cases, when there is a dateRange, we do not copy the ICS
205                            fullICSDistantEvents += calendarComponent.toString() + "\n";
206                        }
207                    }
208                }
209            }
210        }
211        
212        return new ImmutablePair<>(localICSEvents, fullICSDistantEvents);
213    }
214    /**
215     * Get a list of {@link LocalVEvent} form the list of {@link IcsEvents}
216     * @param icsEventsList the list of {@link IcsEvents}
217     * @param dateRange range of dates to limit
218     * @param filteredTags A list of tag's name to filter ICS events. Can be empty to no filter on tags.
219     * @return a list of {@link LocalVEvent}
220     */
221    public String toVTimeZone(List<IcsEvents> icsEventsList, EventsFilterHelper.DateTimeRange dateRange, List<String> filteredTags)
222    {
223        Set<String> timeZoneIds = new HashSet<>();
224        String timeZonesAsString = "";
225        for (IcsEvents icsEvents : icsEventsList)
226        {
227            if (icsEvents.hasEvents())
228            {
229                Tag tag = icsEvents.getTag();
230                if (filteredTags.isEmpty() || tag != null && filteredTags.contains(tag.getName()))
231                {
232                    for (VEvent calendarComponent : icsEvents.getEvents())
233                    {
234                        DtStart<Temporal> dateTimeStart = calendarComponent.getDateTimeStart();
235                        TzId startDateTZ = dateTimeStart != null ? dateTimeStart.<TzId>getParameter(Parameter.TZID).orElse(null) : null;
236                        if (startDateTZ != null)
237                        {
238                            if (!timeZoneIds.contains(startDateTZ.getValue()))
239                            {
240                                timeZoneIds.add(startDateTZ.getValue());
241
242                                timeZonesAsString += startDateTZ.getValue() + "\n";
243                            }
244                        }
245                        DtEnd<Temporal> dateTimeEnd = calendarComponent.getDateTimeEnd();
246                        TzId endDateTZ = dateTimeEnd != null ? dateTimeEnd.<TzId>getParameter(Parameter.TZID).orElse(null) : null;
247                        if (endDateTZ != null)
248                        {
249                            if (!timeZoneIds.contains(endDateTZ.getValue()))
250                            {
251                                timeZoneIds.add(endDateTZ.getValue());
252
253                                timeZonesAsString += endDateTZ.getValue() + "\n";
254                            }
255                        }
256                    }
257                }
258            }
259        }
260        
261        return timeZonesAsString;
262    }
263    
264    /**
265     * SAX ics events hits
266     * @param handler the content handler
267     * @param icsEvents The ics events
268     * @param startNumber the start index
269     * @throws SAXException if an error occurred while saxing
270     */
271    public void saxIcsEventHits(ContentHandler handler, List<LocalVEvent> icsEvents, int startNumber) throws SAXException
272    {
273        int hitIndex = startNumber;
274        for (LocalVEvent icsEvent : icsEvents)
275        {
276            saxIcsEventHit(handler, icsEvent, hitIndex++);
277        }
278    }
279    
280    /**
281     * SAX a ics events hit
282     * @param handler the content handler
283     * @param icsEvent The ics event
284     * @param number the hit index
285     * @throws SAXException if an error occurred while saxing
286     */
287    public void saxIcsEventHit(ContentHandler handler, LocalVEvent icsEvent, int number) throws SAXException
288    {
289        VEvent event = icsEvent.getEvent();
290        
291        AttributesImpl attrs = new AttributesImpl();
292        attrs.addCDATAAttribute("number", Integer.toString(number));
293        attrs.addCDATAAttribute("icsEvent", "true");
294        XMLUtils.startElement(handler, "hit", attrs);
295
296        String id = event.getProperty(Property.UID).map(Property::getValue).orElse(UUID.randomUUID().toString());
297        XMLUtils.createElement(handler, "id", id);
298        
299        saxIcsEvent(handler, icsEvent);
300        
301        XMLUtils.endElement(handler, "hit");
302    }
303    
304    /**
305     * SAX a ics event
306     * @param handler the content handler
307     * @param icsEvent The ics event
308     * @throws SAXException if an error occurred while saxing
309     */
310    public void saxIcsEvent(ContentHandler handler, LocalVEvent icsEvent) throws SAXException
311    {
312        XMLUtils.startElement(handler, "event");
313        
314        VEvent event = icsEvent.getEvent();
315        
316        String title = event.getProperty(Property.SUMMARY).map(Property::getValue).orElse(StringUtils.EMPTY);
317        XMLUtils.createElement(handler, "title", title);
318        
319        String description = event.getProperty(Property.DESCRIPTION).map(Property::getValue).orElse(StringUtils.EMPTY);
320        XMLUtils.createElement(handler, "description", description);
321        
322        DtStamp dateTimeStamp = event.getDateTimeStamp();
323        Instant dtStamp = dateTimeStamp != null ? dateTimeStamp.getDate() : Instant.now();
324        
325        Created created = event.getCreated();
326        Instant creationDate = created != null ? created.getDate() : dtStamp;
327        
328        LastModified lastModified = event.getLastModified();
329        Instant lastModifiedDate = lastModified != null ? lastModified.getDate() : dtStamp;
330
331        _saxInstant(handler, "creationDate", creationDate);
332        _saxInstant(handler, "lastModifiedDate", lastModifiedDate);
333        
334        _saxZonedDateTime(handler, "startDate", icsEvent.getStart());
335        _saxZonedDateTime(handler, "endDate", icsEvent.getEnd());
336        
337        _saxAllDay(handler, icsEvent);
338        
339        CalendarSearchService.saxTag(handler, icsEvent.getTag());
340        
341        Url url = event.getUrl();
342        if (url != null)
343        {
344            XMLUtils.createElement(handler, "url", url.getValue());
345        }
346        
347        XMLUtils.endElement(handler, "event");
348        
349    }
350    
351    /**
352     * Sax all day property
353     * @param handler the content handler
354     * @param icsEvent the ICS event
355     * @throws SAXException if an error occurred while saxing
356     */
357    protected void _saxAllDay(ContentHandler handler, LocalVEvent icsEvent) throws SAXException
358    {
359        VEvent event = icsEvent.getEvent();
360        
361        DtStart startDate = event.getDateTimeStart();
362        DtEnd endDate = event.getDateTimeEnd();
363        
364        boolean allDayEvent = startDate != null && "DATE".equals(startDate.getParameter("VALUE").map(Parameter::getValue).orElse(null))
365                    && (endDate == null || "DATE".equals(endDate.getParameter("VALUE").map(Parameter::getValue).orElse(null)));
366        
367        XMLUtils.createElement(handler, "allDay", String.valueOf(allDayEvent));
368    }
369    
370    /**
371     * Sax ICS date
372     * @param handler the content handler
373     * @param tagName the xml tag name
374     * @param zonedDatetime the ZonedDateTime to sax
375     * @throws SAXException if an error occurred while saxing
376     */
377    protected void _saxZonedDateTime(ContentHandler handler, String tagName, ZonedDateTime zonedDatetime) throws SAXException
378    {
379        if (zonedDatetime != null)
380        {
381            XMLUtils.createElement(handler, tagName, DateUtils.getISODateTimeFormatter().format(zonedDatetime));
382        }
383    }
384
385    /**
386     * Sax ICS date
387     * @param handler the content handler
388     * @param tagName the xml tag name
389     * @param instant the Instant to sax
390     * @throws SAXException if an error occurred while saxing
391     */
392    protected void _saxInstant(ContentHandler handler, String tagName, Instant instant) throws SAXException
393    {
394        if (instant != null)
395        {
396            _saxZonedDateTime(handler, tagName, instant.atZone(ZoneOffset.UTC));
397        }
398    }
399
400    /**
401     * Sax ICS errors
402     * @param icsResults the ICS events
403     * @param handler the content handler
404     * @throws SAXException if an error occurred
405     */
406    public void saxICSErrors(List<IcsEvents> icsResults, ContentHandler handler) throws SAXException
407    {
408        List<IcsEvents> errorsIcs = icsResults.stream()
409            .filter(ics -> ics.getStatus() != IcsEvents.Status.OK)
410            .collect(Collectors.toList());
411
412        if (!errorsIcs.isEmpty())
413        {
414            XMLUtils.startElement(handler, "errors-ics");
415            for (IcsEvents ics : errorsIcs)
416            {
417                AttributesImpl attrs = new AttributesImpl();
418                attrs.addCDATAAttribute("url", ics.getUrl());
419                attrs.addCDATAAttribute("status", ics.getStatus().name());
420                XMLUtils.createElement(handler, "ics", attrs);
421            }
422            XMLUtils.endElement(handler, "errors-ics");
423        }
424    }
425}