]> git.basschouten.com Git - openhab-addons.git/commitdiff
[icalendar] Add EventFilter for existing calendars (#8583)
authorMichael Wodniok <michi@noorganization.org>
Sat, 24 Oct 2020 20:35:07 +0000 (22:35 +0200)
committerGitHub <noreply@github.com>
Sat, 24 Oct 2020 20:35:07 +0000 (22:35 +0200)
This commit fixes #8022.

Signed-off-by: Michael Wodniok <michi@noorganization.org>
16 files changed:
bundles/org.openhab.binding.icalendar/README.md
bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/ICalendarBindingConstants.java
bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/ICalendarHandlerFactory.java
bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/config/EventFilterConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/config/ICalendarConfiguration.java
bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/handler/ConfigBrokenException.java [new file with mode: 0644]
bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/handler/EventFilterHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/handler/ICalendarHandler.java
bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/handler/PullJob.java
bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/logic/AbstractPresentableCalendar.java
bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/logic/BiweeklyPresentableCalendar.java
bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/logic/Event.java
bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/logic/EventTextFilter.java [new file with mode: 0644]
bundles/org.openhab.binding.icalendar/src/main/resources/OH-INF/i18n/icalendar_de.properties
bundles/org.openhab.binding.icalendar/src/main/resources/OH-INF/thing/thing-types.xml
bundles/org.openhab.binding.icalendar/src/test/java/org/openhab/binding/icalendar/internal/logic/BiweeklyPresentableCalendarTest.java

index b39431b63b842ec0249a3e6b160ef1cdedb4b8d5..0636c57137342a6ac50a91abfe93bbe076a2b0db 100644 (file)
@@ -6,12 +6,16 @@ Furthermore it is possible to embed `command tags` in the calendar event descrip
 
 ## Supported Things
 
-The only thing type is the calendar.
-It is based on a single iCalendar file.
+The primary thing type is the calendar.
+It is based on a single iCalendar file and implemented as bridge.
 There can be multiple things having different properties representing different calendars.
 
+Each calendar can have event filters which allow to get multiple events, maybe filtered by additional criteria. Time based filtering is done by each event's start.
+
 ## Thing Configuration
 
+### Configuration for `calendar`
+
 Each `calendar` thing requires the following configuration parameters:
 
 | parameter name      | description                                                                                                                                                                               | optional                      |
@@ -23,20 +27,60 @@ Each `calendar` thing requires the following configuration parameters:
 | `maxSize`           | The maximum size of the iCal-file in Mebibytes.                                                                                                                                           | mandatory (default available) |
 | `authorizationCode` | The authorization code to permit the execution of embedded command tags. If set, the binding checks that the authorization code in the command tag matches before executing any commands. | optional                      |
 
+### Configuration for `eventfilter`
+
+Each `eventfilter` thing requires a bridge of type `calendar` and has following configuration options:
+
+| parameter name   | description                                                                                                                                                                                  | optional                                   |
+|------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------|
+| `maxEvents`      | The count of expected results.                                                                                                                                                               | mandatory                                  |
+| `refreshTime`    | The frequency in minutes the channels get refreshed.                                                                                                                                         | mandatory (default available)              |
+| `datetimeUnit`   | A unit for time settings in this filter. Valid values: `MINUTE`, `HOUR`, `DAY` and `WEEK`.                                                                                                   | optional (required for time-based filtering) |
+| `datetimeStart`  | The start of the time frame where to search for events relative to current time. Combined with `datetimeUnit`.                                                                               | optional                                   |
+| `datetimeEnd`    | The end of the time frame where to search for events relative to current time. Combined with `datetimeUnit`. The value must be greater than `datetimeStart` to get results.                  | optional                                   |
+| `datetimeRound`  | Whether to round the datetimes of start and end down to the earlier time unit. Example if set: current time is 13:00, timeunit is set to `DAY`. Resulting search will start and end at 0:00. | optional                                   |
+| `textEventField` | A field to filter the events text-based. Valid values: `SUMMARY`, `DESCRIPTION`, `COMMENT`, `CONTACT` and `LOCATION` (as described in RFC 5545).                                             | optional/required for text-based filtering |
+| `textEventValue` | The text to filter events with.                                                                                                                                                              | optional                                   |
+| `textValueType`  | The type of the text to filter with. Valid values: `TEXT` (field must contain value), `REGEX` (field must match value, completely, dot matches all, case insensetive).                       | optional/required for text-based filtering |
+
 ## Channels
 
-The channels describe the current and the next forthcoming event.
+### Channels for `calendar` 
+
+The channels of `calendar` describe the current and the next forthcoming event.
 They are all read-only.
 
-| Channel           | Type      | Description                                                                    |
-|-------------------|-----------|--------------------------------------------------------------------------------|
-| current_presence  | Switch    | Current presence of an event, `ON` if there is currently an event, `OFF` otherwise |
-| current_title     | String    | Title of a currently present event                                             |
-| current_start     | DateTime  | Start of a currently present event                                             |
-| current_end       | DateTime  | End of a currently present event                                               |
-| next_title        | String    | Title of the next event                                                        |
-| next_start        | DateTime  | Start of the next event                                                        |
-| next_end          | DateTime  | End of the next event                                                          |
+| Channel           | Type      | Description                                                                         |
+|-------------------|-----------|-------------------------------------------------------------------------------------|
+| current_presence  | Switch    | Current presence of an event, `ON` if there is currently an event, `OFF` otherwise  |
+| current_title     | String    | Title of a currently present event                                                  |
+| current_start     | DateTime  | Start of a currently present event                                                  |
+| current_end       | DateTime  | End of a currently present event                                                    |
+| next_title        | String    | Title of the next event                                                             |
+| next_start        | DateTime  | Start of the next event                                                             |
+| next_end          | DateTime  | End of the next event                                                               |
+
+### Channels for `eventfilter`
+
+The channels of `eventfilter` are generated using following scheme, all are read-only.
+
+| Channel-scheme      | Type      | Description            |
+|---------------------|-----------|------------------------|
+| `result_<no>#begin` | DateTime  | The begin of an event  |
+| `result_<no>#end`   | DateTime  | The end of an event    |
+| `result_<no>#title` | String    | The title of an event  |
+
+The scheme replaces `<no>` by the results index, beginning at `0`. An `eventfilter` having `maxEvents` set to 3 will have following channels:
+
+* `result_0#begin`
+* `result_0#end`
+* `result_0#title`
+* `result_1#begin`
+* `result_1#end`
+* `result_1#title`
+* `result_2#begin`
+* `result_2#end`
+* `result_2#title` 
 
 ## Command Tags
 
@@ -76,16 +120,19 @@ The `Authorization_Code` may *optionally* be used as follows:
 All required information must be provided in the thing definition, either via UI or in the `.things` file..
 
 ```
-Thing icalendar:calendar:deadbeef "My calendar" @ "Internet" [ url="http://example.org/calendar.ical", refreshTime=60 ]
+Bridge icalendar:calendar:deadbeef    "My calendar" @ "Internet" [ url="http://example.org/calendar.ical", refreshTime=60 ]
+Thing  icalendar:eventfilter:feedd0d0 "Tomorrows events" (icalendar:calendar:deadbeef) [ maxEvents=1, datetimeUnit="DAY", datetimeStart=1, datetimeEnd=2, datetimeRound=true ]
 ```
 
 Link the channels as usual to items:
 
 ```
-String   current_event_name  "current event [%s]"                       <calendar> { channel="icalendar:calendar:deadbeef:current_title" }
-DateTime current_event_until "current until [%1$tT, %1$tY-%1$tm-%1$td]" <calendar> { channel="icalendar:calendar:deadbeef:current_end" }
-String   next_event_name     "next event [%s]"                          <calendar> { channel="icalendar:calendar:deadbeef:next_title" }
-DateTime next_event_at       "next at [%1$tT, %1$tY-%1$tm-%1$td]"       <calendar> { channel="icalendar:calendar:deadbeef:next_start" }
+String   current_event_name        "current event [%s]"                       <calendar> { channel="icalendar:calendar:deadbeef:current_title" }
+DateTime current_event_until       "current until [%1$tT, %1$tY-%1$tm-%1$td]" <calendar> { channel="icalendar:calendar:deadbeef:current_end" }
+String   next_event_name           "next event [%s]"                          <calendar> { channel="icalendar:calendar:deadbeef:next_title" }
+DateTime next_event_at             "next at [%1$tT, %1$tY-%1$tm-%1$td]"       <calendar> { channel="icalendar:calendar:deadbeef:next_start" }
+String   first_event_name_tomorrow "first event [%s]"                         <calendar> { channel="icalendar:eventfilter:feedd0d0:event_0#title" }
+DateTime first_event_at_tomorrow   "first at [%1$tT, %1$tY-%1$tm-%1$td]"      <calendar> { channel="icalendar:eventfilter:feedd0d0:event_0#begin" }
 ```
 
 Sitemap just showing the current event and the beginning of the next:
@@ -98,6 +145,10 @@ sitemap local label="My Calendar Sitemap" {
         Text item=next_event_name label="next event [%s]"
         Text item=next_event_at label="next at [%1$tT, %1$tY-%1$tm-%1$td]"
     }
+    Frame label="tomorrow" {
+        Text item=first_event_name_tomorrow
+        Text item=first_event_at_tomorrow
+    }
 }
 ```
 
@@ -114,3 +165,7 @@ Command tags in a calendar event (in the case that configuration parameter `auth
 BEGIN:Calendar_Test_Switch:ON
 END:Calendar_Test_Switch:OFF
 ```
+
+## Breaking changes
+
+In OH3 `calendar` was changed from Thing to Bridge. You need to recreate calendars (or replace `Thing` by `Bridge` in your `.things` file).
index bff3dcd379fc7d2b779f8ed3460279a0fe2069e8..df0d0845cd967a8657c9453d4727d98432830fb3 100644 (file)
@@ -14,6 +14,8 @@ package org.openhab.binding.icalendar.internal;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.type.ChannelGroupTypeUID;
+import org.openhab.core.thing.type.ChannelTypeUID;
 
 /**
  * The {@link ICalendarBindingConstants} class defines common constants, which are
@@ -28,6 +30,7 @@ public class ICalendarBindingConstants {
 
     // List of all Thing Type UIDs
     public static final ThingTypeUID THING_TYPE_CALENDAR = new ThingTypeUID(BINDING_ID, "calendar");
+    public static final ThingTypeUID THING_TYPE_FILTERED_EVENTS = new ThingTypeUID(BINDING_ID, "eventfilter");
 
     // List of all Channel ids
     public static final String CHANNEL_CURRENT_EVENT_TITLE = "current_title";
@@ -40,4 +43,19 @@ public class ICalendarBindingConstants {
 
     // additional constants
     public static final int HTTP_TIMEOUT_SECS = 60;
+    public static final String DATETIME_UNIT_MINUTE = "minute";
+    public static final String DATETIME_UNIT_HOUR = "hour";
+    public static final String DATETIME_UNIT_DAY = "day";
+    public static final String DATETIME_UNIT_WEEK = "week";
+
+    // specials for EventFilter
+    public static final int DEFAULT_FILTER_REFRESH = 15;
+    public static final String RESULT_GROUP_ID_PREFIX = "result_";
+    public static final String RESULT_BEGIN_ID = "begin";
+    public static final String RESULT_END_ID = "end";
+    public static final String RESULT_TITLE_ID = "title";
+    public static final ChannelGroupTypeUID GROUP_TYPE_UID = new ChannelGroupTypeUID(BINDING_ID, "result");
+    public static final ChannelTypeUID BEGIN_TYPE_UID = new ChannelTypeUID(BINDING_ID, "result_start");
+    public static final ChannelTypeUID END_TYPE_UID = new ChannelTypeUID(BINDING_ID, "result_end");
+    public static final ChannelTypeUID TITLE_TYPE_UID = new ChannelTypeUID(BINDING_ID, "result_title");
 }
index 7904c12b8b6455be60277a46310dd60fdc21a946..f0e8f6b197222519078713d8b7035c369046fa72 100644 (file)
  */
 package org.openhab.binding.icalendar.internal;
 
-import static org.openhab.binding.icalendar.internal.ICalendarBindingConstants.THING_TYPE_CALENDAR;
+import static org.openhab.binding.icalendar.internal.ICalendarBindingConstants.*;
 
 import java.util.Collections;
 import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
 import org.eclipse.jetty.client.HttpClient;
+import org.openhab.binding.icalendar.internal.handler.EventFilterHandler;
 import org.openhab.binding.icalendar.internal.handler.ICalendarHandler;
 import org.openhab.core.events.EventPublisher;
+import org.openhab.core.i18n.TimeZoneProvider;
 import org.openhab.core.io.net.http.HttpClientFactory;
+import org.openhab.core.thing.Bridge;
 import org.openhab.core.thing.Thing;
 import org.openhab.core.thing.ThingTypeUID;
 import org.openhab.core.thing.binding.BaseThingHandlerFactory;
@@ -31,6 +36,8 @@ import org.openhab.core.thing.binding.ThingHandlerFactory;
 import org.osgi.service.component.annotations.Activate;
 import org.osgi.service.component.annotations.Component;
 import org.osgi.service.component.annotations.Reference;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 /**
  * The {@link ICalendarHandlerFactory} is responsible for creating things and thing
@@ -38,21 +45,27 @@ import org.osgi.service.component.annotations.Reference;
  *
  * @author Michael Wodniok - Initial contribution
  * @author Andrew Fiddian-Green - EventPublisher code
+ * @author Michael Wodniok - Added FilteredEvent item type/handler
  */
 @NonNullByDefault
 @Component(configurationPid = "binding.icalendar", service = ThingHandlerFactory.class)
 public class ICalendarHandlerFactory extends BaseThingHandlerFactory {
 
-    private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections.singleton(THING_TYPE_CALENDAR);
+    private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Stream
+            .of(Collections.singleton(THING_TYPE_CALENDAR), Collections.singleton(THING_TYPE_FILTERED_EVENTS))
+            .flatMap(Set::stream).collect(Collectors.toSet());
+    private final Logger logger = LoggerFactory.getLogger(ICalendarHandlerFactory.class);
 
     private final HttpClient sharedHttpClient;
     private final EventPublisher eventPublisher;
+    private final TimeZoneProvider tzProvider;
 
     @Activate
     public ICalendarHandlerFactory(@Reference HttpClientFactory httpClientFactory,
-            @Reference EventPublisher eventPublisher) {
+            @Reference EventPublisher eventPublisher, @Reference TimeZoneProvider tzProvider) {
         this.eventPublisher = eventPublisher;
         sharedHttpClient = httpClientFactory.getCommonHttpClient();
+        this.tzProvider = tzProvider;
     }
 
     @Override
@@ -67,6 +80,16 @@ public class ICalendarHandlerFactory extends BaseThingHandlerFactory {
         if (!supportsThingType(thingTypeUID)) {
             return null;
         }
-        return new ICalendarHandler(thing, sharedHttpClient, eventPublisher);
+        if (thingTypeUID.equals(THING_TYPE_CALENDAR)) {
+            if (thing instanceof Bridge) {
+                return new ICalendarHandler((Bridge) thing, sharedHttpClient, eventPublisher, tzProvider);
+            } else {
+                logger.warn(
+                        "The API of iCalendar has changed. You have to recreate the calendar according to the docs.");
+            }
+        } else if (thingTypeUID.equals(THING_TYPE_FILTERED_EVENTS)) {
+            return new EventFilterHandler(thing, tzProvider);
+        }
+        return null;
     }
 }
diff --git a/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/config/EventFilterConfiguration.java b/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/config/EventFilterConfiguration.java
new file mode 100644 (file)
index 0000000..650a040
--- /dev/null
@@ -0,0 +1,45 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.icalendar.internal.config;
+
+import java.math.BigDecimal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * The EventFilterConfiguration holds configuration for the Event Filter Item Type.
+ *
+ * @author Michael Wodniok - Initial contribution
+ */
+@NonNullByDefault
+public class EventFilterConfiguration {
+    @Nullable
+    public BigDecimal maxEvents;
+    @Nullable
+    public BigDecimal refreshTime;
+    @Nullable
+    public String datetimeUnit;
+    @Nullable
+    public BigDecimal datetimeStart;
+    @Nullable
+    public BigDecimal datetimeEnd;
+    @Nullable
+    public Boolean datetimeRound;
+    @Nullable
+    public String textEventField;
+    @Nullable
+    public String textEventValue;
+    @Nullable
+    public String textValueType;
+}
index b90620011b0ba78796ebdcbc3c16ed7956caf5b4..948c9e5cc85566558f908bd01aa51ceb94c2de00 100644 (file)
@@ -14,17 +14,28 @@ package org.openhab.binding.icalendar.internal.config;
 
 import java.math.BigDecimal;
 
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
 /**
  * The {@link ICalendarConfiguration} class contains fields mapping thing configuration parameters.
  *
  * @author Michael Wodniok - Initial contribution
  * @author Andrew Fiddian-Green - Support for authorizationCode
+ * @author Michael Wodniok - Added Nullable annotations for conformity
  */
+@NonNullByDefault
 public class ICalendarConfiguration {
+    @Nullable
     public String authorizationCode;
-    public Integer maxSize;
+    @Nullable
+    public BigDecimal maxSize;
+    @Nullable
     public String password;
+    @Nullable
     public BigDecimal refreshTime;
+    @Nullable
     public String url;
+    @Nullable
     public String username;
 }
diff --git a/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/handler/ConfigBrokenException.java b/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/handler/ConfigBrokenException.java
new file mode 100644 (file)
index 0000000..62bda27
--- /dev/null
@@ -0,0 +1,30 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+
+package org.openhab.binding.icalendar.internal.handler;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Exception or semantically describe configuration errors. Message is meant to be shown to the user.
+ *
+ * @author Michael Wodniok - Initial contribution
+ */
+@NonNullByDefault
+public class ConfigBrokenException extends Exception {
+    private static final long serialVersionUID = -3805312008429711152L;
+
+    public ConfigBrokenException(String message) {
+        super(message);
+    }
+}
diff --git a/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/handler/EventFilterHandler.java b/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/handler/EventFilterHandler.java
new file mode 100644 (file)
index 0000000..ac108ec
--- /dev/null
@@ -0,0 +1,396 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.icalendar.internal.handler;
+
+import static org.openhab.binding.icalendar.internal.ICalendarBindingConstants.*;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.time.ZonedDateTime;
+import java.time.temporal.ChronoField;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.icalendar.internal.config.EventFilterConfiguration;
+import org.openhab.binding.icalendar.internal.handler.PullJob.CalendarUpdateListener;
+import org.openhab.binding.icalendar.internal.logic.AbstractPresentableCalendar;
+import org.openhab.binding.icalendar.internal.logic.Event;
+import org.openhab.binding.icalendar.internal.logic.EventTextFilter;
+import org.openhab.core.i18n.TimeZoneProvider;
+import org.openhab.core.library.types.DateTimeType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.Bridge;
+import org.openhab.core.thing.Channel;
+import org.openhab.core.thing.ChannelGroupUID;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.thing.ThingStatusInfo;
+import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerCallback;
+import org.openhab.core.thing.binding.builder.ChannelBuilder;
+import org.openhab.core.thing.binding.builder.ThingBuilder;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.openhab.core.types.UnDefType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link EventFilterHandler} filters events from a calendar and presents them in a dynamic way.
+ *
+ * @author Michael Wodniok - Initial Contribution
+ */
+@NonNullByDefault
+public class EventFilterHandler extends BaseThingHandler implements CalendarUpdateListener {
+
+    private @Nullable EventFilterConfiguration configuration;
+    private final Logger logger = LoggerFactory.getLogger(EventFilterHandler.class);
+    private final List<ResultChannelSet> resultChannels;
+    private final TimeZoneProvider tzProvider;
+    private @Nullable ScheduledFuture<?> updateFuture;
+    private boolean initFinished;
+
+    public EventFilterHandler(Thing thing, TimeZoneProvider tzProvider) {
+        super(thing);
+        resultChannels = new CopyOnWriteArrayList<>();
+        initFinished = false;
+        this.tzProvider = tzProvider;
+    }
+
+    @Override
+    public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
+        if (bridgeStatusInfo.getStatus() == ThingStatus.OFFLINE) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
+        } else if (bridgeStatusInfo.getStatus() == ThingStatus.ONLINE) {
+            updateStates();
+        } else {
+            updateStatus(ThingStatus.UNKNOWN);
+        }
+    }
+
+    @Override
+    public void dispose() {
+        final ScheduledFuture<?> currentUpdateFuture = updateFuture;
+        if (currentUpdateFuture != null) {
+            currentUpdateFuture.cancel(true);
+        }
+    }
+
+    @Override
+    public void handleCommand(ChannelUID channelUID, Command command) {
+        if (command instanceof RefreshType) {
+            if (initFinished) {
+                updateStates();
+            }
+        }
+    }
+
+    @Override
+    public void initialize() {
+        updateStatus(ThingStatus.UNKNOWN);
+
+        Bridge iCalendarBridge = getBridge();
+        if (iCalendarBridge == null) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
+                    "This thing requires a bridge configured to work.");
+            return;
+        }
+
+        final EventFilterConfiguration config = getConfigAs(EventFilterConfiguration.class);
+        if (config.datetimeUnit == null && (config.datetimeEnd != null || config.datetimeStart != null)) {
+            logger.warn("Start/End date-time is set but no unit. This will ignore the filter.");
+        }
+        if (config.textEventField != null && config.textValueType == null) {
+            logger.warn("Event field is set but not match type. This will ignore the filter.");
+        }
+        configuration = config;
+
+        if (iCalendarBridge.getStatus() != ThingStatus.ONLINE) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
+            return;
+        } else {
+            updateChannelSet(config);
+            updateStates();
+        }
+        initFinished = true;
+    }
+
+    @Override
+    public void onCalendarUpdated() {
+        updateStates();
+    }
+
+    /**
+     * Consists of a set of channels and their group for describing a filtered event. *
+     */
+    private class ResultChannelSet {
+        ChannelGroupUID resultGroup;
+        ChannelUID beginChannel;
+        ChannelUID endChannel;
+        ChannelUID titleChannel;
+
+        public ResultChannelSet(ChannelGroupUID group, ChannelUID begin, ChannelUID end, ChannelUID title) {
+            resultGroup = group;
+            beginChannel = begin;
+            endChannel = end;
+            titleChannel = title;
+        }
+    }
+
+    /**
+     * Describes some fixed time factors for unit selection.
+     */
+    private enum TimeMultiplicator {
+        MINUTE(60),
+        HOUR(3600),
+        DAY(86400),
+        WEEK(604800);
+
+        private final int secondsPerUnit;
+
+        private TimeMultiplicator(int secondsPerUnit) {
+            this.secondsPerUnit = secondsPerUnit;
+        }
+
+        /**
+         * Returns the count of seconds per unit.
+         *
+         * @return Seconds per unit.
+         */
+        public int getMultiplier() {
+            return secondsPerUnit;
+        }
+    }
+
+    /**
+     * Generates a list of channel sets according to the required amount.
+     *
+     * @param resultCount The required amount of results.
+     */
+    private void generateExpectedChannelList(int resultCount) {
+        synchronized (resultChannels) {
+            if (resultChannels.size() == resultCount) {
+                return;
+            }
+            resultChannels.clear();
+            for (int position = 0; position < resultCount; position++) {
+                ChannelGroupUID currentGroup = new ChannelGroupUID(getThing().getUID(),
+                        RESULT_GROUP_ID_PREFIX + position);
+                ResultChannelSet current = new ResultChannelSet(currentGroup,
+                        new ChannelUID(currentGroup, RESULT_BEGIN_ID), new ChannelUID(currentGroup, RESULT_END_ID),
+                        new ChannelUID(currentGroup, RESULT_TITLE_ID));
+                resultChannels.add(current);
+            }
+        }
+    }
+
+    /**
+     * Checks existing channels, adds missing and removes extraneous channels from the Thing.
+     *
+     * @param config The validated Configuration of the Thing.
+     */
+    private void updateChannelSet(EventFilterConfiguration config) {
+        final ThingHandlerCallback handlerCallback = getCallback();
+        if (handlerCallback == null) {
+            return;
+        }
+
+        final List<Channel> currentChannels = getThing().getChannels();
+        final ThingBuilder thingBuilder = editThing();
+        BigDecimal maxEvents = config.maxEvents;
+        if (maxEvents == null || maxEvents.compareTo(BigDecimal.ZERO) < 1) {
+            thingBuilder.withoutChannels(currentChannels);
+            updateThing(thingBuilder.build());
+            return;
+        }
+        generateExpectedChannelList(maxEvents.intValue());
+
+        synchronized (resultChannels) {
+            currentChannels.stream().filter((Channel current) -> {
+                String currentGroupId = current.getUID().getGroupId();
+                if (currentGroupId == null) {
+                    return true;
+                }
+                for (ResultChannelSet channelSet : resultChannels) {
+                    if (channelSet.resultGroup.getId().contentEquals(currentGroupId)) {
+                        return false;
+                    }
+                }
+                return true;
+            }).forEach((Channel toDelete) -> {
+                thingBuilder.withoutChannel(toDelete.getUID());
+            });
+
+            resultChannels.stream().filter((ResultChannelSet current) -> {
+                return (getThing().getChannelsOfGroup(current.resultGroup.toString()).size() == 0);
+            }).forEach((ResultChannelSet current) -> {
+                for (ChannelBuilder builder : handlerCallback.createChannelBuilders(current.resultGroup,
+                        GROUP_TYPE_UID)) {
+                    Channel currentChannel = builder.build();
+                    Channel existingChannel = getThing().getChannel(currentChannel.getUID());
+                    if (existingChannel == null) {
+                        thingBuilder.withChannel(currentChannel);
+                    }
+                }
+            });
+        }
+        updateThing(thingBuilder.build());
+    }
+
+    /**
+     * Updates all states and channels. Reschedules an update if no error occurs.
+     */
+    private void updateStates() {
+        final Bridge iCalendarBridge = getBridge();
+        if (iCalendarBridge == null) {
+            logger.debug("Bridge not instantiated!");
+            return;
+        }
+        final ICalendarHandler iCalendarHandler = (ICalendarHandler) iCalendarBridge.getHandler();
+        if (iCalendarHandler == null) {
+            logger.debug("ICalendarHandler not instantiated!");
+            return;
+        }
+        final EventFilterConfiguration config = configuration;
+        if (config == null) {
+            logger.debug("Configuration not instantiated!");
+            return;
+        }
+        final AbstractPresentableCalendar cal = iCalendarHandler.getRuntimeCalendar();
+        if (cal != null) {
+            updateStatus(ThingStatus.ONLINE);
+
+            Instant reference = Instant.now();
+            TimeMultiplicator multiplicator = null;
+            EventTextFilter filter = null;
+            int maxEvents;
+            Instant begin = Instant.EPOCH;
+            Instant end = Instant.ofEpochMilli(Long.MAX_VALUE);
+
+            try {
+                String textFilterValue = config.textEventValue;
+                if (textFilterValue != null) {
+                    String textEventField = config.textEventField;
+                    String textValueType = config.textValueType;
+                    if (textEventField == null || textValueType == null) {
+                        throw new ConfigBrokenException("Text filter settings are not set properly.");
+                    }
+                    try {
+                        EventTextFilter.Field textFilterField = EventTextFilter.Field.valueOf(textEventField);
+                        EventTextFilter.Type textFilterType = EventTextFilter.Type.valueOf(textValueType);
+
+                        filter = new EventTextFilter(textFilterField, textFilterValue, textFilterType);
+                    } catch (IllegalArgumentException e2) {
+                        throw new ConfigBrokenException("textEventField or textValueType are not set properly.");
+                    }
+                }
+
+                BigDecimal maxEventsBD = config.maxEvents;
+                if (maxEventsBD == null) {
+                    throw new ConfigBrokenException("maxEvents is not set.");
+                }
+                maxEvents = maxEventsBD.intValue();
+                if (maxEvents < 0) {
+                    throw new ConfigBrokenException("maxEvents is less than 0. This is not allowed.");
+                }
+
+                try {
+                    final String datetimeUnit = config.datetimeUnit;
+                    if (datetimeUnit != null) {
+                        multiplicator = TimeMultiplicator.valueOf(datetimeUnit);
+                    }
+                } catch (IllegalArgumentException e) {
+                    throw new ConfigBrokenException("datetimeUnit is not set properly.");
+                }
+
+                final Boolean datetimeRound = config.datetimeRound;
+                if (datetimeRound != null && datetimeRound.booleanValue()) {
+                    if (multiplicator == null) {
+                        throw new ConfigBrokenException("datetimeUnit is missing but required for datetimeRound.");
+                    }
+                    ZonedDateTime refDT = reference.atZone(tzProvider.getTimeZone());
+                    switch (multiplicator) {
+                        case WEEK:
+                            refDT = refDT.with(ChronoField.DAY_OF_WEEK, 1);
+                        case DAY:
+                            refDT = refDT.with(ChronoField.HOUR_OF_DAY, 0);
+                        case HOUR:
+                            refDT = refDT.with(ChronoField.MINUTE_OF_HOUR, 0);
+                        case MINUTE:
+                            refDT = refDT.with(ChronoField.SECOND_OF_MINUTE, 0);
+                    }
+                    reference = refDT.toInstant();
+                }
+
+                BigDecimal datetimeStart = config.datetimeStart;
+                if (datetimeStart != null) {
+                    if (multiplicator == null) {
+                        throw new ConfigBrokenException("datetimeUnit is missing but required for datetimeStart.");
+                    }
+                    begin = reference.plusSeconds(datetimeStart.longValue() * multiplicator.getMultiplier());
+                }
+                BigDecimal datetimeEnd = config.datetimeEnd;
+                if (datetimeEnd != null) {
+                    if (multiplicator == null) {
+                        throw new ConfigBrokenException("datetimeUnit is missing but required for datetimeEnd.");
+                    }
+                    end = reference.plusSeconds(datetimeEnd.longValue() * multiplicator.getMultiplier());
+                }
+            } catch (ConfigBrokenException e) {
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
+                return;
+            }
+
+            synchronized (resultChannels) {
+                List<Event> results = cal.getFilteredEventsBetween(begin, end, filter, maxEvents);
+                for (int position = 0; position < resultChannels.size(); position++) {
+                    ResultChannelSet channels = resultChannels.get(position);
+                    if (position < results.size()) {
+                        Event result = results.get(position);
+                        updateState(channels.titleChannel, new StringType(result.title));
+                        updateState(channels.beginChannel,
+                                new DateTimeType(result.start.atZone(tzProvider.getTimeZone())));
+                        updateState(channels.endChannel, new DateTimeType(result.end.atZone(tzProvider.getTimeZone())));
+                    } else {
+                        updateState(channels.titleChannel, UnDefType.UNDEF);
+                        updateState(channels.beginChannel, UnDefType.UNDEF);
+                        updateState(channels.endChannel, UnDefType.UNDEF);
+                    }
+                }
+            }
+        } else {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+                    "Calendar has not been retrieved yet.");
+        }
+
+        int refreshTime = DEFAULT_FILTER_REFRESH;
+        if (config.refreshTime != null) {
+            refreshTime = config.refreshTime.intValue();
+            if (refreshTime < 1) {
+                logger.debug("refreshTime is set to invalid value. Using default.");
+                refreshTime = DEFAULT_FILTER_REFRESH;
+            }
+        }
+        ScheduledFuture<?> currentUpdateFuture = updateFuture;
+        if (currentUpdateFuture != null) {
+            currentUpdateFuture.cancel(true);
+        }
+        updateFuture = scheduler.scheduleWithFixedDelay(this::updateStates, refreshTime, refreshTime, TimeUnit.MINUTES);
+    }
+}
index 5f943544ee1442bcef7fb0c7fdcb6891082bbfe6..deb7c4add60c937dd008cdd32509969c4eebb4e1 100644 (file)
@@ -17,10 +17,10 @@ import static org.openhab.binding.icalendar.internal.ICalendarBindingConstants.*
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.IOException;
+import java.math.BigDecimal;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.time.Instant;
-import java.time.ZoneId;
 import java.util.List;
 import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
@@ -37,15 +37,18 @@ import org.openhab.binding.icalendar.internal.logic.CommandTagType;
 import org.openhab.binding.icalendar.internal.logic.Event;
 import org.openhab.core.OpenHAB;
 import org.openhab.core.events.EventPublisher;
+import org.openhab.core.i18n.TimeZoneProvider;
 import org.openhab.core.items.events.ItemEventFactory;
 import org.openhab.core.library.types.DateTimeType;
 import org.openhab.core.library.types.OnOffType;
 import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.Bridge;
 import org.openhab.core.thing.ChannelUID;
 import org.openhab.core.thing.Thing;
 import org.openhab.core.thing.ThingStatus;
 import org.openhab.core.thing.ThingStatusDetail;
-import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.thing.binding.BaseBridgeHandler;
+import org.openhab.core.thing.binding.ThingHandler;
 import org.openhab.core.types.Command;
 import org.openhab.core.types.RefreshType;
 import org.openhab.core.types.UnDefType;
@@ -60,25 +63,28 @@ import org.slf4j.LoggerFactory;
  * @author Andrew Fiddian-Green - Support for Command Tags embedded in the Event description
  */
 @NonNullByDefault
-public class ICalendarHandler extends BaseThingHandler implements CalendarUpdateListener {
+public class ICalendarHandler extends BaseBridgeHandler implements CalendarUpdateListener {
 
     private final File calendarFile;
     private @Nullable ICalendarConfiguration configuration;
     private final EventPublisher eventPublisherCallback;
     private final HttpClient httpClient;
     private final Logger logger = LoggerFactory.getLogger(ICalendarHandler.class);
+    private final TimeZoneProvider tzProvider;
     private @Nullable ScheduledFuture<?> pullJobFuture;
     private @Nullable AbstractPresentableCalendar runtimeCalendar;
     private @Nullable ScheduledFuture<?> updateJobFuture;
     private Instant updateStatesLastCalledTime;
 
-    public ICalendarHandler(Thing thing, HttpClient httpClient, EventPublisher eventPublisher) {
-        super(thing);
+    public ICalendarHandler(Bridge bridge, HttpClient httpClient, EventPublisher eventPublisher,
+            TimeZoneProvider tzProvider) {
+        super(bridge);
         this.httpClient = httpClient;
         calendarFile = new File(OpenHAB.getUserDataFolder() + File.separator
                 + getThing().getUID().getAsString().replaceAll("[<>:\"/\\\\|?*]", "_") + ".ical");
         eventPublisherCallback = eventPublisher;
         updateStatesLastCalledTime = Instant.now();
+        this.tzProvider = tzProvider;
     }
 
     @Override
@@ -119,42 +125,53 @@ public class ICalendarHandler extends BaseThingHandler implements CalendarUpdate
         final ICalendarConfiguration currentConfiguration = getConfigAs(ICalendarConfiguration.class);
         configuration = currentConfiguration;
 
-        if ((currentConfiguration.username == null && currentConfiguration.password != null)
-                || (currentConfiguration.username != null && currentConfiguration.password == null)) {
-            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
-                    "Only one of username and password was set. This is invalid.");
-            return;
-        }
-
-        PullJob regularPull;
         try {
-            regularPull = new PullJob(httpClient, new URI(currentConfiguration.url), currentConfiguration.username,
-                    currentConfiguration.password, calendarFile, currentConfiguration.maxSize * 1048576, this);
-        } catch (URISyntaxException e) {
-            logger.warn(
-                    "The URI '{}' for downloading the calendar contains syntax errors. This will result in no downloads/updates.",
-                    currentConfiguration.url, e);
-            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR);
-            return;
-        }
+            if ((currentConfiguration.username == null && currentConfiguration.password != null)
+                    || (currentConfiguration.username != null && currentConfiguration.password == null)) {
+                throw new ConfigBrokenException("Only one of username and password was set. This is invalid.");
+            }
 
-        if (calendarFile.isFile()) {
-            if (reloadCalendar()) {
-                updateStatus(ThingStatus.ONLINE);
-                updateStates();
-                rescheduleCalendarStateUpdate();
+            PullJob regularPull;
+            final BigDecimal maxSizeBD = currentConfiguration.maxSize;
+            if (maxSizeBD == null || maxSizeBD.intValue() < 1) {
+                throw new ConfigBrokenException(
+                        "maxSize is either not set or less than 1 (mebibyte), which is not allowed.");
+            }
+            final int maxSize = maxSizeBD.intValue();
+            try {
+                regularPull = new PullJob(httpClient, new URI(currentConfiguration.url), currentConfiguration.username,
+                        currentConfiguration.password, calendarFile, maxSize * 1048576, this);
+            } catch (URISyntaxException e) {
+                throw new ConfigBrokenException(String.format(
+                        "The URI '%s' for downloading the calendar contains syntax errors.", currentConfiguration.url));
+
+            }
+
+            final BigDecimal refreshTimeBD = currentConfiguration.refreshTime;
+            if (refreshTimeBD == null || refreshTimeBD.longValue() < 1) {
+                throw new ConfigBrokenException(
+                        "refreshTime is either not set or less than 1 (minute), which is not allowed.");
+            }
+            final long refreshTime = refreshTimeBD.longValue();
+            if (calendarFile.isFile()) {
+                if (reloadCalendar()) {
+                    updateStatus(ThingStatus.ONLINE);
+                    updateStates();
+                    rescheduleCalendarStateUpdate();
+                } else {
+                    updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+                            "The calendar seems to be configured correctly, but the local copy of calendar could not be loaded.");
+                }
+                pullJobFuture = scheduler.scheduleWithFixedDelay(regularPull, refreshTime, refreshTime,
+                        TimeUnit.MINUTES);
             } else {
-                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
-                        "The calendar seems to be configured correctly, but the local copy of calendar could not be loaded.");
+                updateStatus(ThingStatus.OFFLINE);
+                logger.debug(
+                        "The calendar is currently offline as no local copy exists. It will go online as soon as a valid valid calendar is retrieved.");
+                pullJobFuture = scheduler.scheduleWithFixedDelay(regularPull, 0, refreshTime, TimeUnit.MINUTES);
             }
-            pullJobFuture = scheduler.scheduleWithFixedDelay(regularPull, currentConfiguration.refreshTime.longValue(),
-                    currentConfiguration.refreshTime.longValue(), TimeUnit.MINUTES);
-        } else {
-            updateStatus(ThingStatus.OFFLINE);
-            logger.debug(
-                    "The calendar is currently offline as no local copy exists. It will go online as soon as a valid valid calendar is retrieved.");
-            pullJobFuture = scheduler.scheduleWithFixedDelay(regularPull, 0,
-                    currentConfiguration.refreshTime.longValue(), TimeUnit.MINUTES);
+        } catch (ConfigBrokenException e) {
+            updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
         }
     }
 
@@ -162,20 +179,36 @@ public class ICalendarHandler extends BaseThingHandler implements CalendarUpdate
     public void onCalendarUpdated() {
         if (reloadCalendar()) {
             updateStates();
+            for (Thing childThing : getThing().getThings()) {
+                ThingHandler handler = childThing.getHandler();
+                if (handler instanceof CalendarUpdateListener) {
+                    try {
+                        ((CalendarUpdateListener) handler).onCalendarUpdated();
+                    } catch (Exception e) {
+                        logger.trace("The update of a child handler failed. Ignoring.", e);
+                    }
+                }
+            }
         } else {
             logger.trace("Calendar was updated, but loading failed.");
         }
     }
 
+    /**
+     * @return the calendar that is used for all operations
+     */
+    @Nullable
+    public AbstractPresentableCalendar getRuntimeCalendar() {
+        return runtimeCalendar;
+    }
+
     private void executeEventCommands(List<Event> events, CommandTagType execTime) {
         // no begun or ended events => exit quietly as there is nothing to do
         if (events.isEmpty()) {
             return;
         }
 
-        // prevent potential synchronization issues (MVN null pointer warnings) in "configuration"
-        @Nullable
-        ICalendarConfiguration syncConfiguration = configuration;
+        final ICalendarConfiguration syncConfiguration = configuration;
         if (syncConfiguration == null) {
             logger.debug("Configuration not instantiated!");
             return;
@@ -318,9 +351,9 @@ public class ICalendarHandler extends BaseThingHandler implements CalendarUpdate
                 } else {
                     updateState(CHANNEL_CURRENT_EVENT_TITLE, new StringType(currentEvent.title));
                     updateState(CHANNEL_CURRENT_EVENT_START,
-                            new DateTimeType(currentEvent.start.atZone(ZoneId.systemDefault())));
+                            new DateTimeType(currentEvent.start.atZone(tzProvider.getTimeZone())));
                     updateState(CHANNEL_CURRENT_EVENT_END,
-                            new DateTimeType(currentEvent.end.atZone(ZoneId.systemDefault())));
+                            new DateTimeType(currentEvent.end.atZone(tzProvider.getTimeZone())));
                 }
             } else {
                 updateState(CHANNEL_CURRENT_EVENT_PRESENT, OnOffType.OFF);
@@ -332,8 +365,9 @@ public class ICalendarHandler extends BaseThingHandler implements CalendarUpdate
             final Event nextEvent = calendar.getNextEvent(now);
             if (nextEvent != null) {
                 updateState(CHANNEL_NEXT_EVENT_TITLE, new StringType(nextEvent.title));
-                updateState(CHANNEL_NEXT_EVENT_START, new DateTimeType(nextEvent.start.atZone(ZoneId.systemDefault())));
-                updateState(CHANNEL_NEXT_EVENT_END, new DateTimeType(nextEvent.end.atZone(ZoneId.systemDefault())));
+                updateState(CHANNEL_NEXT_EVENT_START,
+                        new DateTimeType(nextEvent.start.atZone(tzProvider.getTimeZone())));
+                updateState(CHANNEL_NEXT_EVENT_END, new DateTimeType(nextEvent.end.atZone(tzProvider.getTimeZone())));
             } else {
                 updateState(CHANNEL_NEXT_EVENT_TITLE, UnDefType.UNDEF);
                 updateState(CHANNEL_NEXT_EVENT_START, UnDefType.UNDEF);
index b77d39810ec3231ab2ab111e301f7afd7fe9c71e..36c26c442995b592a86823024e6c8cce73faa6d6 100644 (file)
@@ -53,7 +53,7 @@ import org.slf4j.LoggerFactory;
  */
 @NonNullByDefault
 class PullJob implements Runnable {
-    private final static String TMP_FILE_PREFIX = "icalendardld";
+    private static final String TMP_FILE_PREFIX = "icalendardld";
 
     private final Authentication.@Nullable Result authentication;
     private final File destination;
@@ -91,7 +91,7 @@ class PullJob implements Runnable {
     @Override
     public void run() {
         final Request request = httpClient.newRequest(sourceURI).followRedirects(true).method(HttpMethod.GET);
-        final Authentication.@Nullable Result currentAuthentication = authentication;
+        final Authentication.Result currentAuthentication = authentication;
         if (currentAuthentication != null) {
             currentAuthentication.apply(request);
         }
index 121ea70ecec5a4c5adafb451f8cc1f74c10746f3..4f394c91b6c8b7e18757e853d6148023d75ed23f 100644 (file)
@@ -26,6 +26,7 @@ import org.eclipse.jdt.annotation.Nullable;
  *
  * @author Michael Wodniok - Initial contribution
  * @author Andrew Fiddian-Green - Methods getJustBegunEvents() & getJustEndedEvents()
+ * @author Michael Wodniok - Added getFilteredEventsBetween()
  */
 @NonNullByDefault
 public abstract class AbstractPresentableCalendar {
@@ -86,4 +87,16 @@ public abstract class AbstractPresentableCalendar {
      * @return True if an event is present.
      */
     public abstract boolean isEventPresent(Instant instant);
+
+    /**
+     * Return a filtered List of events with a maximum count, ordered by start.
+     *
+     * @param begin The begin of the time range where to search for events
+     * @param end The end of the time range where to search for events
+     * @param filter A filter for contents, if set to null, all events will be returned
+     * @param maximumCount The maximum of events returned here.
+     * @return A list with the filtered results.
+     */
+    public abstract List<Event> getFilteredEventsBetween(Instant begin, Instant end, @Nullable EventTextFilter filter,
+            int maximumCount);
 }
index 895d2a48b9f4dc86c8a6278e4543528595de097c..4e107b50843ef078b51ab1c606798fbcd9797a14 100644 (file)
@@ -18,24 +18,32 @@ import java.time.Duration;
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.Date;
 import java.util.List;
 import java.util.TimeZone;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.icalendar.internal.logic.EventTextFilter.Type;
 
 import biweekly.ICalendar;
 import biweekly.component.VEvent;
 import biweekly.io.TimezoneAssignment;
 import biweekly.io.TimezoneInfo;
 import biweekly.io.text.ICalReader;
+import biweekly.property.Comment;
+import biweekly.property.Contact;
 import biweekly.property.DateEnd;
 import biweekly.property.DateStart;
 import biweekly.property.Description;
 import biweekly.property.DurationProperty;
+import biweekly.property.Location;
 import biweekly.property.Status;
 import biweekly.property.Summary;
+import biweekly.property.TextProperty;
 import biweekly.property.Uid;
 import biweekly.util.com.google.ical.compat.javautil.DateIterator;
 
@@ -46,6 +54,7 @@ import biweekly.util.com.google.ical.compat.javautil.DateIterator;
  *
  * @author Michael Wodniok - Initial contribution
  * @author Andrew Fiddian-Green - Methods getJustBegunEvents() & getJustEndedEvents()
+ * @author Michael Wodniok - Extension for filtered events
  */
 @NonNullByDefault
 class BiweeklyPresentableCalendar extends AbstractPresentableCalendar {
@@ -140,7 +149,6 @@ class BiweeklyPresentableCalendar extends AbstractPresentableCalendar {
             while (startDates.hasNext()) {
                 final Instant startInstant = startDates.next().toInstant();
                 if (startInstant.isAfter(instant)) {
-                    @Nullable
                     final Uid currentEventUid = currentEvent.getUid();
                     if (currentEventUid == null || !isCounteredBy(startInstant, currentEventUid, negativeEvents)) {
                         candidates.add(new VEventWPeriod(currentEvent, startInstant, startInstant.plus(duration)));
@@ -167,6 +175,104 @@ class BiweeklyPresentableCalendar extends AbstractPresentableCalendar {
         return (this.getCurrentComponentWPeriod(instant) != null);
     }
 
+    @Override
+    public List<Event> getFilteredEventsBetween(Instant begin, Instant end, @Nullable EventTextFilter filter,
+            int maximumCount) {
+        List<VEventWPeriod> candidates = this.getVEventWPeriodsBetween(begin, end);
+        final List<Event> results = new ArrayList<>(candidates.size());
+
+        if (filter != null) {
+            Pattern filterPattern;
+            if (filter.type == Type.TEXT) {
+                filterPattern = Pattern.compile(".*" + Pattern.quote(filter.value) + ".*",
+                        Pattern.CASE_INSENSITIVE | Pattern.DOTALL);
+            } else {
+                filterPattern = Pattern.compile(filter.value);
+            }
+
+            Class<? extends TextProperty> propertyClass;
+            switch (filter.field) {
+                case SUMMARY:
+                    propertyClass = Summary.class;
+                    break;
+                case COMMENT:
+                    propertyClass = Comment.class;
+                    break;
+                case CONTACT:
+                    propertyClass = Contact.class;
+                    break;
+                case DESCRIPTION:
+                    propertyClass = Description.class;
+                    break;
+                case LOCATION:
+                    propertyClass = Location.class;
+                    break;
+                default:
+                    throw new IllegalArgumentException("Unknown Property to filter for.");
+            }
+
+            List<VEventWPeriod> filteredCandidates = candidates.stream().filter(current -> {
+                List<? extends TextProperty> properties = current.vEvent.getProperties(propertyClass);
+                for (TextProperty prop : properties) {
+                    if (filterPattern.matcher(prop.getValue()).matches()) {
+                        return true;
+                    }
+                }
+                return false;
+            }).collect(Collectors.toList());
+            candidates = filteredCandidates;
+        }
+
+        for (VEventWPeriod eventWPeriod : candidates) {
+            results.add(eventWPeriod.toEvent());
+        }
+
+        Collections.sort(results);
+
+        return results.subList(0, (maximumCount > results.size() ? results.size() : maximumCount));
+    }
+
+    /**
+     * Finds events which begin in the given frame.
+     *
+     * @param frameBegin Begin of the frame where to search events.
+     * @param frameEnd End of the time frame where to search events.
+     * @return All events which begin in the time frame.
+     */
+    private List<VEventWPeriod> getVEventWPeriodsBetween(Instant frameBegin, Instant frameEnd) {
+        final List<VEvent> positiveEvents = new ArrayList<>();
+        final List<VEvent> negativeEvents = new ArrayList<>();
+        classifyEvents(positiveEvents, negativeEvents);
+
+        final List<VEventWPeriod> eventList = new ArrayList<>();
+        for (final VEvent positiveEvent : positiveEvents) {
+            final DateIterator positiveBeginDates = getRecurredEventDateIterator(positiveEvent);
+            positiveBeginDates.advanceTo(Date.from(frameBegin));
+            while (positiveBeginDates.hasNext()) {
+                final Instant begInst = positiveBeginDates.next().toInstant();
+                if (begInst.isAfter(frameEnd)) {
+                    break;
+                }
+                Duration duration = getEventLength(positiveEvent);
+                if (duration == null) {
+                    duration = Duration.ZERO;
+                }
+
+                final VEventWPeriod resultingVEWP = new VEventWPeriod(positiveEvent, begInst, begInst.plus(duration));
+                final Uid eventUid = positiveEvent.getUid();
+                if (eventUid != null) {
+                    if (!isCounteredBy(begInst, eventUid, negativeEvents)) {
+                        eventList.add(resultingVEWP);
+                    }
+                } else {
+                    eventList.add(resultingVEWP);
+                }
+            }
+        }
+
+        return eventList;
+    }
+
     /**
      * Classifies events into positive and negative ones.
      *
@@ -175,7 +281,6 @@ class BiweeklyPresentableCalendar extends AbstractPresentableCalendar {
      */
     private void classifyEvents(Collection<VEvent> positiveEvents, Collection<VEvent> negativeEvents) {
         for (final VEvent currentEvent : usedCalendar.getEvents()) {
-            @Nullable
             final Status eventStatus = currentEvent.getStatus();
             boolean positive = (eventStatus == null || (eventStatus.isTentative() || eventStatus.isConfirmed()));
             final Collection<VEvent> positiveOrNegativeEvents = (positive ? positiveEvents : negativeEvents);
@@ -205,7 +310,6 @@ class BiweeklyPresentableCalendar extends AbstractPresentableCalendar {
                 final Instant startInstant = startDates.next().toInstant();
                 final Instant endInstant = startInstant.plus(duration);
                 if (startInstant.isBefore(instant) && endInstant.isAfter(instant)) {
-                    @Nullable
                     final Uid eventUid = currentEvent.getUid();
                     if (eventUid == null || !isCounteredBy(startInstant, eventUid, negativeEvents)) {
                         return new VEventWPeriod(currentEvent, startInstant, endInstant);
@@ -270,7 +374,6 @@ class BiweeklyPresentableCalendar extends AbstractPresentableCalendar {
      */
     private boolean isCounteredBy(Instant startInstant, Uid eventUid, Collection<VEvent> counterEvents) {
         for (final VEvent counterEvent : counterEvents) {
-            @Nullable
             final Uid counterEventUid = counterEvent.getUid();
             if (counterEventUid != null && eventUid.getValue().contentEquals(counterEventUid.getValue())) {
                 final DateIterator counterStartDates = getRecurredEventDateIterator(counterEvent);
index 0e3863180934c9be44bdcb2c87564ea1e169e97e..0bca3ce4247269246eb766ad2d4087ef04ad3e5a 100644 (file)
@@ -26,7 +26,7 @@ import org.eclipse.jdt.annotation.Nullable;
  * @author Andrew Fiddian-Green - Added support for event description
  */
 @NonNullByDefault
-public class Event {
+public class Event implements Comparable<Event> {
     public final List<CommandTag> commandTags = new ArrayList<CommandTag>();
     public final Instant end;
     public final Instant start;
@@ -50,6 +50,16 @@ public class Event {
         }
     }
 
+    @Override
+    public String toString() {
+        String[] tagStrings = new String[this.commandTags.size()];
+        for (int i = 0; i < tagStrings.length; i++) {
+            tagStrings[i] = this.commandTags.get(i).toString();
+        }
+        return "Event(title: " + this.title + ", start: " + this.start.toString() + ", end: " + this.end.toString()
+                + ", commandTags: List(" + String.join(", ", tagStrings) + ")";
+    }
+
     @Override
     public boolean equals(@Nullable Object other) {
         if (other == null || other.getClass() != this.getClass()) {
@@ -59,4 +69,9 @@ public class Event {
         return (this.title.equals(otherEvent.title) && this.start.equals(otherEvent.start)
                 && this.end.equals(otherEvent.end));
     }
+
+    @Override
+    public int compareTo(Event o) {
+        return start.compareTo(o.start);
+    }
 }
diff --git a/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/logic/EventTextFilter.java b/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/logic/EventTextFilter.java
new file mode 100644 (file)
index 0000000..4a5de97
--- /dev/null
@@ -0,0 +1,46 @@
+/**
+ * Copyright (c) 2010-2020 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.icalendar.internal.logic;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Transport class for a simple text filter.
+ *
+ * @author Michael Wodniok - Initial contribution
+ */
+@NonNullByDefault
+public class EventTextFilter {
+    public static enum Type {
+        TEXT,
+        REGEX
+    }
+
+    public static enum Field {
+        SUMMARY,
+        DESCRIPTION,
+        COMMENT,
+        CONTACT,
+        LOCATION
+    }
+
+    public Field field;
+    public String value;
+    public Type type;
+
+    public EventTextFilter(Field field, String value, Type type) {
+        this.field = field;
+        this.value = value;
+        this.type = type;
+    }
+}
index 7a2ec665e066cff6612c142db974b68738f5dd77..e91507ebb3b49ec2583eec94fced03217e0a0014 100644 (file)
@@ -5,6 +5,8 @@ binding.icalendar.description = Binding zur Nutzung von iCal-Kalendern als Pr
 # thing types
 thing-type.icalendar.calendar.label = Kalender
 thing-type.icalendar.calendar.description = Kalender basierend auf einem lesbaren iCal-Kalender.
+thing-type.icalendar.eventfilter.label = Eintragsfilter
+thing-type.icalendar.eventfilter.description = Gefilterte Events aus dem zugeordneten Kalender.
 
 # thing type config description
 thing-type.config.icalendar.calendar.url.label = URL
@@ -19,6 +21,35 @@ thing-type.config.icalendar.calendar.maxSize.label = Maximale Gr
 thing-type.config.icalendar.calendar.maxSize.description = Es werden nur iCal-Dateien verwendet, die bis zur angegebenen Größe (in Mebibytes) groß sind 
 thing-type.config.icalendar.calendar.authorizationCode.label = Autorisierungs-Code
 thing-type.config.icalendar.calendar.authorizationCode.description = Code zur Autorisierung von Kommandos in Kalendareinträgen
+thing-type.config.icalendar.eventfilter.maxEvents.label = Ergebnis-Maximum
+thing-type.config.icalendar.eventfilter.maxEvents.description = Maximale Anzahl an Ergebnissen dieses Filters
+thing-type.config.icalendar.eventfilter.refreshTime.label = Aktualisierungsintervall
+thing-type.config.icalendar.eventfilter.refreshTime.description = Intervall, in dem die Ergebnisliste aktualisiert wird (Minuten)
+thing-type.config.icalendar.eventfilter.datetimeUnit.label = Zeiteinheit
+thing-type.config.icalendar.eventfilter.datetimeUnit.description = Einheit der Angaben zu Start und Ende
+thing-type.config.icalendar.eventfilter.datetimeUnit.option.MINUTE = Minute
+thing-type.config.icalendar.eventfilter.datetimeUnit.option.HOUR = Stunde
+thing-type.config.icalendar.eventfilter.datetimeUnit.option.DAY = Tag
+thing-type.config.icalendar.eventfilter.datetimeUnit.option.WEEK = Woche
+thing-type.config.icalendar.eventfilter.datetimeStart.label = Start
+thing-type.config.icalendar.eventfilter.datetimeStart.description = Startzeitpunkt relativ zu "jetzt" (inklusiv)
+thing-type.config.icalendar.eventfilter.datetimeEnd.label = Ende
+thing-type.config.icalendar.eventfilter.datetimeEnd.description = Endzeitpunkt relativ zu "jetzt" (exklusiv)
+thing-type.config.icalendar.eventfilter.datetimeRound.label = Abrundung auf Zeiteinheit
+thing-type.config.icalendar.eventfilter.datetimeRound.description = Zeitpunkt sollen auf die Zeiteinheit abgerundet werden (z.B. auf Mitternacht bei Einheit "Tag")
+thing-type.config.icalendar.eventfilter.textEventField.label = Event-Feld
+thing-type.config.icalendar.eventfilter.textEventField.description = Das Feld innerhalb der Ereignis, in dem gefiltert werden soll
+thing-type.config.icalendar.eventfilter.textEventField.option.SUMMARY = Betreff/Titel
+thing-type.config.icalendar.eventfilter.textEventField.option.DESCRIPTION = Bescheibung/Inhalt
+thing-type.config.icalendar.eventfilter.textEventField.option.COMMENT = Kommentar
+thing-type.config.icalendar.eventfilter.textEventField.option.CONTACT = Kontakt
+thing-type.config.icalendar.eventfilter.textEventField.option.LOCATION = Ort
+thing-type.config.icalendar.eventfilter.textEventValue.label = Suchausdruck
+thing-type.config.icalendar.eventfilter.textValueType.label = Typ des Suchausdrucks
+thing-type.config.icalendar.eventfilter.textValueType.description = "Text" prüft, ob der Ausdruck enthalten ist, "Regulärer Ausdruck" prüft, ob der Ausdruck aus den Feldwert im Ganzen zutrifft
+thing-type.config.icalendar.eventfilter.textValueType.option.TEXT = Text
+thing-type.config.icalendar.eventfilter.textValueType.option.REGEX = Regulärer Ausdruck
+
 
 # channel types
 channel-type.icalendar.event_current_title.label = Titel des aktuellen Eintrags
@@ -35,3 +66,11 @@ channel-type.icalendar.event_next_start.label = Start des n
 channel-type.icalendar.event_next_start.description = Start des nächsten Eintrags
 channel-type.icalendar.event_next_end.label = Ende des nächsten Eintrags
 channel-type.icalendar.event_next_end.description = Ende des nächsten Eintrags
+channel-group-type.icalendar.result.label = Ergebnis
+channel-group-type.icalendar.result.description = Ergebnis, gefunden durch den Filter
+channel-type.icalendar.result_start.label = Ergebnisstart
+channel-type.icalendar.result_start.description = Startzeitpunkt des gefundenen Ergebnis'
+channel-type.icalendar.result_end.label = Ergebnisende
+channel-type.icalendar.result_end.description = Endzeitpunkt des gefundenen Ergebnis'
+channel-type.icalendar.result_title.label = Ergebnistitel
+channel-type.icalendar.result_title.description = Titel des gefundenen Ergebnis'
index 7b4e615a7f3605427e844d7b0299a61bdfe7b8b8..bf8cda4fdabfe973fb8bde96ed3c6277c0758320 100644 (file)
@@ -4,7 +4,7 @@
        xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
        xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
 
-       <thing-type id="calendar">
+       <bridge-type id="calendar">
                <label>Calendar</label>
                <description>Calendar based on an iCal calendar.</description>
 
@@ -55,7 +55,7 @@
                        </parameter>
                </config-description>
 
-       </thing-type>
+       </bridge-type>
 
        <channel-type id="event_current_title">
                <item-type>String</item-type>
                <description>End of the next event in calendar</description>
                <state readOnly="true"/>
        </channel-type>
+
+       <channel-type id="result_start">
+               <item-type>DateTime</item-type>
+               <label>Start of Result</label>
+               <description>Start of the found result in calendar</description>
+               <state readOnly="true"/>
+       </channel-type>
+       <channel-type id="result_end">
+               <item-type>DateTime</item-type>
+               <label>End of Result</label>
+               <description>End of the found result in calendar</description>
+               <state readOnly="true"/>
+       </channel-type>
+       <channel-type id="result_title">
+               <item-type>String</item-type>
+               <label>Title of Result</label>
+               <description>Title of the found result in calendar</description>
+               <state readOnly="true"/>
+       </channel-type>
+       <channel-group-type id="result">
+               <label>Result Event</label>
+               <description>A resulting event found by filter</description>
+               <channels>
+                       <channel typeId="result_start" id="begin"/>
+                       <channel typeId="result_end" id="end"/>
+                       <channel typeId="result_title" id="title"/>
+               </channels>
+       </channel-group-type>
+
+
+       <thing-type id="eventfilter">
+               <supported-bridge-type-refs>
+                       <bridge-type-ref id="calendar"/>
+               </supported-bridge-type-refs>
+               <label>Event Filter</label>
+               <description>Filtered Events from the calendar</description>
+
+               <config-description>
+                       <parameter-group name="general">
+                               <label>General Filter Options</label>
+                       </parameter-group>
+                       <parameter-group name="datetime_based">
+                               <label>Date and Time based Filter</label>
+                       </parameter-group>
+                       <parameter-group name="text_based">
+                               <label>Text based Filter</label>
+                       </parameter-group>
+
+                       <parameter name="maxEvents" type="integer" min="0" groupName="general">
+                               <label>Maximum Matches</label>
+                               <required>true</required>
+                       </parameter>
+                       <parameter name="refreshTime" type="integer" min="1" groupName="general" unit="min">
+                               <label>Refresh Time</label>
+                               <description>The frequency in minutes the channels get refreshed</description>
+                               <required>true</required>
+                               <default>15</default>
+                       </parameter>
+                       <parameter name="datetimeUnit" type="text" groupName="datetime_based">
+                               <limitToOptions>true</limitToOptions>
+                               <options>
+                                       <option value="MINUTE">minute</option>
+                                       <option value="HOUR">hour</option>
+                                       <option value="DAY">day</option>
+                                       <option value="WEEK">week</option>
+                               </options>
+                               <default>HOUR</default>
+                               <label>Date or Time Unit for Start and End</label>
+                       </parameter>
+                       <parameter name="datetimeStart" type="integer" groupName="datetime_based">
+                               <label>Start</label>
+                               <description>Start date/time amount to find events relative to "now" (inclusive)</description>
+                       </parameter>
+                       <parameter name="datetimeEnd" type="integer" groupName="datetime_based">
+                               <label>End</label>
+                               <description>End date/time amount to find events relative to "now" (exclusive)</description>
+                       </parameter>
+                       <parameter name="datetimeRound" type="boolean" groupName="datetime_based">
+                               <label>Round to Date/Time unit</label>
+                               <description>Setting this will round start and end date/time to the unit down (e.g. if unit is day: start and end
+                                       will be rounded to 0:00 day time)</description>
+                       </parameter>
+                       <parameter name="textEventField" type="text" groupName="text_based">
+                               <label>Event Field</label>
+                               <description>iCal field to match</description>
+                               <limitToOptions>true</limitToOptions>
+                               <options>
+                                       <option value="SUMMARY">summary/subject</option>
+                                       <option value="DESCRIPTION">description/content</option>
+                                       <option value="COMMENT">comment</option>
+                                       <option value="CONTACT">contact</option>
+                                       <option value="LOCATION">location</option>
+                               </options>
+                       </parameter>
+                       <parameter name="textEventValue" type="text" groupName="text_based">
+                               <label>Event Value</label>
+                       </parameter>
+                       <parameter name="textValueType" type="text" groupName="text_based">
+                               <limitToOptions>true</limitToOptions>
+                               <options>
+                                       <option value="REGEX">Regular Expression</option>
+                                       <option value="TEXT">Text</option>
+                               </options>
+                               <default>TEXT</default>
+                               <label>Value Type</label>
+                               <description>"text" checks the value for containment, "regular expression" matches whole value</description>
+                       </parameter>
+               </config-description>
+       </thing-type>
 </thing:thing-descriptions>
index dead9ac63c01dfcb942f571f9ab37799d6020d72..b7bc27b17056bdd7a6cbace0e158f3cfc403dcf0 100644 (file)
@@ -36,8 +36,8 @@ import org.openhab.core.types.Command;
  * Tests for presentable calendar.
  *
  * @author Michael Wodniok - Initial contribution.
- *
  * @author Andrew Fiddian-Green - Tests for Command Tag code
+ * @author Michael Wodniok - Extended Tests for filtered Events
  *
  */
 public class BiweeklyPresentableCalendarTest {
@@ -542,4 +542,47 @@ public class BiweeklyPresentableCalendarTest {
         assertNotNull(cmd7);
         assertEquals(QuantityType.class, cmd7.getClass());
     }
+
+    @SuppressWarnings("null")
+    @Test
+    public void testGetFilteredEventsBetween() {
+        Event[] expectedFilteredEvents1 = new Event[] {
+                new Event("Test Series in UTC", Instant.parse("2019-09-12T09:05:00Z"),
+                        Instant.parse("2019-09-12T09:10:00Z"), ""),
+                new Event("Test Event in UTC+2", Instant.parse("2019-09-14T08:00:00Z"),
+                        Instant.parse("2019-09-14T09:00:00Z"), "") };
+        List<Event> realFilteredEvents1 = calendar.getFilteredEventsBetween(Instant.parse("2019-09-12T06:00:00Z"),
+                Instant.parse("2019-09-15T06:00:00Z"), null, 3);
+        assertArrayEquals(expectedFilteredEvents1, realFilteredEvents1.toArray(new Event[0]));
+
+        Event[] expectedFilteredEvents2 = new Event[] {
+                new Event("Evt", Instant.parse("2019-11-10T10:00:00Z"), Instant.parse("2019-11-10T11:45:00Z"), ""),
+                new Event("Evt", Instant.parse("2019-11-17T10:00:00Z"), Instant.parse("2019-11-17T11:45:00Z"), ""),
+                new Event("Evt", Instant.parse("2019-12-01T10:00:00Z"), Instant.parse("2019-12-01T11:45:00Z"), "") };
+        List<Event> realFilteredEvents2 = calendar2.getFilteredEventsBetween(Instant.parse("2019-11-08T06:00:00Z"),
+                Instant.parse("2019-12-31T06:00:00Z"), null, 3);
+        assertArrayEquals(expectedFilteredEvents2, realFilteredEvents2.toArray(new Event[] {}));
+
+        Event[] expectedFilteredEvents3 = new Event[] { new Event("Test Event in UTC+2",
+                Instant.parse("2019-09-14T08:00:00Z"), Instant.parse("2019-09-14T09:00:00Z"), "") };
+        List<Event> realFilteredEvents3 = calendar.getFilteredEventsBetween(Instant.parse("2019-09-12T06:00:00Z"),
+                Instant.parse("2019-09-15T06:00:00Z"),
+                new EventTextFilter(EventTextFilter.Field.SUMMARY, "utc+2", EventTextFilter.Type.TEXT), 3);
+        assertArrayEquals(expectedFilteredEvents3, realFilteredEvents3.toArray(new Event[] {}));
+
+        Event[] expectedFilteredEvents4 = new Event[] { new Event("Test Series in UTC",
+                Instant.parse("2019-09-12T09:05:00Z"), Instant.parse("2019-09-12T09:10:00Z"), "") };
+        List<Event> realFilteredEvents4 = calendar.getFilteredEventsBetween(Instant.parse("2019-09-12T06:00:00Z"),
+                Instant.parse("2019-09-15T06:00:00Z"),
+                new EventTextFilter(EventTextFilter.Field.SUMMARY, ".*UTC$", EventTextFilter.Type.REGEX), 3);
+        assertArrayEquals(expectedFilteredEvents4, realFilteredEvents4.toArray(new Event[] {}));
+
+        List<Event> realFilteredEvents5 = calendar.getFilteredEventsBetween(Instant.parse("2019-09-15T06:00:00Z"),
+                Instant.parse("2019-09-12T06:00:00Z"), null, 3);
+        assertEquals(0, realFilteredEvents5.size());
+
+        List<Event> realFilteredEvents6 = calendar.getFilteredEventsBetween(Instant.parse("2019-09-15T06:00:00Z"),
+                Instant.parse("2019-12-31T00:00:00Z"), null, 3);
+        assertEquals(0, realFilteredEvents6.size());
+    }
 }