@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,243 @@
1
+ <script type="text/javascript">
2
+ RED.nodes.registerType('rp-save-file', {
3
+ category: 'RP Utils',
4
+ color: '#87CEEB',
5
+ defaults: {
6
+ name: { value: "" },
7
+ inputPath: { value: "payload" },
8
+ inputPathType: { value: "msg" },
9
+ folderPath: { value: "", required: true },
10
+ folderPathType: { value: "str" },
11
+ filename: { value: "" },
12
+ filenameType: { value: "str" },
13
+ fileType: { value: "auto" },
14
+ imageFormat: { value: "jpg" },
15
+ imageQuality: { value: 90 },
16
+ pngOptimize: { value: false },
17
+ jsonIndent: { value: 2 },
18
+ overwriteProtection: { value: "true" },
19
+ outputPath: { value: "savedFile" },
20
+ outputPathType: { value: "msg" }
21
+ },
22
+ inputs: 1,
23
+ outputs: 1,
24
+ icon: "font-awesome/fa-save",
25
+ label: function () {
26
+ return this.name || "Save File";
27
+ },
28
+ oneditprepare: function () {
29
+ const jsonataTypeOption = {
30
+ value: 'jsonata',
31
+ label: 'JSONata',
32
+ icon: 'red/images/typedInput/expr.svg'
33
+ };
34
+
35
+ $("#node-input-inputPath").typedInput({
36
+ default: 'msg',
37
+ types: ['msg', 'flow', 'global'],
38
+ typeField: "#node-input-inputPathType"
39
+ });
40
+
41
+ $("#node-input-folderPath").typedInput({
42
+ default: 'str',
43
+ types: ['str', 'env', 'msg', 'flow', 'global'],
44
+ typeField: "#node-input-folderPathType"
45
+ });
46
+
47
+ const filenameTypes = ['str', 'msg', 'flow', 'global', jsonataTypeOption];
48
+
49
+ $("#node-input-filename").typedInput({
50
+ default: 'str',
51
+ types: filenameTypes,
52
+ typeField: "#node-input-filenameType"
53
+ });
54
+
55
+ $("#node-input-outputPath").typedInput({
56
+ default: 'msg',
57
+ types: ['msg', 'flow', 'global'],
58
+ typeField: "#node-input-outputPathType"
59
+ });
60
+
61
+ const fileTypeField = $("#node-input-fileType");
62
+ const imageFormatField = $("#node-input-imageFormat");
63
+ const imageFields = $(".savefile-image-options");
64
+ const jsonFields = $(".savefile-json-options");
65
+ const pngOnlyFields = $(".savefile-png-only");
66
+ const qualityFields = $(".savefile-quality");
67
+ const imageSeparators = $(".savefile-image-separator");
68
+ const jsonSeparators = $(".savefile-json-separator");
69
+ const autoSeparators = $(".savefile-auto-separator");
70
+
71
+ function toggleFields() {
72
+ const current = fileTypeField.val();
73
+ imageFields.toggle(current === 'image' || current === 'auto');
74
+ jsonFields.toggle(current === 'json' || current === 'auto');
75
+ toggleImageFormatFields();
76
+ toggleSeparators();
77
+ }
78
+
79
+ function toggleImageFormatFields() {
80
+ const fmt = imageFormatField.val();
81
+ const showImage = fileTypeField.val() === 'image' || fileTypeField.val() === 'auto';
82
+ pngOnlyFields.toggle(showImage && fmt === 'png');
83
+ qualityFields.toggle(showImage && fmt !== 'png');
84
+ }
85
+
86
+ function toggleSeparators() {
87
+ const ft = fileTypeField.val();
88
+ const showImageSep = ft === 'image';
89
+ const showJsonSep = ft === 'json';
90
+ const showAutoSep = ft === 'auto';
91
+ imageSeparators.toggle(showImageSep);
92
+ jsonSeparators.toggle(showJsonSep);
93
+ autoSeparators.toggle(showAutoSep);
94
+ }
95
+
96
+ fileTypeField.on("change", toggleFields);
97
+ imageFormatField.on("change", toggleImageFormatFields);
98
+ toggleFields();
99
+ }
100
+ });
101
+ </script>
102
+
103
+ <script type="text/x-red" data-template-name="rp-save-file">
104
+ <div class="form-row">
105
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
106
+ <input type="text" id="node-input-name" placeholder="Name">
107
+ </div>
108
+
109
+ <div class="form-row">
110
+ <label for="node-input-inputPath"><i class="fa fa-sign-in"></i> Input</label>
111
+ <input type="text" id="node-input-inputPath" style="width: 70%;">
112
+ <input type="hidden" id="node-input-inputPathType">
113
+ </div>
114
+ <div class="form-tips">
115
+ <b>Input:</b> Location of the data to save. Defaults to <code>msg.payload</code>.
116
+ </div>
117
+
118
+ <hr>
119
+
120
+ <div class="form-row">
121
+ <label for="node-input-folderPath"><i class="fa fa-folder-open"></i> Folder</label>
122
+ <input type="text" id="node-input-folderPath" style="width: 70%;" placeholder="/tmp/output">
123
+ <input type="hidden" id="node-input-folderPathType">
124
+ </div>
125
+
126
+ <div class="form-row">
127
+ <label for="node-input-filename"><i class="fa fa-file-text-o"></i> Filename</label>
128
+ <input type="text" id="node-input-filename" style="width: 70%;" placeholder="optional (auto timestamp)">
129
+ <input type="hidden" id="node-input-filenameType">
130
+ </div>
131
+ <div class="form-tips">
132
+ <b>Filename:</b> Leave blank to auto-generate; omit slashes. Extension is added automatically unless you provide one.
133
+ </div>
134
+
135
+ <div class="form-row">
136
+ <label for="node-input-fileType"><i class="fa fa-list"></i> File Type</label>
137
+ <select id="node-input-fileType">
138
+ <option value="auto">Auto-detect</option>
139
+ <option value="image">Image</option>
140
+ <option value="json">JSON</option>
141
+ <option value="text">Text</option>
142
+ <option value="binary">Binary</option>
143
+ </select>
144
+ </div>
145
+
146
+ <div class="form-row savefile-auto-separator">
147
+ <hr>
148
+ </div>
149
+
150
+ <div class="form-row savefile-image-options savefile-separator savefile-image-separator">
151
+ <hr>
152
+ </div>
153
+ <div class="form-row savefile-image-options">
154
+ <label for="node-input-imageFormat"><i class="fa fa-picture-o"></i> Image Format</label>
155
+ <select id="node-input-imageFormat">
156
+ <option value="jpg">JPG</option>
157
+ <option value="png">PNG</option>
158
+ <option value="webp">WebP</option>
159
+ </select>
160
+ </div>
161
+
162
+ <div class="form-row savefile-image-options savefile-quality">
163
+ <label for="node-input-imageQuality"><i class="fa fa-sliders"></i> Quality</label>
164
+ <input type="number" id="node-input-imageQuality" min="1" max="100" step="1" style="width: 30%;" placeholder="90">
165
+ </div>
166
+ <div class="form-row savefile-image-options savefile-png-only">
167
+ <label for="node-input-pngOptimize"><i class="fa fa-leaf"></i> PNG Options</label>
168
+ <label style="margin-left: 0; width: auto;">
169
+ <input type="checkbox" id="node-input-pngOptimize" style="margin-right: 5px;"> Optimize PNG
170
+ </label>
171
+ </div>
172
+ <div class="form-row savefile-image-options savefile-separator savefile-image-separator">
173
+ <hr>
174
+ </div>
175
+
176
+ <div class="form-row savefile-json-options savefile-separator savefile-json-separator">
177
+ <hr>
178
+ </div>
179
+ <div class="form-row savefile-json-options">
180
+ <label for="node-input-jsonIndent"><i class="fa fa-indent"></i> JSON Indent</label>
181
+ <input type="number" id="node-input-jsonIndent" min="0" max="8" step="1" style="width: 30%;" placeholder="2">
182
+ </div>
183
+ <div class="form-row savefile-json-options savefile-separator savefile-json-separator">
184
+ <hr>
185
+ </div>
186
+
187
+ <div class="form-row savefile-auto-separator">
188
+ <hr>
189
+ </div>
190
+
191
+ <div class="form-row">
192
+ <label for="node-input-overwriteProtection"><i class="fa fa-shield"></i> Overwrite</label>
193
+ <select id="node-input-overwriteProtection">
194
+ <option value="true">Avoid overwriting (add suffix)</option>
195
+ <option value="false">Allow overwrite</option>
196
+ </select>
197
+ </div>
198
+
199
+ <div class="form-row">
200
+ <label for="node-input-outputPath"><i class="fa fa-sign-out"></i> Result to</label>
201
+ <input type="text" id="node-input-outputPath" style="width: 70%;">
202
+ <input type="hidden" id="node-input-outputPathType">
203
+ </div>
204
+ <div class="form-tips">
205
+ <b>Result:</b> Stores <code>{path, filename, type, extension, sizeBytes[, format]}</code> for downstream nodes. Defaults to <code>msg.savedFile</code>.
206
+ </div>
207
+ </script>
208
+
209
+ <script type="text/x-red" data-help-name="rp-save-file">
210
+ <p>Writes incoming data to the local filesystem. Supports raw image objects, encoded image buffers, JSON values, plain text, or arbitrary binary content.</p>
211
+
212
+ <h3>Inputs</h3>
213
+ <dl class="message-properties">
214
+ <dt>payload (default) <span class="property-type">any</span></dt>
215
+ <dd>Data to write. Auto-detection treats raw image objects and encoded image buffers as images; objects/arrays as JSON; strings as text; buffers as binary.</dd>
216
+ </dl>
217
+
218
+ <h3>Properties</h3>
219
+ <dl class="message-properties">
220
+ <dt>Folder <span class="property-type">string | env | msg | flow | global</span></dt>
221
+ <dd>Destination directory. Created automatically if missing.</dd>
222
+ <dt>Filename <span class="property-type">string | msg | flow | global</span></dt>
223
+ <dd>Optional name without slashes. When blank, a timestamped name is generated. If you include an extension here it is used.</dd>
224
+ <dt>File Type <span class="property-type">enum</span></dt>
225
+ <dd>Force saving as image, JSON, text, or binary, or let the node auto-detect from data/extension.</dd>
226
+ <dt>Image Format / Quality / Optimize PNG <span class="property-type">enum/number/bool</span></dt>
227
+ <dd>Applies when saving images. Raw image objects are validated and encoded with Sharp.</dd>
228
+ <dt>JSON Indent <span class="property-type">number</span></dt>
229
+ <dd>Spacing for pretty-printed JSON output.</dd>
230
+ <dt>Overwrite <span class="property-type">enum</span></dt>
231
+ <dd>By default the node avoids overwriting existing files, appending a numeric suffix when needed.</dd>
232
+ <dt>Result to <span class="property-type">msg | flow | global</span></dt>
233
+ <dd>Where to store a summary of the saved file: <code>{ path, filename, type, extension, sizeBytes, format? }</code>.</dd>
234
+ </dl>
235
+
236
+ <h3>Behavior</h3>
237
+ <ul>
238
+ <li>Auto-detect looks at configured extension, raw image structure, or Sharp metadata for buffers.</li>
239
+ <li>Image inputs accept encoded buffers or raw objects {data, width, height, channels, colorSpace}.</li>
240
+ <li>JSON inputs are pretty-printed; JSON strings are preserved if already valid.</li>
241
+ <li>When overwrite protection is enabled, a numeric suffix is appended if the target file exists.</li>
242
+ </ul>
243
+ </script>
@@ -0,0 +1,440 @@
1
+ /**
2
+ * @file Generic filesystem sink that can write JSON, images, text, or binary data.
3
+ * Designed to accept raw image objects, encoded buffers, or plain JS values.
4
+ */
5
+ const fs = require('fs').promises;
6
+ const path = require('path');
7
+ const sharp = require('sharp');
8
+ const JSON_WARN_BYTES = 5 * 1024 * 1024; // warn when JSON output is larger than 5MB
9
+
10
+ module.exports = function (RED) {
11
+ const NodeUtils = require('../../lib/node-utils.js')(RED);
12
+
13
+ function SaveFileNode(config) {
14
+ RED.nodes.createNode(this, config);
15
+ const node = this;
16
+ const diskWarnState = { logged: false };
17
+
18
+ node.on('input', async (msg, send, done) => {
19
+ const started = Date.now();
20
+ node.status({ fill: 'blue', shape: 'dot', text: 'saving...' });
21
+
22
+ try {
23
+ const evaluateProperty = (value, type, fallback = 'str') =>
24
+ new Promise((resolve, reject) => {
25
+ const actualType = type || fallback;
26
+ try {
27
+ RED.util.evaluateNodeProperty(value, actualType, node, msg, (err, res) => {
28
+ if (err) reject(err);
29
+ else resolve(res);
30
+ });
31
+ } catch (err) {
32
+ reject(err);
33
+ }
34
+ });
35
+
36
+ // Resolve paths and inputs
37
+ const folderPathType = config.folderPathType || 'str';
38
+ const folderPathValue = await evaluateProperty(config.folderPath, folderPathType);
39
+ const folderPath = String(folderPathValue ?? '').trim();
40
+ if (!folderPath) {
41
+ throw new Error('Folder path is not configured or resolved.');
42
+ }
43
+ await fs.mkdir(folderPath, { recursive: true });
44
+
45
+ const inputPath = config.inputPath || 'payload';
46
+ const inputPathType = config.inputPathType || 'msg';
47
+ let incoming;
48
+ if (inputPathType === 'msg') {
49
+ incoming = RED.util.getMessageProperty(msg, inputPath);
50
+ } else if (inputPathType === 'flow') {
51
+ incoming = node.context().flow.get(inputPath);
52
+ } else if (inputPathType === 'global') {
53
+ incoming = node.context().global.get(inputPath);
54
+ }
55
+ if (incoming === undefined) {
56
+ throw new Error('No data found at configured input path.');
57
+ }
58
+
59
+ // Resolve file name
60
+ const filenameType = config.filenameType || 'str';
61
+ const filenameValue = await evaluateProperty(config.filename, filenameType);
62
+ const filenameRaw = filenameValue !== undefined && filenameValue !== null
63
+ ? String(filenameValue).trim()
64
+ : '';
65
+ if (/[\\/]/.test(filenameRaw)) {
66
+ throw new Error('Filename must not contain path separators.');
67
+ }
68
+ const parsedName = path.parse(filenameRaw);
69
+ let baseName = parsedName.name || '';
70
+ let extFromName = parsedName.ext ? parsedName.ext.replace(/^\./, '') : '';
71
+
72
+ // Determine file type and format
73
+ const configType = String(config.fileType || 'auto').toLowerCase();
74
+ let imageFormat = String(config.imageFormat || 'jpg').toLowerCase();
75
+ const extInfo = mapExtension(extFromName);
76
+
77
+ let fileType = configType;
78
+ if (fileType === 'auto' && extInfo?.type) {
79
+ fileType = extInfo.type;
80
+ if (extInfo.format) {
81
+ imageFormat = extInfo.format;
82
+ }
83
+ }
84
+
85
+ const inferred = fileType === 'auto'
86
+ ? await inferFileType(incoming, extInfo)
87
+ : { type: fileType, format: imageFormat };
88
+
89
+ fileType = inferred.type;
90
+ if (fileType === 'image' && inferred.format) {
91
+ imageFormat = inferred.format;
92
+ }
93
+ if (fileType === 'image' && extInfo?.format) {
94
+ imageFormat = extInfo.format;
95
+ }
96
+
97
+ if (!['image', 'json', 'text', 'binary'].includes(fileType)) {
98
+ throw new Error(`Unsupported file type: ${fileType}`);
99
+ }
100
+
101
+ // Finalize extension and name
102
+ let extension = extFromName || defaultExtension(fileType, imageFormat);
103
+ if (!extension) {
104
+ throw new Error('Unable to determine file extension.');
105
+ }
106
+ if (!baseName) {
107
+ baseName = defaultBaseName(fileType);
108
+ }
109
+ let filename = `${baseName}.${extension}`;
110
+ const originalFilename = filename;
111
+ let filePath = path.join(folderPath, filename);
112
+
113
+ const overwriteEnabled = String(config.overwriteProtection) !== 'false';
114
+ if (overwriteEnabled) {
115
+ let counter = 2;
116
+ while (await fileExists(filePath)) {
117
+ filename = `${baseName}_${counter}.${extension}`;
118
+ filePath = path.join(folderPath, filename);
119
+ counter += 1;
120
+ if (counter > 1000) {
121
+ throw new Error('Too many file variations exist in target folder.');
122
+ }
123
+ }
124
+ }
125
+
126
+ // Check disk space (skip if 90%+ used)
127
+ const diskInfo = await getDiskUsageInfo(folderPath, node, diskWarnState);
128
+ if (diskInfo && diskInfo.usedRatio >= 0.9) {
129
+ const usedPercent = (diskInfo.usedRatio * 100).toFixed(1);
130
+ node.warn(`Storage at "${folderPath}" is ${usedPercent}% full. Skipping save to avoid exhausting disk space.`);
131
+ node.status({ fill: 'yellow', shape: 'ring', text: `disk ${usedPercent}% full` });
132
+ done && done();
133
+ return;
134
+ }
135
+
136
+ // Prepare data to write
137
+ const quality = parseInt(config.imageQuality, 10) || 90;
138
+ const pngOptimize = config.pngOptimize === true || config.pngOptimize === 'true';
139
+ const jsonIndent = Number.isInteger(parseInt(config.jsonIndent, 10))
140
+ ? parseInt(config.jsonIndent, 10)
141
+ : 2;
142
+
143
+ let payloadToWrite;
144
+ let isText = false;
145
+
146
+ if (fileType === 'image') {
147
+ payloadToWrite = await toImageBuffer(incoming, imageFormat, quality, pngOptimize, NodeUtils, node);
148
+ } else if (fileType === 'json') {
149
+ if (typeof incoming === 'string') {
150
+ try {
151
+ // Keep valid JSON strings as-is
152
+ JSON.parse(incoming);
153
+ payloadToWrite = incoming;
154
+ } catch {
155
+ payloadToWrite = JSON.stringify(incoming, null, jsonIndent);
156
+ }
157
+ } else {
158
+ payloadToWrite = JSON.stringify(incoming, null, jsonIndent);
159
+ }
160
+ isText = true;
161
+ } else if (fileType === 'text') {
162
+ payloadToWrite = incoming === undefined || incoming === null ? '' : String(incoming);
163
+ isText = true;
164
+ } else {
165
+ payloadToWrite = await toBinary(incoming);
166
+ }
167
+
168
+ if (isText) {
169
+ await fs.writeFile(filePath, payloadToWrite, 'utf8');
170
+ } else {
171
+ await fs.writeFile(filePath, payloadToWrite);
172
+ }
173
+
174
+ const sizeBytes = isText
175
+ ? Buffer.byteLength(payloadToWrite, 'utf8')
176
+ : payloadToWrite.length;
177
+
178
+ if (fileType === 'json' && sizeBytes >= JSON_WARN_BYTES) {
179
+ const sizeMB = (sizeBytes / (1024 * 1024)).toFixed(1);
180
+ node.warn(`JSON output is large (${sizeMB} MB); this may impact the event loop and memory usage.`);
181
+ }
182
+
183
+ const resultInfo = {
184
+ path: filePath,
185
+ filename,
186
+ type: fileType,
187
+ extension,
188
+ sizeBytes
189
+ };
190
+ if (fileType === 'image') {
191
+ resultInfo.format = imageFormat;
192
+ }
193
+
194
+ // Optionally attach result info for downstream nodes
195
+ const outputPath = config.outputPath || 'savedFile';
196
+ const outputPathType = config.outputPathType || 'msg';
197
+ try {
198
+ if (outputPathType === 'msg') {
199
+ RED.util.setMessageProperty(msg, outputPath, resultInfo, true);
200
+ } else if (outputPathType === 'flow') {
201
+ node.context().flow.set(outputPath, resultInfo);
202
+ } else if (outputPathType === 'global') {
203
+ node.context().global.set(outputPath, resultInfo);
204
+ }
205
+ } catch (setErr) {
206
+ node.warn(`Unable to store output metadata: ${setErr.message}`);
207
+ }
208
+
209
+ const elapsed = Date.now() - started;
210
+ const renameOccurred = overwriteEnabled && filename !== originalFilename;
211
+ const statusSuffix = renameOccurred ? ' (avoided overwrite)' : '';
212
+ node.status({
213
+ fill: 'green',
214
+ shape: 'dot',
215
+ text: `saved ${filename}${statusSuffix} (${elapsed}ms)`
216
+ });
217
+
218
+ // Record basic performance metric for consistency across nodes
219
+ NodeUtils.recordPerformanceMetrics(node, msg, { taskMs: elapsed }, elapsed);
220
+
221
+ send(msg);
222
+ done && done();
223
+ } catch (err) {
224
+ node.status({ fill: 'red', shape: 'ring', text: 'Error' });
225
+ node.error(err.message, msg);
226
+ done && done(err);
227
+ }
228
+ });
229
+ }
230
+
231
+ RED.nodes.registerType('rp-save-file', SaveFileNode);
232
+ };
233
+
234
+ // Helpers
235
+ function mapExtension(ext) {
236
+ if (!ext) return null;
237
+ const lower = ext.toLowerCase();
238
+ if (lower === 'jpg' || lower === 'jpeg') return { type: 'image', format: 'jpg' };
239
+ if (lower === 'png') return { type: 'image', format: 'png' };
240
+ if (lower === 'webp') return { type: 'image', format: 'webp' };
241
+ if (lower === 'json') return { type: 'json' };
242
+ if (lower === 'txt') return { type: 'text' };
243
+ if (lower === 'bin' || lower === 'dat') return { type: 'binary' };
244
+ return null;
245
+ }
246
+
247
+ function defaultExtension(type, imageFormat) {
248
+ if (type === 'image') return imageFormat || 'jpg';
249
+ if (type === 'json') return 'json';
250
+ if (type === 'text') return 'txt';
251
+ if (type === 'binary') return 'bin';
252
+ return '';
253
+ }
254
+
255
+ function defaultBaseName(type) {
256
+ const timestamp = new Date()
257
+ .toISOString()
258
+ .replace(/[-:]/g, '')
259
+ .replace(/\.\d{3}Z$/, 'Z')
260
+ .replace('T', '_');
261
+ switch (type) {
262
+ case 'image': return `image_${timestamp}`;
263
+ case 'json': return `data_${timestamp}`;
264
+ case 'text': return `text_${timestamp}`;
265
+ default: return `file_${timestamp}`;
266
+ }
267
+ }
268
+
269
+ async function fileExists(p) {
270
+ try {
271
+ await fs.access(p);
272
+ return true;
273
+ } catch {
274
+ return false;
275
+ }
276
+ }
277
+
278
+ async function inferFileType(value, extInfo) {
279
+ if (extInfo?.type) {
280
+ return { type: extInfo.type, format: extInfo.format };
281
+ }
282
+
283
+ if (value && typeof value === 'object') {
284
+ if (Buffer.isBuffer(value)) {
285
+ try {
286
+ const meta = await sharp(value).metadata();
287
+ if (meta?.format) {
288
+ const fmt = normalizeSharpFormat(meta.format);
289
+ if (fmt) return { type: 'image', format: fmt };
290
+ }
291
+ } catch {
292
+ // Not an image buffer
293
+ }
294
+ return { type: 'binary' };
295
+ }
296
+
297
+ if (isRawImageLike(value)) {
298
+ return { type: 'image' };
299
+ }
300
+
301
+ return { type: 'json' };
302
+ }
303
+
304
+ if (typeof value === 'string') {
305
+ const trimmed = value.trim();
306
+ if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
307
+ (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
308
+ return { type: 'json' };
309
+ }
310
+ return { type: 'text' };
311
+ }
312
+
313
+ return { type: 'binary' };
314
+ }
315
+
316
+ function normalizeSharpFormat(fmt) {
317
+ if (!fmt) return null;
318
+ if (fmt === 'jpeg' || fmt === 'jpg') return 'jpg';
319
+ if (fmt === 'png') return 'png';
320
+ if (fmt === 'webp') return 'webp';
321
+ return null;
322
+ }
323
+
324
+ function isRawImageLike(obj) {
325
+ return obj &&
326
+ typeof obj === 'object' &&
327
+ !Buffer.isBuffer(obj) &&
328
+ Object.prototype.hasOwnProperty.call(obj, 'data') &&
329
+ Object.prototype.hasOwnProperty.call(obj, 'width') &&
330
+ Object.prototype.hasOwnProperty.call(obj, 'height');
331
+ }
332
+
333
+ async function toBinary(value) {
334
+ if (Buffer.isBuffer(value)) return value;
335
+ if (value && typeof value === 'object' && Buffer.isBuffer(value.data)) {
336
+ return Buffer.from(value.data);
337
+ }
338
+ if (typeof value === 'string') {
339
+ return Buffer.from(value, 'utf8');
340
+ }
341
+ return Buffer.from(JSON.stringify(value ?? ''), 'utf8');
342
+ }
343
+
344
+ async function toImageBuffer(imageValue, format, quality, pngOptimize, NodeUtils, node) {
345
+ // Encoded buffer path
346
+ if (Buffer.isBuffer(imageValue) && !(imageValue.width && imageValue.height)) {
347
+ let inputFormat;
348
+ try {
349
+ const metadata = await sharp(imageValue).metadata();
350
+ inputFormat = metadata.format;
351
+ } catch {
352
+ inputFormat = null;
353
+ }
354
+
355
+ if (inputFormat && (normalizeSharpFormat(inputFormat) === format)) {
356
+ return imageValue;
357
+ }
358
+
359
+ const sharpInstance = sharp(imageValue);
360
+ return encodeSharp(sharpInstance, format, quality, pngOptimize);
361
+ }
362
+
363
+ // Raw image path (uses validator for clear errors)
364
+ const validated = NodeUtils.validateImageStructure(imageValue, node);
365
+ if (!validated) {
366
+ throw new Error('Invalid image structure.');
367
+ }
368
+
369
+ const colorSpace = validated.colorSpace;
370
+ const channels = validated.channels;
371
+ let data = validated.data;
372
+
373
+ if (colorSpace === 'BGR' || colorSpace === 'BGRA') {
374
+ data = Buffer.from(data);
375
+ for (let i = 0; i < data.length; i += channels) {
376
+ const t = data[i];
377
+ data[i] = data[i + 2];
378
+ data[i + 2] = t;
379
+ }
380
+ }
381
+
382
+ let sharpInstance = sharp(data, {
383
+ raw: {
384
+ width: validated.width,
385
+ height: validated.height,
386
+ channels: channels
387
+ }
388
+ });
389
+
390
+ if (colorSpace === 'GRAY') {
391
+ sharpInstance = sharpInstance.toColourspace('b-w');
392
+ }
393
+
394
+ return encodeSharp(sharpInstance, format, quality, pngOptimize);
395
+ }
396
+
397
+ function encodeSharp(sharpInstance, format, quality, pngOptimize) {
398
+ if (format === 'png') {
399
+ const pngOptions = pngOptimize
400
+ ? { compressionLevel: 9, palette: true }
401
+ : { compressionLevel: 6 };
402
+ return sharpInstance.png(pngOptions).toBuffer();
403
+ }
404
+ if (format === 'webp') {
405
+ return sharpInstance.webp({ quality }).toBuffer();
406
+ }
407
+ return sharpInstance.jpeg({ quality }).toBuffer();
408
+ }
409
+
410
+ async function getDiskUsageInfo(targetPath, node, warnState) {
411
+ try {
412
+ const stats = await fs.statfs(targetPath);
413
+ const blockSize = Number(stats.bsize) || 0;
414
+ const totalBlocks = Number(stats.blocks) || 0;
415
+ const availableBlocks = Number(
416
+ stats.bavail !== undefined ? stats.bavail :
417
+ stats.bfree !== undefined ? stats.bfree : 0
418
+ );
419
+
420
+ const totalBytes = totalBlocks * blockSize;
421
+ const availableBytes = availableBlocks * blockSize;
422
+
423
+ if (totalBytes <= 0) {
424
+ return null;
425
+ }
426
+
427
+ const usedRatio = 1 - (availableBytes / totalBytes);
428
+ return {
429
+ totalBytes,
430
+ availableBytes,
431
+ usedRatio
432
+ };
433
+ } catch (err) {
434
+ if (!warnState.logged) {
435
+ node.warn(`Disk usage check failed for "${targetPath}": ${err.message}`);
436
+ warnState.logged = true;
437
+ }
438
+ return null;
439
+ }
440
+ }