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}