UNPKG

node-red-contrib-cron-plus

Version:

A flexible scheduler (cron, solar events, fixed dates) node for Node-RED with full dynamic control and time zone support

1,104 lines (1,027 loc) 170 kB
<!-- MIT License Copyright (c) 2019, 2020, 2021, 2022 Steve-Mcl Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. --> <script type="text/javascript"> /* global RED, $, jQuery */ /* global cartodb, L */ (function ($) { 'use strict' /* cronBuilder - adapted for cron-plus from https://github.com/juliacscai/jquery-cron-quartz - License... The MIT License (MIT) Copyright (c) 2016 Julia Cai */ const cronInputs = { period: '<div class="cron-select-period"><label></label><select class="cron-period-select"></select></div>', startTime: '<div class="cron-input cron-start-time">Start time <select class="cron-clock-hour"></select>:<select class="cron-clock-minute"></select></div>', container: '<div class="cron-input"></div>', minutes: { tag: 'cron-minutes', inputs: ['<p>Every <select class="cron-minutes-select"></select> minutes(s)</p>'] }, hourly: { tag: 'cron-hourly', inputs: ['<p><input type="radio" name="hourlyType" value="every"> Every <select class="cron-hourly-select"></select> hour(s)</p>', '<p><input type="radio" name="hourlyType" value="clock"> Every day at <select class="cron-hourly-hour"></select>:<select class="cron-hourly-minute"></select></p>'] }, daily: { tag: 'cron-daily', inputs: ['<p><input type="radio" name="dailyType" value="every"> Every <select class="cron-daily-select"></select> day(s)</p>', '<p><input type="radio" name="dailyType" value="clock"> Every week day</p>'] }, weekly: { tag: 'cron-weekly', inputs: ['<p><input type="checkbox" name="dayOfWeekMon"> Monday <input type="checkbox" name="dayOfWeekTue"> Tuesday ' + '<input type="checkbox" name="dayOfWeekWed"> Wednesday <input type="checkbox" name="dayOfWeekThu"> Thursday</p>', '<p><input type="checkbox" name="dayOfWeekFri"> Friday <input type="checkbox" name="dayOfWeekSat"> Saturday ' + '<input type="checkbox" name="dayOfWeekSun"> Sunday</p>'] }, monthly: { tag: 'cron-monthly', inputs: ['<p><input type="radio" name="monthlyType" value="byDay"> Day <select class="cron-monthly-day"></select> of every <select class="cron-monthly-month"></select> month(s)</p>', '<p><input type="radio" name="monthlyType" value="byWeek"> The <select class="cron-monthly-nth-day"></select> ' + '<select class="cron-monthly-day-of-week"></select> of every <select class="cron-monthly-month-by-week"></select> month(s)</p>'] }, yearly: { tag: 'cron-yearly', inputs: ['<p><input type="radio" name="yearlyType" value="byDay"> Every <select class="cron-yearly-month"></select> <select class="cron-yearly-day"></select></p>', '<p><input type="radio" name="yearlyType" value="byWeek"> The <select class="cron-yearly-nth-day"></select> ' + '<select class="cron-yearly-day-of-week"></select> of <select class="cron-yearly-month-by-week"></select></p>'] } } const periodOpts = arrayToOptions(['Minutes', 'Hourly', 'Daily', 'Weekly', 'Monthly', 'Yearly']) const minuteOpts = rangeToOptions(1, 59) const hourOpts = rangeToOptions(1, 24) const dayOpts = rangeToOptions(1, 31) const minuteClockOpts = rangeToOptions(0, 59, true) const hourClockOpts = rangeToOptions(0, 23, true) const dayInMonthOpts = rangeToOptions(1, 31) const monthOpts = arrayToOptions(['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'], [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]) const monthNumOpts = rangeToOptions(1, 12) const nthWeekOpts = arrayToOptions(['First', 'Second', 'Third', 'Forth', 'Last'], [1, 2, 3, 4, 'L']) const dayOfWeekOpts = arrayToOptions(['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'], ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN']) // Convert an array of values to options to append to select input function arrayToOptions (opts, values) { let inputOpts = '' for (let i = 0; i < opts.length; i++) { let value = opts[i] if (values != null) value = values[i] inputOpts += "<option value='" + value + "'>" + opts[i] + '</option>\n' } return inputOpts } // Convert an integer range to options to append to select input function rangeToOptions (start, end, isClock) { let inputOpts = ''; let label for (let i = start; i <= end; i++) { if (isClock && i < 10) label = '0' + i else label = i inputOpts += "<option value='" + i + "'>" + label + '</option>\n' } return inputOpts } // Add input elements to UI as defined in cronInputs function addInputElements ($baseEl, inputObj, onFinish) { $(cronInputs.container).addClass(inputObj.tag).appendTo($baseEl) $baseEl.children('.' + inputObj.tag).append(inputObj.inputs) if (typeof onFinish === 'function') onFinish() } const eventHandlers = { periodSelect: function () { const period = ($(this).val()) const $selector = $(this).parent() $selector.siblings('div.cron-input').hide() $selector.siblings().find('select option').removeAttr('selected') $selector.siblings().find('select option:first').attr('selected', 'selected') $selector.siblings('div.cron-start-time').show() $selector.siblings('div.cron-start-time').children('select.cron-clock-hour').val('12') switch (period) { case 'Minutes': $selector.siblings('div.cron-minutes') .show() .find('select.cron-minutes-select').val('1') $selector.siblings('div.cron-start-time').hide() break case 'Hourly':{ const $hourlyEl = $selector.siblings('div.cron-hourly') $hourlyEl.show() .find('input[name=hourlyType][value=every]').prop('checked', true) $hourlyEl.find('select.cron-hourly-hour').val('12') $selector.siblings('div.cron-start-time').hide() } break case 'Daily':{ const $dailyEl = $selector.siblings('div.cron-daily') $dailyEl.show() .find('input[name=dailyType][value=every]').prop('checked', true) } break case 'Weekly': $selector.siblings('div.cron-weekly') .show() .find('input[type=checkbox]').prop('checked', false) break case 'Monthly':{ const $monthlyEl = $selector.siblings('div.cron-monthly') $monthlyEl.show() .find('input[name=monthlyType][value=byDay]').prop('checked', true) } break case 'Yearly':{ const $yearlyEl = $selector.siblings('div.cron-yearly') $yearlyEl.show() .find('input[name=yearlyType][value=byDay]').prop('checked', true) } break } } } // Public functions $.cronBuilder = function (el, options) { const base = this // Access to jQuery and DOM versions of element base.$el = $(el) base.el = el // Reverse reference to the DOM object base.$el.data('cronBuilder', base) // Initialization base.init = function () { base.options = $.extend({}, $.cronBuilder.defaultOptions, options) base.$el.append(cronInputs.period) base.$el.find('div.cron-select-period label').text(base.options.selectorLabel) base.$el.find('select.cron-period-select') .append(periodOpts) .bind('change', eventHandlers.periodSelect) addInputElements(base.$el, cronInputs.minutes, function () { base.$el.find('select.cron-minutes-select').append(minuteOpts) }) addInputElements(base.$el, cronInputs.hourly, function () { base.$el.find('select.cron-hourly-select').append(hourOpts) base.$el.find('select.cron-hourly-hour').append(hourClockOpts) base.$el.find('select.cron-hourly-minute').append(minuteClockOpts) }) addInputElements(base.$el, cronInputs.daily, function () { base.$el.find('select.cron-daily-select').append(dayOpts) }) addInputElements(base.$el, cronInputs.weekly) addInputElements(base.$el, cronInputs.monthly, function () { base.$el.find('select.cron-monthly-day').append(dayInMonthOpts) base.$el.find('select.cron-monthly-month').append(monthNumOpts) base.$el.find('select.cron-monthly-nth-day').append(nthWeekOpts) base.$el.find('select.cron-monthly-day-of-week').append(dayOfWeekOpts) base.$el.find('select.cron-monthly-month-by-week').append(monthNumOpts) }) addInputElements(base.$el, cronInputs.yearly, function () { base.$el.find('select.cron-yearly-month').append(monthOpts) base.$el.find('select.cron-yearly-day').append(dayInMonthOpts) base.$el.find('select.cron-yearly-nth-day').append(nthWeekOpts) base.$el.find('select.cron-yearly-day-of-week').append(dayOfWeekOpts) base.$el.find('select.cron-yearly-month-by-week').append(monthOpts) }) base.$el.append(cronInputs.startTime) base.$el.find('select.cron-clock-hour').append(hourClockOpts) base.$el.find('select.cron-clock-minute').append(minuteClockOpts) if (typeof base.options.onChange === 'function') { base.$el.find('select, input').change(function () { base.options.onChange(base.getExpression()) }) } base.$el.find('select.cron-period-select') .triggerHandler('change') } base.getExpression = function () { // var b = c.data("block"); const sec = 0 // ignoring seconds by default const year = '*' // every year by default let dow = '?' let month = '*'; let dom = '*' let min = base.$el.find('select.cron-clock-minute').val() let hour = base.$el.find('select.cron-clock-hour').val() const period = base.$el.find('select.cron-period-select').val() switch (period) { case 'Minutes': { const $selector = base.$el.find('div.cron-minutes') const nmin = $selector.find('select.cron-minutes-select').val() if (nmin > 1) min = '0/' + nmin else min = '*' hour = '*' } break case 'Hourly': { const $selector = base.$el.find('div.cron-hourly') if ($selector.find('input[name=hourlyType][value=every]').is(':checked')) { min = 0 hour = '*' const nhour = $selector.find('select.cron-hourly-select').val() if (nhour > 1) hour = '0/' + nhour } else { min = $selector.find('select.cron-hourly-minute').val() hour = $selector.find('select.cron-hourly-hour').val() } } break case 'Daily': { const $selector = base.$el.find('div.cron-daily') if ($selector.find('input[name=dailyType][value=every]').is(':checked')) { const ndom = $selector.find('select.cron-daily-select').val() if (ndom > 1) dom = '1/' + ndom } else { dom = '?' dow = 'MON-FRI' } } break case 'Weekly': { const $selector = base.$el.find('div.cron-weekly') const ndow = [] if ($selector.find('input[name=dayOfWeekMon]').is(':checked')) { ndow.push('MON') } if ($selector.find('input[name=dayOfWeekTue]').is(':checked')) { ndow.push('TUE') } if ($selector.find('input[name=dayOfWeekWed]').is(':checked')) { ndow.push('WED') } if ($selector.find('input[name=dayOfWeekThu]').is(':checked')) { ndow.push('THU') } if ($selector.find('input[name=dayOfWeekFri]').is(':checked')) { ndow.push('FRI') } if ($selector.find('input[name=dayOfWeekSat]').is(':checked')) { ndow.push('SAT') } if ($selector.find('input[name=dayOfWeekSun]').is(':checked')) { ndow.push('SUN') } dow = '*' dom = '?' if (ndow.length < 7 && ndow.length > 0) dow = ndow.join(',') } break case 'Monthly': { const $selector = base.$el.find('div.cron-monthly') let nmonth let mnd if ($selector.find('input[name=monthlyType][value=byDay]').is(':checked')) { month = '*' nmonth = $selector.find('select.cron-monthly-month').val() dom = $selector.find('select.cron-monthly-day').val() dow = '?' } else { mnd = $selector.find('select.cron-monthly-nth-day').val() dow = $selector.find('select.cron-monthly-day-of-week').val() + (mnd === 'L' ? 'L' : '#' + mnd) nmonth = $selector.find('select.cron-monthly-month-by-week').val() dom = '?' } if (nmonth > 1) month = '1/' + nmonth } break case 'Yearly': { const $selector = base.$el.find('div.cron-yearly') let ynd if ($selector.find('input[name=yearlyType][value=byDay]').is(':checked')) { dom = $selector.find('select.cron-yearly-day').val() month = $selector.find('select.cron-yearly-month').val() dow = '?' } else { ynd = $selector.find('select.cron-yearly-nth-day').val() dow = $selector.find('select.cron-yearly-day-of-week').val() + (ynd === 'L' ? 'L' : '#' + ynd) month = $selector.find('select.cron-yearly-month-by-week').val() dom = '?' } } break default: break } return [sec, min, hour, dom, month, dow, year].join(' ') } base.init() } // Plugin default options $.cronBuilder.defaultOptions = { selectorLabel: 'Select period: ' } // Plugin definition $.fn.cronBuilder = function (options) { return this.each(function () { // eslint-disable-next-line no-new, new-cap (new $.cronBuilder(this, options)) }) } }(jQuery)); (function () { RED._cron_plus_debug = false // set true at runtime to enable debug output let map, mapPopup, selectedLocation, selectedLocationInput const filesAdded = [] // list of files already added const cronTooltipClass = 'cron-plus-expression-tip form-tips ui-corner-all ui-widget-shadow' function toggleFullscreen (selector, callback) { callback = callback || function () {} const el = document.querySelector(selector || '#node-cronplus-tab-static-schedules') const $el = $(el) if (!document.fullscreenElement) { el.requestFullscreen().then(() => { $el.addClass('cron-plus-fullscreen-element').removeClass('cron-plus-expanded-element') callback() }).catch((err) => { if (callback) { callback(err) } else { alert(`Unable to get fullscreen mode: ${err.message}`) } }) } else { $el.removeClass('cron-plus-fullscreen-element').removeClass('cron-plus-expanded-element') try { document.exitFullscreen() } finally { callback() } } } function checkLoadJsCssFile (filename, filetype, callback) { if (filesAdded.indexOf(filename) === -1) { loadJsCssFile(filename, filetype, function () { filesAdded.push(filename) if (callback) callback() }) } else { if (callback) callback() } } function loadJsCssFile (filename, filetype, callback) { let fileRef if (filetype === 'js') { // if filename is a external JavaScript file fileRef = document.createElement('script') fileRef.setAttribute('type', 'text/javascript') fileRef.setAttribute('src', filename) } else if (filetype === 'css') { // if filename is an external CSS file fileRef = document.createElement('link') fileRef.setAttribute('rel', 'stylesheet') fileRef.setAttribute('type', 'text/css') fileRef.setAttribute('href', filename) } if (typeof fileRef !== 'undefined') { document.getElementsByTagName('head')[0].appendChild(fileRef) } fileRef.onload = function () { if (callback) callback() } } function safeFloat (value, def) { if ((undefined === value) || (value === null)) { return def || 0.0 } try { value = parseFloat(value) } catch (e) { value = def || 0.0 } if (isNaN(value)) { return def || 0.0 } return value } function isCronLike (expression) { if (typeof expression !== 'string') return false if (expression.indexOf('*') >= 0) return true const cleaned = expression.replace(/\s\s+/g, ' ') const spaces = cleaned.split(' ') return spaces.length >= 4 && spaces.length <= 6 } function isDateSequenceLike (expression) { try { if (typeof expression !== 'string') return false if (expression.indexOf('*') >= 0) return false if (expression.indexOf('#') >= 0) return false if (expression.indexOf('?') >= 0) return false const cleaned = expression.replace(/\s\s+/g, ' ') const parts = cleaned.split(',') for (let index = 0; index < parts.length; index++) { let part = parts[index] if (parseInt(part) === part) part = parseInt(part) const d = new Date(part) const isDate = (d instanceof Date && !isNaN(d.valueOf())) if (!isDate) return false } return true } catch (error) { return false } } function showSidebarHelpPanel () { if (RED.sidebar.help) { RED.sidebar.help.show()// >= V1.1.0 } else { RED.sidebar.show('info')// < V1.1.0 } } function showCronHelp () { showSidebarHelpPanel() $('#cron-plus-expression-info').get(0).scrollIntoView() } function showSolarHelp () { showSidebarHelpPanel() $('#cron-plus-solar-events-info').get(0).scrollIntoView() } const getIdealDialogHeight = function () { return Math.min(800, ($(document).height() < 600) ? $(document).height() - 30 : $(document).height() - 100) } function initMap () { // create map popup dialog $('#cron-plus-map-dialog').dialog({ classes: { 'ui-dialog-content': 'cron-plus-map-dialog-content' }, autoOpen: false, height: getIdealDialogHeight(), width: '75%', minWidth: 300, maxWidth: 1000, modal: true, buttons: { Cancel: function () { $('#cron-plus-map-dialog').dialog('close') }, OK: function () { $('#cron-plus-map-dialog').dialog('close') if (selectedLocation && selectedLocationInput) { if (selectedLocationInput.typedInput('instance')) { selectedLocationInput.typedInput('value', selectedLocation.lat + ' ' + selectedLocation.lng) } else { selectedLocationInput.val(selectedLocation.lat + ' ' + selectedLocation.lng) } selectedLocationInput.focus() // if selectedLocationInput is a typedInput, trigger focus on that if (selectedLocationInput.typedInput('instance')) { selectedLocationInput.typedInput('focus') } selectedLocationInput.change() } } }, show: { effect: 'blind', duration: 500 }, // eslint-disable-next-line no-unused-vars open: function (event) { $(this).css('padding', '0px 0px 0px 0px') $('.ui-dialog-buttonpane').find('button:contains("OK")').addClass('primary') $('#cron-plus-map-dialog').dialog('option', 'height', getIdealDialogHeight()) $('#cron-plus-dynamic-nodes-dialog').dialog('option', 'position', { my: 'center', at: 'center', of: window }) }, hide: { effect: 'explode', duration: 500 }, // eslint-disable-next-line no-unused-vars close: function (event, ui) { if (RED._cron_plus_debug) console.debug('destroying map') if (map) map.remove() } }) } function createMap (srcInput) { if (RED._cron_plus_debug) { console.debug('createMap', srcInput) } $('.cron-plus-map-loading').hide() if (!window.L || navigator.onLine === false) { $('.cron-plus-map-not-loaded').show() $('#cron-plus-map').hide() return } $('#cron-plus-map').show() $('.cron-plus-map-not-loaded').hide() let lat, lon selectedLocationInput = srcInput function getInputValue () { if (selectedLocationInput.typedInput('instance')) { return selectedLocationInput.typedInput('value') } return selectedLocationInput.val() } const latlon = getInputValue() if (latlon && typeof latlon === 'string') { let splitChar = ' ' if (latlon.includes(',')) splitChar = ',' const arrLatlon = latlon.split(splitChar) if (arrLatlon.length >= 2) { lat = arrLatlon[0] lon = arrLatlon[1] } } if (RED._cron_plus_debug) console.debug('createMap', lat, lon) let zoom = 3// by default, zoomed out let showPopupOnOpen = false if (lat && lon) { showPopupOnOpen = true zoom = 5// if location is set, zoom in a bit } lat = safeFloat(lat, 50.0) lon = safeFloat(lon, 0.0) if (RED._cron_plus_debug) console.debug('lat/lon', lat, lon) selectedLocation = window.L.latLng(lat, lon) if (RED._cron_plus_debug) console.debug('selectedLocation', selectedLocation.lat, selectedLocation.lng) // create leaflet map map = window.L.map('cron-plus-map', { zoomControl: true, center: selectedLocation, // [selectedLocation.lat,selectedLocation.lng], zoom }) mapPopup = window.L.popup() map.on('click', function (e) { if (RED._cron_plus_debug) console.debug(e) const normaliseLat = function (x) { while (x > 90.0 || x < -90.0) { if (x > 90.0) { x = -(90.0 - (x - 90)) } if (x < -90.0) { x = (90.0 - ((-x) - 90)) } } return x } const normaliseLng = function (x) { while (x > 180.0 || x < -180.0) { if (x > 180.0) { x = -(180.0 - (x - 180)) } if (x < -180.0) { x = (180.0 - ((-x) - 180)) } } return x } selectedLocation = {} selectedLocation.lat = normaliseLat(e.latlng.lat) selectedLocation.lng = normaliseLng(e.latlng.lng) const content = '<div><span>Lat:</span> <span>' + selectedLocation.lat + '</span></div>' + '<div><span>Lon:</span> <span>' + selectedLocation.lng + '</span></div>' showMapPopup(content, e.latlng.lat, e.latlng.lng, map) }) function showMapPopup (content, lat, lon, map) { mapPopup .setLatLng([lat, lon]) .setContent(content) .openOn(map) } // add a base layer const tileUrl = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' const layer = L.tileLayer(tileUrl, {}) layer.addTo(map) // add cartodb layer with one sublayer cartodb.createLayer(map, { user_name: 'node-red', type: 'namedmap', options: { named_map: { name: 'node-red@cronplus', params: { color: '#5CA2D1' }, layers: [{}] } } }) .addTo(map) .done(function (layer) { layer.setInteraction(true) if (showPopupOnOpen) { const content = '<div><span>Lat:</span> <span>' + selectedLocation.lat + '</span></div>' + '<div><span>Lon:</span> <span>' + selectedLocation.lng + '</span></div>' showMapPopup(content, selectedLocation.lat, selectedLocation.lng, map) } // eslint-disable-next-line no-unused-vars layer.on('featureClick', function (e, latlng, pos, data, layer) { if (RED._cron_plus_debug) console.debug('click', latlng, data) }) $('#cron-plus-map-dialog').dialog('option', 'position', { my: 'center', at: 'center', of: window }) }) } function showMap (srcInput) { $('#cron-plus-map-dialog').dialog('open') if (navigator.onLine === true) { $('.cron-plus-map-not-loaded').show() $('.cron-plus-map-loading').hide() $('#cron-plus-map').hide() const proto = location.protocol === 'https:' ? location.protocol : 'http:' checkLoadJsCssFile(proto + '//libs.cartocdn.com/cartodb.js/v3/3.11/themes/css/cartodb.css', 'css', function () { checkLoadJsCssFile(proto + '//libs.cartocdn.com/cartodb.js/v3/3.11/cartodb.uncompressed.js', 'js', function () { createMap(srcInput) }) }) } else { $('.cron-plus-map-not-loaded').show() $('.cron-plus-map-loading').hide() $('#cron-plus-map').hide() } } function updateEditorLayout () { onSchedulesExpandCompress() if (RED._cron_plus_debug) console.debug('cronplus-updateEditorLayout') const scheduleList = $('#node-cronplus-tab-static-schedules') const scheduleListFullScreen = scheduleList.css('position') === 'fixed' || document.fullscreenElement if (scheduleListFullScreen) { return // don't update layout if schedules are fullscreen } const dlg = $('#dialog-form') let height = dlg.height() - 5 const expandRow = dlg.find('.form-row-auto-height') if (expandRow && expandRow.length) { const childRows = dlg.find('.form-row:not(.form-row-auto-height)') for (let i = 0; i < childRows.size(); i++) { const cr = $(childRows[i]) if (cr.is(':visible')) { height -= cr.outerHeight(true) } } expandRow.css('height', height + 'px') } const show = $('#node-input-defaultLocation').typedInput('type') === 'default' || !$('#node-input-defaultLocation').typedInput('type') $('.cron-node-input-option-location-div').toggleClass('cron-plus-hide-per-location', !show) } $.widget('custom.combobox', { _create: function () { const that = this this.input = this.element this.input.addClass('red-ui-typedInput-input') this.elementDiv = $(this.input).wrap('<div>').parent().addClass('red-ui-typedInput-input-wrap') this.uiSelect = $(this.elementDiv).wrap($('<div>', { style: this.options.style })).parent() const attrStyle = this.options.style || this.element.attr('style') this.uiWidth = this.input.outerWidth() this.input.css('paddingLeft', 5) let m if ((m = /width\s*:\s*(calc\s*\(.*\)|\d+(%|px))/i.exec(attrStyle)) !== null) { this.input.css('width', 'calc(100% - 4px)') this.uiSelect.width(m[1]) this.uiWidth = null } else { this.uiSelect.width(this.uiWidth) } ['Right', 'Left'].forEach(function (d) { const m = that.element.css('margin' + d) that.uiSelect.css('margin' + d, m) that.input.css('margin' + d, 1) }) this.uiSelect.addClass('red-ui-typedInput-container') this.input.on('change', function () { that.validate() }) this.button = $('<button tabindex="0" class="red-ui-typedInput-option-expand" style="display:inline-block;width: 20px"><span class="red-ui-typedInput-option-caret"><i class="red-ui-typedInput-icon fa fa-caret-down"></i></span></button>').appendTo(this.uiSelect) // this.buttonLabel = $('<span class="red-ui-typedInput-option-label"></span>').prependTo(this.button); this.button.on('click', function (event) { event.preventDefault() event.stopPropagation() that._showDropdownMenu() }).on('keydown', function (evt) { if (evt.keyCode === 40 /* down */) { that._showDropdownMenu() } evt.stopPropagation() }).on('blur', function () { that.uiSelect.removeClass('red-ui-typedInput-focus') }).on('focus', function () { that.uiSelect.addClass('red-ui-typedInput-focus') }) this.menu = this._createMenu(this.options.menu) }, _createMenu: function (options) { if (RED._cron_plus_debug) console.debug('combobox -> _createMenu options:', options) const that = this this.disarmClick = false options = options || {} const menu = $('<div>').addClass('red-ui-typedInput-options red-ui-editor-dialog') const callback = options.options ? options.options.callback : null const beforeSelect = options.options ? options.options.beforeSelect : null const menuItems = options.menu || [] menuItems.forEach(function (opt) { if (typeof opt === 'string') { opt = { value: opt, label: opt } } const op = $('<a href="#"></a>').attr('value', opt.value).appendTo(menu) if (opt.label) { op.text(opt.label) } if (opt.title) { op.prop('title', opt.title) } if (opt.icon) { if (opt.icon.indexOf('<') === 0) { $(opt.icon).prependTo(op) } else if (opt.icon.indexOf('/') !== -1) { $('<img>', { src: opt.icon, style: 'margin-right: 4px; height: 18px;' }).prependTo(op) } else { $('<i>', { class: 'red-ui-typedInput-icon ' + opt.icon }).prependTo(op) } } else { op.css({ paddingLeft: '18px' }) } if (!opt.icon && !opt.label) { op.text(opt.value) } op.on('click', function (event) { event.preventDefault() event.stopPropagation() let cancel = false if (beforeSelect) { cancel = beforeSelect(opt) } if (!cancel) { if (callback) { that.value(callback(opt.value)) } else { that.value(opt.value) } } that._hideMenu(menu) }) }) menu.css({ display: 'none' }) menu.appendTo(document.body) menu.on('keydown', function (evt) { if (evt.keyCode === 40/* down */) { evt.preventDefault() $(this).children(':focus').next().trigger('focus') } else if (evt.keyCode === 38/* up */) { evt.preventDefault() $(this).children(':focus').prev().trigger('focus') } else if (evt.keyCode === 27/* escape */) { evt.preventDefault() that._hideMenu(menu) } evt.stopPropagation() }) return menu }, _showDropdownMenu: function () { if (this.menu) { this.menu.css({ minWidth: this.uiSelect.width() }) this._showMenu(this.menu, this.button) } }, _hideMenu: function (menu) { $(document).off('mousedown.red-ui-typedInput-close-property-select') menu.hide() menu.css({ height: 'auto' }) this.input.trigger('focus') // this.button.trigger("focus"); }, _showMenu: function (menu, relativeTo) { if (this.disarmClick) { this.disarmClick = false return } const that = this // var pos = relativeTo.offset(); // var height = relativeTo.height(); const pos = this.uiSelect.offset() const height = this.uiSelect.height() const menuHeight = menu.height() const menuWidth = menu.width() let top = (height + pos.top) let left = pos.left if (left + menuWidth > $(window).width()) { left -= (left + menuWidth) - $(window).width() + 5 } if (top + menuHeight > $(window).height()) { top -= (top + menuHeight) - $(window).height() + 5 } if (top < 0) { menu.height(menuHeight + top) top = 0 } menu.css({ top: top + 'px', left: (left) + 'px' }) menu.slideDown(100) this._delay(function () { that.uiSelect.addClass('red-ui-typedInput-focus') $(document).on('mousedown.red-ui-typedInput-close-property-select', function (event) { if (!$(event.target).closest(menu).length) { that._hideMenu(menu) } if ($(event.target).closest(relativeTo).length) { that.disarmClick = true event.preventDefault() } }) }) }, _destroy: function () { if (this.menu) { this.menu.remove() } this.button.remove() this.element.unwrap() this.element.unwrap() this.input.removeClass('red-ui-typedInput-input') }, value: function (value) { const that = this if (!arguments.length) { return that.input.val() } else { that.input.val(value) that.validate() } }, validate: function () { let ok const value = this.value() const _validate = this.options.validate// || this.validate; if (!_validate || typeof _validate !== 'function') { ok = true } else { ok = _validate(value) } if (ok) { this.uiSelect.removeClass('input-error') } else { this.uiSelect.addClass('input-error') } return ok }, showMenu: function () { this.button.show() }, hideMenu: function () { this.button.hide() }, show: function () { this.uiSelect.show() }, hide: function () { this.uiSelect.hide() } }) RED.nodes.registerType('cronplus', { category: 'input', icon: 'timer.png', color: '#a6bbcf', inputs: 1, outputs: 1, defaults: { name: { value: '' }, // FUTURE config: { type: "CRON-PLUS Config" }, outputField: { value: 'payload' }, timeZone: { value: '' }, persistDynamic: { value: undefined }, storeName: { value: '' }, commandResponseMsgOutput: { value: 'output1' }, defaultLocation: { value: '' }, defaultLocationType: { value: '' }, outputs: { value: 1 }, options: { value: [{ payload: '', topic: '', expression: '' }], validate: function (value) { const dupCheck = {} if (value.length) { for (let i = 0; i < value.length; i++) { if (!value[i].name && value[i].topic) { value[i].name = value[i].topic } if (!value[i].name) { console.warn('cron-plus: validation error - schedule name missing') $('#node-input-option-container > li:nth-child(' + (i + 1) + ') .node-input-option-name').addClass('input-error') return false } else { $('#node-input-option-container > li:nth-child(' + (i + 1) + ') .node-input-option-name').removeClass('input-error') } if (dupCheck[value[i].name]) { console.warn("cron-plus: validation error - duplicate schedule named '" + value[i].name + "'") $('#node-input-option-container > li:nth-child(' + (i + 1) + ') .node-input-option-name').addClass('input-error') return false } else { $('#node-input-option-container > li:nth-child(' + (i + 1) + ') .node-input-option-name').removeClass('input-error') } dupCheck[value[i].name] = true if (!value[i].expressionType || value[i].expressionType === 'cron') { if (!value[i].expression) { console.warn('cron-plus: validation error - expression missing') // $("#node-input-option-container > li:nth-child(" + (i+1) + ") .node-input-option-expressionType").addClass("input-error"); return false } } else if (value[i].expressionType === 'solar') { if (value[i].solarType !== 'all' && value[i].solarType !== 'selected') { console.warn("cron-plus: validation error - solarType is not 'all' or 'selected'") // $("#node-input-option-container > li:nth-child(" + (i+1) + ") .node-input-option-solarType").addClass("input-error"); return false } if (value[i].solarType === 'selected' && !value[i].solarEvents) { console.warn('cron-plus: validation error - solarEvents missing') return false } } } } return true }, required: true } }, label: function () { return this.name || 'cron-plus' }, outputLabels: function (index) { const node = this const fanOut = node.commandResponseMsgOutput === 'fanOut' const hasCommandOutputPin = !!((node.commandResponseMsgOutput === 'output2' || fanOut)) const optionCount = node.options ? node.options.length : 0 let dynOutputPinIndex = 0 let cmdOutputPinIndex = hasCommandOutputPin ? 1 : 0 if (fanOut) { dynOutputPinIndex = optionCount cmdOutputPinIndex = optionCount + 1 } if (!fanOut && !hasCommandOutputPin) return 'All messages and events' if (!fanOut && hasCommandOutputPin && index === 0) return 'Static and Dynamic schedule messages' if (index === cmdOutputPinIndex) return 'Command responses only' if (index === dynOutputPinIndex) return 'Dynamic schedules only' const item = node.options && node.options[index] if (item) return item.name + ' (' + item.expressionType + ')' }, oneditprepare: function () { if (RED._cron_plus_debug) { console.debug('oneditprepare - cronplus') } const node = this const dupCheck = {} node.commandResponseMsgOutput = ['output1', 'output2', 'fanOut'].indexOf(node.commandResponseMsgOutput) >= 0 ? node.commandResponseMsgOutput : 'output1' // eslint-disable-next-line no-undef initMap() // inherit/upgrade deprecated properties from config const hasStoreNameProperty = Object.prototype.hasOwnProperty.call(node, 'storeName') && typeof node.storeName === 'string' const hasDeprecatedPersistDynamic = Object.prototype.hasOwnProperty.call(node, 'persistDynamic') && typeof node.persistDynamic === 'boolean' if (hasStoreNameProperty) { // not an upgrade - accept node.storeName property value } else if (hasDeprecatedPersistDynamic) { // upgrade from older version node.storeName = node.persistDynamic ? 'file' : '' // default to file - that was the only option before } // populate store names const currentStore = node.storeName || '' $('#node-input-storeName').empty() $('#node-input-storeName').append('<option value="">None: Don\'t persist state</option>') $('#node-input-storeName').append('<option value="file">File: local file system</option>') RED.settings.context.stores.forEach(function (item) { const defaultStore = Object.hasOwnProperty.call(RED.settings, 'context') ? RED.settings.context.default : '' if (!item) { return // skip empty store names } let name = item if (item === defaultStore) { name += ' (default)' } const opt = $(`<option value="${item}">${'Node Context: ' + name}</option>`) $('#node-input-storeName').append(opt) }) // if the store does not exist, add it to the dropdown and select it (appended with the text "NOT AVAILABLE!") if (currentStore && currentStore !== 'file' && RED.settings.context.stores.indexOf(currentStore) === -1) { const opt = $(`<option selected disabled style="color: var(--red-ui-text-color-error) !important;" value="${currentStore}">${'Node Context: ' + currentStore + ' (INVALID)'}</option>`) $('#node-input-storeName').append(opt) } else { // select the current option $('#node-input-storeName').val(currentStore) } // // hook up the persistDynamic change event - enable/disable storeName select // $("#node-input-persistDynamic").on("change", function(e) { // // $("#node-input-storeName").prop("disabled", !$(this).prop("checked")) // $('#node-input-storeName').toggle($(this).prop("checked")) // }); // create common location typed input $('#node-input-defaultLocation').typedInput({ default: 'default', types: [ { value: 'default', label: 'Location per schedule', hasValue: false, icon: 'fa fa-map-marker' }, { value: 'fixed', label: 'Fixed Location', icon: 'fa fa-map-pin', expand: function () { // eslint-disable-next-line no-undef showMap($('#node-input-defaultLocation')) }, validate: function (v, opt) { return !!v && v.length >= 3 } }, 'env' ] }) $('#node-input-defaultLocation').typedInput('type', node.defaultLocationType || 'default') $('#node-input-defaultLocation').typedInput('value', node.defaultLocation || '') $('#node-input-defaultLocation').on('change', function () { const show = $('#node-input-defaultLocation').typedInput('type') === 'default' || !$('#node-input-defaultLocation').typedInput('type') $('.cron-node-input-option-location-div').toggleClass('cron-plus-hide-per-location', !show) }) // create the popup dialog for the cron builder $('#cron-plus-expression-builder-dialog').dialog({ autoOpen: false, height: 300, width: 480, minWidth: 400, maxWidth: 600, modal: true, buttons: { Cancel: func