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}