3302 lines
122 KiB
JavaScript

/**
* 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<list.length;i++) {
var ns = list[i];
exports.addNodeSet(ns);
}
},
addNodeSet: function(ns) {
if (!ns.types) {
// A node has been loaded without any types. Ignore it.
return;
}
ns.added = false;
nodeSets[ns.id] = ns;
for (var j=0;j<ns.types.length;j++) {
typeToId[ns.types[j]] = ns.id;
}
nodeList.push(ns);
moduleList[ns.module] = moduleList[ns.module] || {
name:ns.module,
version:ns.version,
local:ns.local,
sets:{}
};
if (ns.pending_version) {
moduleList[ns.module].pending_version = ns.pending_version;
}
moduleList[ns.module].sets[ns.name] = ns;
RED.events.emit("registry:node-set-added",ns);
},
removeNodeSet: function(id) {
var ns = nodeSets[id];
if (!ns) { return {} }
for (var j=0;j<ns.types.length;j++) {
delete typeToId[ns.types[j]];
}
delete nodeSets[id];
for (var i=0;i<nodeList.length;i++) {
if (nodeList[i].id === id) {
nodeList.splice(i,1);
break;
}
}
delete moduleList[ns.module].sets[ns.name];
if (Object.keys(moduleList[ns.module].sets).length === 0) {
delete moduleList[ns.module];
}
RED.events.emit("registry:node-set-removed",ns);
return ns;
},
getNodeSet: function(id) {
return nodeSets[id];
},
enableNodeSet: function(id) {
var ns = nodeSets[id];
ns.enabled = true;
RED.events.emit("registry:node-set-enabled",ns);
},
disableNodeSet: function(id) {
var ns = nodeSets[id];
ns.enabled = false;
RED.events.emit("registry:node-set-disabled",ns);
},
registerNodeType: function(nt,def) {
if (nt.substring(0,8) != "subflow:") {
if (!nodeSets[typeToId[nt]]) {
var error = "";
var fullType = nt;
if (RED._loadingModule) {
fullType = "["+RED._loadingModule+"] "+nt;
if (nodeSets[RED._loadingModule]) {
error = nodeSets[RED._loadingModule].err || "";
} else {
error = "Unknown error";
}
}
RED.notify(RED._("palette.event.unknownNodeRegistered",{type:fullType, error:error}), "error");
return;
}
def.set = nodeSets[typeToId[nt]];
nodeSets[typeToId[nt]].added = true;
nodeSets[typeToId[nt]].enabled = true;
var ns;
if (def.set.module === "node-red") {
ns = "node-red";
} else {
ns = def.set.id;
}
def["_"] = function() {
var args = Array.prototype.slice.call(arguments, 0);
var original = args[0];
if (args[0].indexOf(":") === -1) {
args[0] = ns+":"+args[0];
}
var result = RED._.apply(null,args);
if (result === args[0]) {
result = original;
}
return result;
}
// TODO: too tightly coupled into palette UI
}
def.type = nt;
nodeDefinitions[nt] = def;
if (def.defaults) {
for (var d in def.defaults) {
if (def.defaults.hasOwnProperty(d)) {
if (def.defaults[d].type) {
try {
def.defaults[d]._type = parseNodePropertyTypeString(def.defaults[d].type)
} catch(err) {
console.warn(err);
}
}
}
}
}
RED.events.emit("registry:node-type-added",nt);
},
removeNodeType: function(nt) {
if (nt.substring(0,8) != "subflow:") {
// NON-NLS - internal debug message
throw new Error("this api is subflow only. called with:",nt);
}
delete nodeDefinitions[nt];
RED.events.emit("registry:node-type-removed",nt);
},
getNodeType: function(nt) {
return nodeDefinitions[nt];
},
setIconSets: function(sets) {
iconSets = sets;
iconSets["font-awesome"] = RED.nodes.fontAwesome.getIconList();
},
getIconSets: function() {
return iconSets;
}
};
return exports;
})();
// allNodes holds information about the Flow nodes.
var allNodes = (function() {
// Map node.id -> 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<searchSet.length;n++) {
var node = searchSet[n];
if (objectLookup) {
node = nodes[node];
}
if (filter.hasOwnProperty("type") && node.type !== filter.type) {
continue;
}
if (doZFilter && node.z !== filter.z) {
continue;
}
result.push(node);
}
return result;
},
getNodeOrder: function(z) {
return (groupsByZ[z] || []).concat(tabMap[z]).map(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<removedNodes.length;i++) {
var result = removeNode(removedNodes[i].id);
removedLinks = removedLinks.concat(result.links);
}
for (i=0;i<removedJunctions.length;i++) {
var result = removeJunction(removedJunctions[i])
removedLinks = removedLinks.concat(result.links)
}
// Must get 'removedGroups' in the right order.
// - start with the top-most groups
// - then recurse into them
removedGroups = (groupsByZ[id] || []).filter(function(g) { return !g.g; });
for (i=0;i<removedGroups.length;i++) {
removedGroups[i].nodes.forEach(function(n) {
if (n.type === "group") {
removedGroups.push(n);
}
});
}
// Now remove them in the reverse order
for (i=removedGroups.length-1; 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<sfNodes.length; i++) {
var node = sfNodes[i];
var m = /^subflow:(.+)$/.exec(node.type);
if (m) {
if (m[1] === nodeid) {
return true;
} else {
var result = subflowContains(m[1],nodeid);
if (result) {
return true;
}
}
}
}
return false;
}
function getDownstreamNodes(node) {
const downstreamLinks = nodeLinks[node.id].out
const downstreamNodes = new Set(downstreamLinks.map(l => 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<n.outputs;i++) {
node.wires.push([]);
}
var wires = links.filter(function(d){return d.source === n;});
for (var j=0;j<wires.length;j++) {
var w = wires[j];
if (w.target.type != "subflow") {
if (w.sourcePort < node.wires.length) {
node.wires[w.sourcePort].push(w.target.id);
}
}
}
if (n.inputs > 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<wires.length;i++) {
var w = wires[i];
if (w.target.type != "subflow") {
nIn.wires.push({id:w.target.id})
}
}
node.in.push(nIn);
});
n.out.forEach(function(p,c) {
var nOut = {x:p.x,y:p.y,wires:[]};
var wires = links.filter(function(d) { return d.target === p });
for (i=0;i<wires.length;i++) {
if (wires[i].source.type != "subflow") {
nOut.wires.push({id:wires[i].source.id,port:wires[i].sourcePort})
} else {
nOut.wires.push({id:n.id,port:0})
}
}
node.out.push(nOut);
});
if (node.in.length > 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<set.length;n++) {
var node = set[n];
if (node.type.substring(0,8) == "subflow:") {
var subflowId = node.type.substring(8);
if (!exportedSubflows[subflowId]) {
exportedSubflows[subflowId] = true;
var subflow = getSubflow(subflowId);
var subflowSet = allNodes.getNodes(subflowId).slice();
subflowSet.unshift(subflow);
RED.nodes.eachConfig(function(n) {
if (n.z == subflowId) {
subflowSet.push(n);
exportedConfigNodes[n.id] = true;
}
});
subflowSet = subflowSet.concat(RED.nodes.junctions(subflowId))
subflowSet = subflowSet.concat(RED.nodes.groups(subflowId))
var exportableSubflow = createExportableNodeSet(subflowSet, exportedIds, exportedSubflows, exportedConfigNodes);
nns = exportableSubflow.concat(nns);
}
}
if (node.type !== "subflow") {
var convertedNode = RED.nodes.convertNode(node, { credentials: false });
for (var d in node._def.defaults) {
if (node._def.defaults[d].type) {
var nodeList = node[d];
if (!Array.isArray(nodeList)) {
nodeList = [nodeList];
}
nodeList = nodeList.filter(function(id) {
if (id in configNodes) {
var confNode = configNodes[id];
if (confNode._def.exportable !== false) {
if (!(id in exportedConfigNodes)) {
exportedConfigNodes[id] = true;
set.push(confNode);
}
return true;
}
return false;
}
return true;
})
if (nodeList.length === 0) {
convertedNode[d] = Array.isArray(node[d])?[]:""
} else {
convertedNode[d] = Array.isArray(node[d])?nodeList:nodeList[0]
}
}
}
nns.push(convertedNode);
if (node.type === "group") {
nns = nns.concat(createExportableNodeSet(node.nodes, exportedIds, exportedSubflows, exportedConfigNodes));
}
} else {
var convertedSubflow = convertSubflow(node, { credentials: false });
nns.push(convertedSubflow);
}
}
return nns;
}
// Create the Flow JSON for the current configuration
// opts.credentials (whether to include (known) credentials) - default: true
// opts.dimensions (whether to include node dimensions) - default: false
function createCompleteNodeSet(opts) {
var nns = [];
var i;
for (i=0;i<workspacesOrder.length;i++) {
if (workspaces[workspacesOrder[i]].type == "tab") {
nns.push(convertWorkspace(workspaces[workspacesOrder[i]], opts));
}
}
for (i in subflows) {
if (subflows.hasOwnProperty(i)) {
nns.push(convertSubflow(subflows[i], opts));
}
}
for (i in groups) {
if (groups.hasOwnProperty(i)) {
nns.push(convertNode(groups[i], opts));
}
}
for (i in junctions) {
if (junctions.hasOwnProperty(i)) {
nns.push(convertNode(junctions[i], opts));
}
}
for (i in configNodes) {
if (configNodes.hasOwnProperty(i)) {
nns.push(convertNode(configNodes[i], opts));
}
}
RED.nodes.eachNode(function(n) {
nns.push(convertNode(n, opts));
})
return nns;
}
function checkForMatchingSubflow(subflow,subflowNodes) {
subflowNodes = subflowNodes || [];
var i;
var match = null;
RED.nodes.eachSubflow(function(sf) {
if (sf.name != subflow.name ||
sf.info != subflow.info ||
sf.in.length != subflow.in.length ||
sf.out.length != subflow.out.length) {
return;
}
var sfNodes = RED.nodes.filterNodes({z:sf.id});
if (sfNodes.length != subflowNodes.length) {
return;
}
var subflowNodeSet = [subflow].concat(subflowNodes);
var sfNodeSet = [sf].concat(sfNodes);
var exportableSubflowNodes = JSON.stringify(subflowNodeSet);
var exportableSFNodes = JSON.stringify(createExportableNodeSet(sfNodeSet));
var nodeMap = {};
for (i=0;i<sfNodes.length;i++) {
exportableSubflowNodes = exportableSubflowNodes.replace(new RegExp("\""+subflowNodes[i].id+"\"","g"),'"'+sfNodes[i].id+'"');
}
exportableSubflowNodes = exportableSubflowNodes.replace(new RegExp("\""+subflow.id+"\"","g"),'"'+sf.id+'"');
if (exportableSubflowNodes !== exportableSFNodes) {
return;
}
match = sf;
return false;
});
return match;
}
function compareNodes(nodeA,nodeB,idMustMatch) {
if (idMustMatch && nodeA.id != nodeB.id) {
return false;
}
if (nodeA.type != nodeB.type) {
return false;
}
var def = nodeA._def;
for (var d in def.defaults) {
if (def.defaults.hasOwnProperty(d)) {
var vA = nodeA[d];
var vB = nodeB[d];
if (typeof vA !== typeof vB) {
return false;
}
if (vA === null || typeof vA === "string" || typeof vA === "number") {
if (vA !== vB) {
return false;
}
} else {
if (JSON.stringify(vA) !== JSON.stringify(vB)) {
return false;
}
}
}
}
return true;
}
function identifyImportConflicts(importedNodes) {
var imported = {
tabs: {},
subflows: {},
groups: {},
junctions: {},
configs: {},
nodes: {},
all: [],
conflicted: {},
zMap: {},
}
importedNodes.forEach(function(n) {
imported.all.push(n);
if (n.type === "tab") {
imported.tabs[n.id] = n;
} else if (n.type === "subflow") {
imported.subflows[n.id] = n;
} else if (n.type === "group") {
imported.groups[n.id] = n;
} else if (n.type === "junction") {
imported.junctions[n.id] = n;
} else if (n.hasOwnProperty("x") && n.hasOwnProperty("y")) {
imported.nodes[n.id] = n;
} else {
imported.configs[n.id] = n;
}
var nodeZ = n.z || "__global__";
imported.zMap[nodeZ] = imported.zMap[nodeZ] || [];
imported.zMap[nodeZ].push(n)
if (allNodes.hasNode(n.id) || configNodes[n.id] || workspaces[n.id] || subflows[n.id] || groups[n.id] || junctions[n.id]) {
imported.conflicted[n.id] = n;
}
})
return imported;
}
/**
* Replace the provided nodes.
* This must contain complete Subflow defs or complete Flow Tabs.
* It does not replace an individual node in the middle of a flow.
*/
function replaceNodes(newNodes) {
var zMap = {};
var newSubflows = {};
var newConfigNodes = {};
var removedNodes = [];
// Figure out what we're being asked to replace - subflows/configNodes
newNodes.forEach(function(n) {
if (n.type === "subflow") {
newSubflows[n.id] = n;
} else if (!n.hasOwnProperty('x') && !n.hasOwnProperty('y')) {
newConfigNodes[n.id] = n;
}
if (n.z) {
zMap[n.z] = zMap[n.z] || [];
zMap[n.z].push(n);
}
})
// Filter out config nodes inside a subflow def that is being replaced
var configNodeIds = Object.keys(newConfigNodes);
configNodeIds.forEach(function(id) {
var n = newConfigNodes[id];
if (newSubflows[n.z]) {
// This config node is in a subflow to be replaced.
// - remove from the list as it'll get handled with the subflow
delete newConfigNodes[id];
}
});
// Rebuild the list of ids
configNodeIds = Object.keys(newConfigNodes);
// ------------------------------
// Replace subflow definitions
//
// For each of the subflows to be replaced:
var newSubflowIds = Object.keys(newSubflows);
newSubflowIds.forEach(function(id) {
var n = newSubflows[id];
// Get a snapshot of the existing subflow definition
removedNodes = removedNodes.concat(createExportableSubflow(id));
// Remove the old subflow definition - but leave the instances in place
var removalResult = RED.subflow.removeSubflow(n.id, true);
// Create the list of nodes for the new subflow def
// Need to sort the list in order to remove missing nodes
var subflowNodes = [n].concat(zMap[n.id]).filter((s) => !!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<object>} nodes An array of nodes to analyse
* @param {{ emitNotification?: boolean; }} options An options object
* @returns {Array<string>} 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 = $("<ul>");
unknownTypes.forEach(function(type) {
$("<li>").text(type).appendTo(typeList);
});
RED.notify(
"<p>" + RED._("clipboard.importUnrecognised", {count: unknownTypes.length }) + "</p>" + 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<object>} existingNodes An array containing conflicting nodes.
* @param {Array<object>} 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 = $("<ul>");
let itemCount = 0;
for (const { existing, imported } of existingNodes) {
if (itemCount >= maxItemCount) { break; }
const conflictType = (imported.type !== existing.type) ? " | " + imported.type : "";
$("<li>").text(existing.id + " [ " + existing.type + conflictType + " ]").appendTo(nodeList);
itemCount++;
}
if (existingNodes.length > maxItemCount) {
$("<li>").text(RED._("deploy.confirm.plusNMore", { count: (existingNodes.length - maxItemCount) })).appendTo(nodeList);
}
const wrapper = $("<p>").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<NodeId, "copy" | "import" | "replace" | "skip">} ImportMap
*
* @param {string | object | Array<object>} 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<candidateLinks.length;n++) {
var link = candidateLinks[n];
if (filter.source) {
if (filter.source.hasOwnProperty("id") && link.source.id !== filter.source.id) {
continue;
}
if (filter.source.hasOwnProperty("z") && link.source.z !== filter.source.z) {
continue;
}
}
if (filter.target) {
if (filter.target.hasOwnProperty("id") && link.target.id !== filter.target.id) {
continue;
}
if (filter.target.hasOwnProperty("z") && link.target.z !== filter.target.z) {
continue;
}
}
if (filter.hasOwnProperty("sourcePort") && link.sourcePort !== filter.sourcePort) {
continue;
}
result.push(link);
}
return result;
}
/**
* Update any config nodes referenced by the provided node to ensure
* their 'users' list is correct.
*
* Options:
* - `action` - Add or remove the node from the Config Node users list. Default `add`.
* - `emitEvent` - Emit the `nodes:changes` event. Default true.
*
* @param {object} node The node in which to check if it contains references
* @param {{ action?: "add" | "remove"; emitEvent?: boolean; }} options Options to apply.
*/
function updateConfigNodeUsers(node, options = {}) {
const defaultOptions = { action: "add", emitEvent: true };
options = Object.assign({}, defaultOptions, options);
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) {
if (options.action === "add") {
if (configNode.users.indexOf(node) === -1) {
configNode.users.push(node);
if (options.emitEvent) {
RED.events.emit('nodes:change', configNode);
}
}
} else if (options.action === "remove") {
if (configNode.users.indexOf(node) !== -1) {
const users = configNode.users;
users.splice(users.indexOf(node), 1);
if (options.emitEvent) {
RED.events.emit('nodes:change', configNode);
}
}
}
}
}
}
}
}
}
function flowVersion(version) {
if (version !== undefined) {
loadedFlowVersion = version;
} else {
return loadedFlowVersion;
}
}
function clear() {
links = [];
linkTabMap = {};
nodeLinks = {};
configNodes = {};
workspacesOrder = [];
groups = {};
groupsByZ = {};
junctions = {};
junctionsByZ = {};
var workspaceIds = Object.keys(workspaces);
// Ensure all workspaces are unlocked so we don't get any edit-protection
// preventing removal
workspaceIds.forEach(function(id) {
workspaces[id].locked = false
});
var subflowIds = Object.keys(subflows);
subflowIds.forEach(function(id) {
RED.subflow.removeSubflow(id)
});
workspaceIds.forEach(function(id) {
RED.workspaces.remove(workspaces[id]);
});
defaultWorkspace = null;
initialLoad = null;
workspaces = {};
allNodes.clear();
RED.nodes.dirty(false);
RED.view.redraw(true, true);
RED.palette.refresh();
RED.workspaces.refresh();
RED.sidebar.config.refresh();
RED.sidebar.info.refresh();
RED.events.emit("workspace:clear");
}
function addGroup(group) {
if (!group.__isProxy__) {
group = new Proxy(group, nodeProxyHandler)
}
groupsByZ[group.z] = groupsByZ[group.z] || [];
groupsByZ[group.z].push(group);
groups[group.id] = group;
allNodes.addObjectToWorkspace(group.z, group.id, group.changed || group.moved)
RED.events.emit("groups:add",group);
return group
}
function removeGroup(group) {
var i = groupsByZ[group.z].indexOf(group);
groupsByZ[group.z].splice(i,1);
if (groupsByZ[group.z].length === 0) {
delete groupsByZ[group.z];
}
if (group.g) {
if (groups[group.g]) {
var index = groups[group.g].nodes.indexOf(group);
groups[group.g].nodes.splice(index,1);
}
}
RED.group.markDirty(group);
allNodes.removeObjectFromWorkspace(group.z, group.id)
delete groups[group.id];
RED.events.emit("groups:remove",group);
}
function getGroupOrder(z) {
const groups = groupsByZ[z]
return groups.map(g => 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<links.length;l++) {
if (cb(links[l]) === false) {
break;
}
}
},
eachConfig: function(cb) {
for (var id in configNodes) {
if (configNodes.hasOwnProperty(id)) {
if (cb(configNodes[id]) === false) {
break;
}
}
}
},
eachSubflow: function(cb) {
for (var id in subflows) {
if (subflows.hasOwnProperty(id)) {
if (cb(subflows[id]) === false) {
break;
}
}
}
},
eachWorkspace: function(cb) {
for (var i=0;i<workspacesOrder.length;i++) {
if (cb(workspaces[workspacesOrder[i]]) === false) {
break;
}
}
},
eachGroup: function(cb) {
for (var group of Object.values(groups)) {
if (cb(group) === false) {
break
}
}
},
eachJunction: function(cb) {
for (var junction of Object.values(junctions)) {
if (cb(junction) === false) {
break
}
}
},
node: getNode,
version: flowVersion,
originalFlow: function(flow) {
if (flow === undefined) {
return initialLoad;
} else {
initialLoad = flow;
}
},
filterNodes: filterNodes,
filterLinks: filterLinks,
import: importNodes,
identifyImportConflicts: identifyImportConflicts,
getAllFlowNodes: getAllFlowNodes,
getAllUpstreamNodes: getAllUpstreamNodes,
getAllDownstreamNodes: getAllDownstreamNodes,
getDownstreamNodes: getDownstreamNodes,
getNodeIslands: getNodeIslands,
createExportableNodeSet: createExportableNodeSet,
createCompleteNodeSet: createCompleteNodeSet,
updateConfigNodeUsers: updateConfigNodeUsers,
id: generateId,
dirty: function(d) {
if (d == null) {
return dirty;
} else {
setDirty(d);
}
}
};
})();