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                    if (tag != null)
129                    {
130                        eventList.setTag(tag);
131                    }
132                }
133
134                icsEvents.add(eventList);
135            }
136        }
137        
138        return icsEvents;
139    }
140    
141    /**
142     * Get the ICS tags from search service
143     * @param zoneItem The zone item id
144     * @param siteName the site name
145     * @return the ICS tags
146     */
147    public Set<Tag> getIcsTags(ZoneItem zoneItem, String siteName)
148    {
149        Set<Tag> tags = new LinkedHashSet<>();
150        
151        if (zoneItem != null && zoneItem.getServiceParameters().hasValue("ics"))
152        {
153            ModifiableModelAwareRepeater icsRepeater = zoneItem.getServiceParameters().getValue("ics");
154            for (ModifiableModelAwareRepeaterEntry repeaterEntry : icsRepeater.getEntries())
155            {
156                String tagName = repeaterEntry.getValue("tag");
157                Tag tag = _tagProviderEP.getTag(tagName, Map.of("siteName", siteName));
158                if (tag != null)
159                {
160                    tags.add(tag);
161                }
162            }
163        }
164        
165        return tags;
166    }
167    
168    /**
169     * Get a list of {@link LocalVEvent} form the list of {@link IcsEvents}
170     * @param icsEventsList the list of {@link IcsEvents}
171     * @param dateRange range of dates to limit
172     * @return a list of {@link LocalVEvent}
173     */
174    public Pair<List<LocalVEvent>, String> toLocalIcsEvent(List<IcsEvents> icsEventsList, EventsFilterHelper.DateTimeRange dateRange)
175    {
176        return toLocalIcsEvent(icsEventsList, dateRange, List.of());
177    }
178    
179    /**
180     * Get a list of {@link LocalVEvent} form the list of {@link IcsEvents}
181     * @param icsEventsList the list of {@link IcsEvents}
182     * @param dateRange range of dates to limit
183     * @param filteredTags A list of tag's name to filter ICS events. Can be empty to no filter on tags.
184     * @return a list of {@link LocalVEvent}
185     */
186    public Pair<List<LocalVEvent>, String> toLocalIcsEvent(List<IcsEvents> icsEventsList, EventsFilterHelper.DateTimeRange dateRange, List<String> filteredTags)
187    {
188        List<LocalVEvent> localICSEvents = new ArrayList<>();
189        String fullICSDistantEvents = "";
190        for (IcsEvents icsEvents : icsEventsList)
191        {
192            if (icsEvents.hasEvents())
193            {
194                Tag tag = icsEvents.getTag();
195                if (filteredTags.isEmpty() || tag != null && filteredTags.contains(tag.getName()))
196                {
197                    for (VEvent calendarComponent : icsEvents.getEvents())
198                    {
199                        List<LocalVEvent> localCalendarComponents = _icsReader.getEventDates(calendarComponent, dateRange, tag);
200                        localICSEvents.addAll(localCalendarComponents);
201                        if (dateRange == null)
202                        {
203                            // 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
204                            fullICSDistantEvents += calendarComponent.toString() + "\n";
205                        }
206                    }
207                }
208            }
209        }
210        
211        return new ImmutablePair<>(localICSEvents, fullICSDistantEvents);
212    }
213    /**
214     * Get a list of {@link LocalVEvent} form the list of {@link IcsEvents}
215     * @param icsEventsList the list of {@link IcsEvents}
216     * @param dateRange range of dates to limit
217     * @param filteredTags A list of tag's name to filter ICS events. Can be empty to no filter on tags.
218     * @return a list of {@link LocalVEvent}
219     */
220    public String toVTimeZone(List<IcsEvents> icsEventsList, EventsFilterHelper.DateTimeRange dateRange, List<String> filteredTags)
221    {
222        Set<String> timeZoneIds = new HashSet<>();
223        String timeZonesAsString = "";
224        for (IcsEvents icsEvents : icsEventsList)
225        {
226            if (icsEvents.hasEvents())
227            {
228                Tag tag = icsEvents.getTag();
229                if (filteredTags.isEmpty() || tag != null && filteredTags.contains(tag.getName()))
230                {
231                    for (VEvent calendarComponent : icsEvents.getEvents())
232                    {
233                        TzId startDateTZ = calendarComponent.getDateTimeStart().<TzId>flatMap(property -> property.getParameter(Parameter.TZID)).orElse(null);
234                        if (startDateTZ != null)
235                        {
236                            if (!timeZoneIds.contains(startDateTZ.getValue()))
237                            {
238                                timeZoneIds.add(startDateTZ.getValue());
239
240                                timeZonesAsString += startDateTZ.getValue() + "\n";
241                            }
242                        }
243                        
244                        TzId endDateTZ = calendarComponent.getDateTimeEnd().<TzId>flatMap(property -> property.getParameter(Parameter.TZID)).orElse(null);
245                        if (endDateTZ != null)
246                        {
247                            if (!timeZoneIds.contains(endDateTZ.getValue()))
248                            {
249                                timeZoneIds.add(endDateTZ.getValue());
250
251                                timeZonesAsString += endDateTZ.getValue() + "\n";
252                            }
253                        }
254                    }
255                }
256            }
257        }
258        
259        return timeZonesAsString;
260    }
261    
262    /**
263     * SAX ics events hits
264     * @param handler the content handler
265     * @param icsEvents The ics events
266     * @param startNumber the start index
267     * @throws SAXException if an error occurred while saxing
268     */
269    public void saxIcsEventHits(ContentHandler handler, List<LocalVEvent> icsEvents, int startNumber) throws SAXException
270    {
271        int hitIndex = startNumber;
272        for (LocalVEvent icsEvent : icsEvents)
273        {
274            saxIcsEventHit(handler, icsEvent, hitIndex++);
275        }
276    }
277    
278    /**
279     * SAX a ics events hit
280     * @param handler the content handler
281     * @param icsEvent The ics event
282     * @param number the hit index
283     * @throws SAXException if an error occurred while saxing
284     */
285    public void saxIcsEventHit(ContentHandler handler, LocalVEvent icsEvent, int number) throws SAXException
286    {
287        VEvent event = icsEvent.getEvent();
288        
289        AttributesImpl attrs = new AttributesImpl();
290        attrs.addCDATAAttribute("number", Integer.toString(number));
291        attrs.addCDATAAttribute("icsEvent", "true");
292        XMLUtils.startElement(handler, "hit", attrs);
293
294        String id = event.getProperty(Property.UID).map(Property::getValue).orElse(UUID.randomUUID().toString());
295        XMLUtils.createElement(handler, "id", id);
296        
297        saxIcsEvent(handler, icsEvent);
298        
299        XMLUtils.endElement(handler, "hit");
300    }
301    
302    /**
303     * SAX a ics event
304     * @param handler the content handler
305     * @param icsEvent The ics event
306     * @throws SAXException if an error occurred while saxing
307     */
308    public void saxIcsEvent(ContentHandler handler, LocalVEvent icsEvent) throws SAXException
309    {
310        XMLUtils.startElement(handler, "event");
311        
312        VEvent event = icsEvent.getEvent();
313        
314        String title = event.getProperty(Property.SUMMARY).map(Property::getValue).orElse(StringUtils.EMPTY);
315        XMLUtils.createElement(handler, "title", title);
316        
317        String description = event.getProperty(Property.DESCRIPTION).map(Property::getValue).orElse(StringUtils.EMPTY);
318        XMLUtils.createElement(handler, "description", description);
319        
320        Instant dtStamp = event.getDateTimeStamp().map(DtStamp::getDate).orElse(Instant.now());
321        Instant creationDate = event.getCreated().map(Created::getDate).orElse(dtStamp);
322        Instant lastModifiedDate = event.getLastModified().map(LastModified::getDate).orElse(dtStamp);
323        
324        _saxInstant(handler, "creationDate", creationDate);
325        _saxInstant(handler, "lastModifiedDate", lastModifiedDate);
326        
327        _saxZonedDateTime(handler, "startDate", icsEvent.getStart());
328        _saxZonedDateTime(handler, "endDate", icsEvent.getEnd());
329        
330        _saxAllDay(handler, icsEvent);
331        
332        CalendarSearchService.saxTag(handler, icsEvent.getTag());
333        
334        Url url = event.getUrl().orElse(null);
335        if (url != null)
336        {
337            XMLUtils.createElement(handler, "url", url.getValue());
338        }
339        
340        XMLUtils.endElement(handler, "event");
341        
342    }
343    
344    /**
345     * Sax all day property
346     * @param handler the content handler
347     * @param icsEvent the ICS event
348     * @throws SAXException if an error occurred while saxing
349     */
350    protected void _saxAllDay(ContentHandler handler, LocalVEvent icsEvent) throws SAXException
351    {
352        VEvent event = icsEvent.getEvent();
353        
354        DtStart startDate = event.getDateTimeStart().orElse(null);
355        DtEnd endDate = event.getDateTimeEnd().orElse(null);
356        
357        boolean allDayEvent = startDate != null && "DATE".equals(startDate.getParameter("VALUE").map(Parameter::getValue).orElse(null))
358                    && (endDate == null || "DATE".equals(endDate.getParameter("VALUE").map(Parameter::getValue).orElse(null)));
359        
360        XMLUtils.createElement(handler, "allDay", String.valueOf(allDayEvent));
361    }
362    
363    /**
364     * Sax ICS date
365     * @param handler the content handler
366     * @param tagName the xml tag name
367     * @param zonedDatetime the ZonedDateTime to sax
368     * @throws SAXException if an error occurred while saxing
369     */
370    protected void _saxZonedDateTime(ContentHandler handler, String tagName, ZonedDateTime zonedDatetime) throws SAXException
371    {
372        if (zonedDatetime != null)
373        {
374            XMLUtils.createElement(handler, tagName, DateUtils.getISODateTimeFormatter().format(zonedDatetime));
375        }
376    }
377
378    /**
379     * Sax ICS date
380     * @param handler the content handler
381     * @param tagName the xml tag name
382     * @param instant the Instant to sax
383     * @throws SAXException if an error occurred while saxing
384     */
385    protected void _saxInstant(ContentHandler handler, String tagName, Instant instant) throws SAXException
386    {
387        if (instant != null)
388        {
389            _saxZonedDateTime(handler, tagName, instant.atZone(ZoneOffset.UTC));
390        }
391    }
392
393    /**
394     * Sax ICS errors
395     * @param icsResults the ICS events
396     * @param handler the content handler
397     * @throws SAXException if an error occurred
398     */
399    public void saxICSErrors(List<IcsEvents> icsResults, ContentHandler handler) throws SAXException
400    {
401        List<IcsEvents> errorsIcs = icsResults.stream()
402            .filter(ics -> ics.getStatus() != IcsEvents.Status.OK)
403            .collect(Collectors.toList());
404
405        if (!errorsIcs.isEmpty())
406        {
407            XMLUtils.startElement(handler, "errors-ics");
408            for (IcsEvents ics : errorsIcs)
409            {
410                AttributesImpl attrs = new AttributesImpl();
411                attrs.addCDATAAttribute("url", ics.getUrl());
412                attrs.addCDATAAttribute("status", ics.getStatus().name());
413                XMLUtils.createElement(handler, "ics", attrs);
414            }
415            XMLUtils.endElement(handler, "errors-ics");
416        }
417    }
418}