/** * Copyright JS Foundation and other contributors, http://js.foundation * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. **/ /** * An Interface to nodes and utility functions for creating/adding/deleting nodes and links * @namespace RED.nodes */ RED.nodes = (function() { var PORT_TYPE_INPUT = 1; var PORT_TYPE_OUTPUT = 0; var node_defs = {}; var linkTabMap = {}; var configNodes = {}; var links = []; var nodeLinks = {}; var defaultWorkspace; var workspaces = {}; var workspacesOrder =[]; var subflows = {}; var loadedFlowVersion = null; var groups = {}; var groupsByZ = {}; var junctions = {}; var junctionsByZ = {}; var initialLoad; var dirty = false; function setDirty(d) { dirty = d; if (!d) { allNodes.clearState() } RED.events.emit("workspace:dirty",{dirty:dirty}); } // The registry holds information about all node types. var registry = (function() { var moduleList = {}; var nodeList = []; var nodeSets = {}; var typeToId = {}; var nodeDefinitions = {}; var iconSets = {}; nodeDefinitions['tab'] = { defaults: { label: {value:""}, disabled: {value: false}, locked: {value: false}, info: {value: ""}, env: {value: []} } }; var exports = { setModulePendingUpdated: function(module,version) { moduleList[module].pending_version = version; RED.events.emit("registry:module-updated",{module:module,version:version}); }, getModule: function(module) { return moduleList[module]; }, getNodeSetForType: function(nodeType) { return exports.getNodeSet(typeToId[nodeType]); }, getModuleList: function() { return moduleList; }, getNodeList: function() { return nodeList; }, getNodeTypes: function() { return Object.keys(nodeDefinitions); }, /** * Get an array of node definitions * @param {Object} options - options object * @param {boolean} [options.configOnly] - if true, only return config nodes * @param {function} [options.filter] - a filter function to apply to the list of nodes * @returns array of node definitions */ getNodeDefinitions: function(options) { const result = [] const configOnly = (options && options.configOnly) const filter = (options && options.filter) const keys = Object.keys(nodeDefinitions) for (const key of keys) { const def = nodeDefinitions[key] if(!def) { continue } if (configOnly && def.category !== "config") { continue } if (filter && !filter(nodeDefinitions[key])) { continue } result.push(nodeDefinitions[key]) } return result }, setNodeList: function(list) { nodeList = []; for(var i=0;i node var nodes = {}; // Map tab.id -> Array of nodes on that tab var tabMap = {}; // Map tab.id -> Set of dirty object ids on that tab var tabDirtyMap = {}; // Map tab.id -> Set of object ids of things deleted from the tab that weren't otherwise dirty var tabDeletedNodesMap = {}; // Set of object ids of things added to a tab after initial import var addedDirtyObjects = new Set() function changeCollectionDepth(tabNodes, toMove, direction, singleStep) { const result = [] const moved = new Set(); const startIndex = direction ? tabNodes.length - 1 : 0 const endIndex = direction ? -1 : tabNodes.length const step = direction ? -1 : 1 let target = startIndex // Only used for all-the-way moves for (let i = startIndex; i != endIndex; i += step) { if (toMove.size === 0) { break; } const n = tabNodes[i] if (toMove.has(n)) { if (singleStep) { if (i !== startIndex && !moved.has(tabNodes[i - step])) { tabNodes.splice(i, 1) tabNodes.splice(i - step, 0, n) n._reordered = true result.push(n) } } else { if (i !== target) { tabNodes.splice(i, 1) tabNodes.splice(target, 0, n) n._reordered = true result.push(n) } target += step } toMove.delete(n); moved.add(n); } } return result } var api = { addTab: function(id) { tabMap[id] = []; tabDirtyMap[id] = new Set(); tabDeletedNodesMap[id] = new Set(); }, hasTab: function(z) { return tabMap.hasOwnProperty(z) }, removeTab: function(id) { delete tabMap[id]; delete tabDirtyMap[id]; delete tabDeletedNodesMap[id]; }, addNode: function(n) { nodes[n.id] = n; if (tabMap.hasOwnProperty(n.z)) { tabMap[n.z].push(n); api.addObjectToWorkspace(n.z, n.id, n.changed || n.moved) } else { console.warn("Node added to unknown tab/subflow:",n); tabMap["_"] = tabMap["_"] || []; tabMap["_"].push(n); } }, removeNode: function(n) { delete nodes[n.id] if (tabMap.hasOwnProperty(n.z)) { var i = tabMap[n.z].indexOf(n); if (i > -1) { tabMap[n.z].splice(i,1); } api.removeObjectFromWorkspace(n.z, n.id) } }, /** * Add an object to our dirty/clean tracking state * @param {String} z * @param {String} id * @param {Boolean} isDirty */ addObjectToWorkspace: function (z, id, isDirty) { if (isDirty) { addedDirtyObjects.add(id) } if (tabDeletedNodesMap[z].has(id)) { tabDeletedNodesMap[z].delete(id) } api.markNodeDirty(z, id, isDirty) }, /** * Remove an object from our dirty/clean tracking state * @param {String} z * @param {String} id */ removeObjectFromWorkspace: function (z, id) { if (!addedDirtyObjects.has(id)) { tabDeletedNodesMap[z].add(id) } else { addedDirtyObjects.delete(id) } api.markNodeDirty(z, id, false) }, hasNode: function(id) { return nodes.hasOwnProperty(id); }, getNode: function(id) { return nodes[id] }, moveNode: function(n, newZ) { api.removeNode(n); n.z = newZ; api.addNode(n) }, /** * @param {array} nodes * @param {boolean} direction true:forwards false:back * @param {boolean} singleStep true:single-step false:all-the-way */ changeDepth: function(nodes, direction, singleStep) { if (!Array.isArray(nodes)) { nodes = [nodes] } let result = [] const tabNodes = tabMap[nodes[0].z]; const toMove = new Set(nodes.filter(function(n) { return n.type !== "group" && n.type !== "subflow" })); if (toMove.size > 0) { result = result.concat(changeCollectionDepth(tabNodes, toMove, direction, singleStep)) if (result.length > 0) { RED.events.emit('nodes:reorder',{ z: nodes[0].z, nodes: result }); } } const groupNodes = groupsByZ[nodes[0].z] || [] const groupsToMove = new Set(nodes.filter(function(n) { return n.type === 'group'})) if (groupsToMove.size > 0) { const groupResult = changeCollectionDepth(groupNodes, groupsToMove, direction, singleStep) if (groupResult.length > 0) { result = result.concat(groupResult) RED.events.emit('groups:reorder',{ z: nodes[0].z, nodes: groupResult }); } } RED.view.redraw(true) return result }, moveNodesForwards: function(nodes) { return api.changeDepth(nodes, true, true) }, moveNodesBackwards: function(nodes) { return api.changeDepth(nodes, false, true) }, moveNodesToFront: function(nodes) { return api.changeDepth(nodes, true, false) }, moveNodesToBack: function(nodes) { return api.changeDepth(nodes, false, false) }, getNodes: function(z) { return tabMap[z]; }, clear: function() { nodes = {}; tabMap = {}; tabDirtyMap = {}; tabDeletedNodesMap = {}; addedDirtyObjects = new Set(); }, /** * Clear all internal state on what is dirty. */ clearState: function () { // Called when a deploy happens, we can forget about added/remove // items as they have now been deployed. addedDirtyObjects = new Set() const flowsToCheck = new Set() for (const [z, set] of Object.entries(tabDeletedNodesMap)) { if (set.size > 0) { set.clear() flowsToCheck.add(z) } } for (const [z, set] of Object.entries(tabDirtyMap)) { if (set.size > 0) { set.clear() flowsToCheck.add(z) } } for (const z of flowsToCheck) { api.checkTabState(z) } }, eachNode: function(cb) { var nodeList,i,j; for (i in subflows) { if (subflows.hasOwnProperty(i)) { nodeList = tabMap[i]; for (j = 0; j < nodeList.length; j++) { if (cb(nodeList[j]) === false) { return; } } } } for (i = 0; i < workspacesOrder.length; i++) { nodeList = tabMap[workspacesOrder[i]]; for (j = 0; j < nodeList.length; j++) { if (cb(nodeList[j]) === false) { return; } } } // Flow nodes that do not have a valid tab/subflow if (tabMap["_"]) { nodeList = tabMap["_"]; for (j = 0; j < nodeList.length; j++) { if (cb(nodeList[j]) === false) { return; } } } }, filterNodes: function(filter) { var result = []; var searchSet = null; var doZFilter = false; if (filter.hasOwnProperty("z")) { if (tabMap.hasOwnProperty(filter.z)) { searchSet = tabMap[filter.z]; } else { doZFilter = true; } } var objectLookup = false; if (searchSet === null) { searchSet = Object.keys(nodes); objectLookup = true; } for (var n=0;n n.id) }, setNodeOrder: function(z, order) { var orderMap = {}; order.forEach(function(id,i) { orderMap[id] = i; }) tabMap[z].sort(function(A,B) { A._reordered = true; B._reordered = true; return orderMap[A.id] - orderMap[B.id]; }) if (groupsByZ[z]) { groupsByZ[z].sort(function(A,B) { return orderMap[A.id] - orderMap[B.id]; }) } }, /** * Update our records if an object is dirty or not * @param {String} z tab id * @param {String} id object id * @param {Boolean} dirty whether the object is dirty or not */ markNodeDirty: function(z, id, dirty) { if (tabDirtyMap[z]) { if (dirty) { tabDirtyMap[z].add(id) } else { tabDirtyMap[z].delete(id) } api.checkTabState(z) } }, /** * Check if a tab should update its contentsChange flag * @param {String} z tab id */ checkTabState: function (z) { const ws = workspaces[z] || subflows[z] if (ws) { const contentsChanged = tabDirtyMap[z].size > 0 || tabDeletedNodesMap[z].size > 0 if (Boolean(ws.contentsChanged) !== contentsChanged) { ws.contentsChanged = contentsChanged if (ws.type === 'tab') { RED.events.emit("flows:change", ws); } else { RED.events.emit("subflows:change", ws); } } } } } return api; })() /** * Generates a random ID consisting of 8 bytes. * * @returns {string} The generated ID. */ function generateId() { var bytes = []; for (var i=0;i<8;i++) { bytes.push(Math.round(0xff*Math.random()).toString(16).padStart(2,'0')); } return bytes.join(""); } function parseNodePropertyTypeString(typeString) { typeString = typeString.trim(); var c; var pos = 0; var isArray = /\[\]$/.test(typeString); if (isArray) { typeString = typeString.substring(0,typeString.length-2); } var l = typeString.length; var inBrackets = false; var inToken = false; var currentToken = ""; var types = []; while (pos < l) { c = typeString[pos]; if (inToken) { if (c === "|") { types.push(currentToken.trim()) currentToken = ""; inToken = false; } else if (c === ")") { types.push(currentToken.trim()) currentToken = ""; inBrackets = false; inToken = false; } else { currentToken += c; } } else { if (c === "(") { if (inBrackets) { throw new Error("Invalid character '"+c+"' at position "+pos) } inBrackets = true; } else if (c !== " ") { inToken = true; currentToken = c; } } pos++; } currentToken = currentToken.trim(); if (currentToken.length > 0) { types.push(currentToken) } return { types: types, array: isArray } } const nodeProxyHandler = { get(node, prop) { if (prop === '__isProxy__') { return true } else if (prop == '__node__') { return node } return node[prop] }, set(node, prop, value) { if (node.z && (RED.nodes.workspace(node.z)?.locked || RED.nodes.subflow(node.z)?.locked)) { if ( node._def.defaults[prop] || prop === 'z' || prop === 'l' || prop === 'd' || (prop === 'changed' && (!!node.changed) !== (!!value)) || // jshint ignore:line ((prop === 'x' || prop === 'y') && !node.resize && node.type !== 'group') ) { throw new Error(`Cannot modified property '${prop}' of locked object '${node.type}:${node.id}'`) } } if (node.z && (prop === 'changed' || prop === 'moved')) { setTimeout(() => { allNodes.markNodeDirty(node.z, node.id, node.changed || node.moved) }, 0) } node[prop] = value; return true } } function addNode(n) { let newNode if (!n.__isProxy__) { newNode = new Proxy(n, nodeProxyHandler) } else { newNode = n } if (n.type.indexOf("subflow") !== 0) { n["_"] = n._def._; } else { var subflowId = n.type.substring(8); var sf = RED.nodes.subflow(subflowId); if (sf) { sf.instances.push(newNode); } n["_"] = RED._; } if (n._def.category == "config") { configNodes[n.id] = n; } else { if (n.wires && (n.wires.length > n.outputs)) { n.outputs = n.wires.length; } n.dirty = true; // TODO: The event should be triggered? updateConfigNodeUsers(newNode, { action: "add" }); // TODO: What is this property used for? if (n._def.category == "subflows" && typeof n.i === "undefined") { var nextId = 0; RED.nodes.eachNode(function(node) { nextId = Math.max(nextId,node.i||0); }); n.i = nextId+1; } allNodes.addNode(newNode); if (!nodeLinks[n.id]) { nodeLinks[n.id] = {in:[],out:[]}; } } RED.events.emit('nodes:add',newNode); return newNode } function addLink(l) { if (nodeLinks[l.source.id]) { const isUnique = nodeLinks[l.source.id].out.every(function(link) { return link.sourcePort !== l.sourcePort || link.target.id !== l.target.id }) if (!isUnique) { return } } links.push(l); if (l.source) { // Possible the node hasn't been added yet if (!nodeLinks[l.source.id]) { nodeLinks[l.source.id] = {in:[],out:[]}; } nodeLinks[l.source.id].out.push(l); } if (l.target) { if (!nodeLinks[l.target.id]) { nodeLinks[l.target.id] = {in:[],out:[]}; } nodeLinks[l.target.id].in.push(l); } if (l.source.z === l.target.z && linkTabMap[l.source.z]) { linkTabMap[l.source.z].push(l); allNodes.addObjectToWorkspace(l.source.z, getLinkId(l), true) } RED.events.emit("links:add",l); } function getLinkId(link) { return link.source.id + ':' + link.sourcePort + ':' + link.target.id } function getNode(id) { if (id in configNodes) { return configNodes[id]; } return allNodes.getNode(id); } function removeNode(id) { var removedLinks = []; var removedNodes = []; var node; if (id in configNodes) { node = configNodes[id]; delete configNodes[id]; RED.events.emit('nodes:remove',node); RED.workspaces.refresh(); } else if (allNodes.hasNode(id)) { node = allNodes.getNode(id); allNodes.removeNode(node); delete nodeLinks[id]; removedLinks = links.filter(function(l) { return (l.source === node) || (l.target === node); }); removedLinks.forEach(removeLink); // TODO: The event should not be triggered? updateConfigNodeUsers(node, { action: "remove" }); var updatedConfigNode = false; for (var d in node._def.defaults) { if (node._def.defaults.hasOwnProperty(d)) { var property = node._def.defaults[d]; if (property.type) { var type = registry.getNodeType(property.type); if (type && type.category == "config") { var configNode = configNodes[node[d]]; if (configNode) { updatedConfigNode = true; // TODO: Not sure if still exists if (configNode._def.exclusive) { removeNode(node[d]); removedNodes.push(configNode); } } } } } } if (node.type.indexOf("subflow:") === 0) { var subflowId = node.type.substring(8); var sf = RED.nodes.subflow(subflowId); if (sf) { sf.instances.splice(sf.instances.indexOf(node),1); } } if (updatedConfigNode) { RED.workspaces.refresh(); } try { if (node._def.oneditdelete) { node._def.oneditdelete.call(node); } } catch(err) { console.log("oneditdelete",node.id,node.type,err.toString()); } RED.events.emit('nodes:remove',node); } if (node && node._def.onremove) { // Deprecated: never documented but used by some early nodes console.log("Deprecated API warning: node type ",node.type," has an onremove function - should be oneditdelete - please report"); node._def.onremove.call(node); } return {links:removedLinks,nodes:removedNodes}; } function moveNodesForwards(nodes) { return allNodes.moveNodesForwards(nodes); } function moveNodesBackwards(nodes) { return allNodes.moveNodesBackwards(nodes); } function moveNodesToFront(nodes) { return allNodes.moveNodesToFront(nodes); } function moveNodesToBack(nodes) { return allNodes.moveNodesToBack(nodes); } function getNodeOrder(z) { return allNodes.getNodeOrder(z); } function setNodeOrder(z, order) { allNodes.setNodeOrder(z,order); } function moveNodeToTab(node, z) { if (node.type === "group") { moveGroupToTab(node,z); return; } if (node.type === "junction") { moveJunctionToTab(node,z); return; } var oldZ = node.z; allNodes.moveNode(node,z); var nl = nodeLinks[node.id]; if (nl) { nl.in.forEach(function(l) { var idx = linkTabMap[oldZ].indexOf(l); if (idx != -1) { linkTabMap[oldZ].splice(idx, 1); } if ((l.source.z === z) && linkTabMap[z]) { linkTabMap[z].push(l); } }); nl.out.forEach(function(l) { var idx = linkTabMap[oldZ].indexOf(l); if (idx != -1) { linkTabMap[oldZ].splice(idx, 1); } if ((l.target.z === z) && linkTabMap[z]) { linkTabMap[z].push(l); } }); } RED.events.emit("nodes:change",node); } function moveGroupToTab(group, z) { var index = groupsByZ[group.z].indexOf(group); groupsByZ[group.z].splice(index,1); groupsByZ[z] = groupsByZ[z] || []; groupsByZ[z].push(group); group.z = z; RED.events.emit("groups:change",group); } function moveJunctionToTab(junction, z) { var index = junctionsByZ[junction.z].indexOf(junction); junctionsByZ[junction.z].splice(index,1); junctionsByZ[z] = junctionsByZ[z] || []; junctionsByZ[z].push(junction); var oldZ = junction.z; junction.z = z; var nl = nodeLinks[junction.id]; if (nl) { nl.in.forEach(function(l) { var idx = linkTabMap[oldZ].indexOf(l); if (idx != -1) { linkTabMap[oldZ].splice(idx, 1); } if ((l.source.z === z) && linkTabMap[z]) { linkTabMap[z].push(l); } }); nl.out.forEach(function(l) { var idx = linkTabMap[oldZ].indexOf(l); if (idx != -1) { linkTabMap[oldZ].splice(idx, 1); } if ((l.target.z === z) && linkTabMap[z]) { linkTabMap[z].push(l); } }); } RED.events.emit("junctions:change",junction); } function removeLink(l) { var index = links.indexOf(l); if (index != -1) { links.splice(index,1); if (l.source && nodeLinks[l.source.id]) { var sIndex = nodeLinks[l.source.id].out.indexOf(l) if (sIndex !== -1) { nodeLinks[l.source.id].out.splice(sIndex,1) } } if (l.target && nodeLinks[l.target.id]) { var tIndex = nodeLinks[l.target.id].in.indexOf(l) if (tIndex !== -1) { nodeLinks[l.target.id].in.splice(tIndex,1) } } if (l.source.z === l.target.z && linkTabMap[l.source.z]) { index = linkTabMap[l.source.z].indexOf(l); if (index !== -1) { linkTabMap[l.source.z].splice(index,1) } allNodes.removeObjectFromWorkspace(l.source.z, getLinkId(l)) } } RED.events.emit("links:remove",l); } function addWorkspace(ws,targetIndex) { workspaces[ws.id] = ws; allNodes.addTab(ws.id); linkTabMap[ws.id] = []; ws._def = RED.nodes.getType('tab'); if (targetIndex === undefined) { workspacesOrder.push(ws.id); } else { workspacesOrder.splice(targetIndex,0,ws.id); } RED.events.emit('flows:add',ws); if (targetIndex !== undefined) { RED.events.emit('flows:reorder',workspacesOrder) } } function getWorkspace(id) { return workspaces[id]; } function removeWorkspace(id) { var ws = workspaces[id]; var removedNodes = []; var removedLinks = []; var removedGroups = []; var removedJunctions = []; if (ws) { delete workspaces[id]; delete linkTabMap[id]; workspacesOrder.splice(workspacesOrder.indexOf(id),1); var i; var node; if (allNodes.hasTab(id)) { removedNodes = allNodes.getNodes(id).slice() } for (i in configNodes) { if (configNodes.hasOwnProperty(i)) { node = configNodes[i]; if (node.z == id) { removedNodes.push(node); } } } removedJunctions = RED.nodes.junctions(id) for (i=0;i=0; i--) { removeGroup(removedGroups[i]); } allNodes.removeTab(id); RED.events.emit('flows:remove',ws); } return {nodes:removedNodes,links:removedLinks, groups: removedGroups, junctions: removedJunctions}; } /** * Add a Subflow to the Workspace * * @param {object} sf The Subflow to add. * @param {boolean|undefined} createNewIds Whether to create a new ID and update the name. */ function addSubflow(sf, createNewIds) { if (createNewIds) { // Update the Subflow Id sf.id = generateId(); // Update the Subflow name to highlight that this is a copy const subflowNames = Object.keys(subflows).map(function (sfid) { return subflows[sfid].name || ""; }).sort(); let copyNumber = 1; let subflowName = sf.name; subflowNames.forEach(function(name) { if (subflowName == name) { subflowName = sf.name + " (" + copyNumber + ")"; copyNumber++; } }); sf.name = subflowName; } sf.instances = []; subflows[sf.id] = sf; allNodes.addTab(sf.id); linkTabMap[sf.id] = []; RED.nodes.registerType("subflow:"+sf.id, { defaults:{ name:{value:""}, env:{value:[], validate: function(value) { const errors = [] if (value) { value.forEach(env => { const r = RED.utils.validateTypedProperty(env.value, env.type) if (r !== true) { errors.push(env.name+': '+r) } }) } if (errors.length === 0) { return true } else { return errors } }} }, icon: function() { return sf.icon||"subflow.svg" }, category: sf.category || "subflows", inputs: sf.in.length, outputs: sf.out.length, color: sf.color || "#DDAA99", label: function() { return this.name||RED.nodes.subflow(sf.id).name }, labelStyle: function() { return this.name?"red-ui-flow-node-label-italic":""; }, paletteLabel: function() { return RED.nodes.subflow(sf.id).name }, inputLabels: function(i) { return sf.inputLabels?sf.inputLabels[i]:null }, outputLabels: function(i) { return sf.outputLabels?sf.outputLabels[i]:null }, oneditprepare: function() { if (this.type !== 'subflow') { // A subflow instance node RED.subflow.buildEditForm("subflow",this); } else { // A subflow template node RED.subflow.buildEditForm("subflow-template", this); } }, oneditresize: function(size) { if (this.type === 'subflow') { $("#node-input-env-container").editableList('height',size.height - 80); } }, set:{ module: "node-red" } }); sf._def = RED.nodes.getType("subflow:"+sf.id); RED.events.emit("subflows:add",sf); } function getSubflow(id) { return subflows[id]; } function removeSubflow(sf) { if (subflows[sf.id]) { delete subflows[sf.id]; allNodes.removeTab(sf.id); registry.removeNodeType("subflow:"+sf.id); RED.events.emit("subflows:remove",sf); } } function subflowContains(sfid,nodeid) { var sfNodes = allNodes.getNodes(sfid); for (var i = 0; i l.target)) return Array.from(downstreamNodes) } function getAllDownstreamNodes(node) { return getAllFlowNodes(node,'down').filter(function(n) { return n !== node }); } function getAllUpstreamNodes(node) { return getAllFlowNodes(node,'up').filter(function(n) { return n !== node }); } function getAllFlowNodes(node, direction) { var selection = RED.view.selection(); var visited = new Set(); var nodes = [node]; var initialNode = true; while(nodes.length > 0) { var n = nodes.shift(); visited.add(n); var links = []; if (!initialNode || !direction || (initialNode && direction === 'up')) { links = links.concat(nodeLinks[n.id].in); } if (!initialNode || !direction || (initialNode && direction === 'down')) { links = links.concat(nodeLinks[n.id].out); } initialNode = false; links.forEach(function(l) { if (!visited.has(l.source)) { nodes.push(l.source); } if (!visited.has(l.target)) { nodes.push(l.target); } }) } return Array.from(visited); } function convertWorkspace(n,opts) { var exportCreds = true; if (opts) { if (opts.hasOwnProperty("credentials")) { exportCreds = opts.credentials; } } var node = {}; node.id = n.id; node.type = n.type; for (var d in n._def.defaults) { if (n._def.defaults.hasOwnProperty(d)) { if (d === 'locked' && !n.locked) { continue } node[d] = n[d]; } } if (exportCreds) { var credentialSet = {}; if (n.credentials) { for (var tabCred in n.credentials) { if (n.credentials.hasOwnProperty(tabCred)) { if (!n.credentials._ || n.credentials["has_"+tabCred] != n.credentials._["has_"+tabCred] || (n.credentials["has_"+tabCred] && n.credentials[tabCred])) { credentialSet[tabCred] = n.credentials[tabCred]; } } } if (Object.keys(credentialSet).length > 0) { node.credentials = credentialSet; } } } return node; } /** * Converts a node to an exportable JSON Object **/ function convertNode(n, opts) { var exportCreds = true; var exportDimensions = false; if (opts === false) { exportCreds = false; } else if (typeof opts === "object") { if (opts.hasOwnProperty("credentials")) { exportCreds = opts.credentials; } if (opts.hasOwnProperty("dimensions")) { exportDimensions = opts.dimensions; } } if (n.type === 'tab') { return convertWorkspace(n, { credentials: exportCreds }); } var node = {}; node.id = n.id; node.type = n.type; node.z = n.z; if (node.z === 0 || node.z === "") { delete node.z; } if (n.d === true) { node.d = true; } if (n.g) { node.g = n.g; } if (node.type == "unknown") { for (var p in n._orig) { if (n._orig.hasOwnProperty(p)) { node[p] = n._orig[p]; } } } else { for (var d in n._def.defaults) { if (n._def.defaults.hasOwnProperty(d)) { node[d] = n[d]; } } if (exportCreds) { var credentialSet = {}; if ((/^subflow:/.test(node.type) || (node.type === "group")) && n.credentials) { // A subflow instance/group node can have arbitrary creds for (var sfCred in n.credentials) { if (n.credentials.hasOwnProperty(sfCred)) { if (!n.credentials._ || n.credentials["has_"+sfCred] != n.credentials._["has_"+sfCred] || (n.credentials["has_"+sfCred] && n.credentials[sfCred])) { credentialSet[sfCred] = n.credentials[sfCred]; } } } } else if (n.credentials) { // All other nodes have a well-defined list of possible credentials for (var cred in n._def.credentials) { if (n._def.credentials.hasOwnProperty(cred)) { if (n._def.credentials[cred].type == 'password') { if (!n.credentials._ || n.credentials["has_"+cred] != n.credentials._["has_"+cred] || (n.credentials["has_"+cred] && n.credentials[cred])) { credentialSet[cred] = n.credentials[cred]; } } else if (n.credentials[cred] != null && (!n.credentials._ || n.credentials[cred] != n.credentials._[cred])) { credentialSet[cred] = n.credentials[cred]; } } } } if (Object.keys(credentialSet).length > 0) { node.credentials = credentialSet; } } } if (n.type === "group") { node.x = n.x; node.y = n.y; node.w = n.w; node.h = n.h; // In 1.1.0, we have seen an instance of this array containing `undefined` // Until we know how that can happen, add a filter here to remove them node.nodes = node.nodes.filter(function(n) { return !!n }).map(function(n) { return n.id }); } if (n.type === "tab" || n.type === "group") { if (node.env && node.env.length === 0) { delete node.env; } } if (n._def.category != "config" || n.type === 'junction') { node.x = n.x; node.y = n.y; if (exportDimensions) { if (!n.hasOwnProperty('w')) { // This node has not yet been drawn in the view. So we need // to explicitly calculate its dimensions. Store the result // on the node as if it had been drawn will save us doing // it again var dimensions = RED.view.calculateNodeDimensions(n); n.w = dimensions[0]; n.h = dimensions[1]; } node.w = n.w; node.h = n.h; } node.wires = []; for(var i=0;i 0 && n.inputLabels && !/^\s*$/.test(n.inputLabels.join(""))) { node.inputLabels = n.inputLabels.slice(); } if (n.outputs > 0 && n.outputLabels && !/^\s*$/.test(n.outputLabels.join(""))) { node.outputLabels = n.outputLabels.slice(); } if ((!n._def.defaults || !n._def.defaults.hasOwnProperty("icon")) && n.icon) { var defIcon = RED.utils.getDefaultNodeIcon(n._def, n); if (n.icon !== defIcon.module+"/"+defIcon.file) { node.icon = n.icon; } } if ((!n._def.defaults || !n._def.defaults.hasOwnProperty("l")) && n.hasOwnProperty('l')) { var showLabel = n._def.hasOwnProperty("showLabel")?n._def.showLabel:true; if (showLabel != n.l) { node.l = n.l; } } } if (n.info) { node.info = n.info; } return node; } function convertSubflow(n, opts) { var exportCreds = true; var exportDimensions = false; if (opts === false) { exportCreds = false; } else if (typeof opts === "object") { if (opts.hasOwnProperty("credentials")) { exportCreds = opts.credentials; } if (opts.hasOwnProperty("dimensions")) { exportDimensions = opts.dimensions; } } var node = {}; node.id = n.id; node.type = n.type; node.name = n.name; node.info = n.info; node.category = n.category; node.in = []; node.out = []; node.env = n.env; node.meta = n.meta; if (exportCreds) { var credentialSet = {}; // A subflow node can have arbitrary creds for (var sfCred in n.credentials) { if (n.credentials.hasOwnProperty(sfCred)) { if (!n.credentials._ || n.credentials["has_"+sfCred] != n.credentials._["has_"+sfCred] || (n.credentials["has_"+sfCred] && n.credentials[sfCred])) { credentialSet[sfCred] = n.credentials[sfCred]; } } } if (Object.keys(credentialSet).length > 0) { node.credentials = credentialSet; } } node.color = n.color; n.in.forEach(function(p) { var nIn = {x:p.x,y:p.y,wires:[]}; var wires = links.filter(function(d) { return d.source === p }); for (var i=0;i 0 && n.inputLabels && !/^\s*$/.test(n.inputLabels.join(""))) { node.inputLabels = n.inputLabels.slice(); } if (node.out.length > 0 && n.outputLabels && !/^\s*$/.test(n.outputLabels.join(""))) { node.outputLabels = n.outputLabels.slice(); } if (n.icon) { if (n.icon !== "node-red/subflow.svg") { node.icon = n.icon; } } if (n.status) { node.status = {x: n.status.x, y: n.status.y, wires:[]}; links.forEach(function(d) { if (d.target === n.status) { if (d.source.type != "subflow") { node.status.wires.push({id:d.source.id, port:d.sourcePort}) } else { node.status.wires.push({id:n.id, port:0}) } } }); } return node; } function createExportableSubflow(id) { var sf = getSubflow(id); var nodeSet; var sfNodes = allNodes.getNodes(sf.id); if (sfNodes) { nodeSet = sfNodes.slice(); nodeSet.unshift(sf); } else { nodeSet = [sf]; } return createExportableNodeSet(nodeSet); } /** * Converts the current node selection to an exportable JSON Object **/ function createExportableNodeSet(set, exportedIds, exportedSubflows, exportedConfigNodes) { var nns = []; exportedIds = exportedIds || {}; set = set.filter(function(n) { if (exportedIds[n.id]) { return false; } exportedIds[n.id] = true; return true; }) exportedConfigNodes = exportedConfigNodes || {}; exportedSubflows = exportedSubflows || {}; for (var n=0;n !!s); // Import the new subflow - no clashes should occur as we've removed // the old version var result = importNodes(subflowNodes); newSubflows[id] = getSubflow(id); }) // Having replaced the subflow definitions, now need to update the // instance nodes. RED.nodes.eachNode(function(n) { if (/^subflow:/.test(n.type)) { var sfId = n.type.substring(8); if (newSubflows[sfId]) { // This is an instance of one of the replaced subflows // - update the new def's instances array to include this one newSubflows[sfId].instances.push(n); // - update the instance's _def to point to the new def n._def = RED.nodes.getType(n.type); // - set all the flags so the view refreshes properly n.dirty = true; n.changed = true; n._colorChanged = true; } } }) newSubflowIds.forEach(function(id) { var n = newSubflows[id]; RED.events.emit("subflows:change",n); }) // Just in case the imported subflow changed color. RED.utils.clearNodeColorCache(); // ------------------------------ // Replace config nodes // configNodeIds.forEach(function(id) { const configNode = getNode(id); const currentUserCount = configNode.users; // Add a snapshot of the Config Node removedNodes = removedNodes.concat(convertNode(configNode)); // Remove the Config Node instance removeNode(id); // Import the new one importNodes([newConfigNodes[id]]); // Re-attributes the user count getNode(id).users = currentUserCount; }); return { removedNodes: removedNodes } } /** * Analyzes the array of nodes passed as an argument to find unknown node types. * * Options: * - `emitNotification` - Emit an notification with unknown types. Default false. * * @remarks Throws an error to be handled. * @param {Array} nodes An array of nodes to analyse * @param {{ emitNotification?: boolean; }} options An options object * @returns {Array} An array with unknown types */ function identifyUnknowType(nodes, options = {}) { const unknownTypes = []; for (let node of nodes) { // TODO: remove workspace in next release+1 const knowTypes = ["workspace", "tab", "subflow", "group", "junction"]; if (!knowTypes.includes(node.type) && node.type.substring(0, 8) != "subflow:" && !registry.getNodeType(node.type) && !unknownTypes.includes(node.type)) { unknownTypes.push(node.type); } } if (options.emitNotification && unknownTypes.length) { const typeList = $("
    "); unknownTypes.forEach(function(type) { $("
  • ").text(type).appendTo(typeList); }); RED.notify( "

    " + RED._("clipboard.importUnrecognised", {count: unknownTypes.length }) + "

    " + typeList[0].outerHTML, "error", false, 10000); } return unknownTypes; } /** * Warns the user that the import contains existing nodes that the user need to resolve. * * @remarks Throws an error to be handled. * @param {Array} existingNodes An array containing conflicting nodes. * @param {Array} importedNodes An array containing all imported nodes. */ function emitExistingNodesNotification(existingNodes, importedNodes) { const errorMessage = RED._("clipboard.importDuplicate", { count: existingNodes.length }); const maxItemCount = 5; // Max 5 items in the list const nodeList = $("
      "); let itemCount = 0; for (const { existing, imported } of existingNodes) { if (itemCount >= maxItemCount) { break; } const conflictType = (imported.type !== existing.type) ? " | " + imported.type : ""; $("
    • ").text(existing.id + " [ " + existing.type + conflictType + " ]").appendTo(nodeList); itemCount++; } if (existingNodes.length > maxItemCount) { $("
    • ").text(RED._("deploy.confirm.plusNMore", { count: (existingNodes.length - maxItemCount) })).appendTo(nodeList); } const wrapper = $("

      ").append(nodeList); const existingNodesError = new Error(errorMessage + wrapper.html()); existingNodesError.code = "import_conflict"; existingNodesError.importConfig = identifyImportConflicts(importedNodes); throw existingNodesError; } /** * Makes a copy of the Config Node received as a parameter. * * @remarks Don't change the ID. * @param {object} configNode The Config Node to copy. * @param {object} def The Config Node definition. * @param {object} options Same options as import * @returns The new Config Node copied. */ function copyConfigNode(configNode, def, options = {}) { const newNode = { id: configNode.id, z: configNode.z, type: configNode.type, changed: false, icon: configNode.icon, info: configNode.info, label: def.label, users: [], _config: {}, _def: def }; if (!configNode.z) { delete newNode.z; } if (options.markChanged) { newNode.changed = true; } if (configNode.hasOwnProperty("d")) { // Disabled newNode.d = configNode.d; } // Copy node user properties for (const d in def.defaults) { if (def.defaults.hasOwnProperty(d)) { newNode._config[d] = JSON.stringify(configNode[d]); newNode[d] = configNode[d]; } } // Copy credentials - ONLY if the node contains it to avoid erase it if (def.hasOwnProperty("credentials") && configNode.hasOwnProperty("credentials")) { newNode.credentials = {}; for (const c in def.credentials) { if (def.credentials.hasOwnProperty(c) && configNode.credentials.hasOwnProperty(c)) { newNode.credentials[c] = configNode.credentials[c]; } } } return newNode; } /** * Makes a copy of the Node received as a parameter. * * @remarks Don't change the ID. * @param {object} node The Node to copy. * @param {object} def The Node definition. * @param {object} options Same options as import * @returns The new Node copied. */ function copyNode(node, def, options = {}) { const newNode = { id: node.id, x: parseFloat(node.x || 0), y: parseFloat(node.y || 0), z: node.z, type: node.type, changed: false, info: node.info, _config: {}, _def: def }; if (node.type !== "group" && node.type !== "junction") { newNode.wires = node.wires || []; newNode.inputLabels = node.inputLabels; newNode.outputLabels = node.outputLabels; newNode.icon = node.icon; } if (node.hasOwnProperty("l")) { // Label (show/hide) newNode.l = node.l; } if (node.hasOwnProperty("d")) { // Disabled newNode.d = node.d; } if (node.hasOwnProperty("g")) { // Group newNode.g = node.g; } if (options.markChanged) { newNode.changed = true; } if (node.type === "group") { for (const d in newNode._def.defaults) { if (newNode._def.defaults.hasOwnProperty(d) && d !== "inputs" && d !== "outputs") { newNode[d] = node[d]; newNode._config[d] = JSON.stringify(node[d]); } } newNode._config.x = node.x; newNode._config.y = node.y; if (node.hasOwnProperty("w")) { // Weight newNode.w = node.w; } if (node.hasOwnProperty("h")) { // Height newNode.h = node.h; } } else if (node.type === "junction") { newNode._def = { defaults: {} }; newNode._config.x = node.x; newNode._config.y = node.y; newNode.wires = node.wires || []; newNode.inputs = 1; newNode.outputs = 1; newNode.w = 0; newNode.h = 0; } else if (node.type.substring(0, 7) === "subflow") { newNode.name = node.name; newNode.inputs = node.inputs ?? 0; newNode.outputs = node.outputs ?? 0; newNode.env = node.env; } else { newNode._config.x = node.x; newNode._config.y = node.y; if (node.hasOwnProperty("inputs") && def.defaults.hasOwnProperty("inputs")) { newNode._config.inputs = JSON.stringify(node.inputs); newNode.inputs = parseInt(node.inputs, 10); } else { newNode.inputs = def.inputs; } if (node.hasOwnProperty("outputs") && def.defaults.hasOwnProperty("outputs")) { newNode._config.outputs = JSON.stringify(node.outputs); newNode.outputs = parseInt(node.outputs, 10); } else { newNode.outputs = def.outputs; } // The node declares outputs in its defaults, but has not got a valid value // Defer to the length of the wires array if (isNaN(newNode.outputs)) { newNode.outputs = newNode.wires.length; } else if (newNode.wires.length > newNode.outputs) { // If 'wires' is longer than outputs, clip wires console.log("Warning: node.wires longer than node.outputs - trimming wires:", node.id, " wires:", node.wires.length, " outputs:", node.outputs); // TODO: Pas dans l'autre sens ? newNode.wires = newNode.wires.slice(0, newNode.outputs); } // Copy node user properties for (const d in def.defaults) { if (newNode._def.defaults.hasOwnProperty(d) && d !== "inputs" && d !== "outputs") { newNode._config[d] = JSON.stringify(node[d]); newNode[d] = node[d]; } } // Copy credentials - ONLY if the node contains it to avoid erase it if (def.hasOwnProperty("credentials") && node.hasOwnProperty("credentials")) { newNode.credentials = {}; for (const c in def.credentials) { if (newNode._def.credentials.hasOwnProperty(c) && node.credentials.hasOwnProperty(c)) { newNode.credentials[c] = node.credentials[c]; } } } } return newNode; } /** * Handles the import of nodes - these nodes can be copied, replaced or just imported. * * Options: * - `addFlow` - whether to import nodes to a new tab. Default false. * - `generateIds` - whether to replace all node ids. Default false. * - `markChanged` - whether to set `changed` = true on all newly imported nodes. * - `reimport` - if node has a `z` property, dont overwrite it * Only applicible when `generateIds` is false. Default false. * - `importMap` - how to resolve any conflicts. * - nodeId: `import` - import as-is * - nodeId: `copy` - import with new id * - nodeId: `replace` - repace the existing * * @remarks Only Subflow definition (tab) and Config Node are replaceable! * * @typedef {string} NodeId * @typedef {Record} ImportMap * * @param {string | object | Array} originalNodes A node or array of nodes to import * @param {{ addFlow?: boolean; generateIds?: boolean; importMap?: ImportMap; markChanged?: boolean; * reimport?: boolean; }} options Options to involve on import. * @returns An object containing all the elements created. */ function importNodes(originalNodes, options = {}) { const defaultOptions = { generateIds: false, addFlow: false, markChanged: false, reimport: false, importMap: {} }; options = Object.assign({}, defaultOptions, options); const createNewIds = options.generateIds; const createNewWorkspace = options.addFlow; const reimport = (!createNewIds && !!options.reimport); // Checks and converts nodes into an Array if necessary if (typeof originalNodes === "string") { if (originalNodes === "") { return; } try { originalNodes = JSON.parse(originalNodes); } catch(err) { const error = new Error(RED._("clipboard.invalidFlow", { message: err.message })); error.code = "NODE_RED"; throw error; } } if (!Array.isArray(originalNodes)) { originalNodes = [originalNodes]; } const seenIds = new Set(); const existingNodes = []; const nodesToReplace = []; // Checks if the imported nodes contains duplicates or existing nodes. originalNodes = originalNodes.filter(function (node) { const id = node.id; // This is a temporary fix to help resolve corrupted flows caused by 0.20.0 where multiple // copies of the flow would get loaded at the same time. // NOTE: Generally it is the last occurrence which is kept but not here. if (seenIds.has(id)) { return false; } seenIds.add(id); // Checks if the node already exists - the user will choose between copying the node, replacing it or not import it if (!createNewIds) { if (!options.importMap[id]) { // No conflict resolution for this node // TODO: Sure? const existingNode = allNodes.getNode(id) || configNodes[id] || workspaces[id] || subflows[id] || groups[id] || junctions[id]; if (existingNode) { existingNodes.push({ existing: existingNode, imported: node }); } } else if (options.importMap[id] === "replace") { nodesToReplace.push(node); return false; } } // Ensure ignored nodes are not imported if (options.importMap[id] === "skip") { return false; } return true; }); // If some nodes already exists, ask the user for each node to choose // between copying it, replacing it or not importing it. // NOTE: Stops the import here - throws an error. if (existingNodes.length) { emitExistingNodesNotification(existingNodes, originalNodes); } // NOTE: activeWorkspace can be equal to 0 if it's the initial load let activeWorkspace = RED.workspaces.active(); const activeSubflow = getSubflow(activeWorkspace); if (activeSubflow) { for (const node of originalNodes) { const group = /^subflow:(.+)$/.exec(node.type); if (group) { const subflowId = group[1]; let error; if (subflowId === activeSubflow.id) { error = new Error(RED._("notification.errors.cannotAddSubflowToItself")); } else if (subflowContains(subflowId, activeSubflow.id)) { error = new Error(RED._("notification.errors.cannotAddCircularReference")); } if (error) { // TODO: standardise error codes error.code = "NODE_RED"; throw error; } } } } const removedNodes = []; // Now that the user has made his choice, replace the nodes that need to be replaced. if (nodesToReplace.length > 0) { const result = replaceNodes(nodesToReplace); removedNodes.concat(result.removedNodes); } let isInitialLoad = false; if (!initialLoad) { isInitialLoad = true; initialLoad = JSON.parse(JSON.stringify(originalNodes)); } // Identifies unknown nodes and can emit a notification to warn the user. const unknownTypes = identifyUnknowType(originalNodes, { emitNotification: !isInitialLoad }); const subflowMap = {}; const workspaceMap = {}; const newSubflows = []; const newWorkspaces = []; // Find all tabs and subflow tabs and add it to workspace // NOTE: Subflow tab not the instance for (const node of originalNodes) { const oldId = node.id; // TODO: remove workspace in next release+1 if (node.type === "workspace" || node.type === "tab") { // Legacy type if (node.type === "workspace") { node.type = "tab"; } // The flow being sorted, the first workspace is supposed to be the default if (defaultWorkspace == null) { defaultWorkspace = node; } // If it is the initial load, the value is equal to 0 if (activeWorkspace === 0) { activeWorkspace = defaultWorkspace.id; } if (createNewIds || options.importMap[node.id] === "copy") { node.id = generateId(); } addWorkspace(node); workspaceMap[oldId] = node.id; newWorkspaces.push(node); RED.workspaces.add(node); } else if (node.type === "subflow") { node.in.forEach(function(input, i) { input.type = "subflow"; input.direction = "in"; input.z = node.id; input.i = i; input.id = generateId(); }); node.out.forEach(function(output, i) { output.type = "subflow"; output.direction = "out"; output.z = node.id; output.i = i; output.id = generateId(); }); if (node.status) { node.status.type = "subflow"; node.status.direction = "status"; node.status.z = node.id; node.status.id = generateId(); } subflowMap[oldId] = node; newSubflows.push(node); // NOTE: Update the id in the `addSubflow` function addSubflow(node, (createNewIds || options.importMap[node.id] === "copy")); } } // Add a tab if there isn't one there already - Like first install if (!defaultWorkspace) { defaultWorkspace = { type: "tab", id: generateId(), disabled: false, info: "", label: RED._("workspace.defaultName", { number: 1 }), env: [] }; addWorkspace(defaultWorkspace); RED.workspaces.add(defaultWorkspace); newWorkspaces.push(defaultWorkspace); activeWorkspace = defaultWorkspace.id; } let newWorkspace = null; let recoveryWorkspace = null; // Correct or update the z property of each node for (const node of originalNodes) { const isConfigNode = !node.hasOwnProperty("x") && !node.hasOwnProperty("y"); // If it's the initial load, create a recovery workspace if any nodes don't have `node.z` and assign to it. if (!node.z && isInitialLoad && node.hasOwnProperty("x") && node.hasOwnProperty("y")) { // Hit the rare issue where node z values get set to 0. // Repair the flow - but we really need to track that down. if (!recoveryWorkspace) { recoveryWorkspace = { id: generateId(), type: "tab", disabled: false, label: RED._("clipboard.recoveredNodes"), info: RED._("clipboard.recoveredNodesInfo"), env: [] }; } node.z = recoveryWorkspace.id; continue; } // TODO: remove workspace in next release+1 if (node.type === "workspace" || node.type === "tab" || node.type === "subflow") { continue; } // Fix `node.z` for not found/new one case if (createNewIds || options.importMap[node.id] === "copy") { // Config Node can have an undefined `node.z` if (!isConfigNode || (isConfigNode && node.z)) { if (subflowMap[node.z]) { node.z = subflowMap[node.z].id; } else { node.z = workspaceMap[node.z]; if (!workspaces[node.z]) { node.z = activeWorkspace; if (createNewWorkspace) { if (newWorkspace === null) { newWorkspace = RED.workspaces.add(null, true); newWorkspaces.push(newWorkspace); } node.z = newWorkspace.id; } } } } } else { const keepNodesCurrentZ = reimport && node.z && (RED.workspaces.contains(node.z) || RED.nodes.subflow(node.z)); if (isConfigNode && !keepNodesCurrentZ && node.z && !workspaceMap[node.z] && !subflowMap[node.z]) { node.z = activeWorkspace; } else if (!isConfigNode && !keepNodesCurrentZ && (node.z == null || (!workspaceMap[node.z] && !subflowMap[node.z]))) { node.z = activeWorkspace; if (createNewWorkspace) { if (!newWorkspace) { newWorkspace = RED.workspaces.add(null,true); newWorkspaces.push(newWorkspace); } node.z = newWorkspace.id; } } } } // Add the recovery tab if used and warn the user if (recoveryWorkspace) { addWorkspace(recoveryWorkspace); RED.workspaces.add(recoveryWorkspace); // Put the recovery workspace at the first position newWorkspaces.splice(0, 1, recoveryWorkspace, newWorkspaces[0]); const notification = RED.notify(RED._("clipboard.recoveredNodesNotification", { flowName: RED._("clipboard.recoveredNodes") }), { type: "warning", fixed: true, buttons: [ { text: RED._("common.label.close"), click: function () { notification.close(); } } ] }); } const nodeMap = {}; const newNodes = []; const newGroups = []; const newJunctions = []; // Find all Config Nodes, Groups, Junctions, Nodes and Subflow instances and add them // NOTE: Replaced Config Nodes and Subflow instances no longer appear below for (const node of originalNodes) { const oldId = node.id; // TODO: remove workspace in next release+1 if (node.type === "workspace" || node.type === "tab" || node.type === "subflow") { continue; } // Get the Node definition let def = registry.getNodeType(node.type); // Update the Node definition for Subflow instance // TODO: A thing with `node.i` if (node.type.substring(0, 7) === "subflow") { const parentId = node.type.split(":")[1]; const subflow = subflowMap[parentId] || getSubflow(parentId); // If the parent Subflow is not found, this Subflow will be marked as unknown if (subflow) { if (createNewIds || options.importMap[node.id] === "copy") { node.type = "subflow:" + subflow.id; def = registry.getNodeType(node.type); } node.inputs = subflow.in.length; node.outputs = subflow.out.length; } } let isUnknownNode = false; // Try to fix the node definition if (!def) { // Group Node if (node.type === "group") { def = RED.group.def; } // Unknown Config Node else if (!node.hasOwnProperty("x") && !node.hasOwnProperty("y")) { isUnknownNode = true; def = { category: "config", defaults: {}, set: registry.getNodeSet("node-red/unknown") }; // Unknown Node } else { isUnknownNode = true; def = { color: "#fee", defaults: {}, label: "unknown: " + node.type, labelStyle: "red-ui-flow-node-label-italic", inputs: node.inputs ?? 0, // TODO: Find if the node has an input outputs: node.outputs ?? node.wires?.length ?? 0, set: registry.getNodeSet("node-red/unknown") }; } } // TODO: Group Node has config as category - why? const isConfigNode = def?.category === "config" && node.type !== "group"; // Now the properties have been fixed, copy the node properties: // NOTE: If the Node def is unknown, user properties will not be copied if (isConfigNode) { nodeMap[oldId] = copyConfigNode(node, def, options); } else { // Node, Group, Junction or Subflow nodeMap[oldId] = copyNode(node, def, options); } // Unknown Node - Copy user properties so that the node is always exportable if (isUnknownNode) { const propertiesNotCopyable = ["x", "y", "z", "id", "wires"]; nodeMap[oldId]._orig = Object.entries(node).reduce(function (orig, [prop, value]) { if (node.hasOwnProperty(prop) && !propertiesNotCopyable.includes(prop)) { orig[prop] = value; } return orig; }, {}); nodeMap[oldId].name = node.type; nodeMap[oldId].type = "unknown"; } // Now the node has been copied, change the `id` if it's a copy if (createNewIds || options.importMap[oldId] === "copy") { nodeMap[oldId].id = generateId(); } if (node.type === "junction") { newJunctions.push(nodeMap[oldId]) } else if (node.type === "group") { newGroups.push(nodeMap[oldId]); } else { newNodes.push(nodeMap[oldId]); } } const newLinks = []; // Remap all Wires and (Config) Node references for (const node of [...newNodes, ...newGroups]) { if (node.wires) { for (const wiresGroup of node.wires) { const wires = Array.isArray(wiresGroup) ? wiresGroup : [wiresGroup]; wires.forEach(function (wire) { // Skip if the wire is clinked to a non-existent node if (!nodeMap.hasOwnProperty(wire)) { return; } if (node.z === nodeMap[wire].z) { const link = { source: node, sourcePort: node.wires.indexOf(wiresGroup), target: nodeMap[wire] }; addLink(link); newLinks.push(link); } else { console.log("Warning: dropping link that crosses tabs:", node.id, "->", nodeMap[wire].id); } }); } delete node.wires; } // Update the Group id if (node.g && nodeMap[node.g]) { node.g = nodeMap[node.g].id; } else { delete node.g; } // If importing into a subflow, ensure an outbound-link doesn't get added if (activeSubflow && /^link /.test(node.type) && node.links) { node.links = node.links.filter(function (id) { const otherNode = nodeMap[id] || RED.nodes.node(id); return (otherNode?.z === activeWorkspace); }); } // Update the node id for select inputs and Links nodes for (const d in node._def.defaults) { if (node._def.defaults.hasOwnProperty(d) && node._def.defaults[d].type) { let nodeList = node[d]; if (!Array.isArray(nodeList)) { nodeList = [nodeList]; } nodeList = nodeList.map(function (id) { const n = nodeMap[id]; return n ? n.id : id; }); node[d] = Array.isArray(node[d]) ? nodeList : nodeList[0]; } } } // Add Links to Workspace for (const subflow of newSubflows) { subflow.in.forEach(function (input) { input.wires.forEach(function (wire) { if (nodeMap.hasOwnProperty(wire.id)) { const link = { source: input, sourcePort: 0, target: nodeMap[wire.id] }; addLink(link); newLinks.push(link); } }); delete input.wires; }); subflow.out.forEach(function (output) { output.wires.forEach(function (wire) { let link; if (subflowMap[wire.id] && subflowMap[wire.id].id === subflow.id) { link = { source: subflow.in[wire.port], sourcePort: wire.port, target: output }; } else if (nodeMap.hasOwnProperty(wire.id) || subflowMap.hasOwnProperty(wire.id)) { link = { source: nodeMap[wire.id] || subflowMap[wire.id], sourcePort: wire.port, target: output }; } if (link) { addLink(link); newLinks.push(link); } }); delete output.wires; }); subflow.status?.wires.forEach(function (wire) { let link; if (subflowMap[wire.id] && subflowMap[wire.id].id === subflow.id) { link = { source: subflow.in[wire.port], sourcePort: wire.port, target: subflow.status }; } else if (nodeMap.hasOwnProperty(wire.id) || subflowMap.hasOwnProperty(wire.id)) { link = { source: nodeMap[wire.id] || subflowMap[wire.id], sourcePort: wire.port, target: subflow.status }; } if (link) { addLink(link); newLinks.push(link); } }); if (subflow.status) { delete subflow.status.wires; } } // Order the groups to ensure they are outer-most to inner-most const groupDepthMap = {}; const groupsId = newGroups.map(function (group) { return group.id; }); for (const group of newGroups) { // Delete the group if it is not part of the import if (group.g && !groupsId.includes(group.g)) { delete group.g; } // If the group does not contain a group, it's the outer-most if (!group.g) { groupDepthMap[group.id] = 0; } } let changedDepth; do { changedDepth = false; for (const group of newGroups) { if (group.g) { if (groupDepthMap[group.id] !== groupDepthMap[group.g] + 1) { groupDepthMap[group.id] = groupDepthMap[group.g] + 1; changedDepth = true; } } } } while(changedDepth); newGroups.sort(function (a, b) { return (groupDepthMap[a.id] - groupDepthMap[b.id]); }); // Add Groups to Workspace for (const index in newGroups) { if (newGroups.indexOf(newGroups[index]) !== -1) { newGroups[index] = addGroup(newGroups[index]); nodeMap[newGroups[index].id] = newGroups[index]; } } // Add Junctions to Workspace for (const index in newJunctions) { if (newJunctions.indexOf(newJunctions[index]) !== -1) { newJunctions[index] = addJunction(newJunctions[index]); nodeMap[newJunctions[index].id] = newJunctions[index]; } } // Now the nodes have been fully updated, add them as Proxy. for (const index in newNodes) { if (newNodes.indexOf(newNodes[index]) !== -1) { newNodes[index] = addNode(newNodes[index]); nodeMap[newNodes[index].id] = newNodes[index]; } } // Finally validate all Nodes. for (const node of newNodes) { RED.editor.validateNode(node); } const lookupNode = (id) => { const mappedNode = nodeMap[id]; if (!mappedNode) { return null; } if (mappedNode.__isProxy__) { return mappedNode; } else { return nodeMap[mappedNode.id]; } }; // Update groups to reference proxy node objects for (const group of newGroups) { // bypass the proxy in case the flow is locked group.__node__.nodes = group.nodes.map(lookupNode); // Just in case the group references a node that doesn't exist for some reason group.__node__.nodes = group.nodes.filter(function (n) { if (n) { // Repair any nodes that have forgotten they are in this group if (n.g !== group.id) { n.g = group.id; } } return !!n; }); } // Update links to use proxy node objects for (const link of newLinks) { link.source = lookupNode(link.source.id) || link.source; link.target = lookupNode(link.target.id) || link.target; } // TODO: Right place? RED.workspaces.refresh(); return { nodes: newNodes, links: newLinks, groups: newGroups, junctions: newJunctions, workspaces: newWorkspaces, subflows: newSubflows, missingWorkspace: newWorkspace, removedNodes: removedNodes }; } // TODO: supports filter.z|type function filterNodes(filter) { return allNodes.filterNodes(filter); } function filterLinks(filter) { var result = []; var candidateLinks = []; var hasCandidates = false; var filterSZ = filter.source && filter.source.z; var filterTZ = filter.target && filter.target.z; var filterZ; if (filterSZ || filterTZ) { if (filterSZ === filterTZ) { filterZ = filterSZ; } else { filterZ = (filterSZ === undefined)?filterTZ:filterSZ } } if (filterZ) { candidateLinks = linkTabMap[filterZ] || []; hasCandidates = true; } else if (filter.source && filter.source.hasOwnProperty("id")) { if (nodeLinks[filter.source.id]) { hasCandidates = true; candidateLinks = candidateLinks.concat(nodeLinks[filter.source.id].out) } } else if (filter.target && filter.target.hasOwnProperty("id")) { if (nodeLinks[filter.target.id]) { hasCandidates = true; candidateLinks = candidateLinks.concat(nodeLinks[filter.target.id].in) } } if (!hasCandidates) { candidateLinks = links; } for (var n=0;n g.id) } function addJunction(junction) { if (!junction.__isProxy__) { junction = new Proxy(junction, nodeProxyHandler) } junctionsByZ[junction.z] = junctionsByZ[junction.z] || [] junctionsByZ[junction.z].push(junction) junctions[junction.id] = junction; if (!nodeLinks[junction.id]) { nodeLinks[junction.id] = {in:[],out:[]}; } allNodes.addObjectToWorkspace(junction.z, junction.id, junction.changed || junction.moved) RED.events.emit("junctions:add", junction) return junction } function removeJunction(junction) { var i = junctionsByZ[junction.z].indexOf(junction) junctionsByZ[junction.z].splice(i, 1) if (junctionsByZ[junction.z].length === 0) { delete junctionsByZ[junction.z] } delete junctions[junction.id] delete nodeLinks[junction.id]; allNodes.removeObjectFromWorkspace(junction.z, junction.id) RED.events.emit("junctions:remove", junction) var removedLinks = links.filter(function(l) { return (l.source === junction) || (l.target === junction); }); removedLinks.forEach(removeLink); return { links: removedLinks } } function getNodeHelp(type) { var helpContent = ""; var helpElement = $("script[data-help-name='"+type+"']"); if (helpElement) { helpContent = helpElement.html(); var helpType = helpElement.attr("type"); if (helpType === "text/markdown") { helpContent = RED.utils.renderMarkdown(helpContent); } } return helpContent; } function getNodeIslands(nodes) { var selectedNodes = new Set(nodes); // Maps node => island index var nodeToIslandIndex = new Map(); // Maps island index => [nodes in island] var islandIndexToNodes = new Map(); var internalLinks = new Set(); nodes.forEach((node, index) => { nodeToIslandIndex.set(node,index); islandIndexToNodes.set(index, [node]); var inboundLinks = RED.nodes.getNodeLinks(node, PORT_TYPE_INPUT); var outboundLinks = RED.nodes.getNodeLinks(node, PORT_TYPE_OUTPUT); inboundLinks.forEach(l => { if (selectedNodes.has(l.source)) { internalLinks.add(l) } }) outboundLinks.forEach(l => { if (selectedNodes.has(l.target)) { internalLinks.add(l) } }) }) internalLinks.forEach(l => { let source = l.source; let target = l.target; if (nodeToIslandIndex.get(source) !== nodeToIslandIndex.get(target)) { let sourceIsland = nodeToIslandIndex.get(source); let islandToMove = nodeToIslandIndex.get(target); let nodesToMove = islandIndexToNodes.get(islandToMove); nodesToMove.forEach(n => { nodeToIslandIndex.set(n,sourceIsland); islandIndexToNodes.get(sourceIsland).push(n); }) islandIndexToNodes.delete(islandToMove); } }) const result = []; islandIndexToNodes.forEach((nodes,index) => { result.push(nodes); }) return result; } function detachNodes(nodes) { let allSelectedNodes = []; nodes.forEach(node => { if (node.type === 'group') { let groupNodes = RED.group.getNodes(node,true,true); allSelectedNodes = allSelectedNodes.concat(groupNodes); } else { allSelectedNodes.push(node); } }) if (allSelectedNodes.length > 0 ) { const nodeIslands = RED.nodes.getNodeIslands(allSelectedNodes); let removedLinks = []; let newLinks = []; let createdLinkIds = new Set(); nodeIslands.forEach(nodes => { let selectedNodes = new Set(nodes); let allInboundLinks = []; let allOutboundLinks = []; // Identify links that enter or exit this island of nodes nodes.forEach(node => { var inboundLinks = RED.nodes.getNodeLinks(node, PORT_TYPE_INPUT); var outboundLinks = RED.nodes.getNodeLinks(node, PORT_TYPE_OUTPUT); inboundLinks.forEach(l => { if (!selectedNodes.has(l.source)) { allInboundLinks.push(l) } }) outboundLinks.forEach(l => { if (!selectedNodes.has(l.target)) { allOutboundLinks.push(l) } }) }); // Identify the links to restore allInboundLinks.forEach(inLink => { // For Each inbound link, // - get source node. // - trace through to all outbound links let sourceNode = inLink.source; let targetNodes = new Set(); let visited = new Set(); let stack = [inLink.target]; while (stack.length > 0) { let node = stack.pop(stack); visited.add(node) let links = RED.nodes.getNodeLinks(node, PORT_TYPE_OUTPUT); links.forEach(l => { if (visited.has(l.target)) { return } visited.add(l.target); if (selectedNodes.has(l.target)) { // internal link stack.push(l.target) } else { targetNodes.add(l.target) } }) } targetNodes.forEach(target => { let linkId = `${sourceNode.id}[${inLink.sourcePort}] -> ${target.id}` if (!createdLinkIds.has(linkId)) { createdLinkIds.add(linkId); let link = { source: sourceNode, sourcePort: inLink.sourcePort, target: target } let existingLinks = RED.nodes.filterLinks(link) if (existingLinks.length === 0) { newLinks.push(link); } } }) }) // 2. delete all those links allInboundLinks.forEach(l => { RED.nodes.removeLink(l); removedLinks.push(l)}) allOutboundLinks.forEach(l => { RED.nodes.removeLink(l); removedLinks.push(l)}) }) newLinks.forEach(l => RED.nodes.addLink(l)); return { newLinks, removedLinks } } } /** * Finds unknown nodes of the same type and replaces them to update their * definition. * * An imported node that has no definition is marked as unknown. * When adding a new node type, this function searches for existing nodes * of that type to update their definition. * * @param {string} type The type of the Node added */ function onNodeTypeAdded(type) { const nodesToReplace = []; RED.nodes.eachConfig(function (node) { if (node.type === "unknown" && node.name === type) { nodesToReplace.push(node); } }); RED.nodes.eachNode(function (node) { if (node.type === "unknown" && node.name === type) { nodesToReplace.push(node); } }); // Skip if there is nothing to replace if (!nodesToReplace.length) { return; } const nodeGroupMap = {}; const removedNodes = []; nodesToReplace.forEach(function (node) { // Create a snapshot of the Node removedNodes.push(convertNode(node)); // Remove the Node // NOTE: DON'T use removeNode - no need for everything that is done there. // Just delete the node because we re-import it as is afterwards. if (configNodes.hasOwnProperty(node.id)) { delete configNodes[node.id]; } else { allNodes.removeNode(node); } // Reimporting a node *without* including its group object will cause // the g property to be cleared. Cache it here so we can restore it. if (node.g) { nodeGroupMap[node.id] = node.g } }); const removedLinks = []; const nodeMap = nodesToReplace.reduce(function (map, node) { map[node.id] = node; return map; }, {}); // Remove any links between nodes that are going to be reimported. // This prevents a duplicate link from being added. RED.nodes.eachLink(function (link) { if (nodeMap.hasOwnProperty(link.source.id) && nodeMap.hasOwnProperty(link.target.id)) { removedLinks.push(link); } }); removedLinks.forEach(removeLink); // Force the redraw to be synchronous so the view updates // *now* and removes the unknown node RED.view.redraw(true, true); // Re-import removed nodes - now nodes have their definition const result = importNodes(removedNodes, { generateIds: false, reimport: true }); const newNodeMap = {}; // Rattach the nodes to their group result?.nodes.forEach(function (node) { newNodeMap[node.id] = node; if (nodeGroupMap[node.id]) { // This node is in a group - need to substitute the // node reference inside the group node.g = nodeGroupMap[node.id]; const group = RED.nodes.group(node.g); if (group) { const index = group.nodes.findIndex((g) => g.id === node.id); if (index > -1) { group.nodes[index] = node; } } } }); // Relink nodes RED.nodes.eachLink(function (link) { if (newNodeMap.hasOwnProperty(link.source.id)) { link.source = newNodeMap[link.source.id]; } if (newNodeMap.hasOwnProperty(link.target.id)) { link.target = newNodeMap[link.target.id]; } }); RED.view.redraw(true); } return { init: function () { RED.events.on("registry:node-type-added", onNodeTypeAdded); RED.events.on("deploy", function () { allNodes.clearState(); }); }, registry:registry, setNodeList: registry.setNodeList, getNodeSet: registry.getNodeSet, addNodeSet: registry.addNodeSet, removeNodeSet: registry.removeNodeSet, enableNodeSet: registry.enableNodeSet, disableNodeSet: registry.disableNodeSet, setIconSets: registry.setIconSets, getIconSets: registry.getIconSets, registerType: registry.registerNodeType, getType: registry.getNodeType, getNodeHelp: getNodeHelp, convertNode: convertNode, add: addNode, remove: removeNode, clear: clear, detachNodes: detachNodes, moveNodesForwards: moveNodesForwards, moveNodesBackwards: moveNodesBackwards, moveNodesToFront: moveNodesToFront, moveNodesToBack: moveNodesToBack, getNodeOrder: getNodeOrder, setNodeOrder: setNodeOrder, moveNodeToTab: moveNodeToTab, addLink: addLink, removeLink: removeLink, getNodeLinks: function(id, portType) { if (typeof id !== 'string') { id = id.id; } if (nodeLinks[id]) { if (portType === 1) { // Return cloned arrays so they can be safely modified by caller return [].concat(nodeLinks[id].in) } else { return [].concat(nodeLinks[id].out) } } return []; }, addWorkspace: addWorkspace, removeWorkspace: removeWorkspace, getWorkspaceOrder: function() { return [...workspacesOrder] }, setWorkspaceOrder: function(order) { workspacesOrder = order; }, workspace: getWorkspace, addSubflow: addSubflow, removeSubflow: removeSubflow, subflow: getSubflow, subflowContains: subflowContains, addGroup: addGroup, removeGroup: removeGroup, group: function(id) { return groups[id] }, groups: function(z) { return groupsByZ[z]?groupsByZ[z].slice():[] }, addJunction: addJunction, removeJunction: removeJunction, junction: function(id) { return junctions[id] }, junctions: function(z) { return junctionsByZ[z]?junctionsByZ[z].slice():[] }, eachNode: function(cb) { allNodes.eachNode(cb); }, eachLink: function(cb) { for (var l=0;l