@flowfuse/nr-assistant
Version:
FlowFuse Node-RED Expert plugin
1,141 lines (1,083 loc) • 89.2 kB
JavaScript
import { ExpertActionsInterface } from './expertActionsInterface.js'
// Actions supported by this module (namespace/action-name):
const SELECT_NODES = 'automation/select-nodes'
const GET_NODES = 'automation/get-nodes'
const EDIT_NODE = 'automation/open-node-edit'
const SEARCH = 'automation/search'
const ADD_FLOW_TAB = 'automation/add-flow-tab'
const UPDATE_NODES = 'automation/update-nodes'
const SHOW_WORKSPACE = 'automation/show-workspace'
const GET_FLOW = 'automation/get-workspace-nodes'
const LIST_WORKSPACES = 'automation/list-workspaces'
const CLOSE_SEARCH = 'automation/close-search'
const CLOSE_TYPE_SEARCH = 'automation/close-type-search'
const CLOSE_ACTION_LIST = 'automation/close-action-list'
const ADD_TAB = 'automation/add-tab'
const REMOVE_TAB = 'automation/remove-tab'
const ADD_NODES = 'automation/add-nodes'
const REMOVE_NODES = 'automation/remove-nodes'
const SET_WIRES = 'automation/set-wires'
const SET_LINKS = 'automation/set-links'
const IMPORT_FLOW = 'automation/import-flow'
const CLOSE_EDITOR_TRAY = 'automation/close-editor-tray'
const GET_NODE_TYPES = 'automation/get-node-types'
const GET_PALETTE = 'automation/get-palette'
const LIST_CONFIG_NODES = 'automation/list-config-nodes'
const OPEN_PALETTE_MANAGER = 'automation/open-palette-manager'
const MANAGE_GROUPS = 'automation/manage-groups'
const ERROR_CODES = Object.freeze({
GROUP_OPERATION_REQUIRED: 'GROUP_OPERATION_REQUIRED',
FORBIDDEN_PROPERTY: 'FORBIDDEN_PROPERTY'
})
const LINK_NODE_TYPES = ['link in', 'link out', 'link call']
/**
* @typedef {SELECT_NODES
* |GET_NODES
* |EDIT_NODE
* |SEARCH
* |ADD_FLOW_TAB
* |UPDATE_NODES
* |SHOW_WORKSPACE
* |GET_FLOW
* |LIST_WORKSPACES
* |CLOSE_SEARCH
* |CLOSE_TYPE_SEARCH
* |CLOSE_ACTION_LIST
* |ADD_TAB
* |REMOVE_TAB
* |ADD_NODES
* |REMOVE_NODES
* |SET_WIRES
* |SET_LINKS
* |IMPORT_FLOW
* |CLOSE_EDITOR_TRAY
* |GET_NODE_TYPES
* |GET_PALETTE
* |LIST_CONFIG_NODES
* |OPEN_PALETTE_MANAGER
* |MANAGE_GROUPS} ExpertAutomationsActionsEnum
*/
export class ExpertAutomations extends ExpertActionsInterface {
actions = Object.freeze({
[GET_NODES]: {
params: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'The ID of a single node to retrieve. Can be used with `include` property to also retrieve nodes upstream, downstream, or connected to the specified node.'
},
ids: {
type: 'array',
items: {
type: 'string'
},
description: 'The IDs of multiple nodes to retrieve (alternative to `id` property)'
},
include: {
type: 'string',
enum: ['upstream', 'downstream', 'connected'],
description: 'If `id` is provided, `include` can be used to specify whether to also retrieve nodes upstream, downstream, or connected to the specified node. Not applicable if `ids` property is used.'
}
}
}
},
[SELECT_NODES]: {
params: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'The ID of a single node to select'
},
ids: {
type: 'array',
items: {
type: 'string'
},
description: 'The IDs of multiple nodes to select (alternative to `id` property)'
},
include: {
type: 'string',
enum: ['upstream', 'downstream', 'connected'],
description: 'If `id` is provided, `include` can be used to specify whether to also select nodes upstream, downstream, or connected to the specified node. Not applicable if `ids` property is used.'
}
}
}
},
[EDIT_NODE]: {
params: {
type: 'object',
properties: {
id: {
type: 'string',
description: 'The ID of the node to edit'
}
}
}
},
[SEARCH]: {
params: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'The search query string'
},
interactive: {
type: 'boolean',
description: 'Whether the search is interactive (e.g. show the search box UI)'
}
}
}
},
[ADD_FLOW_TAB]: {
params: {
type: 'object',
properties: {
title: {
type: 'string',
description: 'Optional title for the new flow tab'
}
}
}
},
[UPDATE_NODES]: {
params: {
type: 'object',
properties: {
nodes: {
type: 'array',
description: 'Array of node updates to apply sequentially',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'ID of the node to update' },
updates: {
type: 'array',
description: 'List of property updates. For full replacement omit start/end. For line-based edits provide start (and end for replace/delete). All line numbers reference the original content before any updates in the list are applied.',
items: {
type: 'object',
properties: {
property: { type: 'string', description: 'Dot-separated property path (e.g. "func", "name", "rules.0.to")' },
op: {
type: 'string',
enum: ['replace', 'insert', 'delete'],
description: 'replace: set property value (omit start/end) or replace lines (with start/end). insert: insert lines before start. delete: remove lines start..end.'
},
start: { type: 'number', description: 'Start line (1-indexed, inclusive). Omit for full property replacement.' },
end: { type: 'number', description: 'End line (1-indexed, inclusive). Required for line-based replace and delete.' },
content: { description: 'Value to set. For full replacement: any JSON type. For line-based edits: a string.' }
},
required: ['property', 'op']
}
}
},
required: ['id']
}
}
},
required: ['nodes']
}
},
[SHOW_WORKSPACE]: {
params: {
type: 'object',
properties: {
id: { type: 'string', description: 'ID of the flow tab or subflow to navigate to' }
},
required: ['id']
}
},
[GET_FLOW]: {
params: {
type: 'object',
properties: {
type: {
type: 'string',
description: 'Optional parameter to filter by node type. Exclude this parameter to get all node types.'
},
tabId: {
type: 'string',
description: 'Optional parameter to only get nodes on a specific workspace. Exclude this parameter to get node from all workspaces.'
},
full: {
type: 'boolean',
description: 'When true, returns the raw node objects instead of condensed summaries.'
}
}
}
},
[LIST_WORKSPACES]: {
params: null
},
[CLOSE_SEARCH]: { params: null },
[CLOSE_TYPE_SEARCH]: { params: null },
[CLOSE_ACTION_LIST]: { params: null },
[ADD_TAB]: {
params: {
type: 'object',
properties: {
id: { type: 'string', description: 'Tab ID — auto-generated if omitted' },
label: { type: 'string', description: 'Tab label' },
disabled: { type: 'boolean', description: 'Create as disabled' },
info: { type: 'string', description: 'Tab notes' },
env: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Environment variable name' },
value: { type: 'string', description: 'Environment variable value' },
type: {
type: 'string',
enum: ['str', 'num', 'bool', 'json', 'env', 'cred', 'jsonata'],
description: 'Environment variable type'
}
},
required: ['name', 'value', 'type']
},
description: 'Environment variables'
}
},
required: ['label']
}
},
[REMOVE_TAB]: {
params: {
type: 'object',
properties: {
id: { type: 'string', description: 'ID of the tab to remove' }
},
required: ['id']
}
},
[ADD_NODES]: {
params: {
type: 'object',
properties: {
nodes: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', description: 'Unique node ID' },
type: { type: 'string', description: 'Node type identifier' },
x: { type: 'number', description: 'Canvas x position' },
y: { type: 'number', description: 'Canvas y position' },
z: { type: 'string', description: 'Tab (workspace) ID — required for non-config nodes, omit for config nodes' }
},
additionalProperties: true,
required: ['id', 'type']
},
description: 'Array of node objects to add to the canvas'
},
generateIds: { type: 'boolean', description: 'Regenerate node IDs during import (use if IDs may conflict). Default: false', default: false }
},
required: ['nodes']
}
},
[REMOVE_NODES]: {
params: {
type: 'object',
properties: {
ids: {
type: 'array',
items: { type: 'string' },
description: 'IDs of nodes to remove from the canvas'
},
reconnectWires: {
type: 'boolean',
description: 'If true, reconnects wires around removed nodes (pass-through). Default: false'
}
},
required: ['ids']
}
},
[SET_WIRES]: {
params: {
type: 'object',
properties: {
mode: { type: 'string', enum: ['add', 'remove'], description: 'Whether to add or remove a wire' },
source: { type: 'string', description: 'Source node ID' },
output: { type: 'number', description: 'Source output port index (0-based)' },
target: { type: 'string', description: 'Target node ID' }
},
required: ['mode', 'source', 'target']
}
},
[SET_LINKS]: {
params: {
type: 'object',
properties: {
mode: { type: 'string', enum: ['add', 'remove'], description: 'Whether to add or remove a virtual link between link nodes' },
source: { type: 'string', description: 'Source link node ID (link out or link call)' },
target: { type: 'string', description: 'Target link node ID (link in)' }
},
required: ['mode', 'source', 'target']
}
},
[IMPORT_FLOW]: {
params: {
type: 'object',
properties: {
flow: {
type: ['string', 'array'],
description: 'Flow JSON string or array to import onto the canvas'
},
addFlowTab: {
type: 'boolean',
description: 'Whether to create a new tab for the imported nodes (true) or import into the current tab (false). Default: false'
},
generateIds: {
type: 'boolean',
description: 'Whether to regenerate node IDs during import. Default: true.',
default: true
}
},
required: ['flow']
}
},
[CLOSE_EDITOR_TRAY]: {
params: null
},
[GET_NODE_TYPES]: {
params: {
type: 'object',
required: ['types'],
properties: {
types: {
type: 'array',
items: { type: 'string' },
minItems: 1,
description: 'One or more node type identifiers to look up (e.g. ["inject", "function", "ui-text"])'
}
}
}
},
[GET_PALETTE]: {
params: {
type: 'object',
properties: {
typedModules: {
type: 'array',
items: { type: 'string' },
description: 'Module names that have pre-built schemas. When provided, each palette entry includes a hasSchema flag.'
}
}
}
},
[LIST_CONFIG_NODES]: {
params: {
type: 'object',
properties: {
type: {
type: 'string',
description: 'Optional filter by config node type (e.g. "ui-base", "mqtt-broker")'
},
tabId: {
type: 'string',
description: 'Scope filter: "global" for config nodes not attached to any tab, a tab ID for config nodes scoped to that tab, or omit to return all'
}
}
}
},
[OPEN_PALETTE_MANAGER]: {
params: {
type: 'object',
properties: {
view: {
type: 'string',
enum: ['nodes', 'install'],
default: 'install',
description: 'Which tab to show in the palette manager'
},
filter: {
type: 'string',
description: 'Optional package name or search term to pre-filter the palette manager'
}
}
}
},
[MANAGE_GROUPS]: {
params: {
type: 'object',
properties: {
operations: {
type: 'array',
items: {
type: 'object',
properties: {
op: {
type: 'string',
enum: ['create', 'update', 'manage-members', 'delete'],
description: 'Operation to perform'
},
id: {
type: 'string',
description: 'Group ID (required for update, manage-members, and delete)'
},
nodeIds: {
type: 'array',
items: { type: 'string' },
description: 'Node IDs: required for create and manage-members'
},
name: {
type: 'string',
description: 'Group display name (optional for create and update)'
},
mode: {
type: 'string',
enum: ['add', 'remove'],
description: 'manage-members only — "add" moves nodes into the group, "remove" takes them out'
},
style: {
type: 'object',
description: 'Visual style: stroke/border-color, fill, color, stroke-opacity, fill-opacity, label, label-position',
properties: {
stroke: { type: 'string' },
'border-color': { type: 'string' },
fill: { type: 'string' },
color: { type: 'string' },
'stroke-opacity': { type: 'number' },
'fill-opacity': { type: 'number' },
label: { type: 'boolean' },
'label-position': { type: 'string', enum: ['nw', 'n', 'ne', 'sw', 's', 'se'] }
}
}
},
required: ['op']
},
description: 'Array of group operations to execute sequentially'
}
},
required: ['operations']
}
}
})
/**
* Get node or nodes by id
* @param {string|string[]} nodeId - the id or ids of the nodes to retrieve
* @param {'upstream'|'downstream'|'connected'|null} include - if provided, should be one of 'upstream', 'downstream', or 'connected', and will select nodes. Only valid if a single node is being requested.
* @returns the nodes that were retrieved
*/
getNodes (nodeId, include) {
if (typeof nodeId === 'string') {
// user is requesting a single node. This mode supports getting connected nodes in a certain direction if include is provided
const node = this.RED.nodes.node(nodeId)
if (!node) {
return null
}
switch (include) {
case 'upstream':
return this.RED.nodes.getAllFlowNodes(node, 'up')
case 'downstream':
return this.RED.nodes.getAllFlowNodes(node, 'down')
case 'connected':
return this.RED.nodes.getAllFlowNodes(node)
}
return [node] // if include is not provided or is unrecognized, just return the single node in an array
} else if (Array.isArray(nodeId)) {
// user is requesting multiple specific nodes by id, include parameter is not applicable in this case
const nodes = nodeId.map(id => this.RED.nodes.node(id)).filter(n => n)
return nodes
}
return null
}
/**
* Select node or nodes on the workspace
* @param {string|string[]} nodeId - the id or ids of the nodes to select
* @param {'upstream'|'downstream'|'connected'|null} include - if provided, should be one of 'upstream', 'downstream', or 'connected', and will select nodes in addition to the provided nodeIds. Only valid if a single nodeId is provided.
* @returns the nodes that were selected
*/
selectNodes (nodeId, include) {
const nodes = this.getNodes(nodeId, include)
if (nodes && nodes.length > 0) {
let id = nodeId
if (Array.isArray(nodeId)) id = nodeId[0]
// call reveal to bring the selected nodes into view (or at least the first one)
this.RED.view.reveal(id, false) // no flash
this.RED.view.select({ nodes })
}
return nodes
}
/**
* Select and edit a single node on the workspace, or clear selection if no nodeId is provided
* Also, support upstream, downstream, and connected
* @param {string} nodeId - the id of the node to select and edit, or null/undefined to clear selection
* @returns the node that was selected and edited
* @throws if the node cannot be found or if the view is not in a state that allows selecting and editing nodes
*/
editNode (nodeId) {
if (this.RED.view.state() !== this.RED.state.DEFAULT) {
// only allow selecting and editing nodes when in default state (not editing another node, not in the middle of adding a connection, etc.)
throw new Error('Cannot select and edit node when not in default view state')
}
const selectedNodes = this.selectNodes([nodeId])
if (!selectedNodes || selectedNodes.length < 1) {
throw new Error(`Node with id ${nodeId} not found`)
}
this.RED.editor.edit(selectedNodes[0])
return selectedNodes[0]
}
search (query, interactive) {
if (interactive) {
this.RED.search.show(query)
} else {
const results = this.RED.search.search(query)
return results
}
}
/// Function extracted from Node-RED source `editor-client/src/js/ui/clipboard.js`
/**
* Performs the import of nodes, handling any conflicts that may arise
* @param {string|Object[]} nodesStr the nodes to import — either a JSON string or an array of node objects
* @param {object} importOptions
* @param {boolean} importOptions.addFlow whether to add the nodes to a new flow or to the current flow
* @param {boolean} [importOptions.notify=true] whether to show notifications for import success/failure (default true)
*/
importFlow (nodesStr, { addFlow = false, generateIds = true } = { addFlow: false, generateIds: true }) {
let newNodes = nodesStr
if (typeof nodesStr === 'string') {
try {
nodesStr = nodesStr.trim()
if (nodesStr.length === 0) {
return
}
newNodes = this.redOps.validateFlowString(nodesStr)
} catch (err) {
const e = new Error(this.RED._('clipboard.invalidFlow', { message: err.message }))
e.code = 'NODE_RED'
throw e
}
} else if (Array.isArray(nodesStr)) {
this.redOps.validateFlow(nodesStr)
} else {
throw new Error('importFlow expects a JSON string or an array of node objects')
}
// If importing onto the current tab (not creating a new one), check it's not locked
if (!addFlow && this.RED.workspaces.isLocked()) {
throw new Error('Cannot import into a locked workspace')
}
let imported
try {
imported = this.RED.view.importNodes(newNodes, { generateIds, addFlow, touchImport: true, applyNodeDefaults: true })
} catch (err) {
const e = new Error(`importNodes failed: ${err.message}`)
e.code = 'NODE_RED'
throw e
}
this.RED.nodes.dirty(true)
return imported
}
async addFlowTab (title) {
const cmd = () => {
if (!title) {
// if no title is specified, we let the core action perform this (auto naming)
// NOTE: core action does not support setting the flow name.
this.redOps.invoke('core:add-flow')
} else {
// As a title is provided, we have take a different approach: import a new flow with the label prop set.
const importOptions = { generateIds: true, addFlow: false, notify: false }
this.importFlow([{ id: '', type: 'tab', label: title, disabled: false, info: '', env: [] }], importOptions)
}
}
let newTab = await this.redOps.commandAndWait(cmd, 'flows:add')
if (!newTab) {
return null
}
if (Array.isArray(newTab)) {
newTab = newTab[0]
}
if (newTab && newTab.type === 'tab') {
// select the new tab
RED.workspaces.show(newTab.id)
}
return newTab
}
/**
* Move a node to a different tab, creating link in/out pairs for any cross-tab wires.
* Uses core:split-wires-with-junctions first only for fan-in (multiple inbound) or fan-out
* (multiple wires from the same output port), then core:split-wire-with-link-nodes for the
* remaining single wires. The node and its adjacent link nodes are moved to the target tab.
* @param {string} id - node ID to move
* @param {string} targetTabId - target tab ID
* @param {Set<string>} [coMovingIds] - IDs of all nodes being moved to the same tab in this batch;
* wires between co-moving nodes are kept intact (no splitting).
*/
_moveNodeToTab (id, targetTabId, coMovingIds = new Set()) {
const node = this.RED.nodes.node(id)
if (!node) throw new Error(`Node ${id} not found`)
this._assertWorkspaceExists(targetTabId)
this._assertWorkspaceNotLocked(targetTabId)
this._assertWorkspaceNotLocked(node.z)
// Show source tab so link nodes are created on the correct workspace
this.RED.workspaces.show(node.z)
// Get wire objects for the core actions (PORT_TYPE_INPUT = 1, PORT_TYPE_OUTPUT = 0)
let inboundWires = this.RED.nodes.getNodeLinks(id, 1)
let outboundWires = this.RED.nodes.getNodeLinks(id, 0)
// Exclude wires to/from co-moving nodes — both ends land on the same tab, no cross-tab split needed.
const externalInbound = inboundWires.filter(l => !coMovingIds.has(l.source.id))
const externalOutbound = outboundWires.filter(l => !coMovingIds.has(l.target.id))
// Add junctions only where there is actual fan-in or fan-out on a single port.
// Fan-in: multiple external wires arriving at the input. Fan-out: multiple external wires from the same output port.
let junctionNodes = []
const outboundByPort = {}
for (const w of externalOutbound) {
const port = w.sourcePort ?? 0
if (!outboundByPort[port]) outboundByPort[port] = []
outboundByPort[port].push(w)
}
const wiresNeedingJunction = [
...(externalInbound.length > 1 ? externalInbound : []),
...Object.values(outboundByPort).filter(g => g.length > 1).flat()
]
if (wiresNeedingJunction.length > 0) {
this.RED.actions.invoke('core:split-wires-with-junctions', {
wires: wiresNeedingJunction
})
// Re-fetch wires after junction insertion
inboundWires = this.RED.nodes.getNodeLinks(id, 1)
outboundWires = this.RED.nodes.getNodeLinks(id, 0)
// Collect newly created junction nodes (they stay on the source tab)
junctionNodes = [
...inboundWires.filter(l => l.source.type === 'junction').map(l => l.source),
...outboundWires.filter(l => l.target.type === 'junction').map(l => l.target)
]
}
// Existing adjacent link nodes (from a previous tab move) are moved directly — no new pair needed
const existingLinkIns = inboundWires.filter(l => l.source.type === 'link in' && !coMovingIds.has(l.source.id)).map(l => l.source)
const existingLinkOuts = outboundWires.filter(l => l.target.type === 'link out' && !coMovingIds.has(l.target.id)).map(l => l.target)
// Only split external wires where the adjacent node is not already a link node
const wiresToSplit = [
...inboundWires.filter(l => !coMovingIds.has(l.source.id) && l.source.type !== 'link in'),
...outboundWires.filter(l => !coMovingIds.has(l.target.id) && l.target.type !== 'link out')
]
const linkNodesToMove = [...existingLinkIns, ...existingLinkOuts]
if (wiresToSplit.length > 0) {
this.RED.view.select({ links: wiresToSplit })
this.RED.actions.invoke('core:split-wire-with-link-nodes')
// Collect the newly created link nodes adjacent to the moved node
const newInbound = this.RED.nodes.getNodeLinks(id, 1)
const newOutbound = this.RED.nodes.getNodeLinks(id, 0)
linkNodesToMove.push(...newInbound.map(l => l.source).filter(n => n.type === 'link in'))
linkNodesToMove.push(...newOutbound.map(l => l.target).filter(n => n.type === 'link out'))
}
for (const n of [node, ...linkNodesToMove]) {
this.RED.nodes.moveNodeToTab(n, targetTabId)
}
this.RED.nodes.dirty(true)
this.RED.view.redraw(true)
return { linkNodes: linkNodesToMove, junctionNodes }
}
/**
* Update properties of an existing node in place.
* Accepts a unified updates array where each item targets a property.
* Omit start/end for full replacement; provide start for line-based edits.
* @param {string} id - node ID
* @param {Array} updates - [{ property, op, start?, end?, content? }]
*/
async updateNode (id, updates) {
const properties = {}
const patches = []
for (const update of updates) {
if (typeof update.start === 'number') {
patches.push({
property: update.property,
op: update.op,
start: update.start,
...(update.end !== undefined ? { end: update.end } : {}),
...(update.content !== undefined ? { content: update.content } : {})
})
} else {
properties[update.property] = update.content
}
}
const hasProperties = Object.keys(properties).length > 0
const hasPatches = patches.length > 0
if (!hasProperties && !hasPatches) {
throw new Error('At least one of "properties" or "patches" must be provided')
}
const node = this.RED.nodes.node(id)
if (!node) throw new Error(`Node ${id} not found`)
const changes = {}
// Apply line-based patches first (before full property replacement)
// so that patches reference the original line numbers
if (hasPatches) {
this._applyPatches(node, patches, changes)
}
// Apply full property replacement
if (hasProperties) {
for (const key in properties) {
if (Object.prototype.hasOwnProperty.call(properties, key)) {
if (!(key in changes)) {
changes[key] = node[key]
}
}
}
Object.assign(node, properties)
}
const wasChanged = node.changed
this.RED.history.push({ t: 'edit', node, changes, changed: wasChanged, dirty: this.RED.nodes.dirty() })
node.changed = true
node.dirty = true
this.RED.nodes.dirty(true)
if (this.RED.editor?.validateNode) {
this.RED.editor.validateNode(node)
}
this.RED.view.updateActive()
this.RED.view.redraw()
this.RED.sidebar?.info?.refresh()
if (this.RED.view.state() !== this.RED.state?.DEFAULT) {
await this.closeEditorTray()
}
}
async getPalette (typedModules = null) {
const typedSet = !typedModules || !Array.isArray(typedModules) || !typedModules.length ? null : new Set(typedModules)
const palette = {}
const plugins = await $.ajax({
url: 'plugins',
method: 'GET',
headers: {
Accept: 'application/json'
}
})
const nodes = await $.ajax({
url: 'nodes',
method: 'GET',
headers: {
Accept: 'application/json'
}
})
plugins.forEach(plugin => {
if (Object.prototype.hasOwnProperty.call(palette, plugin.module)) {
palette[plugin.module].plugins.push(plugin)
} else {
const entry = {
version: plugin.version,
enabled: plugin.enabled,
module: plugin.module,
plugins: [
plugin
],
nodes: []
}
if (typedSet) entry.hasSchema = typedSet.has(plugin.module)
palette[plugin.module] = entry
}
})
nodes.forEach(node => {
if (Object.prototype.hasOwnProperty.call(palette, node.module)) {
palette[node.module].nodes.push(node)
} else {
const entry = {
version: node.version,
enabled: node.enabled,
module: node.module,
plugins: [],
nodes: [
node
]
}
if (typedSet) entry.hasSchema = typedSet.has(node.module)
palette[node.module] = entry
}
})
return palette
}
async closeEditorTray () {
if (this.RED.view.state() === this.RED.state?.DEFAULT) return true
const MAX = 10
let count = 0
while (count++ < MAX) {
// eslint-disable-next-line no-undef
$('.red-ui-tray-toolbar button#node-dialog-cancel').trigger('click')
await new Promise(resolve => setTimeout(resolve, 300))
if (this.RED.view.state() === this.RED.state?.DEFAULT) break
}
return this.RED.view.state() === this.RED.state?.DEFAULT
}
/**
* Apply line-based patches to string properties of a node.
* Supports dot-separated property paths (e.g. "rules.0.to" for rules[0].to).
* @param {Object} node - the node object to patch
* @param {Array} patches - array of { property, op, start, end?, content? }
* @param {Object} changes - map to record original values (for history/undo)
*/
_applyPatches (node, patches, changes) {
const grouped = {}
for (const patch of patches) {
if (!grouped[patch.property]) {
grouped[patch.property] = []
}
grouped[patch.property].push(patch)
}
for (const [property, targetPatches] of Object.entries(grouped)) {
const segments = property.split('.')
const topLevel = segments[0]
const hasPath = segments.length > 1
if (!(topLevel in changes)) {
changes[topLevel] = hasPath ? JSON.parse(JSON.stringify(node[topLevel])) : node[topLevel]
}
let targetValue, setTarget
if (hasPath) {
const resolved = this._resolvePath(node[topLevel], segments.slice(1).join('.'))
targetValue = resolved.parent[resolved.key]
setTarget = (v) => { resolved.parent[resolved.key] = v }
} else {
targetValue = node[property]
setTarget = (v) => { node[property] = v }
}
if (typeof targetValue !== 'string') {
throw new Error(`Property "${property}" is not a string (got ${targetValue === null ? 'null' : typeof targetValue})`)
}
// Auto-detect line separator: some properties use \t (e.g. inject JSONata)
const sep = targetValue.includes('\t') && !targetValue.includes('\n') ? '\t' : '\n'
const lines = targetValue.split(sep)
const lineCount = lines.length
// Validate all patches before applying any
for (const p of targetPatches) {
if (!Number.isInteger(p.start) || p.start < 1) {
throw new Error(`Patch "start" must be a positive integer (got ${p.start})`)
}
switch (p.op) {
case 'replace':
if (p.end == null) throw new Error('Patch op "replace" requires "end"')
if (!Number.isInteger(p.end) || p.end < 1) {
throw new Error(`Patch "end" must be a positive integer (got ${p.end})`)
}
if (p.start > p.end) throw new Error(`Invalid patch range: start ${p.start} > end ${p.end}`)
if (p.end > lineCount) {
throw new Error(`Patch "end" (${p.end}) exceeds line count (${lineCount}) for property "${property}"`)
}
if (p.content == null) throw new Error('Patch op "replace" requires "content"')
break
case 'insert':
if (p.start > lineCount + 1) {
throw new Error(`Insert position "start" (${p.start}) exceeds line count + 1 (${lineCount + 1}) for property "${property}"`)
}
if (p.content == null) throw new Error('Patch op "insert" requires "content"')
break
case 'delete':
if (p.end == null) throw new Error('Patch op "delete" requires "end"')
if (!Number.isInteger(p.end) || p.end < 1) {
throw new Error(`Patch "end" must be a positive integer (got ${p.end})`)
}
if (p.start > p.end) throw new Error(`Invalid patch range: start ${p.start} > end ${p.end}`)
if (p.end > lineCount) {
throw new Error(`Patch "end" (${p.end}) exceeds line count (${lineCount}) for property "${property}"`)
}
break
default:
throw new Error(`Unknown patch op "${p.op}"`)
}
}
// Check for overlapping ranges (replace/delete only; inserts don't consume lines)
const rangeOps = targetPatches.filter(p => p.op !== 'insert').sort((a, b) => a.start - b.start)
for (let i = 1; i < rangeOps.length; i++) {
const prev = rangeOps[i - 1]
const curr = rangeOps[i]
if (curr.start <= prev.end) {
throw new Error(
`Overlapping patches on property "${property}": ` +
`lines ${prev.start}-${prev.end} and ${curr.start}-${curr.end}`
)
}
}
// Sort for bottom-up application (highest line numbers first)
// so that earlier line numbers remain stable as we splice.
//
// Secondary sort for same `start`: replace/delete before insert.
// An insert at line N shifts everything below it, so if a replace/delete
// also targets line N, it must run first — otherwise it
// would hit the newly inserted content instead of the original line.
const descending = [...targetPatches].sort((a, b) => {
if (b.start !== a.start) return b.start - a.start
const aIsInsert = a.op === 'insert' ? 1 : 0
const bIsInsert = b.op === 'insert' ? 1 : 0
return aIsInsert - bIsInsert
})
for (const p of descending) {
switch (p.op) {
case 'replace': {
const replacementLines = p.content.split('\n')
lines.splice(p.start - 1, p.end - p.start + 1, ...replacementLines)
break
}
case 'insert': {
const insertLines = p.content.split('\n')
lines.splice(p.start - 1, 0, ...insertLines)
break
}
case 'delete':
lines.splice(p.start - 1, p.end - p.start + 1)
break
}
}
setTarget(lines.join(sep))
}
}
/**
* Resolve a dot-separated path within an object/array structure.
* Numeric segments index into arrays.
* @param {*} obj - root value to navigate from
* @param {string} path - dot-separated path (e.g. "0.to", "rules.2.expression")
* @returns {{ parent: *, key: string|number }} parent container and final key
*/
_resolvePath (obj, path) {
const segments = path.split('.')
let current = obj
for (let i = 0; i < segments.length - 1; i++) {
const seg = segments[i]
const idx = Number(seg)
current = Array.isArray(current) && Number.isInteger(idx) ? current[idx] : current[seg]
if (current == null) {
throw new Error(`Path segment "${seg}" resolved to ${current}`)
}
}
const lastSeg = segments[segments.length - 1]
const lastIdx = Number(lastSeg)
const key = Array.isArray(current) && Number.isInteger(lastIdx) ? lastIdx : lastSeg
return { parent: current, key }
}
/**
* Read the live canvas state (including undeployed edits) and return it.
* Uses Node-RED's built-in export to get the complete node set.
* @returns {Object[]} full flows array (tabs + nodes + config nodes)
*/
getFlow () {
return this.RED.nodes.createCompleteNodeSet({ credentials: false })
}
/**
* Navigate to a workspace tab, validating it exists first.
* @param {string} id - workspace ID to show
*/
showWorkspace (id) {
this._assertWorkspaceExists(id)
this.RED.workspaces.show(id)
}
/**
* Check if a workspace tab exists.
* @param {string} id - workspace ID to check
* @returns {boolean} true if the workspace exists, false otherwise
*/
hasWorkspace (id) {
return !!this.RED.nodes.workspace(id)
}
/**
* Throw if the workspace does not exist.
* @param {string} id - workspace ID
*/
_assertWorkspaceExists (id) {
if (!this.hasWorkspace(id)) throw new Error(`Workspace ${id} not found`)
}
/**
* Throw if the workspace tab is locked. No-op when tabId is falsy (e.g. config nodes).
* @param {string|null|undefined} tabId - workspace tab ID
*/
_assertWorkspaceNotLocked (tabId) {
if (!tabId) return
if (this.RED.workspaces.isLocked(tabId)) throw new Error(`Workspace ${tabId} is locked`)
}
closeSearch () { this.RED.search.hide() }
closeTypeSearch () {
// RED.typeSearch.hide() alone does NOT invoke the cancelCallback set by
// RED.view, which cleans up ghost nodes, drag lines, and resets mouse state.
// Dispatching ESC on the type-search input triggers NR4's keyboard handler
// (scope "red-ui-type-search") which calls both hide() and cancelCallback().
try {
const input = document.getElementById('red-ui-type-search-input')
if (input) {
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true }))
return
}
} catch (_) { /* Node.js test env or missing DOM */ }
this.RED.typeSearch.hide()
}
closeActionList () { this.RED.actionList.hide() }
/**
* Add a new flow tab with an explicit ID and configuration.
* @param {Object} tab - tab definition with id, label, disabled, info, env
*/
addTab (tab) {
if (tab.label == null) throw new Error('Tab label is required')
if (tab.id && (this.RED.nodes.node(tab.id) || this.RED.nodes.workspace(tab.id) || this.RED.nodes.subflow(tab.id))) {
throw new Error(`ID ${tab.id} already exists — provide a unique ID or omit to auto-generate`)
}
const ws = {
type: 'tab',
id: tab.id || this.RED.nodes.id(),
label: tab.label,
disabled: tab.disabled || false,
info: tab.info || '',
env: tab.env || []
}
this.RED.nodes.addWorkspace(ws)
this.RED.workspaces.add(ws)
this.RED.history.push({ t: 'add', workspaces: [ws], dirty: this.RED.nodes.dirty() })
this.RED.nodes.dirty(true)
this.showWorkspace(ws.id)
return this.RED.nodes.workspace(ws.id)
}
/**
* Remove an existing flow tab from the NR4 editor.
* @param {string} id - tab ID to remove
*/
removeTab (id) {
const ws = this.RED.nodes.workspace(id)
if (!ws) {
throw new Error(`Tab with id ${id} not found`)
}
if (ws.locked) {
throw new Error(`Tab ${id} is locked and cannot be removed`)
}
this.RED.workspaces.delete(ws)
}
/**
* Add one or more nodes to the live NR4 canvas.
* Delegates to RED.view.importNodes which handles node initialisation,
* history (undo/redo) and view updates internally.
* @param {Object[]} nodes - array of raw node objects (must include id, type; z required for non-config nodes)
* @param {Object} [options]
* @param {boolean} [options.generateIds=false] - regenerate node IDs during import
*/
addNodes (nodes, { generateIds = false } = {}) {
if (!nodes.length) throw new Error('nodes array must not be empty')
// Validate required fields and types
const prepared = nodes.map(rawNode => {
if (!rawNode.id) throw new Error('Node is missing required property: id')
if (!rawNode.type) throw new Error('Node is missing required property: type')
const def = this.RED.nodes.getType(rawNode.type)
if (!def) throw new Error(`Unknown node type: ${rawNode.type}`)
const isConfigNode = def.category === 'config'
if (!isConfigNode && !rawNode.z) throw new Error('Node is missing required property: z')
return { ...rawNode }
})
// Validate all target tabs exist and are not locked
const uniqueZs = [...new Set(prepared.map(n => n.z).filter(Boolean))]
for (const z of uniqueZs) {
this._assertWorkspaceExists(z)
this._assertWorkspaceNotLocked(z)
}
// Pre-import: reject if any node ID already exists on the canvas
if (!generateIds) {
const existing = prepared.filter(n => this.RED.nodes.node(n.id))
if (existing.length > 0) {
throw new Error(`Node ID(s) already exist: ${existing.map(n => n.id).join(', ')} — use generateIds: true to auto-assign new IDs`)
}
}
// importNodes places nodes on the active tab regardless of z — switch to each
// target tab before importing so nodes land on the correct workspace.
const originalActiveId = this.RED.workspaces.active()
const configNodes = prepared.filter(n => !n.z)
const byTab = new Map()
for (const node of prepared.filter(n => n.z)) {
if (!byTab.has(node.z)) byTab.set(node.z, [])
byTab.get(node.z).push(node)
}
if (configNodes.length > 0) {
this.RED.view.importNodes(configNodes, { generateIds, addFlow: false, notify: false, touchImport: true, applyNodeDefaults: true })
}
for (const [tabId, tabNodes] of byTab) {
this.RED.workspaces.show(tabId)
this.RED.view.importNodes(tabNodes, { generateIds, addFlow: false, notify: false, touchImpor