node-red-contrib-chronos
Version:
Time-based Node-RED scheduling, repeating, queueing, routing, filtering and manipulating nodes
1,006 lines (913 loc) • 42.4 kB
JavaScript
/*
* Copyright (c) 2020 - 2026 Jens-Uwe Rossbach
*
* This code is licensed under the MIT License.
*
* 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.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
module.exports = function(RED)
{
function ChronosChangeNode(settings)
{
const TOKENIZER_PATTERN = /(\[[^[]*\])|(y|M|w|d|hh?|mm?|ss?|SS?S?|.)/g;
const node = this;
RED.nodes.createNode(this, settings);
node.RED = RED;
node.chronos = require("./common/chronos.js");
node.name = settings.name;
node.config = RED.nodes.getNode(settings.config);
if (!node.config)
{
node.status({fill: "red", shape: "dot", text: "node-red-contrib-chronos/chronos-config:common.status.noConfig"});
node.error(RED._("node-red-contrib-chronos/chronos-config:common.error.noConfig"));
return;
}
if (!node.chronos.validateConfiguration(node))
{
node.status({fill: "red", shape: "dot", text: "node-red-contrib-chronos/chronos-config:common.status.invalidConfig"});
node.error(RED._("node-red-contrib-chronos/chronos-config:common.error.invalidConfig"));
return;
}
if (settings.rules.length == 0)
{
node.status({fill: "red", shape: "dot", text: "change.status.noRules"});
node.error(RED._("change.error.noRules"));
return;
}
node.chronos.printNodeInfo(node);
node.status({});
node.mode = (typeof settings.mode == "undefined") ? "moment" : settings.mode;
node.rules = settings.rules;
let valid = true;
for (let i=0; i<node.rules.length; ++i)
{
const rule = node.rules[i];
if (node.mode == "moment")
{
// backward compatibility to 1.24.0 and below
if (rule.action == "change")
{
if (rule.type == "toString")
{
rule.action = "convert";
delete rule.type;
// backward compatibility to v1.19.1 and below
if (!rule.formatType)
{
rule.formatType = "custom";
}
// backward compatibility to v1.21.0 and below
if (rule.formatType == "relative")
{
rule.formatType = "predefined";
rule.format = "relative";
}
else if (rule.formatType == "calendar")
{
rule.formatType = "predefined";
rule.format = "calendar";
}
else if (rule.formatType == "iso8601")
{
rule.formatType = "predefined";
rule.format = "iso8601";
}
else if (rule.formatType == "iso8601utc")
{
rule.formatType = "predefined";
rule.format = "iso8601utc";
}
if (typeof rule.tzType == "undefined")
{
rule.tzType = "current";
rule.tzValue = "";
}
}
else if ((rule.type == "set") || (rule.type == "add") || (rule.type == "subtract"))
{
if (typeof rule.valueType == "undefined")
{
rule.valueType = "num";
}
}
}
if ((rule.action == "set") && (rule.type == "date"))
{
if (!node.chronos.isValidUserDate(rule.date))
{
valid = false;
break;
}
if ((rule.time.type == "time") && !node.chronos.isValidUserTime(rule.time.value))
{
valid = false;
break;
}
}
else if (
(rule.action == "convert") &&
(rule.formatType == "custom") &&
!rule.format)
{
valid = false;
break;
}
}
else if (rule.action == "set")
{
if ((rule.time1.type == "time") && !node.chronos.isValidUserTime(rule.time1.value, false))
{
valid = false;
break;
}
if ((rule.time2.type == "time") && !node.chronos.isValidUserTime(rule.time2.value, false))
{
valid = false;
break;
}
}
}
if (!valid)
{
node.status({fill: "red", shape: "dot", text: "node-red-contrib-chronos/chronos-config:common.status.invalidConfig"});
node.error(RED._("node-red-contrib-chronos/chronos-config:common.error.invalidConfig"));
return;
}
node.on("input", async(msg, send, done) =>
{
if (msg)
{
if (!send || !done) // Node-RED 0.x not supported anymore
{
return;
}
for (let i=0; i<node.rules.length; ++i)
{
try
{
let rule = node.rules[i];
if (node.mode == "moment")
{
if (rule.action == "set")
{
switch (rule.type)
{
case "now":
{
setTarget(msg, rule.target, Date.now());
break;
}
case "date":
{
setTarget(
msg,
rule.target,
node.chronos.getTime(
node,
node.chronos.getUserDate(
node,
rule.date),
rule.time.type,
rule.time.value).valueOf());
break;
}
case "jsonata":
{
let expression = null;
let result = null;
try
{
expression = node.chronos.getJSONataExpression(node, rule.expression);
// time change node specific JSONata extensions
expression.assign("target", getValue(msg, rule.target.name, rule.target.type));
expression.registerFunction("set", (ts, part, value) =>
{
return setPart(node.chronos.getTimeFrom(node, ts), part, value);
}, "<(sn)sn:n>");
expression.registerFunction("add", (ts, value, unit) =>
{
return node.chronos.getTimeFrom(node, ts).add(value, unit).valueOf();
}, "<(sn)ns:n>");
expression.registerFunction("subtract", (ts, value, unit) =>
{
return node.chronos.getTimeFrom(node, ts).subtract(value, unit).valueOf();
}, "<(sn)ns:n>");
expression.registerFunction("startOf", (ts, arg) =>
{
return node.chronos.getTimeFrom(node, ts).startOf(arg).valueOf();
}, "<(sn)s:n>");
expression.registerFunction("endOf", (ts, arg) =>
{
return node.chronos.getTimeFrom(node, ts).endOf(arg).valueOf();
}, "<(sn)s:n>");
result = await node.chronos.evaluateJSONataExpression(RED, expression, msg);
}
catch (e)
{
if (e instanceof node.chronos.TimeError)
{
throw e;
}
else
{
const details = {rule: i+1, expression: rule.expression, code: e.code, description: e.message, position: e.position, token: e.token};
if (e.value)
{
details.value = e.value;
}
throw new node.chronos.TimeError(
RED._("node-red-contrib-chronos/chronos-config:common.error.evaluationFailed"),
details);
}
}
if ((typeof result != "number") && (typeof result != "string"))
{
throw new node.chronos.TimeError(
RED._("node-red-contrib-chronos/chronos-config:common.error.notTime"),
{rule: i+1, expression: rule.expression, result: result});
}
setTarget(msg, rule.target, result);
break;
}
}
}
else if ((rule.action == "change") || (rule.action == "convert"))
{
let input = undefined;
let property = getValue(msg, rule.target.name, rule.target.type);
if ((typeof property == "number") || (typeof property == "string"))
{
input = node.chronos.getTime(
node,
node.chronos.getCurrentTime(node),
(typeof property == "number")
? "time"
: "auto",
property);
}
if (input)
{
let output = undefined;
if (rule.action == "change")
{
switch (rule.type)
{
case "set":
{
output = setPart(
input,
rule.part,
getNumber(
getValue(
msg,
rule.value,
rule.valueType)));
break;
}
case "add":
{
output = addTime(
input,
getValue(
msg,
rule.value,
rule.valueType),
rule.unit).valueOf();
break;
}
case "subtract":
{
output = subtractTime(
input,
getValue(
msg,
rule.value,
rule.valueType),
rule.unit).valueOf();
break;
}
case "startOf":
{
input.startOf(rule.arg);
output = input.valueOf();
break;
}
case "endOf":
{
input.endOf(rule.arg);
output = input.valueOf();
break;
}
}
}
else if (rule.action == "convert")
{
if (rule.tzType == "timeZone")
{
input.tz(rule.tzValue);
}
else if (rule.tzType == "utcOffset")
{
// if it's a number, convert it to a number
if (+rule.tzValue === +rule.tzValue)
{
rule.tzValue = +rule.tzValue;
}
input.utcOffset(rule.tzValue);
}
if (rule.formatType == "custom")
{
output = input.format(rule.format);
}
else if (rule.formatType == "predefined")
{
if (rule.format == "calendar")
{
output = input.calendar();
}
else if (rule.format == "relative")
{
output = input.fromNow();
}
else if (rule.format == "regional")
{
output = input.format("L LTS");
}
else if (rule.format == "regionalDate")
{
output = input.format("L");
}
else if (rule.format == "regionalTime")
{
output = input.format("LTS");
}
else if (rule.format == "iso8601")
{
output = input.toISOString(true);
}
else if (rule.format == "iso8601utc")
{
output = input.toISOString();
}
}
}
setTarget(msg, rule.target, output);
}
else
{
let prop = rule.target.name;
if ((rule.target.type == "global") || (rule.target.type == "flow"))
{
let ctx = RED.util.parseContextStore(rule.target.name);
prop = ctx.key + (ctx.store ? " (" + ctx.store + ")" : "");
}
node.error(RED._("change.error.invalidProperty", {property: rule.target.type + "." + prop}), msg);
}
}
}
else if (rule.action == "set")
{
const now = node.chronos.getCurrentTime(node);
let time1 =
(rule.time1.type == "now")
? now
: node.chronos.retrieveTime(
node,
msg,
now.clone(),
rule.time1.type,
rule.time1.value);
let time2 =
(rule.time2.type == "now")
? now
: node.chronos.retrieveTime(
node,
msg,
now.clone(),
rule.time2.type,
rule.time2.value);
if (time1.isAfter(time2))
{
if (time1.isSame(time2, "day"))
{
if (rule.time2.type == "now")
{
// shift time1 one day into the past
time1 = node.chronos.retrieveTime(
node,
msg,
now.clone().subtract(1, "day"),
rule.time1.type,
rule.time1.value);
}
else
{
// shift time2 one day into the future
time2 = node.chronos.retrieveTime(
node,
msg,
now.clone().add(1, "day"),
rule.time2.type,
rule.time2.value);
}
}
else
{
// flip the two times
const time = time2;
time2 = time1;
time1 = time;
}
}
setTarget(
msg,
rule.target,
time2.diff(time1));
}
else if ((rule.action == "change") || (rule.action == "convert"))
{
let input = undefined;
let property = getValue(msg, rule.target.name, rule.target.type);
if ((typeof property == "number") || (typeof property == "string"))
{
input = ((rule.action == "change") && (rule.type == "numval"))
? property : node.chronos.getDuration(node, property);
}
if (input)
{
let output = undefined;
if (rule.action == "change")
{
switch (rule.type)
{
case "add":
{
output = addTime(
input,
getValue(
msg,
rule.value,
rule.valueType),
(rule.unit == "milliseconds")
? undefined
: rule.unit).asMilliseconds();
break;
}
case "subtract":
{
output = subtractTime(
input,
getValue(
msg,
rule.value,
rule.valueType),
(rule.unit == "milliseconds")
? undefined
: rule.unit).asMilliseconds();
break;
}
}
}
else if (rule.action == "convert")
{
if (rule.formatType == "custom")
{
output = formatCustomString(input, rule.format);
}
else if (rule.formatType == "string")
{
switch (rule.format)
{
case "timespan":
{
output = formatTimespanString(input, 1);
break;
}
case "timespan10th":
{
output = formatTimespanString(input, 10);
break;
}
case "timespan100th":
{
output = formatTimespanString(input, 100);
break;
}
case "timespanMillis":
{
output = formatTimespanString(input, 1000);
break;
}
case "textualTimespan":
{
output = input.humanize();
break;
}
case "iso8601":
{
output = formatISOString(input);
break;
}
}
}
else
{
output = input.as(rule.format);
if (rule.precisionType == "int")
{
switch (rule.precision)
{
case "round":
{
output = Math.round(output);
break;
}
case "floor":
{
output = Math.floor(output);
break;
}
case "ceil":
{
output = Math.ceil(output);
break;
}
}
}
else if ((rule.precisionType == "float") && (rule.precision > 0))
{
const exp = Math.pow(10, rule.precision);
output = Math.round(output * exp) / exp;
}
}
}
setTarget(msg, rule.target, output);
}
else
{
let prop = rule.target.name;
if ((rule.target.type == "global") || (rule.target.type == "flow"))
{
let ctx = RED.util.parseContextStore(rule.target.name);
prop = ctx.key + (ctx.store ? " (" + ctx.store + ")" : "");
}
node.error(RED._("change.error.invalidProperty", {property: rule.target.type + "." + prop}), msg);
}
}
}
catch (e)
{
if (e instanceof node.chronos.TimeError)
{
let errMsg = RED.util.cloneMessage(msg);
if ("errorDetails" in errMsg)
{
errMsg._errorDetails = errMsg.errorDetails;
}
errMsg.errorDetails = e.details;
node.error(e.message, errMsg);
}
else
{
throw e;
}
}
}
node.send(msg);
done();
}
});
function setPart(input, part, value)
{
switch (part)
{
case "year":
{
input.year(value);
break;
}
case "quarter":
{
input.quarter(value);
break;
}
case "month":
{
input.month(value - 1);
break;
}
case "week":
{
input.week(value);
break;
}
case "weekday":
{
input.weekday(value - 1);
break;
}
case "day":
{
input.date(value);
break;
}
case "hour":
{
input.hour(value);
break;
}
case "minute":
{
input.minute(value);
break;
}
case "second":
{
input.second(value);
break;
}
case "millisecond":
{
input.millisecond(value);
break;
}
}
return input.valueOf();
}
function addTime(input, value, unit)
{
return input.add(getNumber(value), unit);
}
function subtractTime(input, value, unit)
{
return input.subtract(getNumber(value), unit);
}
function getNumber(value)
{
node.debug("val: " + value + " type: " + (typeof value));
if ((typeof value != "number") && (typeof value != "string"))
{
throw new node.chronos.TimeError(RED._("change.error.invalidNumber"), {value: value});
}
if (typeof value == "string")
{
if ((value.length > 0) && (+value === +value))
{
value = +value;
}
else
{
throw new node.chronos.TimeError(RED._("change.error.invalidNumber"), {value: value});
}
}
node.debug("val: " + value + " type: " + (typeof value));
return value;
}
function getValue(msg, value, type)
{
let ret = undefined;
if (type == "env")
{
if (typeof value == "string")
{
ret = RED.util.evaluateNodeProperty(value, type, node);
}
if ((typeof ret == "undefined") || ((typeof ret == "string") && (ret.length == 0)))
{
ret = value;
}
}
else if ((type == "global") || (type == "flow"))
{
const ctx = RED.util.parseContextStore(value);
ret = node.context()[type].get(ctx.key, ctx.store);
}
else if (type == "msg")
{
ret = RED.util.getMessageProperty(msg, value);
}
else
{
ret = value;
}
return ret;
}
function setTarget(msg, target, value)
{
if ((target.type == "global") || (target.type == "flow"))
{
const ctx = RED.util.parseContextStore(target.name);
node.context()[target.type].set(ctx.key, value, ctx.store);
}
else if (target.type == "msg")
{
RED.util.setMessageProperty(msg, target.name, value, true);
}
}
function formatTimespanString(input, precision)
{
let ret = "";
const days = Math.floor(input.asDays());
if (days > 0)
{
ret += days + ".";
}
const hours = input.hours();
if (hours > 0)
{
ret += String(hours).padStart(2, "0") + ":";
}
else if (ret.length > 0)
{
ret += "00:";
}
const minutes = input.minutes();
if (minutes > 0)
{
ret += String(minutes).padStart(2, "0") + ":";
}
else if (ret.length > 0)
{
ret += "00:";
}
const seconds = input.seconds();
if (seconds > 0)
{
ret += String(seconds).padStart(2, "0");
}
else if (ret.length > 0)
{
ret += "00:";
}
if (precision != 1)
{
if (ret.length > 0)
{
ret += ".";
}
const milliseconds = input.milliseconds();
if (milliseconds > 0)
{
switch (precision)
{
case 10:
{
ret += Math.floor(milliseconds/100);
break;
}
case 100:
{
ret += Math.floor(milliseconds/10);
break;
}
case 1000:
{
ret += milliseconds;
break;
}
}
}
}
return ret;
}
function formatISOString(input)
{
let ret = "P";
const years = input.years();
if (years > 0)
{
ret += years + "Y";
}
const months = input.months();
if (months > 0)
{
ret += months + "M";
}
const days = input.days();
if (days > 0)
{
ret += days + "D";
}
let time = "T";
const hours = input.hours();
if (hours > 0)
{
time += hours + "H";
}
const minutes = input.minutes();
if (minutes > 0)
{
time += minutes + "M";
}
const seconds = input.seconds();
if (seconds > 0)
{
time += seconds;
const milliseconds = input.milliseconds();
if (milliseconds > 0)
{
time += "." + String(milliseconds).padStart(3, "0");
}
time += "S";
}
if (time != "T")
{
ret += time;
}
if (ret == "P")
{
ret += "T0S";
}
return ret;
}
function formatCustomString(input, format)
{
let ret = "";
const tokens = format.match(TOKENIZER_PATTERN);
if (tokens)
{
for (const token of tokens)
{
if (token == "y")
{
ret += input.years();
}
else if (token == "M")
{
ret += input.months();
}
else if (token == "w")
{
ret += input.weeks();
}
else if (token == "d")
{
ret += input.days();
}
else if (token == "h")
{
ret += input.hours();
}
else if (token == "hh")
{
ret += String(input.hours()).padStart(2, "0");
}
else if (token == "m")
{
ret += input.minutes();
}
else if (token == "mm")
{
ret += String(input.minutes()).padStart(2, "0");
}
else if (token == "s")
{
ret += input.seconds();
}
else if (token == "ss")
{
ret += String(input.seconds()).padStart(2, "0");
}
else if (token == "S")
{
ret += Math.floor(input.milliseconds() / 100);
}
else if (token == "SS")
{
ret += String(Math.floor(input.milliseconds() / 10)).padStart(2, "0");
}
else if (token == "SSS")
{
ret += String(input.milliseconds()).padStart(3, "0");
}
else if (token.startsWith("["))
{
ret += token.substring(1, token.length-1);
}
else
{
ret += token;
}
}
return ret;
}
}
}
RED.nodes.registerType("chronos-change", ChronosChangeNode);
};