From 7312890d4410a9655af69f5e97eb3e130efe0425 Mon Sep 17 00:00:00 2001 From: Michael Wodniok Date: Sat, 24 Oct 2020 22:35:07 +0200 Subject: [PATCH] [icalendar] Add EventFilter for existing calendars (#8583) This commit fixes #8022. Signed-off-by: Michael Wodniok --- .../org.openhab.binding.icalendar/README.md | 89 +++- .../internal/ICalendarBindingConstants.java | 18 + .../internal/ICalendarHandlerFactory.java | 31 +- .../config/EventFilterConfiguration.java | 45 ++ .../config/ICalendarConfiguration.java | 13 +- .../handler/ConfigBrokenException.java | 30 ++ .../internal/handler/EventFilterHandler.java | 396 ++++++++++++++++++ .../internal/handler/ICalendarHandler.java | 126 ++++-- .../icalendar/internal/handler/PullJob.java | 4 +- .../logic/AbstractPresentableCalendar.java | 13 + .../logic/BiweeklyPresentableCalendar.java | 111 ++++- .../icalendar/internal/logic/Event.java | 17 +- .../internal/logic/EventTextFilter.java | 46 ++ .../OH-INF/i18n/icalendar_de.properties | 39 ++ .../resources/OH-INF/thing/thing-types.xml | 113 ++++- .../BiweeklyPresentableCalendarTest.java | 45 +- 16 files changed, 1058 insertions(+), 78 deletions(-) create mode 100644 bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/config/EventFilterConfiguration.java create mode 100644 bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/handler/ConfigBrokenException.java create mode 100644 bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/handler/EventFilterHandler.java create mode 100644 bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/logic/EventTextFilter.java diff --git a/bundles/org.openhab.binding.icalendar/README.md b/bundles/org.openhab.binding.icalendar/README.md index b39431b63..0636c5713 100644 --- a/bundles/org.openhab.binding.icalendar/README.md +++ b/bundles/org.openhab.binding.icalendar/README.md @@ -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_#begin` | DateTime | The begin of an event | +| `result_#end` | DateTime | The end of an event | +| `result_#title` | String | The title of an event | + +The scheme replaces `` 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]" { channel="icalendar:calendar:deadbeef:current_title" } -DateTime current_event_until "current until [%1$tT, %1$tY-%1$tm-%1$td]" { channel="icalendar:calendar:deadbeef:current_end" } -String next_event_name "next event [%s]" { channel="icalendar:calendar:deadbeef:next_title" } -DateTime next_event_at "next at [%1$tT, %1$tY-%1$tm-%1$td]" { channel="icalendar:calendar:deadbeef:next_start" } +String current_event_name "current event [%s]" { channel="icalendar:calendar:deadbeef:current_title" } +DateTime current_event_until "current until [%1$tT, %1$tY-%1$tm-%1$td]" { channel="icalendar:calendar:deadbeef:current_end" } +String next_event_name "next event [%s]" { channel="icalendar:calendar:deadbeef:next_title" } +DateTime next_event_at "next at [%1$tT, %1$tY-%1$tm-%1$td]" { channel="icalendar:calendar:deadbeef:next_start" } +String first_event_name_tomorrow "first event [%s]" { channel="icalendar:eventfilter:feedd0d0:event_0#title" } +DateTime first_event_at_tomorrow "first at [%1$tT, %1$tY-%1$tm-%1$td]" { 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). diff --git a/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/ICalendarBindingConstants.java b/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/ICalendarBindingConstants.java index bff3dcd37..df0d0845c 100644 --- a/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/ICalendarBindingConstants.java +++ b/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/ICalendarBindingConstants.java @@ -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"); } diff --git a/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/ICalendarHandlerFactory.java b/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/ICalendarHandlerFactory.java index 7904c12b8..f0e8f6b19 100644 --- a/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/ICalendarHandlerFactory.java +++ b/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/ICalendarHandlerFactory.java @@ -12,17 +12,22 @@ */ 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 SUPPORTED_THING_TYPES_UIDS = Collections.singleton(THING_TYPE_CALENDAR); + private static final Set 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 index 000000000..650a040fc --- /dev/null +++ b/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/config/EventFilterConfiguration.java @@ -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; +} diff --git a/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/config/ICalendarConfiguration.java b/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/config/ICalendarConfiguration.java index b90620011..948c9e5cc 100644 --- a/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/config/ICalendarConfiguration.java +++ b/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/config/ICalendarConfiguration.java @@ -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 index 000000000..62bda2722 --- /dev/null +++ b/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/handler/ConfigBrokenException.java @@ -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 index 000000000..ac108ec08 --- /dev/null +++ b/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/handler/EventFilterHandler.java @@ -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 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 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 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); + } +} diff --git a/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/handler/ICalendarHandler.java b/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/handler/ICalendarHandler.java index 5f943544e..deb7c4add 100644 --- a/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/handler/ICalendarHandler.java +++ b/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/handler/ICalendarHandler.java @@ -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 (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."); + 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."); } - 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); + + 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); + 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); + } + } 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 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); diff --git a/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/handler/PullJob.java b/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/handler/PullJob.java index b77d39810..36c26c442 100644 --- a/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/handler/PullJob.java +++ b/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/handler/PullJob.java @@ -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); } diff --git a/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/logic/AbstractPresentableCalendar.java b/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/logic/AbstractPresentableCalendar.java index 121ea70ec..4f394c91b 100644 --- a/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/logic/AbstractPresentableCalendar.java +++ b/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/logic/AbstractPresentableCalendar.java @@ -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 getFilteredEventsBetween(Instant begin, Instant end, @Nullable EventTextFilter filter, + int maximumCount); } diff --git a/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/logic/BiweeklyPresentableCalendar.java b/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/logic/BiweeklyPresentableCalendar.java index 895d2a48b..4e107b508 100644 --- a/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/logic/BiweeklyPresentableCalendar.java +++ b/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/logic/BiweeklyPresentableCalendar.java @@ -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 getFilteredEventsBetween(Instant begin, Instant end, @Nullable EventTextFilter filter, + int maximumCount) { + List candidates = this.getVEventWPeriodsBetween(begin, end); + final List 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 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 filteredCandidates = candidates.stream().filter(current -> { + List 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 getVEventWPeriodsBetween(Instant frameBegin, Instant frameEnd) { + final List positiveEvents = new ArrayList<>(); + final List negativeEvents = new ArrayList<>(); + classifyEvents(positiveEvents, negativeEvents); + + final List 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 positiveEvents, Collection negativeEvents) { for (final VEvent currentEvent : usedCalendar.getEvents()) { - @Nullable final Status eventStatus = currentEvent.getStatus(); boolean positive = (eventStatus == null || (eventStatus.isTentative() || eventStatus.isConfirmed())); final Collection 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 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); diff --git a/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/logic/Event.java b/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/logic/Event.java index 0e3863180..0bca3ce42 100644 --- a/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/logic/Event.java +++ b/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/logic/Event.java @@ -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 { public final List commandTags = new ArrayList(); 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 index 000000000..4a5de97dd --- /dev/null +++ b/bundles/org.openhab.binding.icalendar/src/main/java/org/openhab/binding/icalendar/internal/logic/EventTextFilter.java @@ -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; + } +} diff --git a/bundles/org.openhab.binding.icalendar/src/main/resources/OH-INF/i18n/icalendar_de.properties b/bundles/org.openhab.binding.icalendar/src/main/resources/OH-INF/i18n/icalendar_de.properties index 7a2ec665e..e91507ebb 100644 --- a/bundles/org.openhab.binding.icalendar/src/main/resources/OH-INF/i18n/icalendar_de.properties +++ b/bundles/org.openhab.binding.icalendar/src/main/resources/OH-INF/i18n/icalendar_de.properties @@ -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' diff --git a/bundles/org.openhab.binding.icalendar/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.icalendar/src/main/resources/OH-INF/thing/thing-types.xml index 7b4e615a7..bf8cda4fd 100644 --- a/bundles/org.openhab.binding.icalendar/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.icalendar/src/main/resources/OH-INF/thing/thing-types.xml @@ -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"> - + Calendar based on an iCal calendar. @@ -55,7 +55,7 @@ - + String @@ -99,4 +99,113 @@ End of the next event in calendar + + + DateTime + + Start of the found result in calendar + + + + DateTime + + End of the found result in calendar + + + + String + + Title of the found result in calendar + + + + + A resulting event found by filter + + + + + + + + + + + + + + Filtered Events from the calendar + + + + + + + + + + + + + + + true + + + + The frequency in minutes the channels get refreshed + true + 15 + + + true + + + + + + + HOUR + + + + + Start date/time amount to find events relative to "now" (inclusive) + + + + End date/time amount to find events relative to "now" (exclusive) + + + + 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) + + + + iCal field to match + true + + + + + + + + + + + + + true + + + + + TEXT + + "text" checks the value for containment, "regular expression" matches whole value + + + diff --git a/bundles/org.openhab.binding.icalendar/src/test/java/org/openhab/binding/icalendar/internal/logic/BiweeklyPresentableCalendarTest.java b/bundles/org.openhab.binding.icalendar/src/test/java/org/openhab/binding/icalendar/internal/logic/BiweeklyPresentableCalendarTest.java index dead9ac63..b7bc27b17 100644 --- a/bundles/org.openhab.binding.icalendar/src/test/java/org/openhab/binding/icalendar/internal/logic/BiweeklyPresentableCalendarTest.java +++ b/bundles/org.openhab.binding.icalendar/src/test/java/org/openhab/binding/icalendar/internal/logic/BiweeklyPresentableCalendarTest.java @@ -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 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 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 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 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 realFilteredEvents5 = calendar.getFilteredEventsBetween(Instant.parse("2019-09-15T06:00:00Z"), + Instant.parse("2019-09-12T06:00:00Z"), null, 3); + assertEquals(0, realFilteredEvents5.size()); + + List realFilteredEvents6 = calendar.getFilteredEventsBetween(Instant.parse("2019-09-15T06:00:00Z"), + Instant.parse("2019-12-31T00:00:00Z"), null, 3); + assertEquals(0, realFilteredEvents6.size()); + } }