2 * Copyright (c) 2010-2023 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 static final Duration ONE_DAY = Duration.ofDays(1).minusNanos(1);
68 private final ICalendar usedCalendar;
70 BiweeklyPresentableCalendar(InputStream streamed) throws IOException, CalendarException {
71 try (final ICalReader reader = new ICalReader(streamed)) {
72 final ICalendar currentCalendar = reader.readNext();
73 if (currentCalendar == null) {
74 throw new CalendarException("No calendar was parsed.");
76 this.usedCalendar = currentCalendar;
81 public @Nullable Event getCurrentEvent(Instant instant) {
82 final VEventWPeriod currentComponentWPeriod = this.getCurrentComponentWPeriod(instant);
83 if (currentComponentWPeriod == null) {
87 return currentComponentWPeriod.toEvent();
91 public List<Event> getJustBegunEvents(Instant frameBegin, Instant frameEnd) {
92 return this.getVEventWPeriodsBetween(frameBegin, frameEnd, 0).stream().map(e -> e.toEvent())
93 .collect(Collectors.toList());
97 public List<Event> getJustEndedEvents(Instant frameBegin, Instant frameEnd) {
98 return this.getVEventWPeriodsBetween(frameBegin, frameEnd, 0, true).stream().map(e -> e.toEvent())
99 .collect(Collectors.toList());
103 public @Nullable Event getNextEvent(Instant instant) {
104 final Collection<VEventWPeriod> candidates = new ArrayList<VEventWPeriod>();
105 final Collection<VEvent> negativeEvents = new ArrayList<VEvent>();
106 final Collection<VEvent> positiveEvents = new ArrayList<VEvent>();
107 classifyEvents(positiveEvents, negativeEvents);
108 for (final VEvent currentEvent : positiveEvents) {
109 final DateIterator startDates = this.getRecurredEventDateIterator(currentEvent);
110 final Duration duration = getEventLength(currentEvent);
111 if (duration == null) {
114 startDates.advanceTo(Date.from(instant));
115 while (startDates.hasNext()) {
116 final Instant startInstant = startDates.next().toInstant();
117 if (startInstant.isAfter(instant)) {
118 final Uid currentEventUid = currentEvent.getUid();
119 if (currentEventUid == null || !isCounteredBy(startInstant, currentEventUid, negativeEvents)) {
120 candidates.add(new VEventWPeriod(currentEvent, startInstant, startInstant.plus(duration)));
126 VEventWPeriod earliestNextEvent = null;
127 for (final VEventWPeriod positiveCandidate : candidates) {
128 if (earliestNextEvent == null || earliestNextEvent.start.isAfter(positiveCandidate.start)) {
129 earliestNextEvent = positiveCandidate;
133 if (earliestNextEvent == null) {
136 return earliestNextEvent.toEvent();
140 public boolean isEventPresent(Instant instant) {
141 return (this.getCurrentComponentWPeriod(instant) != null);
145 public List<Event> getFilteredEventsBetween(Instant begin, Instant end, @Nullable EventTextFilter filter,
147 List<VEventWPeriod> candidates = this.getVEventWPeriodsBetween(begin, end, maximumCount);
148 final List<Event> results = new ArrayList<>(candidates.size());
150 if (filter != null) {
151 Pattern filterPattern;
152 if (filter.type == Type.TEXT) {
153 filterPattern = Pattern.compile(".*" + Pattern.quote(filter.value) + ".*",
154 Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
156 filterPattern = Pattern.compile(filter.value);
159 Class<? extends TextProperty> propertyClass;
160 switch (filter.field) {
162 propertyClass = Summary.class;
165 propertyClass = Comment.class;
168 propertyClass = Contact.class;
171 propertyClass = Description.class;
174 propertyClass = Location.class;
177 throw new IllegalArgumentException("Unknown Property to filter for.");
180 List<VEventWPeriod> filteredCandidates = candidates.stream().filter(current -> {
181 List<? extends TextProperty> properties = current.vEvent.getProperties(propertyClass);
182 for (TextProperty prop : properties) {
183 if (filterPattern.matcher(prop.getValue()).matches()) {
188 }).collect(Collectors.toList());
189 candidates = filteredCandidates;
192 for (VEventWPeriod eventWPeriod : candidates) {
193 results.add(eventWPeriod.toEvent());
196 Collections.sort(results);
198 return results.subList(0, (maximumCount > results.size() ? results.size() : maximumCount));
202 * Finds events which begin in the given frame.
204 * @param frameBegin Begin of the frame where to search events.
205 * @param frameEnd End of the time frame where to search events.
206 * @param maximumPerSeries Limit the results per series. Set to 0 for no limit.
207 * @return All events which begin in the time frame.
209 private List<VEventWPeriod> getVEventWPeriodsBetween(Instant frameBegin, Instant frameEnd, int maximumPerSeries) {
210 return this.getVEventWPeriodsBetween(frameBegin, frameEnd, maximumPerSeries, false);
214 * Finds events which begin in the given frame by end time and date
216 * @param frameBegin Begin of the frame where to search events.
217 * @param frameEnd End of the time frame where to search events. The Instant is inclusive when searchByEnd is true.
218 * @param maximumPerSeries Limit the results per series. Set to 0 for no limit.
219 * @param searchByEnd Whether to search by begin of the event or by end.
220 * @return All events which begin in the time frame.
222 private List<VEventWPeriod> getVEventWPeriodsBetween(Instant frameBegin, Instant frameEnd, int maximumPerSeries,
223 boolean searchByEnd) {
224 final List<VEvent> positiveEvents = new ArrayList<>();
225 final List<VEvent> negativeEvents = new ArrayList<>();
226 classifyEvents(positiveEvents, negativeEvents);
228 final List<VEventWPeriod> eventList = new ArrayList<>();
229 for (final VEvent positiveEvent : positiveEvents) {
230 final DateIterator positiveBeginDates = getRecurredEventDateIterator(positiveEvent);
231 Duration duration = getEventLength(positiveEvent);
232 if (duration == null) {
233 duration = Duration.ZERO;
235 positiveBeginDates.advanceTo(Date.from(frameBegin.minus(searchByEnd ? duration : Duration.ZERO)));
236 int foundInSeries = 0;
237 while (positiveBeginDates.hasNext()) {
238 final Instant begInst = positiveBeginDates.next().toInstant();
239 if ((!searchByEnd && (begInst.isAfter(frameEnd) || begInst.equals(frameEnd)))
240 || (searchByEnd && begInst.plus(duration).isAfter(frameEnd))) {
243 // biweekly is not as precise as java.time. An exact check is required.
244 if ((!searchByEnd && begInst.isBefore(frameBegin))
245 || (searchByEnd && begInst.plus(duration).isBefore(frameBegin))) {
249 final VEventWPeriod resultingVEWP = new VEventWPeriod(positiveEvent, begInst, begInst.plus(duration));
250 final Uid eventUid = positiveEvent.getUid();
251 if (eventUid != null) {
252 if (!isCounteredBy(begInst, eventUid, negativeEvents)) {
253 eventList.add(resultingVEWP);
255 if (maximumPerSeries != 0 && foundInSeries >= maximumPerSeries) {
260 eventList.add(resultingVEWP);
262 if (maximumPerSeries != 0 && foundInSeries >= maximumPerSeries) {
273 * Classifies events into positive and negative ones.
275 * @param positiveEvents A List where to add positive ones.
276 * @param negativeEvents A List where to add negative ones.
278 private void classifyEvents(Collection<VEvent> positiveEvents, Collection<VEvent> negativeEvents) {
279 for (final VEvent currentEvent : usedCalendar.getEvents()) {
280 final Status eventStatus = currentEvent.getStatus();
281 boolean positive = (eventStatus == null || (eventStatus.isTentative() || eventStatus.isConfirmed()));
282 final RecurrenceId eventRecurrenceId = currentEvent.getRecurrenceId();
283 if (positive && eventRecurrenceId != null) {
284 // RecurrenceId moves an event. This blocks other events of series and creates a new single instance
285 positiveEvents.add(currentEvent);
286 negativeEvents.add(currentEvent);
288 final Collection<VEvent> positiveOrNegativeEvents = (positive ? positiveEvents : negativeEvents);
289 positiveOrNegativeEvents.add(currentEvent);
295 * Searches for a current event at given Instant.
297 * @param instant The Instant to use for finding events.
298 * @return A VEventWPeriod describing the event or null if there is none.
300 private @Nullable VEventWPeriod getCurrentComponentWPeriod(Instant instant) {
301 final List<VEvent> negativeEvents = new ArrayList<VEvent>();
302 final List<VEvent> positiveEvents = new ArrayList<VEvent>();
303 classifyEvents(positiveEvents, negativeEvents);
305 VEventWPeriod earliestEndingEvent = null;
307 for (final VEvent currentEvent : positiveEvents) {
308 final DateIterator startDates = this.getRecurredEventDateIterator(currentEvent);
309 final Duration duration = getEventLength(currentEvent);
310 if (duration == null) {
313 startDates.advanceTo(Date.from(instant.minus(duration)));
314 while (startDates.hasNext()) {
315 final Instant startInstant = startDates.next().toInstant();
316 final Instant endInstant = startInstant.plus(duration);
317 if (startInstant.isBefore(instant) && endInstant.isAfter(instant)) {
318 final Uid eventUid = currentEvent.getUid();
319 if (eventUid == null || !isCounteredBy(startInstant, eventUid, negativeEvents)) {
320 if (earliestEndingEvent == null || endInstant.isBefore(earliestEndingEvent.end)) {
321 earliestEndingEvent = new VEventWPeriod(currentEvent, startInstant, endInstant);
325 if (startInstant.isAfter(instant.plus(duration))) {
331 return earliestEndingEvent;
335 * Finds a duration of the event.
337 * @param vEvent The event to find out the duration.
338 * @return Either a Duration describing the events length or null, if no information is available.
340 private static @Nullable Duration getEventLength(VEvent vEvent) {
341 final DurationProperty duration = vEvent.getDuration();
342 if (duration != null) {
343 final biweekly.util.Duration eventDuration = duration.getValue();
344 return Duration.ofMillis(eventDuration.toMillis());
346 final DateStart start = vEvent.getDateStart();
350 final DateEnd end = vEvent.getDateEnd();
352 return Duration.between(start.getValue().toInstant(), end.getValue().toInstant());
354 return start.getValue().hasTime() ? Duration.ZERO : ONE_DAY;
358 * Retrieves a DateIterator to iterate through the events occurrences.
360 * @param vEvent The VEvent to create the iterator for.
361 * @return The DateIterator for {@link VEvent}
363 private DateIterator getRecurredEventDateIterator(VEvent vEvent) {
364 final TimezoneInfo tzinfo = this.usedCalendar.getTimezoneInfo();
366 final DateStart firstStart = vEvent.getDateStart();
368 if (tzinfo.isFloating(firstStart)) {
369 tz = TimeZone.getDefault();
371 final TimezoneAssignment startAssignment = tzinfo.getTimezone(firstStart);
372 tz = (startAssignment == null ? TimeZone.getTimeZone("UTC") : startAssignment.getTimeZone());
374 return vEvent.getDateIterator(tz);
378 * Checks whether a counter event blocks an event with given uid and start.
380 * @param startInstant The start of the event.
381 * @param eventUid The uid of the event.
382 * @param counterEvents Events that may counter.
383 * @return True if a counter event exists that matches uid and start, else false.
385 private boolean isCounteredBy(Instant startInstant, Uid eventUid, Collection<VEvent> counterEvents) {
386 for (final VEvent counterEvent : counterEvents) {
387 final Uid counterEventUid = counterEvent.getUid();
388 if (counterEventUid != null && eventUid.getValue().contentEquals(counterEventUid.getValue())) {
389 final RecurrenceId counterRecurrenceId = counterEvent.getRecurrenceId();
390 if (counterRecurrenceId != null) {
391 ICalDate recurrenceDate = counterRecurrenceId.getValue();
392 if (recurrenceDate != null) {
393 Instant recurrenceInstant = Instant.ofEpochMilli(recurrenceDate.getTime());
394 if (recurrenceInstant.equals(startInstant)) {
397 Range futureOrPast = counterRecurrenceId.getRange();
398 if (futureOrPast != null && futureOrPast.equals(Range.THIS_AND_FUTURE)
399 && startInstant.isAfter(recurrenceInstant)) {
402 if (futureOrPast != null && futureOrPast.equals(Range.THIS_AND_PRIOR)
403 && startInstant.isBefore(recurrenceInstant)) {
408 final DateIterator counterStartDates = getRecurredEventDateIterator(counterEvent);
409 counterStartDates.advanceTo(Date.from(startInstant));
410 if (counterStartDates.hasNext()) {
411 final Instant counterStartInstant = counterStartDates.next().toInstant();
412 if (counterStartInstant.equals(startInstant)) {
423 * A Class describing an event together with a start and end instant.
425 * @author Michael Wodniok - Initial contribution.
427 private static class VEventWPeriod {
432 public VEventWPeriod(VEvent vEvent, Instant start, Instant end) {
433 this.vEvent = vEvent;
438 public Event toEvent() {
439 final Summary eventSummary = vEvent.getSummary();
440 final String title = eventSummary != null ? eventSummary.getValue() : "-";
441 final Description eventDescription = vEvent.getDescription();
442 final String description = eventDescription != null ? eventDescription.getValue() : "";
443 return new Event(title, start, end, description);