Compare commits

...
Author SHA1 Message Date
Dayuan Jiang 07f3e3c2a3 feat(mcp-server): screenshot attached to create_new_diagram and edit_diagram results (#983)
* feat(mcp-server): optional screenshot attached to create_new_diagram and edit_diagram results

Both write tools take an optional boolean `screenshot`; the default comes
from DRAWIO_AUTO_SCREENSHOT. The body of screenshot_diagram moved into
captureScreenshot so the write tools can append the PNG and checklist to
their result, or a "Screenshot skipped" note when the preview tab is not
available. The preview page delays the PNG export by 600 ms right after
loading a new version so icon images finish loading.

* fix(mcp-server): review fixes for screenshot attached to create and edit results

* fix(mcp-server): Codex review fixes for the screenshot on write results

- A cleared canvas also gets the screenshot note when one was asked for
- The note for a tab that never polled says it may not have connected yet

* docs: list undo and custom drawing rules among the MCP features

* test(mcp-server): truncation check with named styles and compact cells after the merge
2026-10-11 20:54:17 +09:00
Dayuan Jiang 8ab837679e feat(mcp-server): load .drawio.svg, change summary, XML reference by topic, preview options, truncation message (#982)
* feat(mcp-server): load .drawio.svg (Editable SVG) files with load_diagram

* feat(mcp-server): summarize the user's manual changes per cell in stale rejections and get_diagram

* feat(mcp-server): XML reference for tables, layers and groups via get_drawing_guide topic

* feat(mcp-server): DRAWIO_LANG, DRAWIO_UI and DRAWIO_DARK for the preview page

Three host-config environment variables fix the language, theme and
dark mode of the draw.io editor embedded in the preview page. The new
drawio-themes module holds the theme list and draw.io's locale names
(zh-hant becomes zh-tw); drawioEmbedParams() in http-server.ts builds
the variable tail of the iframe query and getHtmlPage fills the new
{{DRAWIO_PARAMS}} placeholder. Without the variables the page keeps
sending dark=auto as before. Both READMEs document the variables.

* feat(mcp-server): report XML cut off inside a cell and explain drawing in parts

create_new_diagram and add_page now detect bare-cell XML that ends inside an
unfinished mxCell (XML comments stripped first) and return an error with the
last 300 characters of the input, asking the model to resend from that cell or
continue with edit_diagram add operations. isMxCellXmlComplete moves from
lib/utils.ts into packages/mcp-server/src/new-diagram.ts and is re-exported
from lib/utils.ts for the web app. The drawing guide gains a "Large diagrams"
paragraph and the INSTRUCTIONS edit_diagram line mentions drawing in parts.

* fix(mcp-server): review fixes for load .drawio.svg, change summary, XML reference, draw.io embed options, truncation message

* fix(mcp-server): Codex review fixes for .drawio.svg loading, change summary, references, embed options and truncation

- Truncation check: a closing tag such as </mxCell/> that the auto-fix
  repairs is no cut, and input without any cell keeps the validator's
  message
- Guide: a call rejected as cut off drew nothing, so all of its cells are
  sent again; the topic pointer is its own paragraph
- Change summary: decoded labels (no &nbsp; or merged words from <br>),
  a fast path for equal XML, and "the order of the cells changed" as
  the fallback
- DRAWIO_UI in any case, DRAWIO_LANG=zh-Hans maps to draw.io's zh
- load_diagram and export_diagram describe which files load again
2026-10-11 20:40:05 +09:00
Dayuan Jiang cb066a4825 feat: redraw the app icon on a white tile with shorter bottom boxes (#985)
* feat: redraw the app icon on a white tile with shorter bottom boxes

Same org chart shape as before, redrawn as a vector so edges are sharp.
The white tile keeps the icon visible on dark docks, taskbars and tabs,
so dark mode now uses the same image and favicon-white.svg is removed.
The desktop icon is now 1024x1024.

* chore: add SVG sources for the app icon and favicons

resources/icon.svg renders resources/icon.png; resources/favicon.svg renders
the favicon .ico and .png files.

* fix: add a title to the icon SVG sources for the Biome a11y check
2026-10-11 20:32:23 +09:00
Dayuan Jiang 77be43d996 feat(mcp-server): saved sessions, restore_version and History on disk (#981)
* feat(mcp-server): list and resume saved diagram sessions

start_session accepts an optional session_id to continue a saved diagram
under the same preview URL and auto-save file, reporting the restored
pages. New read-only tool list_saved_diagrams prints every auto-saved
file newest first with its pages. Autosaver gains list() and dataDir().
INSTRUCTIONS and both READMEs mention the new tool.

* feat(mcp-server): add restore_version tool for undo and redo from History

Add the restore_version write tool so the model can put an earlier
version from History back on the canvas (steps_back, default 1). The
current canvas is kept in History unless it is the blank page, so a redo
is possible. http-server.ts exports restoreHistoryEntry, shared by the
HTTP restore endpoint and the tool; history.ts exports HistoryEntry and
otherVersions (distinct versions other than the canvas, newest first).
The drawing guide tells the model to call restore_version when the user
asks to undo, and both READMEs document the tool.

* feat(mcp-server): save the diagram History next to the auto-saved file

The Autosaver writes each session's History (the XML of its last 20 versions, without thumbnails) to <session-id>.history.json beside the .drawio file, after the same 1 second delay and only when the entries changed. When a saved session is resumed after an idle timeout or a process restart, the saved History is loaded back before the diagram, so the History panel and restore_version keep working. Removing the oldest files also removes their History files.

* fix(mcp-server): review fixes for saved sessions, undo tool, history on disk

* fix(mcp-server): Codex review fixes for saved sessions, undo and History on disk

- start_session resumes every saved file, also a cleared one or one of
  empty named pages, and decompresses pages draw.io compressed
- History kept by a lost user edit or a recovering tab is saved too
- A diagram cleared before its first save keeps its History on disk
- The History file is written again after the 50-file cap removed it
- restore_version compares versions like the edit gate (a bare
  mxGraphModel from the browser has no page name), checks the restore
  result, and says "none" when no version is left
- The preview closes an open History list when the server state was
  recreated, since the entries got new ids
- start_session's description mentions session_id

* fix(mcp-server): compare History versions pairwise and tie restores to the server state

Second Codex review of the History fixes:
- One bare mxGraphModel in History no longer hides page renames in every
  comparison: names are left out only when one of the two is bare
- A History list asked for before the server recreated the session is
  dropped, and a restore names the state its list belongs to; the server
  refuses one from a lost state (History ids change after a restart)

* fix(mcp-server): restore from a History list with the state it was taken in

Third Codex review: a restore sent the tab's current state, so an old
list shown after a failed refresh passed the server's check. The list now
keeps the state it came from, and the server refuses a missing (null)
state too; only a tab of an older version, which sends none, is let
through.
2026-10-11 20:23:23 +09:00
Dayuan Jiang c774a39c33 feat(mcp-server): drawing guide hints and the user's own drawing rules (#980)
* feat(mcp-server): add source-reading and clear-canvas hints to the drawing guide

Carry the missing web-prompt sentences into the MCP drawing guide: draw from
a document, image or web page by reading it yourself first, compare image
replications with screenshot_diagram, keep replies short after a successful
draw, compose artistic requests from standard shapes, and clear the canvas by
sending only the two root cells. Replace the "If the diagram is large" bullet
with guidance on choosing create_new_diagram vs edit_diagram.

The server INSTRUCTIONS mention reading files yourself and opening .drawio
files with load_diagram; the create_new_diagram description documents the
clear-canvas call, and its handler reports "Canvas cleared" when the prepared
XML holds only the root cells. Add tests for both and the matching README
lines in the mcp-server and claude-plugin packages.

* feat(mcp-server): append the user's instructions.md to the drawing guide

Read <DRAWIO_DATA_DIR>/instructions.md (default ~/.next-ai-drawio) on
every call and append its first 5000 characters to the drawing guide
under a "## Custom Instructions" heading, for start_session,
get_drawing_guide and the diagram-workflow prompt. start_session now
tells the model where the file lives. Document the feature in both
READMEs and cover it with unit and server-wiring tests.

* fix(mcp-server): review fixes for drawing guide text and custom instructions file

* fix(mcp-server): Codex review fixes for the guide and custom instructions

- Report "Canvas cleared" only for a one-page document; several empty
  pages get the page summary
- Build the instructions.md path with path.join (Windows separators)
- Keep the 15000-character guide budget in the wiring test
- Plugin README: DRAWIO_DATA_DIR also holds instructions.md
2026-10-11 20:04:59 +09:00
Dayuan Jiang 3d1a020b35 docs(mcp): mention the xml source of load_diagram in the tool table (#978) 2026-10-11 12:05:18 +09:00
caoxiaole07andcaoxiaole07 8d690503d4 feat(mcp): load_diagram accepts inline 'xml' content as an alternative to 'path' (#946)
Agents frequently hold .drawio content in memory (another tool's output,
a repository read, an API response) and previously had to write it to a
temporary file just so load_diagram could read it back. Add a mutually
exclusive 'xml' argument that goes through the same parser as the 'path'
branch, so both plain XML and draw.io's compressed save format work.

Edit-gate semantics by source:
- 'path': unchanged — the model has not seen the content, one
  get_diagram round-trip is still required before editing.
- plain 'xml': the model supplied the exact content (same rationale as
  create_new_diagram), so it is recorded as seen and can be edited
  immediately.
- compressed 'xml': the session stores the decompressed form, which the
  model cannot derive from the compressed input — the gate is kept.

Argument validation (mutual exclusion / presence) fires before the
session check so callers get useful errors regardless of session state.
parseDrawioFileContent now reports whether any page was decompressed,
which drives the gate decision. The diagram-workflow prompt is updated
to document the new argument.

Co-authored-by: caoxiaole07 <[email protected]>
2026-10-11 10:03:13 +09:00
renovate[bot] 4b4f36e374 chore(deps): update dependency @biomejs/biome to v2.5.15 (#931)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-11 08:33:56 +09:00
Dayuan Jiang a9091e96f1 ci: remind and close issues that wait for the reporter (#977)
Issues labelled "wait for confrim" get a reminder and the "no response"
label after 15 days without activity, and are closed as not planned after
15 more. A reply from the reporter removes both labels.
2026-10-11 08:02:28 +09:00
Dayuan Jiang 213b672e92 test(e2e): deselect by clicking a corner away from the shape (#975)
The test clicked the canvas at (10, 10) to deselect. When the click on the
shape before it needed retries, Playwright scrolled the shape into the
container's top-left corner, so the next click landed on the shape and the
selection stayed (seen on the main run of cd5352c and on #973). The new
clickEmptyCanvas helper clicks the corner farthest from the shape.
Reproduced locally by scrolling the shape into the corner: the old click
keeps the selection, the helper clears it.
2026-10-10 23:53:30 +09:00
45 changed files with 2779 additions and 417 deletions
@@ -0,0 +1,55 @@
# Issues labelled "wait for confrim" are waiting for the reporter. After 15
# days without activity the issue gets a reminder and the "no response"
# label; after 15 more days it is closed. A reply from the reporter removes
# both labels, so the issue shows up as needing the maintainer again.
name: Wait for confirmation
on:
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
issue_comment:
types: [created]
permissions:
issues: write
jobs:
remind-and-close:
if: github.event_name != 'issue_comment'
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
only-issue-labels: "wait for confrim"
days-before-issue-stale: 15
days-before-issue-close: 15
days-before-pr-stale: -1
days-before-pr-close: -1
stale-issue-label: "no response"
stale-issue-message: >-
Still there? We asked for more information 15 days ago. A reply
would help; if we hear nothing in another 15 days this issue will
be closed. It can be reopened any time.
close-issue-message: >-
No reply in 30 days, so this issue is closed for now. If the
problem is still there, reply or reopen it.
close-issue-reason: not_planned
remove-stale-when-updated: true
reporter-replied:
if: >-
github.event_name == 'issue_comment' &&
contains(github.event.issue.labels.*.name, 'wait for confrim') &&
github.event.comment.user.type != 'Bot' &&
!contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
runs-on: ubuntu-latest
steps:
- name: The reporter answered, so the issue is the maintainer's again
run: >-
gh issue edit "$NUMBER" --repo "$REPO"
--remove-label "wait for confrim" --remove-label "no response"
env:
GH_TOKEN: ${{ github.token }}
NUMBER: ${{ github.event.issue.number }}
REPO: ${{ github.repository }}
+1
View File
@@ -117,6 +117,7 @@ Then ask the AI for "a flowchart of user authentication with login, MFA and sess
- A screenshot tool, so the AI can look at the rendered diagram and fix it
- Version history, multi-page diagrams, and download as `.drawio`, `.png`, `.svg` or `.drawio.svg`
- Auto-save to `~/.next-ai-drawio/`, so a diagram survives a restart
- Ask the AI to undo, and keep your own drawing rules in `~/.next-ai-drawio/instructions.md`
See the [MCP server README](./packages/mcp-server/README.md) for VS Code, Cursor and other client configurations.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

+2 -8
View File
@@ -3,7 +3,7 @@
import { getAssetUrl } from "@/lib/base-path"
import { cn } from "@/lib/utils"
/** The app icon in a rounded tile; the white variant is used in dark mode */
/** The app icon in a rounded tile; the icon has its own white tile, so dark mode uses it too */
export function BrandMark({ className }: { className?: string }) {
return (
<span
@@ -16,13 +16,7 @@ export function BrandMark({ className }: { className?: string }) {
<img
src={getAssetUrl("/favicon-192x192.png")}
alt=""
className="size-full object-contain dark:hidden"
/>
{/* biome-ignore lint/performance/noImgElement: tiny static icon */}
<img
src={getAssetUrl("/favicon-white.svg")}
alt=""
className="hidden size-5 object-contain dark:block"
className="size-full object-contain"
/>
</span>
)
+1
View File
@@ -117,6 +117,7 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
- 截图工具,AI 可以看一眼画好的图并自行修正
- 版本历史、多页图表,下载为 `.drawio`、`.png`、`.svg` 或 `.drawio.svg`
- 自动保存到 `~/.next-ai-drawio/`,重启后接着画
- 可以让 AI 撤销修改,也可以在 `~/.next-ai-drawio/instructions.md` 里写下自己的画图规则
VS Code、Cursor 等客户端的配置见 [MCP 服务器 README](../../packages/mcp-server/README.md)。
+1
View File
@@ -117,6 +117,7 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
- スクリーンショットツール。AI が描いた結果を確認して自分で直せる
- バージョン履歴、複数ページのダイアグラム、`.drawio`、`.png`、`.svg`、`.drawio.svg` でのダウンロード
- `~/.next-ai-drawio/` への自動保存。再起動後も続きから描ける
- AI に元に戻す操作を頼める。独自の作図ルールは `~/.next-ai-drawio/instructions.md` に書ける
VS Code、Cursor などの設定は [MCP サーバーの README](../../packages/mcp-server/README.md) を参照してください。
+1 -51
View File
@@ -2,8 +2,6 @@ import { type ClassValue, clsx } from "clsx"
import * as pako from "pako"
import { twMerge } from "tailwind-merge"
import { hasCells } from "@/packages/mcp-server/src/pages.ts"
import { readStyleClasses } from "@/packages/mcp-server/src/style-classes.ts"
import { repairQuoteBeforeSlash } from "@/packages/mcp-server/src/xml-validation.ts"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
@@ -34,55 +32,7 @@ export function isRealDiagram(xml: string | undefined | null): boolean {
// mxCell XML Helpers
// ============================================================================
/**
* Check if mxCell XML output is complete (not truncated).
* Complete XML ends with a self-closing tag (/>) or closing mxCell tag.
* Uses a robust approach that handles any LLM provider's wrapper tags
* by finding the last valid mxCell ending and checking if suffix is just closing tags.
* @param xml - The XML string to check (can be undefined/null)
* @returns true if XML appears complete, false if truncated or empty
*/
export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
// Named style definitions before the cells are not cells: output cut off
// right after them is incomplete. A compact cell whose last quote is
// missing is complete; prepareNewDiagram repairs it.
const trimmed = repairQuoteBeforeSlash(
readStyleClasses(xml || "").xml,
).trim()
if (!trimmed) return false
// Find position of last complete mxCell ending (either /> or </mxCell>)
const lastSelfClose = trimmed.lastIndexOf("/>")
const lastMxCellClose = trimmed.lastIndexOf("</mxCell>")
const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)
// No valid ending found at all
if (lastValidEnd === -1) return false
// If the last mxCell has no </mxCell> after it, it must be self-closing.
// Otherwise the trailing "/>" belongs to a child such as <mxGeometry .../>
// and the output was cut off before the cell was closed.
const lastCellStart = trimmed.lastIndexOf("<mxCell")
if (
lastCellStart > lastMxCellClose &&
// (quoted values may hold a raw "<", which the auto-fix escapes)
!/^<mxCell\b(?:[^<>"']|"[^"]*"|'[^']*')*\/>/.test(
trimmed.slice(lastCellStart),
)
) {
return false
}
// Check what comes after the last valid ending
// For />: add 2 chars, for </mxCell>: add 9 chars
const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2
const suffix = trimmed.slice(lastValidEnd + endOffset)
// If suffix is empty or only contains closing tags (any provider's wrapper) or whitespace, it's complete
// This regex matches any sequence of closing XML tags like </foo>, </bar>, </|DSML|xyz>
return /^(\s*<\/[^>]+>)*\s*$/.test(suffix)
}
export { isMxCellXmlComplete } from "@/packages/mcp-server/src/new-diagram.ts"
/**
* Extract only complete mxCell elements from partial/streaming XML.
+36 -36
View File
@@ -75,7 +75,7 @@
},
"devDependencies": {
"@anthropic-ai/tokenizer": "^0.0.4",
"@biomejs/biome": "2.5.7",
"@biomejs/biome": "2.5.15",
"@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
@@ -2093,9 +2093,9 @@
}
},
"node_modules/@biomejs/biome": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.7.tgz",
"integrity": "sha512-zr8K/DcY5tYsQOQwqMJ0AWElo6QgmgNI7idXgXLhevVszlt8RGVpesEJPqx3ThazLaOwjJ5Y8fz3BtH5fGZNsw==",
"version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.5.15.tgz",
"integrity": "sha512-WZTW4slm/pdkh92K6t/3aEN++44JD1PQ7squ7RCMLI1flHGl43DVOIGXvCjHiZgBMO1V4uYxoFNtqrIA4qGuIQ==",
"dev": true,
"license": "MIT OR Apache-2.0",
"bin": {
@@ -2109,20 +2109,20 @@
"url": "https://opencollective.com/biome"
},
"optionalDependencies": {
"@biomejs/cli-darwin-arm64": "2.5.7",
"@biomejs/cli-darwin-x64": "2.5.7",
"@biomejs/cli-linux-arm64": "2.5.7",
"@biomejs/cli-linux-arm64-musl": "2.5.7",
"@biomejs/cli-linux-x64": "2.5.7",
"@biomejs/cli-linux-x64-musl": "2.5.7",
"@biomejs/cli-win32-arm64": "2.5.7",
"@biomejs/cli-win32-x64": "2.5.7"
"@biomejs/cli-darwin-arm64": "2.5.15",
"@biomejs/cli-darwin-x64": "2.5.15",
"@biomejs/cli-linux-arm64": "2.5.15",
"@biomejs/cli-linux-arm64-musl": "2.5.15",
"@biomejs/cli-linux-x64": "2.5.15",
"@biomejs/cli-linux-x64-musl": "2.5.15",
"@biomejs/cli-win32-arm64": "2.5.15",
"@biomejs/cli-win32-x64": "2.5.15"
}
},
"node_modules/@biomejs/cli-darwin-arm64": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.7.tgz",
"integrity": "sha512-vxo/Ls3/PYdQWyLhYYcgMOCzQypAjcY+iihS8M0wW03l16TCLW4zqZzGo75gm1VdCMj38hTVZ31KBWrZ4G9dJw==",
"version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.5.15.tgz",
"integrity": "sha512-BZVzFhJ/mUvTLMYbc9x6el0o2Uv5zP7bACYyhFb6fSc1giroXm7PL0a5KqMJ9F+/BnkYRjagD/ROSDVOZ6eapg==",
"cpu": [
"arm64"
],
@@ -2137,9 +2137,9 @@
}
},
"node_modules/@biomejs/cli-darwin-x64": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.7.tgz",
"integrity": "sha512-Cd3Ga61amT/Yl/0x8elP5hhGYaFy4bw6WuysTgf7oo8TA5tJ5A1k+DkVoJ2BHbTVil51gTX9VPzArnrlLJ3Kyg==",
"version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.5.15.tgz",
"integrity": "sha512-V5Kw63V+fVGNFhFrizDxMbGXAzZCh8kYzJpy3GI6gTapWDg7bZK9oYC9CbVgQSEdTnupz0U5Efz8Ev+5vdBBXw==",
"cpu": [
"x64"
],
@@ -2154,9 +2154,9 @@
}
},
"node_modules/@biomejs/cli-linux-arm64": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.7.tgz",
"integrity": "sha512-rR2QE0yF2GYSuYuKIa7pKvODGJqnOH+2eDREAM8wV+mWKSkMQKdAp4zXEZfTaxY8PMoNONnpgSWcBCyLDPDOKg==",
"version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.5.15.tgz",
"integrity": "sha512-XaG7P7eeSLYETD3K9grfB0mQmpLQZjygdbsxekWBJAg4am79fGAtRpfRyNnTvXB2fZFNnJZG3nXm7HBat0+VUw==",
"cpu": [
"arm64"
],
@@ -2174,9 +2174,9 @@
}
},
"node_modules/@biomejs/cli-linux-arm64-musl": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.7.tgz",
"integrity": "sha512-xPI5yB6XlpDbNkS+bm1t42olw5c4l3UrlOmLg7KtLJvjvkNF/1V4tnUgfkylGIeb3u/T+BzMGYqgQhzjAoJzuQ==",
"version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.5.15.tgz",
"integrity": "sha512-tGzZUTcJCV7tj5Adh/Gn6nR4MycqA3iohq2LEcrarAgRBkU0h0OKd2UzCRkOsOYOuUGaUJb4YmqRqyy2feGGSw==",
"cpu": [
"arm64"
],
@@ -2194,9 +2194,9 @@
}
},
"node_modules/@biomejs/cli-linux-x64": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.7.tgz",
"integrity": "sha512-FQgqJhscrqJUFptGaRSUJWlXAExwWcDwLuK49dvKfkQ1bB5SEEyFssnsxQY83Xm6jR0EbbX3+8+D5bfvYqUG2Q==",
"version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.5.15.tgz",
"integrity": "sha512-xE4iEW/3LqlYj9GFgGrFsFSH51dEEpUbYWBfeOv0q87WkUDxK2o/HhreSb7qMJqck70RVM6Lg96hgedcAYcOkA==",
"cpu": [
"x64"
],
@@ -2214,9 +2214,9 @@
}
},
"node_modules/@biomejs/cli-linux-x64-musl": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.7.tgz",
"integrity": "sha512-rE5VZi+qtmPgQH+l7jVxYoZ18b/TiHEhulhMpjmCZH1PltSbjRcxNWywC3HZ9tYottG7ORkeTtoscBilKSBm0g==",
"version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.5.15.tgz",
"integrity": "sha512-IlxUcyxilVGPsE008x13pWdkTbU3nQpP2i9b5UrbOYj6goBkKsRX1XB7yJLcV+O1H9LBchIIm4adOaVLZBX0ZQ==",
"cpu": [
"x64"
],
@@ -2234,9 +2234,9 @@
}
},
"node_modules/@biomejs/cli-win32-arm64": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.7.tgz",
"integrity": "sha512-Oq4x0CCwP4jirrcTywXs5kOGZ4v5vuEP+gWrbtjApOA2CL9F3F9GlIdQIci8AKSCa/zURanMRpX/4wQ7Am6hHg==",
"version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.5.15.tgz",
"integrity": "sha512-2kPKzhNlm8C+Ru3GcO0Me2ODkCBLrVOUNuyi84RJyWDVKAA4+Kjj3jMjL938lF6BRfvltC4vB2nPY9SvN51Ovg==",
"cpu": [
"arm64"
],
@@ -2251,9 +2251,9 @@
}
},
"node_modules/@biomejs/cli-win32-x64": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.7.tgz",
"integrity": "sha512-V+0wu/nrj2S+MhP4EQ0uHNolP0IALEsz45pg0WoKkHfDeh0+ItHwP/p7bX5RPoMOl9NkpHYWdYPhIcy2mACHvQ==",
"version": "2.5.15",
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.5.15.tgz",
"integrity": "sha512-yAzh4UqEImV6Hcy0zjUqNfJAsUhoD64FBQCdTG8Md/Z2wQeVP/ZmJ3XRYAs1g8J9fjTFyl2fCtc5FB5til47+g==",
"cpu": [
"x64"
],
+1 -1
View File
@@ -110,7 +110,7 @@
},
"devDependencies": {
"@anthropic-ai/tokenizer": "^0.0.4",
"@biomejs/biome": "2.5.7",
"@biomejs/biome": "2.5.15",
"@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
+23 -3
View File
@@ -27,11 +27,13 @@ claude mcp add drawio -- npx @next-ai-drawio/mcp-server@latest
- **Real-time Preview**: Diagrams appear and update in your browser as Claude creates them
- **Drawing Rules and Shape Libraries**: Claude gets the web app's layout and style rules and the icon docs for AWS, Azure, GCP, Kubernetes and more
- **Self-check**: Claude can take a screenshot of the rendered diagram and fix what looks wrong
- **Draw from Your Files**: ask Claude to draw from a document, image or web page; it reads the source with its own tools and draws. Existing .drawio files open with load_diagram
- **Version History**: Restore one of the last 20 versions from the **History** button, shown as thumbnails
- **Natural Language**: Describe diagrams in plain text - flowcharts, architecture diagrams, etc.
- **Edit Support**: Modify existing diagrams with natural language instructions, including your own edits in the browser
- **Export**: Save diagrams as `.drawio`, `.png`, `.svg`, or `.drawio.svg` files
- **Auto-save**: Each diagram is saved to `~/.next-ai-drawio/`, so `claude --resume` can pick it up again
- **Custom Instructions**: keep your own drawing rules in `~/.next-ai-drawio/instructions.md` (for example "Always draw in minimal style"); they are appended to the drawing guide on every call
- **Self-contained**: Embedded server, no external dependencies required
## Use Case Examples
@@ -71,20 +73,34 @@ Create a sequence diagram showing OAuth 2.0 authorization code flow
between user, client app, auth server, and resource server
```
### 6. Draw From a Document
```
Read docs/architecture.md and draw the system as a diagram
```
### 7. Recreate a Sketch
```
Recreate the whiteboard photo at ~/Desktop/sketch.jpg as a clean draw.io diagram
```
## Available Tools
| Tool | Description |
|------|-------------|
| `start_session` | Opens browser with real-time diagram preview; the result includes the drawing rules |
| `start_session` | Opens browser with real-time diagram preview; the result includes the drawing rules. Pass `session_id` to continue a saved diagram |
| `list_saved_diagrams` | List the auto-saved diagrams of earlier sessions, newest first, with their pages |
| `get_drawing_guide` | Return the drawing rules again |
| `get_shape_library` | Return the shapes and icon styles of a library such as `aws4` |
| `create_new_diagram` | Create a new diagram from XML |
| `load_diagram` | Load a `.drawio` file from disk |
| `load_diagram` | Load a `.drawio` or `.drawio.svg` file from disk |
| `edit_diagram` | Edit diagram by ID-based operations; all or nothing |
| `get_diagram` | Get the current diagram XML |
| `screenshot_diagram` | Return a PNG of a page so Claude can check the result |
| `export_diagram` | Save diagram to a `.drawio`, `.png`, `.svg`, or `.drawio.svg` file |
| `list_pages`, `add_page`, `rename_page`, `delete_page` | Work with multi-page diagrams |
| `restore_version` | Undo: go back to an earlier version from History (redo is possible too) |
## How It Works
@@ -103,7 +119,11 @@ Claude Code <--stdio--> MCP Server <--http--> Browser (draw.io)
|----------|---------|-------------|
| `PORT` | `6002` | Port for the embedded HTTP server |
| `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for draw.io (for self-hosted deployments) |
| `DRAWIO_DATA_DIR` | `~/.next-ai-drawio` | Folder for auto-saved diagrams; `off` turns auto-save off |
| `DRAWIO_DATA_DIR` | `~/.next-ai-drawio` | Folder for auto-saved diagrams and your `instructions.md`; `off` turns auto-save off (`instructions.md` is then read from the default folder) |
| `DRAWIO_LANG` | unset | Language of the draw.io editor, such as `en`, `zh`, `zh-tw`, `ja` or `de`. Unset, draw.io chooses (the browser language on `embed.diagrams.net`, English on a self-hosted draw.io) and the user can change it under **Extras > Language** |
| `DRAWIO_UI` | unset | draw.io theme: `kennedy`, `atlas`, `dark`, `min`, `sketch` or `simple`. Unset, the user picks one under **Extras > Theme** |
| `DRAWIO_DARK` | `auto` | Dark mode of the draw.io editor: `auto` follows the system, `1` forces dark, `0` forces light |
| `DRAWIO_AUTO_SCREENSHOT` | unset | Set to `true` to attach a screenshot to every `create_new_diagram` and `edit_diagram` result (costs 2 to 10 s per call) |
| `DEBUG` | unset | Set to `true` to log debug messages |
## Links
+31 -11
View File
@@ -99,31 +99,36 @@ Use the standard MCP configuration with:
1. Restart your MCP client after updating config
2. Ask the AI to create a diagram:
> "Create a flowchart showing user authentication with login, MFA, and session management"
> "Read docs/architecture.md and draw the system as a diagram"
3. The diagram appears in your browser in real-time!
## Features
- **Real-time Preview**: Diagrams appear and update in your browser as the AI creates them
- **Drawing Rules**: The AI gets the same layout, edge and style rules as the web app, plus the shape library docs (AWS, Azure, GCP, Kubernetes, Cisco and more), so it uses real icon names instead of guessing
- **Self-check**: The AI can take a screenshot of the rendered diagram and fix overlapping shapes or edges that cross shapes
- **Self-check**: The AI can take a screenshot of the rendered diagram (`screenshot: true` on `create_new_diagram` or `edit_diagram`, or `screenshot_diagram`) and fix overlapping shapes or edges that cross shapes
- **Draw from Your Files**: ask the AI to draw from a document, image or web page; it reads the source with the host's own tools and draws. Existing .drawio files open with load_diagram
- **Edit Support**: Modify existing diagrams with natural language instructions. If any change in an edit fails, nothing is written and the AI gets the reason and the current page XML
- **Your Edits Are Kept**: Changes you make in the browser are read before the AI edits again. If the AI overwrites a change you were still making, your version is saved in History
- **Version History**: Click **History** at the top right of the preview page to restore one of the last 20 versions, shown as thumbnails
- **Download and Export**: Save as `.drawio`, `.png`, `.svg`, or `.drawio.svg` (an SVG with the diagram embedded, which draw.io can open and edit again), from the **Download** button or through `export_diagram`
- **Version History**: Click **History** at the top right of the preview page to restore one of the last 20 versions, shown as thumbnails, or ask the AI to undo (`restore_version`)
- **Download and Export**: Save as `.drawio`, `.png`, `.svg`, or `.drawio.svg` (an SVG with the diagram embedded, which draw.io and `load_diagram` can open again), from the **Download** button or through `export_diagram`
- **Multi-page**: List, add, rename, and delete pages, and edit any page
- **Auto-save**: Each session's diagram is saved to `~/.next-ai-drawio/<session-id>.drawio`, so it survives a restart of the MCP client
- **Themes and Dark Mode**: Pick a draw.io theme under **Extras > Theme**; the page follows the system dark mode
- **Auto-save**: Each session's diagram is saved to `~/.next-ai-drawio/<session-id>.drawio`, and its last 20 versions to `<session-id>.history.json`, so the diagram, History and undo survive a restart of the MCP client
- **Custom Instructions**: keep your own drawing rules in `~/.next-ai-drawio/instructions.md` (for example "Always draw in minimal style"); they are appended to the drawing guide on every call
- **Themes and Dark Mode**: Pick a draw.io theme under **Extras > Theme**; the page follows the system dark mode, or fix them with `DRAWIO_UI`, `DRAWIO_DARK` and `DRAWIO_LANG`
- **Self-contained**: Embedded server, works offline (except draw.io UI which loads from `embed.diagrams.net` by default, configurable via `DRAWIO_BASE_URL`)
## Available Tools
| Tool | Description |
|------|-------------|
| `start_session` | Opens browser with real-time diagram preview; the result includes the drawing rules |
| `get_drawing_guide` | Return the drawing rules again, for example after a long conversation was compacted |
| `start_session` | Opens browser with real-time diagram preview; the result includes the drawing rules. Pass `session_id` to continue a saved diagram |
| `list_saved_diagrams` | List the auto-saved diagrams of earlier sessions, newest first, with their pages |
| `get_drawing_guide` | Return the drawing rules again, for example after a long conversation was compacted, or, with `topic`, a short XML reference for tables, layers or groups |
| `get_shape_library` | Return the shapes and icon styles of a library such as `aws4`, `azure2`, or `kubernetes` |
| `create_new_diagram` | Create a new diagram from XML; a plain list of `mxCell` elements is enough |
| `load_diagram` | Load a `.drawio` file from disk into the session (handles compressed files) |
| `load_diagram` | Load a `.drawio` or `.drawio.svg` file into the session, from a `path` on disk or from its `xml` content (handles compressed files) |
| `edit_diagram` | Edit diagram by ID-based operations (update/add/delete cells); all or nothing |
| `get_diagram` | Get the current diagram XML, including your edits in the browser |
| `screenshot_diagram` | Return a PNG of a page so the AI can check the rendered diagram |
@@ -132,12 +137,23 @@ Use the standard MCP configuration with:
| `add_page` | Append a new page without touching existing ones |
| `rename_page` | Rename a page |
| `delete_page` | Delete a page (refuses to delete the last one) |
| `restore_version` | Undo: put an earlier version from History back on the canvas; the current one is kept, so a redo is possible |
## Continue a Diagram Later
After every change, the diagram is saved as a normal `.drawio` file in `~/.next-ai-drawio/`, and `start_session` tells the AI the file path. When you resume a conversation after restarting your MCP client (for example `claude --resume`), the AI calls `start_session` and then `load_diagram` with that path. You can also open the file in draw.io yourself.
After every change, the diagram is saved as a normal `.drawio` file in `~/.next-ai-drawio/`, named after the session id (for example `mcp-mgd0a1b2-x7k2p1.drawio`). `start_session` tells the AI both the id and the file path.
The newest 50 files are kept. Set `DRAWIO_DATA_DIR` to use another folder, or to `off` to turn auto-save off.
To continue a diagram in a later conversation (for example after `claude --resume`, or in a new chat), the AI calls `start_session` with `session_id` set to that id: the same preview URL opens, the saved diagram is shown, and auto-save keeps writing to the same file. If the id is no longer in the conversation, `list_saved_diagrams` returns every saved diagram, newest first, with the names and cell counts of its pages, so you can ask for "the architecture diagram from yesterday" and let the AI pick it. `load_diagram` with the file path still works too, and you can open the file in draw.io yourself.
History comes back with the diagram: the last 20 versions are saved next to it in `<session-id>.history.json` (without thumbnails). When the AI continues the session with `start_session` and its `session_id`, or a preview tab is still open after a restart, the **History** button shows them again and `restore_version` can undo to them.
The newest 50 files are kept, each with its History file. Set `DRAWIO_DATA_DIR` to use another folder, or to `off` to turn auto-save off.
## Custom Instructions
To give the AI your own drawing rules, write them in `~/.next-ai-drawio/instructions.md` as Markdown, for example "Always draw in minimal style" or "Label every edge". The file is appended to the drawing guide under a `## Custom Instructions` heading each time the guide is returned (`start_session`, `get_drawing_guide` and the `diagram-workflow` prompt), so edits apply without restarting your MCP client. Only the first 5000 characters are used.
`DRAWIO_DATA_DIR` changes the folder the file is read from; with `DRAWIO_DATA_DIR=off` the default folder is still used, since the file is only read. The text reaches the model as it is, so keep it to drawing rules you trust.
## How It Works
@@ -168,7 +184,11 @@ The newest 50 files are kept. Set `DRAWIO_DATA_DIR` to use another folder, or to
|----------|---------|-------------|
| `PORT` | `6002` | Port for the embedded HTTP server |
| `DRAWIO_BASE_URL` | `https://embed.diagrams.net` | Base URL for the draw.io embed. Set this to use a self-hosted draw.io instance for private deployments. |
| `DRAWIO_DATA_DIR` | `~/.next-ai-drawio` | Folder for the auto-saved `.drawio` files. Set to `off` to turn auto-save off. |
| `DRAWIO_DATA_DIR` | `~/.next-ai-drawio` | Folder for the auto-saved `.drawio` files and your `instructions.md`. Set to `off` to turn auto-save off (`instructions.md` is then read from the default folder). |
| `DRAWIO_LANG` | unset | Language of the draw.io editor. Unset, draw.io chooses: the browser language on `embed.diagrams.net`, English on a self-hosted draw.io until the user picks one under **Extras > Language**. A code such as `en`, `zh`, `zh-tw`, `ja` or `de` fixes it and hides that submenu. |
| `DRAWIO_UI` | unset | draw.io theme. Unset, the user picks one under **Extras > Theme** and draw.io remembers it. `kennedy`, `atlas`, `dark`, `min`, `sketch` or `simple` fixes the theme and hides that menu. |
| `DRAWIO_DARK` | `auto` | Dark mode of the draw.io editor: `auto` follows the system, `1` forces dark, `0` forces light. The page header keeps following the system. |
| `DRAWIO_AUTO_SCREENSHOT` | unset | Set to `true` to attach a screenshot to every `create_new_diagram` and `edit_diagram` result, so the AI checks each drawing. Costs 2 to 10 s per call; the preview tab must be open and in front. A call can still pass `screenshot: false`. |
| `DEBUG` | unset | Set to `true` to log debug messages to stderr. |
### Private Deployment (Self-hosted draw.io)
@@ -0,0 +1,58 @@
/**
* The user's own drawing rules, kept in a Markdown file and appended to the
* drawing guide, like the web app's custom system message
* (app/api/chat/route.ts). The file is read on every call, so edits apply
* without restarting the MCP host.
*/
import { readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import { log } from "./logger.ts"
import { expandHome } from "./persistence.ts"
export const CUSTOM_INSTRUCTIONS_FILE = "instructions.md"
// Same cap as the web app's custom system message
const MAX_CHARS = 5000
/**
* DRAWIO_DATA_DIR, default ~/.next-ai-drawio. "off" only turns auto-save
* off; this file is merely read, so the default folder is used then.
*/
export function customInstructionsDir(): string {
const dir = process.env.DRAWIO_DATA_DIR
return dir && dir !== "off"
? expandHome(dir)
: join(homedir(), ".next-ai-drawio")
}
/** Full path of the instructions file. */
export function customInstructionsPath(dir = customInstructionsDir()): string {
return join(dir, CUSTOM_INSTRUCTIONS_FILE)
}
let warned = false
/** The file's content, trimmed and capped; "" when there is none. */
export function readCustomInstructions(dir = customInstructionsDir()): string {
const path = customInstructionsPath(dir)
try {
return readFileSync(path, "utf-8").trim().slice(0, MAX_CHARS)
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT" && !warned) {
warned = true
log.warn(`Could not read the custom instructions ${path}: ${error}`)
}
return ""
}
}
/** The guide with the user's rules appended under "## Custom Instructions". */
export function guideWithCustomInstructions(
guide: string,
dir?: string,
): string {
const text = readCustomInstructions(dir)
return text ? `${guide}\n\n## Custom Instructions\n${text}` : guide
}
+14 -4
View File
@@ -18,13 +18,18 @@ import {
export const DRAWING_GUIDE = `# Draw.io drawing guide
## Workflow
- create_new_diagram draws a new diagram and REPLACES the whole document. add_page adds another tab. edit_diagram changes cells of an existing page. load_diagram opens a .drawio file (the server reads the file itself). get_diagram returns the current XML, including the user's manual edits. export_diagram saves to a file.
- create_new_diagram draws a new diagram and REPLACES the whole document. add_page adds another tab. edit_diagram changes cells of an existing page. load_diagram opens a .drawio file (the server reads the file itself, or takes the file's content as its 'xml' argument when you already have it in hand). get_diagram returns the current XML, including the user's manual edits. export_diagram saves to a file.
- Before drawing, describe your layout plan in 2-3 sentences, so shapes do not overlap and edges do not cross shapes.
- Send XML only through tool calls, never in chat text. Never draw a box just to send the user a message.
- Before using any icon library (AWS, Azure, GCP, Kubernetes, Cisco, BPMN, Material Design, web icons...), call get_shape_library and use the exact style names it returns. NEVER guess icon style names. For AWS, use the AWS 2025 icons (library aws4).
- After drawing or heavily editing a complex diagram, call screenshot_diagram once to see the result, and fix overlapping shapes and edges that cross shapes.
- When replicating a diagram from an image, match its style and layout closely: straight or curved lines, rounded or square shapes.
- After drawing or heavily editing a complex diagram, pass screenshot: true on that create_new_diagram or edit_diagram call (or call screenshot_diagram) to see the result, and fix overlapping shapes and edges that cross shapes.
- Drawing from a source: to draw from a document (PDF, Markdown, code, data), an image or screenshot, or a web page, first read it yourself (the attachment the user shared, or your own file-reading or web-fetch tools), then plan the layout and draw. This server never receives attachments; it only receives the XML you send. Extract the entities and relationships the diagram needs; do not copy the text into boxes.
- When replicating a diagram from an image, match its style and layout closely: straight or curved lines, rounded or square shapes, colors and relative positions. Call screenshot_diagram afterwards and compare with the original.
- After a successful create_new_diagram or edit_diagram call, do not describe the diagram; the user sees it in the preview. One short sentence at most.
- For artistic requests (a cat, a logo, a scene), compose the picture from standard shapes and connectors while keeping it clear.
- To clear the canvas to one blank page, call create_new_diagram with only the two root cells <mxCell id="0"/><mxCell id="1" parent="0"/>; the previous diagram stays in History.
- The preview page has History (it saves a snapshot before every AI change and can restore any of the last 20 versions) and Download. You can make changes freely; nothing is lost.
- If the user asks to undo or go back, call restore_version instead of re-sending the earlier XML from memory.
## The XML you send
Single page (create_new_diagram, add_page): send ONLY the named styles and the mxCell elements. The server adds <mxfile>, <mxGraphModel>, <root> and the root cells id="0" and id="1", expands named styles (see Styles), adds html=1 to every cell and whiteSpace=wrap to shapes, and fills in vertex, edge, parent="1" and the mxGeometry element. A shape is one self-closing mxCell with x, y, w and h; an edge is one with source and target. A cell with source or target is always an edge. Write parent only for a shape inside a container, and an mxGeometry element only for edge waypoints or for a separate label cell placed on an edge: <mxCell id="9" value="yes" style="edgeLabel;" parent="<edge id>" connectable="0"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>. An edge's own text simply goes in its value.
@@ -47,6 +52,8 @@ Containers and swimlanes: children use the container id as parent and coordinate
${indent(SWIMLANE_EXAMPLE)}
Large diagrams: if the diagram will not fit one call, send the first cells with create_new_diagram and add the rest with edit_diagram add operations (one per cell, same id rules). A call rejected because the XML ends inside an unfinished cell drew nothing: send all of its cells again, in smaller parts if needed.
## Layout
- Keep every element of a page within x 0 to 800 and y 0 to 600, so the whole diagram fits one view without a page break.
- Containers (for example AWS cloud boxes) are at most 700 pixels wide and 550 pixels tall.
@@ -87,6 +94,9 @@ ${indent(STYLE_CLASS_EXAMPLE)}
- Edges: endArrow=block, open or none; startArrow=classic; curved=1; dashed=1; edgeStyle=orthogonalEdgeStyle
- Text: fontSize=14, fontStyle=1 (bold), align=center or right
- Animated connectors: add flowAnimation=1 to the edge style.
- Edge options: dashed=1; strokeWidth=2; edgeStyle=elbowEdgeStyle;elbow=vertical (or horizontal); jumpStyle=arc;jumpSize=10 where edges cross.
For tables, layers or groups, call get_drawing_guide with topic=tables, layers or groups.
## Minimal style
When the user asks for a minimal, plain, black-and-white or unstyled diagram, use these rules instead of the styles above:
@@ -100,7 +110,7 @@ When the user asks for a minimal, plain, black-and-white or unstyled diagram, us
- add inserts a new cell with a new id. One cell per operation.
- delete removes a cell. Its children and every edge connected to it are deleted too, so give only the container's id.
- All-or-nothing: if any operation fails, nothing is applied. A rejected call includes the current XML of the page; rebuild your operations on it and retry.
- If the diagram is large, change it with edit_diagram instead of redrawing it.
- Use create_new_diagram for a new diagram, a major restructuring or an empty canvas. Use edit_diagram for small changes: adding or removing a few elements, labels, colors or positions. Never redraw a large diagram for a small change.
{"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" x=\\"100\\" y=\\"100\\" w=\\"120\\" h=\\"60\\"/>"}]}
{"page_name": "CNN", "operations": [{"operation": "add", "cell_id": "conv-1", "new_xml": "<mxCell id=\\"conv-1\\" value=\\"Conv\\" x=\\"40\\" y=\\"40\\" w=\\"120\\" h=\\"60\\"/>"}]}
+24
View File
@@ -0,0 +1,24 @@
export const DRAWIO_THEMES = [
"kennedy",
"atlas",
"dark",
"min",
"sketch",
"simple",
] as const
export type DrawioTheme = (typeof DRAWIO_THEMES)[number]
export function isDrawioTheme(value: unknown): value is DrawioTheme {
return (
typeof value === "string" &&
(DRAWIO_THEMES as readonly string[]).includes(value)
)
}
/** draw.io's name for a locale: lowercase, "zh-tw" for Traditional and "zh" for Simplified Chinese */
export function toDrawioLang(locale: string): string {
const lang = locale.toLowerCase()
if (lang === "zh-hant") return "zh-tw"
return lang === "zh-hans" ? "zh" : lang
}
+113
View File
@@ -142,3 +142,116 @@ export function markPageSeen(
? liveXml
: lastSeenXml
}
const CELL_TAGS = new Set(["mxCell", "UserObject", "object"])
/** The cells of a page by id: the direct children of its <root>. */
function pageCells(page: Element): Map<string, Element> {
const cells = new Map<string, Element>()
for (const el of Array.from(page.querySelector("root")?.children ?? [])) {
const id = el.getAttribute("id")
if (id && CELL_TAGS.has(el.tagName)) cells.set(id, el)
}
return cells
}
/** `id ("label")`: the cell's value without HTML, cut at 30 characters. */
function describeCell(id: string, cell: Element): string {
// getAttributeNode gives the decoded value (linkedom's getAttribute
// escapes it again); an HTML label then still holds tags and entities
const label = (
(cell.getAttributeNode("value") ?? cell.getAttributeNode("label"))
?.value ?? ""
)
.replace(/<[^>]*>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&amp;/g, "&")
.replace(/\s+/g, " ")
.trim()
if (!label) return id
return `${id} ("${label.length > 30 ? `${label.slice(0, 30)}...` : label}")`
}
/** At most 20 entries, then "and N more". */
function listCells(cells: string[]): string {
const shown = cells.slice(0, 20).join(", ")
return cells.length > 20 ? `${shown} and ${cells.length - 20} more` : shown
}
/**
* One paragraph naming what the user changed between the XML the model last
* saw and the live store, for stale rejections and get_diagram. Pages are
* paired by index (draw.io regenerates diagram ids on re-serialisation) and
* cells by id. Returns "" when there is nothing to compare or the two sides
* have the same content.
*/
export function describeChanges(lastSeenXml: string, liveXml: string): string {
if (!lastSeenXml || lastSeenXml === liveXml) return ""
const parse = (xml: string) => {
const normalized = normalizeToMxfile(xml)
return normalized ? parseMxfile(normalized) : null
}
const before = parse(lastSeenXml)
const live = parse(liveXml)
if (!before || !live) return ""
// Same rule as checkEditGate: a bare <mxGraphModel> carries no page name
const includeNames =
!isMxGraphModel(liveXml) && !isMxGraphModel(lastSeenXml)
if (
contentFingerprint(lastSeenXml, includeNames) ===
contentFingerprint(liveXml, includeNames)
)
return ""
const pagesBefore = Array.from(before.querySelectorAll("diagram"))
const pagesLive = Array.from(live.querySelectorAll("diagram"))
const pageCount = Math.max(pagesBefore.length, pagesLive.length)
const clauses: string[] = []
const renames: string[] = []
for (let i = 0; i < pageCount; i++) {
const pageBefore = pagesBefore[i]
const pageLive = pagesLive[i]
if (!pageBefore || !pageLive) {
clauses.push(`page ${i + 1} ${pageLive ? "added" : "removed"}`)
continue
}
const cellsBefore = pageCells(pageBefore)
const cellsLive = pageCells(pageLive)
const modified: string[] = []
const added: string[] = []
const removed: string[] = []
for (const [id, cell] of cellsLive) {
const old = cellsBefore.get(id)
if (!old) added.push(describeCell(id, cell))
else if (canonicalizeElement(old) !== canonicalizeElement(cell))
modified.push(describeCell(id, cell))
}
for (const [id, cell] of cellsBefore) {
if (!cellsLive.has(id)) removed.push(describeCell(id, cell))
}
// Name the page only when the document has several
const where = pageCount > 1 ? `page ${i + 1} ` : ""
if (modified.length)
clauses.push(`${where}modified ${listCells(modified)}`)
if (added.length) clauses.push(`${where}added ${listCells(added)}`)
if (removed.length)
clauses.push(`${where}removed ${listCells(removed)}`)
if (includeNames) {
const name = pageLive.getAttribute("name") ?? ""
if (name !== (pageBefore.getAttribute("name") ?? ""))
renames.push(`Page ${i + 1} renamed to "${name}".`)
}
}
// The fingerprint also covers the cell order (z-order) and anything in
// <root> that is not a cell
if (!clauses.length && !renames.length)
clauses.push("the order of the cells changed")
const intro = "Since you last saw it, the user changed the diagram"
const body = clauses.length
? `${intro}: ${clauses.join("; ")}.`
: `${intro}.`
return [body, ...renames].join(" ")
}
+51 -1
View File
@@ -3,11 +3,13 @@
* Stores {xml, svg} entries in a circular buffer
*/
import { contentFingerprint } from "./edit-gate.ts"
import { log } from "./logger.ts"
import { isMxGraphModel } from "./pages.ts"
const MAX_HISTORY = 20
interface HistoryEntry {
export interface HistoryEntry {
id: number // Stable across shifts of the circular buffer
xml: string
svg: string
@@ -59,6 +61,54 @@ export function clearHistory(sessionId: string): void {
historyStore.delete(sessionId)
}
/**
* The versions the diagram can go back (or forward) to: every entry whose
* content differs from currentXml, newest first, one entry per distinct
* content (its newest copy). Re-serialised copies of one diagram count as
* the same version.
*/
export function otherVersions(
sessionId: string,
currentXml: string,
): HistoryEntry[] {
const kept = [versionOf(currentXml)]
const result: HistoryEntry[] = []
const history = getHistory(sessionId)
for (let i = history.length - 1; i >= 0; i--) {
const version = versionOf(history[i].xml)
if (kept.some((seen) => sameVersion(seen, version))) continue
kept.push(version)
result.push(history[i])
}
return result
}
/** True when History holds a copy of this diagram. */
export function isInHistory(sessionId: string, xml: string): boolean {
const version = versionOf(xml)
return getHistory(sessionId).some((entry) =>
sameVersion(version, versionOf(entry.xml)),
)
}
/** A diagram's fingerprints, with and without its page names */
function versionOf(xml: string) {
return {
bare: isMxGraphModel(xml),
named: contentFingerprint(xml),
cells: contentFingerprint(xml, false),
}
}
// Same rule as checkEditGate: a bare <mxGraphModel> (the browser's sync
// can send one) has no page name, so names count only when both have them
function sameVersion(
a: ReturnType<typeof versionOf>,
b: ReturnType<typeof versionOf>,
): boolean {
return a.bare || b.bare ? a.cells === b.cells : a.named === b.named
}
/**
* Give the last entry the image the browser took of shownXml, the diagram
* it just loaded, when that entry is this diagram
+79 -20
View File
@@ -44,6 +44,7 @@ function readBody(
})
}
import { isDrawioTheme, toDrawioLang } from "./drawio-themes.ts"
import { contentFingerprint } from "./edit-gate.ts"
import {
addHistory,
@@ -569,6 +570,8 @@ function handleStateApi(
data.xml !== current.xml
if (saved) {
addHistory(sessionId, data.xml, data.svg || "")
// Saved with the History, as after any change
stateListener?.(sessionId, current.xml)
}
res.writeHead(409, {
"Content-Type": "application/json",
@@ -605,6 +608,7 @@ function handleStateApi(
// it in history so the user can restore it.
addHistory(sessionId, data.xml, data.svg || "")
savedToHistory = true
if (current) stateListener?.(sessionId, current.xml)
}
res.writeHead(409, { "Content-Type": "application/json" })
res.end(
@@ -670,6 +674,36 @@ function handleHistoryApi(
)
}
/**
* Put a History entry back on the canvas (the preview page's Restore button
* and the restore_version tool). Returns the new version, or null when the
* entry is unknown.
*/
export function restoreHistoryEntry(
sessionId: string,
entryId: number,
): number | null {
const entry = getHistoryEntry(sessionId, entryId)
if (!entry) return null
// Edits in the browser since the last entry are not in history
// yet: keep them, so the restore can be undone
// (any state besides a blank page; a cleared document with its
// own pages counts)
const current = stateStore.get(sessionId)
if (
current &&
contentFingerprint(current.xml) !== contentFingerprint(BLANK_MXFILE)
) {
keepInHistory(sessionId, current.xml, current.svg)
}
const newVersion = setState(sessionId, entry.xml)
addHistory(sessionId, entry.xml, entry.svg)
log.info(`Restored session ${sessionId} to history entry ${entryId}`)
return newVersion
}
function handleRestoreApi(
req: http.IncomingMessage,
res: http.ServerResponse,
@@ -682,37 +716,34 @@ function handleRestoreApi(
readBody(req, res, (body) => {
try {
const { sessionId, id } = JSON.parse(body)
const data = JSON.parse(body)
const { sessionId, id } = data
if (!sessionId || typeof id !== "number") {
res.writeHead(400, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "sessionId and id required" }))
return
}
// Picked from the list of a state the server has since lost
// (a restart reloads History under new ids), or before the tab
// knew the state. A tab of an older version sends none.
const current = stateStore.get(sessionId)
if (
current &&
"stateId" in data &&
data.stateId !== current.stateId
) {
res.writeHead(409, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "Session was recreated" }))
return
}
const entry = getHistoryEntry(sessionId, id)
if (!entry) {
const newVersion = restoreHistoryEntry(sessionId, id)
if (newVersion === null) {
res.writeHead(404, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "Entry not found" }))
return
}
// Edits in the browser since the last entry are not in history
// yet: keep them, so the restore can be undone
// (any state besides a blank page; a cleared document with its
// own pages counts)
const current = stateStore.get(sessionId)
if (
current &&
contentFingerprint(current.xml) !==
contentFingerprint(BLANK_MXFILE)
) {
keepInHistory(sessionId, current.xml, current.svg)
}
const newVersion = setState(sessionId, entry.xml)
addHistory(sessionId, entry.xml, entry.svg)
log.info(`Restored session ${sessionId} to history entry ${id}`)
res.writeHead(200, { "Content-Type": "application/json" })
res.end(JSON.stringify({ success: true, newVersion }))
} catch {
@@ -783,6 +814,33 @@ function loadPreviewTemplate(): string {
const scriptJson = (value: string) =>
JSON.stringify(value).replace(/</g, "\\u003c")
/**
* The configurable tail of the draw.io iframe query: dark mode, and the
* language and theme when the host config fixes them (DRAWIO_DARK,
* DRAWIO_LANG, DRAWIO_UI). lang and ui are left out unless set, because
* draw.io hides its Extras > Language / Theme submenu once they are given.
*/
export function drawioEmbedParams(
env: NodeJS.ProcessEnv = process.env,
): string {
const params = new URLSearchParams()
const ui = (env.DRAWIO_UI ?? "").toLowerCase()
const dark = (env.DRAWIO_DARK ?? "").toLowerCase()
const lang = toDrawioLang(env.DRAWIO_LANG ?? "")
if (["1", "true", "dark"].includes(dark)) {
params.set("dark", "1")
} else if (["0", "false", "light"].includes(dark)) {
params.set("dark", "0")
} else {
// draw.io takes ui=dark as dark mode only when no dark parameter is
// present, and this page always sends one
params.set("dark", !dark && ui === "dark" ? "1" : "auto")
}
if (/^[a-z]{2,3}(-[a-z]{2,4})?$/.test(lang)) params.set("lang", lang)
if (isDrawioTheme(ui)) params.set("ui", ui)
return params.toString()
}
function getHtmlPage(sessionId: string): string {
return loadPreviewTemplate()
.replace("{{SESSION_BADGE}}", () =>
@@ -792,6 +850,7 @@ function getHtmlPage(sessionId: string): string {
)
.replaceAll("{{DISABLED}}", sessionId ? "" : "disabled")
.replace("{{DRAWIO_URL}}", () => normalizeUrl(DRAWIO_BASE_URL))
.replace("{{DRAWIO_PARAMS}}", () => drawioEmbedParams())
.replace("{{SESSION_JSON}}", () => scriptJson(sessionId))
.replace("{{ORIGIN_JSON}}", () => scriptJson(DRAWIO_ORIGIN))
}
File diff suppressed because it is too large Load Diff
+41 -5
View File
@@ -18,7 +18,7 @@ import {
import { getXmlSyntaxError } from "./xml-syntax.ts"
export type LoadResult =
| { ok: true; xml: string }
| { ok: true; xml: string; hadCompressedPages: boolean }
| { ok: false; error: string }
/**
@@ -43,11 +43,27 @@ export function decompressPageContent(compressed: string): string | null {
}
}
/**
* Read the diagram XML a .drawio.svg (Editable SVG) carries in its root
* element's content attribute. Returns undefined when the text is not an
* SVG, and "" when the SVG has no embedded diagram.
*
* Uses getAttributeNode().value rather than getAttribute(): linkedom's
* getAttribute re-escapes <, > and & in XML documents, which would turn
* the embedded "<mxfile" into "&lt;mxfile".
*/
export function extractSvgEmbeddedXml(text: string): string | undefined {
const doc = new DOMParser().parseFromString(text, "text/xml")
const root = doc.documentElement
if (root?.tagName !== "svg") return undefined
return (root.getAttributeNode("content")?.value ?? "").trim()
}
/**
* Parse the content of a .drawio file into the canonical session shape:
* an <mxfile> whose every page holds plain <mxGraphModel> XML. Accepts a
* bare <mxGraphModel> (wrapped into a one-page mxfile) and decompresses
* any compressed pages.
* bare <mxGraphModel> (wrapped into a one-page mxfile), a .drawio.svg with
* the diagram embedded, and decompresses any compressed pages.
*/
export function parseDrawioFileContent(content: string): LoadResult {
let trimmed = content.trim()
@@ -62,9 +78,25 @@ export function parseDrawioFileContent(content: string): LoadResult {
trimmed = normalized
}
if (!isMxFile(trimmed)) {
const embedded = extractSvgEmbeddedXml(trimmed)
if (embedded === undefined) {
return {
ok: false,
error: "Not a draw.io file: expected an <mxfile> or <mxGraphModel> root element, or a .drawio.svg with the diagram embedded.",
}
}
if (!embedded) {
return {
ok: false,
error: "This SVG has no embedded diagram. Only .drawio.svg (Editable SVG) files can be loaded; ask for the .drawio file or redraw from the image.",
}
}
if (isMxFile(embedded) || isMxGraphModel(embedded)) {
return parseDrawioFileContent(embedded)
}
return {
ok: false,
error: "Not a draw.io file: expected an <mxfile> or <mxGraphModel> root element.",
error: "The SVG's embedded content is not draw.io XML.",
}
}
const doc = parseMxfile(trimmed)
@@ -101,5 +133,9 @@ export function parseDrawioFileContent(content: string): LoadResult {
decompressedAny = true
}
// Nothing changed — keep the file's own serialisation.
return { ok: true, xml: decompressedAny ? serializeMxfile(doc) : trimmed }
return {
ok: true,
xml: decompressedAny ? serializeMxfile(doc) : trimmed,
hadCompressedPages: decompressedAny,
}
}
+71 -1
View File
@@ -12,7 +12,7 @@ import {
} from "./style-classes.ts"
import { readAttributes } from "./xml-attributes.ts"
import { getXmlSyntaxError } from "./xml-syntax.ts"
import { validateAndFixXml } from "./xml-validation.ts"
import { repairQuoteBeforeSlash, validateAndFixXml } from "./xml-validation.ts"
export type NewDiagram =
| { ok: true; xml: string; fixes: string[] }
@@ -53,6 +53,74 @@ export function reservedIdError(input: string): string | null {
return null
}
/**
* Check if mxCell XML output is complete (not truncated).
* Complete XML ends with a self-closing tag (/>) or closing mxCell tag.
* Uses a robust approach that handles any LLM provider's wrapper tags
* by finding the last valid mxCell ending and checking if suffix is just closing tags.
* @param xml - The XML string to check (can be undefined/null)
* @returns true if XML appears complete, false if truncated or empty
*/
export function isMxCellXmlComplete(xml: string | undefined | null): boolean {
// Named style definitions before the cells are not cells: output cut off
// right after them is incomplete. A compact cell whose last quote is
// missing is complete; prepareNewDiagram repairs it.
const trimmed = repairQuoteBeforeSlash(
readStyleClasses(xml || "").xml,
).trim()
if (!trimmed) return false
// Find position of last complete mxCell ending (either /> or </mxCell>)
const lastSelfClose = trimmed.lastIndexOf("/>")
const lastMxCellClose = trimmed.lastIndexOf("</mxCell>")
const lastValidEnd = Math.max(lastSelfClose, lastMxCellClose)
// No valid ending found at all
if (lastValidEnd === -1) return false
// If the last mxCell has no </mxCell> after it, it must be self-closing.
// Otherwise the trailing "/>" belongs to a child such as <mxGeometry .../>
// and the output was cut off before the cell was closed.
const lastCellStart = trimmed.lastIndexOf("<mxCell")
if (
lastCellStart > lastMxCellClose &&
// (quoted values may hold a raw "<", which the auto-fix escapes)
!/^<mxCell\b(?:[^<>"']|"[^"]*"|'[^']*')*\/>/.test(
trimmed.slice(lastCellStart),
)
) {
return false
}
// Check what comes after the last valid ending
// For />: add 2 chars, for </mxCell>: add 9 chars
const endOffset = lastMxCellClose > lastSelfClose ? 9 : 2
const suffix = trimmed.slice(lastValidEnd + endOffset)
// If suffix is empty or only contains closing tags (any provider's wrapper) or whitespace, it's complete
// This regex matches any sequence of closing XML tags like </foo>, </bar>, </|DSML|xyz>
return /^(\s*<\/[^>]+>)*\s*$/.test(suffix)
}
/**
* Output that stops inside an mxCell would be wrapped and rejected with an
* error about wrapper tags the model never sent. Bare cells only. Returns the
* error for the model, or null.
*/
export function truncatedCellError(input: string): string | null {
if (/<(mxGraphModel|mxfile)\b/.test(input)) return null
// Comments, and closing tags such as </mxCell/> that the auto-fix
// repairs, are no cut
const stripped = input
.replace(/<!--[\s\S]*?-->/g, "")
.replace(/<\/([a-zA-Z][a-zA-Z0-9]*)\s*\/>/g, "</$1>")
// No cell at all: the validator's message fits better
if (!/<mxCell\b/.test(stripped) || isMxCellXmlComplete(stripped))
return null
return `The XML ends inside an unfinished mxCell, so the output was probably cut off. Nothing was drawn. It ended with:\n${input.slice(-300)}\nResend the complete diagram, or send the first cells now and add the remaining cells with edit_diagram add operations.`
}
/**
* The named style definitions are taken out first (style-classes.ts). Bare
* cells then get the wrapper and root cells, since the strict parser rejects
@@ -96,6 +164,8 @@ export function prepareNewDiagram(
if (styleError) return { ok: false, error: styleError }
const reserved = reservedIdError(cells)
if (reserved) return { ok: false, error: reserved }
const truncated = truncatedCellError(cells)
if (truncated) return { ok: false, error: truncated }
let xml = wrapCellsInModel(cells)
const { valid, error, fixed, fixes } = validateAndFixXml(xml)
if (fixed) xml = fixed
+101 -11
View File
@@ -3,7 +3,8 @@
* diagram survives the MCP process (hosts start a new one when a
* conversation is resumed). Like the web app's IndexedDB sessions
* (lib/session-storage.ts): saved 1 second after the last change, at most
* 50 kept. History is not saved.
* 50 kept. The session's History (the XML of its last 20 versions, without
* the thumbnails) is saved next to it as <session-id>.history.json.
*/
import {
@@ -12,6 +13,7 @@ import {
readdirSync,
readFileSync,
renameSync,
rmSync,
statSync,
unlinkSync,
writeFileSync,
@@ -65,6 +67,10 @@ export class Autosaver {
private dir: string | null,
private delayMs = DELAY_MS,
private maxFiles = MAX_FILES,
// The session's History entries, saved next to its diagram
private history: (
sessionId: string,
) => { id: number; xml: string }[] = () => [],
) {}
/** Path of a session's file, or null when saving is off. */
@@ -72,6 +78,60 @@ export class Autosaver {
return this.dir ? join(this.dir, `${sessionId}.drawio`) : null
}
/** Path of a session's History file, or null when saving is off. */
historyPathFor(sessionId: string): string | null {
return this.dir ? join(this.dir, `${sessionId}.history.json`) : null
}
/** The XML of the session's saved History entries, oldest first. */
loadHistory(sessionId: string): string[] {
const path = this.historyPathFor(sessionId)
if (!path) return []
try {
const entries: unknown = JSON.parse(readFileSync(path, "utf-8"))
return Array.isArray(entries) &&
entries.every((e) => typeof e === "string")
? entries
: []
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
log.warn(`Could not read the saved History ${path}: ${error}`)
}
return []
}
}
/** The folder the files are saved in, or null when saving is off. */
dataDir(): string | null {
return this.dir
}
/**
* Every file a session can resume (start_session's own files and
* hand-named ones such as mcp-notes.drawio), newest first. Empty when
* saving is off or the folder does not exist yet.
*/
list(): { sessionId: string; path: string; savedAt: Date }[] {
return this.files(/^mcp-[a-z0-9-]{1,64}\.drawio$/).map((file) => ({
sessionId: file.name.slice(0, -".drawio".length),
path: file.path,
savedAt: file.mtime,
}))
}
/** The files in the folder whose name matches, newest first */
private files(pattern: RegExp) {
const dir = this.dir
if (!dir || !existsSync(dir)) return []
return readdirSync(dir)
.filter((name) => pattern.test(name))
.map((name) => {
const path = join(dir, name)
return { name, path, mtime: statSync(path).mtime }
})
.sort((a, b) => b.mtime.getTime() - a.mtime.getTime())
}
// Saved files that could not be read back: never written over, since
// the session then shows something else than what they hold. Cleared
// once the file is read, or is surely gone (a folder without permission
@@ -132,30 +192,60 @@ export class Autosaver {
}
try {
const isNew = !existsSync(path)
// A blank page the browser shows before any drawing: nothing to keep
if (isNew && isBlank(entry.xml)) return
// A blank page the browser shows before any drawing: nothing to
// keep (unless the diagram was cleared before its first save)
if (
isNew &&
isBlank(entry.xml) &&
this.history(sessionId).every((e) => isBlank(e.xml))
)
return
mkdirSync(this.dir, { recursive: true })
// Write to a temporary file first so a crash never leaves half a file
writeFileSync(`${path}.tmp`, entry.xml, "utf-8")
renameSync(`${path}.tmp`, path)
this.writeHistory(sessionId)
if (isNew) this.removeOldest()
} catch (error) {
log.warn(`Auto-save failed for ${path}: ${error}`)
}
}
// What each session's History file holds (entry count and last id):
// draw.io autosaves often, and the file is only rewritten when the
// entries changed
private historyKeys = new Map<string, string>()
// Saved with the diagram, after the same delay, so the History entry
// that follows every tool write is included. The thumbnails are left
// out (large); the History grid shows the entry's number instead.
private writeHistory(sessionId: string): void {
const path = this.historyPathFor(sessionId)
const entries = this.history(sessionId)
if (!path || entries.length === 0) return
const key = `${entries.length}:${entries[entries.length - 1].id}`
if (this.historyKeys.get(sessionId) === key) return
try {
const json = JSON.stringify(entries.map((e) => e.xml))
writeFileSync(`${path}.tmp`, json, "utf-8")
renameSync(`${path}.tmp`, path)
this.historyKeys.set(sessionId, key)
} catch (error) {
log.warn(`Saving the History failed for ${path}: ${error}`)
}
}
private removeOldest(): void {
if (!this.dir) return
const dir = this.dir
// Only our own session files (mcp-<time in base 36>-<random>, made
// by start_session): DRAWIO_DATA_DIR may be a folder that also holds
// the user's diagrams
const files = readdirSync(dir)
.filter((f) => /^mcp-[0-9a-z]{8}-[0-9a-z]{1,6}\.drawio$/.test(f))
.map((f) => ({ f, mtime: statSync(join(dir, f)).mtimeMs }))
.sort((a, b) => b.mtime - a.mtime)
for (const { f } of files.slice(this.maxFiles)) {
unlinkSync(join(dir, f))
const own = this.files(/^mcp-[0-9a-z]{8}-[0-9a-z]{1,6}\.drawio$/)
for (const { name, path } of own.slice(this.maxFiles)) {
unlinkSync(path)
// Its History goes with it (there may be none), and is written
// again if the session saves once more
rmSync(path.replace(/\.drawio$/, ".history.json"), { force: true })
this.historyKeys.delete(name.slice(0, -".drawio".length))
}
}
}
+1 -1
View File
@@ -40,7 +40,7 @@
</button>
</div>
</div>
<iframe id="drawio" src="{{DRAWIO_URL}}/?embed=1&proto=json&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0&themes=1&dark=auto" title="draw.io editor"></iframe>
<iframe id="drawio" src="{{DRAWIO_URL}}/?embed=1&proto=json&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0&themes=1&{{DRAWIO_PARAMS}}" title="draw.io editor"></iframe>
</div>
<div id="notice" role="status"></div>
<div id="history-modal">
+17 -2
View File
@@ -237,6 +237,8 @@ async function pushState(xml, svg = '', baseVersion = currentVersion, source = '
// process restarted. Decide whose diagram wins.
function recoverState(s) {
stateId = s.stateId;
// History entries got new ids with the new state: an open list is stale
if (historyModal.classList.contains('open')) cancelBtn.onclick();
// The old state's pending work is gone with it
const projectionShown = projectionExportActive;
projectionExportActive = false;
@@ -298,11 +300,13 @@ async function poll() {
// The tab's own push still on its way is not loaded back: the
// canvas may have moved on since (an undo), and its answer follows
const ownPush = pushesInFlight.includes(s.xml);
let justLoaded = false; // this poll put a new version on the canvas
if ((forceReload || (s.version > currentVersion && !projectionExportActive && !ownPush)) && s.xml) {
forceReload = false;
projectionExportActive = false;
currentVersion = s.version;
loadDiagram(s.xml, true);
justLoaded = true;
}
// Handle sync request - server needs fresh state. After the load
// above, so draw.io exports what it just loaded; never while a
@@ -352,6 +356,10 @@ async function poll() {
// Let draw.io render the loaded page before exporting
// (same proven settle delay as the AI-preview path).
setTimeout(fireExport, 600);
} else if (justLoaded) {
// A write with a screenshot: give the new diagram's external
// icon images a moment to load before the PNG is taken
setTimeout(fireExport, 600);
} else {
fireExport();
}
@@ -453,18 +461,24 @@ const historyGrid = document.getElementById('history-grid');
const historyEmpty = document.getElementById('history-empty');
const restoreBtn = document.getElementById('restore-btn');
const cancelBtn = document.getElementById('cancel-btn');
let historyData = [], selectedId = null;
// historyStateId: the server state the list belongs to, sent with a restore
let historyData = [], historyStateId = null, selectedId = null;
historyBtn.onclick = async () => {
if (!sessionId) return;
// A list for a state the server recreated meanwhile has old ids
const sid = stateId;
try {
const r = await fetch('/api/history?sessionId=' + encodeURIComponent(sessionId));
if (r.ok) {
const d = await r.json();
if (sid !== stateId) return;
historyData = d.entries || [];
historyStateId = sid;
renderHistory();
}
} catch {}
if (sid !== stateId) return;
historyModal.classList.add('open');
};
@@ -518,9 +532,10 @@ restoreBtn.onclick = async () => {
const r = await fetch('/api/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, id: selectedId })
body: JSON.stringify({ sessionId, id: selectedId, stateId: historyStateId })
});
if (r.ok) { cancelBtn.onclick(); await poll(); }
else if (r.status === 409) { cancelBtn.onclick(); showNotice('History changed on the server. Please open it again.'); }
else { showNotice('Restore failed. Please try again.'); }
} catch { showNotice('Restore failed. Please try again.'); }
restoreBtn.textContent = 'Restore';
+71
View File
@@ -0,0 +1,71 @@
/**
* Short XML references returned by get_drawing_guide with a topic. Each
* snippet is the bare mxCell elements of one page, as create_new_diagram
* accepts them, and is checked by tests/xml-reference.test.ts.
*/
import { indent } from "./xml-examples.ts"
const TABLE_ROW_STYLE =
"shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;strokeColor=inherit;top=0;left=0;bottom=0;right=0;collapsible=0;dropTarget=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;"
const TABLE_CELL_STYLE =
"shape=partialRectangle;html=1;whiteSpace=wrap;connectable=0;strokeColor=inherit;overflow=hidden;fillColor=none;top=0;left=0;bottom=0;right=0;pointerEvents=1;"
const TABLES_EXAMPLE = `<mxCell id="table1" style="shape=table;startSize=0;container=1;collapsible=0;childLayout=tableLayout;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="240" height="80" as="geometry"/>
</mxCell>
<mxCell id="row1" style="${TABLE_ROW_STYLE}" vertex="1" parent="table1">
<mxGeometry y="0" width="240" height="40" as="geometry"/>
</mxCell>
<mxCell id="cell11" value="Name" style="${TABLE_CELL_STYLE}fontStyle=1;" vertex="1" parent="row1">
<mxGeometry x="0" y="0" width="120" height="40" as="geometry"/>
</mxCell>
<mxCell id="cell12" value="Role" style="${TABLE_CELL_STYLE}fontStyle=1;" vertex="1" parent="row1">
<mxGeometry x="120" y="0" width="120" height="40" as="geometry"/>
</mxCell>
<mxCell id="row2" style="${TABLE_ROW_STYLE}" vertex="1" parent="table1">
<mxGeometry y="40" width="240" height="40" as="geometry"/>
</mxCell>
<mxCell id="cell21" value="Alice" style="${TABLE_CELL_STYLE}" vertex="1" parent="row2">
<mxGeometry x="0" y="0" width="120" height="40" as="geometry"/>
</mxCell>
<mxCell id="cell22" value="Admin" style="${TABLE_CELL_STYLE}" vertex="1" parent="row2">
<mxGeometry x="120" y="0" width="120" height="40" as="geometry"/>
</mxCell>`
const LAYERS_EXAMPLE = `<mxCell id="L2" value="Layer 2" parent="0"/>
<mxCell id="2" value="On the default layer" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="120" height="60" as="geometry"/>
</mxCell>
<mxCell id="3" value="On Layer 2" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="L2">
<mxGeometry x="200" y="40" width="120" height="60" as="geometry"/>
</mxCell>`
const GROUPS_EXAMPLE = `<mxCell id="group1" style="group" vertex="1" connectable="0" parent="1">
<mxGeometry x="40" y="40" width="260" height="60" as="geometry"/>
</mxCell>
<mxCell id="2" value="A" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="group1">
<mxGeometry x="0" y="0" width="120" height="60" as="geometry"/>
</mxCell>
<mxCell id="3" value="B" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="group1">
<mxGeometry x="140" y="0" width="120" height="60" as="geometry"/>
</mxCell>`
export const XML_REFERENCE: Record<"tables" | "layers" | "groups", string> = {
tables: `## Tables
A table is three levels of cells, as draw.io's sidebar inserts them: the table container (style "shape=table;startSize=0;container=1;collapsible=0;childLayout=tableLayout;", parent="1", geometry wide enough for all columns), one row per table row (style as below, parent = table id, width = table width, height = row height, y = row offset), and one cell per column inside each row (style as below, parent = row id, x = column offset, y = 0, width = column width, height = row height; the cell text is the value). A header row can use fontStyle=1 on its cells. Example with 2 rows and 2 columns:
${indent(TABLES_EXAMPLE)}
`,
layers: `## Layers
A layer is the one case of a cell with parent="0" and no vertex or edge attribute; id="1" is the default layer. Add a layer cell and give shapes parent="<layer id>" to put them on it:
${indent(LAYERS_EXAMPLE)}
`,
groups: `## Groups
A group is a container with style="group" (parent="1", geometry enclosing its children). Children use parent="<group id>" and coordinates relative to the group; moving the group moves them:
${indent(GROUPS_EXAMPLE)}
`,
}
@@ -0,0 +1,99 @@
/**
* Tests for the user's instructions.md appended to the drawing guide
* (src/custom-instructions.ts).
*/
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
import { homedir, tmpdir } from "node:os"
import { join } from "node:path"
import { afterEach, describe, expect, it } from "vitest"
import {
CUSTOM_INSTRUCTIONS_FILE,
customInstructionsDir,
guideWithCustomInstructions,
readCustomInstructions,
} from "../src/custom-instructions.ts"
import { DRAWING_GUIDE } from "../src/drawing-guide.ts"
const GUIDE = "# Drawing guide\n\nKeep edges orthogonal."
const tempDir = () => mkdtempSync(join(tmpdir(), "mcp-instructions-"))
const writeRules = (dir: string, text: string) =>
writeFileSync(join(dir, CUSTOM_INSTRUCTIONS_FILE), text)
describe("readCustomInstructions", () => {
it("gives an empty string when there is no file", () => {
const dir = tempDir()
expect(readCustomInstructions(dir)).toBe("")
expect(guideWithCustomInstructions(GUIDE, dir)).toBe(GUIDE)
})
it("appends the file's content under its own heading", () => {
const dir = tempDir()
writeRules(dir, "\nAlways draw in minimal style.\n\n")
expect(readCustomInstructions(dir)).toBe(
"Always draw in minimal style.",
)
expect(guideWithCustomInstructions(GUIDE, dir)).toBe(
`${GUIDE}\n\n## Custom Instructions\nAlways draw in minimal style.`,
)
})
it("cuts the content at 5000 characters", () => {
const dir = tempDir()
writeRules(dir, "x".repeat(6000))
expect(readCustomInstructions(dir)).toBe("x".repeat(5000))
})
it("gives an empty string when the file cannot be read", () => {
const dir = tempDir()
// A folder in place of the file: readFileSync fails with EISDIR
mkdirSync(join(dir, CUSTOM_INSTRUCTIONS_FILE))
expect(readCustomInstructions(dir)).toBe("")
expect(guideWithCustomInstructions(GUIDE, dir)).toBe(GUIDE)
})
it("reads the file again on every call", () => {
const dir = tempDir()
writeRules(dir, "First rule.")
expect(readCustomInstructions(dir)).toBe("First rule.")
writeRules(dir, "Second rule.")
expect(readCustomInstructions(dir)).toBe("Second rule.")
})
})
describe("customInstructionsDir", () => {
const original = process.env.DRAWIO_DATA_DIR
afterEach(() => {
if (original === undefined) delete process.env.DRAWIO_DATA_DIR
else process.env.DRAWIO_DATA_DIR = original
})
it("honours DRAWIO_DATA_DIR", () => {
const dir = tempDir()
writeRules(dir, "Use blue fill #dae8fc.")
process.env.DRAWIO_DATA_DIR = dir
expect(customInstructionsDir()).toBe(dir)
expect(guideWithCustomInstructions(GUIDE)).toBe(
`${GUIDE}\n\n## Custom Instructions\nUse blue fill #dae8fc.`,
)
})
it("expands ~, which JSON configs pass on as it is", () => {
process.env.DRAWIO_DATA_DIR = "~/drawio-saves"
expect(customInstructionsDir()).toBe(join(homedir(), "drawio-saves"))
})
it("falls back to the home folder when DRAWIO_DATA_DIR is off or unset", () => {
const home = join(homedir(), ".next-ai-drawio")
process.env.DRAWIO_DATA_DIR = "off"
expect(customInstructionsDir()).toBe(home)
delete process.env.DRAWIO_DATA_DIR
expect(customInstructionsDir()).toBe(home)
})
})
describe("DRAWING_GUIDE", () => {
it("stays within its own budget; instructions.md adds up to 5000 more", () => {
expect(DRAWING_GUIDE.length).toBeLessThanOrEqual(15000)
})
})
+137
View File
@@ -19,6 +19,7 @@ beforeAll(() => {
import {
checkEditGate,
contentFingerprint,
describeChanges,
markPageSeen,
} from "../src/edit-gate.ts"
@@ -168,3 +169,139 @@ describe("markPageSeen", () => {
expect(markPageSeen("", live, { page_id: "A" })).toBe(live)
})
})
describe("describeChanges", () => {
const BOX2 = `<mxCell id="box2" value="World" vertex="1" parent="1"><mxGeometry x="200" y="40" width="120" height="60" as="geometry"/></mxCell>`
const XML_A_PLUS_BOX2 = XML_A.replace("</root>", `${BOX2}</root>`)
const PAGE_2 = `<diagram id="p2" name="Page-2"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram>`
const XML_A_TWO_PAGES = XML_A.replace("</mxfile>", `${PAGE_2}</mxfile>`)
it("names a moved cell with its label", () => {
const summary = describeChanges(XML_A, XML_B)
expect(summary).toBe(
'Since you last saw it, the user changed the diagram: modified box1 ("Hello").',
)
expect(summary).toContain("modified box1")
})
it("names a deleted cell", () => {
expect(describeChanges(XML_A_PLUS_BOX2, XML_A)).toContain(
'removed box2 ("World")',
)
})
it("names an added cell", () => {
expect(describeChanges(XML_A, XML_A_PLUS_BOX2)).toContain(
'added box2 ("World")',
)
})
it("is empty for draw.io's re-serialisation of the same content", () => {
expect(describeChanges(XML_A, XML_A_RESERIALIZED)).toBe("")
})
it("is empty when the model has seen nothing yet", () => {
expect(describeChanges("", XML_B)).toBe("")
})
it("is empty when one side does not parse", () => {
expect(describeChanges(XML_A, "not xml at all")).toBe("")
})
it("reports a renamed page", () => {
const renamed = XML_A.replace('name="Page-1"', 'name="Renamed"')
expect(describeChanges(XML_A, renamed)).toBe(
'Since you last saw it, the user changed the diagram. Page 1 renamed to "Renamed".',
)
})
// A bare <mxGraphModel> push carries no page name, so the invented
// "Page-1" must not read as a rename
it("never reports a rename against a bare mxGraphModel", () => {
const seenRenamed = XML_A.replace('name="Page-1"', 'name="Arch"')
expect(describeChanges(seenRenamed, XML_A_BARE)).toBe("")
const bareMoved = XML_A_BARE.replace('x="40" y="40"', 'x="300" y="200"')
const summary = describeChanges(seenRenamed, bareMoved)
expect(summary).toContain("modified box1")
expect(summary).not.toContain("renamed")
})
it("strips HTML from labels and reads UserObject labels", () => {
const html = XML_A.replace(
'value="Hello"',
'value="&lt;b&gt;Hello&lt;/b&gt;"',
)
expect(describeChanges(XML_A, html)).toContain(
'modified box1 ("Hello")',
)
const userObject = XML_A.replace(
"</root>",
`<UserObject id="u1" label="&lt;i&gt;Link&lt;/i&gt;" link="https://x.y"><mxCell style="rounded=1;" vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell></UserObject></root>`,
)
expect(describeChanges(XML_A, userObject)).toContain(
'added u1 ("Link")',
)
})
it("keeps the words of an HTML label apart and decodes its spaces", () => {
const lines = XML_A.replace(
'value="Hello"',
'value="Line1&lt;br&gt;Line2&amp;nbsp;end &amp;amp; more"',
)
expect(describeChanges(XML_A, lines)).toContain(
'modified box1 ("Line1 Line2 end & more")',
)
})
it("cuts long labels at 30 characters", () => {
const long = "a".repeat(40)
const summary = describeChanges(
XML_A,
XML_A.replace('value="Hello"', `value="${long}"`),
)
expect(summary).toContain(`("${"a".repeat(30)}...")`)
})
it("lists at most 20 cells and counts the rest", () => {
const many = Array.from(
{ length: 25 },
(_, i) => `<mxCell id="n${i}" vertex="1" parent="1"/>`,
).join("")
const summary = describeChanges(
XML_A,
XML_A.replace("</root>", `${many}</root>`),
)
expect(summary).toContain("added n0, n1,")
expect(summary).toContain("n19 and 5 more.")
expect(summary).not.toContain("n20")
})
it("reports added and removed pages", () => {
expect(describeChanges(XML_A, XML_A_TWO_PAGES)).toBe(
"Since you last saw it, the user changed the diagram: page 2 added.",
)
expect(describeChanges(XML_A_TWO_PAGES, XML_A)).toBe(
"Since you last saw it, the user changed the diagram: page 2 removed.",
)
})
it("names the page of a cell change in a multi-page document", () => {
const movedOnPage1 = XML_A_TWO_PAGES.replace(
'x="40" y="40"',
'x="300" y="200"',
)
expect(describeChanges(XML_A_TWO_PAGES, movedOnPage1)).toContain(
'page 1 modified box1 ("Hello")',
)
})
it("falls back to a generic clause when only the cell order changed", () => {
const reordered = XML_A_PLUS_BOX2.replace(BOX2, "").replace(
'<mxCell id="box1"',
`${BOX2}<mxCell id="box1"`,
)
expect(describeChanges(XML_A_PLUS_BOX2, reordered)).toBe(
"Since you last saw it, the user changed the diagram: the order of the cells changed.",
)
})
})
+118
View File
@@ -0,0 +1,118 @@
/**
* Tests for the diagram history: the versions restore_version can go to.
*/
import { beforeAll, describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.ts"
import {
addHistory,
clearHistory,
getHistory,
isInHistory,
otherVersions,
} from "../src/history.ts"
beforeAll(() => {
// XML parsing for the content fingerprints
installDomPolyfill()
})
const page = (cellId: string) =>
`<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="${cellId}" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
const A = page("a")
const B = page("b")
const C = page("c")
// draw.io's copy of B: other attribute order, whitespace and viewport
// attributes, same diagram
const B_COPY = `<mxfile host="app.diagrams.net">
<diagram name="P" id="p">
<mxGraphModel dx="100" dy="200" pageWidth="850">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="b" parent="1" vertex="1" />
</root>
</mxGraphModel>
</diagram>
</mxfile>`
const fill = (id: string, versions: string[]) => {
clearHistory(id)
for (const xml of versions) addHistory(id, xml)
}
describe("otherVersions", () => {
it("lists the versions before the canvas, newest first", () => {
const id = "history-abc"
fill(id, [A, B, C])
expect(otherVersions(id, C).map((e) => e.xml)).toEqual([B, A])
})
it("lists each version once after an undo, the left one first", () => {
const id = "history-abcb"
fill(id, [A, B, C, B])
expect(otherVersions(id, B).map((e) => e.xml)).toEqual([C, A])
})
it("lists every version when the canvas is in none of them", () => {
const id = "history-manual"
fill(id, [A, B, C])
expect(otherVersions(id, page("manual")).map((e) => e.xml)).toEqual([
C,
B,
A,
])
})
it("counts a re-serialised copy as the same version", () => {
const id = "history-copy"
fill(id, [A, B, B_COPY])
expect(otherVersions(id, B).map((e) => e.xml)).toEqual([A])
expect(otherVersions(id, B_COPY).map((e) => e.xml)).toEqual([A])
})
it("keeps the newest copy of a repeated version", () => {
const id = "history-newest"
fill(id, [A, B, A])
const [newestA] = otherVersions(id, B)
expect(newestA.id).toBe(getHistory(id)[2].id)
expect(otherVersions(id, B)).toHaveLength(1)
})
it("matches a bare model from the browser with its named page", () => {
const id = "history-bare"
const named = (xml: string) => xml.replace('name="P"', 'name="Arch"')
fill(id, [named(A), named(B)])
// The browser's sync sent page B without its <mxfile> wrapper
const bareB = B.replace(/^.*?(<mxGraphModel>)/, "$1").replace(
"</diagram></mxfile>",
"",
)
expect(otherVersions(id, bareB).map((e) => e.xml)).toEqual([named(A)])
expect(isInHistory(id, bareB)).toBe(true)
expect(isInHistory(id, C)).toBe(false)
})
it("still counts a rename when an unrelated bare model is in History", () => {
const id = "history-mixed"
const bareX = page("x")
.replace(/^.*?(<mxGraphModel>)/, "$1")
.replace("</diagram></mxfile>", "")
const arch = B.replace('name="P"', 'name="Arch"')
const overview = B.replace('name="P"', 'name="Overview"')
fill(id, [bareX, arch])
expect(otherVersions(id, overview).map((e) => e.xml)).toEqual([
arch,
bareX,
])
expect(isInHistory(id, overview)).toBe(false)
})
it("returns nothing for an empty history", () => {
const id = "history-empty"
clearHistory(id)
expect(otherVersions(id, A)).toEqual([])
})
})
@@ -11,11 +11,14 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.ts"
import { addHistory, getHistory } from "../src/history.ts"
import {
drawioEmbedParams,
getState,
keepInHistory,
onSessionRecreate,
onStateChange,
requestExport,
requestSync,
restoreHistoryEntry,
setState,
shutdown,
startHttpServer,
@@ -256,6 +259,25 @@ describe("POST /api/state", () => {
expect(history.at(-1)?.xml).toBe("<mxfile>lost user edit</mxfile>")
})
it("asks for a save when a lost user edit is kept in history", async () => {
const id = "mcp-conflict-save"
setState(id, "<mxfile>user v1</mxfile>", undefined, true)
const aiVersion = setState(id, "<mxfile>AI edit</mxfile>")
const saves: string[] = []
onStateChange((sessionId, xml) => saves.push(`${sessionId} ${xml}`))
try {
await postJson("/api/state", {
sessionId: id,
xml: "<mxfile>lost user edit</mxfile>",
baseVersion: aiVersion - 1,
})
} finally {
onStateChange(() => {})
}
// The canvas stays the AI write; the save takes the new History
expect(saves).toEqual([`${id} <mxfile>AI edit</mxfile>`])
})
it("ends a pending sync when the sync reply is older than an AI write", async () => {
const id = "mcp-stale-sync"
setState(id, "<mxfile>before</mxfile>", undefined, true)
@@ -445,6 +467,54 @@ describe("preview page", () => {
})
})
describe("draw.io embed parameters from the host config", () => {
it("follows the system dark mode and fixes nothing else by default", () => {
expect(drawioEmbedParams({})).toBe("dark=auto")
})
it("turns dark mode on with the dark theme", () => {
// draw.io ignores ui=dark for dark mode once a dark parameter is sent
const params = drawioEmbedParams({ DRAWIO_UI: "dark" })
expect(params).toContain("dark=1")
expect(params).toContain("ui=dark")
// Unless the user asked for light mode
expect(drawioEmbedParams({ DRAWIO_UI: "dark", DRAWIO_DARK: "0" })).toBe(
"dark=0&ui=dark",
)
})
it("reads the theme in any case", () => {
expect(drawioEmbedParams({ DRAWIO_UI: "Dark" })).toBe("dark=1&ui=dark")
})
it("ignores a theme or language draw.io does not know", () => {
expect(drawioEmbedParams({ DRAWIO_UI: "neon" })).toBe("dark=auto")
expect(drawioEmbedParams({ DRAWIO_LANG: "en;drop" })).toBe("dark=auto")
expect(drawioEmbedParams({ DRAWIO_LANG: "english" })).toBe("dark=auto")
})
it("uses draw.io's name for Traditional Chinese", () => {
for (const value of ["zh-Hant", "zh-hant"]) {
expect(drawioEmbedParams({ DRAWIO_LANG: value })).toBe(
"dark=auto&lang=zh-tw",
)
}
expect(drawioEmbedParams({ DRAWIO_LANG: "ja" })).toBe(
"dark=auto&lang=ja",
)
// and "zh" for Simplified Chinese
expect(drawioEmbedParams({ DRAWIO_LANG: "zh-Hans" })).toBe(
"dark=auto&lang=zh",
)
})
it("reads dark mode in any spelling", () => {
expect(drawioEmbedParams({ DRAWIO_DARK: "TRUE" })).toBe("dark=1")
expect(drawioEmbedParams({ DRAWIO_DARK: "light" })).toBe("dark=0")
expect(drawioEmbedParams({ DRAWIO_DARK: "maybe" })).toBe("dark=auto")
})
})
describe("history restore", () => {
it("restores the entry the user picked after older entries drop", async () => {
const id = "mcp-history"
@@ -468,6 +538,34 @@ describe("history restore", () => {
const page = (cellId: string) =>
`<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="${cellId}" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
it("refuses a restore picked from a state the server has since lost", async () => {
const id = "mcp-history-stale-state"
setState(id, page("now"))
addHistory(id, page("old"))
const [entry] = getHistory(id)
const stale = await postJson("/api/restore", {
sessionId: id,
id: entry.id,
stateId: "a-lost-state",
})
expect(stale.status).toBe(409)
// A list taken before the tab knew the state
const unknown = await postJson("/api/restore", {
sessionId: id,
id: entry.id,
stateId: null,
})
expect(unknown.status).toBe(409)
expect(getState(id)?.xml).toBe(page("now"))
const fresh = await postJson("/api/restore", {
sessionId: id,
id: entry.id,
stateId: getState(id)?.stateId,
})
expect(fresh.status).toBe(200)
expect(getState(id)?.xml).toBe(page("old"))
})
// A thumbnail the tab took after loading the server write at `version`
const thumbnail = (id: string, svg: string, version: number) =>
postJson("/api/history-svg", {
@@ -664,6 +762,32 @@ describe("history restore", () => {
expect(getState(id)?.xml).toBe(doc("ai"))
expect(getHistory(id).map((e) => e.xml)).toContain(doc("manual"))
})
it("restoreHistoryEntry leaves the canvas alone for an unknown entry", () => {
const id = "mcp-restore-unknown"
setState(id, page("x"))
addHistory(id, page("x"))
expect(restoreHistoryEntry(id, 999999)).toBeNull()
expect(getState(id)?.xml).toBe(page("x"))
expect(getHistory(id)).toHaveLength(1)
})
it("restoreHistoryEntry restores an entry and keeps the canvas in History", () => {
const id = "mcp-restore-entry"
addHistory(id, page("old"))
const [entry] = getHistory(id)
// The canvas is a later state that is not in History yet
setState(id, page("ai"))
const newVersion = restoreHistoryEntry(id, entry.id)
expect(newVersion).toBe(getState(id)?.version)
expect(getState(id)?.xml).toBe(page("old"))
expect(getHistory(id).map((e) => e.xml)).toEqual([
page("old"),
page("ai"),
page("old"),
])
})
})
describe("bodies over the size limit", () => {
+154 -3
View File
@@ -19,6 +19,7 @@ beforeAll(() => {
import {
decompressPageContent,
extractSvgEmbeddedXml,
parseDrawioFileContent,
} from "../src/load-diagram.ts"
@@ -54,7 +55,11 @@ describe("decompressPageContent", () => {
describe("parseDrawioFileContent", () => {
it("passes a plain-XML mxfile through unchanged", () => {
const r = parseDrawioFileContent(PLAIN_MXFILE)
expect(r).toEqual({ ok: true, xml: PLAIN_MXFILE })
expect(r).toEqual({
ok: true,
xml: PLAIN_MXFILE,
hadCompressedPages: false,
})
})
it("wraps a bare mxGraphModel into a one-page mxfile", () => {
@@ -103,7 +108,11 @@ describe("parseDrawioFileContent", () => {
it("keeps empty pages as-is", () => {
const withEmpty = `<mxfile><diagram id="a" name="Page-1">${MODEL_XML}</diagram><diagram id="b" name="Empty"></diagram></mxfile>`
const r = parseDrawioFileContent(withEmpty)
expect(r).toEqual({ ok: true, xml: withEmpty })
expect(r).toEqual({
ok: true,
xml: withEmpty,
hadCompressedPages: false,
})
})
it("rejects empty files", () => {
@@ -112,11 +121,17 @@ describe("parseDrawioFileContent", () => {
})
it("rejects non-drawio content", () => {
const r = parseDrawioFileContent("<svg><rect/></svg>")
const r = parseDrawioFileContent("<html><body/></html>")
expect(r.ok).toBe(false)
if (!r.ok) expect(r.error).toContain("Not a draw.io file")
})
it("rejects an SVG without an embedded diagram", () => {
const r = parseDrawioFileContent("<svg><rect/></svg>")
expect(r.ok).toBe(false)
if (!r.ok) expect(r.error).toContain("embedded diagram")
})
it("rejects a page whose content is neither XML nor compressed", () => {
const bad = `<mxfile><diagram id="a" name="Broken">!!! not a diagram !!!</diagram></mxfile>`
const r = parseDrawioFileContent(bad)
@@ -124,3 +139,139 @@ describe("parseDrawioFileContent", () => {
if (!r.ok) expect(r.error).toContain('"Broken"')
})
})
describe("hadCompressedPages", () => {
it("is false for a plain-XML mxfile", () => {
const r = parseDrawioFileContent(PLAIN_MXFILE)
expect(r.ok).toBe(true)
if (r.ok) expect(r.hadCompressedPages).toBe(false)
})
it("is false for a bare mxGraphModel", () => {
const r = parseDrawioFileContent(MODEL_XML)
expect(r.ok).toBe(true)
if (r.ok) expect(r.hadCompressedPages).toBe(false)
})
it("is true for a fully compressed mxfile", () => {
const r = parseDrawioFileContent(COMPRESSED_MXFILE)
expect(r.ok).toBe(true)
if (r.ok) expect(r.hadCompressedPages).toBe(true)
})
it("is true for a mixed plain/compressed file", () => {
const mixed = `<mxfile><diagram id="a" name="Plain">${MODEL_XML}</diagram><diagram id="b" name="Squeezed">${drawioCompress(MODEL_XML)}</diagram></mxfile>`
const r = parseDrawioFileContent(mixed)
expect(r.ok).toBe(true)
if (r.ok) expect(r.hadCompressedPages).toBe(true)
})
})
/**
* Escape text for an XML attribute the way draw.io/Chromium serialise the
* diagram into the SVG root's content attribute.
*/
function escapeAttr(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/\n/g, "&#xa;")
}
/** Build a .drawio.svg (Editable SVG) with the given mxfile embedded. */
function drawioSvg(mxfileXml: string): string {
return `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="200px" height="100px" viewBox="-0.5 -0.5 200 100" content="${escapeAttr(mxfileXml)}"><defs/><g><rect x="40" y="40" width="120" height="60" fill="#ffffff" stroke="#000000"/></g></svg>`
}
const SVG_PROLOG = `<?xml version="1.0" encoding="UTF-8"?>
<!-- Do not edit this file with editors other than draw.io -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
`
describe("extractSvgEmbeddedXml", () => {
it("returns undefined for non-SVG text", () => {
expect(extractSvgEmbeddedXml(PLAIN_MXFILE)).toBeUndefined()
expect(extractSvgEmbeddedXml("hello")).toBeUndefined()
})
it("returns an empty string for an SVG without content", () => {
expect(extractSvgEmbeddedXml("<svg><rect/></svg>")).toBe("")
})
it("returns the decoded content attribute", () => {
expect(extractSvgEmbeddedXml(drawioSvg(PLAIN_MXFILE))).toBe(
PLAIN_MXFILE,
)
})
})
describe("parseDrawioFileContent with .drawio.svg", () => {
it("loads a compressed one-page mxfile embedded in an SVG", () => {
const r = parseDrawioFileContent(drawioSvg(COMPRESSED_MXFILE))
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.xml).toContain("<mxfile")
expect(r.xml).toContain("<mxGraphModel")
expect(r.xml).toContain('value="Hello"')
expect(r.xml).not.toContain("<svg")
}
})
it("accepts draw.io's XML declaration, comment and DOCTYPE prolog", () => {
const r = parseDrawioFileContent(
SVG_PROLOG + drawioSvg(COMPRESSED_MXFILE),
)
expect(r.ok).toBe(true)
if (r.ok) expect(r.xml).toContain('value="Hello"')
})
it("round-trips &quot; and &#xa; in an uncompressed embedded label", () => {
const model = MODEL_XML.replace(
'value="Hello"',
'value="say &quot;hi&quot;&#xa;next"',
)
// A pretty-printed file: the newline between tags becomes &#xa; in
// the SVG attribute, the label's entities become &amp;quot; etc.
const mxfile = `<mxfile host="app.diagrams.net">\n<diagram id="p1" name="Page-1">${model}</diagram>\n</mxfile>`
const svg = drawioSvg(mxfile)
expect(svg).toContain("&amp;quot;")
expect(svg).toContain("&amp;#xa;")
expect(svg).toContain("&gt;&#xa;&lt;diagram")
const r = parseDrawioFileContent(svg)
expect(r.ok).toBe(true)
if (r.ok) {
expect(r.xml).toContain('value="say &quot;hi&quot;&#xa;next"')
const doc = new DOMParser().parseFromString(r.xml, "text/xml")
const cell = doc.querySelector('mxCell[id="box1"]') as Element
expect(cell.getAttributeNode("value")?.value).toBe('say "hi"\nnext')
}
})
it("loads a two-page embedded mxfile as two pages", () => {
const twoPages = `<mxfile compressed="true"><diagram id="a" name="First">${drawioCompress(MODEL_XML)}</diagram><diagram id="b" name="Second">${drawioCompress(MODEL_XML.replace("Hello", "World"))}</diagram></mxfile>`
const r = parseDrawioFileContent(drawioSvg(twoPages))
expect(r.ok).toBe(true)
if (r.ok) {
const doc = new DOMParser().parseFromString(r.xml, "text/xml")
const diagrams = Array.from(
doc.querySelectorAll("diagram"),
) as Element[]
expect(diagrams).toHaveLength(2)
for (const d of diagrams) {
expect(d.querySelector("mxGraphModel")).not.toBeNull()
}
expect(r.xml).toContain('value="World"')
}
})
it("rejects an SVG whose content is not draw.io XML", () => {
const r = parseDrawioFileContent(
'<svg xmlns="http://www.w3.org/2000/svg" content="hello"><rect/></svg>',
)
expect(r.ok).toBe(false)
if (!r.ok) expect(r.error).toContain("not draw.io XML")
})
})
@@ -0,0 +1,215 @@
/**
* Tests for prepareNewDiagram's truncation check: output that stops inside an
* mxCell gets a clear error instead of one about the wrapper tags the model
* never sent.
*/
import { beforeAll, describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.ts"
beforeAll(() => {
installDomPolyfill()
})
import {
isMxCellXmlComplete,
prepareNewDiagram,
truncatedCellError,
} from "../src/new-diagram.ts"
describe("isMxCellXmlComplete", () => {
it("returns false for empty/null input", () => {
expect(isMxCellXmlComplete("")).toBe(false)
expect(isMxCellXmlComplete(null)).toBe(false)
expect(isMxCellXmlComplete(undefined)).toBe(false)
})
it("returns true for self-closing mxCell", () => {
const xml =
'<mxCell id="2" value="Hello" style="rounded=1;" vertex="1" parent="1"/>'
expect(isMxCellXmlComplete(xml)).toBe(true)
// A raw "<" in a value (escaped later by the auto-fix)
expect(
isMxCellXmlComplete(
'<mxCell id="3" value="<b>Title</b>" style="text;html=1;" vertex="1" parent="1"/>',
),
).toBe(true)
})
it("returns true for mxCell with closing tag", () => {
const xml = `<mxCell id="2" value="Hello" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
</mxCell>`
expect(isMxCellXmlComplete(xml)).toBe(true)
})
it("returns false for truncated mxCell", () => {
const xml =
'<mxCell id="2" value="Hello" style="rounded=1;" vertex="1" parent'
expect(isMxCellXmlComplete(xml)).toBe(false)
})
it("returns false for mxCell with unclosed geometry", () => {
const xml = `<mxCell id="2" value="Hello" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120"`
expect(isMxCellXmlComplete(xml)).toBe(false)
})
it("returns false when output stops after a child of an open mxCell", () => {
const xml = `<mxCell id="2" value="A" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="80" height="40" as="geometry"/>
</mxCell>
<mxCell id="3" value="B" vertex="1" parent="1">
<mxGeometry x="100" y="0" width="80" height="40" as="geometry"/>`
expect(isMxCellXmlComplete(xml)).toBe(false)
})
it("returns false when output stops after </mxGeometry> of an open mxCell", () => {
const xml = `<mxCell id="e1" edge="1" parent="1" source="2" target="3">
<mxGeometry relative="1" as="geometry">
<mxPoint x="10" y="10" as="sourcePoint"/>
</mxGeometry>`
expect(isMxCellXmlComplete(xml)).toBe(false)
})
it("returns true for a self-closing last mxCell with > in its value", () => {
const xml = `<mxCell id="2" value="A" vertex="1" parent="1">
<mxGeometry as="geometry"/>
</mxCell>
<mxCell id="3" value="A -> B" vertex="1" parent="1"/></root>`
expect(isMxCellXmlComplete(xml)).toBe(true)
})
it("returns true for multiple complete mxCells", () => {
const xml = `<mxCell id="2" value="A" vertex="1" parent="1"/>
<mxCell id="3" value="B" vertex="1" parent="1"/>`
expect(isMxCellXmlComplete(xml)).toBe(true)
})
})
describe("prepareNewDiagram with cut-off output", () => {
const A = `<mxCell id="2" value="A" vertex="1" parent="1"><mxGeometry x="0" y="0" width="80" height="40" as="geometry"/></mxCell>`
it("rejects a cell cut off inside an attribute", () => {
const cut = `${A}<mxCell id="3" value="B" vertex="1" par`
const out = prepareNewDiagram(cut)
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.error).toContain("cut off")
// The tail is quoted so the model knows where to resume
expect(out.error).toContain('<mxCell id="3" value="B" vertex="1" par')
expect(out.error).toContain("edit_diagram add")
})
it("rejects a cell cut off after its geometry", () => {
const cut = `${A}<mxCell id="3" value="B" vertex="1" parent="1"><mxGeometry x="200" y="0" width="80" height="40" as="geometry"/>`
const out = prepareNewDiagram(cut)
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.error).toContain("cut off")
expect(out.error).not.toContain("</root>")
})
it("quotes at most the last 300 characters", () => {
const cut = `${A}<mxCell id="3" value="${"x".repeat(400)}`
const out = prepareNewDiagram(cut)
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.error).toContain(cut.slice(-300))
expect(out.error).not.toContain(cut.slice(-301))
})
it("does not report complete cells followed by an XML comment as cut", () => {
const cells = `${A}<!-- end of the diagram -->`
expect(truncatedCellError(cells)).toBeNull()
expect(prepareNewDiagram(cells).ok).toBe(true)
})
it("leaves a closing tag the auto-fix repairs to the auto-fix", () => {
// </mxCell/> is a typo, not a cut
const typo = A.replace("</mxCell>", "</mxCell/>")
expect(truncatedCellError(typo)).toBeNull()
expect(prepareNewDiagram(typo).ok).toBe(true)
})
it("leaves input without any cell to the validator", () => {
for (const input of ["", "Here is the diagram"]) {
expect(truncatedCellError(input)).toBeNull()
const out = prepareNewDiagram(input)
expect(out.ok).toBe(false)
if (!out.ok) expect(out.error).not.toContain("cut off")
}
})
it("never checks a full <mxfile>", () => {
const cut = `<mxfile><diagram id="p1" name="Page-1"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" value="A" vertex="1" par`
expect(truncatedCellError(cut)).toBeNull()
const out = prepareNewDiagram(cut)
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.error).not.toContain("cut off")
})
})
describe("truncation check with named styles and compact cells", () => {
const BLUE =
'<mxStyle name="blue" value="fillColor=#dae8fc;strokeColor=#6c8ebf;"/>\n'
const A =
'<mxCell id="2" value="A" style="blue;" x="0" y="0" w="80" h="40"/>\n'
it("accepts complete compact cells after the definitions and expands them", () => {
const out = prepareNewDiagram(
`${BLUE}${A}<mxCell id="3" value="B" style="blue;" x="120" y="0" w="80" h="40"/>`,
)
expect(out.ok).toBe(true)
if (!out.ok) return
expect(out.xml).toContain("fillColor=#dae8fc")
expect(out.xml).toContain("<mxGeometry")
expect(out.xml).not.toContain("<mxStyle")
})
it("reports a cut inside a compact cell that follows the definitions", () => {
const cut = `${BLUE}${A}<mxCell id="3" value="B" style="blue;" x="120" y="0" w="8`
const out = prepareNewDiagram(cut)
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.error).toContain("cut off")
expect(out.error).toContain(
'<mxCell id="3" value="B" style="blue;" x="120" y="0" w="8',
)
expect(out.error).toContain("edit_diagram add")
})
it("reports output that stops right after the definitions and the first cells", () => {
const cut = `${BLUE}${A}<mxCell id="3" value="B" style="blue;"`
expect(isMxCellXmlComplete(cut)).toBe(false)
expect(truncatedCellError(cut)).toContain("cut off")
})
it("does not treat a compact cell's quote slip as a cut", () => {
const slip = `${A}<mxCell id="3" value="B" x="120" y="0" w="80" h="40/>`
expect(isMxCellXmlComplete(slip)).toBe(true)
expect(truncatedCellError(slip)).toBeNull()
const out = prepareNewDiagram(slip)
expect(out.ok).toBe(true)
if (out.ok) expect(out.xml).toContain('height="40"')
})
it("leaves definitions without any cell to the style check", () => {
expect(truncatedCellError(BLUE)).toBeNull()
const out = prepareNewDiagram(BLUE)
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.error).not.toContain("cut off")
expect(out.error).toContain("no cells")
})
it("leaves an unclosed definition to the style check", () => {
const cut = `${BLUE}<mxStyle name="flow" value="edgeSt`
const out = prepareNewDiagram(cut)
expect(out.ok).toBe(false)
if (out.ok) return
expect(out.error).not.toContain("cut off")
expect(out.error).toContain("not closed")
})
})
@@ -9,6 +9,7 @@ import {
readdirSync,
readFileSync,
rmSync,
statSync,
utimesSync,
writeFileSync,
} from "node:fs"
@@ -84,10 +85,14 @@ describe("Autosaver", () => {
writeFileSync(join(dir, `${id}.drawio`), DIAGRAM)
utimesSync(join(dir, `${id}.drawio`), 1000 + i, 1000 + i)
}
// A session's History file goes with its diagram
writeFileSync(join(dir, "mcp-mgd0a1b2-old123.history.json"), "[]")
writeFileSync(join(dir, "mcp-mgd0a1b3-mid456.history.json"), "[]")
saver.schedule("mcp-mgd0a1b4-new789", DIAGRAM)
saver.flush()
expect(readdirSync(dir).sort()).toEqual([
"mcp-mgd0a1b3-mid456.drawio",
"mcp-mgd0a1b3-mid456.history.json",
"mcp-mgd0a1b4-new789.drawio",
"mcp-notes.drawio",
"mcp-system-design-v2.drawio",
@@ -188,9 +193,175 @@ describe("Autosaver", () => {
it("does nothing when saving is off", () => {
const saver = new Autosaver(null)
expect(saver.pathFor("mcp-x")).toBeNull()
expect(saver.historyPathFor("mcp-x")).toBeNull()
expect(saver.loadHistory("mcp-x")).toEqual([])
saver.schedule("mcp-x", DIAGRAM)
saver.flush()
})
it("lists every session file newest first, hand-named ones included", () => {
const dir = tempDir()
const saver = new Autosaver(dir, 10)
const names = [
"mcp-mgd0a1b2-old123.drawio",
"mine.drawio",
"mcp-notes.drawio",
"mcp-readme.txt",
"mcp-mgd0a1b3-new456.drawio",
]
for (const [i, name] of names.entries()) {
writeFileSync(join(dir, name), DIAGRAM)
utimesSync(join(dir, name), 1000 + i, 1000 + i)
}
expect(saver.dataDir()).toBe(dir)
expect(saver.list()).toEqual([
{
sessionId: "mcp-mgd0a1b3-new456",
path: join(dir, "mcp-mgd0a1b3-new456.drawio"),
savedAt: new Date(1004 * 1000),
},
{
sessionId: "mcp-notes",
path: join(dir, "mcp-notes.drawio"),
savedAt: new Date(1002 * 1000),
},
{
sessionId: "mcp-mgd0a1b2-old123",
path: join(dir, "mcp-mgd0a1b2-old123.drawio"),
savedAt: new Date(1000 * 1000),
},
])
})
it("lists nothing when saving is off or the folder does not exist", () => {
const off = new Autosaver(null)
expect(off.dataDir()).toBeNull()
expect(off.list()).toEqual([])
expect(new Autosaver(join(tempDir(), "missing")).list()).toEqual([])
})
})
describe("Autosaver History", () => {
const V1 = DIAGRAM.replace('id="a"', 'id="v1"')
const V2 = DIAGRAM.replace('id="a"', 'id="v2"')
const V3 = DIAGRAM.replace('id="a"', 'id="v3"')
// One session's History entries, as history.ts keeps them (the saver
// leaves the thumbnails out)
const withHistory = (dir: string, entries: { id: number; xml: string }[]) =>
new Autosaver(dir, 30, 50, () => entries)
it("saves the XML of the History entries next to the diagram", async () => {
const dir = tempDir()
const entries = [
{ id: 0, xml: V1 },
{ id: 1, xml: V2 },
]
const saver = withHistory(dir, entries)
const path = saver.historyPathFor("mcp-h") as string
expect(path).toBe(join(dir, "mcp-h.history.json"))
saver.schedule("mcp-h", V2)
expect(existsSync(path)).toBe(false)
await sleep(80)
expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual([V1, V2])
expect(saver.loadHistory("mcp-h")).toEqual([V1, V2])
expect(readdirSync(dir).sort()).toEqual([
"mcp-h.drawio",
"mcp-h.history.json",
])
})
it("saves a diagram cleared before its first save, with its History", () => {
const dir = tempDir()
const saver = withHistory(dir, [{ id: 0, xml: V1 }])
saver.schedule("mcp-cleared", BLANK)
saver.flush()
expect(
readFileSync(saver.pathFor("mcp-cleared") as string, "utf-8"),
).toBe(BLANK)
expect(saver.loadHistory("mcp-cleared")).toEqual([V1])
})
it("still skips a blank page whose History holds only blank pages", () => {
const dir = tempDir()
const saver = withHistory(dir, [{ id: 0, xml: BLANK }])
saver.schedule("mcp-empty", BLANK)
saver.flush()
expect(readdirSync(dir)).toEqual([])
})
it("writes the History again after the cap removed it", () => {
const dir = tempDir()
const entries = [{ id: 0, xml: V1 }]
const saver = new Autosaver(dir, 10, 1, () => entries)
const old = "mcp-aaaaaaaa-old"
saver.schedule(old, V1)
saver.flush()
utimesSync(saver.pathFor(old) as string, 1000, 1000)
// A newer session pushes the old one out
saver.schedule("mcp-aaaaaaab-new", V1)
saver.flush()
expect(existsSync(saver.historyPathFor(old) as string)).toBe(false)
// The old session is still open and saves again, History unchanged
saver.schedule(old, V1)
saver.flush()
expect(saver.loadHistory(old)).toEqual([V1])
})
it("rewrites the History file only when the entries changed", () => {
const dir = tempDir()
const entries = [{ id: 0, xml: V1 }]
const saver = withHistory(dir, entries)
saver.schedule("mcp-same", V1)
saver.flush()
const path = saver.historyPathFor("mcp-same") as string
utimesSync(path, 1000, 1000)
const old = statSync(path).mtimeMs
// draw.io's own autosave of an unchanged diagram
saver.schedule("mcp-same", V1)
saver.flush()
expect(statSync(path).mtimeMs).toBe(old)
entries.push({ id: 1, xml: V2 })
saver.schedule("mcp-same", V2)
saver.flush()
expect(statSync(path).mtimeMs).not.toBe(old)
expect(saver.loadHistory("mcp-same")).toEqual([V1, V2])
// The circular buffer dropped the oldest entry: same length, new id
entries.shift()
entries.push({ id: 2, xml: V3 })
saver.schedule("mcp-same", V3)
saver.flush()
expect(saver.loadHistory("mcp-same")).toEqual([V2, V3])
})
it("writes no History file while there are no entries", () => {
const dir = tempDir()
const saver = withHistory(dir, [])
saver.schedule("mcp-empty", DIAGRAM)
saver.flush()
expect(readdirSync(dir)).toEqual(["mcp-empty.drawio"])
})
it("reads [] for a missing or malformed History file", () => {
const dir = tempDir()
const saver = new Autosaver(dir, 10)
const path = saver.historyPathFor("mcp-bad") as string
expect(saver.loadHistory("mcp-bad")).toEqual([])
for (const content of ["{not json", '{"a":1}', "[1, 2]", '"x"']) {
writeFileSync(path, content)
expect(saver.loadHistory("mcp-bad")).toEqual([])
}
})
it("flush() writes the History on shutdown", () => {
const dir = tempDir()
const saver = withHistory(dir, [{ id: 0, xml: V1 }])
saver.schedule("mcp-exit", V1)
saver.flush()
expect(saver.loadHistory("mcp-exit")).toEqual([V1])
})
})
describe("defaultDataDir", () => {
@@ -14,6 +14,8 @@
*/
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { afterAll, beforeAll, describe, expect, it } from "vitest"
@@ -42,11 +44,22 @@ const EXPECTED_TOOLS = [
"get_drawing_guide",
"get_shape_library",
"screenshot_diagram",
"list_saved_diagrams",
"restore_version",
]
// Auto-save folder of the spawned server, holding one saved session
const dataDir = mkdtempSync(path.join(tmpdir(), "mcp-wiring-"))
const SAVED_ID = "mcp-aaaaaaaa-bbb"
const SAVED_DIAGRAM = `<mxfile><diagram id="p" name="Flow"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="a" vertex="1" parent="1"/></root></mxGraphModel></diagram></mxfile>`
// Claude Code truncates tool descriptions and server instructions here
const MAX_DESCRIPTION = 2048
// The server reads <DRAWIO_DATA_DIR>/instructions.md into the guide; a
// developer's real ~/.next-ai-drawio/instructions.md must not leak in
const CUSTOM_RULE = "Always use blue fill #dae8fc."
let proc: ChildProcessWithoutNullStreams
let stdoutBuf = ""
const pending = new Map<
@@ -72,8 +85,11 @@ function send(method: string, params: unknown, isNotification = false) {
}
beforeAll(async () => {
writeFileSync(path.join(dataDir, "instructions.md"), CUSTOM_RULE)
writeFileSync(path.join(dataDir, `${SAVED_ID}.drawio`), SAVED_DIAGRAM)
proc = spawn(tsxBin, [entry], {
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, DRAWIO_DATA_DIR: dataDir },
}) as ChildProcessWithoutNullStreams
proc.stdout.on("data", (chunk: Buffer) => {
@@ -111,6 +127,7 @@ beforeAll(async () => {
afterAll(() => {
proc?.kill("SIGTERM")
rmSync(dataDir, { recursive: true, force: true })
})
describe("MCP server wiring", () => {
@@ -136,6 +153,40 @@ describe("MCP server wiring", () => {
expect(props.page_index).toBeTruthy()
})
it("advertises session_id on start_session", async () => {
const resp = await send("tools/list", {})
const start = resp.result.tools.find(
(t: { name: string }) => t.name === "start_session",
)
expect(start?.inputSchema?.properties?.session_id).toBeTruthy()
expect(start?.inputSchema?.required ?? []).not.toContain("session_id")
})
it("lists the saved diagrams with their pages, without a session", async () => {
const resp = await send("tools/call", {
name: "list_saved_diagrams",
arguments: {},
})
expect(resp.result.isError).toBeFalsy()
const text: string = resp.result.content[0].text
const line = text.split("\n").find((l) => l.startsWith(SAVED_ID))
expect(line, text).toBeTruthy()
expect(line).toContain("pages: Flow (3 cells)")
expect(line).toContain(path.join(dataDir, `${SAVED_ID}.drawio`))
expect(line).not.toContain("(current)")
})
it("advertises screenshot on create_new_diagram and edit_diagram", async () => {
const resp = await send("tools/list", {})
for (const name of ["create_new_diagram", "edit_diagram"]) {
const tool = resp.result.tools.find(
(t: { name: string }) => t.name === name,
)
const props = tool?.inputSchema?.properties ?? {}
expect(props.screenshot?.type, `${name} screenshot`).toBe("boolean")
}
})
it("advertises name/id/xml on add_page", async () => {
const resp = await send("tools/list", {})
const addPage = resp.result.tools.find(
@@ -147,6 +198,23 @@ describe("MCP server wiring", () => {
expect(props.xml).toBeTruthy()
})
it("advertises steps_back on restore_version", async () => {
const resp = await send("tools/list", {})
const restore = resp.result.tools.find(
(t: { name: string }) => t.name === "restore_version",
)
expect(restore?.inputSchema?.properties?.steps_back).toBeTruthy()
})
it("refuses restore_version without a session", async () => {
const resp = await send("tools/call", {
name: "restore_version",
arguments: {},
})
expect(resp.result.isError).toBe(true)
expect(resp.result.content[0].text).toContain("start_session")
})
it("keeps every description and the instructions within the host limit", async () => {
const resp = await send("tools/list", {})
for (const tool of resp.result.tools) {
@@ -185,6 +253,86 @@ describe("MCP server wiring", () => {
})
const text: string = resp.result.content[0].text
expect(text).toContain("Edge routing rules")
expect(text).toContain("do not describe the diagram")
expect(text).toContain("clear the canvas")
expect(text.length).toBeLessThanOrEqual(15000)
})
it("appends the user's instructions.md to the drawing guide", async () => {
const resp = await send("tools/call", {
name: "get_drawing_guide",
arguments: {},
})
const text: string = resp.result.content[0].text
expect(
text.endsWith(`\n\n## Custom Instructions\n${CUSTOM_RULE}`),
).toBe(true)
})
it("advertises topic on get_drawing_guide", async () => {
const resp = await send("tools/list", {})
const guide = resp.result.tools.find(
(t: { name: string }) => t.name === "get_drawing_guide",
)
expect(guide?.inputSchema?.properties?.topic).toBeTruthy()
})
it("serves the table XML reference with topic=tables", async () => {
const resp = await send("tools/call", {
name: "get_drawing_guide",
arguments: { topic: "tables" },
})
expect(resp.result.isError).toBeFalsy()
const text: string = resp.result.content[0].text
expect(text).toContain("shape=table")
expect(text).not.toContain("Edge routing rules")
})
})
describe("load_diagram dual-source arguments", () => {
it("advertises both optional 'path' and 'xml' sources", async () => {
const resp = await send("tools/list", {})
const load = resp.result.tools.find(
(t: { name: string }) => t.name === "load_diagram",
)
const props = load?.inputSchema?.properties ?? {}
expect(props.path).toBeTruthy()
expect(props.xml).toBeTruthy()
const required: string[] = load?.inputSchema?.required ?? []
expect(required).not.toContain("path")
expect(required).not.toContain("xml")
})
it("rejects passing both 'path' and 'xml'", async () => {
// Argument validation fires before the session check: no session
// exists in this harness, so a both-args call must report the
// mutual-exclusion error, not "No active session".
const resp = await send("tools/call", {
name: "load_diagram",
arguments: { path: "/tmp/x.drawio", xml: "<mxfile/>" },
})
expect(resp.error, JSON.stringify(resp.error)).toBeUndefined()
expect(resp.result?.isError).toBe(true)
expect(resp.result?.content?.[0]?.text).toContain("not both")
})
it("rejects passing neither 'path' nor 'xml'", async () => {
const resp = await send("tools/call", {
name: "load_diagram",
arguments: {},
})
expect(resp.result?.isError).toBe(true)
expect(resp.result?.content?.[0]?.text).toContain("either 'path'")
})
it("accepts 'xml' alone as a source (fails only on the missing session)", async () => {
const resp = await send("tools/call", {
name: "load_diagram",
arguments: {
xml: '<mxfile><diagram id="p" name="P"><mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/></root></mxGraphModel></diagram></mxfile>',
},
})
expect(resp.result?.isError).toBe(true)
expect(resp.result?.content?.[0]?.text).toContain("No active session")
})
})
@@ -164,6 +164,15 @@ describe("prepareNewDiagram", () => {
)
expect(out.ok).toBe(true)
})
it("accepts the root cells alone as a blank page", () => {
const out = prepareNewDiagram(
`<mxCell id="0"/><mxCell id="1" parent="0"/>`,
)
expect(out.ok).toBe(true)
if (!out.ok) return
expect(hasCells(out.xml)).toBe(false)
})
})
describe("labels that look like attributes", () => {
@@ -0,0 +1,58 @@
/**
* The XML references returned by get_drawing_guide with a topic. Each
* snippet must be accepted by create_new_diagram as bare mxCell elements;
* whether draw.io renders it as intended is checked by hand.
*/
import { beforeAll, describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.ts"
beforeAll(() => {
installDomPolyfill()
})
import { prepareNewDiagram } from "../src/new-diagram.ts"
import { hasCells } from "../src/pages.ts"
import { XML_REFERENCE } from "../src/xml-reference.ts"
/** The code block of a reference: the lines indented by four spaces. */
function snippet(topic: keyof typeof XML_REFERENCE): string {
return XML_REFERENCE[topic]
.split("\n")
.filter((line) => line.startsWith(" "))
.map((line) => line.slice(4))
.join("\n")
}
describe("XML_REFERENCE", () => {
it.each(["tables", "layers", "groups"] as const)(
"%s snippet passes prepareNewDiagram",
(topic) => {
const xml = snippet(topic)
expect(xml).toContain("<mxCell")
const result = prepareNewDiagram(xml)
expect(result.ok, result.ok ? "" : result.error).toBe(true)
if (result.ok) expect(result.fixes).toEqual([])
},
)
it("tables use draw.io's table shapes", () => {
const xml = snippet("tables")
expect(xml).toContain("shape=table;")
expect(xml).toContain("shape=tableRow;")
expect(xml).toContain("shape=partialRectangle;")
})
it('layers keep the layer cell with parent="0"', () => {
const result = prepareNewDiagram(snippet("layers"))
expect(result.ok).toBe(true)
if (!result.ok) return
expect(hasCells(result.xml)).toBe(true)
expect(result.xml).toContain('id="L2"')
expect(result.xml).toContain('parent="L2"')
})
it("groups use a group container", () => {
expect(snippet("groups")).toContain('style="group"')
})
})
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 14 KiB

-37
View File
@@ -1,37 +0,0 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
width="1536.000000pt" height="1536.000000pt" viewBox="0 0 1536.000000 1536.000000"
preserveAspectRatio="xMidYMid meet">
<g transform="translate(0.000000,1536.000000) scale(0.100000,-0.100000)"
fill="#ffffff" stroke="none">
<path d="M2765 14404 c-100 -29 -181 -58 -225 -82 -227 -125 -359 -296 -431
-560 -19 -70 -19 -108 -19 -1175 0 -1068 1 -1104 20 -1172 58 -206 159 -356
319 -474 71 -53 199 -121 226 -121 9 0 26 -5 38 -12 12 -6 62 -19 112 -29 85
-17 207 -18 2219 -19 1172 0 2133 -3 2138 -8 4 -4 7 -246 6 -538 l-3 -529
-2330 -5 c-2506 -6 -2373 -3 -2470 -54 -61 -31 -150 -113 -194 -178 -87 -128
-82 -77 -90 -1025 l-6 -838 -360 -6 c-292 -4 -368 -8 -405 -21 -194 -68 -303
-177 -373 -372 l-22 -61 1 -2887 c1 -2716 2 -2890 18 -2935 56 -153 161 -276
286 -334 126 -59 0 -54 1400 -54 1394 0 1290 -4 1410 53 95 45 198 148 242
241 62 133 58 -93 58 3026 0 2992 1 2883 -40 2990 -59 156 -183 272 -360 337
-25 9 -146 14 -440 18 l-405 5 0 540 0 540 2020 3 c1111 1 2030 0 2043 -3 l22
-5 -2 -538 -3 -537 -380 -6 c-312 -4 -388 -8 -426 -21 -195 -68 -326 -204
-383 -399 -15 -51 -16 -295 -16 -2921 0 -2778 1 -2867 19 -2920 36 -104 72
-167 134 -230 75 -78 115 -105 222 -151 l50 -22 1219 -3 c672 -1 1255 1 1300
6 109 12 217 63 298 140 73 69 107 118 144 208 l29 69 3 2880 c2 2687 1 2884
-15 2945 -48 183 -188 332 -373 398 -37 13 -114 17 -430 21 l-385 6 -3 534
c-2 421 0 536 10 543 7 4 925 8 2039 8 1718 0 2028 -2 2038 -14 8 -10 11 -154
11 -531 -1 -284 -4 -523 -7 -531 -4 -12 -69 -14 -392 -14 -354 0 -391 -2 -448
-20 -168 -52 -282 -148 -353 -295 -22 -45 -40 -91 -40 -103 0 -11 -5 -33 -10
-47 -7 -18 -10 -988 -10 -2875 0 -2393 2 -2858 14 -2902 43 -167 148 -298 293
-369 57 -27 107 -44 151 -50 88 -11 2429 -11 2508 0 210 31 416 238 445 450 6
39 8 1245 7 2926 -3 2713 -4 2862 -21 2900 -41 93 -74 150 -110 191 -46 52
-149 134 -169 134 -8 0 -19 5 -24 10 -6 6 -42 19 -80 30 -63 18 -100 20 -415
20 -307 0 -348 2 -353 16 -3 9 -6 390 -6 848 0 797 -1 834 -19 886 -31 87 -50
118 -111 183 -66 70 -141 119 -221 144 -50 16 -228 18 -2389 23 l-2335 5 0
535 0 535 2165 5 c1191 3 2170 8 2176 12 6 4 35 12 65 17 201 35 435 198 539
376 55 93 82 153 110 245 19 63 20 94 20 1167 0 1047 -1 1106 -19 1180 -70
290 -275 523 -539 613 -160 54 232 50 -5028 49 -4182 0 -4856 -2 -4899 -15z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

+16
View File
@@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<title>Next AI Draw.io</title>
<!-- Web icon, source of public/favicon.ico, app/favicon.ico and public/favicon-*.png.
The tile fills the canvas so the icon stays large in browser tabs. -->
<rect x="4" y="4" width="1016" height="1016" rx="228" fill="#ffffff" stroke="#dfe3e8" stroke-width="8"/>
<g transform="translate(512 512) scale(0.76) translate(-512 -438)" fill="#14181d">
<rect x="140" y="62" width="744" height="244" rx="56"/>
<rect x="478" y="300" width="68" height="224"/>
<rect x="142" y="378" width="740" height="68" rx="14"/>
<rect x="142" y="378" width="68" height="146" rx="14"/>
<rect x="814" y="378" width="68" height="146" rx="14"/>
<rect x="62" y="518" width="228" height="296" rx="40"/>
<rect x="398" y="518" width="228" height="296" rx="40"/>
<rect x="734" y="518" width="228" height="296" rx="40"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 945 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 28 KiB

+18
View File
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<title>Next AI Draw.io</title>
<!-- Desktop app icon, source of resources/icon.png (1024x1024).
The tile is 824px wide on the 1024 canvas, following the macOS icon grid. -->
<g transform="translate(100 100) scale(0.8046875)">
<rect x="4" y="4" width="1016" height="1016" rx="228" fill="#ffffff" stroke="#dfe3e8" stroke-width="8"/>
<g transform="translate(512 512) scale(0.66) translate(-512 -438)" fill="#14181d">
<rect x="140" y="62" width="744" height="244" rx="56"/>
<rect x="478" y="300" width="68" height="224"/>
<rect x="142" y="378" width="740" height="68" rx="14"/>
<rect x="142" y="378" width="68" height="146" rx="14"/>
<rect x="814" y="378" width="68" height="146" rx="14"/>
<rect x="62" y="518" width="228" height="296" rx="40"/>
<rect x="398" y="518" width="228" height="296" rx="40"/>
<rect x="734" y="518" width="228" height="296" rx="40"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1013 B

+1 -1
View File
@@ -87,7 +87,7 @@ console.log("Copying static files...")
const targetStaticDir = join(targetDir, ".next", "static")
copyDereferenced(staticDir, targetStaticDir)
// Copy public folder (required for favicon-white.svg and other assets)
// Copy public folder (required for favicons and other assets)
console.log("Copying public folder...")
const publicDir = join(rootDir, "public")
const targetPublicDir = join(targetDir, "public")
+20 -3
View File
@@ -108,6 +108,25 @@ async function boxOf(locator: Locator) {
return box
}
/**
* Click the canvas where no shape is, to deselect: the corner of the
* container farthest from the shape. A fixed spot near the top-left corner
* is not safe, as a click on a shape may scroll it into that corner.
*/
async function clickEmptyCanvas(page: Page, shape: Locator) {
const container = getIframeContent(page).locator(".geDiagramContainer")
const box = await boxOf(container)
const shapeBox = await boxOf(shape)
const shapeX = shapeBox.x + shapeBox.width / 2
const shapeY = shapeBox.y + shapeBox.height / 2
await container.click({
position: {
x: shapeX < box.x + box.width / 2 ? box.width - 20 : 20,
y: shapeY < box.y + box.height / 2 ? box.height - 20 : 20,
},
})
}
/** draw.io's own Undo button: draw.io sets "disabled" with nothing to undo */
function drawioUndo(page: Page) {
return getIframeContent(page).locator('.geSimpleMainMenu a[title="Undo"]')
@@ -4512,9 +4531,7 @@ test.describe("Edge cases", () => {
})
await sendMessage(page, "Make this red")
// Deselected while the diagram exports
await canvas
.locator(".geDiagramContainer")
.click({ position: { x: 10, y: 10 } })
await clickEmptyCanvas(page, shape)
await expect(chip).toHaveCount(0)
await page.evaluate(() => (window as any).__releaseExports())
await waitForCompleteCount(page, 2)
+77
View File
@@ -434,3 +434,80 @@ describe("MCP preview with a diagram over the size limit", () => {
)
})
})
describe("MCP preview History and a recreated session", () => {
/** The pending request to a URL that starts with prefix */
const take = (t: Awaited<ReturnType<typeof inStep>>, prefix: string) => {
const call = t.calls.find((c) => c.url.startsWith(prefix))
if (!call) throw new Error(`no pending request to ${prefix}`)
t.calls.splice(t.calls.indexOf(call), 1)
return call
}
const historyList = {
status: 200,
body: { entries: [{ id: 5, index: 0, svg: "" }], count: 1 },
}
const modalOpen = () =>
document.getElementById("history-modal")?.classList.contains("open")
it("drops a History list asked for before the session was recreated", async () => {
const t = await inStep()
;(document.getElementById("history-btn") as HTMLButtonElement).click()
await t.settle()
const list = take(t, "/api/history")
// The server restarts before the list arrives: its ids are old
const poll = t.page.poll()
take(t, "/api/state").answer(state("S2", 1, "<mxfile>A</mxfile>"))
await poll
list.answer(historyList)
await t.settle()
await t.settle()
expect(modalOpen()).toBe(false)
})
it("restores from an old list with that list's state", async () => {
const t = await inStep()
const history = document.getElementById(
"history-btn",
) as HTMLButtonElement
history.click()
await t.settle()
take(t, "/api/history").answer(historyList)
await t.settle()
await t.settle()
// The server restarts; the open list closes
const poll = t.page.poll()
take(t, "/api/state").answer(state("S2", 1, "<mxfile>A</mxfile>"))
await poll
expect(modalOpen()).toBe(false)
// Asking again fails, so the old list shows
history.click()
await t.settle()
take(t, "/api/history").fail()
await t.settle()
expect(modalOpen()).toBe(true)
;(document.querySelector(".history-item") as HTMLElement).click()
;(document.getElementById("restore-btn") as HTMLButtonElement).click()
await t.settle()
// The server refuses it: the ids are S1's
expect(take(t, "/api/restore").body.stateId).toBe("S1")
})
it("names the state its History list belongs to when restoring", async () => {
const t = await inStep()
;(document.getElementById("history-btn") as HTMLButtonElement).click()
await t.settle()
take(t, "/api/history").answer(historyList)
await t.settle()
await t.settle()
expect(modalOpen()).toBe(true)
;(document.querySelector(".history-item") as HTMLElement).click()
;(document.getElementById("restore-btn") as HTMLButtonElement).click()
await t.settle()
expect(take(t, "/api/restore").body).toMatchObject({
sessionId: "mcp-test",
id: 5,
stateId: "S1",
})
})
})
-71
View File
@@ -4,7 +4,6 @@ import {
cn,
extractCompleteMxCells,
extractDiagramXML,
isMxCellXmlComplete,
isRealDiagram,
replaceNodes,
} from "@/lib/utils"
@@ -36,76 +35,6 @@ describe("isRealDiagram", () => {
})
})
describe("isMxCellXmlComplete", () => {
it("returns false for empty/null input", () => {
expect(isMxCellXmlComplete("")).toBe(false)
expect(isMxCellXmlComplete(null)).toBe(false)
expect(isMxCellXmlComplete(undefined)).toBe(false)
})
it("returns true for self-closing mxCell", () => {
const xml =
'<mxCell id="2" value="Hello" style="rounded=1;" vertex="1" parent="1"/>'
expect(isMxCellXmlComplete(xml)).toBe(true)
// A raw "<" in a value (escaped later by the auto-fix)
expect(
isMxCellXmlComplete(
'<mxCell id="3" value="<b>Title</b>" style="text;html=1;" vertex="1" parent="1"/>',
),
).toBe(true)
})
it("returns true for mxCell with closing tag", () => {
const xml = `<mxCell id="2" value="Hello" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
</mxCell>`
expect(isMxCellXmlComplete(xml)).toBe(true)
})
it("returns false for truncated mxCell", () => {
const xml =
'<mxCell id="2" value="Hello" style="rounded=1;" vertex="1" parent'
expect(isMxCellXmlComplete(xml)).toBe(false)
})
it("returns false for mxCell with unclosed geometry", () => {
const xml = `<mxCell id="2" value="Hello" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120"`
expect(isMxCellXmlComplete(xml)).toBe(false)
})
it("returns false when output stops after a child of an open mxCell", () => {
const xml = `<mxCell id="2" value="A" vertex="1" parent="1">
<mxGeometry x="0" y="0" width="80" height="40" as="geometry"/>
</mxCell>
<mxCell id="3" value="B" vertex="1" parent="1">
<mxGeometry x="100" y="0" width="80" height="40" as="geometry"/>`
expect(isMxCellXmlComplete(xml)).toBe(false)
})
it("returns false when output stops after </mxGeometry> of an open mxCell", () => {
const xml = `<mxCell id="e1" edge="1" parent="1" source="2" target="3">
<mxGeometry relative="1" as="geometry">
<mxPoint x="10" y="10" as="sourcePoint"/>
</mxGeometry>`
expect(isMxCellXmlComplete(xml)).toBe(false)
})
it("returns true for a self-closing last mxCell with > in its value", () => {
const xml = `<mxCell id="2" value="A" vertex="1" parent="1">
<mxGeometry as="geometry"/>
</mxCell>
<mxCell id="3" value="A -> B" vertex="1" parent="1"/></root>`
expect(isMxCellXmlComplete(xml)).toBe(true)
})
it("returns true for multiple complete mxCells", () => {
const xml = `<mxCell id="2" value="A" vertex="1" parent="1"/>
<mxCell id="3" value="B" vertex="1" parent="1"/>`
expect(isMxCellXmlComplete(xml)).toBe(true)
})
})
describe("cn (class name utility)", () => {
it("merges class names", () => {
expect(cn("foo", "bar")).toBe("foo bar")