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 return this.getVEventWPeriodsBetween(frameBegin, frameEnd, 0).stream().map(e -> e.toEvent())
92 .collect(Collectors.toList());
96 public List<Event> getJustEndedEvents(Instant frameBegin, Instant frameEnd) {
97 return this.getVEventWPeriodsBetween(frameBegin, frameEnd, 0, true).stream().map(e -> e.toEvent())
98 .collect(Collectors.toList());
102 public @Nullable Event getNextEvent(Instant instant) {
103 final Collection<VEventWPeriod> candidates = new ArrayList<VEventWPeriod>();
104 final Collection<VEvent> negativeEvents = new ArrayList<VEvent>();
105 final Collection<VEvent> positiveEvents = new ArrayList<VEvent>();
106 classifyEvents(positiveEvents, negativeEvents);
107 for (final VEvent currentEvent : positiveEvents) {
108 final DateIterator startDates = this.getRecurredEventDateIterator(currentEvent);
109 final Duration duration = getEventLength(currentEvent);
110 if (duration == null) {
113 startDates.advanceTo(Date.from(instant));
114 while (startDates.hasNext()) {
115 final Instant startInstant = startDates.next().toInstant();
116 if (startInstant.isAfter(instant)) {
117 final Uid currentEventUid = currentEvent.getUid();
118 if (currentEventUid == null || !isCounteredBy(startInstant, currentEventUid, negativeEvents)) {
119 candidates.add(new VEventWPeriod(currentEvent, startInstant, startInstant.plus(duration)));
125 VEventWPeriod earliestNextEvent = null;
126 for (final VEventWPeriod positiveCandidate : candidates) {
127 if (earliestNextEvent == null || earliestNextEvent.start.isAfter(positiveCandidate.start)) {
128 earliestNextEvent = positiveCandidate;
132 if (earliestNextEvent == null) {
135 return earliestNextEvent.toEvent();
139 public boolean isEventPresent(Instant instant) {
140 return (this.getCurrentComponentWPeriod(instant) != null);
144 public List<Event> getFilteredEventsBetween(Instant begin, Instant end, @Nullable EventTextFilter filter,
146 List<VEventWPeriod> candidates = this.getVEventWPeriodsBetween(begin, end, maximumCount);
147 final List<Event> results = new ArrayList<>(candidates.size());
149 if (filter != null) {
150 Pattern filterPattern;
151 if (filter.type == Type.TEXT) {
152 filterPattern = Pattern.compile(".*" + Pattern.quote(filter.value) + ".*",
153 Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
155 filterPattern = Pattern.compile(filter.value);
158 Class<? extends TextProperty> propertyClass;
159 switch (filter.field) {
161 propertyClass = Summary.class;
164 propertyClass = Comment.class;
167 propertyClass = Contact.class;
170 propertyClass = Description.class;
173 propertyClass = Location.class;
176 throw new IllegalArgumentException("Unknown Property to filter for.");
179 List<VEventWPeriod> filteredCandidates = candidates.stream().filter(current -> {
180 List<? extends TextProperty> properties = current.vEvent.getProperties(propertyClass);
181 for (TextProperty prop : properties) {
182 if (filterPattern.matcher(prop.getValue()).matches()) {
187 }).collect(Collectors.toList());
188 candidates = filteredCandidates;
191 for (VEventWPeriod eventWPeriod : candidates) {
192 results.add(eventWPeriod.toEvent());
195 Collections.sort(results);
197 return results.subList(0, (maximumCount > results.size() ? results.size() : maximumCount));
201 * Finds events which begin in the given frame.
203 * @param frameBegin Begin of the frame where to search events.
204 * @param frameEnd End of the time frame where to search events.
205 * @param maximumPerSeries Limit the results per series. Set to 0 for no limit.
206 * @return All events which begin in the time frame.
208 private List<VEventWPeriod> getVEventWPeriodsBetween(Instant frameBegin, Instant frameEnd, int maximumPerSeries) {
209 return this.getVEventWPeriodsBetween(frameBegin, frameEnd, maximumPerSeries, false);
213 * Finds events which begin in the given frame by end time and date
215 * @param frameBegin Begin of the frame where to search events.
216 * @param frameEnd End of the time frame where to search events. The Instant is inclusive when searchByEnd is true.
217 * @param maximumPerSeries Limit the results per series. Set to 0 for no limit.
218 * @param searchByEnd Whether to search by begin of the event or by end.
219 * @return All events which begin in the time frame.
221 private List<VEventWPeriod> getVEventWPeriodsBetween(Instant frameBegin, Instant frameEnd, int maximumPerSeries,
222 boolean searchByEnd) {
223 final List<VEvent> positiveEvents = new ArrayList<>();
224 final List<VEvent> negativeEvents = new ArrayList<>();
225 classifyEvents(positiveEvents, negativeEvents);
227 final List<VEventWPeriod> eventList = new ArrayList<>();
228 for (final VEvent positiveEvent : positiveEvents) {
229 final DateIterator positiveBeginDates = getRecurredEventDateIterator(positiveEvent);
230 Duration duration = getEventLength(positiveEvent);
231 if (duration == null) {
232 duration = Duration.ZERO;
234 positiveBeginDates.advanceTo(Date.from(frameBegin.minus(searchByEnd ? duration : Duration.ZERO)));
235 int foundInSeries = 0;
236 while (positiveBeginDates.hasNext()) {
237 final Instant begInst = positiveBeginDates.next().toInstant();
238 if ((!searchByEnd && (begInst.isAfter(frameEnd) || begInst.equals(frameEnd)))
239 || (searchByEnd && begInst.plus(duration).isAfter(frameEnd))) {
242 // biweekly is not as precise as java.time. An exact check is required.
243 if ((!searchByEnd && begInst.isBefore(frameBegin))
244 || (searchByEnd && begInst.plus(duration).isBefore(frameBegin))) {
248 final VEventWPeriod resultingVEWP = new VEventWPeriod(positiveEvent, begInst, begInst.plus(duration));
249 final Uid eventUid = positiveEvent.getUid();
250 if (eventUid != null) {
251 if (!isCounteredBy(begInst, eventUid, negativeEvents)) {
252 eventList.add(resultingVEWP);
254 if (maximumPerSeries != 0 && foundInSeries >= maximumPerSeries) {
259 eventList.add(resultingVEWP);
261 if (maximumPerSeries != 0 && foundInSeries >= maximumPerSeries) {
272 * Classifies events into positive and negative ones.
274 * @param positiveEvents A List where to add positive ones.
275 * @param negativeEvents A List where to add negative ones.
277 private void classifyEvents(Collection<VEvent> positiveEvents, Collection<VEvent> negativeEvents) {
278 for (final VEvent currentEvent : usedCalendar.getEvents()) {
279 final Status eventStatus = currentEvent.getStatus();
280 boolean positive = (eventStatus == null || (eventStatus.isTentative() || eventStatus.isConfirmed()));
281 final RecurrenceId eventRecurrenceId = currentEvent.getRecurrenceId();
282 if (positive && eventRecurrenceId != null) {
283 // RecurrenceId moves an event. This blocks other events of series and creates a new single instance
284 positiveEvents.add(currentEvent);
285 negativeEvents.add(currentEvent);
287 final Collection<VEvent> positiveOrNegativeEvents = (positive ? positiveEvents : negativeEvents);
288 positiveOrNegativeEvents.add(currentEvent);
294 * Searches for a current event at given Instant.
296 * @param instant The Instant to use for finding events.
297 * @return A VEventWPeriod describing the event or null if there is none.
299 private @Nullable VEventWPeriod getCurrentComponentWPeriod(Instant instant) {
300 final List<VEvent> negativeEvents = new ArrayList<VEvent>();
301 final List<VEvent> positiveEvents = new ArrayList<VEvent>();
302 classifyEvents(positiveEvents, negativeEvents);
304 VEventWPeriod earliestEndingEvent = null;
306 for (final VEvent currentEvent : positiveEvents) {
307 final DateIterator startDates = this.getRecurredEventDateIterator(currentEvent);
308 final Duration duration = getEventLength(currentEvent);
309 if (duration == null) {
312 startDates.advanceTo(Date.from(instant.minus(duration)));
313 while (startDates.hasNext()) {
314 final Instant startInstant = startDates.next().toInstant();
315 final Instant endInstant = startInstant.plus(duration);
316 if (startInstant.isBefore(instant) && endInstant.isAfter(instant)) {
317 final Uid eventUid = currentEvent.getUid();
318 if (eventUid == null || !isCounteredBy(startInstant, eventUid, negativeEvents)) {
319 if (earliestEndingEvent == null || endInstant.isBefore(earliestEndingEvent.end)) {
320 earliestEndingEvent = new VEventWPeriod(currentEvent, startInstant, endInstant);
324 if (startInstant.isAfter(instant.plus(duration))) {
330 return earliestEndingEvent;
334 * Finds a duration of the event.
336 * @param vEvent The event to find out the duration.
337 * @return Either a Duration describing the events length or null, if no information is available.
339 private static @Nullable Duration getEventLength(VEvent vEvent) {
340 final DurationProperty duration = vEvent.getDuration();
341 if (duration != null) {
342 final biweekly.util.Duration eventDuration = duration.getValue();
343 return Duration.ofMillis(eventDuration.toMillis());
345 final DateStart start = vEvent.getDateStart();
346 final DateEnd end = vEvent.getDateEnd();
347 if (start == null || end == null) {
350 return Duration.between(start.getValue().toInstant(), end.getValue().toInstant());
354 * Retrieves a DateIterator to iterate through the events occurrences.
356 * @param vEvent The VEvent to create the iterator for.
357 * @return The DateIterator for {@link VEvent}
359 private DateIterator getRecurredEventDateIterator(VEvent vEvent) {
360 final TimezoneInfo tzinfo = this.usedCalendar.getTimezoneInfo();
362 final DateStart firstStart = vEvent.getDateStart();
364 if (tzinfo.isFloating(firstStart)) {
365 tz = TimeZone.getDefault();
367 final TimezoneAssignment startAssignment = tzinfo.getTimezone(firstStart);
368 tz = (startAssignment == null ? TimeZone.getTimeZone("UTC") : startAssignment.getTimeZone());
370 return vEvent.getDateIterator(tz);
374 * Checks whether an counter event blocks an event with given uid and start.
376 * @param startInstant The start of the event.
377 * @param eventUid The uid of the event.
378 * @param counterEvents Events that may counter.
379 * @return True if a counter event exists that matches uid and start, else false.
381 private boolean isCounteredBy(Instant startInstant, Uid eventUid, Collection<VEvent> counterEvents) {
382 for (final VEvent counterEvent : counterEvents) {
383 final Uid counterEventUid = counterEvent.getUid();
384 if (counterEventUid != null && eventUid.getValue().contentEquals(counterEventUid.getValue())) {
385 final RecurrenceId counterRecurrenceId = counterEvent.getRecurrenceId();
386 if (counterRecurrenceId != null) {
387 ICalDate recurrenceDate = counterRecurrenceId.getValue();
388 if (recurrenceDate != null) {
389 Instant recurrenceInstant = Instant.ofEpochMilli(recurrenceDate.getTime());
390 if (recurrenceInstant.equals(startInstant)) {
393 Range futureOrPast = counterRecurrenceId.getRange();
394 if (futureOrPast != null && futureOrPast.equals(Range.THIS_AND_FUTURE)
395 && startInstant.isAfter(recurrenceInstant)) {
398 if (futureOrPast != null && futureOrPast.equals(Range.THIS_AND_PRIOR)
399 && startInstant.isBefore(recurrenceInstant)) {
404 final DateIterator counterStartDates = getRecurredEventDateIterator(counterEvent);
405 counterStartDates.advanceTo(Date.from(startInstant));
406 if (counterStartDates.hasNext()) {
407 final Instant counterStartInstant = counterStartDates.next().toInstant();
408 if (counterStartInstant.equals(startInstant)) {
419 * A Class describing an event together with a start and end instant.
421 * @author Michael Wodniok - Initial contribution.
423 private static class VEventWPeriod {
428 public VEventWPeriod(VEvent vEvent, Instant start, Instant end) {
429 this.vEvent = vEvent;
434 public Event toEvent() {
435 final Summary eventSummary = vEvent.getSummary();
436 final String title = eventSummary != null ? eventSummary.getValue() : "-";
437 final Description eventDescription = vEvent.getDescription();
438 final String description = eventDescription != null ? eventDescription.getValue() : "";
439 return new Event(title, start, end, description);