114 lines
7.5 KiB
TypeScript
114 lines
7.5 KiB
TypeScript
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<T extends DiagramNode | DiagramEdge>(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<DiagramNode>(base.id, node.id, field, baseNode, manualNode, incomingNode, conflicts);
|
|
if (value === undefined) delete node[field]; else (node as unknown as Record<string, unknown>)[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<DiagramEdge>(base.id, edge.id, field, baseEdge, manualEdge, incomingEdge, conflicts);
|
|
if (value === undefined) delete edge[field]; else (edge as unknown as Record<string, unknown>)[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)) };
|
|
}
|