mirror of
https://github.com/nocodb/nocodb.git
synced 2026-05-01 02:16:48 +00:00
293 lines
9.2 KiB
TypeScript
293 lines
9.2 KiB
TypeScript
import type { Edge, Node } from '@vue-flow/core'
|
|
import type { WorkflowNodeDefinition, WorkflowType } from 'nocodb-sdk'
|
|
import { GeneralNodeID, INIT_WORKFLOW_NODES } from 'nocodb-sdk'
|
|
import { generateRandomUUID } from '~/utils/generateName'
|
|
|
|
/**
|
|
* Filter nodes and edges based on edit permission
|
|
* Removes Plus and Trigger nodes when user doesn't have edit permission
|
|
*/
|
|
const filterNodesByPermission = (nodes: Array<Node>, edges: Array<Edge>, hasEditPermission: boolean) => {
|
|
if (hasEditPermission) {
|
|
return { nodes, edges }
|
|
}
|
|
|
|
// Filter out Plus and Trigger nodes when user doesn't have edit permission
|
|
const filteredNodes = nodes.filter((node) => node.type !== GeneralNodeID.PLUS && node.type !== GeneralNodeID.TRIGGER)
|
|
|
|
// Get IDs of filtered out nodes
|
|
const filteredNodeIds = new Set(
|
|
nodes.filter((node) => node.type === GeneralNodeID.PLUS || node.type === GeneralNodeID.TRIGGER).map((n) => n.id),
|
|
)
|
|
|
|
// Filter out edges connected to filtered nodes
|
|
const filteredEdges = edges.filter((edge) => !filteredNodeIds.has(edge.source) && !filteredNodeIds.has(edge.target))
|
|
|
|
return { nodes: filteredNodes, edges: filteredEdges }
|
|
}
|
|
|
|
/**
|
|
* Get source nodes/edges based on permission
|
|
* Don't use draft if user doesn't have edit permission
|
|
*/
|
|
const getSourceNodesAndEdges = (workflow: WorkflowType, hasEditPermission: boolean) => {
|
|
const sourceNodes = hasEditPermission
|
|
? workflow?.draft?.nodes || workflow?.nodes || INIT_WORKFLOW_NODES
|
|
: workflow?.nodes || INIT_WORKFLOW_NODES
|
|
|
|
const sourceEdges = hasEditPermission ? workflow?.draft?.edges || workflow?.edges || [] : workflow?.edges || []
|
|
|
|
return { sourceNodes: sourceNodes as Array<Node>, sourceEdges: sourceEdges as Array<Edge> }
|
|
}
|
|
|
|
const generateUniqueNodeId = (nodes: Node[]): string => {
|
|
let candidateId = generateRandomUUID()
|
|
|
|
// Keep incrementing until we find an ID that doesn't exist
|
|
while (nodes.some((n) => n.id === candidateId)) {
|
|
candidateId = generateRandomUUID()
|
|
}
|
|
|
|
return candidateId
|
|
}
|
|
|
|
/**
|
|
* Generate a unique trigger ID for webhook triggers
|
|
* Format: trg_{8 alphanumeric characters}
|
|
*/
|
|
const generateTriggerId = (): string => {
|
|
const uuid = generateRandomUUID()
|
|
// Use first 8 characters of UUID (removing hyphens)
|
|
const shortId = uuid.replace(/-/g, '').substring(0, 8)
|
|
return `trg_${shortId}`
|
|
}
|
|
|
|
interface UIWorkflowNodeDefinition extends WorkflowNodeDefinition {
|
|
input?: number
|
|
output?: number
|
|
}
|
|
|
|
function transformNode(backendNode: WorkflowNodeDefinition): UIWorkflowNodeDefinition {
|
|
const inputPorts = backendNode.ports.filter((p: any) => p.direction === 'input')
|
|
const outputPorts = backendNode.ports.filter((p: any) => p.direction === 'output')
|
|
|
|
return {
|
|
...backendNode,
|
|
input: inputPorts.length > 0 ? inputPorts.length : undefined,
|
|
output: outputPorts.length > 0 ? outputPorts.length : undefined,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Find all parent nodes (upstream nodes) for a given node
|
|
* @param nodeId - The node ID to find parents for
|
|
* @param edges - All edges in the workflow
|
|
* @returns array of parent node IDs in execution order
|
|
*/
|
|
const findAllParentNodes = (nodeId: string, edges: Edge[]): string[] => {
|
|
const parents: string[] = []
|
|
const visited = new Set<string>()
|
|
|
|
const traverse = (currentId: string) => {
|
|
if (visited.has(currentId)) return
|
|
visited.add(currentId)
|
|
|
|
// Find edges that point to this node
|
|
const parentEdges = edges.filter((edge) => edge.target === currentId)
|
|
|
|
for (const edge of parentEdges) {
|
|
if (edge.source && edge.source !== currentId) {
|
|
// First traverse to parents of this parent (to maintain execution order)
|
|
traverse(edge.source)
|
|
// Then add this parent
|
|
if (!parents.includes(edge.source)) {
|
|
parents.push(edge.source)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
traverse(nodeId)
|
|
return parents
|
|
}
|
|
|
|
/**
|
|
* Recursively prefix all variable keys (including children) with the node reference
|
|
*/
|
|
const prefixVariableKeysRecursive = (variable: any, prefix: string): any => {
|
|
return {
|
|
...variable,
|
|
key: `${prefix}.${variable.key}`,
|
|
children: variable.children?.map((child: any) => prefixVariableKeysRecursive(child, prefix)),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate a unique node title based on the node type title
|
|
* E.g., 'NocoDB', 'NocoDB1', 'NocoDB2', etc.
|
|
*/
|
|
const generateUniqueNodeTitle = (nodeMeta: UIWorkflowNodeDefinition, nodes: Node[]): string => {
|
|
const baseTitle = nodeMeta.title
|
|
|
|
// Get all existing node titles that start with this base title
|
|
const existingTitles = nodes.map((n) => n.data?.title).filter((title): title is string => typeof title === 'string')
|
|
|
|
// Check if base title is available (without number)
|
|
if (!existingTitles.includes(baseTitle)) {
|
|
return baseTitle
|
|
}
|
|
|
|
// Find the highest number used with this base title
|
|
let maxNumber = 0
|
|
const regex = new RegExp(`^${baseTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)$`)
|
|
|
|
existingTitles.forEach((title) => {
|
|
const match = title.match(regex)
|
|
if (match && match[1]) {
|
|
const num = parseInt(match[1], 10)
|
|
if (num > maxNumber) {
|
|
maxNumber = num
|
|
}
|
|
}
|
|
})
|
|
|
|
// Return the next available number
|
|
return `${baseTitle}${maxNumber + 1}`
|
|
}
|
|
|
|
const findAllChildNodes = (nodeId: string, edges: Edge[]): Set<string> => {
|
|
const children = new Set<string>()
|
|
const visited = new Set<string>()
|
|
|
|
const traverse = (currentId: string) => {
|
|
if (visited.has(currentId)) return
|
|
visited.add(currentId)
|
|
|
|
const childEdges = edges.filter((edge) => edge.source === currentId)
|
|
|
|
for (const edge of childEdges) {
|
|
if (edge.target) {
|
|
children.add(edge.target)
|
|
traverse(edge.target)
|
|
}
|
|
}
|
|
}
|
|
|
|
traverse(nodeId)
|
|
return children
|
|
}
|
|
|
|
/**
|
|
* Find which output port from an iterate node leads to a target node
|
|
* Recursively follows edges to determine the port path
|
|
* @param iterateNodeId - The iterate node ID
|
|
* @param targetNodeId - The target node ID we're trying to reach
|
|
* @param edges - All edges in the workflow
|
|
* @returns The port ID ('body' or 'output') or null if no path found
|
|
*/
|
|
const findIterateNodePortForPath = (iterateNodeId: string, targetNodeId: string, edges: Edge[]): string | null => {
|
|
// Find all edges from the iterate node
|
|
const iterateEdges = edges.filter((e) => e.source === iterateNodeId)
|
|
|
|
// For each output port from the iterate node
|
|
for (const edge of iterateEdges) {
|
|
const portId = edge.sourceHandle
|
|
|
|
// Check if this edge leads to the target node (directly or indirectly)
|
|
const visited = new Set<string>()
|
|
const queue = [edge.target]
|
|
|
|
while (queue.length > 0) {
|
|
const currentNodeId = queue.shift()!
|
|
|
|
if (currentNodeId === targetNodeId) {
|
|
// Found a path from this port to the target node
|
|
return portId as string
|
|
}
|
|
|
|
if (visited.has(currentNodeId)) continue
|
|
visited.add(currentNodeId)
|
|
|
|
// Add all child nodes to the queue
|
|
const childEdges = edges.filter((e) => e.source === currentNodeId)
|
|
for (const childEdge of childEdges) {
|
|
queue.push(childEdge.target)
|
|
}
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* Update variable references in a string when a node is renamed
|
|
* Replaces $('oldName') with $('newName') and $("oldName") with $("newName")
|
|
* @param content - The string content to update
|
|
* @param oldTitle - The old node title
|
|
* @param newTitle - The new node title
|
|
* @returns Updated string with replaced variable references
|
|
*/
|
|
const updateVariableReferences = (content: string, oldTitle: string, newTitle: string): string => {
|
|
if (!ncIsString(content)) return content
|
|
|
|
// Escape special regex characters in both titles
|
|
const escapedOldTitle = oldTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
const escapedNewTitle = newTitle.replace(/\$/g, '$$$$') // Escape $ for replacement string
|
|
|
|
// Replace both single and double quoted references
|
|
const singleQuoteRegex = new RegExp(`\\$\\('${escapedOldTitle}'\\)`, 'g')
|
|
const doubleQuoteRegex = new RegExp(`\\$\\("${escapedOldTitle}"\\)`, 'g')
|
|
|
|
let updated = content.replace(singleQuoteRegex, `$('${escapedNewTitle}')`)
|
|
updated = updated.replace(doubleQuoteRegex, `$("${escapedNewTitle}")`)
|
|
|
|
return updated
|
|
}
|
|
/**
|
|
* Recursively update variable references in an object
|
|
* @param obj - The object to update
|
|
* @param oldTitle - The old node title
|
|
* @param newTitle - The new node title
|
|
* @returns Updated object with replaced variable references
|
|
*/
|
|
const updateVariableReferencesInObject = (obj: any, oldTitle: string, newTitle: string): any => {
|
|
if (ncIsNullOrUndefined(obj)) return obj
|
|
|
|
if (ncIsString(obj)) {
|
|
return updateVariableReferences(obj, oldTitle, newTitle)
|
|
}
|
|
|
|
if (ncIsArray(obj)) {
|
|
return obj.map((item) => updateVariableReferencesInObject(item, oldTitle, newTitle))
|
|
}
|
|
|
|
if (ncIsObject(obj)) {
|
|
const updated: any = {}
|
|
for (const key in obj) {
|
|
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
updated[key] = updateVariableReferencesInObject(obj[key], oldTitle, newTitle)
|
|
}
|
|
}
|
|
return updated
|
|
}
|
|
|
|
return obj
|
|
}
|
|
|
|
export {
|
|
filterNodesByPermission,
|
|
getSourceNodesAndEdges,
|
|
generateUniqueNodeId,
|
|
generateTriggerId,
|
|
transformNode,
|
|
findAllParentNodes,
|
|
prefixVariableKeysRecursive,
|
|
generateUniqueNodeTitle,
|
|
findAllChildNodes,
|
|
findIterateNodePortForPath,
|
|
updateVariableReferences,
|
|
updateVariableReferencesInObject,
|
|
}
|
|
|
|
export type { UIWorkflowNodeDefinition }
|