@vdwpsmt/node-red-contrib-flow-splitter-extended 1.0.0 → 1.0.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.
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,8 @@ Default configuration file =
124
129
  "fileFormat": "yaml",
125
130
  "destinationFolder": "src",
126
131
  "tabsOrder": [],
127
- "extractFunctionsTemplates": true
132
+ "extractFunctionsTemplates": true,
133
+ "restoreFunctionsTemplates": false
128
134
  }
129
135
  ```
130
136
 
@@ -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
@@ -30,7 +30,8 @@ const DEFAULT_CFG = {
30
30
  destinationFolder: 'src',
31
31
  tabsOrder: [],
32
32
  monolithFilename: "flows.json",
33
- extractFunctionsTemplates: true
33
+ extractFunctionsTemplates: true,
34
+ restoreFunctionsTemplates: false
34
35
  }
35
36
 
36
37
  /**
@@ -113,6 +114,142 @@ function extractFunctionsTemplatesFromSplitFiles(cfg, projectPath) {
113
114
 
114
115
  processFlowDirectory(tabsDir, cfg.fileFormat, 'tab')
115
116
  processFlowDirectory(subflowsDir, cfg.fileFormat, 'subflow')
117
+
118
+ // Clean up orphaned directories from renamed/deleted flows
119
+ cleanupOrphanedDirectories(tabsDir, cfg.fileFormat)
120
+ cleanupOrphanedDirectories(subflowsDir, cfg.fileFormat)
121
+ }
122
+
123
+ /**
124
+ * Remove subdirectories that don't have a corresponding flow file
125
+ * @param {string} dir - Directory to clean (tabs or subflows)
126
+ * @param {string} fileFormat - File format (yaml or json)
127
+ */
128
+ function cleanupOrphanedDirectories(dir, fileFormat) {
129
+ if (!fs.existsSync(dir)) {
130
+ return
131
+ }
132
+
133
+ const extension = fileFormat === 'yaml' ? '.yaml' : '.json'
134
+
135
+ // Get all flow files
136
+ const flowFiles = fs.readdirSync(dir)
137
+ .filter(f => f.endsWith(extension))
138
+ .map(f => path.basename(f, extension))
139
+
140
+ // Get all subdirectories
141
+ const subdirs = fs.readdirSync(dir)
142
+ .filter(f => {
143
+ const fullPath = path.join(dir, f)
144
+ return fs.statSync(fullPath).isDirectory()
145
+ })
146
+
147
+ // Remove orphaned subdirectories
148
+ subdirs.forEach(subdir => {
149
+ if (!flowFiles.includes(subdir)) {
150
+ const subdirPath = path.join(dir, subdir)
151
+ try {
152
+ fs.rmSync(subdirPath, { recursive: true, force: true })
153
+ RED.log.info(`[node-red-contrib-flow-splitter-extended] Removed orphaned directory: ${subdir}`)
154
+ } catch (error) {
155
+ RED.log.warn(`[node-red-contrib-flow-splitter-extended] Could not remove orphaned directory ${subdir}: ${error.message}`)
156
+ }
157
+ }
158
+ })
159
+ }
160
+
161
+ /**
162
+ * Clean up old flow files when a tab or subflow has been renamed.
163
+ * Scans existing files and removes those with IDs that match current flows but have different filenames.
164
+ * @param {Array} flowNodes - Array of all flow nodes from Node-RED
165
+ * @param {object} cfg - Splitter configuration
166
+ * @param {string} projectPath - Path to the project
167
+ */
168
+ function cleanupRenamedFlows(flowNodes, cfg, projectPath) {
169
+ const srcDir = path.join(projectPath, cfg.destinationFolder || 'src')
170
+ const tabsDir = path.join(srcDir, 'tabs')
171
+ const subflowsDir = path.join(srcDir, 'subflows')
172
+ const extension = cfg.fileFormat === 'yaml' ? '.yaml' : '.json'
173
+
174
+ // Build maps of ID -> expected filename from the current flow nodes
175
+ const tabsIdToFilename = new Map()
176
+ const subflowsIdToFilename = new Map()
177
+
178
+ flowNodes.forEach(node => {
179
+ if (node.type === 'tab' && node.id) {
180
+ // Use normalizedLabel if available, otherwise compute from label
181
+ const label = node.label || node.id
182
+ const expectedFilename = node.normalizedLabel ||
183
+ label.replace(/[\/\\:*?"<>|]/g, '-').toLowerCase().replace(/\s+/g, '-')
184
+ tabsIdToFilename.set(node.id, expectedFilename)
185
+ } else if (node.type === 'subflow' && node.id) {
186
+ const name = node.name || node.id
187
+ const expectedFilename = name.replace(/[\/\\:*?"<>|]/g, '-').toLowerCase().replace(/\s+/g, '-')
188
+ subflowsIdToFilename.set(node.id, expectedFilename)
189
+ }
190
+ })
191
+
192
+ // Clean up tabs directory
193
+ cleanupRenamedFlowsInDir(tabsDir, tabsIdToFilename, extension, 'tab')
194
+
195
+ // Clean up subflows directory
196
+ cleanupRenamedFlowsInDir(subflowsDir, subflowsIdToFilename, extension, 'subflow')
197
+ }
198
+
199
+ /**
200
+ * Clean up renamed flows in a specific directory.
201
+ * Removes old files when the same ID exists but with a different filename.
202
+ * @param {string} dir - Directory to scan
203
+ * @param {Map} idToFilename - Map of ID to expected filename
204
+ * @param {string} extension - File extension (.yaml or .json)
205
+ * @param {string} flowType - Type of flow (tab or subflow)
206
+ */
207
+ function cleanupRenamedFlowsInDir(dir, idToFilename, extension, flowType) {
208
+ if (!fs.existsSync(dir)) {
209
+ return
210
+ }
211
+
212
+ const files = fs.readdirSync(dir).filter(f => f.endsWith(extension))
213
+
214
+ files.forEach(file => {
215
+ const filePath = path.join(dir, file)
216
+ const filename = path.basename(file, extension)
217
+
218
+ try {
219
+ let flowData
220
+ const fileContent = fs.readFileSync(filePath, 'utf8')
221
+
222
+ if (extension === '.yaml') {
223
+ flowData = yaml.load(fileContent)
224
+ } else {
225
+ flowData = JSON.parse(fileContent)
226
+ }
227
+
228
+ const flowDataArray = Array.isArray(flowData) ? flowData : [flowData]
229
+ const flowNode = flowDataArray.find(n => n.type === flowType)
230
+
231
+ if (flowNode && flowNode.id) {
232
+ const expectedFilename = idToFilename.get(flowNode.id)
233
+
234
+ // If this ID exists in current flows but with a different filename, this is an old renamed file
235
+ if (expectedFilename && expectedFilename !== filename) {
236
+ RED.log.info(`[node-red-contrib-flow-splitter-extended] Removing old ${flowType} file "${file}" (renamed to "${expectedFilename}${extension}")`)
237
+
238
+ // Remove the old flow file
239
+ fs.unlinkSync(filePath)
240
+
241
+ // Remove the corresponding subdirectory if it exists
242
+ const subdirPath = path.join(dir, filename)
243
+ if (fs.existsSync(subdirPath) && fs.statSync(subdirPath).isDirectory()) {
244
+ fs.rmSync(subdirPath, { recursive: true, force: true })
245
+ RED.log.info(`[node-red-contrib-flow-splitter-extended] Removed old ${flowType} directory "${filename}"`)
246
+ }
247
+ }
248
+ }
249
+ } catch (error) {
250
+ RED.log.warn(`[node-red-contrib-flow-splitter-extended] Error checking ${flowType} file ${file}: ${error.message}`)
251
+ }
252
+ })
116
253
  }
117
254
 
118
255
  /**
@@ -153,12 +290,12 @@ function processFlowDirectory(dir, fileFormat, flowType) {
153
290
  }
154
291
 
155
292
  /**
156
- * Collect functions and templates back into split flow files before rebuilding monolith
293
+ * Restore functions and templates back into split flow files before rebuilding single flows.json file
157
294
  * @param {object} cfg - Splitter configuration
158
295
  * @param {string} projectPath - Path to the project
159
296
  */
160
- function collectFunctionsTemplatesIntoSplitFiles(cfg, projectPath) {
161
- if (cfg.extractFunctionsTemplates === false) {
297
+ function restoreFunctionsTemplatesIntoSplitFiles(cfg, projectPath) {
298
+ if (cfg.restoreFunctionsTemplates === false) {
162
299
  return
163
300
  }
164
301
 
@@ -166,19 +303,19 @@ function collectFunctionsTemplatesIntoSplitFiles(cfg, projectPath) {
166
303
  const tabsDir = path.join(srcDir, 'tabs')
167
304
  const subflowsDir = path.join(srcDir, 'subflows')
168
305
 
169
- RED.log.info("[node-red-contrib-flow-splitter-extended] Collecting functions and templates...")
306
+ RED.log.info("[node-red-contrib-flow-splitter-extended] Restoring functions and templates...")
170
307
 
171
- collectFromFlowDirectory(tabsDir, cfg.fileFormat, 'tab')
172
- collectFromFlowDirectory(subflowsDir, cfg.fileFormat, 'subflow')
308
+ restoreIntoFlowDirectory(tabsDir, cfg.fileFormat, 'tab')
309
+ restoreIntoFlowDirectory(subflowsDir, cfg.fileFormat, 'subflow')
173
310
  }
174
311
 
175
312
  /**
176
- * Process a directory of flow files to collect functions/templates
313
+ * Process a directory of flow files to restore functions/templates
177
314
  * @param {string} dir - Directory to process
178
315
  * @param {string} fileFormat - File format (yaml or json)
179
316
  * @param {string} flowType - Type of flow (tab or subflow)
180
317
  */
181
- function collectFromFlowDirectory(dir, fileFormat, flowType) {
318
+ function restoreIntoFlowDirectory(dir, fileFormat, flowType) {
182
319
  if (!fs.existsSync(dir)) {
183
320
  return
184
321
  }
@@ -201,7 +338,7 @@ function collectFromFlowDirectory(dir, fileFormat, flowType) {
201
338
  }
202
339
 
203
340
  let flowNodes = Array.isArray(flowData) ? flowData : [flowData]
204
- flowNodes = functionsTemplatesHandler.collectFunctionsAndTemplates(flowNodes, flowName, dir, RED)
341
+ flowNodes = functionsTemplatesHandler.restoreFunctionsAndTemplates(flowNodes, flowName, dir, RED)
205
342
 
206
343
  if (fileFormat === 'yaml') {
207
344
  const yamlContent = yaml.dump(flowNodes, {
@@ -216,14 +353,14 @@ function collectFromFlowDirectory(dir, fileFormat, flowType) {
216
353
  }
217
354
 
218
355
  } catch (error) {
219
- RED.log.warn(`[node-red-contrib-flow-splitter-extended] Error collecting ${flowType} ${flowName}: ${error.message}`)
356
+ RED.log.warn(`[node-red-contrib-flow-splitter-extended] Error restoring ${flowType} ${flowName}: ${error.message}`)
220
357
  }
221
358
  })
222
359
  }
223
360
 
224
361
  /**
225
362
  * Manual reload endpoint handler
226
- * Collects functions/templates from files and reloads flows
363
+ * Restores functions/templates from files and reloads flows
227
364
  */
228
365
  async function manualReload(req, res) {
229
366
  try {
@@ -232,7 +369,7 @@ async function manualReload(req, res) {
232
369
  const projectPath = getProjectPath()
233
370
  const cfg = loadSplitterConfig(projectPath)
234
371
 
235
- collectFunctionsTemplatesIntoSplitFiles(cfg, projectPath)
372
+ restoreFunctionsTemplatesIntoSplitFiles(cfg, projectPath)
236
373
 
237
374
  const flowSet = manager.constructFlowSetFromTreeFiles(cfg, projectPath)
238
375
 
@@ -278,9 +415,9 @@ async function onFlowReload(flowEventData) {
278
415
 
279
416
  if (flowEventData.config.flows.length === 0) {
280
417
  // 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")
418
+ RED.log.info("[node-red-contrib-flow-splitter-extended] Rebuilding single flows.json file from source files")
282
419
 
283
- collectFunctionsTemplatesIntoSplitFiles(cfg, projectPath)
420
+ restoreFunctionsTemplatesIntoSplitFiles(cfg, projectPath)
284
421
 
285
422
  const flowSet = manager.constructFlowSetFromTreeFiles(cfg, projectPath)
286
423
 
@@ -303,6 +440,9 @@ async function onFlowReload(flowEventData) {
303
440
  }
304
441
 
305
442
  // Flows exist - split into source files
443
+ // First, clean up any old files from renamed tabs/subflows
444
+ cleanupRenamedFlows(flowEventData.config.flows, cfg, projectPath)
445
+
306
446
  const flowSet = manager.constructFlowSetFromMonolithObject(flowEventData.config.flows)
307
447
 
308
448
  const updatedCfg = manager.constructTreeFilesFromFlowSet(flowSet, cfg, projectPath)
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.1",
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": [