]> git.basschouten.com Git - openhab-addons.git/blob
3102ccc577f647a851e3a93e81455fcebfd18139
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.icalendar.internal.logic;
14
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;
27
28 import org.eclipse.jdt.annotation.NonNullByDefault;
29 import org.eclipse.jdt.annotation.Nullable;
30 import org.openhab.binding.icalendar.internal.logic.EventTextFilter.Type;
31
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;
52
53 /**
54  * Implementation of {@link AbstractPresentableCalendar} with ical4j. Please
55  * use {@link AbstractPresentableCalendar#create(InputStream)} for productive
56  * instantiation.
57  *
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
63  *         (issue 10808)
64  */
65 @NonNullByDefault
66 class BiweeklyPresentableCalendar extends AbstractPresentableCalendar {
67     private static final Duration ONE_DAY = Duration.ofDays(1).minusNanos(1);
68     private final ICalendar usedCalendar;
69
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.");
75             }
76             this.usedCalendar = currentCalendar;
77         }
78     }
79
80     @Override
81     public @Nullable Event getCurrentEvent(Instant instant) {
82         final VEventWPeriod currentComponentWPeriod = this.getCurrentComponentWPeriod(instant);
83         if (currentComponentWPeriod == null) {
84             return null;
85         }
86
87         return currentComponentWPeriod.toEvent();
88     }
89
90     @Override
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());
94     }
95
96     @Override
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());
100     }
101
102     @Override
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) {
112                 continue;
113             }
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)));
121                         break;
122                     }
123                 }
124             }
125         }
126         VEventWPeriod earliestNextEvent = null;
127         for (final VEventWPeriod positiveCandidate : candidates) {
128             if (earliestNextEvent == null || earliestNextEvent.start.isAfter(positiveCandidate.start)) {
129                 earliestNextEvent = positiveCandidate;
130             }
131         }
132
133         if (earliestNextEvent == null) {
134             return null;
135         }
136         return earliestNextEvent.toEvent();
137     }
138
139     @Override
140     public boolean isEventPresent(Instant instant) {
141         return (this.getCurrentComponentWPeriod(instant) != null);
142     }
143
144     @Override
145     public List<Event> getFilteredEventsBetween(Instant begin, Instant end, @Nullable EventTextFilter filter,
146             int maximumCount) {
147         List<VEventWPeriod> candidates = this.getVEventWPeriodsBetween(begin, end, maximumCount);
148         final List<Event> results = new ArrayList<>(candidates.size());
149
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);
155             } else {
156                 filterPattern = Pattern.compile(filter.value);
157             }
158
159             Class<? extends TextProperty> propertyClass;
160             switch (filter.field) {
161                 case SUMMARY:
162                     propertyClass = Summary.class;
163                     break;
164                 case COMMENT:
165                     propertyClass = Comment.class;
166                     break;
167                 case CONTACT:
168                     propertyClass = Contact.class;
169                     break;
170                 case DESCRIPTION:
171                     propertyClass = Description.class;
172                     break;
173                 case LOCATION:
174                     propertyClass = Location.class;
175                     break;
176                 default:
177                     throw new IllegalArgumentException("Unknown Property to filter for.");
178             }
179
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()) {
184                         return true;
185                     }
186                 }
187                 return false;
188             }).collect(Collectors.toList());
189             candidates = filteredCandidates;
190         }
191
192         for (VEventWPeriod eventWPeriod : candidates) {
193             results.add(eventWPeriod.toEvent());
194         }
195
196         Collections.sort(results);
197
198         return results.subList(0, (maximumCount > results.size() ? results.size() : maximumCount));
199     }
200
201     /**
202      * Finds events which begin in the given frame.
203      *
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.
208      */
209     private List<VEventWPeriod> getVEventWPeriodsBetween(Instant frameBegin, Instant frameEnd, int maximumPerSeries) {
210         return this.getVEventWPeriodsBetween(frameBegin, frameEnd, maximumPerSeries, false);
211     }
212
213     /**
214      * Finds events which begin in the given frame by end time and date
215      *
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.
221      */
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);
227
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;
234             }
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))) {
241                     break;
242                 }
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))) {
246                     continue;
247                 }
248
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);
254                         foundInSeries++;
255                         if (maximumPerSeries != 0 && foundInSeries >= maximumPerSeries) {
256                             break;
257                         }
258                     }
259                 } else {
260                     eventList.add(resultingVEWP);
261                     foundInSeries++;
262                     if (maximumPerSeries != 0 && foundInSeries >= maximumPerSeries) {
263                         break;
264                     }
265                 }
266             }
267         }
268
269         return eventList;
270     }
271
272     /**
273      * Classifies events into positive and negative ones.
274      *
275      * @param positiveEvents A List where to add positive ones.
276      * @param negativeEvents A List where to add negative ones.
277      */
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);
287             } else {
288                 final Collection<VEvent> positiveOrNegativeEvents = (positive ? positiveEvents : negativeEvents);
289                 positiveOrNegativeEvents.add(currentEvent);
290             }
291         }
292     }
293
294     /**
295      * Searches for a current event at given Instant.
296      *
297      * @param instant The Instant to use for finding events.
298      * @return A VEventWPeriod describing the event or null if there is none.
299      */
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);
304
305         VEventWPeriod earliestEndingEvent = null;
306
307         for (final VEvent currentEvent : positiveEvents) {
308             final DateIterator startDates = this.getRecurredEventDateIterator(currentEvent);
309             final Duration duration = getEventLength(currentEvent);
310             if (duration == null) {
311                 continue;
312             }
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);
322                         }
323                     }
324                 }
325                 if (startInstant.isAfter(instant.plus(duration))) {
326                     break;
327                 }
328             }
329         }
330
331         return earliestEndingEvent;
332     }
333
334     /**
335      * Finds a duration of the event.
336      *
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.
339      */
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());
345         }
346         final DateStart start = vEvent.getDateStart();
347         if (start == null) {
348             return null;
349         }
350         final DateEnd end = vEvent.getDateEnd();
351         if (end != null) {
352             return Duration.between(start.getValue().toInstant(), end.getValue().toInstant());
353         }
354         return start.getValue().hasTime() ? Duration.ZERO : ONE_DAY;
355     }
356
357     /**
358      * Retrieves a DateIterator to iterate through the events occurrences.
359      *
360      * @param vEvent The VEvent to create the iterator for.
361      * @return The DateIterator for {@link VEvent}
362      */
363     private DateIterator getRecurredEventDateIterator(VEvent vEvent) {
364         final TimezoneInfo tzinfo = this.usedCalendar.getTimezoneInfo();
365
366         final DateStart firstStart = vEvent.getDateStart();
367         TimeZone tz;
368         if (tzinfo.isFloating(firstStart)) {
369             tz = TimeZone.getDefault();
370         } else {
371             final TimezoneAssignment startAssignment = tzinfo.getTimezone(firstStart);
372             tz = (startAssignment == null ? TimeZone.getTimeZone("UTC") : startAssignment.getTimeZone());
373         }
374         return vEvent.getDateIterator(tz);
375     }
376
377     /**
378      * Checks whether a counter event blocks an event with given uid and start.
379      *
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.
384      */
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)) {
395                             return true;
396                         }
397                         Range futureOrPast = counterRecurrenceId.getRange();
398                         if (futureOrPast != null && futureOrPast.equals(Range.THIS_AND_FUTURE)
399                                 && startInstant.isAfter(recurrenceInstant)) {
400                             return true;
401                         }
402                         if (futureOrPast != null && futureOrPast.equals(Range.THIS_AND_PRIOR)
403                                 && startInstant.isBefore(recurrenceInstant)) {
404                             return true;
405                         }
406                     }
407                 } else {
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)) {
413                             return true;
414                         }
415                     }
416                 }
417             }
418         }
419         return false;
420     }
421
422     /**
423      * A Class describing an event together with a start and end instant.
424      *
425      * @author Michael Wodniok - Initial contribution.
426      */
427     private static class VEventWPeriod {
428         final VEvent vEvent;
429         final Instant start;
430         final Instant end;
431
432         public VEventWPeriod(VEvent vEvent, Instant start, Instant end) {
433             this.vEvent = vEvent;
434             this.start = start;
435             this.end = end;
436         }
437
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);
444         }
445     }
446 }