import type { DiagramEdge, DiagramIRV2, DiagramNode, DiagramPage } from "../../model/diagram-ir.js"; import { validateDiagramIR } from "../../model/diagram-ir.js"; export interface SyncConflict { pageId: string; id: string; field: string; base: unknown; manual: unknown; incoming: unknown } export interface SyncResult { ir: DiagramIRV2; added: string[]; removed: string[]; conflicts: SyncConflict[] } const equal = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); function conflictValue(pageId: string, id: string, field: keyof T, base: T, manual: T, incoming: T, conflicts: SyncConflict[]): unknown { const b = base[field]; const m = manual[field]; const n = incoming[field]; if (!equal(m, b) && !equal(n, b) && !equal(m, n)) { conflicts.push({ pageId, id, field: String(field), base: b, manual: m, incoming: n }); return structuredClone(m); } return structuredClone(!equal(m, b) ? m : n); } function fadedStyle(style: string | undefined, edge = false): string { const base = style ?? (edge ? "edgeStyle=orthogonalEdgeStyle;" : "whiteSpace=wrap;html=1;"); return `${base}${base.endsWith(";") ? "" : ";"}dashed=1;opacity=${edge ? 35 : 45};strokeColor=#b85450;`; } function syncPage(base: DiagramPage, manual: DiagramPage, incoming: DiagramPage, prune: boolean, conflicts: SyncConflict[], added: string[], removed: string[]): DiagramPage { const baseNodes = new Map(base.nodes.map((item) => [item.id, item])); const manualNodes = new Map(manual.nodes.map((item) => [item.id, item])); const incomingNodes = new Map(incoming.nodes.map((item) => [item.id, item])); const nodes: DiagramNode[] = []; for (const incomingNode of incoming.nodes) { const baseNode = baseNodes.get(incomingNode.id); const manualNode = manualNodes.get(incomingNode.id); if (!baseNode) { if (manualNode && !equal(manualNode, incomingNode)) { conflicts.push({ pageId: base.id, id: incomingNode.id, field: "$add", base: undefined, manual: structuredClone(manualNode), incoming: structuredClone(incomingNode) }); nodes.push(structuredClone(manualNode)); } else nodes.push(structuredClone(incomingNode)); added.push(incomingNode.id); continue; } if (!manualNode) { nodes.push(structuredClone(incomingNode)); continue; } const node = structuredClone(incomingNode); for (const field of ["label", "kind", "parentId", "properties", "provenance", "extensions"] as const) { const value = conflictValue(base.id, node.id, field, baseNode, manualNode, incomingNode, conflicts); if (value === undefined) delete node[field]; else (node as unknown as Record)[field] = value; } if (manualNode.geometry) node.geometry = structuredClone(manualNode.geometry); else delete node.geometry; if (manualNode.style !== undefined) node.style = manualNode.style; else delete node.style; if (manualNode.width !== undefined) node.width = manualNode.width; if (manualNode.height !== undefined) node.height = manualNode.height; nodes.push(node); } for (const manualNode of manual.nodes) { if (incomingNodes.has(manualNode.id)) continue; if (!baseNodes.has(manualNode.id)) { nodes.push(structuredClone(manualNode)); continue; } removed.push(manualNode.id); if (!prune) nodes.push({ ...structuredClone(manualNode), style: fadedStyle(manualNode.style), properties: { ...(manualNode.properties ?? {}), lifecycleStatus: "removed" } }); } const keptNodeIds = new Set(nodes.map((node) => node.id)); const baseEdges = new Map(base.edges.map((item) => [item.id, item])); const manualEdges = new Map(manual.edges.map((item) => [item.id, item])); const incomingEdges = new Map(incoming.edges.map((item) => [item.id, item])); const edges: DiagramEdge[] = []; for (const incomingEdge of incoming.edges) { if (!keptNodeIds.has(incomingEdge.source) || !keptNodeIds.has(incomingEdge.target)) continue; const baseEdge = baseEdges.get(incomingEdge.id); const manualEdge = manualEdges.get(incomingEdge.id); if (!baseEdge) { if (manualEdge && !equal(manualEdge, incomingEdge)) { conflicts.push({ pageId: base.id, id: incomingEdge.id, field: "$add", base: undefined, manual: structuredClone(manualEdge), incoming: structuredClone(incomingEdge) }); edges.push(structuredClone(manualEdge)); } else edges.push(structuredClone(incomingEdge)); added.push(incomingEdge.id); continue; } if (!manualEdge) { edges.push(structuredClone(incomingEdge)); continue; } const edge = structuredClone(incomingEdge); for (const field of ["source", "target", "label", "kind", "properties", "provenance", "extensions"] as const) { const value = conflictValue(base.id, edge.id, field, baseEdge, manualEdge, incomingEdge, conflicts); if (value === undefined) delete edge[field]; else (edge as unknown as Record)[field] = value; } if (manualEdge.style !== undefined) edge.style = manualEdge.style; else delete edge.style; if (manualEdge.waypoints !== undefined) edge.waypoints = structuredClone(manualEdge.waypoints); else delete edge.waypoints; edges.push(edge); } for (const manualEdge of manual.edges) { if (incomingEdges.has(manualEdge.id)) continue; if (!baseEdges.has(manualEdge.id)) { if (keptNodeIds.has(manualEdge.source) && keptNodeIds.has(manualEdge.target)) edges.push(structuredClone(manualEdge)); continue; } removed.push(manualEdge.id); if (!prune && keptNodeIds.has(manualEdge.source) && keptNodeIds.has(manualEdge.target)) edges.push({ ...structuredClone(manualEdge), style: fadedStyle(manualEdge.style, true), properties: { ...(manualEdge.properties ?? {}), lifecycleStatus: "removed" } }); } return { ...structuredClone(incoming), nodes, edges, ...(manual.width !== undefined ? { width: manual.width } : {}), ...(manual.height !== undefined ? { height: manual.height } : {}) }; } export function syncDiagramIR(base: DiagramIRV2, manual: DiagramIRV2, incoming: DiagramIRV2, options: { prune?: boolean } = {}): SyncResult { validateDiagramIR(base); validateDiagramIR(manual); validateDiagramIR(incoming); const conflicts: SyncConflict[] = []; const added: string[] = []; const removed: string[] = []; const basePages = new Map(base.pages.map((page) => [page.id, page])); const manualPages = new Map(manual.pages.map((page) => [page.id, page])); const pages = incoming.pages.map((page) => { const basePage = basePages.get(page.id); const manualPage = manualPages.get(page.id); if (!basePage || !manualPage) { added.push(page.id); return structuredClone(page); } return syncPage(basePage, manualPage, page, options.prune === true, conflicts, added, removed); }); const incomingPageIds = new Set(incoming.pages.map((page) => page.id)); for (const manualPage of manual.pages) { if (incomingPageIds.has(manualPage.id)) continue; if (!basePages.has(manualPage.id)) { if (options.prune !== true) pages.push(structuredClone(manualPage)); continue; } removed.push(manualPage.id); if (options.prune !== true) pages.push({ ...structuredClone(manualPage), properties: { ...(manualPage.properties ?? {}), lifecycleStatus: "removed" }, }); } const ir: DiagramIRV2 = { ...structuredClone(incoming), pages }; validateDiagramIR(ir); return { ir, added: [...new Set(added)].sort(), removed: [...new Set(removed)].sort(), conflicts: conflicts.sort((a, b) => a.pageId.localeCompare(b.pageId) || a.id.localeCompare(b.id) || a.field.localeCompare(b.field)) }; }