/** * Multi-page (mxfile) helpers for draw.io diagrams. * * The on-disk and embed-protocol shape of a draw.io document is: * * * * ... * * ...one or more children... * * * This module centralises page CRUD so that index.ts, xml-validation.ts, * and diagram-operations.ts can all agree on: * - what "the canonical in-memory shape" is (always mxfile), * - how to find a page (id, name, or index), * - how to add/rename/delete pages without re-parsing ad-hoc. */ import { DOMParser } from "linkedom" export interface PageInfo { id: string name: string index: number cellCount: number } /** Selector used by all multi-page-aware tools. All fields optional. */ export interface PageSelector { page_id?: string page_name?: string page_index?: number } /** True if the selector targets a specific page (any field set). */ export function hasPageSelector(s?: PageSelector | null): boolean { if (!s) return false return ( Boolean(s.page_id) || Boolean(s.page_name) || s.page_index !== undefined ) } /** * Generate a short page id similar in shape to drawio's auto-assigned ids. * Format: 12 chars alphanumeric with a single dash. Not a UUID — drawio itself * uses short ids; collisions are still astronomically unlikely for one session. */ export function generatePageId(): string { const a = Math.random().toString(36).substring(2, 10) const b = Math.random().toString(36).substring(2, 6) return `${a}-${b}` } /** Cheap regex check — does the XML start with an root? */ export function isMxFile(xml: string): boolean { return /^\s*(<\?xml[^>]*\?>\s*)?]/i.test(xml) } /** Cheap regex check — does the XML start with a bare ? */ export function isMxGraphModel(xml: string): boolean { return /^\s*(<\?xml[^>]*\?>\s*)?]/i.test(xml) } function escapeAttr(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) } /** * Strip a leading declaration from an XML string. The XML spec * only permits the declaration at the very start of a document, so embedding * a declaration inside another element produces invalid XML. Callers must * strip before splicing a fragment into a wrapper. */ function stripXmlDeclaration(xml: string): string { return xml.replace(/^\s*<\?xml[^>]*\?>\s*/i, "") } /** * Wrap a bare XML string in .... * If the input is already an mxfile, returns it unchanged. * If the input is neither shape, returns null so the caller can surface a clear error. * * Strips any leading declaration before embedding — a declaration is * only valid at the very start of a document, never inside a . */ export function normalizeToMxfile( xml: string, opts: { pageId?: string; pageName?: string; host?: string } = {}, ): string | null { const trimmed = xml.trim() if (!trimmed) return null if (isMxFile(trimmed)) return trimmed if (!isMxGraphModel(trimmed)) return null const pageId = opts.pageId || generatePageId() const pageName = opts.pageName || "Page-1" const host = opts.host || "app.diagrams.net" const inner = stripXmlDeclaration(trimmed) return `${inner}` } /** * Parse an mxfile XML string. Returns null on parse error or if the root * isn't — callers are expected to have run normalizeToMxfile first. */ export function parseMxfile(xml: string): Document | null { try { const doc = new DOMParser().parseFromString(xml, "text/xml") if (doc.querySelector("parsererror")) return null if (doc.documentElement?.tagName !== "mxfile") return null return doc as unknown as Document } catch { return null } } /** Serialise an mxfile doc back to a string via the global XMLSerializer polyfill. */ export function serializeMxfile(doc: Document): string { const serializer = new XMLSerializer() return serializer.serializeToString(doc) } export type PageProjection = | { ok: true; xml: string; index: number; name: string } | { ok: false; reason: "parse" | "notfound" } /** * Project a single page out of an mxfile string into a standalone one-page * . Used by get_diagram and export_diagram so the three call sites * share one parse → find → serialise path. * * Returns { ok:false, reason:"parse" } if the xml isn't a parseable mxfile, * or { ok:false, reason:"notfound" } if the selector matches no page. */ export function projectPage( xml: string, selector: PageSelector, ): PageProjection { const doc = parseMxfile(xml) if (!doc) return { ok: false, reason: "parse" } const found = findPageElement(doc, selector) if (!found) return { ok: false, reason: "notfound" } const serializer = new XMLSerializer() return { ok: true, xml: `${serializer.serializeToString(found.element)}`, index: found.index, name: found.element.getAttribute("name") || "", } } /** Walk every child of and return summary info. */ export function listPagesFromDoc(doc: Document): PageInfo[] { const diagrams = doc.querySelectorAll("diagram") const result: PageInfo[] = [] diagrams.forEach((d, idx) => { const root = d.querySelector("root") const cellCount = root ? root.querySelectorAll("mxCell").length : 0 result.push({ id: d.getAttribute("id") || "", name: d.getAttribute("name") || `Page-${idx + 1}`, index: idx, cellCount, }) }) return result } /** * Resolve a page selector to its element. * Resolution order: page_id → page_name → page_index → default (first page). * * When no selector field is set we return the first page — the "active page * by convention" mentioned in §3.4 of the design doc. */ export function findPageElement( doc: Document, selector?: PageSelector, ): { element: Element; index: number } | null { const diagrams = Array.from(doc.querySelectorAll("diagram")) if (diagrams.length === 0) return null if (!hasPageSelector(selector)) { return { element: diagrams[0], index: 0 } } if (selector?.page_id) { for (let i = 0; i < diagrams.length; i++) { if (diagrams[i].getAttribute("id") === selector.page_id) { return { element: diagrams[i], index: i } } } return null } if (selector?.page_name) { for (let i = 0; i < diagrams.length; i++) { if (diagrams[i].getAttribute("name") === selector.page_name) { return { element: diagrams[i], index: i } } } return null } if (selector && selector.page_index !== undefined) { const idx = selector.page_index if (Number.isInteger(idx) && idx >= 0 && idx < diagrams.length) { return { element: diagrams[idx], index: idx } } return null } return null } /** * Append a new to the mxfile doc. The new page's model defaults to * an empty . * * `opts.xml` must be a BARE — passing a full would * end up nested inside , which is malformed. We reject the mxfile * shape explicitly and strip any declaration (only valid at * document start, never inside ). * * Returns the new PageInfo. Throws if the requested id collides or the xml * shape is wrong. */ export function addPageToDoc( doc: Document, opts: { id?: string; name?: string; xml?: string } = {}, ): PageInfo { const existing = listPagesFromDoc(doc) const id = opts.id || generatePageId() if (existing.some((p) => p.id === id)) { throw new Error(`Page id "${id}" already exists`) } const name = opts.name || `Page-${existing.length + 1}` let inner: string if (opts.xml?.trim()) { const trimmed = stripXmlDeclaration(opts.xml.trim()) if (isMxFile(trimmed)) { throw new Error( "addPageToDoc: opts.xml must be a bare ; received a full . Extract the target diagram's first.", ) } if (!isMxGraphModel(trimmed)) { throw new Error( "addPageToDoc: opts.xml must be a bare .", ) } inner = trimmed } else { inner = `` } const snippet = `${inner}` const tempDoc = new DOMParser().parseFromString(snippet, "text/xml") if (tempDoc.querySelector("parsererror")) { throw new Error( "Failed to parse new page xml — make sure it is a valid ", ) } const newDiagram = tempDoc.querySelector("diagram") if (!newDiagram) { throw new Error("Failed to construct element for new page") } const imported = doc.importNode(newDiagram, true) as Element doc.documentElement.appendChild(imported) return { id, name, index: existing.length, cellCount: imported.querySelectorAll("mxCell").length, } } /** Rename the page matched by selector. Returns true on success. */ export function renamePageInDoc( doc: Document, selector: PageSelector, newName: string, ): boolean { const found = findPageElement(doc, selector) if (!found) return false found.element.setAttribute("name", newName) return true } /** * Delete a page. Refuses to delete the last remaining page — the embed needs * at least one diagram to render anything, and silently recreating one would * be surprising behaviour for an MCP caller. */ export function deletePageFromDoc( doc: Document, selector: PageSelector, ): { ok: boolean; reason?: string; deletedId?: string; deletedIndex?: number } { const pages = listPagesFromDoc(doc) if (pages.length <= 1) { return { ok: false, reason: "Cannot delete the only remaining page" } } const found = findPageElement(doc, selector) if (!found) { return { ok: false, reason: "Page not found" } } const id = found.element.getAttribute("id") || "" const index = found.index found.element.parentNode?.removeChild(found.element) return { ok: true, deletedId: id, deletedIndex: index } }