@vdwpsmt/node-red-contrib-flow-splitter-extended 1.0.0 → 1.0.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/README.md CHANGED
@@ -22,13 +22,18 @@ It will make the diffs of your version control much more controlled and readable
22
22
  - Supports both YAML and JSON formats
23
23
  - Maintains tab order through configuration
24
24
 
25
- ### Function & Template Extraction (NEW)
26
- - Automatically extracts code from `function` nodes into `.js` files
27
- - Extracts `ui-template` (Dashboard 2.0) content into `.vue` files
28
- - Supports function `initialize` and `finalize` code in separate files
29
- - Extracts node `info` documentation into `.md` files
25
+ ### Optional Function & Template Extraction and Restore (NEW)
26
+ - Automatically extracts into seperate files per function/ui-template
27
+ - Extracts code from `function` nodes into `.js` files
28
+ - Extracts `ui-template` (Dashboard 2.0) content into `.vue` files
29
+ - Supports function `initialize` and `finalize` code in separate files
30
+ - Extracts node `info` documentation into `.md` files
30
31
  - Organizes extracted files in subdirectories alongside their parent tab/subflow
31
- - Automatically syncs changes back to Node-RED on startup
32
+ - Restores changes back to Node-RED
33
+ - Automatically on startup
34
+ - Manually reload using endpoint
35
+ - Both extraction (default true) and restoring (default false) can be changed in `.config.flow-splitter.json`
36
+
32
37
 
33
38
  ## Functioning
34
39
 
@@ -81,7 +86,7 @@ fetch('http://localhost:1880/flow-splitter/reload', {method: 'POST'})
81
86
  ```
82
87
 
83
88
  This allows you to:
84
- 1. Edit function/template files in VS Code
89
+ 1. Edit function/template files in VS Code or any other IDE
85
90
  2. Save changes
86
91
  3. Run the reload command
87
92
  4. See changes immediately in Node-RED (without deploy/restart)
@@ -124,7 +129,9 @@ Default configuration file =
124
129
  "fileFormat": "yaml",
125
130
  "destinationFolder": "src",
126
131
  "tabsOrder": [],
127
- "extractFunctionsTemplates": true
132
+ "extractFunctionsTemplates": true,
133
+ "restoreFunctionsTemplates": false,
134
+ "enableArtifact": false
128
135
  }
129
136
  ```
130
137
 
@@ -134,6 +141,7 @@ You can freely edit the config file, the changes are taken into account at the n
134
141
  - `destinationFolder`: path where to create the `tabs`, `subflows` and `config-nodes` sub-directories
135
142
  - `tabsOrder`: position of each tab (ordered array of the Ids of each tab node)
136
143
  - `extractFunctionsTemplates`: additional extraction of function and ui-template nodes
144
+ - `enableArtifact`: when `true`, writes a deployable monolith artifact to `artifact/flows.json` on reload/start events
137
145
  ## Installation
138
146
 
139
147
  ```bash
@@ -4,7 +4,7 @@ const fs = require('fs-extra')
4
4
  /**
5
5
  * Functions and Templates nodes Handler
6
6
  * Extracts function and ui-template node code into separate files
7
- * and collects them back when rebuilding flows
7
+ * and restores them back when rebuilding flows
8
8
  */
9
9
 
10
10
  /**
@@ -18,6 +18,12 @@ function extractFunctionsAndTemplates(flowNodes, flowName, flowDir, RED) {
18
18
  if (!flowNodes || flowNodes.length === 0) return
19
19
 
20
20
  const extractedDir = path.join(flowDir, flowName)
21
+
22
+ // Delete entire extracted directory to ensure fresh state
23
+ if (fs.existsSync(extractedDir)) {
24
+ fs.removeSync(extractedDir)
25
+ }
26
+
21
27
  const manifest = {}
22
28
  const fileNames = []
23
29
  let count = 0
@@ -124,20 +130,17 @@ function extractFunctionsAndTemplates(flowNodes, flowName, flowDir, RED) {
124
130
 
125
131
  RED.log.info(`[node-red-contrib-flow-splitter] Extracted ${count} functions/templates for "${flowName}"`)
126
132
  }
127
-
128
- // Clean up unused files
129
- cleanupUnusedFiles(extractedDir, manifest, RED)
130
133
  }
131
134
 
132
135
  /**
133
- * Collect functions and templates from separate files back into flow nodes
136
+ * Restore functions and templates from separate files back into flow nodes
134
137
  * @param {Array} flowNodes - Array of nodes from a tab or subflow
135
138
  * @param {string} flowName - Name of the tab or subflow
136
139
  * @param {string} flowDir - Directory where the flow file is stored
137
140
  * @param {object} RED - Node-RED runtime
138
141
  * @returns {Array} - Updated flow nodes
139
142
  */
140
- function collectFunctionsAndTemplates(flowNodes, flowName, flowDir, RED) {
143
+ function restoreFunctionsAndTemplates(flowNodes, flowName, flowDir, RED) {
141
144
  if (!flowNodes || flowNodes.length === 0) return flowNodes
142
145
 
143
146
  const extractedDir = path.join(flowDir, flowName)
@@ -225,77 +228,13 @@ function collectFunctionsAndTemplates(flowNodes, flowName, flowDir, RED) {
225
228
  })
226
229
 
227
230
  if (updatedCount > 0) {
228
- RED.log.info(`[node-red-contrib-flow-splitter] Collected ${updatedCount} functions/templates for "${flowName}"`)
231
+ RED.log.info(`[node-red-contrib-flow-splitter] Restored ${updatedCount} functions/templates for "${flowName}"`)
229
232
  }
230
233
 
231
234
  return flowNodes
232
235
  }
233
236
 
234
- /**
235
- * Clean up files that are no longer in the manifest
236
- * @param {string} extractedDir - Directory containing extracted files
237
- * @param {object} manifest - Manifest object
238
- * @param {object} RED - Node-RED runtime
239
- */
240
- function cleanupUnusedFiles(extractedDir, manifest, RED) {
241
- if (!fs.existsSync(extractedDir)) return
242
-
243
- const validExtensions = ['.vue', '.js', '.md']
244
- const files = getAllFiles(extractedDir, validExtensions)
245
-
246
- files.forEach(file => {
247
- // Skip manifest file
248
- if (file.endsWith('.manifest.json')) return
249
-
250
- let found = false
251
- Object.keys(manifest).forEach((id) => {
252
- const item = manifest[id]
253
- if (file.indexOf(item.fileName) > -1) {
254
- found = true
255
- }
256
- })
257
-
258
- if (!found) {
259
- const filePath = path.join(extractedDir, file)
260
- try {
261
- fs.removeSync(filePath)
262
- RED.log.info(`[node-red-contrib-flow-splitter] Removed unused file: ${file}`)
263
- } catch (error) {
264
- RED.log.warn(`[node-red-contrib-flow-splitter] Could not remove file ${file}: ${error.message}`)
265
- }
266
- }
267
- })
268
- }
269
-
270
- /**
271
- * Get all files with specified extensions from a directory
272
- * @param {string} dir - Directory to search
273
- * @param {Array<string>} exts - Array of extensions to match
274
- * @param {Array<string>} fileList - Accumulated file list
275
- * @param {string} relDir - Relative directory path
276
- * @returns {Array<string>} - List of file paths
277
- */
278
- function getAllFiles(dir, exts, fileList = [], relDir = '') {
279
- if (!fs.existsSync(dir)) return fileList
280
-
281
- const files = fs.readdirSync(dir)
282
-
283
- files.forEach(file => {
284
- const filePath = path.join(dir, file)
285
- const relPath = path.join(relDir, file)
286
- const stat = fs.statSync(filePath)
287
-
288
- if (stat.isDirectory()) {
289
- getAllFiles(filePath, exts, fileList, relPath)
290
- } else if (exts.some(ext => file.endsWith(ext))) {
291
- fileList.push(relPath)
292
- }
293
- })
294
-
295
- return fileList
296
- }
297
-
298
237
  module.exports = {
299
238
  extractFunctionsAndTemplates,
300
- collectFunctionsAndTemplates
239
+ restoreFunctionsAndTemplates
301
240
  }
package/index.js CHANGED
@@ -25,12 +25,16 @@ const functionsTemplatesHandler = require('./functions-templates-handler')
25
25
  let RED
26
26
 
27
27
  const splitCfgFilename = '.config.flow-splitter.json'
28
+ const artifactsDirname = 'artifact'
29
+ const artifactsFilename = 'flows.json'
28
30
  const DEFAULT_CFG = {
29
31
  fileFormat: 'yaml',
30
32
  destinationFolder: 'src',
31
33
  tabsOrder: [],
32
34
  monolithFilename: "flows.json",
33
- extractFunctionsTemplates: true
35
+ extractFunctionsTemplates: true,
36
+ restoreFunctionsTemplates: false,
37
+ enableArtifact: false
34
38
  }
35
39
 
36
40
  /**
@@ -113,6 +117,142 @@ function extractFunctionsTemplatesFromSplitFiles(cfg, projectPath) {
113
117
 
114
118
  processFlowDirectory(tabsDir, cfg.fileFormat, 'tab')
115
119
  processFlowDirectory(subflowsDir, cfg.fileFormat, 'subflow')
120
+
121
+ // Clean up orphaned directories from renamed/deleted flows
122
+ cleanupOrphanedDirectories(tabsDir, cfg.fileFormat)
123
+ cleanupOrphanedDirectories(subflowsDir, cfg.fileFormat)
124
+ }
125
+
126
+ /**
127
+ * Remove subdirectories that don't have a corresponding flow file
128
+ * @param {string} dir - Directory to clean (tabs or subflows)
129
+ * @param {string} fileFormat - File format (yaml or json)
130
+ */
131
+ function cleanupOrphanedDirectories(dir, fileFormat) {
132
+ if (!fs.existsSync(dir)) {
133
+ return
134
+ }
135
+
136
+ const extension = fileFormat === 'yaml' ? '.yaml' : '.json'
137
+
138
+ // Get all flow files
139
+ const flowFiles = fs.readdirSync(dir)
140
+ .filter(f => f.endsWith(extension))
141
+ .map(f => path.basename(f, extension))
142
+
143
+ // Get all subdirectories
144
+ const subdirs = fs.readdirSync(dir)
145
+ .filter(f => {
146
+ const fullPath = path.join(dir, f)
147
+ return fs.statSync(fullPath).isDirectory()
148
+ })
149
+
150
+ // Remove orphaned subdirectories
151
+ subdirs.forEach(subdir => {
152
+ if (!flowFiles.includes(subdir)) {
153
+ const subdirPath = path.join(dir, subdir)
154
+ try {
155
+ fs.rmSync(subdirPath, { recursive: true, force: true })
156
+ RED.log.info(`[node-red-contrib-flow-splitter-extended] Removed orphaned directory: ${subdir}`)
157
+ } catch (error) {
158
+ RED.log.warn(`[node-red-contrib-flow-splitter-extended] Could not remove orphaned directory ${subdir}: ${error.message}`)
159
+ }
160
+ }
161
+ })
162
+ }
163
+
164
+ /**
165
+ * Clean up old flow files when a tab or subflow has been renamed.
166
+ * Scans existing files and removes those with IDs that match current flows but have different filenames.
167
+ * @param {Array} flowNodes - Array of all flow nodes from Node-RED
168
+ * @param {object} cfg - Splitter configuration
169
+ * @param {string} projectPath - Path to the project
170
+ */
171
+ function cleanupRenamedFlows(flowNodes, cfg, projectPath) {
172
+ const srcDir = path.join(projectPath, cfg.destinationFolder || 'src')
173
+ const tabsDir = path.join(srcDir, 'tabs')
174
+ const subflowsDir = path.join(srcDir, 'subflows')
175
+ const extension = cfg.fileFormat === 'yaml' ? '.yaml' : '.json'
176
+
177
+ // Build maps of ID -> expected filename from the current flow nodes
178
+ const tabsIdToFilename = new Map()
179
+ const subflowsIdToFilename = new Map()
180
+
181
+ flowNodes.forEach(node => {
182
+ if (node.type === 'tab' && node.id) {
183
+ // Use normalizedLabel if available, otherwise compute from label
184
+ const label = node.label || node.id
185
+ const expectedFilename = node.normalizedLabel ||
186
+ label.replace(/[\/\\:*?"<>|]/g, '-').toLowerCase().replace(/\s+/g, '-')
187
+ tabsIdToFilename.set(node.id, expectedFilename)
188
+ } else if (node.type === 'subflow' && node.id) {
189
+ const name = node.name || node.id
190
+ const expectedFilename = name.replace(/[\/\\:*?"<>|]/g, '-').toLowerCase().replace(/\s+/g, '-')
191
+ subflowsIdToFilename.set(node.id, expectedFilename)
192
+ }
193
+ })
194
+
195
+ // Clean up tabs directory
196
+ cleanupRenamedFlowsInDir(tabsDir, tabsIdToFilename, extension, 'tab')
197
+
198
+ // Clean up subflows directory
199
+ cleanupRenamedFlowsInDir(subflowsDir, subflowsIdToFilename, extension, 'subflow')
200
+ }
201
+
202
+ /**
203
+ * Clean up renamed flows in a specific directory.
204
+ * Removes old files when the same ID exists but with a different filename.
205
+ * @param {string} dir - Directory to scan
206
+ * @param {Map} idToFilename - Map of ID to expected filename
207
+ * @param {string} extension - File extension (.yaml or .json)
208
+ * @param {string} flowType - Type of flow (tab or subflow)
209
+ */
210
+ function cleanupRenamedFlowsInDir(dir, idToFilename, extension, flowType) {
211
+ if (!fs.existsSync(dir)) {
212
+ return
213
+ }
214
+
215
+ const files = fs.readdirSync(dir).filter(f => f.endsWith(extension))
216
+
217
+ files.forEach(file => {
218
+ const filePath = path.join(dir, file)
219
+ const filename = path.basename(file, extension)
220
+
221
+ try {
222
+ let flowData
223
+ const fileContent = fs.readFileSync(filePath, 'utf8')
224
+
225
+ if (extension === '.yaml') {
226
+ flowData = yaml.load(fileContent)
227
+ } else {
228
+ flowData = JSON.parse(fileContent)
229
+ }
230
+
231
+ const flowDataArray = Array.isArray(flowData) ? flowData : [flowData]
232
+ const flowNode = flowDataArray.find(n => n.type === flowType)
233
+
234
+ if (flowNode && flowNode.id) {
235
+ const expectedFilename = idToFilename.get(flowNode.id)
236
+
237
+ // If this ID exists in current flows but with a different filename, this is an old renamed file
238
+ if (expectedFilename && expectedFilename !== filename) {
239
+ RED.log.info(`[node-red-contrib-flow-splitter-extended] Removing old ${flowType} file "${file}" (renamed to "${expectedFilename}${extension}")`)
240
+
241
+ // Remove the old flow file
242
+ fs.unlinkSync(filePath)
243
+
244
+ // Remove the corresponding subdirectory if it exists
245
+ const subdirPath = path.join(dir, filename)
246
+ if (fs.existsSync(subdirPath) && fs.statSync(subdirPath).isDirectory()) {
247
+ fs.rmSync(subdirPath, { recursive: true, force: true })
248
+ RED.log.info(`[node-red-contrib-flow-splitter-extended] Removed old ${flowType} directory "${filename}"`)
249
+ }
250
+ }
251
+ }
252
+ } catch (error) {
253
+ RED.log.warn(`[node-red-contrib-flow-splitter-extended] Error checking ${flowType} file ${file}: ${error.message}`)
254
+ }
255
+ })
116
256
  }
117
257
 
118
258
  /**
@@ -153,12 +293,12 @@ function processFlowDirectory(dir, fileFormat, flowType) {
153
293
  }
154
294
 
155
295
  /**
156
- * Collect functions and templates back into split flow files before rebuilding monolith
296
+ * Restore functions and templates back into split flow files before rebuilding single flows.json file
157
297
  * @param {object} cfg - Splitter configuration
158
298
  * @param {string} projectPath - Path to the project
159
299
  */
160
- function collectFunctionsTemplatesIntoSplitFiles(cfg, projectPath) {
161
- if (cfg.extractFunctionsTemplates === false) {
300
+ function restoreFunctionsTemplatesIntoSplitFiles(cfg, projectPath) {
301
+ if (cfg.restoreFunctionsTemplates === false) {
162
302
  return
163
303
  }
164
304
 
@@ -166,19 +306,19 @@ function collectFunctionsTemplatesIntoSplitFiles(cfg, projectPath) {
166
306
  const tabsDir = path.join(srcDir, 'tabs')
167
307
  const subflowsDir = path.join(srcDir, 'subflows')
168
308
 
169
- RED.log.info("[node-red-contrib-flow-splitter-extended] Collecting functions and templates...")
309
+ RED.log.info("[node-red-contrib-flow-splitter-extended] Restoring functions and templates...")
170
310
 
171
- collectFromFlowDirectory(tabsDir, cfg.fileFormat, 'tab')
172
- collectFromFlowDirectory(subflowsDir, cfg.fileFormat, 'subflow')
311
+ restoreIntoFlowDirectory(tabsDir, cfg.fileFormat, 'tab')
312
+ restoreIntoFlowDirectory(subflowsDir, cfg.fileFormat, 'subflow')
173
313
  }
174
314
 
175
315
  /**
176
- * Process a directory of flow files to collect functions/templates
316
+ * Process a directory of flow files to restore functions/templates
177
317
  * @param {string} dir - Directory to process
178
318
  * @param {string} fileFormat - File format (yaml or json)
179
319
  * @param {string} flowType - Type of flow (tab or subflow)
180
320
  */
181
- function collectFromFlowDirectory(dir, fileFormat, flowType) {
321
+ function restoreIntoFlowDirectory(dir, fileFormat, flowType) {
182
322
  if (!fs.existsSync(dir)) {
183
323
  return
184
324
  }
@@ -201,7 +341,7 @@ function collectFromFlowDirectory(dir, fileFormat, flowType) {
201
341
  }
202
342
 
203
343
  let flowNodes = Array.isArray(flowData) ? flowData : [flowData]
204
- flowNodes = functionsTemplatesHandler.collectFunctionsAndTemplates(flowNodes, flowName, dir, RED)
344
+ flowNodes = functionsTemplatesHandler.restoreFunctionsAndTemplates(flowNodes, flowName, dir, RED)
205
345
 
206
346
  if (fileFormat === 'yaml') {
207
347
  const yamlContent = yaml.dump(flowNodes, {
@@ -216,14 +356,50 @@ function collectFromFlowDirectory(dir, fileFormat, flowType) {
216
356
  }
217
357
 
218
358
  } catch (error) {
219
- RED.log.warn(`[node-red-contrib-flow-splitter-extended] Error collecting ${flowType} ${flowName}: ${error.message}`)
359
+ RED.log.warn(`[node-red-contrib-flow-splitter-extended] Error restoring ${flowType} ${flowName}: ${error.message}`)
220
360
  }
221
361
  })
222
362
  }
223
363
 
364
+ /**
365
+ * Write a monolith artifact file to artifact/flows.json when enabled.
366
+ * @param {object} cfg - Splitter configuration
367
+ * @param {string} projectPath - Path to the project
368
+ * @param {Array} [flowNodes] - Optional flow nodes to serialize directly
369
+ */
370
+ function writeMonolithArtifact(cfg, projectPath, flowNodes) {
371
+ if (cfg.enableArtifact !== true) {
372
+ return
373
+ }
374
+
375
+ const artifactDir = path.join(projectPath, artifactsDirname)
376
+ const artifactPath = path.join(artifactDir, artifactsFilename)
377
+
378
+ try {
379
+ fs.mkdirSync(artifactDir, { recursive: true })
380
+
381
+ if (Array.isArray(flowNodes)) {
382
+ fs.writeFileSync(artifactPath, eol.auto(JSON.stringify(flowNodes, null, 2)), 'utf8')
383
+ } else {
384
+ const monolithPath = path.join(projectPath, cfg.monolithFilename || RED.settings.flowFile || 'flows.json')
385
+
386
+ if (!fs.existsSync(monolithPath)) {
387
+ RED.log.warn(`[node-red-contrib-flow-splitter-extended] Could not write artifact: monolith file not found at '${monolithPath}'`)
388
+ return
389
+ }
390
+
391
+ fs.copyFileSync(monolithPath, artifactPath)
392
+ }
393
+
394
+ RED.log.info(`[node-red-contrib-flow-splitter-extended] Wrote monolith artifact at '${artifactPath}'`)
395
+ } catch (error) {
396
+ RED.log.warn(`[node-red-contrib-flow-splitter-extended] Could not write monolith artifact: ${error.message}`)
397
+ }
398
+ }
399
+
224
400
  /**
225
401
  * Manual reload endpoint handler
226
- * Collects functions/templates from files and reloads flows
402
+ * Restores functions/templates from files and reloads flows
227
403
  */
228
404
  async function manualReload(req, res) {
229
405
  try {
@@ -232,7 +408,7 @@ async function manualReload(req, res) {
232
408
  const projectPath = getProjectPath()
233
409
  const cfg = loadSplitterConfig(projectPath)
234
410
 
235
- collectFunctionsTemplatesIntoSplitFiles(cfg, projectPath)
411
+ restoreFunctionsTemplatesIntoSplitFiles(cfg, projectPath)
236
412
 
237
413
  const flowSet = manager.constructFlowSetFromTreeFiles(cfg, projectPath)
238
414
 
@@ -245,6 +421,7 @@ async function manualReload(req, res) {
245
421
  }
246
422
 
247
423
  manager.constructMonolithFileFromFlowSet(flowSet, cfg, projectPath, false)
424
+ writeMonolithArtifact(cfg, projectPath)
248
425
 
249
426
  const PRIVATE_RED = getPrivateRED()
250
427
  await PRIVATE_RED.nodes.loadFlows(true)
@@ -278,9 +455,9 @@ async function onFlowReload(flowEventData) {
278
455
 
279
456
  if (flowEventData.config.flows.length === 0) {
280
457
  // The flow file does not exist or is empty - rebuild from split files
281
- RED.log.info("[node-red-contrib-flow-splitter-extended] Rebuilding monolith file from source files")
458
+ RED.log.info("[node-red-contrib-flow-splitter-extended] Rebuilding single flows.json file from source files")
282
459
 
283
- collectFunctionsTemplatesIntoSplitFiles(cfg, projectPath)
460
+ restoreFunctionsTemplatesIntoSplitFiles(cfg, projectPath)
284
461
 
285
462
  const flowSet = manager.constructFlowSetFromTreeFiles(cfg, projectPath)
286
463
 
@@ -291,6 +468,7 @@ async function onFlowReload(flowEventData) {
291
468
 
292
469
  const updatedCfg = manager.constructMonolithFileFromFlowSet(flowSet, cfg, projectPath, false)
293
470
  writeSplitterConfig(updatedCfg, projectPath)
471
+ writeMonolithArtifact(updatedCfg, projectPath)
294
472
 
295
473
  const PRIVATE_RED = getPrivateRED()
296
474
 
@@ -303,10 +481,14 @@ async function onFlowReload(flowEventData) {
303
481
  }
304
482
 
305
483
  // Flows exist - split into source files
484
+ // First, clean up any old files from renamed tabs/subflows
485
+ cleanupRenamedFlows(flowEventData.config.flows, cfg, projectPath)
486
+
306
487
  const flowSet = manager.constructFlowSetFromMonolithObject(flowEventData.config.flows)
307
488
 
308
489
  const updatedCfg = manager.constructTreeFilesFromFlowSet(flowSet, cfg, projectPath)
309
490
  writeSplitterConfig(updatedCfg, projectPath)
491
+ writeMonolithArtifact(updatedCfg, projectPath, flowEventData.config.flows)
310
492
 
311
493
  extractFunctionsTemplatesFromSplitFiles(updatedCfg, projectPath)
312
494
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vdwpsmt/node-red-contrib-flow-splitter-extended",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Split your flows.json file in individual YAML or JSON files (per tab, subflow and config-node) with optional function and ui-template node code extraction.",
5
5
  "main": "index.js",
6
6
  "keywords": [