/**
* Time namespace.
* This namespace exports the {@link https://js-joda.github.io/js-joda/ JS-Joda library}, but also provides additional functionality.
*
* @namespace time
*/
/**
* @typedef { import("./items/items").Item } items.Item
* @private
*/
// reduce timezone file size, see https://github.com/js-joda/js-joda/blob/main/packages/timezone/README.md#reducing-js-joda-timezone-file-size
require('@js-joda/timezone/dist/js-joda-timezone-10-year-range');
const time = require('@js-joda/core');
const log = require('./log')('time');
const osgi = require('./osgi');
const { _isItem, _isZonedDateTime, _isDuration, _isQuantity } = require('./helpers');
const javaZDT = Java.type('java.time.ZonedDateTime');
const javaDuration = Java.type('java.time.Duration');
const javaString = Java.type('java.lang.String');
const javaNumber = Java.type('java.lang.Number');
const ohItem = Java.type('org.openhab.core.items.Item');
const { DateTimeType, DecimalType, StringType, QuantityType } = require('@runtime');
const timeZoneProvider = osgi.getService('org.openhab.core.i18n.TimeZoneProvider');
// Set the system default timezone to the user-configured timezone
// Fixes issues such as https://github.com/openhab/openhab-js/issues/326
time.ZoneId.systemDefault = function () {
return time.ZoneId.of(timeZoneProvider.getTimeZone().getId().toString());
};
// openHAB uses an RFC DateTime string, js-joda defaults to the ISO version, this defaults to RFC instead
const rfcFormatter = time.DateTimeFormatter.ofPattern("uuuu-MM-dd'T'HH:mm:ss.SSS[xxxx][xxxxx]");
const targetParse = time.ZonedDateTime.prototype.parse;
time.ZonedDateTime.prototype.parse = function (text, formatter = rfcFormatter) {
return targetParse(text, formatter);
};
/**
* Adds millis to the passed in ZDT as milliseconds. The millis is rounded first.
* If millis is negative they will be subtracted.
* @private
* @param {number|bigint} millis number of milliseconds to add
*/
function _addMillisToNow (millis) {
return time.ZonedDateTime.now().plus(Math.round(millis), time.ChronoUnit.MILLIS);
}
/**
* Adds the passed in QuantityType<Time> to now.
* @private
* @param {QuantityType} quantityType an Item's QuantityType
* @returns now plus the time length in the quantityType
* @throws error when the units for the quantity type are not one of the Time units
*/
function _addQuantityType (quantityType) {
const secs = quantityType.toUnit('s');
if (secs) {
return time.ZonedDateTime.now().plusSeconds(secs.doubleValue());
} else {
throw Error('Only Time units are supported to convert QuantityTypes to a ZonedDateTime: ' + quantityType.toString());
}
}
/**
* Tests the string to see if it matches a 24-hour clock time like `hh:mm`, `hh:mm:ss`, `h:mm`, `h:mm:ss`
* @private
* @param {string} dtStr potential 24-hour time String
* @returns {boolean} true if it matches HH:MM
*/
function _is24Hr (dtStr) {
const regex = /^(0?[0-9]|1[0-9]|2[0-3])(:[0-5][0-9]){1,2}$/;
return regex.test(dtStr);
}
/**
* Tests the string to see if it matches a 12 hour clock time
* @private
* @param {string} dtStr potential hh:MM aa string
* @returns {boolean} true if it matches hh:mm aa
*/
function _is12Hr (dtStr) {
const regex = /^(0?[0-9]|1[0-2])(:[0-5][0-9]){1,2} ?[a|p|A|P]\.?[m|M]\.?$/;
return regex.test(dtStr);
}
/**
* Parses a string that conforms the ISO8601 standard to a {@link time.ZonedDateTime}.
* The following ISO strings are supported:
* - for date: `YYYY-MM-DD`
* - for time: `hh:mm`, `hh:mm:ss`, `hh:mm:ss.f`
* - a combination of these date and time formats
* - full ISO8601: date format + `T` + any time format + offset (e.g. `Z` for UTC or `+01:00` and some other notations)
*
* @private
* @param isoStr
* @returns {time.ZonedDateTime|null} {@link time.ZonedDateTime} if parsing was successful, else `null`
* @throws `JsJodaException` thrown by the {@link https://js-joda.github.io/js-joda/ JS-Joda library} that signals that string could not be parsed
*/
function _parseISO8601 (isoStr) {
// Compatibility with Zone offsets without the ":" -> +HHmm or -HHmm
function replacer (match, p1, p2, p3) {
return p1 + p2 + ':' + p3;
}
isoStr = isoStr.replace(/([+|-])(\d{2})(\d{2})/, replacer);
const REGEX = {
LOCAL_DATE: /^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])$/, // YYYY-MM-DD
LOCAL_TIME: /^\d{2}:\d{2}(:\d{2})?(\.\d+)?$/, // hh:mm or hh:mm:ss or hh:mm:ss.f
LOCAL_DATE_TIME: /^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T\d{2}:\d{2}(:\d{2})?(\.\d+)?$/, // LOCAL_DATE and LOCAL_TIME connected with "T"
ISO_8601_FULL: /^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):([0-5][0-9])(:[0-5][0-9])?(\.\d+)?(Z|[+-]\d{2}(:\d{2})?\[.*\])/, // with Zone ID
ISO_8601_OFFSET: /^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9])T(2[0-3]|[01][0-9]):([0-5][0-9])(:[0-5][0-9])?(\.\d+)?(Z|[+-]\d{2}(:\d{2})$)/ // offset only
};
switch (true) {
case REGEX.LOCAL_DATE.test(isoStr): return time.ZonedDateTime.of(time.LocalDate.parse(isoStr), time.LocalTime.MIDNIGHT, time.ZoneId.systemDefault());
case REGEX.LOCAL_TIME.test(isoStr): return time.ZonedDateTime.of(time.LocalDate.now(), time.LocalTime.parse(isoStr), time.ZoneId.systemDefault());
case REGEX.LOCAL_DATE_TIME.test(isoStr): return time.ZonedDateTime.of(time.LocalDateTime.parse(isoStr), time.ZoneId.systemDefault());
case REGEX.ISO_8601_FULL.test(isoStr): return time.ZonedDateTime.parse(isoStr);
case REGEX.ISO_8601_OFFSET.test(isoStr): return time.ZonedDateTime.parse(isoStr).withZoneSameLocal(time.ZoneId.systemDefault());
}
return null;
}
/**
* Parses the passed in string based on it's format and converts it to a ZonedDateTime.
* If no timezone is specified, the configured timezone is used.
* @private
* @param {string} str string to parse and convert
* @returns {time.ZonedDateTime}
* @throws Error when the string cannot be parsed
*/
function _parseString (str) {
// 12 hour time string
if (_is12Hr(str)) {
const parts = str.split(':');
let hr = parseInt(parts[0]);
hr = (str.contains('p') || str.contains('P')) ? hr + 12 : hr;
return time.ZonedDateTime.now().withHour(hr)
.withMinute(parseInt(parts[1])) // parseInt will ignore the am/pm
.withSecond(parseInt(parts[2]) || 0)
.withNano(0);
}
// 24-hour time string
// This could also be handled by ISO8601, but h:mm string like 0:30 require this code here!
if (_is24Hr(str)) {
const parts = str.split(':');
return time.ZonedDateTime.now().withHour(parts[0])
.withMinute(parts[1])
.withSecond(parts[2] || 0)
.withNano(0);
}
// ISO8601 Time, Date, or DateTime string
try {
// Blockly compatibility with user input without the "T"
const newStr = /^(-?(?:[1-9][0-9]*)?[0-9]{4})-(1[0-2]|0[1-9])-(3[01]|0[1-9]|[12][0-9]) \d{2}:\d{2}/.test(str) ? str.replace(' ', 'T') : str;
const zdt = _parseISO8601(newStr);
if (zdt !== null) return zdt;
} catch (e) {
throw Error(`Failed to parse ISO8601 string ${str}: ${e}`);
}
// Possibly ISO8601 Duration string
// TODO: Further improvements here
try {
return time.ZonedDateTime.now().plus(time.Duration.parse(str));
} catch (e) { // Unsupported
throw Error(`Failed to parse string ${str}: ${e}`);
}
}
/**
* Converts the state of the passed in Item to a time.ZonedDateTime
* @private
* @param {HostState} rawState
* @returns {time.ZonedDateTime}
* @throws error if the Item's state is not supported or the Item itself is not supported
*/
function _convertItemRawState (rawState) {
if (rawState instanceof DecimalType) { // Number type Items
return _addMillisToNow(rawState.floatValue());
} else if (rawState instanceof StringType) { // String type Items
return _parseString(rawState.toString());
} else if (rawState instanceof DateTimeType) { // DateTime Items
return javaZDTToJsZDT(rawState.getZonedDateTime());
} else if (rawState instanceof QuantityType) { // Number:Time type Items
return _addQuantityType(rawState);
} else {
throw Error(rawState.toString() + ' is not supported for conversion to time.ZonedDateTime');
}
}
/**
* Convert Java Instant to JS-Joda Instant.
*
* @memberOf time
* @param {JavaInstant} instant {@link https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/Instant.html java.time.Instant}
* @returns {time.Instant} {@link https://js-joda.github.io/js-joda/class/packages/core/src/Instant.js~Instant.html JS-Joda Instant}
*/
function javaInstantToJsInstant (instant) {
return time.Instant.ofEpochMilli(instant.toEpochMilli());
}
/**
* Convert Java ZonedDateTime to JS-Joda ZonedDateTime.
*
* @memberOf time
* @param {JavaZonedDateTime} zdt {@link https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/time/ZonedDateTime.html java.time.ZonedDateTime}
* @returns {time.ZonedDateTime} {@link https://js-joda.github.io/js-joda/class/packages/core/src/ZonedDateTime.js~ZonedDateTime.html JS-Joda ZonedDateTime}
*/
function javaZDTToJsZDT (zdt) {
const epoch = zdt.toInstant().toEpochMilli();
const instant = time.Instant.ofEpochMilli(epoch);
const zone = time.ZoneId.of(zdt.getZone().toString());
return time.ZonedDateTime.ofInstant(instant, zone);
}
/**
* Converts the passed in when to a time.ZonedDateTime based on the following
* set of rules.
*
* - null, undefined: time.ZonedDateTime.now()
* - time.ZonedDateTime: unmodified
* - Java ZonedDateTime, DateTimeType: converted to time.ZonedDateTime equivalent
* - JavaScript native {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date Date}: converted to a `time.ZonedDateTime` using configured timezone
* - number, bigint, Java Number, DecimalType: rounded and added to `time.ZonedDateTime.now()` as milliseconds
* - {@link Quantity} & QuantityType: if the unit is time-compatible, added to `time.ZonedDateTime.now()`
* - Item: converts the state of the Item based on the *Type rules described here
* - String, Java String, StringType: parsed based on the following rules; if no timezone is specified the configured timezone is used
* - ISO 8601 DateTime: any Date, Time or DateTime with optional time offset and/or time zone in the {@link https://en.wikipedia.org/wiki/ISO_8601 ISO8601 calendar system}
* - ISO 8601 Duration: any Duration in the {@link https://en.wikipedia.org/wiki/ISO_8601#Durations ISO8601 calendar system} (e.g. 'PT5H4M3.210S'), also see {@link https://js-joda.github.io/js-joda/class/packages/core/src/Duration.js~Duration.html#static-method-parse JS-Joda : Duration}
* - RFC (output from a Java ZonedDateTime.toString()): parsed to time.ZonedDateTime
* - HH:mm[:ss] (i.e. 24 hour time): that time with today's date (seconds are optional)
* - KK:mm[:ss][ ][aa] (i.e. 12 hour time): that time with today's date (seconds and space between time and am/pm are optional)
* @memberof time
* @param {*} [when] any of the types discussed above
* @returns {time.ZonedDateTime}
* @throws error if the type, format, or contents of when are not supported
*/
function toZDT (when) {
// If when is not supplied or null, return now
if (when === undefined || when === null) {
log.debug('toZDT: Returning ZonedDateTime.now()');
return time.ZonedDateTime.now();
}
// Pass through if already a time.ZonedDateTime
if (_isZonedDateTime(when)) {
log.debug('toZDT: Passing trough ' + when);
return when;
}
// Convert Java ZonedDateTime
if (when instanceof javaZDT) {
log.debug('toZTD: Converting Java ZonedDateTime ' + when.toString());
return javaZDTToJsZDT(when);
}
// String or StringType
if (typeof when === 'string' || when instanceof javaString || when instanceof StringType) {
log.debug('toZDT: Parsing string ' + when);
return _parseString(when.toString());
}
// JavaScript Native Date, use the configured timezone
if (when instanceof Date) {
log.debug('toZDT: Converting JS native Date ' + when);
const native = time.nativeJs(when);
const instant = time.Instant.from(native);
return time.ZonedDateTime.ofInstant(instant, time.ZoneId.systemDefault());
}
// Duration, add to now
if (_isDuration(when) || when instanceof javaDuration) {
log.debug('toZDT: Adding duration ' + when + ' to now');
return time.ZonedDateTime.now().plus(time.Duration.parse(when.toString()));
}
// Add JavaScript's number or JavaScript BigInt or Java Number or Java DecimalType as milliseconds to now
if (typeof when === 'number' || typeof when === 'bigint') {
log.debug('toZDT: Adding milliseconds ' + when + ' to now');
return _addMillisToNow(when);
} else if (when instanceof javaNumber || when instanceof DecimalType) {
log.debug('toZDT: Adding Java number or DecimalType ' + when.floatValue() + ' to now');
return _addMillisToNow(when.floatValue());
}
// DateTimeType, extract the javaZDT and convert to time.ZDT
if (when instanceof DateTimeType) {
log.debug('toZTD: Converting DateTimeType ' + when);
return javaZDTToJsZDT(when.getZonedDateTime());
}
// Add Quantity or QuantityType<Time> to now
if (_isQuantity(when)) {
log.debug('toZDT: Adding Quantity ' + when + ' to now');
return _addQuantityType(when.rawQtyType);
} else if (when instanceof QuantityType) {
log.debug('toZDT: Adding QuantityType ' + when + ' to now');
return _addQuantityType(when);
}
// Convert items.Item or raw Item
if (_isItem(when)) {
log.debug('toZDT: Converting Item ' + when);
if (when.isUninitialized) {
throw Error('Item ' + when.name + ' is NULL or UNDEF, cannot convert to a time.ZonedDateTime');
}
return _convertItemRawState(when.rawState);
} else if (when instanceof ohItem) {
log.debug('toZDT: Converting raw Item ' + when);
return _convertItemRawState(when.getState());
}
// Unsupported
throw Error('"' + when + '" is an unsupported type for conversion to time.ZonedDateTime');
}
/**
* Moves the date portion of the date time to today, accounting for DST
*
* @returns {time.ZonedDateTime} a new {@link time.ZonedDateTime} with today's date
*/
time.ZonedDateTime.prototype.toToday = function () {
const now = time.ZonedDateTime.now();
return this.withYear(now.year())
.withMonth(now.month())
.withDayOfMonth(now.dayOfMonth());
};
/**
* Tests whether `this` time.ZonedDateTime is before the passed in timestamp.
* However, the function only compares the time portion of both, ignoring the date portion.
*
* @param {*} timestamp Time for comparison, anything supported by {@link time.toZDT}
* @returns {boolean} true if `this` is before timestamp
*/
time.ZonedDateTime.prototype.isBeforeTime = function (timestamp) {
const comparisonTime = toZDT(timestamp).toLocalTime();
const currTime = this.toLocalTime();
return currTime.isBefore(comparisonTime);
};
/**
* Tests whether `this` time.ZonedDateTime is after the passed in timestamp.
* However, the function only compares the time portion of both, ignoring the date portion.
*
* @param {*} timestamp Time for comparison, anything supported by {@link time.toZDT}
* @returns {boolean} true if `this` is after timestamp
*/
time.ZonedDateTime.prototype.isAfterTime = function (timestamp) {
const comparisonTime = toZDT(timestamp).toLocalTime();
const currTime = this.toLocalTime();
return currTime.isAfter(comparisonTime);
};
/**
* Tests whether `this` time.ZonedDateTime is between the passed in start and end.
* However, the function only compares the time portion of the three, ignoring the date portion.
* The function takes into account times that span midnight.
*
* @param {*} start starting time, anything supported by {@link time.toZDT}
* @param {*} end ending time, anything supported by {@link time.toZDT}
* @returns {boolean} true if `this` is between start and end
*/
time.ZonedDateTime.prototype.isBetweenTimes = function (start, end) {
const startTime = toZDT(start).toLocalTime();
const endTime = toZDT(end).toLocalTime();
const currTime = this.toLocalTime();
if (endTime.isBefore(startTime)) { // Time range spans midnight
return currTime.isAfter(startTime) || currTime.isBefore(endTime);
} else {
return currTime.isAfter(startTime) && currTime.isBefore(endTime);
}
};
/**
* Tests whether `this` time.ZonedDateTime is before the passed in timestamp.
* However, the function only compares the date portion of both, ignoring the time portion.
*
* @param {*} timestamp Time for comparison, anything supported by {@link time.toZDT}
* @returns {boolean} true if `this` is before timestamp
*/
time.ZonedDateTime.prototype.isBeforeDate = function (timestamp) {
const comparisonDate = toZDT(timestamp).toLocalDate();
const currDate = this.toLocalDate();
return currDate.isBefore(comparisonDate);
};
/**
* Tests whether `this` time.ZonedDateTime is after the passed in timestamp.
* However, the function only compares the date portion of both, ignoring the time portion.
*
* @param {*} timestamp Time for comparison, anything supported by {@link time.toZDT}
* @returns {boolean} true if `this` is after timestamp
*/
time.ZonedDateTime.prototype.isAfterDate = function (timestamp) {
const comparisonDate = toZDT(timestamp).toLocalDate();
const currDate = this.toLocalDate();
return currDate.isAfter(comparisonDate);
};
/**
* Tests whether `this` time.ZonedDateTime is between the passed in start and end.
* However, the function only compares the date portion of the three, ignoring the time portion.
*
* @param {*} start starting date, anything supported by {@link time.toZDT}
* @param {*} end ending date, anything supported by {@link time.toZDT}
* @returns {boolean} true if `this` is between start and end
*/
time.ZonedDateTime.prototype.isBetweenDates = function (start, end) {
const startDate = toZDT(start).toLocalDate();
const endDate = toZDT(end).toLocalDate();
const currDate = this.toLocalDate();
return currDate.isAfter(startDate) && currDate.isBefore(endDate);
};
/**
* Tests whether `this` time.ZonedDateTime is before the passed in timestamp.
*
* @param {*} timestamp Time for comparison, anything supported by {@link time.toZDT}
* @returns {boolean} true if `this` is before timestamp
*/
time.ZonedDateTime.prototype.isBeforeDateTime = function (timestamp) {
const comparisonDateTime = toZDT(timestamp).toLocalDateTime();
const currDateTime = this.toLocalDateTime();
return currDateTime.isBefore(comparisonDateTime);
};
/**
* Tests whether `this` time.ZonedDateTime is after the passed in timestamp.
*
* @param {*} timestamp Time for comparison, anything supported by {@link time.toZDT}
* @returns {boolean} true if `this` is after timestamp
*/
time.ZonedDateTime.prototype.isAfterDateTime = function (timestamp) {
const comparisonDateTime = toZDT(timestamp).toLocalDateTime();
const currDateTime = this.toLocalDateTime();
return currDateTime.isAfter(comparisonDateTime);
};
/**
* Tests whether `this` time.ZonedDateTime is between the passed in start and end.
*
* @param {*} start starting DateTime, anything supported by {@link time.toZDT}
* @param {*} end ending DateTime, anything supported by {@link time.toZDT}
* @returns {boolean} true if `this` is between start and end
*/
time.ZonedDateTime.prototype.isBetweenDateTimes = function (start, end) {
const startDateTime = toZDT(start).toLocalDateTime();
const endDateTime = toZDT(end).toLocalDateTime();
const currDateTime = this.toLocalDateTime();
return currDateTime.isAfter(startDateTime) && currDateTime.isBefore(endDateTime);
};
/**
* Tests to see if the difference between this and the passed in ZoneDateTime is
* within the passed in maxDur.
*
* @param {time.ZonedDateTime} zdt the date time to compare to this
* @param {time.Duration} maxDur the duration to test that the difference between this and zdt is within
* @returns {boolean} true if the delta between this and zdt is within maxDur
*/
time.ZonedDateTime.prototype.isClose = function (zdt, maxDur) {
const delta = time.Duration.between(this, zdt).abs();
return delta.compareTo(maxDur) <= 0;
};
/**
* Parses a ZonedDateTime to milliseconds from now until the ZonedDateTime.
*
* @returns {number} duration from now to the ZonedDateTime in milliseconds
*/
time.ZonedDateTime.prototype.getMillisFromNow = function () {
return time.Duration.between(time.ZonedDateTime.now(), this).toMillis();
};
/**
* Stringifies this ZonedDateTime to a format that openHAB accepts for commands and state updates.
* openHAB doesn't accept the zone name that is in square brackets, e.g. `[Europe/Berlin]`, so remove it here.
* The zone information is also present in the offset, e.g. `+01:00`, so we don't need the time zone string.
*
* @returns {string}
*/
time.ZonedDateTime.prototype.toOpenHabString = function () {
return this.toString().replace(/\[[^\]]*]$/, '');
};
module.exports = {
...time,
javaInstantToJsInstant,
javaZDTToJsZDT,
toZDT,
_parseString,
_parseISO8601
};