|
|
@@ -0,0 +1,221 @@
|
|
|
+/**
|
|
|
+ * @node datetime
|
|
|
+ * @name Date and Time
|
|
|
+ * @category data
|
|
|
+ * @version 1.0.0
|
|
|
+ * @description Parse, format and shift timestamps. Offsets such as +02:00 only, not named timezones
|
|
|
+ * @icon calendar
|
|
|
+ */
|
|
|
+
|
|
|
+const configSchema = {
|
|
|
+ type: 'object',
|
|
|
+ properties: {
|
|
|
+ operation: {
|
|
|
+ type: 'string',
|
|
|
+ title: 'Operation',
|
|
|
+ enum: ['now', 'parse', 'format', 'add', 'subtract', 'diff'],
|
|
|
+ default: 'format'
|
|
|
+ },
|
|
|
+ inputField: {
|
|
|
+ type: 'string',
|
|
|
+ title: 'Input Field',
|
|
|
+ description: 'Path to the date, as an ISO string or a millisecond timestamp',
|
|
|
+ default: 'data'
|
|
|
+ },
|
|
|
+ secondField: {
|
|
|
+ type: 'string',
|
|
|
+ title: 'Second Date Field',
|
|
|
+ description: 'Path to the date to compare against, for the diff operation. diff returns the input date minus this one, so a later input gives a positive result'
|
|
|
+ },
|
|
|
+ format: {
|
|
|
+ type: 'string',
|
|
|
+ title: 'Format',
|
|
|
+ description: 'Pattern of YYYY MM DD HH mm ss, or iso for a full ISO 8601 string',
|
|
|
+ default: 'YYYY-MM-DD HH:mm:ss'
|
|
|
+ },
|
|
|
+ amount: {
|
|
|
+ type: 'number',
|
|
|
+ title: 'Amount',
|
|
|
+ description: 'How much to add or subtract',
|
|
|
+ default: 0
|
|
|
+ },
|
|
|
+ unit: {
|
|
|
+ type: 'string',
|
|
|
+ title: 'Unit',
|
|
|
+ enum: ['milliseconds', 'seconds', 'minutes', 'hours', 'days', 'weeks'],
|
|
|
+ default: 'days'
|
|
|
+ },
|
|
|
+ offset: {
|
|
|
+ type: 'string',
|
|
|
+ title: 'UTC Offset',
|
|
|
+ description: 'Offset applied when formatting, such as +02:00. Empty means UTC',
|
|
|
+ default: ''
|
|
|
+ },
|
|
|
+ outputField: {
|
|
|
+ type: 'string',
|
|
|
+ title: 'Output Field',
|
|
|
+ default: 'value'
|
|
|
+ }
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const inputSchema = {
|
|
|
+ type: 'object',
|
|
|
+ properties: {
|
|
|
+ data: { type: 'any' }
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const outputSchema = {
|
|
|
+ type: 'object',
|
|
|
+ properties: {
|
|
|
+ value: { type: 'any', description: 'The formatted string, timestamp or difference' },
|
|
|
+ timestamp: { type: 'number', description: 'The result as milliseconds since the epoch' }
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const UNIT_MS = {
|
|
|
+ milliseconds: 1,
|
|
|
+ seconds: 1000,
|
|
|
+ minutes: 60000,
|
|
|
+ hours: 3600000,
|
|
|
+ days: 86400000,
|
|
|
+ weeks: 604800000
|
|
|
+};
|
|
|
+
|
|
|
+function checkRange(parsed, value) {
|
|
|
+ if (!isFinite(parsed) || Math.abs(parsed) > 8.64e15) {
|
|
|
+ throw new Error('Date and Time: "' + value + '" is not a usable date - it is outside the range JavaScript dates can represent');
|
|
|
+ }
|
|
|
+ return parsed;
|
|
|
+}
|
|
|
+
|
|
|
+function toTimestamp(value, label) {
|
|
|
+ if (value === undefined || value === null || value === '') {
|
|
|
+ throw new Error('Date and Time: no date found at "' + label + '"');
|
|
|
+ }
|
|
|
+ if (typeof value === 'number') {
|
|
|
+ return checkRange(value, value);
|
|
|
+ }
|
|
|
+ if (/^\d+$/.test(String(value))) {
|
|
|
+ return checkRange(parseInt(String(value), 10), value);
|
|
|
+ }
|
|
|
+ const parsed = Date.parse(String(value));
|
|
|
+ if (isNaN(parsed)) {
|
|
|
+ throw new Error('Date and Time: could not read "' + value + '" as a date');
|
|
|
+ }
|
|
|
+ return checkRange(parsed, value);
|
|
|
+}
|
|
|
+
|
|
|
+function offsetMinutes(offset) {
|
|
|
+ if (!offset) return 0;
|
|
|
+ const match = String(offset).match(/^([+-])(\d{2}):?(\d{2})$/);
|
|
|
+ if (!match) {
|
|
|
+ throw new Error('Date and Time: offset must look like +02:00, got "' + offset + '"');
|
|
|
+ }
|
|
|
+ const hours = parseInt(match[2], 10);
|
|
|
+ const mins = parseInt(match[3], 10);
|
|
|
+ const maxHours = match[1] === '-' ? 12 : 14;
|
|
|
+ if (hours > maxHours || mins > 59) {
|
|
|
+ throw new Error('Date and Time: offset "' + offset +
|
|
|
+ '" is out of range. Real offsets run from -12:00 to +14:00');
|
|
|
+ }
|
|
|
+ const minutes = hours * 60 + mins;
|
|
|
+ return match[1] === '-' ? -minutes : minutes;
|
|
|
+}
|
|
|
+
|
|
|
+function pad(value, width) {
|
|
|
+ let text = String(value);
|
|
|
+ while (text.length < width) {
|
|
|
+ text = '0' + text;
|
|
|
+ }
|
|
|
+ return text;
|
|
|
+}
|
|
|
+
|
|
|
+function formatTimestamp(timestamp, pattern, offset) {
|
|
|
+ const shifted = new Date(timestamp + offsetMinutes(offset) * 60000);
|
|
|
+
|
|
|
+ if (pattern === 'iso') {
|
|
|
+ if (!offset) {
|
|
|
+ return new Date(timestamp).toISOString();
|
|
|
+ }
|
|
|
+ return pad(shifted.getUTCFullYear(), 4) + '-' + pad(shifted.getUTCMonth() + 1, 2) + '-' +
|
|
|
+ pad(shifted.getUTCDate(), 2) + 'T' + pad(shifted.getUTCHours(), 2) + ':' +
|
|
|
+ pad(shifted.getUTCMinutes(), 2) + ':' + pad(shifted.getUTCSeconds(), 2) + offset;
|
|
|
+ }
|
|
|
+
|
|
|
+ return String(pattern)
|
|
|
+ .replace(/YYYY/g, pad(shifted.getUTCFullYear(), 4))
|
|
|
+ .replace(/MM/g, pad(shifted.getUTCMonth() + 1, 2))
|
|
|
+ .replace(/DD/g, pad(shifted.getUTCDate(), 2))
|
|
|
+ .replace(/HH/g, pad(shifted.getUTCHours(), 2))
|
|
|
+ .replace(/mm/g, pad(shifted.getUTCMinutes(), 2))
|
|
|
+ .replace(/ss/g, pad(shifted.getUTCSeconds(), 2));
|
|
|
+}
|
|
|
+
|
|
|
+async function execute(config, input, context) {
|
|
|
+ const operation = config.operation || 'format';
|
|
|
+ const outputField = config.outputField || 'value';
|
|
|
+ const offset = config.offset || '';
|
|
|
+ const pattern = config.format || 'YYYY-MM-DD HH:mm:ss';
|
|
|
+ const unit = config.unit || 'days';
|
|
|
+ const step = UNIT_MS[unit];
|
|
|
+
|
|
|
+ if (outputField === 'timestamp') {
|
|
|
+ throw new Error('Date and Time: outputField cannot be "' + outputField +
|
|
|
+ '", which is a reserved output name for this node. Pick another name.');
|
|
|
+ }
|
|
|
+
|
|
|
+ if (step === undefined) {
|
|
|
+ throw new Error('Date and Time: unknown unit "' + unit + '"');
|
|
|
+ }
|
|
|
+
|
|
|
+ const result = {};
|
|
|
+
|
|
|
+ if (operation === 'now') {
|
|
|
+ const now = Date.now();
|
|
|
+ result[outputField] = formatTimestamp(now, pattern, offset);
|
|
|
+ result.timestamp = now;
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ const raw = smartbotic.utils.getFieldValue(input, config.inputField || 'data');
|
|
|
+ const timestamp = toTimestamp(raw, config.inputField || 'data');
|
|
|
+
|
|
|
+ if (operation === 'parse') {
|
|
|
+ result[outputField] = timestamp;
|
|
|
+ result.timestamp = timestamp;
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (operation === 'format') {
|
|
|
+ result[outputField] = formatTimestamp(timestamp, pattern, offset);
|
|
|
+ result.timestamp = timestamp;
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (operation === 'diff') {
|
|
|
+ const other = toTimestamp(
|
|
|
+ smartbotic.utils.getFieldValue(input, config.secondField),
|
|
|
+ config.secondField || 'secondField'
|
|
|
+ );
|
|
|
+ result[outputField] = (timestamp - other) / step;
|
|
|
+ result.timestamp = timestamp;
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+
|
|
|
+ const rawAmount = config.amount === undefined || config.amount === null || config.amount === ''
|
|
|
+ ? 0
|
|
|
+ : config.amount;
|
|
|
+ const amount = Number(rawAmount);
|
|
|
+ if (isNaN(amount)) {
|
|
|
+ throw new Error('Date and Time: amount must be a number, got "' + config.amount + '"');
|
|
|
+ }
|
|
|
+ const delta = amount * step;
|
|
|
+ const shifted = operation === 'subtract' ? timestamp - delta : timestamp + delta;
|
|
|
+ result[outputField] = formatTimestamp(shifted, pattern, offset);
|
|
|
+ result.timestamp = shifted;
|
|
|
+ return result;
|
|
|
+}
|
|
|
+
|
|
|
+module.exports = { configSchema, inputSchema, outputSchema, execute };
|