UNPKG

@cgjgh/node-red-dashboard-2-ui-scheduler

Version:

A UI scheduler node that integrates with Node-RED Dashboard 2.0

1,207 lines (1,097 loc) 279 kB
const version = '3.3.4' 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'] /** * 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) { case 'n