This commit fixes #8022.
Signed-off-by: Michael Wodniok <michi@noorganization.org>
## 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 |
| `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
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:
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
+ }
}
```
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).
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
// 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";
// 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");
}
*/
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;
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
*
* @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
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;
}
}
--- /dev/null
+/**
+ * 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;
+}
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;
}
--- /dev/null
+/**
+ * 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);
+ }
+}
--- /dev/null
+/**
+ * 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);
+ }
+}
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;
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;
* @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
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());
}
}
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;
} 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);
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);
*/
@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;
@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);
}
*
* @author Michael Wodniok - Initial contribution
* @author Andrew Fiddian-Green - Methods getJustBegunEvents() & getJustEndedEvents()
+ * @author Michael Wodniok - Added getFilteredEventsBetween()
*/
@NonNullByDefault
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);
}
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;
*
* @author Michael Wodniok - Initial contribution
* @author Andrew Fiddian-Green - Methods getJustBegunEvents() & getJustEndedEvents()
+ * @author Michael Wodniok - Extension for filtered events
*/
@NonNullByDefault
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)));
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.
*
*/
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);
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);
*/
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);
* @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;
}
}
+ @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()) {
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);
+ }
}
--- /dev/null
+/**
+ * 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;
+ }
+}
# 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
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
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'
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>
</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>
* 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 {
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());
+ }
}