[verified] feat: add semantic diagram lifecycle
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import type { DiagramEdge, DiagramIRV2, DiagramNode, DiagramPage } from "../../model/diagram-ir.js";
|
||||
import { validateDiagramIR } from "../../model/diagram-ir.js";
|
||||
import { decodeDiagramContent, normalizeDrawioDocument } from "../drawio-parser/parser.js";
|
||||
|
||||
export interface PreservationEnvelope {
|
||||
host?: string;
|
||||
attributes?: Record<string, string>;
|
||||
holderTag?: string;
|
||||
holderAttributes?: Record<string, string>;
|
||||
childXml?: string[];
|
||||
geometryAttributes?: Record<string, string>;
|
||||
geometryChildXml?: string[];
|
||||
styleAbsent?: boolean;
|
||||
modelAttributes?: Record<string, string>;
|
||||
modelChildXml?: string[];
|
||||
unknownCells?: string[];
|
||||
}
|
||||
|
||||
export interface ImportLossReport {
|
||||
lossy: boolean;
|
||||
preserved: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ImportDrawioOptions {
|
||||
sourceIsXml?: boolean;
|
||||
}
|
||||
|
||||
const CELL_KNOWN = new Set(["id", "value", "label", "style", "vertex", "edge", "parent", "source", "target", "data-kind", "data-properties", "data-provenance", "data-extensions"]);
|
||||
const GEOMETRY_KNOWN = new Set(["x", "y", "width", "height", "relative", "as"]);
|
||||
const PAGE_KNOWN = new Set(["id", "name", "data-properties", "data-extensions"]);
|
||||
const MODEL_KNOWN = new Set(["pageWidth", "pageHeight", "grid", "gridSize", "page"]);
|
||||
const ROOT_KNOWN = new Set(["host", "data-version", "data-title", "data-theme", "data-provenance", "data-assets", "data-properties", "data-extensions"]);
|
||||
|
||||
function parseDocument(xml: string, expected: "mxfile" | "mxGraphModel"): Document {
|
||||
if (/<!DOCTYPE/i.test(xml)) throw new Error("Invalid XML: DOCTYPE is not allowed");
|
||||
const Parser = new JSDOM("<root/>", { contentType: "text/xml" }).window.DOMParser;
|
||||
const parsed = new Parser().parseFromString(xml, "text/xml");
|
||||
if (parsed.querySelector("parsererror") || parsed.documentElement.tagName !== expected) {
|
||||
throw new Error(`Invalid XML: expected complete <${expected}> document`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function serializeElement(element: Element): string {
|
||||
const XMLSerializer = new JSDOM("<root/>", { contentType: "text/xml" }).window.XMLSerializer;
|
||||
return new XMLSerializer().serializeToString(element);
|
||||
}
|
||||
|
||||
function attrs(element: Element, known: Set<string>): Record<string, string> | undefined {
|
||||
const result: Record<string, string> = {};
|
||||
for (const attribute of Array.from(element.attributes).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (!known.has(attribute.name)) result[attribute.name] = attribute.value;
|
||||
}
|
||||
return Object.keys(result).length ? result : undefined;
|
||||
}
|
||||
|
||||
function selectedAttrs(element: Element, names: Set<string>): Record<string, string> | undefined {
|
||||
const result: Record<string, string> = {};
|
||||
for (const name of names) if (element.hasAttribute(name)) result[name] = element.getAttribute(name)!;
|
||||
return Object.keys(result).length ? result : undefined;
|
||||
}
|
||||
|
||||
function jsonObject(element: Element, name: string): Record<string, unknown> | undefined {
|
||||
const raw = element.getAttribute(name);
|
||||
if (!raw) return undefined;
|
||||
try {
|
||||
const value: unknown = JSON.parse(raw);
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>;
|
||||
} catch {
|
||||
// Invalid semantic JSON remains preserved as an unknown attribute below.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function numberAttr(element: Element | null, name: string, fallback = 0): number {
|
||||
const raw = element?.getAttribute(name);
|
||||
if (raw === null || raw === undefined || raw === "") return fallback;
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value)) throw new Error(`Invalid geometry ${name}: ${raw}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function mergePreservation(extensions: Record<string, unknown> | undefined, envelope: PreservationEnvelope): Record<string, unknown> | undefined {
|
||||
const clean = Object.fromEntries(Object.entries(envelope).filter(([, value]) => value !== undefined && (!Array.isArray(value) || value.length > 0)));
|
||||
if (!Object.keys(clean).length) return extensions;
|
||||
return { ...(extensions ?? {}), $drawio: clean };
|
||||
}
|
||||
|
||||
function cellRows(model: Element): Array<{ holder: Element; cell: Element }> {
|
||||
const root = Array.from(model.children).find((child) => child.tagName === "root");
|
||||
if (!root) throw new Error("Draw.io page is missing <root>");
|
||||
const rows: Array<{ holder: Element; cell: Element }> = [];
|
||||
for (const child of Array.from(root.children)) {
|
||||
if (child.tagName === "mxCell") rows.push({ holder: child, cell: child });
|
||||
else if (child.tagName === "object" || child.tagName === "UserObject") {
|
||||
const cell = Array.from(child.children).find((nested) => nested.tagName === "mxCell");
|
||||
if (cell) rows.push({ holder: child, cell });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function parsePage(modelXml: string, pageElement: Element | undefined, index: number, report: ImportLossReport): DiagramPage {
|
||||
const document = parseDocument(modelXml, "mxGraphModel");
|
||||
const model = document.documentElement;
|
||||
const rows = cellRows(model);
|
||||
const nodeIds = new Set(rows.filter(({ cell }) => cell.getAttribute("vertex") === "1").map(({ holder, cell }) => holder.getAttribute("id") ?? cell.getAttribute("id") ?? ""));
|
||||
const root = Array.from(model.children).find((child) => child.tagName === "root")!;
|
||||
const knownElements = new Set(rows.map(({ holder }) => holder));
|
||||
const unknownCells = Array.from(root.children).filter((child) => !knownElements.has(child) && !["0", "1"].includes(child.getAttribute("id") ?? "")).map((child) => serializeElement(child));
|
||||
const nodes: DiagramNode[] = [];
|
||||
const edges: DiagramEdge[] = [];
|
||||
|
||||
for (const { holder, cell } of rows) {
|
||||
const id = holder.getAttribute("id") ?? cell.getAttribute("id") ?? "";
|
||||
if (!id || id === "0" || id === "1") continue;
|
||||
const semantic = holder === cell ? cell : holder;
|
||||
const geometry = Array.from(cell.children).find((child) => child.tagName === "mxGeometry") ?? null;
|
||||
const envelope: PreservationEnvelope = {
|
||||
attributes: attrs(cell, CELL_KNOWN),
|
||||
holderTag: holder === cell ? undefined : holder.tagName,
|
||||
holderAttributes: holder === cell ? undefined : attrs(holder, new Set(["id", "label", "value", "data-kind", "data-properties", "data-provenance", "data-extensions"])),
|
||||
childXml: Array.from(cell.children).filter((child) => child.tagName !== "mxGeometry").map((child) => serializeElement(child)),
|
||||
geometryAttributes: geometry ? selectedAttrs(geometry, new Set(["relative", "as"])) : undefined,
|
||||
geometryChildXml: geometry ? Array.from(geometry.children).filter((child) => !(cell.getAttribute("edge") === "1" && child.matches("Array[as='points']"))).map((child) => serializeElement(child)) : undefined,
|
||||
styleAbsent: cell.getAttribute("vertex") === "1" && !cell.hasAttribute("style") ? true : undefined,
|
||||
};
|
||||
for (const name of ["data-properties", "data-provenance", "data-extensions"]) {
|
||||
const owner = semantic.hasAttribute(name) ? semantic : cell;
|
||||
if (owner.hasAttribute(name) && jsonObject(owner, name) === undefined) {
|
||||
envelope.attributes = { ...(envelope.attributes ?? {}), [name]: owner.getAttribute(name)! };
|
||||
}
|
||||
}
|
||||
const geometryAttrs = geometry ? attrs(geometry, GEOMETRY_KNOWN) : undefined;
|
||||
if (geometryAttrs) envelope.attributes = { ...(envelope.attributes ?? {}), ...Object.fromEntries(Object.entries(geometryAttrs).map(([key, value]) => [`geometry:${key}`, value])) };
|
||||
const rawLabel = semantic.getAttribute("label") ?? semantic.getAttribute("value") ?? cell.getAttribute("value") ?? id;
|
||||
if (rawLabel.trim() === "") envelope.attributes = { ...(envelope.attributes ?? {}), "semantic:label": rawLabel };
|
||||
const extensions = mergePreservation(jsonObject(semantic, "data-extensions") ?? jsonObject(cell, "data-extensions"), envelope);
|
||||
const label = rawLabel.trim() === "" ? id : rawLabel;
|
||||
if (cell.getAttribute("vertex") === "1") {
|
||||
nodes.push({
|
||||
id,
|
||||
label,
|
||||
kind: semantic.getAttribute("data-kind") ?? cell.getAttribute("data-kind") ?? (nodeIds.has(id) && rows.some((row) => row.cell.getAttribute("parent") === id) ? "container" : "service"),
|
||||
...(cell.getAttribute("parent") && cell.getAttribute("parent") !== "1" ? { parentId: cell.getAttribute("parent")! } : {}),
|
||||
geometry: { x: numberAttr(geometry, "x"), y: numberAttr(geometry, "y"), width: numberAttr(geometry, "width"), height: numberAttr(geometry, "height") },
|
||||
...(cell.hasAttribute("style") ? { style: cell.getAttribute("style")! } : {}),
|
||||
...(jsonObject(semantic, "data-properties") ?? jsonObject(cell, "data-properties") ? { properties: jsonObject(semantic, "data-properties") ?? jsonObject(cell, "data-properties") } : {}),
|
||||
...(jsonObject(semantic, "data-provenance") ?? jsonObject(cell, "data-provenance") ? { provenance: jsonObject(semantic, "data-provenance") ?? jsonObject(cell, "data-provenance") } : {}),
|
||||
...(extensions ? { extensions } : {}),
|
||||
});
|
||||
} else if (cell.getAttribute("edge") === "1") {
|
||||
const source = cell.getAttribute("source") ?? "";
|
||||
const target = cell.getAttribute("target") ?? "";
|
||||
const missing = [source, target].filter((endpoint) => !nodeIds.has(endpoint));
|
||||
if (missing.length) throw new Error(`Unsupported dangling endpoint on edge ${id}: ${missing.join(", ")}`);
|
||||
const points = geometry ? Array.from(geometry.querySelectorAll("Array[as='points'] > mxPoint")).map((point) => ({ x: numberAttr(point, "x"), y: numberAttr(point, "y") })) : [];
|
||||
edges.push({
|
||||
id, source, target, label: rawLabel,
|
||||
kind: semantic.getAttribute("data-kind") ?? cell.getAttribute("data-kind") ?? "relation",
|
||||
...(cell.hasAttribute("style") ? { style: cell.getAttribute("style")! } : {}),
|
||||
...(points.length ? { waypoints: points } : {}),
|
||||
...(jsonObject(semantic, "data-properties") ?? jsonObject(cell, "data-properties") ? { properties: jsonObject(semantic, "data-properties") ?? jsonObject(cell, "data-properties") } : {}),
|
||||
...(jsonObject(semantic, "data-provenance") ?? jsonObject(cell, "data-provenance") ? { provenance: jsonObject(semantic, "data-provenance") ?? jsonObject(cell, "data-provenance") } : {}),
|
||||
...(extensions ? { extensions } : {}),
|
||||
});
|
||||
} else {
|
||||
unknownCells.push(serializeElement(holder));
|
||||
}
|
||||
}
|
||||
|
||||
const pageExtensions = mergePreservation(jsonObject(pageElement ?? model, "data-extensions"), {
|
||||
attributes: { ...(pageElement ? attrs(pageElement, PAGE_KNOWN) : {}), ...Object.fromEntries(Object.entries(attrs(model, MODEL_KNOWN) ?? {}).map(([key, value]) => [`model:${key}`, value])) },
|
||||
modelAttributes: selectedAttrs(model, MODEL_KNOWN),
|
||||
childXml: pageElement ? Array.from(pageElement.children).filter((child) => child.tagName !== "mxGraphModel").map(serializeElement) : undefined,
|
||||
modelChildXml: Array.from(model.children).filter((child) => child !== root).map(serializeElement),
|
||||
unknownCells,
|
||||
});
|
||||
if (unknownCells.length) report.preserved.push(`page ${index + 1}: ${unknownCells.length} unknown cell(s)`);
|
||||
return {
|
||||
id: pageElement?.getAttribute("id") || `page-${index + 1}`,
|
||||
title: pageElement?.getAttribute("name") || `Page ${index + 1}`,
|
||||
nodes,
|
||||
edges,
|
||||
layout: { type: "manual", gridSize: numberAttr(model, "gridSize", 10) },
|
||||
...(numberAttr(model, "pageWidth") > 0 ? { width: numberAttr(model, "pageWidth") } : {}),
|
||||
...(numberAttr(model, "pageHeight") > 0 ? { height: numberAttr(model, "pageHeight") } : {}),
|
||||
...(jsonObject(pageElement ?? model, "data-properties") ? { properties: jsonObject(pageElement ?? model, "data-properties") } : {}),
|
||||
...(pageExtensions ? { extensions: pageExtensions } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function importDrawioToIR(source: string, options: ImportDrawioOptions = {}): { ir: DiagramIRV2; lossReport: ImportLossReport } {
|
||||
const raw = normalizeDrawioDocument(options.sourceIsXml ? source : readFileSync(source, "utf8"));
|
||||
const report: ImportLossReport = { lossy: false, preserved: [], warnings: [] };
|
||||
let pages: DiagramPage[];
|
||||
let root: Element | undefined;
|
||||
if (/^<mxfile/i.test(raw)) {
|
||||
const document = parseDocument(raw, "mxfile");
|
||||
root = document.documentElement;
|
||||
const diagrams = Array.from(root.children).filter((child) => child.tagName === "diagram");
|
||||
if (!diagrams.length) throw new Error("No <diagram> elements found in mxfile");
|
||||
pages = diagrams.map((diagram, index) => {
|
||||
const model = Array.from(diagram.children).find((child) => child.tagName === "mxGraphModel");
|
||||
return parsePage(model ? serializeElement(model) : decodeDiagramContent(diagram.textContent ?? ""), diagram, index, report);
|
||||
});
|
||||
} else {
|
||||
pages = [parsePage(raw, undefined, 0, report)];
|
||||
}
|
||||
const nodePages = new Map<string, string>();
|
||||
for (const page of pages) for (const node of page.nodes) {
|
||||
const priorPage = nodePages.get(node.id);
|
||||
if (priorPage !== undefined) throw new Error(`Duplicate semantic node ID ${node.id} across pages ${priorPage} and ${page.id}`);
|
||||
nodePages.set(node.id, page.id);
|
||||
}
|
||||
const extensions = root ? mergePreservation(jsonObject(root, "data-extensions"), {
|
||||
host: root.getAttribute("host") ?? undefined,
|
||||
attributes: attrs(root, ROOT_KNOWN),
|
||||
childXml: Array.from(root.children).filter((child) => child.tagName !== "diagram").map(serializeElement),
|
||||
}) : undefined;
|
||||
const ir: DiagramIRV2 = {
|
||||
version: 2,
|
||||
...(root?.getAttribute("data-title") ? { title: root.getAttribute("data-title")! } : {}),
|
||||
pages,
|
||||
...(root ? (jsonObject(root, "data-provenance") ? { provenance: jsonObject(root, "data-provenance") } : {}) : {}),
|
||||
...(root ? (jsonObject(root, "data-assets") ? { assets: jsonObject(root, "data-assets") } : {}) : {}),
|
||||
...(root?.getAttribute("data-theme") ? { theme: root.getAttribute("data-theme")! } : {}),
|
||||
...(root ? (jsonObject(root, "data-properties") ? { properties: jsonObject(root, "data-properties") } : {}) : {}),
|
||||
...(extensions ? { extensions } : {}),
|
||||
};
|
||||
validateDiagramIR(ir);
|
||||
return { ir, lossReport: report };
|
||||
}
|
||||
Reference in New Issue
Block a user