@cgjgh/node-red-dashboard-2-ui-scheduler
Version:
A UI scheduler node that integrates with Node-RED Dashboard 2.0
1,207 lines (1,096 loc) • 287 kB
JavaScript
const version = '3.3.7'
const packageName = '@cgjgh/node-red-dashboard-2-ui-scheduler'
/* eslint-disable no-unused-vars */
/* eslint-disable no-case-declarations */
/* eslint-disable no-console */
const { exec } = require('child_process')
const fs = require('fs')
const path = require('path')
const { TZDate } = require('@date-fns/tz')
const coordParser = require('coord-parser')
const cronosjs = require('cronosjs-extended')
const cronstrue = require('cronstrue/i18n')
const { addMinutes, addHours, startOfYear } = require('date-fns')
const { createMs } = require('enhanced-ms')
const analytics = require('node-debug-analytics')
const semver = require('semver')
const SunCalc = require('suncalc2')
SunCalc.addTime(-18, 'nightEnd', 'nightStart')
SunCalc.addTime(-6, 'civilDawn', 'civilDusk')
SunCalc.addTime(6, 'morningGoldenHourEnd', 'eveningGoldenHourStart')
const availableLocales = ['en', 'de', 'fr', 'it', 'nl', 'es', 'pl', 'cs', 'zh-CN']
/**
* Sets the locale for the given configuration object.
*
* If the current locale in the configuration is null or not in the list of
* available locales, it attempts to set the locale to the provided settingsLang
* if it is valid. Defaults to 'en' if no valid locale is found.
*
* @param {Object} config - The configuration object containing the locale setting.
* @param {string} settingsLang - The preferred language setting to be applied.
* @returns {string} - The finalized locale setting.
*/
function setLocale (config, settingsLang) {
if (config.locale === null || !availableLocales.includes(config.locale)) {
config.locale = (settingsLang && availableLocales.includes(settingsLang))
? settingsLang
: 'en'
}
return config.locale
}
// globally accesible milliseconds formatter
let enhancedMs
function initializeMs (language) {
enhancedMs = createMs({ language })
}
// Solar events
const solarEvents = [
{ title: 'Night End', value: 'nightEnd' },
{ title: 'Nautical Dawn', value: 'nauticalDawn' },
{ title: 'Civil Dawn', value: 'civilDawn' },
{ title: 'Sunrise', value: 'sunrise' },
{ title: 'Sunrise End', value: 'sunriseEnd' },
{ title: 'Morning Golden Hour End', value: 'morningGoldenHourEnd' },
{ title: 'Solar Noon', value: 'solarNoon' },
{ title: 'Evening Golden Hour Start', value: 'eveningGoldenHourStart' },
{ title: 'Sunset Start', value: 'sunsetStart' },
{ title: 'Sunset', value: 'sunset' },
{ title: 'Civil Dusk', value: 'civilDusk' },
{ title: 'Nautical Dusk', value: 'nauticalDusk' },
{ title: 'Night Start', value: 'nightStart' },
{ title: 'Nadir', value: 'nadir' }
]
const PERMITTED_SOLAR_EVENTS = solarEvents.map(event => event.value)
const daysOfWeek = [
{ title: 'Sunday', value: 'sunday', short: 'Sun' },
{ title: 'Monday', value: 'monday', short: 'Mon' },
{ title: 'Tuesday', value: 'tuesday', short: 'Tue' },
{ title: 'Wednesday', value: 'wednesday', short: 'Wed' },
{ title: 'Thursday', value: 'thursday', short: 'Thu' },
{ title: 'Friday', value: 'friday', short: 'Fri' },
{ title: 'Saturday', value: 'saturday', short: 'Sat' }
]
const allDaysOfWeek = daysOfWeek.map(day => day.value)
const months = [
{ title: 'January', value: 'january' },
{ title: 'February', value: 'february' },
{ title: 'March', value: 'march' },
{ title: 'April', value: 'april' },
{ title: 'May', value: 'may' },
{ title: 'June', value: 'june' },
{ title: 'July', value: 'july' },
{ title: 'August', value: 'august' },
{ title: 'September', value: 'september' },
{ title: 'October', value: 'october' },
{ title: 'November', value: 'november' },
{ title: 'December', value: 'december' }
]
const allMonths = months.map(month => month.value)
function getMaxDaysInMonth (monthName) {
const month = months.indexOf(monthName) + 1
if (month === 0) {
return 0
}
return month === 2 ? 29 : new Date(2024, month, 0).getDate()
}
/**
* Abbreviates the day names in a given description string.
*
* This function replaces full day names (e.g., "Monday") in the input
* description with their corresponding short forms (e.g., "Mon") using
* a predefined mapping of days of the week.
*
* @param {string} description - The input string containing full day names.
* @returns {string} - The modified string with abbreviated day names.
*/
const abbreviateDays = (description) => {
if (!description) return ''
// Create a mapping from title to short
const dayMapping = daysOfWeek.reduce((acc, day) => {
acc[day.title.toLowerCase()] = day.short // Store lowercase keys
return acc
}, {})
return description.replace(
new RegExp(`\\b(${daysOfWeek.map(day => day.title).join('|')})\\b`, 'gi'), // Add 'i' flag for case-insensitivity
(day) => dayMapping[day.toLowerCase()] || day // Normalize day to lowercase
)
}
// localized strings
let futureTemplate = 'in {time}'
let pastTemplate = '{time} ago'
let never = 'Never'
let lessThanASecond = 'Less than a second'
/**
* Converts a given time in milliseconds to a human-readable format indicating
* how long ago the time was. If the input is not provided, defaults to 0.
* Utilizes the `enhancedMs` function to format the milliseconds and replaces
* any negative signs with an empty string. The formatted time is then inserted
* into the `pastTemplate` string.
*
* @param {number} ms - The time in milliseconds to be converted.
* @returns {string} A string representing the time in a human-readable format.
*/
function pastMs (ms) {
if (!ms) ms = 0
let formatted
if (ms < 1000) {
formatted = lessThanASecond
} else {
// Otherwise, format normally
formatted = enhancedMs(ms)
}
if (formatted && formatted.indexOf('-') >= 0) {
formatted = formatted.replace(/-/g, '')
}
return pastTemplate.replace('{time}', formatted)
}
/**
* Generates a future time string by replacing the placeholder in the futureTemplate
* with the enhanced milliseconds value.
*
* @param {number} ms - The number of milliseconds to be enhanced and formatted.
* @returns {string} A formatted string indicating the future time.
*/
function futureMs (ms) {
if (!ms) ms = 0
let formatted
if (ms < 1000) {
formatted = lessThanASecond
} else {
// Otherwise, format normally
formatted = enhancedMs(ms)
}
if (formatted && formatted.indexOf('-') >= 0) {
formatted = formatted.replace(/-/g, '')
}
return futureTemplate.replace('{time}', formatted)
}
// accepted commands using topic as the command & (in compatible cases, the payload is the schedule name)
// commands not supported by topic are : add/update & describe
const controlTopics = [
{ command: 'trigger', payloadIsName: true },
{ command: 'status', payloadIsName: true },
{ command: 'list', payloadIsName: true },
{ command: 'export', payloadIsName: true },
{ command: 'stop', payloadIsName: true },
{ command: 'stop-all', payloadIsName: false },
{ command: 'stop-topic', payloadIsName: false },
{ command: 'stop-all-dynamic', payloadIsName: false },
{ command: 'stop-all-static', payloadIsName: false },
{ command: 'pause', payloadIsName: true },
{ command: 'pause-all', payloadIsName: false },
{ command: 'pause-topic', payloadIsName: false },
{ command: 'pause-all-dynamic', payloadIsName: false },
{ command: 'pause-all-static', payloadIsName: false },
{ command: 'start', payloadIsName: true },
{ command: 'start-all', payloadIsName: false },
{ command: 'start-topic', payloadIsName: false },
{ command: 'start-all-dynamic', payloadIsName: false },
{ command: 'start-all-static', payloadIsName: false },
{ command: 'clear', payloadIsName: false },
{ command: 'remove', payloadIsName: true },
{ command: 'delete', payloadIsName: true },
{ command: 'debug', payloadIsName: true },
{ command: 'next', payloadIsName: false }
]
const addExtendedControlTopics = function (baseCommand) {
controlTopics.push({ command: `${baseCommand}-all`, payloadIsName: false })
controlTopics.push({ command: `${baseCommand}-topic`, payloadIsName: false })
controlTopics.push({ command: `${baseCommand}-all-dynamic`, payloadIsName: false })
controlTopics.push({ command: `${baseCommand}-all-static`, payloadIsName: false })
controlTopics.push({ command: `${baseCommand}-active`, payloadIsName: false })
controlTopics.push({ command: `${baseCommand}-active-dynamic`, payloadIsName: false })
controlTopics.push({ command: `${baseCommand}-active-static`, payloadIsName: false })
controlTopics.push({ command: `${baseCommand}-inactive`, payloadIsName: false })
controlTopics.push({ command: `${baseCommand}-inactive-dynamic`, payloadIsName: false })
controlTopics.push({ command: `${baseCommand}-inactive-static`, payloadIsName: false })
}
addExtendedControlTopics('trigger')
addExtendedControlTopics('status')
addExtendedControlTopics('export')
addExtendedControlTopics('list')
addExtendedControlTopics('remove')
addExtendedControlTopics('delete')
addExtendedControlTopics('debug')
/**
* Checks for updates of a specified npm package by comparing the current version
* with the latest version available on the npm registry.
* The reason for this is to push updates to the user ASAP while were still in beta.
*
* @param {string} currentVersion - The current version of the package.
* @param {string} packageName - The name of the package to check for updates.
* @param {function} callback - A callback function that receives an object containing
* the update status, current version, and latest version.
*/
function checkForUpdate (currentVersion, packageName, callback) {
exec(`npm view ${packageName} version`, (error, stdout) => {
if (error) {
console.error('Error fetching version:', error)
callback(null)
return
}
const latestVersion = stdout.trim()
const isUpdateAvailable = semver.lt(currentVersion, latestVersion)
callback({
isUpdateAvailable,
currentVersion,
latestVersion
})
})
}
/**
* Applies localized names to solar events by updating their titles.
*
* This function iterates over the predefined list of solar events and attempts
* to retrieve a localized title for each event using the provided RED object.
* If a localized title is found, it updates the event's title with the localized
* version. The localization keys are constructed using the event's value.
*
* @param {Object} RED - The RED object used for retrieving localized strings.
*/
function applyLocalizedSolarEventNames (RED) {
solarEvents.forEach(event => {
// Get the localized title string for this event value.
const localizedTitle = RED._('ui-scheduler.solarEvents.' + event.value)
// Only update if a localized title is found (i.e. it isn't null or falsy)
if (localizedTitle) {
event.title = localizedTitle
}
})
}
/**
* Localizes the day names and their abbreviations in the `daysOfWeek` array.
*
* This function iterates over each day in the `daysOfWeek` array and updates
* the `title` and `short` properties with their localized equivalents using
* the provided `RED` object. If a localized short form is not available, it
* defaults to the first three letters of the localized title.
*
* @param {Object} RED - The localization object used to retrieve localized strings.
*/
function applyLocalizedDayNames (RED) {
daysOfWeek.forEach(day => {
// Get the localized title for this day.
const localizedTitle = RED._('ui-scheduler.days.' + day.value)
if (localizedTitle) {
day.title = localizedTitle
}
// Get the localized short value for this day.
const localizedShort = RED._('ui-scheduler.days.' + day.value + '.short')
if (localizedShort && localizedShort !== 'ui-scheduler.days.' + day.value + '.short') {
day.short = localizedShort
} else {
// Fall back to a three-letter abbreviation from the current title.
day.short = day.title.substring(0, 3)
}
})
}
/**
* Localizes the text templates for future, past, and never time references
* using the RED internationalization function. Updates the global variables
* `futureTemplate`, `pastTemplate`, and `never` with localized strings.
*
* @param {Object} RED - The RED object providing internationalization support.
*/
function applyLocalizedWords (RED) {
futureTemplate = RED._('ui-scheduler.label.future')
pastTemplate = RED._('ui-scheduler.label.past')
never = RED._('ui-scheduler.label.never')
lessThanASecond = RED._('ui-scheduler.label.lessThanASecond')
}
/**
* Retrieves the title of a solar event based on the provided event key.
*
* @param {string} eventKey - The key representing the solar event.
* @returns {string} The title of the solar event if found, otherwise an empty string.
*/
function getSolarEventName (eventKey) {
const eventObj = solarEvents.find(e => e.value === eventKey)
return eventObj ? eventObj.title : ''
}
/**
* Retrieves the title of a day given its key.
*
* @param {string} dayKey - The key representing the day of the week.
* @returns {string} The title of the day if found, otherwise an empty string.
*/
function getDayName (dayKey) {
const dayObj = daysOfWeek.find(d => d.value === dayKey)
return dayObj ? dayObj.title : ''
}
function getDayAbbreviation (dayKey) {
const dayObj = daysOfWeek.find(d => d.value === dayKey)
return dayObj ? dayObj.short : ''
}
/**
* Humanize a cron express
* @param {string} expression the CRON expression to humanize
* @returns {string}
* A human readable version of the expression
*/
const humanizeCron = function (expression, locale, use24HourFormat = true) {
try {
const opt = { use24HourTimeFormat: use24HourFormat }
if (locale) opt.locale = locale
return cronstrue.toString(expression, opt)
} catch (error) {
return `Cannot parse expression '${expression}'`
}
}
/**
* Maps a solar event identifier to its corresponding title or vice versa.
*
* @param {string} event - The solar event identifier or title to map.
* @param {boolean} [toTitle=true] - Determines the mapping direction. If true, maps from identifier to title;
* if false, maps from title to identifier.
* @returns {string} - The mapped solar event title or identifier. Returns the input if no match is found.
*/
function mapSolarEvent (event, toTitle = true) {
const found = solarEvents.find(e => toTitle ? e.value === event : e.title === event)
return found ? (toTitle ? found.title : found.value) : event
}
/**
* Validate a schedule options. Returns true if OK otherwise throws an appropriate error
* @param {object} opt the options object to validate
* @param {boolean} permitDefaults allow certain items to be a default (missing value)
* @returns {boolean}
*/
function validateOpt (opt, permitDefaults = true) {
if (!opt) {
throw new Error('Schedule options are undefined')
}
if (!opt.id) {
throw new Error('Schedule id property missing')
}
if (!opt.expressionType || opt.expressionType === 'cron' || opt.expressionType === 'dates') { // cron
if (!opt.expression) {
throw new Error(`Schedule '${opt.name}' - expression property missing`)
}
let valid = false
try {
valid = cronosjs.validate(opt.expression)
if (valid) { opt.expressionType = 'cron' }
} catch (error) {
console.debug(error)
}
try {
if (!valid) {
valid = isDateSequence(opt.expression)
if (valid) { opt.expressionType = 'dates' }
}
} catch (error) {
console.debug(error)
}
if (!valid) {
throw new Error(`Schedule '${opt.name}' - expression '${opt.expression}' must be either a cron expression, a date, an a array of dates or a CSV of dates`)
}
} else if (opt.expressionType === 'solar') {
if (!opt.offset) {
opt.offset = 0
}
if (opt.locationType === 'fixed' || opt.locationType === 'env') {
// location comes from node
} else {
if (!opt.location) {
throw new Error(`Schedule '${opt.name}' - location property missing`)
}
}
if (opt.solarType !== 'selected' && opt.solarType !== 'all') {
throw new Error(`Schedule '${opt.name}' - solarType property invalid or mising. Must be either "all" or "selected"`)
}
if (opt.solarType === 'selected') {
if (!opt.solarEvents) {
throw new Error(`Schedule '${opt.name}' - solarEvents property missing`)
}
let solarEvents
if (typeof opt.solarEvents === 'string') {
solarEvents = opt.solarEvents.split(',')
} else if (Array.isArray(opt.solarEvents)) {
solarEvents = opt.solarEvents
} else {
throw new Error(`Schedule '${opt.name}' - solarEvents property is invalid`)
}
if (!solarEvents.length) {
throw new Error(`Schedule '${opt.name}' - solarEvents property is empty`)
}
for (let index = 0; index < solarEvents.length; index++) {
const element = solarEvents[index].trim()
if (!PERMITTED_SOLAR_EVENTS.includes(element)) {
throw new Error(`Schedule '${opt.name}' - solarEvents entry '${element}' is invalid`)
}
}
}
} else {
throw new Error(`Schedule '${opt.name}' - invalid schedule type '${opt.expressionType}'. Expected expressionType to be 'cron', 'dates' or 'solar'`)
}
if (permitDefaults) {
opt.payload = ((opt.payload === null || opt.payload === '') && opt.payloadType === 'num') ? 0 : opt.payload
opt.payload = ((opt.payload === null || opt.payload === '') && opt.payloadType === 'str') ? '' : opt.payload
opt.payload = ((opt.payload === null || opt.payload === '') && opt.payloadType === 'bool') ? false : opt.payload
}
if (!opt.payloadType === 'default' && opt.payload === null) {
throw new Error(`Schedule '${opt.name}' - payload property missing`)
}
const okTypes = ['default', 'flow', 'global', 'str', 'num', 'bool', 'json', 'jsonata', 'bin', 'date', 'env', 'custom']
// eslint-disable-next-line eqeqeq
const typeOK = okTypes.find(el => { return el == opt.payloadType })
if (!typeOK) {
throw new Error(`Schedule '${opt.name}' - type property '${opt.payloadType}' is not valid. Must be one of the following... ${okTypes.join(',')}`)
}
return true
}
/**
* Tests if a string or array of date like items are a date or date sequence
* @param {String|Array} data An array of date like entries or a CSV string of dates
*/
function isDateSequence (data) {
try {
const ds = parseDateSequence(data)
return (ds && ds.isDateSequence)
// eslint-disable-next-line no-empty
} catch (error) { }
return false
}
/**
* Adjusts the time of a given Date object to match a specified timezone,
* considering daylight saving time transitions, and returns the updated Date object.
*
* @param {Date} inputDateTime - The original Date object to be adjusted.
* @param {string} timezone - The IANA timezone identifier to adjust the time to.
* @param {Array<number>} timeArray - An array containing the hour, minute, and second to set.
* @returns {TZDate} - The adjusted Date object with the time set according to the specified timezone.
*/
function setTimeForTZ (inputDateTime, timezone, timeArray) {
const inputHour = timeArray[0] || 0
const inputMinute = timeArray[1] || 0
const inputSecond = timeArray[2] || 0
// Create a TZDate instance in the given timezone
const tzDate = new TZDate(
inputDateTime.getFullYear(),
inputDateTime.getMonth(),
inputDateTime.getDate(),
inputHour,
inputMinute,
inputSecond,
timezone
)
return tzDate
}
function getCurrentTimezone (node) {
let tz = node.timeZone
let localTZ = ''
if (!tz) {
try {
localTZ = Intl.DateTimeFormat().resolvedOptions().timeZone
tz = localTZ
} catch (error) { return 'UTC' }
}
return tz
}
/**
* Returns an object describing the parameters.
* @param {string} expression The expressions or coordinates to use
* @param {string} expressionType The expression type ("cron" | "solar" | "dates")
* @param {string} timeZone An optional timezone to use
* @param {number} offset An optional offset to apply
* @param {string} solarType Specifies either "all" or "selected" - related to solarEvents property
* @param {string} solarEvents a CSV of solar events to be included
* @param {date} time Optional time to use (defaults to Date.now() if excluded)
*/
function _describeExpression (expression, expressionType, timeZone, offset, solarType, solarEvents, time, opts, use24HourFormat = true, locale = null) {
const now = time ? new Date(time) : new Date()
opts = opts || {}
let result = { description: undefined, nextDate: undefined, nextDescription: undefined, prettyNext: 'Never' }
const cronOpts = timeZone ? { timezone: timeZone } : undefined
let ds = null
let dsOk = false
let exOk = false
// let now = new Date();
if (solarType === 'all') {
solarEvents = PERMITTED_SOLAR_EVENTS.join(',')
}
if (expressionType === 'solar') {
const opt = {
locationType: opts.locationType || opts.defaultLocationType,
defaultLocationType: opts.defaultLocationType,
defaultLocation: opts.defaultLocation,
expressionType,
location: expression,
offset: offset || 0,
name: 'dummy',
id: 'dummy',
solarType,
solarEvents,
solarDays: opts.solarDays,
payloadType: 'default',
payload: ''
}
if (validateOpt(opt)) {
const pos = coordParser(opt.location)
const offset = isNumber(opt.offset) ? parseInt(opt.offset) : 0
const nowOffset = new Date(now.getTime() - offset * 60000)
const daysOfWeek = opt?.solarDays || null
result = getSolarTimes(pos.lat, pos.lon, 0, solarEvents, now, offset, daysOfWeek, timeZone, use24HourFormat, locale)
// eslint-disable-next-line eqeqeq
if (opts.includeSolarStateOffset && offset != 0) {
const ssOffset = getSolarTimes(pos.lat, pos.lon, 0, solarEvents, nowOffset, 0, daysOfWeek, timeZone, use24HourFormat, locale)
result.solarStateOffset = ssOffset.solarState
}
result.offset = offset
result.now = now
result.nowOffset = nowOffset
ds = parseDateSequence(result.eventTimes.map((event) => event.timeOffset))
dsOk = ds && ds.isDateSequence
result.valid = dsOk
}
} else {
if (expressionType === 'cron' || expressionType === '') {
exOk = cronosjs.validate(expression)
result.valid = exOk
} else {
ds = parseDateSequence(expression)
dsOk = ds.isDateSequence
result.valid = dsOk
}
if (!exOk && !dsOk) {
result.description = 'Invalid expression'
result.valid = false
return result
}
}
if (dsOk) {
const task = ds.task
const dates = ds.dates
const dsFutureDates = dates.filter(d => d >= now)
const dsLastDates = dates.filter(d => d <= now)
const count = dsFutureDates ? dsFutureDates.length : 0
result.description = 'Date sequence with fixed dates'
if (task && task._sequence && count) {
result.nextDate = dsFutureDates[0]
result.previousDate = dsLastDates[dsLastDates.length - 1]
const ms = result.nextDate.valueOf() - now.valueOf()
const msLast = result.previousDate ? now.valueOf() - result.previousDate.valueOf() : 0
result.prettyNext = (result.nextEvent ? getSolarEventName(result.nextEvent) + ' ' : '') + futureMs(ms)
result.prettyPrevious = msLast > 0 ? (result.lastEvent ? getSolarEventName(result.lastEvent) + ' ' : '') + pastMs(msLast) : never
if (expressionType === 'solar') {
if (solarType === 'all') {
result.description = 'All Solar Events'
} else {
const solarEventsArray = solarEvents.split(',')
const events = solarEventsArray.map(event => getSolarEventName(event)).join(', ')
result.description = events + ((opts.solarDays && opts.solarDays.length) ? ', ' + opts.solarDays.map(day => getDayAbbreviation(day)).join(', ') : '')
}
} else {
if (count === 1) {
result.description = 'One time at ' + formatShortDateTimeWithTZ(result.nextDate, timeZone, use24HourFormat, locale)
} else {
result.description = count + ' Date Sequences starting at ' + formatShortDateTimeWithTZ(result.nextDate, timeZone, use24HourFormat, locale)
}
result.nextDates = dsFutureDates.slice(0, 5)
result.prevDates = dsLastDates.slice(-5)
}
}
}
if (exOk) {
const ex = cronosjs.CronosExpression.parse(expression, cronOpts)
const next = ex.nextDate()
// Add 1 second to the current time to avoid edge cases where the previous date is the same as the current date
const nowPlus = new Date(now.getTime() + 1000)
const previous = ex.previousDate(nowPlus, false)
if (next) {
const ms = next.valueOf() - now.valueOf()
result.prettyNext = (result.nextEvent ? getSolarEventName(result.nextEvent) + ' ' : '') + futureMs(ms)
}
try {
result.nextDates = ex.nextNDates(now, 5)
} catch (error) {
console.debug(error)
}
if (previous) {
const msLast = now.valueOf() - previous.valueOf()
result.prettyPrevious = pastMs(msLast)
try {
result.prevDates = ex.previousNDates(nowPlus, 5)
} catch (error) {
console.debug(error)
}
} else {
// result.description = 'Invalid expression'
// result.valid = false
// return result
}
result.description = humanizeCron(expression, locale, use24HourFormat)
result.nextDate = next
result.previousDate = previous
}
return result
}
/**
* Formats a given date into a short date-time string with timezone information.
*
* @param {string|Date} date - The date to format. Can be a date string or a Date object.
* @param {string} tz - The timezone identifier (e.g., 'America/New_York').
* @param {boolean} [use24HourFormat=true] - Whether to use 24-hour format. Defaults to true.
* @param {string} [locale='en'] - The locale to use for formatting. Defaults to 'en'.
* @returns {string} The formatted date-time string or an error message if formatting fails.
*/
function formatShortDateTimeWithTZ (date, tz, use24HourFormat = true, locale = 'en') {
if (!date) {
return ''
}
let dateString
const o = {
locale, // Specify the locale
timeZone: tz || undefined,
timeZoneName: 'short',
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: use24HourFormat ? 'h23' : 'h12'
}
try {
dateString = new Intl.DateTimeFormat(locale, o).format(new Date(date))
} catch (error) {
dateString = 'Error. Check timezone or locale setting'
}
return dateString
}
/**
* Determine if a variable is a number
* @param {string|number} n The string or number to test
* @returns {boolean}
*/
function isNumber (n) {
return !isNaN(parseFloat(n)) && isFinite(n)
}
/**
* Determine if a variable is a valid object
* NOTE: Arrays are also objects - be sure to use Array.isArray if you need to know the difference
* @param {*} o The variable to test
* @returns {boolean}
*/
function isObject (o) {
return (typeof o === 'object' && o !== null)
}
/**
* Determine if a variable is a valid date
* @param {*} d The variable to test
* @returns {boolean}
*/
function isValidDateObject (d) {
return d instanceof Date && !isNaN(d)
}
/**
* Determine if a variable is a cron like string
* @param {string} expression The variable to test
* @returns {boolean}
*/
function isCronLike (expression) {
if (typeof expression !== 'string') return false
if (expression.includes('*')) return true
const cleaned = expression.replace(/\s\s+/g, ' ')
const spaces = cleaned.split(' ')
return spaces.length >= 4 && spaces.length <= 6
}
/**
* Apply defaults to the cron schedule object
* @param {integer} optionIndex An index number to use for defaults
* @param {object} option The option object to update
*/
function applyOptionDefaults (node, option, optionIndex) {
if (isObject(option) === false) {
return// no point in continuing
}
optionIndex = optionIndex == null ? 0 : optionIndex
// eslint-disable-next-line eqeqeq
if (option.expressionType == '') {
if (isDateSequence(option.expression)) {
option.expressionType = 'dates'
} else {
option.expressionType = 'cron'// if empty, default to cron
}
} else if (['cron', 'dates', 'solar'].indexOf(option.expressionType) < 0) {
// if expressionType is not cron or solar - it might be sunrise or sunset from an older version
if (option.expressionType === 'sunrise') {
option.solarEvents = option.solarEvents || 'sunrise'
option.expressionType = 'solar'
} else if (option.expressionType === 'sunset') {
option.solarEvents = option.solarEvents || 'sunset'
option.expressionType = 'solar'
} else {
option.expressionType = 'cron'
}
}
// option.name = option.name || 'schedule' + (optionIndex + 1)
option.topic = option.topic || option.name
option.payloadType = option.payloadType || option.type
if (option.payloadType == null && typeof option.payload === 'string' && option.payload.length) {
option.payloadType = 'str'
}
option.payloadType = option.payloadType || 'default'
delete option.type
if (option.expressionType === 'cron' && !option.expression) option.expression = '0 * * * * * *'
if (option.expressionType === 'solar') {
if (!option.solarType) option.solarType = option.solarEvents ? 'selected' : 'all'
if (!option.solarEvents) option.solarEvents = 'sunrise,sunset'
if (!option.location) option.location = node.defaultLocation || ''
option.locationType = node.defaultLocationType || 'fixed'
}
option.locale = node.locale || 'en'
option.timezone = node.timezone || 'UTC'
option.timeFormat = node.use24HourFormat || '24'
}
/**
* Calculates the next occurrence of a specified weekday from a given start date.
*
* @param {string[]} weekdays - An array of weekday names to search for the next occurrence.
* @param {Date} [startDate=new Date()] - The date from which to start the search. Defaults to the current date.
* @returns {Date} The date of the next occurrence of one of the specified weekdays.
*/
function getNextWeekday (weekdays, startDate = new Date()) {
const start = new Date(startDate)
const startIndex = start.getDay()
let minDays = 7 // initialize with maximum days in a week
const now = new Date()
if (weekdays === null || !Array.isArray(weekdays) || weekdays.length === 0) {
weekdays = allDaysOfWeek
}
weekdays.forEach(day => {
const dayIndex = allDaysOfWeek.indexOf(day)
if (dayIndex === -1) {
//
}
const daysUntilNext = (dayIndex - startIndex + 7) % 7
if (daysUntilNext < minDays) {
minDays = daysUntilNext
}
})
// Calculate the next occurring weekday date
const nextDate = new Date(start.getTime() + minDays * 24 * 60 * 60 * 1000)
// Check if the nextDate at 00:00 is still greater than now UTC
const tempNextDate = new Date(nextDate)
tempNextDate.setHours(0, 0, 0, 0)
if (tempNextDate > now) {
return tempNextDate
}
return new Date(nextDate)
}
/**
* Calculates the most recent occurrence of a specified weekday before a given start date.
*
* @param {string[]} weekdays - An array of weekday names to consider. If empty or not provided, all days of the week are considered.
* @param {Date} [startDate=new Date()] - The date from which to start the search. Defaults to the current date.
* @returns {Date} The date of the last occurrence of one of the specified weekdays.
*/
function getLastWeekday (weekdays, startDate = new Date()) {
if (!weekdays || weekdays.length === 0) {
weekdays = allDaysOfWeek
}
const start = new Date(startDate)
const startIndex = start.getDay()
let minDays = 7 // initialize with maximum days in a week
if (weekdays === null || !Array.isArray(weekdays) || weekdays.length === 0) {
weekdays = allDaysOfWeek
}
weekdays.forEach(day => {
const dayIndex = allDaysOfWeek.indexOf(day)
if (dayIndex === -1) {
//
}
const daysUntilLast = (startIndex - dayIndex + 7) % 7
if (daysUntilLast !== 0 && daysUntilLast < minDays) {
minDays = daysUntilLast
}
})
// Calculate the last occurring weekday date
const lastDate = new Date(start.getTime() - minDays * 24 * 60 * 60 * 1000)
// Check if the lastDate at 00:00 is still less than now UTC
const tempLastDate = new Date(lastDate)
tempLastDate.setHours(0, 0, 0, 0)
if (tempLastDate < new Date()) {
return tempLastDate
}
return new Date(lastDate)
}
/**
* Retrieves the most recent event from a list of events that matches a specified
* solar event and occurred before or at the current time.
*
* @param {Array} events - An array of event objects, each containing an 'event' and 'timeOffset'.
* @param {Array} solarEventsArr - An array of solar event names to match against.
* @param {Date} now - The current date and time used to compare event occurrences.
* @returns {Object|null} The most recent matching event object, or null if no match is found.
*/
function getLastOccurredEvent (events, solarEventsArr, now) {
for (let i = events.length - 1; i >= 0; i--) {
const item = events[i]
if (solarEventsArr.includes(item.event) && new Date(item.timeOffset) <= now) {
return item
}
}
return null // If no matching event is found
}
/**
* Calculates the next occurrence of a specified time on or after a given start date.
*
* @param {Date} startDate - The starting date from which to calculate the next occurrence.
* @param {string} timeString - The time in 'HH:MM' format to find the next occurrence of.
* @returns {Date} A Date object representing the next occurrence of the specified time.
*/
function getNextTimeOccurrence (node, startDate, timeString) {
if (!startDate) startDate = new Date()
if (!timeString) timeString = '00:00'
// Parse the input time string
const timeArray = timeString.split(':').map(Number)
const date = new Date(startDate)
const tz = getCurrentTimezone(node)
const adjustedDate = setTimeForTZ(date, tz, timeArray)
// If the time has already passed for the current day, add one day
if (adjustedDate < new Date(startDate)) {
adjustedDate.setDate(adjustedDate.getDate() + 1)
}
return adjustedDate
}
/**
* Calculates the next occurrences of a time interval from the start of the year.
*
* @param {number} interval - The interval between occurrences, must be a positive number.
* @param {string} unit - The unit of time for the interval, either 'minute' or 'hour'.
* @param {number} [count=1] - The number of occurrences to calculate.
* @returns {Date[]} An array of Date objects representing the next occurrences.
* @throws {Error} Throws an error if the interval is not positive or if the unit is invalid.
*/
function getNextIntervalOccurrences (interval, unit, count = 1) {
const now = new Date()
const anchor = startOfYear(now) // Anchor: January 1st, 00:00
if (interval <= 0) {
throw new Error('Interval must be a positive number.')
}
const occurrences = []
if (unit === 'minute') {
const totalMinutesSinceAnchor = Math.ceil((now - anchor) / (1000 * 60)) // Minutes since Jan 1st
const nextOccurrenceInMinutes = Math.ceil(totalMinutesSinceAnchor / interval) * interval
for (let i = 0; i < count; i++) {
occurrences.push(addMinutes(anchor, nextOccurrenceInMinutes + i * interval)) // Add intervals
}
} else if (unit === 'hour') {
const totalHoursSinceAnchor = Math.ceil((now - anchor) / (1000 * 60 * 60)) // Hours since Jan 1st
const nextOccurrenceInHours = Math.ceil(totalHoursSinceAnchor / interval) * interval
for (let i = 0; i < count; i++) {
occurrences.push(addHours(anchor, nextOccurrenceInHours + i * interval)) // Add intervals
}
} else {
throw new Error('Unit must be either "minute" or "hour".')
}
return occurrences
}
/**
* Generates a sequence of past date occurrences based on a specified interval and unit.
*
* @param {number} interval - The interval between occurrences, must be a positive number.
* @param {string} unit - The unit of time for the interval, either 'minute' or 'hour'.
* @param {number} [count=1] - The number of past occurrences to generate, must be a positive number.
* @returns {Date[]} An array of Date objects representing the past occurrences.
* @throws {Error} If the interval is not a positive number or if the unit is invalid.
*/
function getPreviousIntervalOccurrences (interval, unit, count = 1) {
const now = new Date()
const anchor = startOfYear(now) // Anchor: January 1st, 00:00
if (interval <= 0) {
throw new Error('Interval must be a positive number.')
}
const occurrences = []
if (unit === 'minute') {
const totalMinutesSinceAnchor = Math.floor((now - anchor) / (1000 * 60)) // Minutes since Jan 1st
const lastOccurrenceInMinutes = Math.floor(totalMinutesSinceAnchor / interval) * interval
for (let i = count - 1; i >= 0; i--) { // Reverse the loop
occurrences.push(addMinutes(anchor, lastOccurrenceInMinutes - i * interval))
}
} else if (unit === 'hour') {
const totalHoursSinceAnchor = Math.floor((now - anchor) / (1000 * 60 * 60)) // Hours since Jan 1st
const lastOccurrenceInHours = Math.floor(totalHoursSinceAnchor / interval) * interval
for (let i = count - 1; i >= 0; i--) { // Reverse the loop
occurrences.push(addHours(anchor, lastOccurrenceInHours - i * interval))
}
} else {
throw new Error('Unit must be either "minute" or "hour".')
}
return occurrences
}
/**
* Determines if a given interval is compatible with cron scheduling
* based on the specified type ('minute' or 'hour').
*
* @param {number} interval - The interval to check for compatibility.
* @param {string} type - The type of interval, either 'minute' or 'hour'.
* @returns {boolean} True if the interval is compatible with cron scheduling; otherwise, false.
* @throws {Error} Throws an error if the type is not 'minute' or 'hour'.
*/
function isCronCompatible (interval, type) {
if (type === 'minute') {
return 60 % interval === 0
} else if (type === 'hour') {
return 24 % interval === 0
} else {
throw new Error("Invalid type. Use 'minute' or 'hour'.")
}
}
/**
* Generates a sequence of date occurrences based on a specified interval and unit.
*
* @param {number} interval - The interval between occurrences, must be a positive number.
* @param {string} unit - The unit of time for the interval, either 'minute' or 'hour'.
* @param {string} [type='both'] - The type of occurrences to generate: 'past', 'future', or 'both'.
* @param {number} [count=1] - The number of occurrences to generate for each type, must be a positive number.
* @returns {string} A comma-separated string of ISO formatted date occurrences.
* @throws {Error} If the interval or count is not a positive number, or if the unit is invalid.
*/
function generateDateSequence (interval, unit, type = 'both', count = 1) {
if (interval <= 0 || count <= 0) {
throw new Error('Interval and count must be positive numbers.')
}
let pastOccurrences = []
let futureOccurrences = []
// Get past and/or future occurrences based on the type
if (type === 'past' || type === 'both') {
pastOccurrences = getPreviousIntervalOccurrences(interval, unit, type === 'both' ? 5 : count)
}
if (type === 'future' || type === 'both') {
futureOccurrences = getNextIntervalOccurrences(interval, unit, count)
}
// Combine past and future occurrences (past first)
const allOccurrences = [...pastOccurrences, ...futureOccurrences]
// Format the occurrences into a comma-separated ISO string
return allOccurrences.map((date) => date.toISOString()).join(', ')
}
/**
* Parses a given expression to determine if it represents a valid date sequence.
*
* The function checks if the input expression is a string and splits it by commas.
* Each element is trimmed and checked if it resembles a cron-like expression.
* If any element is cron-like, the function returns early with a result indicating
* failure. Otherwise, it attempts to convert each element into a Date object.
*
* If the resulting dates can form a valid CronosTask sequence, the function
* updates the result to indicate success and includes the sequence details.
*
* @param {string|Array} expression - The expression to parse, which can be a string
* of comma-separated values or an array.
* @returns {Object} An object containing the parsing result, including whether
* the expression is a valid date sequence, the original expression,
* and the parsed dates if successful.
*/
function parseDateSequence (expression) {
const result = { isDateSequence: false, expression }
let dates = expression
if (typeof expression === 'string') {
const spl = expression.split(',')
for (let index = 0; index < spl.length; index++) {
spl[index] = spl[index].trim()
if (isCronLike(spl[index])) {
return result// fail
}
}
dates = spl.map(x => {
if (isNumber(x)) {
x = parseInt(x)
}
const d = new Date(x)
return d
})
}
const ds = new cronosjs.CronosTask(dates)
if (ds && ds._sequence) {
result.dates = ds._sequence._dates
result.task = ds
result.isDateSequence = true
}
return result
}
/**
* Parses solar times based on the provided options and returns a task object
* containing solar event times and a date sequence.
*
* @param {Object} opt - Options for parsing solar times.
* @param {string} [opt.location='0.0,0.0'] - The location coordinates in 'lat,lon' format.
* @param {number} [opt.offset=0] - The time offset in minutes.
* @param {string|Date} [opt.date=new Date()] - The date for which to calculate solar times.
* @param {Array<string>} [opt.solarDays=null] - Days of the week to consider for solar events.
* @param {string|Array<string>} [opt.solarEvents] - Specific solar events to consider.
* @param {string} [opt.solarType] - Type of solar events to consider, 'all' for all permitted events.
* @returns {Object} A task object containing solar event times and a date sequence.
*/
function parseSolarTimes (opt) {
// opt.location = location || ''
const pos = coordParser(opt.location || '0.0,0.0')
const offset = opt.offset ? parseInt(opt.offset) : 0
const date = opt.date ? new Date(opt.date) : new Date()
const daysOfWeek = opt.solarDays || null
const events = opt.solarType === 'all' ? PERMITTED_SOLAR_EVENTS : opt.solarEvents
const result = getSolarTimes(pos.lat, pos.lon, 0, events, date, offset, daysOfWeek, opt.timezone, opt.timeFormat, opt.locale)
const task = parseDateSequence(result.eventTimes.map((o) => o.timeOffset))
task.solarEventTimes = result
return task
}
/**
* Calculates solar event times for a given location and date range.
*
* @param {number} lat - Latitude of the location.
* @param {number} lng - Longitude of the location.
* @param {number} elevation - Elevation of the location (currently unused).
* @param {string|string[]} solarEvents - Comma-separated string or array of solar events to consider.
* @param {Date|null} [startDate=null] - Starting date for calculations. Defaults to current date if not provided.
* @param {number} [offset=0] - Time offset in minutes to apply to event times.
* @param {string[]|null} [daysOfWeek=null] - Array of weekdays to consider for event calculations.
* @returns {Object} An object containing solar state, next and last event details, and event times.
* @throws {Error} Throws an error if solarEvents is not a string or array.
*/
function getSolarTimes (lat, lng, elevation, solarEvents, startDate = null, offset = 0, daysOfWeek = null, timezone = null, timeFormat = null, locale = null) {
// performance.mark('Start');
let getNextWeek = false
let initialStartDate
let nextType
let nextTime
let nextTimeOffset
let lastType
let lastTime
let lastTimeOffset
const now = new Date()
offset = isNumber(offset) ? parseInt(offset) : 0
elevation = isNumber(elevation) ? parseInt(elevation) : 0// not used for now
startDate = startDate ? new Date(startDate) : new Date()
for (let retries = 0; retries < 2; retries++) {
const solarEventsPast = [...PERMITTED_SOLAR_EVENTS]
const solarEventsFuture = [...PERMITTED_SOLAR_EVENTS]
const solarEventsArr = []
// get list of usable solar events into solarEventsArr
let solarEventsArrTemp = []
if (typeof solarEvents === 'string') {
solarEventsArrTemp = solarEvents.split(',')
} else if (Array.isArray(solarEvents)) {
solarEventsArrTemp = [...solarEvents]
} else {
throw new Error('solarEvents must be a CSV or Array')
}
for (let index = 0; index < solarEventsArrTemp.length; index++) {
const se = solarEventsArrTemp[index].trim()
if (PERMITTED_SOLAR_EVENTS.includes(se)) {
solarEventsArr.push(se)
}
}
if (daysOfWeek && daysOfWeek.length > 0) {
startDate = getNextWeekday(daysOfWeek, startDate)
}
if (!getNextWeek) {
initialStartDate = startDate
}
const sorted = getSolarEvents(startDate, solarEventsPast, solarEventsFuture)
// now scan through sorted solar events to determine day/night/twilight etc
let state = ''; const solarState = {}
for (let index = 0; index < sorted.length; index++) {
const event = sorted[index]
if (event.time < startDate) {
switch (event.event) {