@malysus/node-red-contrib-flow-splitter-extended 0.0.0-stage → 1.4.2

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,369 @@
1
+ const path = require('path')
2
+ const fs = require('fs-extra')
3
+
4
+ // Map of Cyrillic characters (Ukrainian + Russian) to Latin equivalents.
5
+ // Used to build filesystem/git-safe file names from node names that may
6
+ // contain Cyrillic text (git can fail with "pathspec ... did not match
7
+ // any files" on some setups when non-ASCII file names are involved).
8
+ const CYRILLIC_TO_LATIN = {
9
+ 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'h', 'ґ': 'g', 'д': 'd', 'е': 'e', 'є': 'ie',
10
+ 'ж': 'zh', 'з': 'z', 'и': 'y', 'і': 'i', 'ї': 'yi', 'й': 'i', 'к': 'k', 'л': 'l',
11
+ 'м': 'm', 'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u',
12
+ 'ф': 'f', 'х': 'kh', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'shch', 'ь': '',
13
+ 'ю': 'iu', 'я': 'ia', 'ъ': '', 'ы': 'y', 'э': 'e', 'ё': 'e'
14
+ }
15
+
16
+ /**
17
+ * Transliterate Cyrillic characters in a string to Latin equivalents and
18
+ * strip any other non-ASCII characters, so the result is always safe to
19
+ * use as a file name (including for git operations).
20
+ * @param {string} text
21
+ * @returns {string}
22
+ */
23
+ function transliterate(text) {
24
+ return text
25
+ .split('')
26
+ .map((char) => {
27
+ const lower = char.toLowerCase()
28
+ const mapped = CYRILLIC_TO_LATIN[lower]
29
+ if (mapped === undefined) {
30
+ return char
31
+ }
32
+ return char === lower ? mapped : mapped.charAt(0).toUpperCase() + mapped.slice(1)
33
+ })
34
+ .join('')
35
+ .replace(/[^\x00-\x7F]/g, '')
36
+ }
37
+
38
+ /**
39
+ * Maps a core `template` node's `format` field (its editor syntax-highlighting
40
+ * mode) to a sensible file extension for the extracted file. Falls back to
41
+ * `.txt` for formats not in this list (e.g. 'plain', or anything unexpected).
42
+ */
43
+ const TEMPLATE_FORMAT_EXTENSIONS = {
44
+ html: 'html',
45
+ xml: 'xml',
46
+ json: 'json',
47
+ javascript: 'js',
48
+ css: 'css',
49
+ sql: 'sql',
50
+ yaml: 'yaml',
51
+ markdown: 'md',
52
+ handlebars: 'hbs'
53
+ }
54
+
55
+ /**
56
+ * @param {string} [format]
57
+ * @returns {string} file extension without the leading dot
58
+ */
59
+ function templateFileExtension(format) {
60
+ return TEMPLATE_FORMAT_EXTENSIONS[format] || 'txt'
61
+ }
62
+
63
+ /**
64
+ * Write through a temporary file renamed over the target, so a crash mid-write
65
+ * never leaves a truncated flow, code or config file behind.
66
+ * @param {string} filePath
67
+ * @param {string|Buffer} content
68
+ */
69
+ function writeFileAtomic(filePath, content) {
70
+ const tmpPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.tmp`)
71
+ fs.writeFileSync(tmpPath, content, 'utf8')
72
+ fs.renameSync(tmpPath, filePath)
73
+ }
74
+
75
+ /**
76
+ * Functions and Templates nodes Handler
77
+ * Extracts function and ui-template node code into separate files
78
+ * and restores them back when rebuilding flows
79
+ */
80
+
81
+ /**
82
+ * Extract functions and templates from flow nodes into separate files
83
+ * @param {Array} flowNodes - Array of nodes from a tab or subflow
84
+ * @param {string} flowName - Name of the tab or subflow
85
+ * @param {string} flowDir - Directory where the flow file is stored
86
+ * @param {object} RED - Node-RED runtime
87
+ */
88
+ function extractFunctionsAndTemplates(flowNodes, flowName, flowDir, RED) {
89
+ if (!flowNodes || flowNodes.length === 0) return
90
+
91
+ const extractedDir = path.join(flowDir, flowName)
92
+
93
+ // Do not remove the whole directory on every deploy. On a large flow this
94
+ // turns one deploy into thousands of unnecessary unlink/write operations.
95
+ // The manifest is also used to remove only files that became orphaned.
96
+ let previousManifest = {}
97
+ const manifestPath = path.join(extractedDir, '.manifest.json')
98
+ if (fs.existsSync(manifestPath)) {
99
+ try {
100
+ previousManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
101
+ } catch (_) {
102
+ previousManifest = {}
103
+ }
104
+ }
105
+
106
+ const manifest = {}
107
+ const fileNameCounts = new Map()
108
+ const filesToKeep = new Set(['.manifest.json'])
109
+ let count = 0
110
+
111
+ const writeIfChanged = (filePath, content) => {
112
+ if (fs.existsSync(filePath)) {
113
+ try {
114
+ if (fs.readFileSync(filePath, 'utf8') === content) return
115
+ } catch (_) {
116
+ // Rewrite unreadable or otherwise invalid files below.
117
+ }
118
+ }
119
+ writeFileAtomic(filePath, content)
120
+ }
121
+
122
+ flowNodes.forEach((node) => {
123
+ const id = node.id
124
+ const type = node.type
125
+
126
+ let name
127
+
128
+ if (type === 'function') {
129
+ name = node.name || 'unnamed-function'
130
+ } else if (type === 'ui-template') {
131
+ name = node.name || 'unnamed-template'
132
+ } else if (type === 'template') {
133
+ // Core Node-RED "template" node (mustache/plain templating).
134
+ // Its content lives in `node.template`, not `node.format` -
135
+ // `node.format` here is only the editor's syntax-highlighting
136
+ // mode (e.g. "html", "json"), never actual code/content.
137
+ name = node.name || 'unnamed-template-node'
138
+ } else {
139
+ return
140
+ }
141
+
142
+ const sanitizedName = transliterate(name).replace(/[\/\\:*?"<>|]/g, '-').replace(/\s+/g, '_')
143
+ const nameCount = (fileNameCounts.get(sanitizedName) || 0) + 1
144
+ fileNameCounts.set(sanitizedName, nameCount)
145
+
146
+ let fileName
147
+ if (nameCount > 1) {
148
+ fileName = `${sanitizedName}(${nameCount})`
149
+ } else {
150
+ fileName = sanitizedName
151
+ }
152
+
153
+ // Detect the node kind so we read/write the right field:
154
+ // - ui-template (Dashboard 2.0): code lives in `format`
155
+ // - core template node: content lives in `template`
156
+ // - function: code lives in `func` (+ optional initialize/finalize)
157
+ const hasTemplateTag = node.format?.trim().indexOf('<template>') !== -1 ?? false
158
+ const hasScriptTag = node.format?.trim().indexOf('<script>') !== -1 ?? false
159
+ const isVue = type === 'ui-template' && typeof node.format === 'string' && (hasTemplateTag || hasScriptTag)
160
+ const isTemplateNode = type === 'template'
161
+ const isFun = (
162
+ (typeof node.func === 'string' && node.func.trim().length > 0) ||
163
+ (typeof node.initialize === 'string' && node.initialize.trim().length > 0) ||
164
+ (typeof node.finalize === 'string' && node.finalize.trim().length > 0)
165
+ ) && isVue === false && isTemplateNode === false
166
+
167
+ let code
168
+ if (isVue) {
169
+ code = node.format
170
+ } else if (isTemplateNode) {
171
+ code = node.template
172
+ } else {
173
+ code = node.func
174
+ }
175
+ let initialize = isFun ? node.initialize : undefined
176
+ let finalize = isFun ? node.finalize : undefined
177
+ let info = node.info ?? undefined
178
+
179
+ // Clean up empty values
180
+ if ((code ?? '').trim().length === 0) code = undefined
181
+ if ((initialize ?? '').trim().length === 0) initialize = undefined
182
+ if ((finalize ?? '').trim().length === 0) finalize = undefined
183
+ if ((info ?? '').trim().length === 0) info = undefined
184
+
185
+ if (isVue || isFun || isTemplateNode) {
186
+ // Ensure output directory exists
187
+ if (!fs.existsSync(extractedDir)) {
188
+ fs.mkdirSync(extractedDir, { recursive: true })
189
+ }
190
+
191
+ count++
192
+
193
+ const baseName = fileName
194
+ const codeExt = isVue ? 'vue' : (isTemplateNode ? templateFileExtension(node.format) : 'js')
195
+ const codeName = `${baseName}.${codeExt}`
196
+ const initializeName = `${baseName}.initialize.js`
197
+ const finalizeName = `${baseName}.finalize.js`
198
+ const infoName = `${baseName}.info.md`
199
+
200
+ const codeFile = path.join(extractedDir, codeName)
201
+ const initializeFile = path.join(extractedDir, initializeName)
202
+ const finalizeFile = path.join(extractedDir, finalizeName)
203
+ const infoFile = path.join(extractedDir, infoName)
204
+
205
+ filesToKeep.add(codeName)
206
+ if (initialize != null) filesToKeep.add(initializeName)
207
+ if (finalize != null) filesToKeep.add(finalizeName)
208
+ if (info != null) filesToKeep.add(infoName)
209
+
210
+ // Write only changed files.
211
+ if (code != null) writeIfChanged(codeFile, code)
212
+ if (initialize != null) writeIfChanged(initializeFile, initialize)
213
+ if (finalize != null) writeIfChanged(finalizeFile, finalize)
214
+ if (info != null) writeIfChanged(infoFile, info)
215
+
216
+ // Store in manifest
217
+ manifest[id] = {
218
+ nodeId: id,
219
+ name,
220
+ sanitizedName,
221
+ fileName,
222
+ isVue,
223
+ isFun,
224
+ isTemplateNode,
225
+ codeExt,
226
+ hasCode: code != null,
227
+ hasInitialize: initialize != null,
228
+ hasFinalize: finalize != null,
229
+ hasInfo: info != null
230
+ }
231
+ }
232
+ })
233
+
234
+ // Save manifest if we extracted anything
235
+ if (count > 0) {
236
+ fs.mkdirSync(extractedDir, { recursive: true })
237
+ const manifestContent = JSON.stringify(manifest, null, 2)
238
+ writeIfChanged(manifestPath, manifestContent)
239
+
240
+ // Remove only files no longer referenced by the current manifest.
241
+ // Never recursively delete the flow directory: editors and git keep
242
+ // useful metadata there, and the old behaviour was very expensive.
243
+ if (fs.existsSync(extractedDir)) {
244
+ for (const file of fs.readdirSync(extractedDir)) {
245
+ if (!filesToKeep.has(file) && !file.startsWith('.')) {
246
+ const fullPath = path.join(extractedDir, file)
247
+ if (fs.statSync(fullPath).isFile()) fs.unlinkSync(fullPath)
248
+ }
249
+ }
250
+ }
251
+
252
+ RED.log.info(`[node-red-contrib-flow-splitter-extended] Extracted ${count} functions/templates for "${flowName}"`)
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Restore functions and templates from separate files back into flow nodes
258
+ * @param {Array} flowNodes - Array of nodes from a tab or subflow
259
+ * @param {string} flowName - Name of the tab or subflow
260
+ * @param {string} flowDir - Directory where the flow file is stored
261
+ * @param {object} RED - Node-RED runtime
262
+ * @returns {Array} - Updated flow nodes
263
+ */
264
+ function restoreFunctionsAndTemplates(flowNodes, flowName, flowDir, RED) {
265
+ if (!flowNodes || flowNodes.length === 0) return flowNodes
266
+
267
+ const extractedDir = path.join(flowDir, flowName)
268
+ const manifestFile = path.join(extractedDir, '.manifest.json')
269
+
270
+ // Check if manifest exists
271
+ if (!fs.existsSync(manifestFile)) {
272
+ return flowNodes
273
+ }
274
+
275
+ let manifest
276
+ try {
277
+ manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf8'))
278
+ } catch (error) {
279
+ RED.log.warn(`[node-red-contrib-flow-splitter-extended] Could not read manifest for "${flowName}": ${error.message}`)
280
+ return flowNodes
281
+ }
282
+
283
+ let updatedCount = 0
284
+
285
+ // Update nodes with content from files
286
+ Object.keys(manifest).forEach((nodeId) => {
287
+ const item = manifest[nodeId]
288
+ const node = flowNodes.find(n => n.id === nodeId)
289
+
290
+ if (!node) {
291
+ RED.log.warn(`[node-red-contrib-flow-splitter-extended] Node ${nodeId} not found in flow "${flowName}"`)
292
+ return
293
+ }
294
+
295
+ const baseName = item.fileName
296
+ // item.codeExt may be absent in manifests written before this fix;
297
+ // fall back to the old vue/js guess so existing manifests still work.
298
+ const codeExt = item.codeExt || (item.isVue ? 'vue' : 'js')
299
+ const codeName = `${baseName}.${codeExt}`
300
+ const initializeName = `${baseName}.initialize.js`
301
+ const finalizeName = `${baseName}.finalize.js`
302
+ const infoName = `${baseName}.info.md`
303
+
304
+ const codeFile = path.join(extractedDir, codeName)
305
+ const initializeFile = path.join(extractedDir, initializeName)
306
+ const finalizeFile = path.join(extractedDir, finalizeName)
307
+ const infoFile = path.join(extractedDir, infoName)
308
+
309
+ // Read and update code
310
+ if (item.hasCode && fs.existsSync(codeFile)) {
311
+ const content = fs.readFileSync(codeFile, 'utf8')
312
+ if (item.isVue) {
313
+ if (node.format !== content) {
314
+ node.format = content
315
+ updatedCount++
316
+ }
317
+ } else if (item.isTemplateNode) {
318
+ if (node.template !== content) {
319
+ node.template = content
320
+ updatedCount++
321
+ }
322
+ } else if (item.isFun) {
323
+ if (node.func !== content) {
324
+ node.func = content
325
+ updatedCount++
326
+ }
327
+ }
328
+ }
329
+
330
+ // Read and update initialize
331
+ if (item.hasInitialize && fs.existsSync(initializeFile)) {
332
+ const content = fs.readFileSync(initializeFile, 'utf8')
333
+ if (node.initialize !== content) {
334
+ node.initialize = content
335
+ updatedCount++
336
+ }
337
+ }
338
+
339
+ // Read and update finalize
340
+ if (item.hasFinalize && fs.existsSync(finalizeFile)) {
341
+ const content = fs.readFileSync(finalizeFile, 'utf8')
342
+ if (node.finalize !== content) {
343
+ node.finalize = content
344
+ updatedCount++
345
+ }
346
+ }
347
+
348
+ // Read and update info
349
+ if (item.hasInfo && fs.existsSync(infoFile)) {
350
+ const content = fs.readFileSync(infoFile, 'utf8')
351
+ if (node.info !== content) {
352
+ node.info = content
353
+ updatedCount++
354
+ }
355
+ }
356
+ })
357
+
358
+ if (updatedCount > 0) {
359
+ RED.log.info(`[node-red-contrib-flow-splitter-extended] Restored ${updatedCount} functions/templates for "${flowName}"`)
360
+ }
361
+
362
+ return flowNodes
363
+ }
364
+
365
+ module.exports = {
366
+ writeFileAtomic,
367
+ extractFunctionsAndTemplates,
368
+ restoreFunctionsAndTemplates
369
+ }
package/index.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ import {NodeDef} from "@node-red/registry";
2
+
3
+ /**
4
+ * This typings files is made to ease developpement of plugins by defining types that are not defined by the NodeRed libraries.
5
+ * When a type is defined by the nodered lib but needs to be extended, it is also done here.
6
+ */
7
+
8
+ export namespace noderedEvent {
9
+ export interface FlowStartedEvent {
10
+ config: FlowConfig;
11
+ type: "flows" | "node" | "full";
12
+ diff?: FlowDiff;
13
+ }
14
+
15
+ export interface FlowDiff {
16
+ added: [string];
17
+ changed: [string];
18
+ removed: [string];
19
+ rewired: [string];
20
+ linked: [string];
21
+ }
22
+
23
+ export interface FlowConfig {
24
+ flows: [ExtendedNodeDef],
25
+ rev: string
26
+ }
27
+
28
+ export interface ExtendedNodeDef extends NodeDef {
29
+ [key: string]: any
30
+ }
31
+ }