mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-09-03 01:50:23 +08:00
feat(mcp): add multi-page (mxfile) support to MCP server (#862)
* feat(mcp): add multi-page (mxfile) support
The MCP server's write path could only address a single drawio page even
though the underlying .drawio file format and the embedded editor both
natively support multi-page documents. A user asking for "a second page
with a CNN diagram" would hit the validator with the error
"Expected closing tag </root> but found </mxCell>" because the validator
assumed input was a bare <mxGraphModel> and could not walk past the
<mxfile><diagram>...</diagram></mxfile> wrapper.
This patch closes the gap end to end:
* New helper module `pages.ts` centralises page CRUD (normalize, parse,
list, find, add, rename, delete) so every layer agrees that the
canonical in-memory shape is always <mxfile>. normalizeToMxfile and
addPageToDoc both strip any leading <?xml ?> declaration before
embedding a fragment inside <diagram> (the declaration is only valid
at document start). addPageToDoc explicitly rejects full <mxfile>
inputs so a caller cannot accidentally nest a document inside a page.
* `xml-validation.ts` now detects an <mxfile> root and scopes the
duplicate-id check per <diagram>. The legacy regex check would
otherwise reject every multi-page doc, because cells "0" and "1"
repeat in each page's <root> by design. The DOM-parse path is gated
by a cheap regex pre-check so legacy bare <mxGraphModel> callers
don't pay any extra cost. The autoFix duplicate-id rename step is
also guarded against mxfile inputs — renaming those sentinel cells
would silently break drawio's parent references.
* `diagram-operations.ts` accepts an optional PageSelector. For
<mxfile> input it resolves the page first and scopes all
querySelectorAll calls to that page's <root>, so a delete on page 2's
cell "2" no longer touches page 1's cell "2".
* `create_new_diagram` accepts either a bare <mxGraphModel> (legacy,
auto-wrapped into a single-page mxfile) or a full <mxfile> with N
diagrams. All existing single-page callers keep working unchanged.
* `edit_diagram`, `get_diagram`, and `export_diagram` gain optional
`page_id` / `page_name` / `page_index` parameters. When omitted they
target the first page — the "active by convention" default. Tool
handlers with all-optional input schemas coalesce missing arguments
via `input ?? {}` so a no-args MCP invocation can't crash on
destructure before reaching the session-existence check.
* New tools: `list_pages`, `add_page`, `rename_page`, `delete_page`.
* Page-targeted PNG/SVG export uses a "load + export + restore" dance:
the server projects the target page into a single-page <mxfile>,
pushes it into the transient state so the browser reloads the iframe
with just that page, waits for drawio to render (~3s), triggers the
export, captures the data, and then restores the original multi-page
document. The dance is wrapped in `try/finally` so the restore runs
unconditionally — even if an exception is thrown mid-dance, the
user's multi-tab view is recovered before the function returns.
The earlier attempt to use drawio's `selectPage` postMessage was a
no-op because drawio's JSON embed protocol does not expose that
action — silently exporting whatever tab happened to be active. The
load-export-restore approach trades a brief visible tab-flicker for
correctness: the exported image is guaranteed to match the requested
page.
* Tool description strings reflect the multi-page semantics so the LLM
client learns the new contract.
* Package version bumped 0.2.0 → 0.3.0 (additive surface — four new
tools, three extended input schemas, canonical XML shape change).
* CI: `.github/workflows/test.yml` gains an explicit install + vitest
run for the mcp-server package so the new multi-page invariants are
covered by automation, not just local runs.
Backward compatibility: every existing single-page caller continues to
work without modification. The session.xml shape is normalised on every
write, removing the wrapper-injection hack from the .drawio download
path.
Tests: 43 unit tests under `packages/mcp-server/tests/multi-page.test.ts`
pin the validator's mxfile path, the page-scoped operations, the XML
declaration-prefix handling for both normalizeToMxfile and addPageToDoc,
addPageToDoc's rejection of full <mxfile> inputs, the single-page
projection used by export_diagram (a direct regression test for the
selectPage bug — two distinct page selectors must produce visually
different projections), and the Transformer + CNN motivating scenario.
A `tests/smoke.mjs` smoke test drives the built `dist/index.js` over
JSON-RPC and asserts all 9 tools register with the right input schemas.
Root vitest suite (107 tests) still green.
* fix(mcp): rewrite page-targeted export browser-side; harden edit/get
The page-targeted PNG/SVG export never worked: export_diagram swapped the
live session to a single-page projection, slept 3s, then wrote the export
flag onto a state object that setState() had already replaced in the store
Map — so the browser never saw the request and every such export timed out.
The swap+restore also clobbered concurrent edits.
Move the projection entirely browser-side: requestExport() hands a single
-page <mxfile> to the bridge via state.exportXml; the bridge loads it,
lets draw.io render, exports, then reloads the user's real document. The
canonical session state is never mutated, so there is no restore race and
no fixed-delay guessing. The export poll now re-reads the live store entry
each tick instead of a captured reference. autosave is suppressed and the
version-bump reload is skipped while a projection is on screen; if no real
document was captured, restore forces a server reload rather than leaving
the iframe stuck on the projection.
Also:
- edit_diagram now returns isError on a page-level failure (selector matched
no page / page has no <root>) instead of reporting success-with-warnings
and persisting a no-op; the pre-edit history snapshot is taken only after
that gate so a failed edit leaves no phantom undo entry.
- edit_diagram/get_diagram re-normalise browser-pushed xml to mxfile so a
bare <mxGraphModel> can't silently strip a multi-page document.
- get_diagram now errors (instead of silently returning the full doc) when a
selector is given but the session isn't a parseable mxfile.
- page_id / page_name / add_page.id get .min(1) so empty strings can't
silently target the first page.
- Extract pages.ts:projectPage(), collapsing three copies of the
parse→find→serialise projection logic in index.ts.
- Replace the never-in-CI tests/smoke.mjs with tests/server-wiring.test.ts,
which boots the server from source via tsx and runs under the existing
vitest CI step.
* chore(mcp): set version to 0.2.1 for release
---------
Co-authored-by: dayuan.jiang <jdy.toh@gmail.com>
This commit is contained in:
@@ -1,8 +1,14 @@
|
||||
/**
|
||||
* ID-based diagram operations
|
||||
* Copied from lib/utils.ts to avoid cross-package imports
|
||||
*
|
||||
* The xmlContent argument may be either a bare <mxGraphModel> (legacy) or a
|
||||
* full <mxfile> with one or more <diagram> pages. For mxfile inputs, an
|
||||
* optional pageSelector identifies which page to edit; when omitted, the
|
||||
* first page is targeted (the "active page by convention" — see pages.ts).
|
||||
*/
|
||||
|
||||
import { findPageElement, hasPageSelector, type PageSelector } from "./pages.js"
|
||||
|
||||
export interface DiagramOperation {
|
||||
operation: "update" | "add" | "delete"
|
||||
cell_id: string
|
||||
@@ -22,15 +28,18 @@ export interface ApplyOperationsResult {
|
||||
|
||||
/**
|
||||
* Apply diagram operations (update/add/delete) using ID-based lookup.
|
||||
* This replaces the text-matching approach with direct DOM manipulation.
|
||||
*
|
||||
* @param xmlContent - The full mxfile XML content
|
||||
* @param operations - Array of operations to apply
|
||||
* @returns Object with result XML and any errors
|
||||
* @param xmlContent - The diagram XML. May be either a bare <mxGraphModel> or
|
||||
* a full <mxfile> with one or more <diagram> children.
|
||||
* @param operations - Array of operations to apply.
|
||||
* @param pageSelector - Optional page selector for multi-page docs. Defaults
|
||||
* to the first page.
|
||||
* @returns Object with result XML (same shape as input) and any per-op errors.
|
||||
*/
|
||||
export function applyDiagramOperations(
|
||||
xmlContent: string,
|
||||
operations: DiagramOperation[],
|
||||
pageSelector?: PageSelector,
|
||||
): ApplyOperationsResult {
|
||||
const errors: OperationError[] = []
|
||||
|
||||
@@ -53,22 +62,75 @@ export function applyDiagramOperations(
|
||||
}
|
||||
}
|
||||
|
||||
// Find the root element (inside mxGraphModel)
|
||||
const root = doc.querySelector("root")
|
||||
if (!root) {
|
||||
return {
|
||||
result: xmlContent,
|
||||
errors: [
|
||||
{
|
||||
type: "update",
|
||||
cellId: "",
|
||||
message: "Could not find <root> element in XML",
|
||||
},
|
||||
],
|
||||
// Locate the <root> element to operate on.
|
||||
//
|
||||
// - For <mxfile> input: resolve the page via pageSelector, then dive into
|
||||
// its <root>. This scopes querySelectorAll calls below to one page so
|
||||
// cells on other pages aren't accidentally matched.
|
||||
// - For bare <mxGraphModel> input: use the document's only <root>.
|
||||
let root: Element | null
|
||||
if (doc.documentElement?.tagName === "mxfile") {
|
||||
const found = findPageElement(doc as unknown as Document, pageSelector)
|
||||
if (!found) {
|
||||
const selDesc = hasPageSelector(pageSelector)
|
||||
? ` matching selector ${JSON.stringify(pageSelector)}`
|
||||
: ""
|
||||
return {
|
||||
result: xmlContent,
|
||||
errors: [
|
||||
{
|
||||
type: "update",
|
||||
cellId: "",
|
||||
message: `Page${selDesc} not found in <mxfile>`,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
root = found.element.querySelector("root")
|
||||
if (!root) {
|
||||
const pageId =
|
||||
found.element.getAttribute("id") || `(index ${found.index})`
|
||||
return {
|
||||
result: xmlContent,
|
||||
errors: [
|
||||
{
|
||||
type: "update",
|
||||
cellId: "",
|
||||
message: `Page "${pageId}" has no <root> element`,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (hasPageSelector(pageSelector)) {
|
||||
return {
|
||||
result: xmlContent,
|
||||
errors: [
|
||||
{
|
||||
type: "update",
|
||||
cellId: "",
|
||||
message:
|
||||
"Page selector provided but document is not multi-page (no <mxfile> wrapper). Use create_new_diagram with a full <mxfile> first, or omit the page selector.",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
root = doc.querySelector("root")
|
||||
if (!root) {
|
||||
return {
|
||||
result: xmlContent,
|
||||
errors: [
|
||||
{
|
||||
type: "update",
|
||||
cellId: "",
|
||||
message: "Could not find <root> element in XML",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build a map of cell IDs to elements
|
||||
// Build a map of cell IDs to elements (scoped to the resolved page).
|
||||
const cellMap = new Map<string, Element>()
|
||||
root.querySelectorAll("mxCell").forEach((cell) => {
|
||||
const id = cell.getAttribute("id")
|
||||
@@ -208,7 +270,9 @@ export function applyDiagramOperations(
|
||||
cellsToDelete.add(cellId)
|
||||
|
||||
// Find children (cells where parent === cellId)
|
||||
const children = root.querySelectorAll(
|
||||
// Scoped to `root` so other pages' cells with the same parent id
|
||||
// (notably "1") are never touched.
|
||||
const children = root!.querySelectorAll(
|
||||
`mxCell[parent="${cellId}"]`,
|
||||
)
|
||||
children.forEach((child) => {
|
||||
|
||||
@@ -93,6 +93,7 @@ interface SessionState {
|
||||
svg?: string // Cached SVG from last browser save
|
||||
syncRequested?: number // Timestamp when sync requested, cleared when browser responds
|
||||
exportFormat?: "png" | "svg" // Set by MCP tool to request browser export
|
||||
exportXml?: string // Single-page projection to load before a page-targeted export
|
||||
exportData?: string // Base64/SVG data returned by browser after export
|
||||
}
|
||||
|
||||
@@ -117,12 +118,37 @@ export function setState(sessionId: string, xml: string, svg?: string): number {
|
||||
svg: svg || existing?.svg, // Preserve cached SVG if not provided
|
||||
syncRequested: undefined, // Clear sync request when browser pushes state
|
||||
exportFormat: existing?.exportFormat, // Preserve pending export request
|
||||
exportXml: existing?.exportXml, // Preserve pending projection
|
||||
exportData: existing?.exportData, // Preserve export result
|
||||
})
|
||||
log.debug(`State updated: session=${sessionId}, version=${newVersion}`)
|
||||
return newVersion
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the browser bridge to export the current diagram as png/svg.
|
||||
*
|
||||
* When `projectionXml` is given (a single-page <mxfile>), the bridge loads it
|
||||
* first, waits for draw.io's own load event, exports, then reloads the
|
||||
* session's real document — so a page-targeted export never mutates the
|
||||
* canonical session state and needs no fixed-delay guessing on the server.
|
||||
*
|
||||
* Returns false when the session is unknown. Callers should then poll
|
||||
* `getState(sessionId)?.exportData` for the result.
|
||||
*/
|
||||
export function requestExport(
|
||||
sessionId: string,
|
||||
format: "png" | "svg",
|
||||
projectionXml?: string,
|
||||
): boolean {
|
||||
const state = stateStore.get(sessionId)
|
||||
if (!state) return false
|
||||
state.exportData = undefined
|
||||
state.exportXml = projectionXml
|
||||
state.exportFormat = format
|
||||
return true
|
||||
}
|
||||
|
||||
export function requestSync(sessionId: string): boolean {
|
||||
const state = stateStore.get(sessionId)
|
||||
if (state) {
|
||||
@@ -286,6 +312,7 @@ function handleStateApi(
|
||||
version: state?.version || 0,
|
||||
syncRequested: !!state?.syncRequested,
|
||||
exportFormat: state?.exportFormat || null,
|
||||
exportXml: state?.exportXml || null,
|
||||
}),
|
||||
)
|
||||
} else if (req.method === "POST") {
|
||||
@@ -305,6 +332,7 @@ function handleStateApi(
|
||||
if (state) {
|
||||
state.exportData = data.exportData
|
||||
state.exportFormat = undefined
|
||||
state.exportXml = undefined
|
||||
log.debug(
|
||||
`Export data received for session=${sessionId}`,
|
||||
)
|
||||
@@ -675,6 +703,8 @@ function getHtmlPage(sessionId: string): string {
|
||||
let pendingSvgExport = null;
|
||||
let pendingAiSvg = false;
|
||||
let pendingMcpExport = null; // 'png' or 'svg' when MCP requested export
|
||||
let projectionExportActive = false; // page-targeted export: showing a transient single-page projection
|
||||
let projectionRestoreXml = null; // the real document to reload once a projection export finishes
|
||||
|
||||
window.addEventListener('message', (e) => {
|
||||
if (e.origin !== '${DRAWIO_ORIGIN}') return;
|
||||
@@ -684,6 +714,10 @@ function getHtmlPage(sessionId: string): string {
|
||||
isReady = true;
|
||||
if (pendingXml) { loadDiagram(pendingXml); pendingXml = null; }
|
||||
} else if ((msg.event === 'save' || msg.event === 'autosave') && msg.xml && msg.xml !== lastXml) {
|
||||
// Ignore autosave while a single-page projection is on screen
|
||||
// for a page-targeted export — otherwise we'd push the
|
||||
// transient projection back as the canonical session state.
|
||||
if (projectionExportActive) return;
|
||||
// Request SVG export, then push state with SVG
|
||||
pendingSvgExport = msg.xml;
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'svg' }), '*');
|
||||
@@ -704,6 +738,9 @@ function getHtmlPage(sessionId: string): string {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sessionId, exportData: d })
|
||||
}).catch(() => {});
|
||||
// Page-targeted export: restore the user's real
|
||||
// multi-page document now that we have the image.
|
||||
restoreFromProjection();
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -761,6 +798,22 @@ function getHtmlPage(sessionId: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the user's real document after a page-targeted projection
|
||||
// export. If we never captured one (lastXml was null at projection
|
||||
// start), fall back to forcing a reload from the server on the next
|
||||
// poll by rewinding currentVersion — never leave the iframe stuck on
|
||||
// the transient projection.
|
||||
function restoreFromProjection() {
|
||||
if (!projectionExportActive) return;
|
||||
projectionExportActive = false;
|
||||
if (projectionRestoreXml) {
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: projectionRestoreXml, autosave: 1 }), '*');
|
||||
projectionRestoreXml = null;
|
||||
} else {
|
||||
currentVersion = -1; // force the next poll to reload from server
|
||||
}
|
||||
}
|
||||
|
||||
async function pushState(xml, svg = '') {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
@@ -786,20 +839,54 @@ function getHtmlPage(sessionId: string): string {
|
||||
pendingSyncExport = true;
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xml' }), '*');
|
||||
}
|
||||
// Load new diagram from server (before export, so we export latest)
|
||||
if (s.version > currentVersion && s.xml) {
|
||||
// Load new diagram from server (before export, so we export latest).
|
||||
// While a page-targeted projection is on screen, skip the reload
|
||||
// so it doesn't fight the projection — and leave currentVersion
|
||||
// unadvanced so this bump is re-detected and applied once the
|
||||
// real document is restored.
|
||||
if (s.version > currentVersion && s.xml && !projectionExportActive) {
|
||||
currentVersion = s.version;
|
||||
loadDiagram(s.xml, true);
|
||||
}
|
||||
// Handle export request from MCP server (png/svg) - after version update
|
||||
// Handle export request from MCP server (png/svg).
|
||||
//
|
||||
// Plain export: capture whatever tab is currently displayed.
|
||||
//
|
||||
// Page-targeted export: the server sends a single-page <mxfile>
|
||||
// projection in s.exportXml. We load it into the iframe, let
|
||||
// draw.io render it, export, then reload the user's real
|
||||
// document — all browser-side. The canonical session state is
|
||||
// never mutated, so there is no server-side restore race and no
|
||||
// dependence on poll timing. autosave is suppressed while the
|
||||
// projection is showing (see projectionExportActive guard).
|
||||
if (s.exportFormat && !pendingMcpExport && isReady) {
|
||||
pendingMcpExport = s.exportFormat;
|
||||
const exportOpts = s.exportFormat === 'png'
|
||||
? { action: 'export', format: 'png', scale: 2 }
|
||||
: { action: 'export', format: 'svg' };
|
||||
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
|
||||
// Timeout: reset if draw.io never responds
|
||||
setTimeout(() => { if (pendingMcpExport) { pendingMcpExport = null; } }, 8000);
|
||||
const fireExport = () => {
|
||||
const exportOpts = pendingMcpExport === 'png'
|
||||
? { action: 'export', format: 'png', scale: 2 }
|
||||
: { action: 'export', format: 'svg' };
|
||||
iframe.contentWindow.postMessage(JSON.stringify(exportOpts), '*');
|
||||
};
|
||||
if (s.exportXml) {
|
||||
// Stash the real document so we can restore after export.
|
||||
projectionRestoreXml = lastXml;
|
||||
projectionExportActive = true;
|
||||
// Load the projection without touching lastXml/server state.
|
||||
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: s.exportXml, autosave: 0 }), '*');
|
||||
// Let draw.io render the loaded page before exporting
|
||||
// (same proven settle delay as the AI-preview path).
|
||||
setTimeout(fireExport, 600);
|
||||
} else {
|
||||
fireExport();
|
||||
}
|
||||
// Timeout: reset if draw.io never responds, and restore the
|
||||
// real document if a projection was left showing.
|
||||
setTimeout(() => {
|
||||
if (pendingMcpExport) {
|
||||
pendingMcpExport = null;
|
||||
restoreFromProjection();
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
@@ -839,7 +926,11 @@ function getHtmlPage(sessionId: string): string {
|
||||
saveConfirmBtn.textContent = 'Exporting...';
|
||||
|
||||
if (format === 'drawio') {
|
||||
// Use lastXml directly instead of requesting export (avoids race with SVG exports)
|
||||
// Use lastXml directly instead of requesting export (avoids race with SVG exports).
|
||||
// session.xml is canonically <mxfile> after the multi-page refactor,
|
||||
// so no wrapper injection is needed. The legacy fallback below
|
||||
// remains only for documents that somehow slipped past
|
||||
// normalisation (e.g. an older session loaded from external state).
|
||||
let xmlData = lastXml || '';
|
||||
if (xmlData && !xmlData.includes('<mxfile')) {
|
||||
xmlData = '<mxfile host="mcp"><diagram name="Page-1">' + xmlData + '</diagram></mxfile>';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
316
packages/mcp-server/src/pages.ts
Normal file
316
packages/mcp-server/src/pages.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Multi-page (mxfile) helpers for draw.io diagrams.
|
||||
*
|
||||
* The on-disk and embed-protocol shape of a draw.io document is:
|
||||
*
|
||||
* <mxfile host="...">
|
||||
* <diagram id="..." name="...">
|
||||
* <mxGraphModel><root><mxCell .../>...</root></mxGraphModel>
|
||||
* </diagram>
|
||||
* ...one or more <diagram> children...
|
||||
* </mxfile>
|
||||
*
|
||||
* 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 <mxfile> root? */
|
||||
export function isMxFile(xml: string): boolean {
|
||||
return /^\s*(<\?xml[^>]*\?>\s*)?<mxfile[\s>]/i.test(xml)
|
||||
}
|
||||
|
||||
/** Cheap regex check — does the XML start with a bare <mxGraphModel>? */
|
||||
export function isMxGraphModel(xml: string): boolean {
|
||||
return /^\s*(<\?xml[^>]*\?>\s*)?<mxGraphModel[\s>]/i.test(xml)
|
||||
}
|
||||
|
||||
function escapeAttr(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip a leading <?xml ... ?> 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 <mxGraphModel> XML string in <mxfile><diagram>...</diagram></mxfile>.
|
||||
* 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 <?xml ?> declaration before embedding — a declaration is
|
||||
* only valid at the very start of a document, never inside a <diagram>.
|
||||
*/
|
||||
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 `<mxfile host="${escapeAttr(host)}"><diagram id="${escapeAttr(pageId)}" name="${escapeAttr(pageName)}">${inner}</diagram></mxfile>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an mxfile XML string. Returns null on parse error or if the root
|
||||
* isn't <mxfile> — 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
|
||||
* <mxfile>. 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: `<mxfile host="app.diagrams.net">${serializer.serializeToString(found.element)}</mxfile>`,
|
||||
index: found.index,
|
||||
name: found.element.getAttribute("name") || "",
|
||||
}
|
||||
}
|
||||
|
||||
/** Walk every <diagram> child of <mxfile> 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 <diagram> 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 <diagram> to the mxfile doc. The new page's model defaults to
|
||||
* an empty <mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>.
|
||||
*
|
||||
* `opts.xml` must be a BARE <mxGraphModel> — passing a full <mxfile> would
|
||||
* end up nested inside <diagram>, which is malformed. We reject the mxfile
|
||||
* shape explicitly and strip any <?xml ?> declaration (only valid at
|
||||
* document start, never inside <diagram>).
|
||||
*
|
||||
* 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 <mxGraphModel>; received a full <mxfile>. Extract the target diagram's <mxGraphModel> first.",
|
||||
)
|
||||
}
|
||||
if (!isMxGraphModel(trimmed)) {
|
||||
throw new Error(
|
||||
"addPageToDoc: opts.xml must be a bare <mxGraphModel>.",
|
||||
)
|
||||
}
|
||||
inner = trimmed
|
||||
} else {
|
||||
inner = `<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel>`
|
||||
}
|
||||
|
||||
const snippet = `<wrapper><diagram id="${escapeAttr(id)}" name="${escapeAttr(name)}">${inner}</diagram></wrapper>`
|
||||
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 <mxGraphModel>",
|
||||
)
|
||||
}
|
||||
const newDiagram = tempDoc.querySelector("diagram")
|
||||
if (!newDiagram) {
|
||||
throw new Error("Failed to construct <diagram> 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 }
|
||||
}
|
||||
@@ -119,8 +119,74 @@ function checkDuplicateAttributes(xml: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
/** Check for duplicate IDs in XML */
|
||||
/**
|
||||
* Check for duplicate IDs in XML.
|
||||
*
|
||||
* For multi-page documents (<mxfile> with multiple <diagram> children), cell
|
||||
* IDs are unique **within a page**, not across the whole document — drawio
|
||||
* legitimately reuses "0" and "1" for the root cells of every page. So we
|
||||
* scope the cell-ID uniqueness check per <diagram>, and additionally check
|
||||
* that the <diagram> ids themselves are unique.
|
||||
*
|
||||
* The legacy regex-based check is kept as a fallback for non-mxfile inputs
|
||||
* and for XML that won't DOM-parse.
|
||||
*/
|
||||
function checkDuplicateIds(xml: string): string | null {
|
||||
// The DOM-aware path only matters for <mxfile> wrappers; for legacy
|
||||
// bare <mxGraphModel> inputs (the overwhelming majority of historic
|
||||
// traffic), the cheap regex fallback at the bottom is enough. A quick
|
||||
// string check avoids paying the DOMParser cost on every call.
|
||||
const mightBeMxFile = /<mxfile[\s>]/i.test(xml)
|
||||
|
||||
// Try DOM-aware, page-scoped check first when the input looks mxfile-ish.
|
||||
if (mightBeMxFile)
|
||||
try {
|
||||
const doc = new DOMParser().parseFromString(xml, "text/xml")
|
||||
if (!doc.querySelector("parsererror")) {
|
||||
const rootEl = doc.documentElement
|
||||
if (rootEl && rootEl.tagName === "mxfile") {
|
||||
const diagrams = doc.querySelectorAll("diagram")
|
||||
|
||||
// 1) <diagram> ids must be unique across the file.
|
||||
const diagramIds = new Map<string, number>()
|
||||
diagrams.forEach((d) => {
|
||||
const id = d.getAttribute("id")
|
||||
if (id)
|
||||
diagramIds.set(id, (diagramIds.get(id) || 0) + 1)
|
||||
})
|
||||
const dupDiagrams = Array.from(diagramIds.entries())
|
||||
.filter(([, c]) => c > 1)
|
||||
.map(([id]) => `'${id}'`)
|
||||
if (dupDiagrams.length > 0) {
|
||||
return `Invalid XML: Found duplicate <diagram> id(s): ${dupDiagrams.slice(0, 3).join(", ")}. Each page must have a unique id.`
|
||||
}
|
||||
|
||||
// 2) Within each page, mxCell ids must be unique.
|
||||
for (let i = 0; i < diagrams.length; i++) {
|
||||
const diagram = diagrams[i]
|
||||
const pageId =
|
||||
diagram.getAttribute("id") || `(index ${i})`
|
||||
const cells = diagram.querySelectorAll("mxCell")
|
||||
const cellIds = new Map<string, number>()
|
||||
cells.forEach((c) => {
|
||||
const id = c.getAttribute("id")
|
||||
if (id) cellIds.set(id, (cellIds.get(id) || 0) + 1)
|
||||
})
|
||||
const dups = Array.from(cellIds.entries())
|
||||
.filter(([, c]) => c > 1)
|
||||
.map(([id, count]) => `'${id}' (${count}x)`)
|
||||
if (dups.length > 0) {
|
||||
return `Invalid XML: Found duplicate cell ID(s) in page "${pageId}": ${dups.slice(0, 3).join(", ")}. All mxCell ids must be unique within a page.`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fall through to regex
|
||||
}
|
||||
|
||||
// Legacy regex-based check for bare <mxGraphModel> and parse-error cases.
|
||||
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
|
||||
const ids = new Map<string, number>()
|
||||
let idMatch
|
||||
@@ -770,35 +836,46 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
fixes.push(`Fixed ${trueNestedFixed} true nested mxCell(s)`)
|
||||
}
|
||||
|
||||
// 22. Fix duplicate IDs by appending suffix
|
||||
const seenIds = new Map<string, number>()
|
||||
const duplicateIds: string[] = []
|
||||
// 22. Fix duplicate IDs by appending suffix.
|
||||
// Skipped for multi-page <mxfile> documents — cell ids "0" and "1" repeat
|
||||
// across pages legitimately (every page has its own <root> with id="0"/"1"
|
||||
// sentinel cells). Renaming them would break drawio's parent references.
|
||||
// For mxfile inputs, duplicate-id validation is page-scoped in
|
||||
// checkDuplicateIds() and a true duplicate produces a hard error rather
|
||||
// than a silent rename.
|
||||
if (!/<mxfile[\s>]/i.test(fixed)) {
|
||||
const seenIds = new Map<string, number>()
|
||||
const duplicateIds: string[] = []
|
||||
|
||||
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
|
||||
let idMatch
|
||||
while ((idMatch = idPattern.exec(fixed)) !== null) {
|
||||
const id = idMatch[1]
|
||||
seenIds.set(id, (seenIds.get(id) || 0) + 1)
|
||||
}
|
||||
const idPattern = /\bid\s*=\s*["']([^"']+)["']/gi
|
||||
let idMatch
|
||||
while ((idMatch = idPattern.exec(fixed)) !== null) {
|
||||
const id = idMatch[1]
|
||||
seenIds.set(id, (seenIds.get(id) || 0) + 1)
|
||||
}
|
||||
|
||||
for (const [id, count] of seenIds) {
|
||||
if (count > 1) duplicateIds.push(id)
|
||||
}
|
||||
for (const [id, count] of seenIds) {
|
||||
if (count > 1) duplicateIds.push(id)
|
||||
}
|
||||
|
||||
if (duplicateIds.length > 0) {
|
||||
const idCounters = new Map<string, number>()
|
||||
fixed = fixed.replace(/\bid\s*=\s*["']([^"']+)["']/gi, (match, id) => {
|
||||
if (!duplicateIds.includes(id)) return match
|
||||
if (duplicateIds.length > 0) {
|
||||
const idCounters = new Map<string, number>()
|
||||
fixed = fixed.replace(
|
||||
/\bid\s*=\s*["']([^"']+)["']/gi,
|
||||
(match, id) => {
|
||||
if (!duplicateIds.includes(id)) return match
|
||||
|
||||
const count = idCounters.get(id) || 0
|
||||
idCounters.set(id, count + 1)
|
||||
const count = idCounters.get(id) || 0
|
||||
idCounters.set(id, count + 1)
|
||||
|
||||
if (count === 0) return match
|
||||
if (count === 0) return match
|
||||
|
||||
const newId = `${id}_dup${count}`
|
||||
return match.replace(id, newId)
|
||||
})
|
||||
fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`)
|
||||
const newId = `${id}_dup${count}`
|
||||
return match.replace(id, newId)
|
||||
},
|
||||
)
|
||||
fixes.push(`Renamed ${duplicateIds.length} duplicate ID(s)`)
|
||||
}
|
||||
}
|
||||
|
||||
// 23. Fix empty id attributes
|
||||
|
||||
Reference in New Issue
Block a user