/** * Single canonical start node on the canvas — id and type follow workflow primary entry kind. */ import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas'; import type { NodeType } from '../../../../api/workflowApi'; import type { WorkflowEntryPoint } from '../../../../api/workflowApi'; import { getLabel } from '../shared/utils'; export const CANVAS_START_NODE_ID = 'start'; /** Primary entry is always the first invocation (gear configures index 0). */ export function getPrimaryEntry(invocations: WorkflowEntryPoint[] | undefined): WorkflowEntryPoint | undefined { return invocations?.[0]; } /** Kind of the primary entry (drives canvas node type) */ export function getPrimaryStartKind(invocations: WorkflowEntryPoint[] | undefined): string { return getPrimaryEntry(invocations)?.kind ?? 'manual'; } function entryTitle(entry: WorkflowEntryPoint | undefined, language: string): string { if (!entry?.title) return ''; const t = entry.title; if (typeof t === 'string') return t.trim(); const s = t[language] || t.de || t.en || Object.values(t)[0]; return (s != null ? String(s) : '').trim(); } export function mapKindToNodeType(kind: string): string { if (kind === 'form') return 'trigger.form'; if (kind === 'schedule') return 'trigger.schedule'; // Immer aktiv: zunächst Standard-Start; Listener (E-Mail, Webhook, …) folgt separat if (kind === 'always_on') return 'trigger.manual'; return 'trigger.manual'; } function categoryForKind(kind: string): 'on_demand' | 'always_on' { if (kind === 'manual' || kind === 'form') return 'on_demand'; return 'always_on'; } function titleForStartNode( kind: string, invocations: WorkflowEntryPoint[], nodeTypes: NodeType[], language: string ): string { const custom = entryTitle(getPrimaryEntry(invocations), language); if (custom) return custom; const nt = nodeTypes.find((n) => n.id === mapKindToNodeType(kind)); if (nt) return getLabel(nt.label, language); return 'Start'; } /** Rewire connections when replacing node ids */ function rewireConnections( connections: CanvasConnection[], fromId: string, toId: string ): CanvasConnection[] { if (fromId === toId) return connections; return connections.map((c) => ({ ...c, sourceId: c.sourceId === fromId ? toId : c.sourceId, targetId: c.targetId === fromId ? toId : c.targetId, })); } /** Deep-rewrite ref.nodeId in parameters (e.g. flow.ifElse condition.ref) */ function rewireRefInParams(params: unknown, fromIds: Set, toId: string): unknown { if (params == null) return params; if (typeof params === 'object' && params !== null && 'type' in params && 'nodeId' in params) { const obj = params as { type?: string; nodeId?: string; path?: unknown }; if (obj.type === 'ref' && typeof obj.nodeId === 'string' && fromIds.has(obj.nodeId)) { return { ...obj, nodeId: toId }; } } if (Array.isArray(params)) { return params.map((item) => rewireRefInParams(item, fromIds, toId)); } if (typeof params === 'object' && params !== null) { const out: Record = {}; for (const [k, v] of Object.entries(params)) { out[k] = rewireRefInParams(v, fromIds, toId); } return out; } return params; } /** Rewrite refs in all nodes' parameters when trigger id changes */ function rewireRefsInNodes( nodes: CanvasNode[], fromIds: Set, toId: string ): CanvasNode[] { if (fromIds.size === 0) return nodes; return nodes.map((n) => { const p = n.parameters; if (!p || typeof p !== 'object') return n; const next = rewireRefInParams(p, fromIds, toId); if (next === p) return n; return { ...n, parameters: next as Record }; }); } /** Remove duplicate trigger nodes; keep first, merge connections onto it */ function dedupeTriggers( nodes: CanvasNode[], connections: CanvasConnection[] ): { nodes: CanvasNode[]; connections: CanvasConnection[] } { const triggers = nodes.filter((n) => n.type.startsWith('trigger.')); if (triggers.length <= 1) return { nodes, connections }; const keep = triggers[0]; const removeIds = new Set(triggers.slice(1).map((n) => n.id)); let nextConn = connections; for (const rid of removeIds) { nextConn = rewireConnections(nextConn, rid, keep.id); } const newNodes = nodes.filter((n) => !removeIds.has(n.id)); return { nodes: newNodes, connections: nextConn }; } /** Normalize canonical id `start` and update type/labels from primary kind */ export function syncCanvasStartNode( nodes: CanvasNode[], connections: CanvasConnection[], invocations: WorkflowEntryPoint[], nodeTypes: NodeType[], language: string ): { nodes: CanvasNode[]; connections: CanvasConnection[] } { const kind = getPrimaryStartKind(invocations); const targetType = mapKindToNodeType(kind); const title = titleForStartNode(kind, invocations, nodeTypes, language); const nt = nodeTypes.find((n) => n.id === targetType); const inputs = nt?.inputs ?? 0; const outputs = nt?.outputs ?? 1; const triggerIdsBeforeDedupe = new Set(nodes.filter((n) => n.type.startsWith('trigger.')).map((n) => n.id)); let { nodes: ns, connections: cs } = dedupeTriggers(nodes, connections); let startIdx = ns.findIndex((n) => n.type.startsWith('trigger.')); if (startIdx === -1) { const newNode: CanvasNode = { id: CANVAS_START_NODE_ID, type: targetType, x: 100, y: 120, title, label: title, inputs, outputs, color: nt?.meta?.color as string | undefined, parameters: {}, }; ns = rewireRefsInNodes([newNode, ...ns], triggerIdsBeforeDedupe, CANVAS_START_NODE_ID); return { nodes: ns, connections: cs }; } const current = ns[startIdx]; const oldId = current.id; let nextConn = cs; if (oldId !== CANVAS_START_NODE_ID) { nextConn = rewireConnections(nextConn, oldId, CANVAS_START_NODE_ID); } ns = rewireRefsInNodes(ns, triggerIdsBeforeDedupe, CANVAS_START_NODE_ID); const updated: CanvasNode = { ...current, id: CANVAS_START_NODE_ID, type: targetType, title, label: title, inputs, outputs, color: nt?.meta?.color as string | undefined, parameters: targetType === current.type ? current.parameters ?? {} : preserveParametersForTypeSwitch(current, targetType), }; const nextNodes = [...ns]; nextNodes[startIdx] = updated; return { nodes: nextNodes, connections: nextConn }; } function preserveParametersForTypeSwitch(node: CanvasNode, newType: string): Record { const p = node.parameters ?? {}; if (newType === 'trigger.form' && p.formFields) return { formFields: p.formFields }; if (newType === 'trigger.schedule' && (p.cron || p.schedule)) { const out: Record = {}; if (p.cron != null) out.cron = p.cron; if (p.schedule != null) out.schedule = p.schedule; return out; } return {}; } /** Build invocations: replace primary (index 0), keep further entries (e.g. listener config later). */ export function buildInvocationsForPrimaryKind( kind: string, existing: WorkflowEntryPoint[] | undefined, titleDe: string ): WorkflowEntryPoint[] { const list = existing ?? []; const primaryId = list[0]?.id ?? (typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `ep-${Date.now()}`); const category = categoryForKind(kind); const primary: WorkflowEntryPoint = { id: primaryId, kind, category, enabled: true, title: { de: titleDe, en: titleDe, fr: titleDe }, description: {}, config: {}, }; const rest = list.slice(1).filter((x) => x.id !== primaryId); return [primary, ...rest]; }