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)
62 * @author Michael Wodniok - Extended logic for defined behavior with parallel current events
66 class BiweeklyPresentableCalendar extends AbstractPresentableCalendar {
67 private final ICalendar usedCalendar;
69 BiweeklyPresentableCalendar(InputStream streamed) throws IOException, CalendarException {
70 try (final ICalReader reader = new ICalReader(streamed)) {
71 final ICalendar currentCalendar = reader.readNext();
72 if (currentCalendar == null) {
73 throw new CalendarException("No calendar was parsed.");
75 this.usedCalendar = currentCalendar;
80 public @Nullable Event getCurrentEvent(Instant instant) {
81 final VEventWPeriod currentComponentWPeriod = this.getCurrentComponentWPeriod(instant);
82 if (currentComponentWPeriod == null) {
86 return currentComponentWPeriod.toEvent();
90 public List<Event> getJustBegunEvents(Instant frameBegin, Instant frameEnd) {
91 final List<Event> eventList = new ArrayList<>();
92 // process all the events in the iCalendar
93 for (final VEvent event : usedCalendar.getEvents()) {
94 // iterate over all begin dates
95 final DateIterator begDates = getRecurredEventDateIterator(event);
96 while (begDates.hasNext()) {
97 final Instant begInst = begDates.next().toInstant();
98 if (begInst.isBefore(frameBegin)) {
100 } else if (begInst.isAfter(frameEnd)) {
103 // fall through => means we are within the time frame
104 Duration duration = getEventLength(event);
105 if (duration == null) {
106 duration = Duration.ofMinutes(1);
108 eventList.add(new VEventWPeriod(event, begInst, begInst.plus(duration)).toEvent());
116 public List<Event> getJustEndedEvents(Instant frameBegin, Instant frameEnd) {
117 final List<Event> eventList = new ArrayList<>();
118 // process all the events in the iCalendar
119 for (final VEvent event : usedCalendar.getEvents()) {
120 final Duration duration = getEventLength(event);
121 if (duration == null) {
124 // iterate over all begin dates
125 final DateIterator begDates = getRecurredEventDateIterator(event);
126 while (begDates.hasNext()) {
127 final Instant begInst = begDates.next().toInstant();
128 final Instant endInst = begInst.plus(duration);
129 if (endInst.isBefore(frameBegin)) {
131 } else if (endInst.isAfter(frameEnd)) {
134 // fall through => means we are within the time frame
135 eventList.add(new VEventWPeriod(event, begInst, endInst).toEvent());
143 public @Nullable Event getNextEvent(Instant instant) {
144 final Collection<VEventWPeriod> candidates = new ArrayList<VEventWPeriod>();
145 final Collection<VEvent> negativeEvents = new ArrayList<VEvent>();
146 final Collection<VEvent> positiveEvents = new ArrayList<VEvent>();
147 classifyEvents(positiveEvents, negativeEvents);
148 for (final VEvent currentEvent : positiveEvents) {
149 final DateIterator startDates = this.getRecurredEventDateIterator(currentEvent);
150 final Duration duration = getEventLength(currentEvent);
151 if (duration == null) {
154 startDates.advanceTo(Date.from(instant));
155 while (startDates.hasNext()) {
156 final Instant startInstant = startDates.next().toInstant();
157 if (startInstant.isAfter(instant)) {
158 final Uid currentEventUid = currentEvent.getUid();
159 if (currentEventUid == null || !isCounteredBy(startInstant, currentEventUid, negativeEvents)) {
160 candidates.add(new VEventWPeriod(currentEvent, startInstant, startInstant.plus(duration)));
166 VEventWPeriod earliestNextEvent = null;
167 for (final VEventWPeriod positiveCandidate : candidates) {
168 if (earliestNextEvent == null || earliestNextEvent.start.isAfter(positiveCandidate.start)) {
169 earliestNextEvent = positiveCandidate;
173 if (earliestNextEvent == null) {
176 return earliestNextEvent.toEvent();
180 public boolean isEventPresent(Instant instant) {
181 return (this.getCurrentComponentWPeriod(instant) != null);
185 public List<Event> getFilteredEventsBetween(Instant begin, Instant end, @Nullable EventTextFilter filter,
187 List<VEventWPeriod> candidates = this.getVEventWPeriodsBetween(begin, end, maximumCount);
188 final List<Event> results = new ArrayList<>(candidates.size());
190 if (filter != null) {
191 Pattern filterPattern;
192 if (filter.type == Type.TEXT) {
193 filterPattern = Pattern.compile(".*" + Pattern.quote(filter.value) + ".*",
194 Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
196 filterPattern = Pattern.compile(filter.value);
199 Class<? extends TextProperty> propertyClass;
200 switch (filter.field) {
202 propertyClass = Summary.class;
205 propertyClass = Comment.class;
208 propertyClass = Contact.class;
211 propertyClass = Description.class;
214 propertyClass = Location.class;
217 throw new IllegalArgumentException("Unknown Property to filter for.");
220 List<VEventWPeriod> filteredCandidates = candidates.stream().filter(current -> {
221 List<? extends TextProperty> properties = current.vEvent.getProperties(propertyClass);
222 for (TextProperty prop : properties) {
223 if (filterPattern.matcher(prop.getValue()).matches()) {
228 }).collect(Collectors.toList());
229 candidates = filteredCandidates;
232 for (VEventWPeriod eventWPeriod : candidates) {
233 results.add(eventWPeriod.toEvent());
236 Collections.sort(results);
238 return results.subList(0, (maximumCount > results.size() ? results.size() : maximumCount));
242 * Finds events which begin in the given frame.
244 * @param frameBegin Begin of the frame where to search events.
245 * @param frameEnd End of the time frame where to search events.
246 * @param maximumPerSeries Limit the results per series. Set to 0 for no limit.
247 * @return All events which begin in the time frame.
249 private List<VEventWPeriod> getVEventWPeriodsBetween(Instant frameBegin, Instant frameEnd, int maximumPerSeries) {
250 final List<VEvent> positiveEvents = new ArrayList<>();
251 final List<VEvent> negativeEvents = new ArrayList<>();
252 classifyEvents(positiveEvents, negativeEvents);
254 final List<VEventWPeriod> eventList = new ArrayList<>();
255 for (final VEvent positiveEvent : positiveEvents) {
256 final DateIterator positiveBeginDates = getRecurredEventDateIterator(positiveEvent);
257 positiveBeginDates.advanceTo(Date.from(frameBegin));
258 int foundInSeries = 0;
259 while (positiveBeginDates.hasNext()) {
260 final Instant begInst = positiveBeginDates.next().toInstant();
261 if (begInst.isAfter(frameEnd) || begInst.equals(frameEnd)) {
264 Duration duration = getEventLength(positiveEvent);
265 if (duration == null) {
266 duration = Duration.ZERO;
269 final VEventWPeriod resultingVEWP = new VEventWPeriod(positiveEvent, begInst, begInst.plus(duration));
270 final Uid eventUid = positiveEvent.getUid();
271 if (eventUid != null) {
272 if (!isCounteredBy(begInst, eventUid, negativeEvents)) {
273 eventList.add(resultingVEWP);
275 if (maximumPerSeries != 0 && foundInSeries >= maximumPerSeries) {
280 eventList.add(resultingVEWP);
282 if (maximumPerSeries != 0 && foundInSeries >= maximumPerSeries) {
293 * Classifies events into positive and negative ones.
295 * @param positiveEvents A List where to add positive ones.
296 * @param negativeEvents A List where to add negative ones.
298 private void classifyEvents(Collection<VEvent> positiveEvents, Collection<VEvent> negativeEvents) {
299 for (final VEvent currentEvent : usedCalendar.getEvents()) {
300 final Status eventStatus = currentEvent.getStatus();
301 boolean positive = (eventStatus == null || (eventStatus.isTentative() || eventStatus.isConfirmed()));
302 final RecurrenceId eventRecurrenceId = currentEvent.getRecurrenceId();
303 if (positive && eventRecurrenceId != null) {
304 // RecurrenceId moves an event. This blocks other events of series and creates a new single instance
305 positiveEvents.add(currentEvent);
306 negativeEvents.add(currentEvent);
308 final Collection<VEvent> positiveOrNegativeEvents = (positive ? positiveEvents : negativeEvents);
309 positiveOrNegativeEvents.add(currentEvent);
315 * Searches for a current event at given Instant.
317 * @param instant The Instant to use for finding events.
318 * @return A VEventWPeriod describing the event or null if there is none.
320 private @Nullable VEventWPeriod getCurrentComponentWPeriod(Instant instant) {
321 final List<VEvent> negativeEvents = new ArrayList<VEvent>();
322 final List<VEvent> positiveEvents = new ArrayList<VEvent>();
323 classifyEvents(positiveEvents, negativeEvents);
325 VEventWPeriod earliestEndingEvent = null;
327 for (final VEvent currentEvent : positiveEvents) {
328 final DateIterator startDates = this.getRecurredEventDateIterator(currentEvent);
329 final Duration duration = getEventLength(currentEvent);
330 if (duration == null) {
333 startDates.advanceTo(Date.from(instant.minus(duration)));
334 while (startDates.hasNext()) {
335 final Instant startInstant = startDates.next().toInstant();
336 final Instant endInstant = startInstant.plus(duration);
337 if (startInstant.isBefore(instant) && endInstant.isAfter(instant)) {
338 final Uid eventUid = currentEvent.getUid();
339 if (eventUid == null || !isCounteredBy(startInstant, eventUid, negativeEvents)) {
340 if (earliestEndingEvent == null || endInstant.isBefore(earliestEndingEvent.end)) {
341 earliestEndingEvent = new VEventWPeriod(currentEvent, startInstant, endInstant);
345 if (startInstant.isAfter(instant.plus(duration))) {
351 return earliestEndingEvent;
355 * Finds a duration of the event.
357 * @param vEvent The event to find out the duration.
358 * @return Either a Duration describing the events length or null, if no information is available.
360 private static @Nullable Duration getEventLength(VEvent vEvent) {
361 final DurationProperty duration = vEvent.getDuration();
362 if (duration != null) {
363 final biweekly.util.Duration eventDuration = duration.getValue();
364 return Duration.ofMillis(eventDuration.toMillis());
366 final DateStart start = vEvent.getDateStart();
367 final DateEnd end = vEvent.getDateEnd();
368 if (start == null || end == null) {
371 return Duration.between(start.getValue().toInstant(), end.getValue().toInstant());
375 * Retrieves a DateIterator to iterate through the events occurrences.
377 * @param vEvent The VEvent to create the iterator for.
378 * @return The DateIterator for {@link VEvent}
380 private DateIterator getRecurredEventDateIterator(VEvent vEvent) {
381 final TimezoneInfo tzinfo = this.usedCalendar.getTimezoneInfo();
383 final DateStart firstStart = vEvent.getDateStart();
385 if (tzinfo.isFloating(firstStart)) {
386 tz = TimeZone.getDefault();
388 final TimezoneAssignment startAssignment = tzinfo.getTimezone(firstStart);
389 tz = (startAssignment == null ? TimeZone.getTimeZone("UTC") : startAssignment.getTimeZone());
391 return vEvent.getDateIterator(tz);
395 * Checks whether an counter event blocks an event with given uid and start.
397 * @param startInstant The start of the event.
398 * @param eventUid The uid of the event.
399 * @param counterEvents Events that may counter.
400 * @return True if a counter event exists that matches uid and start, else false.
402 private boolean isCounteredBy(Instant startInstant, Uid eventUid, Collection<VEvent> counterEvents) {
403 for (final VEvent counterEvent : counterEvents) {
404 final Uid counterEventUid = counterEvent.getUid();
405 if (counterEventUid != null && eventUid.getValue().contentEquals(counterEventUid.getValue())) {
406 final RecurrenceId counterRecurrenceId = counterEvent.getRecurrenceId();
407 if (counterRecurrenceId != null) {
408 ICalDate recurrenceDate = counterRecurrenceId.getValue();
409 if (recurrenceDate != null) {
410 Instant recurrenceInstant = Instant.ofEpochMilli(recurrenceDate.getTime());
411 if (recurrenceInstant.equals(startInstant)) {
414 Range futureOrPast = counterRecurrenceId.getRange();
415 if (futureOrPast != null && futureOrPast.equals(Range.THIS_AND_FUTURE)
416 && startInstant.isAfter(recurrenceInstant)) {
419 if (futureOrPast != null && futureOrPast.equals(Range.THIS_AND_PRIOR)
420 && startInstant.isBefore(recurrenceInstant)) {
425 final DateIterator counterStartDates = getRecurredEventDateIterator(counterEvent);
426 counterStartDates.advanceTo(Date.from(startInstant));
427 if (counterStartDates.hasNext()) {
428 final Instant counterStartInstant = counterStartDates.next().toInstant();
429 if (counterStartInstant.equals(startInstant)) {
440 * A Class describing an event together with a start and end instant.
442 * @author Michael Wodniok - Initial contribution.
444 private static class VEventWPeriod {
449 public VEventWPeriod(VEvent vEvent, Instant start, Instant end) {
450 this.vEvent = vEvent;
455 public Event toEvent() {
456 final Summary eventSummary = vEvent.getSummary();
457 final String title = eventSummary != null ? eventSummary.getValue() : "-";
458 final Description eventDescription = vEvent.getDescription();
459 final String description = eventDescription != null ? eventDescription.getValue() : "";
460 return new Event(title, start, end, description);