@rosepetal/node-red-contrib-utils 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,187 @@
1
+ /**
2
+ * @file Node.js logic for the Array-Select node with single output and flexible selection.
3
+ * @author Rosepetal
4
+ */
5
+
6
+ module.exports = function(RED) {
7
+ function ArraySelectNode(config) {
8
+ RED.nodes.createNode(this, config);
9
+ const node = this;
10
+
11
+ // Store configuration
12
+ node.inputPath = config.inputPath || 'payload';
13
+ node.inputPathType = config.inputPathType || 'msg';
14
+ node.outputPath = config.outputPath || 'payload';
15
+ node.outputPathType = config.outputPathType || 'msg';
16
+ node.selection = config.selection || '0';
17
+ node.asArray = config.asArray || false;
18
+
19
+ // Set initial status
20
+ node.status({
21
+ fill: "blue",
22
+ shape: "dot",
23
+ text: `Ready: ${node.selection}`
24
+ });
25
+
26
+ /**
27
+ * Parse selection string and return indices
28
+ * @param {string} selection - Selection string (e.g., "0", "1,3,5", "1:4", "0::2")
29
+ * @param {number} arrayLength - Length of input array
30
+ * @returns {Array} Array of indices or null if invalid
31
+ */
32
+ function parseSelection(selection, arrayLength) {
33
+ if (!selection || selection.trim() === '') return null;
34
+
35
+ const sel = selection.trim();
36
+
37
+ try {
38
+ // Single index (e.g., "2" or "-1")
39
+ if (/^-?\d+$/.test(sel)) {
40
+ const index = parseInt(sel);
41
+ const normalizedIndex = index < 0 ? arrayLength + index : index;
42
+ if (normalizedIndex >= 0 && normalizedIndex < arrayLength) {
43
+ return [normalizedIndex];
44
+ }
45
+ return null;
46
+ }
47
+
48
+ // Multiple indices (e.g., "0,2,4")
49
+ if (sel.includes(',')) {
50
+ const indices = sel.split(',').map(s => {
51
+ const index = parseInt(s.trim());
52
+ return index < 0 ? arrayLength + index : index;
53
+ }).filter(i => i >= 0 && i < arrayLength);
54
+ return indices.length > 0 ? indices : null;
55
+ }
56
+
57
+ // Range (e.g., "1:4" or "1:4:2")
58
+ if (sel.includes(':')) {
59
+ const parts = sel.split(':');
60
+ if (parts.length === 2) {
61
+ // Simple range: start:end
62
+ let start = parseInt(parts[0]) || 0;
63
+ let end = parseInt(parts[1]) || arrayLength;
64
+ start = start < 0 ? arrayLength + start : start;
65
+ end = end < 0 ? arrayLength + end : end;
66
+
67
+ const indices = [];
68
+ for (let i = Math.max(0, start); i < Math.min(arrayLength, end); i++) {
69
+ indices.push(i);
70
+ }
71
+ return indices.length > 0 ? indices : null;
72
+ } else if (parts.length === 3) {
73
+ // Step range: start:end:step
74
+ let start = parseInt(parts[0]) || 0;
75
+ let end = parseInt(parts[1]) || arrayLength;
76
+ let step = parseInt(parts[2]) || 1;
77
+ start = start < 0 ? arrayLength + start : start;
78
+ end = end < 0 ? arrayLength + end : end;
79
+
80
+ if (step <= 0) step = 1;
81
+
82
+ const indices = [];
83
+ for (let i = Math.max(0, start); i < Math.min(arrayLength, end); i += step) {
84
+ indices.push(i);
85
+ }
86
+ return indices.length > 0 ? indices : null;
87
+ }
88
+ }
89
+
90
+ return null;
91
+ } catch (err) {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ // Handle incoming messages
97
+ node.on('input', function(msg, send, done) {
98
+ try {
99
+ // Get data from configured input path
100
+ let inputArray;
101
+ if (node.inputPathType === 'msg') {
102
+ inputArray = RED.util.getMessageProperty(msg, node.inputPath);
103
+ } else if (node.inputPathType === 'flow') {
104
+ inputArray = node.context().flow.get(node.inputPath);
105
+ } else if (node.inputPathType === 'global') {
106
+ inputArray = node.context().global.get(node.inputPath);
107
+ }
108
+
109
+ // Validate input is an array
110
+ if (!Array.isArray(inputArray)) {
111
+ node.warn(`Input must be an array. Received: ${typeof inputArray}`);
112
+ node.status({ fill: "red", shape: "ring", text: "Invalid input: not an array" });
113
+ return done?.();
114
+ }
115
+
116
+ if (inputArray.length === 0) {
117
+ node.warn('Input array is empty');
118
+ node.status({ fill: "yellow", shape: "ring", text: "Empty array" });
119
+ return done?.();
120
+ }
121
+
122
+ // Process selection
123
+ const indices = parseSelection(node.selection, inputArray.length);
124
+
125
+ if (indices && indices.length > 0) {
126
+ const selectedItems = indices.map(idx => inputArray[idx]);
127
+
128
+ // Determine output format
129
+ const result = (node.asArray || selectedItems.length > 1) ? selectedItems : selectedItems[0];
130
+
131
+ // Create clean output message with only the selected result
132
+ let outputMsg = {};
133
+
134
+ // Set output based on configured path
135
+ if (node.outputPathType === 'msg') {
136
+ RED.util.setMessageProperty(outputMsg, node.outputPath, result, true);
137
+ } else if (node.outputPathType === 'flow') {
138
+ node.context().flow.set(node.outputPath, result);
139
+ // For flow context, send minimal message (no payload)
140
+ } else if (node.outputPathType === 'global') {
141
+ node.context().global.set(node.outputPath, result);
142
+ // For global context, send minimal message (no payload)
143
+ }
144
+
145
+ node.status({
146
+ fill: "green",
147
+ shape: "dot",
148
+ text: `Selected ${selectedItems.length} from [${inputArray.length}]`
149
+ });
150
+
151
+ send(outputMsg);
152
+ } else {
153
+ // Invalid selection
154
+ node.warn(`Invalid selection "${node.selection}" for array of length ${inputArray.length}`);
155
+ node.status({ fill: "red", shape: "ring", text: "Invalid selection" });
156
+ return done?.();
157
+ }
158
+
159
+ // Reset status after a delay
160
+ setTimeout(() => {
161
+ node.status({
162
+ fill: "blue",
163
+ shape: "dot",
164
+ text: `Ready: ${node.selection}`
165
+ });
166
+ }, 2000);
167
+
168
+ done?.();
169
+
170
+ } catch (err) {
171
+ node.status({ fill: "red", shape: "ring", text: "Error" });
172
+ if (done) {
173
+ done(err);
174
+ } else {
175
+ node.error(err, msg);
176
+ }
177
+ }
178
+ });
179
+
180
+ // Clean up on node removal
181
+ node.on('close', function() {
182
+ node.status({});
183
+ });
184
+ }
185
+
186
+ RED.nodes.registerType("rp-array-select", ArraySelectNode);
187
+ };
@@ -0,0 +1,119 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('rp-queue', {
3
+ category: 'RP Utils',
4
+ color: '#87CEEB',
5
+ defaults: {
6
+ name: { value: "" },
7
+ mode: { value: "queue-size" },
8
+ maxQueueSize: { value: 0 },
9
+ intervalMilliseconds: { value: 0 },
10
+ timeout: { value: 0 }
11
+ },
12
+ inputs: 1,
13
+ outputs: 1,
14
+ icon: "font-awesome/fa-clock-o",
15
+ label: function() {
16
+ if (this.name) return this.name;
17
+ return "Queue";
18
+ },
19
+ oneditprepare: function() {
20
+ const modeField = $("#node-input-mode");
21
+ const queueRow = $("#node-row-maxQueueSize");
22
+ const timeoutRow = $("#node-row-timeout");
23
+ const queueTip = $("#node-tip-maxQueueSize");
24
+ const timeoutTip = $("#node-tip-timeout");
25
+
26
+ function toggleModeFields() {
27
+ const mode = modeField.val();
28
+ if (mode === 'timeout') {
29
+ queueRow.hide();
30
+ queueTip.hide();
31
+ timeoutRow.show();
32
+ timeoutTip.show();
33
+ } else {
34
+ queueRow.show();
35
+ queueTip.show();
36
+ timeoutRow.hide();
37
+ timeoutTip.hide();
38
+ }
39
+ }
40
+
41
+ modeField.on("change", toggleModeFields);
42
+ toggleModeFields();
43
+ }
44
+ });
45
+ </script>
46
+
47
+ <script type="text/x-red" data-template-name="rp-queue">
48
+ <div class="form-row">
49
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
50
+ <input type="text" id="node-input-name" placeholder="Name">
51
+ </div>
52
+
53
+ <hr>
54
+
55
+ <div class="form-row">
56
+ <label for="node-input-mode"><i class="fa fa-exchange"></i> Mode</label>
57
+ <select id="node-input-mode">
58
+ <option value="queue-size">Queue Size Limit</option>
59
+ <option value="timeout">Timeout</option>
60
+ </select>
61
+ </div>
62
+ <div class="form-tips">
63
+ <b>Tip:</b> Pick a mode to decide whether the node caps how many messages can wait or keeps unlimited entries that expire after a timeout.
64
+ </div>
65
+
66
+ <div class="form-row" id="node-row-maxQueueSize">
67
+ <label for="node-input-maxQueueSize"><i class="fa fa-list"></i> Max Queue Size</label>
68
+ <input type="number" id="node-input-maxQueueSize" min="0" step="1" style="width: 70%;" placeholder="0">
69
+ </div>
70
+ <div class="form-tips" id="node-tip-maxQueueSize">
71
+ <b>Tip:</b> When using the queue-size mode, this field limits how many messages are buffered; extra messages are dropped until space frees up.
72
+ </div>
73
+
74
+ <div class="form-row" id="node-row-timeout">
75
+ <label for="node-input-timeout"><i class="fa fa-hourglass-half"></i> Timeout (ms)</label>
76
+ <input type="number" id="node-input-timeout" min="0" step="1" style="width: 70%;" placeholder="0">
77
+ </div>
78
+ <div class="form-tips" id="node-tip-timeout">
79
+ <b>Tip:</b> Timeout mode keeps buffering all messages but removes entries that stay longer than this value, keeping the data fresh.
80
+ </div>
81
+
82
+ <div class="form-row">
83
+ <label for="node-input-intervalMilliseconds"><i class="fa fa-clock-o"></i> Interval (ms)</label>
84
+ <input type="number" id="node-input-intervalMilliseconds" min="0" step="1" style="width: 70%;" placeholder="0">
85
+ </div>
86
+ <div class="form-tips">
87
+ <b>Tip:</b> Interval enforces a minimum delay between forwarded messages; set to 0 to send as fast as possible.
88
+ </div>
89
+ </script>
90
+
91
+ <script type="text/x-red" data-help-name="rp-queue">
92
+ <p>Buffers incoming messages and releases them with optional rate limiting and expiration control.</p>
93
+
94
+ <h3>Mode</h3>
95
+ <p>Choose between a queue-size limit or a timeout strategy; only the controls for the selected mode are applied.</p>
96
+
97
+ <h3>Configuration</h3>
98
+ <dl class="message-properties">
99
+ <dt>Mode <span class="property-type">enum</span></dt>
100
+ <dd>Select <code>Queue Size Limit</code> to cap the number of buffered messages or <code>Timeout</code> to drop entries after a duration.</dd>
101
+ <dt>Max Queue Size <span class="property-type">number</span></dt>
102
+ <dd>Valid only in queue-size mode. Determines how many messages can be held before incoming data is ignored.</dd>
103
+ <dt>Timeout (ms) <span class="property-type">number</span></dt>
104
+ <dd>Valid only in timeout mode. Messages that stay longer than this millisecond value are removed before they reach the output.</dd>
105
+ <dt>Interval (ms) <span class="property-type">number</span></dt>
106
+ <dd>Minimum time to wait between forwarded messages. Zero means send immediately when data is available.</dd>
107
+ </dl>
108
+
109
+ <h3>Behavior</h3>
110
+ <ul>
111
+ <li>Messages are processed in FIFO order while respecting the selected mode.</li>
112
+ <li>Queue-size mode enforces a hard cap, rejecting excess messages.</li>
113
+ <li>Timeout mode keeps buffering but evicts stale entries before sending.</li>
114
+ <li>The interval ensures a steady pace by adding delays between sends when configured.</li>
115
+ </ul>
116
+
117
+ <h3>Status</h3>
118
+ <p>The node status reflects whether the queue is idle, holding messages, or actively sending.</p>
119
+ </script>
@@ -0,0 +1,191 @@
1
+ /**
2
+ * @file Node.js logic for the Queue node providing buffering and rate limiting.
3
+ */
4
+
5
+ module.exports = function (RED) {
6
+ function QueueNode(config) {
7
+ RED.nodes.createNode(this, config);
8
+ const node = this;
9
+
10
+ /* ────────────────────────────
11
+ ░░ 1. Read configuration ░░
12
+ ──────────────────────────── */
13
+ const parsedMax = parseInt(config.maxQueueSize, 10);
14
+ const parsedInterval = parseInt(config.intervalMilliseconds, 10);
15
+ const parsedTimeout = parseInt(config.timeout, 10);
16
+ const parsedMode = config.mode;
17
+
18
+ node.maxQueueSize = Number.isInteger(parsedMax) && parsedMax > 0 ? parsedMax : 0;
19
+ node.intervalMs =
20
+ Number.isInteger(parsedInterval) && parsedInterval > 0 ? parsedInterval : 0;
21
+ node.timeoutMs =
22
+ Number.isInteger(parsedTimeout) && parsedTimeout > 0 ? parsedTimeout : 0;
23
+
24
+ node.mode =
25
+ parsedMode === 'timeout' || parsedMode === 'queue-size'
26
+ ? parsedMode
27
+ : 'legacy';
28
+
29
+ const useQueueLimit = node.mode !== 'timeout';
30
+ const useTimeout = node.mode !== 'queue-size';
31
+
32
+ /* ────────────────────────────
33
+ ░░ 2. Internal state ░░
34
+ ──────────────────────────── */
35
+ const state = {
36
+ queue: [],
37
+ timer: null,
38
+ lastSent: 0
39
+ };
40
+
41
+ setStatusIdle();
42
+
43
+ /* ────────────────────────────
44
+ ░░ 3. Helper functions ░░
45
+ ──────────────────────────── */
46
+ function setStatusIdle() {
47
+ node.status({ fill: 'blue', shape: 'dot', text: 'Idle' });
48
+ }
49
+
50
+ function setStatusQueued() {
51
+ node.status({
52
+ fill: 'yellow',
53
+ shape: 'dot',
54
+ text: `Queued: ${state.queue.length}`
55
+ });
56
+ }
57
+
58
+ function clearTimer() {
59
+ if (state.timer) {
60
+ clearTimeout(state.timer);
61
+ state.timer = null;
62
+ }
63
+ }
64
+
65
+ function pruneExpired() {
66
+ if (!useTimeout || node.timeoutMs <= 0 || state.queue.length === 0) {
67
+ return 0;
68
+ }
69
+
70
+ const now = Date.now();
71
+ let dropped = 0;
72
+
73
+ while (state.queue.length > 0) {
74
+ const oldest = state.queue[0];
75
+ if (now - oldest.enqueuedAt >= node.timeoutMs) {
76
+ state.queue.shift();
77
+ dropped += 1;
78
+ } else {
79
+ break;
80
+ }
81
+ }
82
+
83
+ if (dropped > 0) {
84
+ node.warn(`Queue node dropped ${dropped} message(s) due to timeout.`);
85
+ }
86
+
87
+ return dropped;
88
+ }
89
+
90
+ function scheduleNextSend() {
91
+ clearTimer();
92
+
93
+ pruneExpired();
94
+
95
+ if (state.queue.length === 0) {
96
+ setStatusIdle();
97
+ return;
98
+ }
99
+
100
+ const now = Date.now();
101
+ const elapsed = state.lastSent ? now - state.lastSent : Number.POSITIVE_INFINITY;
102
+ let delay = 0;
103
+
104
+ if (node.intervalMs > 0 && Number.isFinite(elapsed) && elapsed < node.intervalMs) {
105
+ delay = node.intervalMs - elapsed;
106
+ }
107
+
108
+ const sendFn = () => {
109
+ state.timer = null;
110
+ attemptSend();
111
+ };
112
+
113
+ state.timer = setTimeout(sendFn, Math.max(delay, 0));
114
+
115
+ setStatusQueued();
116
+ }
117
+
118
+ function attemptSend() {
119
+ pruneExpired();
120
+
121
+ if (state.queue.length === 0) {
122
+ setStatusIdle();
123
+ return;
124
+ }
125
+
126
+ const now = Date.now();
127
+ const elapsed = state.lastSent ? now - state.lastSent : Number.POSITIVE_INFINITY;
128
+
129
+ if (node.intervalMs > 0 && Number.isFinite(elapsed) && elapsed < node.intervalMs) {
130
+ scheduleNextSend();
131
+ return;
132
+ }
133
+
134
+ const entry = state.queue.shift();
135
+ if (!entry) {
136
+ scheduleNextSend();
137
+ return;
138
+ }
139
+
140
+ state.lastSent = now;
141
+ node.status({
142
+ fill: 'green',
143
+ shape: 'dot',
144
+ text: `Sending (remaining ${state.queue.length})`
145
+ });
146
+
147
+ node.send(entry.msg);
148
+ scheduleNextSend();
149
+ }
150
+
151
+ /* ────────────────────────────
152
+ ░░ 4. Input handler ░░
153
+ ──────────────────────────── */
154
+ node.on('input', function (msg, _send, done) {
155
+ try {
156
+ pruneExpired();
157
+
158
+ if (
159
+ useQueueLimit &&
160
+ node.maxQueueSize > 0 &&
161
+ state.queue.length >= node.maxQueueSize
162
+ ) {
163
+ node.warn(
164
+ `Queue node at capacity (${node.maxQueueSize}). Incoming message ignored.`
165
+ );
166
+ setStatusQueued();
167
+ return done?.();
168
+ }
169
+
170
+ state.queue.push({ msg, enqueuedAt: Date.now() });
171
+ setStatusQueued();
172
+ attemptSend();
173
+ done?.();
174
+ } catch (err) {
175
+ node.status({ fill: 'red', shape: 'ring', text: 'Error' });
176
+ done ? done(err) : node.error(err, msg);
177
+ }
178
+ });
179
+
180
+ /* ────────────────────────────
181
+ ░░ 5. Cleanup ░░
182
+ ──────────────────────────── */
183
+ node.on('close', function () {
184
+ clearTimer();
185
+ state.queue.length = 0;
186
+ setStatusIdle();
187
+ });
188
+ }
189
+
190
+ RED.nodes.registerType('rp-queue', QueueNode);
191
+ };