2 * Copyright (c) 2010-2021 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.icalendar.internal.logic;
15 import java.io.IOException;
16 import java.io.InputStream;
17 import java.time.Duration;
18 import java.time.Instant;
19 import java.util.ArrayList;
20 import java.util.Collection;
21 import java.util.Collections;
22 import java.util.Date;
23 import java.util.List;
24 import java.util.TimeZone;
25 import java.util.regex.Pattern;
26 import java.util.stream.Collectors;
28 import org.eclipse.jdt.annotation.NonNullByDefault;
29 import org.eclipse.jdt.annotation.Nullable;
30 import org.openhab.binding.icalendar.internal.logic.EventTextFilter.Type;
32 import biweekly.ICalendar;
33 import biweekly.component.VEvent;
34 import biweekly.io.TimezoneAssignment;
35 import biweekly.io.TimezoneInfo;
36 import biweekly.io.text.ICalReader;
37 import biweekly.parameter.Range;
38 import biweekly.property.Comment;
39 import biweekly.property.Contact;
40 import biweekly.property.DateEnd;
41 import biweekly.property.DateStart;
42 import biweekly.property.Description;
43 import biweekly.property.DurationProperty;
44 import biweekly.property.Location;
45 import biweekly.property.RecurrenceId;
46 import biweekly.property.Status;
47 import biweekly.property.Summary;
48 import biweekly.property.TextProperty;
49 import biweekly.property.Uid;
50 import biweekly.util.ICalDate;
51 import biweekly.util.com.google.ical.compat.javautil.DateIterator;
54 * Implementation of {@link AbstractPresentableCalendar} with ical4j. Please
55 * use {@link AbstractPresentableCalendar#create(InputStream)} for productive
58 * @author Michael Wodniok - Initial contribution
59 * @author Andrew Fiddian-Green - Methods getJustBegunEvents() & getJustEndedEvents()
60 * @author Michael Wodniok - Extension for filtered events
61 * @author Michael Wodniok - Added logic for events moved with "RECURRENCE-ID" (issue 9647)
64 class BiweeklyPresentableCalendar extends AbstractPresentableCalendar {
65 private final ICalendar usedCalendar;
67 BiweeklyPresentableCalendar(InputStream streamed) throws IOException, CalendarException {
68 try (final ICalReader reader = new ICalReader(streamed)) {
69 final ICalendar currentCalendar = reader.readNext();
70 if (currentCalendar == null) {
71 throw new CalendarException("No calendar was parsed.");
73 this.usedCalendar = currentCalendar;
78 public @Nullable Event getCurrentEvent(Instant instant) {
79 final VEventWPeriod currentComponentWPeriod = this.getCurrentComponentWPeriod(instant);
80 if (currentComponentWPeriod == null) {
84 return currentComponentWPeriod.toEvent();
88 public List<Event> getJustBegunEvents(Instant frameBegin, Instant frameEnd) {
89 final List<Event> eventList = new ArrayList<>();
90 // process all the events in the iCalendar
91 for (final VEvent event : usedCalendar.getEvents()) {
92 // iterate over all begin dates
93 final DateIterator begDates = getRecurredEventDateIterator(event);
94 while (begDates.hasNext()) {
95 final Instant begInst = begDates.next().toInstant();
96 if (begInst.isBefore(frameBegin)) {
98 } else if (begInst.isAfter(frameEnd)) {
101 // fall through => means we are within the time frame
102 Duration duration = getEventLength(event);
103 if (duration == null) {
104 duration = Duration.ofMinutes(1);
106 eventList.add(new VEventWPeriod(event, begInst, begInst.plus(duration)).toEvent());
114 public List<Event> getJustEndedEvents(Instant frameBegin, Instant frameEnd) {
115 final List<Event> eventList = new ArrayList<>();
116 // process all the events in the iCalendar
117 for (final VEvent event : usedCalendar.getEvents()) {
118 final Duration duration = getEventLength(event);
119 if (duration == null) {
122 // iterate over all begin dates
123 final DateIterator begDates = getRecurredEventDateIterator(event);
124 while (begDates.hasNext()) {
125 final Instant begInst = begDates.next().toInstant();
126 final Instant endInst = begInst.plus(duration);
127 if (endInst.isBefore(frameBegin)) {
129 } else if (endInst.isAfter(frameEnd)) {
132 // fall through => means we are within the time frame
133 eventList.add(new VEventWPeriod(event, begInst, endInst).toEvent());
141 public @Nullable Event getNextEvent(Instant instant) {
142 final Collection<VEventWPeriod> candidates = new ArrayList<VEventWPeriod>();
143 final Collection<VEvent> negativeEvents = new ArrayList<VEvent>();
144 final Collection<VEvent> positiveEvents = new ArrayList<VEvent>();
145 classifyEvents(positiveEvents, negativeEvents);
146 for (final VEvent currentEvent : positiveEvents) {
147 final DateIterator startDates = this.getRecurredEventDateIterator(currentEvent);
148 final Duration duration = getEventLength(currentEvent);
149 if (duration == null) {
152 startDates.advanceTo(Date.from(instant));
153 while (startDates.hasNext()) {
154 final Instant startInstant = startDates.next().toInstant();
155 if (startInstant.isAfter(instant)) {
156 final Uid currentEventUid = currentEvent.getUid();
157 if (currentEventUid == null || !isCounteredBy(startInstant, currentEventUid, negativeEvents)) {
158 candidates.add(new VEventWPeriod(currentEvent, startInstant, startInstant.plus(duration)));
164 VEventWPeriod earliestNextEvent = null;
165 for (final VEventWPeriod positiveCandidate : candidates) {
166 if (earliestNextEvent == null || earliestNextEvent.start.isAfter(positiveCandidate.start)) {
167 earliestNextEvent = positiveCandidate;
171 if (earliestNextEvent == null) {
174 return earliestNextEvent.toEvent();
178 public boolean isEventPresent(Instant instant) {
179 return (this.getCurrentComponentWPeriod(instant) != null);
183 public List<Event> getFilteredEventsBetween(Instant begin, Instant end, @Nullable EventTextFilter filter,
185 List<VEventWPeriod> candidates = this.getVEventWPeriodsBetween(begin, end, maximumCount);
186 final List<Event> results = new ArrayList<>(candidates.size());
188 if (filter != null) {
189 Pattern filterPattern;
190 if (filter.type == Type.TEXT) {
191 filterPattern = Pattern.compile(".*" + Pattern.quote(filter.value) + ".*",
192 Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
194 filterPattern = Pattern.compile(filter.value);
197 Class<? extends TextProperty> propertyClass;
198 switch (filter.field) {
200 propertyClass = Summary.class;
203 propertyClass = Comment.class;
206 propertyClass = Contact.class;
209 propertyClass = Description.class;
212 propertyClass = Location.class;
215 throw new IllegalArgumentException("Unknown Property to filter for.");
218 List<VEventWPeriod> filteredCandidates = candidates.stream().filter(current -> {
219 List<? extends TextProperty> properties = current.vEvent.getProperties(propertyClass);
220 for (TextProperty prop : properties) {
221 if (filterPattern.matcher(prop.getValue()).matches()) {
226 }).collect(Collectors.toList());
227 candidates = filteredCandidates;
230 for (VEventWPeriod eventWPeriod : candidates) {
231 results.add(eventWPeriod.toEvent());
234 Collections.sort(results);
236 return results.subList(0, (maximumCount > results.size() ? results.size() : maximumCount));
240 * Finds events which begin in the given frame.
242 * @param frameBegin Begin of the frame where to search events.
243 * @param frameEnd End of the time frame where to search events.
244 * @param maximumPerSeries Limit the results per series. Set to 0 for no limit.
245 * @return All events which begin in the time frame.
247 private List<VEventWPeriod> getVEventWPeriodsBetween(Instant frameBegin, Instant frameEnd, int maximumPerSeries) {
248 final List<VEvent> positiveEvents = new ArrayList<>();
249 final List<VEvent> negativeEvents = new ArrayList<>();
250 classifyEvents(positiveEvents, negativeEvents);
252 final List<VEventWPeriod> eventList = new ArrayList<>();
253 for (final VEvent positiveEvent : positiveEvents) {
254 final DateIterator positiveBeginDates = getRecurredEventDateIterator(positiveEvent);
255 positiveBeginDates.advanceTo(Date.from(frameBegin));
256 int foundInSeries = 0;
257 while (positiveBeginDates.hasNext()) {
258 final Instant begInst = positiveBeginDates.next().toInstant();
259 if (begInst.isAfter(frameEnd) || begInst.equals(frameEnd)) {
262 Duration duration = getEventLength(positiveEvent);
263 if (duration == null) {
264 duration = Duration.ZERO;
267 final VEventWPeriod resultingVEWP = new VEventWPeriod(positiveEvent, begInst, begInst.plus(duration));
268 final Uid eventUid = positiveEvent.getUid();
269 if (eventUid != null) {
270 if (!isCounteredBy(begInst, eventUid, negativeEvents)) {
271 eventList.add(resultingVEWP);
273 if (maximumPerSeries != 0 && foundInSeries >= maximumPerSeries) {
278 eventList.add(resultingVEWP);
280 if (maximumPerSeries != 0 && foundInSeries >= maximumPerSeries) {
291 * Classifies events into positive and negative ones.
293 * @param positiveEvents A List where to add positive ones.
294 * @param negativeEvents A List where to add negative ones.
296 private void classifyEvents(Collection<VEvent> positiveEvents, Collection<VEvent> negativeEvents) {
297 for (final VEvent currentEvent : usedCalendar.getEvents()) {
298 final Status eventStatus = currentEvent.getStatus();
299 boolean positive = (eventStatus == null || (eventStatus.isTentative() || eventStatus.isConfirmed()));
300 final RecurrenceId eventRecurrenceId = currentEvent.getRecurrenceId();
301 if (positive && eventRecurrenceId != null) {
302 // RecurrenceId moves an event. This blocks other events of series and creates a new single instance
303 positiveEvents.add(currentEvent);
304 negativeEvents.add(currentEvent);
306 final Collection<VEvent> positiveOrNegativeEvents = (positive ? positiveEvents : negativeEvents);
307 positiveOrNegativeEvents.add(currentEvent);
313 * Searches for a current event at given Instant.
315 * @param instant The Instant to use for finding events.
316 * @return A VEventWPeriod describing the event or null if there is none.
318 private @Nullable VEventWPeriod getCurrentComponentWPeriod(Instant instant) {
319 final List<VEvent> negativeEvents = new ArrayList<VEvent>();
320 final List<VEvent> positiveEvents = new ArrayList<VEvent>();
321 classifyEvents(positiveEvents, negativeEvents);
323 for (final VEvent currentEvent : positiveEvents) {
324 final DateIterator startDates = this.getRecurredEventDateIterator(currentEvent);
325 final Duration duration = getEventLength(currentEvent);
326 if (duration == null) {
329 startDates.advanceTo(Date.from(instant.minus(duration)));
330 while (startDates.hasNext()) {
331 final Instant startInstant = startDates.next().toInstant();
332 final Instant endInstant = startInstant.plus(duration);
333 if (startInstant.isBefore(instant) && endInstant.isAfter(instant)) {
334 final Uid eventUid = currentEvent.getUid();
335 if (eventUid == null || !isCounteredBy(startInstant, eventUid, negativeEvents)) {
336 return new VEventWPeriod(currentEvent, startInstant, endInstant);
339 if (startInstant.isAfter(instant.plus(duration))) {
349 * Finds a duration of the event.
351 * @param vEvent The event to find out the duration.
352 * @return Either a Duration describing the events length or null, if no information is available.
354 private static @Nullable Duration getEventLength(VEvent vEvent) {
355 final DurationProperty duration = vEvent.getDuration();
356 if (duration != null) {
357 final biweekly.util.Duration eventDuration = duration.getValue();
358 return Duration.ofMillis(eventDuration.toMillis());
360 final DateStart start = vEvent.getDateStart();
361 final DateEnd end = vEvent.getDateEnd();
362 if (start == null || end == null) {
365 return Duration.between(start.getValue().toInstant(), end.getValue().toInstant());
369 * Retrieves a DateIterator to iterate through the events occurrences.
371 * @param vEvent The VEvent to create the iterator for.
372 * @return The DateIterator for {@link VEvent}
374 private DateIterator getRecurredEventDateIterator(VEvent vEvent) {
375 final TimezoneInfo tzinfo = this.usedCalendar.getTimezoneInfo();
377 final DateStart firstStart = vEvent.getDateStart();
379 if (tzinfo.isFloating(firstStart)) {
380 tz = TimeZone.getDefault();
382 final TimezoneAssignment startAssignment = tzinfo.getTimezone(firstStart);
383 tz = (startAssignment == null ? TimeZone.getTimeZone("UTC") : startAssignment.getTimeZone());
385 return vEvent.getDateIterator(tz);
389 * Checks whether an counter event blocks an event with given uid and start.
391 * @param startInstant The start of the event.
392 * @param eventUid The uid of the event.
393 * @param counterEvents Events that may counter.
394 * @return True if a counter event exists that matches uid and start, else false.
396 private boolean isCounteredBy(Instant startInstant, Uid eventUid, Collection<VEvent> counterEvents) {
397 for (final VEvent counterEvent : counterEvents) {
398 final Uid counterEventUid = counterEvent.getUid();
399 if (counterEventUid != null && eventUid.getValue().contentEquals(counterEventUid.getValue())) {
400 final RecurrenceId counterRecurrenceId = counterEvent.getRecurrenceId();
401 if (counterRecurrenceId != null) {
402 ICalDate recurrenceDate = counterRecurrenceId.getValue();
403 if (recurrenceDate != null) {
404 Instant recurrenceInstant = Instant.ofEpochMilli(recurrenceDate.getTime());
405 if (recurrenceInstant.equals(startInstant)) {
408 Range futureOrPast = counterRecurrenceId.getRange();
409 if (futureOrPast != null && futureOrPast.equals(Range.THIS_AND_FUTURE)
410 && startInstant.isAfter(recurrenceInstant)) {
413 if (futureOrPast != null && futureOrPast.equals(Range.THIS_AND_PRIOR)
414 && startInstant.isBefore(recurrenceInstant)) {
419 final DateIterator counterStartDates = getRecurredEventDateIterator(counterEvent);
420 counterStartDates.advanceTo(Date.from(startInstant));
421 if (counterStartDates.hasNext()) {
422 final Instant counterStartInstant = counterStartDates.next().toInstant();
423 if (counterStartInstant.equals(startInstant)) {
434 * A Class describing an event together with a start and end instant.
436 * @author Michael Wodniok - Initial contribution.
438 private static class VEventWPeriod {
443 public VEventWPeriod(VEvent vEvent, Instant start, Instant end) {
444 this.vEvent = vEvent;
449 public Event toEvent() {
450 final Summary eventSummary = vEvent.getSummary();
451 final String title = eventSummary != null ? eventSummary.getValue() : "-";
452 final Description eventDescription = vEvent.getDescription();
453 final String description = eventDescription != null ? eventDescription.getValue() : "";
454 return new Event(title, start, end, description);