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
HTML
<!--
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