I want to duplicate some pages and change a few items - the basic layout is going to identical to an existing page. Is there an easy way to do that? Thanks for your help.
Best approach is to export/import, and while importing go to 'view nodes' and select to keep, duplicate or replace the UI elements. If you duplicate UI elements like pages & groups, you can rename them after the import
Unfortunately, it is really hard to select what to duplicate. The window cannot be resized and the icons/text are grayed out making it even harder to figure out what is what in the “config nodes” section.
Now I have completely messed it up . I thought I’d try making a copy of the flow and then adding a ui-page and changing the links of the groups to that page. Unfortunately it looks like it changes the original group (on the original flow) so now it is a huge mess.
Oh well - that will teach me to try things in incremental steps . Luckily, this instance is a dev instance to I think I can get it straightened out, but it is going to be a pain.
Does anyone have other options for doing this?
UPDATE: I managed to get everything back by deleting the flows and the pages, looking for “unused” group in the config-node panel and deleting those and then re-importing the flow from my “live” version. Phew
When you import tell it to make a copy of everything, then sort it out.
Yeah - I just tried that and here is what I found.
- The duplicate ui-pages, ui-groups and widgets were imported but the duplicate groups were not associated with these pages (they were all associated with the original page) and the widgets had the original group name. So now the original page had duplicates of everything and the new “copied” page was empty.
- The duplicate ui-page had the same url as the original page (not a big deal but something something that NR will complain about when deploying)
Going through and sorting that out is almost not worth it unfortunately.
I wish what would happen is that duplicate config/nodes appended “copy” to the objects that are copied and retain the hierarchy? It would also make it simpler to find and edit the copy
Well - I got it to work. Here are the steps I took.
- Created a new flow and moved the flow tab right next to the one I wanted to duplicate
- Copied the old flow (I did it in sections/groups) to the new flow page. This will duplicate the dashboard widgets on the original ui-page.
- Created a new ui-page (named it similar to the original and moved it right below the original ui-page in the Dashboard 2 panel on the right side of the editor). The configuration of the page (theme, custom CSS, icon etc. where the same as the original, but the “Path'“ was new.
- On the new ui-page, created the first group (named it exactly as the original and made sure the settings were the same)
- Dragged the duplicate widgets from the original ui-page/group to the new ui-page/group making sure that they were in the same order (Note: the copied widgets are not sorted in same order as the original - eg. Original order 1→2→3, copied order 2→3→1)
- Repeat for each ui-group on the original ui-page.
My earlier approach of importing the flow (after selecting the option to “copy” duplicates) had a few issues.
- It looks like the ui-groups were not duplicated
- The duplicate ui-page had the same “Path” as the original but had no associated groups.
- All the duplicate widgets were under the original group, so I would have had to do steps 4-6 in any case
I have created a “feature request” with some suggestions to improve the process to import flows ( Importing flow duplicates detected - window is not resizable ). Hopefully it will be considered down the line.
I had this problem for a long time and with big flows its hard to grab the copied UI elements and not the original to the menus. Because of my big flows I decided to make that a little more easy.
- export the original flow you want to copy to disk as file
- use my flow converter (set file path to the file you saved and file path + name to output file)
- press the injection button and this will give unique ids and name (menu) to the output file.
- import this file as new flow. (imported copy will have original name+ copy)
its a bitt dirty and quick coded. use at own risk.
[{"id":"dec2a28eafe202f9","type":"file in","z":"c2904b2287b55c0f","name":"Read Flow JSON","filename":"/home/pi/flowConverter/tasmotaSwitch.json","filenameType":"str","format":"utf8","chunk":false,"sendError":true,"encoding":"none","allProps":false,"x":370,"y":300,"wires":[["3b92e94a2ba8faaf"]]},{"id":"3b92e94a2ba8faaf","type":"json","z":"c2904b2287b55c0f","name":"Parse JSON","property":"payload","action":"obj","pretty":false,"x":390,"y":360,"wires":[["b5e95db9bb174566"]]},{"id":"b5e95db9bb174566","type":"function","z":"c2904b2287b55c0f","name":"Copy & Replace `ui_group` IDs","func":"// Funktion zur Generierung von IDs für Dashboard 1.0 (kürzere Länge)\nfunction generateOldIdFormat() {\n return 'id_' + Math.random().toString(36).substr(2, 8);\n}\n\n// Funktion zur Generierung von IDs für Dashboard 2.0 (längere Länge)\nfunction generateNewIdFormat() {\n return Math.random().toString(36).substr(2, 16);\n}\n\nlet nodes = msg.payload;\nlet idMap = {}; // Karte, um alte und neue IDs zu speichern\n\n// 1. Bearbeite `ui_group` (Dashboard 1.0) und `ui-group` (Dashboard 2.0) Knoten direkt\nnodes.forEach((node) => {\n if (node.type === 'ui_group' || node.type === 'ui-group') {\n // Speichere die alte ID und generiere eine neue\n let oldId = node.id;\n node.id = (node.type === 'ui-group') ? generateNewIdFormat() : generateOldIdFormat();\n\n // Füge das Suffix \"(copy)\" zum Namen hinzu\n node.name += ' (copy)';\n\n // Speichere das Mapping der alten zur neuen ID\n idMap[oldId] = node.id;\n }\n});\n\n// 2. Aktualisiere alle Referenzen auf die neuen `ui_group`/`ui-group` IDs\nnodes.forEach((node) => {\n if (node.group && idMap[node.group]) {\n node.group = idMap[node.group]; // Ändere die Gruppen-ID\n }\n if (node.tab && idMap[node.tab]) {\n node.tab = idMap[node.tab]; // Ändere die Tab-ID, falls vorhanden\n }\n if (node.page && idMap[node.page]) {\n node.page = idMap[node.page]; // Ändere die Page-ID für Dashboard 2.0\n }\n});\n\n// 3. Aktualisiere `ui_group`/`ui-group` IDs in UI-Elementen für Dashboard 1.0 und 2.0\nnodes.forEach((node) => {\n if (node.type.startsWith('ui_') || node.type === 'ui-group') {\n if (node.group && idMap[node.group]) {\n node.group = idMap[node.group];\n }\n if (node.page && idMap[node.page]) {\n node.page = idMap[node.page];\n }\n }\n});\n\nmsg.payload = nodes;\nreturn msg;\n","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[{"var":"crypto","module":"crypto"}],"x":330,"y":420,"wires":[["e185e83cb5948efb"]]},{"id":"e185e83cb5948efb","type":"json","z":"c2904b2287b55c0f","name":"Stringify JSON","property":"payload","action":"str","pretty":true,"x":380,"y":480,"wires":[["b28272fd7a9723c1"]]},{"id":"b28272fd7a9723c1","type":"file","z":"c2904b2287b55c0f","name":"Write New Flow JSON","filename":"/home/pi/flowConverter/tasmotaSwitch-converted.json","filenameType":"str","appendNewline":false,"createDir":true,"overwriteFile":"true","encoding":"none","x":360,"y":540,"wires":[["36b7373ed97c132f"]]},{"id":"71a357138703c1c4","type":"inject","z":"c2904b2287b55c0f","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":340,"y":240,"wires":[["dec2a28eafe202f9"]]},{"id":"36b7373ed97c132f","type":"debug","z":"c2904b2287b55c0f","name":"debug 2493","active":true,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":630,"y":560,"wires":[]}]
This worked great! It did miss associating the “ui-” nodes (DB2) with the group but I was able to do that manually. Should the 3rd loop in the function node be if (node.type.startsWith('ui_') || node.type.startsWith('ui-'))
instead of if (node.type.startsWith('ui_') || node.type === 'ui-group')
? Also, if there I wanted to create a new ui-page for the groups etc. to be associated with would that be possible? For now, what I did was create a new ui-page and then change each group to be associated with that page..
Thanks - this is a great little tool!
You are absolutely right about the issue and your suggested fix. My original condition in the third loop, if (node.type.startsWith('ui_') || node.type === 'ui-group')
, was too specific. It correctly processed the ui-group
node itself but missed all other Dashboard 2.0 nodes (like ui-button
, ui-text
, etc.) that needed their group association updated.
Your suggestion to change it to if (node.type.startsWith('ui_') || node.type.startsWith('ui-'))
is spot on and fixes the bug perfectly.
Regarding your second question about creating a new ui-page
: that's an excellent idea for an improvement! It's definitely possible. The logic would need to be extended to:
-
First, find and duplicate any
ui-page
nodes, giving them new IDs and names. -
Then, when the
ui-group
nodes are duplicated, they can be assigned to these new pages.
I ll take a look at that project and try to improve / rework it.
@WhiteLion - I got this working with some help from Gemini AI/ChatGPT. It updates the ui-group IDs on the other widgets and also fixes ui-page. I have tried this on 2 flows and it has worked like a charm. Here is the updated function node. It handles ui-pages for DB2 but I wasn’t sure that the structure for DB1 was (and I didn’t need it), so that is not included.
[{"id":"1435b5cf30fa88f7","type":"function","z":"eb38bf467edfbe13","name":"Copy & Replace `ui_group` IDs and Page ID","func":"// Function for generating IDs for Dashboard 1.0 (shorter length)\nfunction generateOldIdFormat() {\n return 'id_' + Math.random().toString(36).substr(2, 8);\n}\n\n// Function for generating IDs for Dashboard 2.0 (longer length)\nfunction generateNewIdFormat() {\n return Math.random().toString(36).substr(2, 16);\n}\n\nlet nodes = msg.payload;\nlet idMap = {}; // Map to store old and new IDs\n\n// 1. Duplicate `ui-page` nodes\nnodes.forEach((node) => {\n if (node.type === 'ui-page') {\n let oldId = node.id;\n node.id = generateNewIdFormat(); // Generate a new ID for the page\n node.name += ' (copy)'; // Add (copy) to the page name\n if (node.path) {\n node.path += '_copy'; // ➡️ Add a suffix to the path\n }\n idMap[oldId] = node.id; // Map the old ID to the new one\n }\n});\n\n// 2. Duplicate `ui_group` (Dashboard 1.0) and `ui-group` (Dashboard 2.0) nodes\nnodes.forEach((node) => {\n if (node.type === 'ui_group' || node.type === 'ui-group') {\n let oldId = node.id;\n node.id = (node.type === 'ui-group') ? generateNewIdFormat() : generateOldIdFormat();\n node.name += ' (copy)';\n idMap[oldId] = node.id;\n }\n});\n\n// 3. Update all references to the new `ui-page` and `ui-group` IDs\nnodes.forEach((node) => {\n if (node.page && idMap[node.page]) {\n node.page = idMap[node.page]; // Change the page ID\n }\n if (node.group && idMap[node.group]) {\n node.group = idMap[node.group]; // Change the group ID\n }\n // Also update tab references, though less common in modern dashboards\n if (node.tab && idMap[node.tab]) {\n node.tab = idMap[node.tab];\n }\n});\n\nmsg.payload = nodes;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":865.3333435058594,"y":86.66665649414062,"wires":[["e185e83cb5948efb"]]}]
Thanks for this great utility - I hope others can use it as well.
@WhiteLion - I’ve updated the flow so that it can now process multiple source files. The folder structure that I used was
Files start in “Unprocessed”. After processing, the converted files are in “Converted” and the original files are moved to “Processed”. The setup is done in a change node:
Here is the updated flow (it uses the node-red-contrib-fs-ops node for directory operations). I have tested this on a Mac and it does appear to work.
[{"id":"182cccfc9c426c18","type":"inject","z":"eb38bf467edfbe13","name":"","props":[],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","x":131.66665649414062,"y":963,"wires":[["694aca2344b84760"]]},{"id":"694aca2344b84760","type":"change","z":"eb38bf467edfbe13","name":"Set Path Names","rules":[{"t":"set","p":"sourcePath","pt":"msg","to":"/Users/<YOUR_USER_NAME>/Downloads/ConvertFlows/Unprocessed/","tot":"str"},{"t":"set","p":"processedPath","pt":"msg","to":"/Users/<YOUR_USER_NAME>/Downloads/ConvertFlows/Processed/","tot":"str"},{"t":"set","p":"convertedPath","pt":"msg","to":"/Users/<YOUR_USER_NAME>/Downloads/ConvertFlows/Converted/","tot":"str"},{"t":"set","p":"convertedSuffix","pt":"msg","to":"_converted","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":296.6666564941406,"y":963,"wires":[["bb67a4a7d75aa207"]]},{"id":"bb67a4a7d75aa207","type":"fs-ops-dir","z":"eb38bf467edfbe13","name":"Get File List","path":"sourcePath","pathType":"msg","filter":".json","filterType":"str","dir":"files","dirType":"msg","x":483.6665954589844,"y":963.0000610351562,"wires":[["7c570e5434036325"]]},{"id":"7c570e5434036325","type":"function","z":"eb38bf467edfbe13","name":"Setup file names and paths","func":"// Get list of files\nvar fileNames = msg.files;\n\n// Get the number of files\n// Set len to 0 if file list is empty\nvar len = fileNames?.length ?? 0;\n\n// Check if there are no files\nif (len === 0) {\n // Return null to stop the flow if there are no files\n return null;\n} else {\n // Loop through each file in the array\n for (let i = 0; i < len; i++) {\n // Create a new message object for this iteration \n var newMsg = {\n ...msg, // Copies all properties from the original msg object\n };\n\n // Get the current file name from the array\n var sourceFile = fileNames[i];\n\n // Construct the full path for the original file\n var originalFileName = newMsg.sourcePath + sourceFile;\n\n // Split the file name to get the name and extension\n // Use a nullish coalescing operator to handle cases where split returns an empty array\n var fileNameParts = sourceFile.split('.') ?? [];\n\n // Check if there are at least two parts (name and extension)\n var targetFile = ''\n \n if (fileNameParts.length > 1) {\n // Construct the new file name with the suffix\n targetFile = fileNameParts[0] + newMsg.convertedSuffix + \".\" + fileNameParts[1];\n } else {\n // Handle files with no extension by just adding the suffix\n targetFile = fileNameParts[0] + newMsg.convertedSuffix;\n }\n\n // Construct the full path for the converted file\n var convertedFileName = newMsg.convertedPath + targetFile;\n\n // Update the message properties for this specific file\n newMsg.originalFileName = originalFileName;\n newMsg.convertedFileName = convertedFileName;\n newMsg.sourceFile = sourceFile;\n newMsg.targetFile = targetFile;\n newMsg.fileNameParts = fileNameParts;\n\n // Send the new message for this file\n // The loop continues to the next file\n node.send(newMsg);\n }\n}\n\n// Return null to prevent the original message from being sent again.\nreturn null;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":711.6666412353516,"y":963.6666641235352,"wires":[["0b331da803d44492"]]},{"id":"0b331da803d44492","type":"file in","z":"eb38bf467edfbe13","name":"Read Flow JSON","filename":"originalFileName","filenameType":"msg","format":"utf8","chunk":false,"sendError":true,"encoding":"none","allProps":false,"x":190.99996948242188,"y":1042.6666641235352,"wires":[["e5aff01ee835279e"]]},{"id":"e5aff01ee835279e","type":"json","z":"eb38bf467edfbe13","name":"Parse JSON","property":"payload","action":"obj","pretty":false,"x":409,"y":1040.6666641235352,"wires":[["69eaa151ef4f34af"]]},{"id":"69eaa151ef4f34af","type":"function","z":"eb38bf467edfbe13","name":"Copy & Replace `ui_group` IDs and Page ID","func":"// Function for generating IDs for Dashboard 1.0 (shorter length)\nfunction generateOldIdFormat() {\n return 'id_' + Math.random().toString(36).substr(2, 8);\n}\n\n// Function for generating IDs for Dashboard 2.0 (longer length)\nfunction generateNewIdFormat() {\n return Math.random().toString(36).substr(2, 16);\n}\n\nlet nodes = msg.payload;\nlet idMap = {}; // Map to store old and new IDs\n\n// 1. Duplicate `ui-page` nodes\nnodes.forEach((node) => {\n if (node.type === 'ui-page') {\n let oldId = node.id;\n node.id = generateNewIdFormat(); // Generate a new ID for the page\n node.name += ' (copy)'; // Add (copy) to the page name\n if (node.path) {\n node.path += '_copy'; // ➡️ Add a suffix to the path\n }\n idMap[oldId] = node.id; // Map the old ID to the new one\n }\n});\n\n// 2. Duplicate `ui_group` (Dashboard 1.0) and `ui-group` (Dashboard 2.0) nodes\nnodes.forEach((node) => {\n if (node.type === 'ui_group' || node.type === 'ui-group') {\n let oldId = node.id;\n node.id = (node.type === 'ui-group') ? generateNewIdFormat() : generateOldIdFormat();\n node.name += ' (copy)';\n idMap[oldId] = node.id;\n }\n});\n\n// 3. Update all references to the new `ui-page` and `ui-group` IDs\nnodes.forEach((node) => {\n if (node.page && idMap[node.page]) {\n node.page = idMap[node.page]; // Change the page ID\n }\n if (node.group && idMap[node.group]) {\n node.group = idMap[node.group]; // Change the group ID\n }\n // Also update tab references, though less common in modern dashboards\n if (node.tab && idMap[node.tab]) {\n node.tab = idMap[node.tab];\n }\n});\n\nmsg.payload = nodes;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":692.6666564941406,"y":1039.333351135254,"wires":[["1de299744667831f"]]},{"id":"1de299744667831f","type":"json","z":"eb38bf467edfbe13","name":"Stringify JSON","property":"payload","action":"str","pretty":true,"x":204.00006103515625,"y":1129.6666641235352,"wires":[["cd466af3234c729d"]]},{"id":"cd466af3234c729d","type":"file","z":"eb38bf467edfbe13","name":"Write New Flow JSON","filename":"convertedFileName","filenameType":"msg","appendNewline":false,"createDir":true,"overwriteFile":"true","encoding":"none","x":430.6671676635742,"y":1128.6666641235352,"wires":[["a829ae8c8baf534a"]]},{"id":"a829ae8c8baf534a","type":"fs-ops-move","z":"eb38bf467edfbe13","name":"Move Processed File","sourcePath":"sourcePath","sourcePathType":"msg","sourceFilename":"sourceFile","sourceFilenameType":"msg","destPath":"processedPath","destPathType":"msg","destFilename":"sourceFile","destFilenameType":"msg","link":false,"x":688.6665954589844,"y":1127.6666641235352,"wires":[[]]},{"id":"d9a4b6ad93a5217f","type":"global-config","env":[],"modules":{"node-red-contrib-fs-ops":"1.6.0"}}]