UNPKG

@flowfuse/nr-assistant

Version:
1,141 lines (1,083 loc) 89.2 kB
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