Эх сурвалжийг харах

feat: a Date and Time node, honest about having no timezone database

Supports UTC, host local time via offset strings, and explicit
+02:00-style offsets only - QuickJS here has no Intl, so named
timezones like Europe/Budapest cannot be resolved and no DST table
is embedded. Guards outputField against the reserved "timestamp" key
the node also always returns, with a dedicated error fixture.
fszontagh 1 сар өмнө
parent
commit
9c0601c861

+ 200 - 0
nodes/core/datetime.js

@@ -0,0 +1,200 @@
+/**
+ * @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'
+        },
+        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 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 value;
+    }
+    if (/^\d+$/.test(String(value))) {
+        return parseInt(String(value), 10);
+    }
+    const parsed = Date.parse(String(value));
+    if (isNaN(parsed)) {
+        throw new Error('Date and Time: could not read "' + value + '" as a date');
+    }
+    return parsed;
+}
+
+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 minutes = parseInt(match[2], 10) * 60 + parseInt(match[3], 10);
+    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 delta = (Number(config.amount) || 0) * 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 };

+ 17 - 0
tests/nodes/datetime-errors.json

@@ -0,0 +1,17 @@
+{
+  "name": "verify-datetime-errors",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Fixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { when: '2026-01-15T10:30:00Z' };"}},
+    {"id": "n3", "name": "DatetimeReservedName", "type": "datetime", "position": {"x": 0, "y": 200},
+     "config": {"operation": "format", "inputField": "data.result.when", "outputField": "timestamp"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "failed"}
+  }
+}

+ 33 - 0
tests/nodes/datetime.json

@@ -0,0 +1,33 @@
+{
+  "name": "verify-datetime",
+  "nodes": [
+    {"id": "n1", "name": "Trigger", "type": "click-trigger", "position": {"x": 0, "y": 0}, "config": {}},
+    {"id": "n2", "name": "Fixture", "type": "code", "position": {"x": 0, "y": 100},
+     "config": {"code": "return { when: '2026-01-15T10:30:00Z', later: '2026-01-20T10:30:00Z' };"}},
+    {"id": "n3", "name": "FormatUTC", "type": "datetime", "position": {"x": -150, "y": 200},
+     "config": {"operation": "format", "inputField": "data.result.when", "format": "YYYY-MM-DD HH:mm:ss", "outputField": "value"}},
+    {"id": "n4", "name": "FormatOffset", "type": "datetime", "position": {"x": -50, "y": 200},
+     "config": {"operation": "format", "inputField": "data.result.when", "format": "YYYY-MM-DD HH:mm:ss", "offset": "+02:00", "outputField": "value"}},
+    {"id": "n5", "name": "AddDays", "type": "datetime", "position": {"x": 50, "y": 200},
+     "config": {"operation": "add", "inputField": "data.result.when", "amount": 3, "unit": "days", "format": "YYYY-MM-DD", "outputField": "value"}},
+    {"id": "n6", "name": "Diff", "type": "datetime", "position": {"x": 150, "y": 200},
+     "config": {"operation": "diff", "inputField": "data.result.later", "secondField": "data.result.when", "unit": "days", "outputField": "value"}},
+    {"id": "n7", "name": "Now", "type": "datetime", "position": {"x": 250, "y": 200},
+     "config": {"operation": "now"}}
+  ],
+  "connections": [
+    {"sourceNodeId": "n1", "sourceOutput": "main", "targetNodeId": "n2", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n3", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n4", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n5", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n6", "targetInput": "data"},
+    {"sourceNodeId": "n2", "sourceOutput": "main", "targetNodeId": "n7", "targetInput": "data"}
+  ],
+  "expect": {
+    "n3": {"status": "completed", "output": {"value": "2026-01-15 10:30:00"}},
+    "n4": {"status": "completed", "output": {"value": "2026-01-15 12:30:00"}},
+    "n5": {"status": "completed", "output": {"value": "2026-01-18"}},
+    "n6": {"status": "completed", "output": {"value": 5}},
+    "n7": {"status": "completed"}
+  }
+}