import { decompressPageContent } from "@/packages/mcp-server/src/load-diagram.ts" /** What changed on the first page between two versions of a diagram */ export interface ChangeSummary { shapesAdded: number shapesRemoved: number shapesChanged: number edgesAdded: number edgesRemoved: number edgesChanged: number } export interface DiagramDiff { summary: ChangeSummary /** Ids of cells that were added or changed (for highlighting) */ touchedIds: string[] /** The diagram before had no shapes or connectors */ fromScratch: boolean } export const EMPTY_SUMMARY: ChangeSummary = { shapesAdded: 0, shapesRemoved: 0, shapesChanged: 0, edgesAdded: 0, edgesRemoved: 0, edgesChanged: 0, } interface CellInfo { isEdge: boolean signature: string } /** A page's mxGraphModel element, inflated when the page is compressed */ function modelOfPage(diagram: Element): Element | null { const model = diagram.querySelector("mxGraphModel") if (model) return model const inflated = decompressPageContent(diagram.textContent || "") if (!inflated) return null const inner = new DOMParser().parseFromString(inflated, "text/xml") return inner.querySelector("mxGraphModel") } function parse(xml: string): Document | null { if (!xml?.trim()) return null const doc = new DOMParser().parseFromString(xml, "text/xml") return doc.querySelector("parsererror") ? null : doc } /** The first page's mxGraphModel element, whatever wrapper the XML has */ function firstPageModel(xml: string): Element | null { const doc = parse(xml) if (!doc) return null const diagram = doc.querySelector("diagram") if (diagram) return modelOfPage(diagram) return doc.querySelector("mxGraphModel") ?? doc.documentElement } // Values draw.io leaves out when it saves: the model may write them const DEFAULT_VALUES: Record> = { mxCell: { vertex: "0", edge: "0", connectable: "1", visible: "1", collapsed: "0", style: "", }, mxGeometry: { x: "0", y: "0", width: "0", height: "0", relative: "0" }, mxPoint: { x: "0", y: "0" }, } // Numbers draw.io writes back in their shortest form ("40.0" is "40") const NUMBERS = new Set(["x", "y", "width", "height"]) /** A style as draw.io reads it: named styles in order, a later key wins */ function normalStyle(style: string): string { const named: string[] = [] const keys = new Map() for (const entry of style.split(";").map((e) => e.trim())) { if (!entry) continue const eq = entry.indexOf("=") if (eq < 0) named.push(entry) else keys.set(entry.slice(0, eq), entry.slice(eq + 1)) } const pairs = [...keys].map(([k, v]) => `${k}=${v}`).sort() return [...named, ...pairs].join(";") } /** * What a cell is, independent of how the XML was written: the same cell * saved by draw.io and written by the model can order attributes and style * entries differently, and draw.io drops values that are the default */ function signatureOf(node: Element): string { const defaults = DEFAULT_VALUES[node.tagName] ?? {} const attrs = Array.from(node.attributes) .flatMap((a) => { let value = a.value if (a.name === "style") value = normalStyle(value) else if (NUMBERS.has(a.name) && value.trim() !== "") { const n = Number(value) if (Number.isFinite(n)) value = String(n) } if (defaults[a.name] === value) return [] return [`${a.name}=${value}`] }) .sort() const children = Array.from(node.children).map(signatureOf) return `<${node.tagName} ${attrs.join(" ")}>${children.join("")}` } /** The cells of a page, layers included, with the element that holds each */ function cellsIn(model: Element | null) { if (!model) return [] return Array.from(model.getElementsByTagName("mxCell")).flatMap((cell) => { // Cells with links or custom data are wrapped; the wrapper holds the id const wrapper = cell.parentElement const isWrapped = wrapper?.tagName === "object" || wrapper?.tagName === "UserObject" const id = cell.getAttribute("id") || wrapper?.getAttribute("id") if (!id) return [] return [{ id, cell, node: isWrapped && wrapper ? wrapper : cell }] }) } /** Shapes and connectors of the first page */ function collectCells(xml: string): Map { const cells = new Map() for (const { id, cell, node } of cellsIn(firstPageModel(xml))) { if (id === "0" || id === "1") continue cells.set(id, { isEdge: cell.getAttribute("edge") === "1", signature: signatureOf(node), }) } return cells } export function diffDiagrams(beforeXml: string, afterXml: string): DiagramDiff { const before = collectCells(beforeXml) const after = collectCells(afterXml) const summary: ChangeSummary = { ...EMPTY_SUMMARY } const touchedIds: string[] = [] for (const [id, cell] of after) { const old = before.get(id) if (!old) { touchedIds.push(id) if (cell.isEdge) summary.edgesAdded++ else summary.shapesAdded++ } else if (old.signature !== cell.signature) { touchedIds.push(id) if (cell.isEdge) summary.edgesChanged++ else summary.shapesChanged++ } } for (const [id, cell] of before) { if (after.has(id)) continue if (cell.isEdge) summary.edgesRemoved++ else summary.shapesRemoved++ } return { summary, touchedIds, fromScratch: before.size === 0 } } // Where the view was scrolled to, saved with the page; not a change const VIEW_ATTRIBUTES = new Set(["dx", "dy"]) // A page setting written on one side only is draw.io's default being // filled in when the other side has that default; an empty list: any value // is a change. Settings not listed are not compared then. const PAGE_DEFAULTS: Record = { background: ["none"], backgroundImage: [], extFonts: [], math: ["0"], shadow: ["0"], page: ["1"], pageScale: ["1"], // Letter or A4, after the browser's language pageWidth: ["850", "827"], pageHeight: ["1100", "1169"], grid: ["1"], gridSize: ["10"], guides: ["1"], tooltips: ["1"], connect: ["1"], arrows: ["1"], fold: ["1"], } function samePageSettings(a: Element, b: Element): boolean { const names = new Set( [...Array.from(a.attributes), ...Array.from(b.attributes)].map( (attr) => attr.name, ), ) for (const name of names) { if (VIEW_ATTRIBUTES.has(name)) continue const va = a.getAttribute(name) const vb = b.getAttribute(name) if (va !== null && vb !== null) { if (va !== vb) return false continue } const defaults = PAGE_DEFAULTS[name] if (defaults && !defaults.includes((va ?? vb) as string)) return false } return true } /** * Every cell of a page, layers included, and the order of each parent's * children (which shape is in front) */ function contentOf(model: Element | null) { const cells = new Map() const children = new Map() for (const { id, cell, node } of cellsIn(model)) { cells.set(id, signatureOf(node)) const parent = cell.getAttribute("parent") ?? "" children.set(parent, [...(children.get(parent) ?? []), id]) } return { cells, children } } function sameContent(a: Element | null, b: Element | null): boolean { const ca = contentOf(a) const cb = contentOf(b) if (ca.cells.size !== cb.cells.size) return false for (const [id, signature] of ca.cells) { if (cb.cells.get(id) !== signature) return false } for (const [parent, ids] of ca.children) { if (cb.children.get(parent)?.join("\n") !== ids.join("\n")) { return false } } return true } /** * Whether two documents hold the same pages (names, cells, their order) * and page settings */ export function isSameDocument(a: string, b: string): boolean { const pagesOf = (xml: string) => { const doc = parse(xml) if (!doc) return null const diagrams = Array.from(doc.getElementsByTagName("diagram")) if (diagrams.length === 0) { return [{ name: null, model: doc.querySelector("mxGraphModel") }] } return diagrams.map((d) => ({ name: d.getAttribute("name"), model: modelOfPage(d), })) } const pagesA = pagesOf(a) const pagesB = pagesOf(b) if (!pagesA || !pagesB) return a === b if (pagesA.length !== pagesB.length) return false return pagesA.every((pageA, i) => { const pageB = pagesB[i] if (pageA.name !== null && pageB.name !== null) { if (pageA.name !== pageB.name) return false } if (pageA.model && pageB.model) { if (!samePageSettings(pageA.model, pageB.model)) return false } return sameContent(pageA.model, pageB.model) }) }