@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.
- package/LICENSE +201 -0
- package/NOTICE +13 -0
- package/README.md +319 -2
- package/functions-templates-handler.js +369 -0
- package/index.d.ts +31 -0
- package/index.js +796 -0
- package/package.json +65 -4
|
@@ -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
|
+
}
|