import { SEMANTIC_EDGE_MAP, VALID_EDGE_TYPES } from '../lib/constants'; import type { EdgeType } from '../types'; export type ParsedTriplets = { edges: Array<{ from: string; to: string; label: string }>; nodeNames: Set; /** 出現在 from 但不出現在任何 to 的節點(事件源 / 起始點) */ sourceNodes: Set; /** 出現在 to 但不出現在任何 from 的節點(終點)*/ sinkNodes: Set; }; export type NodeRole = 'Input' | 'Component' | 'Output'; /** * 解析後的零件 URI * 支援格式: * component://validate_json * component://validate_json@stable * component://validate_json@pinned:v1 * workflow://wf_save_to_db * ui://u6u-btn * style://glow-effect */ export interface ResolvedComponentId { type: 'component' | 'workflow' | 'ui' | 'style'; canonicalId: string; stability: 'floating' | 'stable' | 'pinned'; pinnedVersion?: string; /** 原始 URI 字串 */ raw: string; } /** 解析零件 URI 協議 */ export function resolveComponentId(uri: string): ResolvedComponentId { const raw = uri.trim(); // 解析協議前綴 let type: ResolvedComponentId['type'] = 'component'; let rest = raw; if (raw.startsWith('component://')) { type = 'component'; rest = raw.slice('component://'.length); } else if (raw.startsWith('workflow://')) { type = 'workflow'; rest = raw.slice('workflow://'.length); } else if (raw.startsWith('ui://')) { type = 'ui'; rest = raw.slice('ui://'.length); } else if (raw.startsWith('style://')) { type = 'style'; rest = raw.slice('style://'.length); } // 解析穩定性標籤 // component://id@stable // component://id@pinned:v1 let canonicalId = rest; let stability: ResolvedComponentId['stability'] = 'floating'; let pinnedVersion: string | undefined; const atIdx = rest.indexOf('@'); if (atIdx > 0) { canonicalId = rest.slice(0, atIdx); const tag = rest.slice(atIdx + 1); if (tag === 'stable') { stability = 'stable'; } else if (tag.startsWith('pinned:')) { stability = 'pinned'; pinnedVersion = tag.slice('pinned:'.length); } } return { type, canonicalId, stability, pinnedVersion, raw }; } /** 解析 triplets 字串陣列,回傳節點與邊的結構 */ export function parseTriplets(rawTriplets: unknown[]): ParsedTriplets | null { const edges: Array<{ from: string; to: string; label: string }> = []; const nodeNames = new Set(); const fromSet = new Set(); const toSet = new Set(); for (const line of rawTriplets) { if (typeof line !== 'string') continue; const parts = line.split('>>').map((s: string) => s.trim()); if (parts.length !== 3) continue; const [from, action, to] = parts; edges.push({ from, to, label: action }); nodeNames.add(from); nodeNames.add(to); fromSet.add(from); toSet.add(to); } if (nodeNames.size === 0) return null; const sourceNodes = new Set([...fromSet].filter(n => !toSet.has(n))); const sinkNodes = new Set([...toSet].filter(n => !fromSet.has(n))); return { edges, nodeNames, sourceNodes, sinkNodes }; } /** 保留字節點名稱 — 明確宣告為 Input 或 Output 端點 */ const INPUT_NAMES = new Set(['input', 'trigger', 'webhook', 'start']); const OUTPUT_NAMES = new Set(['output', 'result', 'end', 'done']); /** 根據節點在圖中的位置決定其 type * * 規則: * - 名稱在 INPUT_NAMES → Input(無論位置) * - 名稱在 OUTPUT_NAMES → Output(無論位置) * - sourceNode(只出現在 from)且名稱不在 INPUT_NAMES → Component(例如 cron 作為觸發源) * - sinkNode(只出現在 to)且名稱不在 OUTPUT_NAMES → Component(最常見情況:最後一個實際零件) * - 其他中間節點 → Component */ export function resolveNodeRole(name: string, parsed: ParsedTriplets): NodeRole { if (INPUT_NAMES.has(name.toLowerCase())) return 'Input'; if (OUTPUT_NAMES.has(name.toLowerCase())) return 'Output'; if (parsed.sourceNodes.has(name)) return 'Input'; return 'Component'; } /** 將 edge label 轉換為合法 EdgeType * 優先序:VALID_EDGE_TYPES(完整匹配)→ SEMANTIC_EDGE_MAP(語意別名)→ 預設 PIPE */ export function toEdgeType(label: string): EdgeType { const upper = label.toUpperCase(); if (VALID_EDGE_TYPES.has(upper)) return upper as EdgeType; return (SEMANTIC_EDGE_MAP[label] ?? SEMANTIC_EDGE_MAP[upper] ?? 'PIPE') as EdgeType; }