001/*
002 *  Copyright 2020 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.io.IOException;
019import java.io.InputStream;
020import java.net.HttpURLConnection;
021import java.net.URL;
022import java.time.Duration;
023import java.time.Instant;
024import java.time.LocalDate;
025import java.time.temporal.ChronoUnit;
026import java.util.ArrayList;
027import java.util.Collection;
028import java.util.Date;
029import java.util.List;
030
031import org.apache.avalon.framework.activity.Initializable;
032import org.apache.avalon.framework.service.ServiceException;
033import org.apache.avalon.framework.service.ServiceManager;
034import org.apache.avalon.framework.service.Serviceable;
035import org.slf4j.Logger;
036
037import org.ametys.cms.tag.Tag;
038import org.ametys.core.cache.AbstractCacheManager;
039import org.ametys.core.cache.Cache;
040import org.ametys.plugins.calendar.events.EventsFilterHelper;
041import org.ametys.runtime.config.Config;
042import org.ametys.runtime.i18n.I18nizableText;
043import org.ametys.runtime.plugin.component.LogEnabled;
044
045import net.fortuna.ical4j.data.CalendarBuilder;
046import net.fortuna.ical4j.filter.Filter;
047import net.fortuna.ical4j.filter.PeriodRule;
048import net.fortuna.ical4j.model.Calendar;
049import net.fortuna.ical4j.model.Component;
050import net.fortuna.ical4j.model.ComponentList;
051import net.fortuna.ical4j.model.DateList;
052import net.fortuna.ical4j.model.DateTime;
053import net.fortuna.ical4j.model.Period;
054import net.fortuna.ical4j.model.Property;
055import net.fortuna.ical4j.model.component.CalendarComponent;
056import net.fortuna.ical4j.model.component.VEvent;
057import net.fortuna.ical4j.model.parameter.Value;
058import net.fortuna.ical4j.model.property.DateProperty;
059import net.fortuna.ical4j.model.property.DtEnd;
060import net.fortuna.ical4j.model.property.DtStart;
061import net.fortuna.ical4j.model.property.RRule;
062
063/**
064 * Read a distant ICS file for a certain number of events in the future
065 */
066public class IcsReader implements Serviceable, org.apache.avalon.framework.component.Component, Initializable, LogEnabled
067{
068    /** The Avalon role. */
069    public static final String ROLE = IcsReader.class.getName();
070    
071    private static final String __ICS_CACHE_ID = IcsReader.class.getName() + "$icsCache";
072
073    /** logger */
074    protected Logger _logger;
075
076    private AbstractCacheManager _abstractCacheManager;
077
078    @Override
079    public void service(ServiceManager smanager) throws ServiceException
080    {
081        _abstractCacheManager = (AbstractCacheManager) smanager.lookup(AbstractCacheManager.ROLE);
082    }
083    
084    /**
085     * Get a list of events from an ics file
086     * @param url url of the ics file
087     * @param dateRange range of dates to fetch
088     * @param nbEvents number of events to read
089     * @param maxFileSize max ics file size (in bytes)
090     * @return a List of {@link VEvent}
091     */
092    public IcsEvents getEventList(String url, EventsFilterHelper.DateRange dateRange, Long nbEvents, Long maxFileSize)
093    {
094        getLogger().debug("Fetch ics url : {}", url);
095        CacheKey cacheKey = new CacheKey(url, dateRange, nbEvents, maxFileSize);
096        
097        Cache<CacheKey, IcsEvents> cache = getIcsCache();
098        return cache.get(cacheKey, key -> _getEventList(url, dateRange, nbEvents, maxFileSize));
099    }
100    
101    /**
102     * Get a list of events from an ics file, without trying the cache
103     * @param url url of the ics file
104     * @param dateRange range of dates to fetch
105     * @param nbEvents number of events to read
106     * @param maxFileSize max ics file size (in bytes)
107     * @return a List of {@link VEvent}
108     */
109    protected IcsEvents _getEventList(String url, EventsFilterHelper.DateRange dateRange, Long nbEvents, Long maxFileSize)
110    {
111        try
112        {
113            long fileSize = getFileSize(url);
114            if (fileSize > maxFileSize)
115            {
116                getLogger().debug("ICS File is too big : {}", url);
117                return new IcsEvents(url, null, IcsEvents.Status.OVERSIZED);
118            }
119            
120            URL icsUrl = new URL(url);
121            
122            HttpURLConnection connexion = (HttpURLConnection) icsUrl.openConnection();
123    
124            try (InputStream flux = connexion.getInputStream())
125            {
126                CalendarBuilder builder = new CalendarBuilder();
127                Calendar calendar = builder.build(flux);
128        
129                getLogger().debug("Calendar is built for url : {}", url);
130                
131                ComponentList<CalendarComponent> components = calendar.getComponents(Component.VEVENT);
132                
133                Collection<CalendarComponent> componentList;
134                
135                if (dateRange != null)
136                {
137                    Period period = new Period(new DateTime(dateRange.getStartDate()), new DateTime(dateRange.getEndDate()));
138                    Filter<CalendarComponent> filter = new Filter<>(new PeriodRule<>(period));
139                    componentList = filter.filter(components);
140                }
141                else
142                {
143                    componentList = components;
144                }
145                
146                getLogger().debug("Calendar is filtered for url : {}", url);
147                
148                List<VEvent> eventList = new ArrayList<>();
149                Long nbEventsRemaining = nbEvents;
150                for (CalendarComponent calendarComponent : componentList)
151                {
152                    if (nbEventsRemaining > 0)
153                    {
154                        if (calendarComponent instanceof VEvent)
155                        {
156                            eventList.add((VEvent) calendarComponent);
157                            nbEventsRemaining--;
158                        }
159                    }
160                    else
161                    {
162                        break;
163                    }
164                }
165                getLogger().debug("List is generated for url : {}", url);
166                
167                return new IcsEvents(url, eventList);
168            }
169        }
170        catch (Exception e)
171        {
172            getLogger().error("Error while reading ics with url = '" + url + "'", e);
173            return new IcsEvents(url, null, IcsEvents.Status.ERROR);
174        }
175    }
176
177    public void initialize() throws Exception
178    {
179        System.setProperty("ical4j.unfolding.relaxed", "true");
180        System.setProperty("net.fortuna.ical4j.timezone.cache.impl", "net.fortuna.ical4j.util.MapTimeZoneCache");
181        
182        Long cacheTtlConf = Config.getInstance().getValue("org.ametys.plugins.calendar.ics.reader.cache.ttl");
183        Long cacheTtl = (long) (cacheTtlConf != null && cacheTtlConf.intValue() > 0 ? cacheTtlConf.intValue() : 60);
184        
185        Duration duration = Duration.ofMinutes(cacheTtl);
186        
187        _abstractCacheManager.createMemoryCache(__ICS_CACHE_ID,
188                new I18nizableText("plugin.calendar", "CALENDAR_SERVICE_AGENDA_ICS_CACHE_LABEL"),
189                new I18nizableText("plugin.calendar", "CALENDAR_SERVICE_AGENDA_ICS_CACHE_DESC"),
190                false, // ical4j events crash the api that calculates the size, sorry
191                duration);
192    }
193    
194    private Cache<CacheKey, IcsEvents> getIcsCache()
195    {
196        return _abstractCacheManager.get(__ICS_CACHE_ID);
197    }
198    
199    /**
200     * Try to get the size of the file, and download it if needed
201     * @param url the url to get
202     * @return size in octet, or -1 if error
203     * @throws IOException Something went wrong
204     */
205    private long getFileSize(String url) throws IOException
206    {
207        getLogger().debug("Start to try to determine size of the file : {}", url);
208        
209        URL icsUrl = new URL(url);
210        
211        HttpURLConnection connexion = (HttpURLConnection) icsUrl.openConnection();
212        
213        long nbByte = connexion.getContentLengthLong();
214        
215        if (nbByte < 0)
216        {
217            try (InputStream flux = connexion.getInputStream())
218            {
219                getLogger().debug("Unable to get size from header, we download the file : {}", url);
220                nbByte = 0;
221                while (flux.read() != -1)
222                {
223                    nbByte++;
224                }
225            }
226        }
227        getLogger().debug("End of estimation of the size of the file, {} bytes : {}", nbByte, url);
228        return nbByte;
229    }
230    
231    /**
232     * Get a list of {@link LocalDate} covered by this event
233     * @param event the event to test
234     * @param dateRange the dates to check, can be null but this will return null
235     * @param tag the tag used for this ICS
236     * @return a list of {@link LocalDate} for the days in which this event appears, or null if nothing matches
237     */
238    public List<LocalVEvent> getEventDates(VEvent event, EventsFilterHelper.DateRange dateRange, Tag tag)
239    {
240        List<LocalVEvent> result = new ArrayList<>();
241
242        Property rRuleProperty = event.getProperty(Property.RRULE);
243        if (rRuleProperty instanceof RRule)
244        {
245            Property startProperty = event.getProperty(Property.DTSTART);
246            Property endProperty = event.getProperty(Property.DTEND);
247            if (startProperty instanceof DtStart && endProperty instanceof DtEnd)
248            {
249                if (dateRange != null)
250                {
251                    DtStart dtStart = (DtStart) startProperty;
252                    DtEnd dtEnd = (DtEnd) endProperty;
253                    long eventDurationInMs = dtEnd.getDate().getTime() - dtStart.getDate().getTime();
254    
255                    RRule rRule = (RRule) rRuleProperty;
256                    net.fortuna.ical4j.model.Date periodeStart = new net.fortuna.ical4j.model.Date(dateRange.getStartDate());
257                    net.fortuna.ical4j.model.Date periodeEnd = new net.fortuna.ical4j.model.Date(dateRange.getEndDate());
258                    
259                    DateList dates = rRule.getRecur().getDates(dtStart.getDate(), periodeStart, periodeEnd, Value.DATE_TIME);
260                
261                    for (net.fortuna.ical4j.model.Date startDate : dates)
262                    {
263                        long eventEnd = startDate.getTime() + eventDurationInMs;
264                        Date endDate;
265                        if (dtEnd.getDate() instanceof DateTime)
266                        {
267                            endDate = new DateTime(eventEnd);
268                        }
269                        else
270                        {
271                            endDate = new Date(Instant.ofEpochMilli(eventEnd).minus(1, ChronoUnit.DAYS).toEpochMilli());
272                        }
273                        result.add(new LocalVEvent(event, startDate, endDate, tag));
274                    }
275                }
276                else
277                {
278                    getLogger().debug("Impossible to get the lest of events without a date range, it can be too much");
279                }
280            }
281        }
282        else
283        {
284            Date startDate = _getEventDateTime(event, Property.DTSTART);
285            Date endDate = _getEventDateTime(event, Property.DTEND);
286            
287            // If no dates, it can not be displayed.
288            // If one date is missing, consider the other equals
289            if (startDate == null && endDate == null)
290            {
291                return result;
292            }
293            else if (startDate == null)
294            {
295                startDate = endDate;
296            }
297            else if (endDate == null)
298            {
299                endDate = startDate;
300            }
301            
302            result.add(new LocalVEvent(event, startDate, endDate, tag));
303        }
304        return result;
305    }
306    
307    /**
308     * Return a string representing the start/end date of an event, or null if no start/end date was found
309     * @param event the event to read
310     * @param property a {@link Property} to check ( {@link Property#DTSTART} or  {@link Property#DTEND} )
311     * @return a string representing the date
312     */
313    private Date _getEventDateTime(CalendarComponent event, String property)
314    {
315        Date result = null;
316        Property checkedProperty = event.getProperty(property);
317        if (checkedProperty instanceof DateProperty)
318        {
319            DateProperty checked = (DateProperty) checkedProperty;
320            result = checked.getDate();
321            
322            // if we check the DTEND and it is just a DATE (not a DATETIME), we remove one day to it (because it is exclusive)
323            if (result != null && Property.DTEND.equals(property) && !(result instanceof DateTime))
324            {
325                result = new Date(result.toInstant().minus(1, ChronoUnit.DAYS).toEpochMilli());
326            }
327        }
328        return result;
329    }
330
331    public void setLogger(Logger logger)
332    {
333        _logger = logger;
334    }
335    
336    private Logger getLogger()
337    {
338        return _logger;
339    }
340    
341    /**
342     * Object wrapper for ics events
343     */
344    public static class IcsEvents
345    {
346        /**
347         * The status of the ics
348         */
349        public static enum Status 
350        {
351            /** If the ics exceeds the authorized max size */
352            OVERSIZED,
353            /** If there is some erros reading ics url */
354            ERROR,
355            /** If the ics is OK */
356            OK
357        }
358        
359        private String _url;
360        private List<VEvent> _events;
361        private Status _status;
362        private Tag _tag;
363        
364        /**
365         * The constructor
366         * @param url the url
367         * @param events the list of event of the ics
368         */
369        public IcsEvents(String url, List<VEvent> events)
370        {
371            this(url, events, Status.OK);
372        }
373        
374        /**
375         * The constructor
376         * @param url the url
377         * @param events the list of event of the ics
378         * @param status the status of the ics
379         */
380        public IcsEvents(String url, List<VEvent> events, Status status)
381        {
382            _url = url;
383            _events = events;
384            _tag = null;
385            _status = status;
386        }
387        
388        /**
389         * Get the url of the ics
390         * @return the url
391         */
392        public String getUrl()
393        {
394            return _url;
395        }
396        
397        /**
398         * <code>true</code> if the ics has events
399         * @return <code>true</code> if the ics has events
400         */
401        public boolean hasEvents()
402        {
403            return _events != null && _events.size() > 0;
404        }
405        
406        /**
407         * Get the list of events of the ics
408         * @return the list of events
409         */
410        public List<VEvent> getEvents()
411        {
412            return _events;
413        }
414        
415        /**
416         * Get the tag of the ics
417         * @return the tag
418         */
419        public Tag getTag()
420        {
421            return _tag;
422        }
423        
424        /**
425         * Set the tag of the ics
426         * @param tag the tag
427         */
428        public void setTag(Tag tag)
429        {
430            _tag = tag;
431        }
432        
433        /**
434         * Get the status of the ics
435         * @return the status
436         */
437        public Status getStatus()
438        {
439            return _status;
440        }
441    }
442}