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