Compare commits

...

3 Commits

Author SHA1 Message Date
agent_coder 2a951df096 feat(mcp): drawio стадия 3 — семантические тулы drawioFromGraph/FromMermaid/EditCells (#425)
Сырой XML остаётся escape-hatch, но для 90% случаев модель не видит ни координат,
ни style-строк — весь класс ошибок лейаута и иконок уходит by construction: эти
решения принимает сервер, а не LLM. Стоит на #443, переиспользует конвейер #423 и
shape-index/elkjs/линтер #424.

- drawioFromGraph(pageId, where, graph, direction?, preset?, layout?): граф
  узлов/групп/связей → резолв иконок (shape-index #424; неизвестная → generic по
  kind с подписью, не пустой квадрат), стили из пресета (kind→палитра),
  elkjs-layered с compound-группами, ассемблер XML (линтер-чистый by construction:
  зазоры >=150, прозрачные контейнеры, относительные координаты детей,
  cross-container рёбра parent=1, эскейп меток). Хинты pinned/sameLayerAs/layer и
  layout none/full/incremental — детерминированным post-pass'ом (ELK-констрейнты
  оказались ненадёжны). Пресеты default/dark/colorblind-safe (Okabe-Ito) — данные.
- drawioFromMermaid(pageId, where, mermaid): чистый парсер flowchart (без
  браузера/CLI) → graph → тот же конвейер. Формы/направление/пунктир=async/
  subgraph→группы/цепочки; не-flowchart отвергает внятно.
- drawioEditCells(pageId, node, operations, baseHash): ID-based add/update/delete,
  delete каскадит на детей контейнера и связанные рёбра; baseHash обязателен
  (optimistic lock как drawioUpdate); сентинелы 0/1 от delete защищены.

DoS-границы (LLM-вход): MAX_GRAPH_EDGES=1000/GROUPS=500 в validateGraph (узлы уже
500), mermaid MAX_CHARS=200k/LINES=20k/GROUPS=500, chain 500 — все с быстрым
throw ДО лейаута/ассемблера (ассемблер и маппер вне ELK-таймаута, иначе OOM
воркера). incremental сохраняет неперечисленные существующие ячейки (mergeExisting
Cells) — «добавь узел» не стирает ручную расстановку. sameLayerAs/layer после
снапа раскладываются по перпендикулярной оси с зазором >=150 → 0 quality-warnings;
pinned — точные пользовательские координаты (clamp>=0, могут дать warning, гарантия
«0 by construction» относится к авто-лейауту).

Регистрация: 3 shared-spec на оба хоста (camelCase, execute-in-spec, inlineBoth
Hosts не понадобился), DocmostClientLike/Method += 3, contract, routing-проза
(fromGraph→архитектуры/облака, fromMermaid→стандартные flowchart, raw xml→экзотика).

Тесты: mcp node --test 782/782 (57 новых) — приёмка (15+ узлов/2 вложенные группы/
AWS-иконки→0 lint/0 warnings/иконки резолвятся, hints, incremental без сдвига,
edit_cells update/cascade/stale-baseHash, снапшоты пресетов + colorblind-safe,
mermaid ветвление+subgraph) + регрессии на DoS-границы. tsc чисто; server jest
(contract + ai-chat) 290/290. closes #425.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 23:16:50 +03:00
agent_coder 5dc7a2703f feat(mcp): getPageContext — «где я / что вокруг» по pageId одним вызовом (#443, часть 3/3)
Финальная из трёх частей #443. Есть pageId (из поиска, из ссылки) — нужно понять
местоположение и окружение. Раньше это get_page + полное дерево. Только чтение,
только метаданные (без контента). closes #443.

- client.ts getPageContext(pageId): resolvePageId (slugId→uuid, для uuid без
  round-trip) → POST /pages/breadcrumbs + listSidebarPages(spaceId, uuid). Два
  запроса для uuid-входа (spaceId берётся из ответа breadcrumbs), +1 для slugId.
  Выход {page:{pageId,title,spaceId}, breadcrumbs:[{pageId,title}] (root→parent;
  [] для корня), children:[{pageId,title,hasChildren}]}.
- Порядок breadcrumbs выверен по серверному источнику (getPageBreadCrumbs: CTE
  сидится на childPageId, идёт вверх, .reverse() → root→page; страница —
  ПОСЛЕДНИЙ элемент). page = chain[last], breadcrumbs = chain.slice(0,-1); корень
  → []. Проекция явная — наружу только pageId (UUID), slugId/icon/position не
  утекают. Пустой chain / 404 / 403 → явная ошибка, не пустой объект.
- children — прямые дети через listSidebarPages (cursor-пагинация #442/#451,
  страница с >20 детьми отдаёт всех без дублей, порядок по position, hasChildren).
- Общий реестр: getPageContext на ОБОИХ хостах через цикл спеков (mcpName===
  inAppKey==="getPageContext", camelCase); DocmostClientLike/DocmostClientMethod
  += it; compile-time contract; TOOL_FAMILY READ + routing-проза.

Тесты: get-page-context.test.mjs +6 (3-й уровень split, no-slugId-leak + 2
запроса, корень→[], slugId-resolve, >20 детей без cap/дублей, плохой id→ошибка,
пустой chain→throw), tool-specs +1. mcp node --test 725/725, tsc чисто; server
jest (contract + ai-chat) 265/265.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 22:19:25 +03:00
agent_coder bfb4c8d8d0 feat(mcp): getTree — иерархия пространства/поддерева одним вызовом (#443, часть 2/3)
Раньше единственный способ получить дерево — listPages tree:true: BFS по
sidebar-эндпоинту, десятки-сотни HTTP-вызовов и молчаливая потеря страниц
(#442). Бэкенд форка уже отдаёт всё одним POST /pages/tree (getSidebarPagesTree,
merged #442/#451), так что это MCP-сторона + общий реестр. Только чтение.

- lib/tree.ts: buildPageTree(nodes) аддитивно расширен до buildPageTree(nodes,
  options?) с {shape?: "lean"|"getTree"; maxDepth?}. Дефолт/{} — байт-идентичный
  lean {id,slugId,title,children?} (существующие вызыватели listPages tree:true
  и subtree-BFS не тронуты, есть регрессионный тест на точный набор полей).
  shape:"getTree" проецирует {pageId, title, children?, hasChildren?} (id→pageId;
  slugId/icon/position/parentPageId не утекают ни на одной глубине).
- client.ts: getTree(spaceId, rootPageId?, maxDepth?) — тот же код-путь, что
  listPages tree:true: один enumerateSpacePages(spaceId, rootPageId) (единичный
  /pages/tree + cursor-BFS фолбэк для stock upstream) → buildPageTree(pages,
  {shape:"getTree", maxDepth}). listPages tree:true помечен DEPRECATED в JSDoc
  (BFS-фолбэк на месте, поведение не тронуто).
- maxDepth/hasChildren: полное дерево строится, потом обрезается. Корни = глубина
  1; maxDepth:1 → только корни; hasChildren:true ТОЛЬКО на срезанном узле с
  серверным hasChildren (source of truth), опущен у листьев и раскрытых узлов.
  Схема тула min(1) отбраковывает 0/отрицательные.
- tool-specs.ts: shared-спек getTree (оба хоста через цикл реестра),
  DocmostClientLike Pick += getTree, listPages desc/catalogLine — про депрекацию.
  server-instructions.ts: getTree:"READ" + routing-проза. Loader
  DocmostClientMethod += getTree, compile-time client-call contract += getTree.

Циклы/self-ref безопасны: project рекурсирует только от корней, циклический
компонент недостижим из корней (нет бесконечной рекурсии). Orphan (родитель
отфильтрован пермишенами) всплывает как корень.

Тесты: tree.test.mjs +9 (nesting+порядок+no-leak, maxDepth:1/2 + hasChildren,
orphan→root, seeded rootPageId, ≤0/нефинитный = без среза, lean-байт-идентичность),
tool-specs.test.mjs (схема getTree + депрекация listPages). mcp node --test
718/718, tsc чисто; server jest (shared-tool-specs.contract + ai-chat) 263/263.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 21:58:32 +03:00
20 changed files with 4135 additions and 17 deletions
@@ -59,6 +59,8 @@ function __assertClientCallContract(client: DocmostClientLike): void {
void client.getWorkspace();
void client.getSpaces();
void client.listPages(s, n, true);
void client.getTree(s, s, n);
void client.getPageContext(s);
void client.listSidebarPages(s, s);
void client.getOutline(s);
void client.getPageJson(s);
@@ -121,6 +123,23 @@ function __assertClientCallContract(client: DocmostClientLike): void {
void client.drawioGet(s, s, 'xml');
void client.drawioCreate(s, { position: 'append', anchorNodeId: s }, s, s, 'elk');
void client.drawioUpdate(s, s, s, s, 'elk');
// --- draw.io high-level semantic tools (#425 stage 3) ---
void client.drawioEditCells(s, s, [{ op: 'delete', cellId: s }], s);
void client.drawioFromGraph(
s,
{ position: 'append', anchorNodeId: s },
{ nodes: [{ id: s, label: s }] },
'LR',
s,
'full',
s,
);
void client.drawioFromMermaid(
s,
{ position: 'append', anchorNodeId: s },
s,
s,
);
// --- write (comment) ---
void client.createComment(s, s, 'inline', s, s, s);
void client.resolveComment(s, true);
@@ -23,6 +23,8 @@ type DocmostClientMethod =
| 'getWorkspace'
| 'getSpaces'
| 'listPages'
| 'getTree'
| 'getPageContext'
| 'listSidebarPages'
| 'getOutline'
| 'getPageJson'
@@ -69,6 +71,10 @@ type DocmostClientMethod =
| 'drawioGet'
| 'drawioCreate'
| 'drawioUpdate'
// --- draw.io high-level semantic tools (#425 stage 3) ---
| 'drawioEditCells'
| 'drawioFromGraph'
| 'drawioFromMermaid'
// --- write (comment) ---
| 'createComment'
| 'resolveComment';
+63
View File
@@ -0,0 +1,63 @@
{
"$comment": "Semantic palettes for drawioFromGraph (issue #425). DATA, not code: node `kind` -> fill/stroke slot, edge `kind` -> line-style props, per preset. The `default` node palette is the issue's base table; `dark` keeps the same hues on a dark canvas with lighter strokes/font; `colorblind-safe` maps every slot onto the Okabe-Ito qualitative palette (8 colours proven distinguishable for all common colour-vision deficiencies) so no two adjacent kinds collide. `fontColor`/`fillColor`/`strokeColor` are exact draw.io values. `edgeDefault` is the fallback line style; `group` is the (always-transparent) container stroke per preset.",
"presets": {
"default": {
"canvasDark": false,
"nodes": {
"service": { "fillColor": "#dae8fc", "strokeColor": "#6c8ebf", "fontColor": "#000000" },
"db": { "fillColor": "#d5e8d4", "strokeColor": "#82b366", "fontColor": "#000000" },
"queue": { "fillColor": "#fff2cc", "strokeColor": "#d6b656", "fontColor": "#000000" },
"gateway": { "fillColor": "#ffe6cc", "strokeColor": "#d79b00", "fontColor": "#000000" },
"error": { "fillColor": "#f8cecc", "strokeColor": "#b85450", "fontColor": "#000000" },
"external": { "fillColor": "#f5f5f5", "strokeColor": "#666666", "fontColor": "#333333" },
"security": { "fillColor": "#e1d5e7", "strokeColor": "#9673a6", "fontColor": "#000000" }
},
"edges": {
"sync": { "props": "" },
"async": { "props": "dashed=1;" },
"error": { "props": "dashed=1;strokeColor=#DD344C;" }
},
"edgeDefault": { "strokeColor": "#333333", "fontColor": "#333333" },
"group": { "strokeColor": "#666666", "fontColor": "#333333" }
},
"dark": {
"canvasDark": true,
"nodes": {
"service": { "fillColor": "#1a2a44", "strokeColor": "#7ea6e0", "fontColor": "#dae8fc" },
"db": { "fillColor": "#1f331e", "strokeColor": "#97d077", "fontColor": "#d5e8d4" },
"queue": { "fillColor": "#3a3218", "strokeColor": "#e5c15a", "fontColor": "#fff2cc" },
"gateway": { "fillColor": "#3a2812", "strokeColor": "#ffb570", "fontColor": "#ffe6cc" },
"error": { "fillColor": "#3a1c1b", "strokeColor": "#e08e8b", "fontColor": "#f8cecc" },
"external": { "fillColor": "#2b2b2b", "strokeColor": "#999999", "fontColor": "#e0e0e0" },
"security": { "fillColor": "#2c2338", "strokeColor": "#b39ddb", "fontColor": "#e1d5e7" }
},
"edges": {
"sync": { "props": "" },
"async": { "props": "dashed=1;" },
"error": { "props": "dashed=1;strokeColor=#ff6b6b;" }
},
"edgeDefault": { "strokeColor": "#cccccc", "fontColor": "#e0e0e0" },
"group": { "strokeColor": "#aaaaaa", "fontColor": "#e0e0e0" }
},
"colorblind-safe": {
"canvasDark": false,
"okabeIto": ["#000000", "#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7"],
"nodes": {
"service": { "fillColor": "#D6E9F5", "strokeColor": "#0072B2", "fontColor": "#000000" },
"db": { "fillColor": "#D6EFE4", "strokeColor": "#009E73", "fontColor": "#000000" },
"queue": { "fillColor": "#FCF8CC", "strokeColor": "#F0E442", "fontColor": "#000000" },
"gateway": { "fillColor": "#FBEBD0", "strokeColor": "#E69F00", "fontColor": "#000000" },
"error": { "fillColor": "#F7DDCC", "strokeColor": "#D55E00", "fontColor": "#000000" },
"external": { "fillColor": "#EDEDED", "strokeColor": "#000000", "fontColor": "#000000" },
"security": { "fillColor": "#F3DEEB", "strokeColor": "#CC79A7", "fontColor": "#000000" }
},
"edges": {
"sync": { "props": "" },
"async": { "props": "dashed=1;" },
"error": { "props": "dashed=1;strokeColor=#D55E00;" }
},
"edgeDefault": { "strokeColor": "#000000", "fontColor": "#000000" },
"group": { "strokeColor": "#000000", "fontColor": "#000000" }
}
}
}
+365 -3
View File
@@ -64,6 +64,14 @@ import {
} from "./lib/drawio-xml.js";
import { renderDiagramShapes } from "./lib/drawio-preview.js";
import { applyElkLayout } from "./lib/drawio-layout.js";
import {
buildFromGraph,
type Graph,
type LayoutMode as GraphLayoutMode,
} from "./lib/drawio-graph.js";
import { applyCellOps, type CellOp } from "./lib/drawio-cell-ops.js";
import { mermaidToGraph } from "./lib/drawio-mermaid.js";
import { parseCells as parseDrawioCells } from "./lib/drawio-xml.js";
import {
applyTextEdits,
TextEdit,
@@ -811,13 +819,17 @@ export class DocmostClient {
* large instances, so a single bounded page of results is returned (default
* 50, max 100) via the `/pages/recent` feed.
*
* Tree (`tree` true): the space's FULL page hierarchy as a nested tree (each
* node has a `children` array). This mode REQUIRES `spaceId` (a page tree is
* Tree (`tree` true): DEPRECATED prefer `getTree`, which shares this exact
* code path (a single `/pages/tree` request via `enumerateSpacePages` +
* `buildPageTree`) but returns the compact `{pageId, title, children?,
* hasChildren?}` shape and supports `rootPageId`/`maxDepth`. This tree mode is
* kept for backward compatibility; it REQUIRES `spaceId` (a page tree is
* scoped to one space) and IGNORES `limit` the whole hierarchy is returned.
* It fetches the tree via `enumerateSpacePages`, which on the fork server
* resolves to a single `/pages/tree` request returning the whole
* permission-filtered flat page set (soft-deleted pages excluded
* server-side).
* server-side); the cursor-BFS in `enumerateSpacePages` is only a fallback for
* stock upstream servers that lack `/pages/tree`.
*/
async listPages(spaceId?: string, limit: number = 50, tree: boolean = false) {
await this.ensureAuthenticated();
@@ -841,6 +853,107 @@ export class DocmostClient {
return items.map((page: any) => filterPage(page));
}
/**
* Fetch a space's page hierarchy (or one subtree) as a nested tree in a SINGLE
* request the #443 `getTree` tool. Shares its whole code path with
* `listPages(tree:true)`: `enumerateSpacePages` issues one `POST /pages/tree`
* (with the cursor-BFS only as a fallback for stock upstream servers that lack
* the endpoint), then `buildPageTree` nests the flat, permission-filtered,
* position-ordered list. No second tree fetch, no per-node BFS.
*
* - `rootPageId` restrict to that page's subtree; the server seeds the CTE
* with the page itself, so the result is exactly ONE root (the page and its
* descendants). Omit it for the whole space.
* - `maxDepth` trim the response to that many levels (roots = depth 1) to
* save tokens; the server still returns everything in one request, the cut
* is applied in `buildPageTree` AFTER the full tree is built. A node whose
* children were cut carries `hasChildren: true` (source of truth = the flat
* item's server `hasChildren`) so the caller can descend with a follow-up
* `getTree(spaceId, rootPageId=that node)` call.
*
* Output nodes are `{pageId, title, children?, hasChildren?}` only the UUID
* `pageId` is exposed (never `slugId`/`icon`/`position`). Requires `spaceId`
* (a page tree is scoped to one space).
*/
async getTree(spaceId: string, rootPageId?: string, maxDepth?: number) {
await this.ensureAuthenticated();
if (!spaceId) {
throw new Error(
"getTree: spaceId is required (a page tree is scoped to one space).",
);
}
const { pages } = await this.enumerateSpacePages(spaceId, rootPageId);
return buildPageTree(pages, { shape: "getTree", maxDepth });
}
/**
* "Where am I / what's around" for a single page the #443 `getPageContext`
* tool. Metadata only (no page content), using exactly TWO server requests:
*
* 1. `POST /pages/breadcrumbs` a recursive CTE that walks UP from the page.
* The server returns the chain root->page order (it `.reverse()`s the
* child-first walk before responding), INCLUDING the page itself as the
* LAST element. So the last element is the page and everything before it
* is the ancestor chain root->parent. This carries the page's own title
* and spaceId, so no extra page-info fetch is needed for a UUID input.
* 2. `listSidebarPages(spaceId, pageId)` the page's DIRECT children,
* cursor-paginated (a page with >20 children returns ALL of them, no
* dupes) and in sidebar `position` order, each carrying `hasChildren`.
*
* The input may be a slugId (agents copy them from URLs); it is run through
* `resolvePageId` first, exactly like the other page tools. A UUID input adds
* no request there (short-circuit), keeping the total at two; a slugId input
* adds one unavoidable resolve round-trip.
*
* INVARIANT: only the UUID `pageId` is exposed anywhere server `id` is
* mapped to `pageId` and `slugId` is never leaked. A nonexistent/inaccessible
* pageId makes the server 404/403, which propagates as a clear tool error
* (never a hollow empty object).
*/
async getPageContext(pageId: string) {
await this.ensureAuthenticated();
// Resolve a possibly-slugId input to the canonical UUID (no round-trip for a
// UUID). Errors here (bad/inaccessible id) propagate as a clear tool error.
const pageUuid = await this.resolvePageId(pageId);
// Request 1: the ancestor chain, root->page, page included as the LAST item.
const response = await this.client.post("/pages/breadcrumbs", {
pageId: pageUuid,
});
const chain: any[] = (response.data?.data ?? response.data) ?? [];
if (!Array.isArray(chain) || chain.length === 0) {
// The endpoint always includes the page itself, so an empty chain means
// the page is gone/inaccessible — surface a clear error, not {}.
throw new Error(`getPageContext: page "${pageId}" not found or inaccessible`);
}
// Split: the last element is the page, the rest (root->parent) are the
// breadcrumbs. A root page has no ancestors -> breadcrumbs is [].
const self = chain[chain.length - 1];
const ancestors = chain.slice(0, -1);
const page = {
pageId: self.id,
title: self.title,
spaceId: self.spaceId,
};
const breadcrumbs = ancestors.map((n: any) => ({
pageId: n.id,
title: n.title,
}));
// Request 2: direct children in sidebar order, each with hasChildren.
const childItems = await this.listSidebarPages(self.spaceId, pageUuid);
const children = childItems.map((c: any) => ({
pageId: c.id,
title: c.title,
hasChildren: Boolean(c.hasChildren),
}));
return { page, breadcrumbs, children };
}
/**
* List sidebar pages for a space. With no pageId the request returns the
* space ROOT pages; with a pageId it returns the direct CHILDREN of that
@@ -4525,6 +4638,255 @@ export class DocmostClient {
};
}
// --- draw.io high-level semantic tools (issue #425) ---
/**
* ID-based targeted edits of an existing drawio diagram (add / update / delete
* cells) instead of resending the whole XML. Reads the CURRENT diagram, checks
* the optimistic lock (`baseHash` is MANDATORY, exactly as drawioUpdate), applies
* the operations to the parsed model (a `delete` CASCADES to container children
* and to every edge whose source/target is deleted), then runs the SAME #423
* pipeline as drawioUpdate (lint + quality warnings -> preview -> attachment ->
* repoint the node). Ids are stable so diffs stay meaningful across edits.
*/
async drawioEditCells(
pageId: string,
node: string,
operations: CellOp[],
baseHash: string,
): Promise<{
success: boolean;
nodeId: string;
attachmentId: string;
warnings: string[];
verify?: any;
}> {
await this.ensureAuthenticated();
if (typeof baseHash !== "string" || baseHash.length === 0) {
throw new Error(
"drawioEditCells: baseHash is mandatory — read the diagram with drawioGet first and pass back its meta.hash",
);
}
if (!Array.isArray(operations) || operations.length === 0) {
throw new Error(
"drawioEditCells: operations must be a non-empty array of { op, ... }",
);
}
const { node: drawio, ref } = await this.resolveDrawioNode(pageId, node);
const oldAttrs = drawio.attrs || {};
const oldSrc = oldAttrs.src;
const nodeId = oldAttrs.id ?? ref;
if (!oldSrc) {
throw new Error(
`drawioEditCells: node "${node}" on page ${pageId} has no src to edit`,
);
}
const currentSvg = await this.fetchAttachmentText(oldSrc);
const currentModel = decodeDrawioSvg(currentSvg);
const currentHash = mxHash(currentModel);
if (currentHash !== baseHash) {
throw new Error(
`drawioEditCells: conflict — the diagram changed since it was read ` +
`(baseHash ${baseHash} != current ${currentHash}). Re-read it with drawioGet and retry.`,
);
}
// Apply the operations to the parsed model, then run the standard pipeline.
const editedModel = applyCellOps(currentModel, operations);
const prepared = prepareModel(editedModel);
const inner = renderDiagramShapes(prepared.cells, prepared.bbox);
const diagramTitle = oldAttrs.title || "Page-1";
const svg = buildDrawioSvg(prepared.modelXml, inner, prepared.bbox, diagramTitle);
const att = await this.uploadAttachmentBuffer(
pageId,
Buffer.from(svg, "utf-8"),
"diagram.drawio.svg",
"image/svg+xml",
);
const newSrc = `/api/files/${att.id}/${att.fileName}`;
const collabToken = await this.getCollabTokenWithReauth();
const pageUuid = await this.resolvePageId(pageId);
let repointed = 0;
const mutation = await this.mutatePage(
pageUuid,
collabToken,
this.apiUrl,
(liveDoc) => {
repointed = 0;
const doc =
liveDoc && liveDoc.type === "doc" ? liveDoc : { type: "doc", content: [] };
if (!Array.isArray(doc.content)) doc.content = [];
const hit = getNodeByRef(doc, ref);
if (!hit || hit.type !== "drawio") return null;
let target: any = doc;
for (const idx of hit.path) {
if (!target || !Array.isArray(target.content)) {
target = null;
break;
}
target = target.content[idx];
}
if (!target || target.type !== "drawio") return null;
target.attrs = {
...target.attrs,
src: newSrc,
attachmentId: att.id,
width: prepared.bbox.width,
height: prepared.bbox.height,
};
repointed++;
return doc;
},
);
if (repointed === 0) {
return {
success: true,
nodeId,
attachmentId: att.id,
warnings: [
...prepared.warnings,
"target drawio node was removed concurrently; uploaded attachment is unreferenced",
],
verify: mutation.verify,
};
}
return {
success: true,
nodeId,
attachmentId: att.id,
warnings: prepared.warnings,
verify: mutation.verify,
};
}
/**
* The main high-level tool: build a diagram from a SEMANTIC graph (nodes with
* a `kind`/`icon`, groups, edges) the model never supplies coordinates or
* style strings. The server resolves icons via the shape catalog (#424),
* assigns palette colors from the preset, runs ELK layered layout (honouring
* `direction` and the `layer`/`sameLayerAs`/`pinned` hints and compound groups),
* and assembles linter-clean XML, then inserts it through the SAME create
* pipeline as drawioCreate. `layout:"incremental"` is only meaningful when a
* target `node` is given (it preserves that diagram's existing coordinates and
* places only new cells); on a fresh insert it behaves like "full".
*/
async drawioFromGraph(
pageId: string,
where: {
position: "before" | "after" | "append";
anchorNodeId?: string;
anchorText?: string;
},
graph: Graph,
direction?: "LR" | "RL" | "TB" | "BT",
preset?: string,
layout?: GraphLayoutMode,
node?: string,
): Promise<{
success: boolean;
nodeId: string;
attachmentId: string;
warnings: string[];
iconsResolved: number;
iconsMissing: string[];
verify?: any;
}> {
await this.ensureAuthenticated();
// Direction/preset supplied as separate params override the graph fields so
// both the flat tool schema and an inline graph can set them.
const merged: Graph = {
...graph,
direction: direction ?? graph.direction,
preset: preset ?? graph.preset,
};
const mode: GraphLayoutMode = layout ?? "full";
// Incremental into an EXISTING node: read its coords so ELK preserves them,
// and keep the full existing model so incremental MERGES (never drops) any
// cell the new graph doesn't re-list.
let existingCoords: Map<string, { x: number; y: number }> | undefined;
let existingModelXml: string | undefined;
let editExisting = false;
let baseHash: string | undefined;
if (node && (mode === "incremental" || mode === "none")) {
const { node: drawio } = await this.resolveDrawioNode(pageId, node);
const src = (drawio.attrs || {}).src;
if (src) {
const svg = await this.fetchAttachmentText(src);
const model = decodeDrawioSvg(svg);
baseHash = mxHash(model);
existingModelXml = model;
existingCoords = new Map();
for (const c of parseDrawioCells(model)) {
if (c.vertex && c.geometry.x != null && c.geometry.y != null) {
existingCoords.set(c.id, { x: c.geometry.x, y: c.geometry.y });
}
}
editExisting = true;
}
}
const built = await buildFromGraph(
merged,
mode,
existingCoords,
existingModelXml,
);
if (editExisting && node && baseHash) {
// Re-target the existing diagram: replace it with the assembled model.
const res = await this.drawioUpdate(pageId, node, built.modelXml, baseHash);
return {
...res,
iconsResolved: built.iconsResolved,
iconsMissing: built.iconsMissing,
};
}
const res = await this.drawioCreate(pageId, where, built.modelXml);
return {
...res,
iconsResolved: built.iconsResolved,
iconsMissing: built.iconsMissing,
};
}
/**
* Convert a Mermaid `flowchart` to a redactable draw.io diagram via a PURE
* parser (no Electron / draw.io CLI): mermaid text -> graph-JSON -> the
* drawioFromGraph pipeline. Only `flowchart`/`graph` is supported (the most
* common wiki case); other diagram types throw a clear error so the model can
* fall back to drawioFromGraph.
*/
async drawioFromMermaid(
pageId: string,
where: {
position: "before" | "after" | "append";
anchorNodeId?: string;
anchorText?: string;
},
mermaid: string,
preset?: string,
): Promise<{
success: boolean;
nodeId: string;
attachmentId: string;
warnings: string[];
iconsResolved: number;
iconsMissing: string[];
verify?: any;
}> {
await this.ensureAuthenticated();
const graph = mermaidToGraph(mermaid);
if (preset) graph.preset = preset;
return this.drawioFromGraph(pageId, where, graph, graph.direction, graph.preset);
}
// --- Page history / diff / transform ---
/**
+168
View File
@@ -0,0 +1,168 @@
// ID-based cell operations for `drawioEditCells` (issue #425, stage 3).
//
// Instead of resending the whole XML (whose diff is fragile — draw.io reorders
// attributes, and a {search,replace} text match breaks on it), the model sends
// targeted operations keyed by cell id:
//
// { op: "add", xml: "<mxCell .../>" } // append a new cell
// { op: "update", cellId: "n3", xml: "<mxCell .../>" } // replace that cell
// { op: "delete", cellId: "n5" } // + CASCADE
//
// `delete` CASCADES: it removes the cell, every descendant cell whose parent
// chain leads to it (container children), AND every edge whose source or target
// is any deleted cell. Ids are STABLE across edits so diffs stay meaningful.
//
// Operations apply to the parsed DOM of the current model; the caller re-lints
// and rebuilds the .drawio.svg through the existing #423 pipeline afterwards.
import { JSDOM } from "jsdom";
let _window: any = null;
function xmlWindow(): any {
if (!_window) _window = new JSDOM("").window;
return _window;
}
export type CellOp =
| { op: "add"; xml: string }
| { op: "update"; cellId: string; xml: string }
| { op: "delete"; cellId: string };
export class CellOpsError extends Error {
constructor(message: string) {
super(`drawioEditCells: ${message}`);
this.name = "CellOpsError";
}
}
// The mxGraph root sentinels. id="0" is the graph root; id="1" is the default
// layer that parents every real cell. A delete targeting either would cascade
// through the whole diagram body (every cell chains up to "1"), so such an op is
// rejected outright.
const SENTINEL_IDS = new Set(["0", "1"]);
/** Parse a single `<mxCell …>…</mxCell>` fragment into an element, or throw. */
function parseCellFragment(xml: string): any {
const parser = new (xmlWindow().DOMParser)();
// Wrap so a self-closed or child-bearing single cell parses as one root.
const doc = parser.parseFromString(`<root>${xml}</root>`, "application/xml");
if (doc.getElementsByTagName("parsererror").length > 0) {
throw new CellOpsError(`operation xml is not well-formed: ${xml.slice(0, 120)}`);
}
const cells = doc.getElementsByTagName("mxCell");
if (cells.length !== 1) {
throw new CellOpsError(
`each add/update op must carry exactly one <mxCell> (got ${cells.length})`,
);
}
return cells[0];
}
/** All ids reachable as descendants of `rootId` via the parent relation. */
function collectDescendants(
rootId: string,
parentOf: Map<string, string | undefined>,
): Set<string> {
const doomed = new Set<string>([rootId]);
let grew = true;
while (grew) {
grew = false;
for (const [id, parent] of parentOf) {
if (!doomed.has(id) && parent != null && doomed.has(parent)) {
doomed.add(id);
grew = true;
}
}
}
return doomed;
}
/**
* Apply the operation list to a model XML string and return the new model XML.
* Uses the DOM so attribute order / formatting is preserved for untouched cells.
* Throws CellOpsError on an unknown target id or a malformed op fragment (so the
* model gets a precise error and nothing is half-applied).
*/
export function applyCellOps(modelXml: string, ops: CellOp[]): string {
if (!Array.isArray(ops) || ops.length === 0) {
throw new CellOpsError("operations must be a non-empty array");
}
const parser = new (xmlWindow().DOMParser)();
const doc = parser.parseFromString(modelXml, "application/xml");
if (doc.getElementsByTagName("parsererror").length > 0) {
throw new CellOpsError("the current diagram XML is not well-formed");
}
const root = doc.getElementsByTagName("root")[0];
if (!root) throw new CellOpsError("the current diagram has no <root> element");
const cellEls = () => Array.from(root.getElementsByTagName("mxCell")) as any[];
const byId = () => {
const m = new Map<string, any>();
for (const el of cellEls()) m.set(el.getAttribute("id") ?? "", el);
return m;
};
for (const op of ops) {
if (op.op === "add") {
const frag = parseCellFragment(op.xml);
const id = frag.getAttribute("id");
if (!id) throw new CellOpsError("an add op's <mxCell> is missing an id");
if (byId().has(id))
throw new CellOpsError(`add op id "${id}" already exists (use update)`);
root.appendChild(doc.importNode(frag, true));
} else if (op.op === "update") {
const map = byId();
const target = map.get(op.cellId);
if (!target)
throw new CellOpsError(`update target cell "${op.cellId}" does not exist`);
const frag = parseCellFragment(op.xml);
const newId = frag.getAttribute("id");
if (newId && newId !== op.cellId)
throw new CellOpsError(
`update op cellId "${op.cellId}" != the <mxCell> id "${newId}" (ids are stable)`,
);
// Replace the element in place so surrounding cells are untouched.
const imported = doc.importNode(frag, true);
target.parentNode.replaceChild(imported, target);
} else if (op.op === "delete") {
// Reject a sentinel-targeted delete BEFORE collecting descendants: "0"/"1"
// parent the entire diagram, so a cascade from either would wipe the whole
// model body (doomed.delete("0"/"1") only spared the sentinel itself, not
// its children).
if (SENTINEL_IDS.has(op.cellId))
throw new CellOpsError(
`cannot delete sentinel cell "${op.cellId}" (the graph root/default layer)`,
);
const map = byId();
if (!map.has(op.cellId))
throw new CellOpsError(`delete target cell "${op.cellId}" does not exist`);
// Build the parent relation over the CURRENT cells for the cascade.
const parentOf = new Map<string, string | undefined>();
for (const el of cellEls()) {
parentOf.set(el.getAttribute("id") ?? "", el.getAttribute("parent") ?? undefined);
}
const doomed = collectDescendants(op.cellId, parentOf);
// Cascade to edges whose source/target is any doomed cell.
for (const el of cellEls()) {
if (el.getAttribute("edge") !== "1") continue;
const src = el.getAttribute("source");
const tgt = el.getAttribute("target");
if ((src && doomed.has(src)) || (tgt && doomed.has(tgt))) {
doomed.add(el.getAttribute("id") ?? "");
}
}
// Never delete the sentinels even if referenced by a malformed op.
doomed.delete("0");
doomed.delete("1");
for (const el of cellEls()) {
const id = el.getAttribute("id") ?? "";
if (doomed.has(id)) el.parentNode.removeChild(el);
}
} else {
throw new CellOpsError(`unknown op "${(op as any).op}"`);
}
}
const ser = new (xmlWindow().XMLSerializer)();
return ser.serializeToString(doc.documentElement);
}
+916
View File
@@ -0,0 +1,916 @@
// Semantic graph -> draw.io pipeline for `drawioFromGraph` (issue #425, stage 3).
//
// The model describes a diagram SEMANTICALLY — nodes with a `kind` and an
// optional `icon`, groups (containers), edges with a `kind` — and NEVER sees a
// coordinate or a style string. This module owns the whole server-side pipeline:
//
// 1. validateGraph — a hand-written validator (no zod dependency, so this
// lib stays importable by client.ts without coupling to
// a zod major) that rejects malformed graphs early.
// 2. resolveNodeStyle — `icon` -> exact style via the shape catalog (#424);
// an UNKNOWN icon degrades to a generic shape by `kind`
// WITH the label (never an empty square). `kind` -> the
// preset palette slot.
// 3. graphToElk — graph -> ELK-JSON, honouring the layout hints
// (`layer`/`sameLayerAs` -> layer constraints, `pinned`
// -> a fixed node) and compound group nodes.
// 4. assembleModel — graph + ELK coordinates -> a full mxGraphModel XML
// that satisfies the #423 linter BY CONSTRUCTION
// (sentinels, transparent containers, relative child
// coords, cross-container edges parent="1", >=150px
// gaps from ELK spacing, escaped labels).
//
// The `layout` mode: "full" re-lays everything; "incremental" fixes existing
// coordinates (ELK interactive mode) and places only new nodes; "none" keeps the
// caller-provided/prior coordinates untouched.
import ELK from "elkjs/lib/elk.bundled.js";
import { JSDOM } from "jsdom";
import {
searchShapes,
awsServiceStyle,
type ShapeResult,
} from "./drawio-shapes.js";
import {
getPreset,
genericNodeStyle,
iconNodeStyle,
edgeStyle,
groupStyle,
type PresetData,
} from "./drawio-presets.js";
import { MIN_SHAPE_GAP } from "./drawio-xml.js";
// --- graph schema (plain TS + a hand validator) ----------------------------
export interface GraphNode {
id: string;
label: string;
kind?: string;
/** Icon reference, e.g. "aws:lambda" | "azure:cosmos" | "lambda". */
icon?: string;
/** Group (container) id this node belongs to. */
group?: string;
/** Layer hint (ELK layerChoiceConstraint): 0-based column/row index. */
layer?: number;
/** Put this node in the same layer as another node id. */
sameLayerAs?: string;
/** Fix this node at exact coordinates (an ELK fixed node). */
pinned?: { x: number; y: number };
}
export interface GraphGroup {
id: string;
label: string;
kind?: string;
/** Parent group id — lets a group nest inside another group (e.g. subnet in VPC). */
group?: string;
}
export interface GraphEdge {
from: string;
to: string;
label?: string;
kind?: string;
}
export interface Graph {
nodes: GraphNode[];
groups?: GraphGroup[];
edges?: GraphEdge[];
direction?: "LR" | "RL" | "TB" | "BT";
preset?: string;
}
export type LayoutMode = "none" | "full" | "incremental";
/** A structured validation error (mirrors the drawio linter's shape loosely). */
export class GraphValidationError extends Error {
issues: string[];
constructor(issues: string[]) {
super(`drawioFromGraph: invalid graph — ${issues.join("; ")}`);
this.name = "GraphValidationError";
this.issues = issues;
}
}
const MAX_GRAPH_NODES = 500; // parity with drawio-layout's ELK_MAX_NODES.
// Edge/group caps mirror drawio-layout's ELK_MAX_EDGES. Without an edge cap a
// tiny node set with a huge edge list (e.g. 500 nodes / 200000 edges) passes
// node validation, then graphToElk/runElk exhausts the heap SYNCHRONOUSLY inside
// elk.bundled.js — before the 5s ELK timeout can fire and OUTSIDE it entirely
// for the mapper/assembler — crashing the worker on LLM-authored input. Reject
// the over-limit shape here, before any layout or assembly runs.
export const MAX_GRAPH_EDGES = 1000; // parity with drawio-layout's ELK_MAX_EDGES.
export const MAX_GRAPH_GROUPS = 500; // groups are compound ELK nodes; bound them too.
/**
* Validate the graph structure BEFORE any layout/assembly so the model gets a
* precise, actionable error instead of a corrupt diagram. Throws
* GraphValidationError listing every problem.
*/
export function validateGraph(graph: Graph): void {
const issues: string[] = [];
if (!graph || typeof graph !== "object") {
throw new GraphValidationError(["graph must be an object"]);
}
if (!Array.isArray(graph.nodes) || graph.nodes.length === 0) {
throw new GraphValidationError(["graph.nodes must be a non-empty array"]);
}
// Size caps FIRST (fail fast, before touching per-element loops) so an
// over-limit graph can never reach the layout engine and OOM the worker.
if (graph.nodes.length > MAX_GRAPH_NODES) {
throw new GraphValidationError([
`graph has ${graph.nodes.length} nodes (max ${MAX_GRAPH_NODES})`,
]);
}
if (Array.isArray(graph.edges) && graph.edges.length > MAX_GRAPH_EDGES) {
throw new GraphValidationError([
`graph has ${graph.edges.length} edges (max ${MAX_GRAPH_EDGES})`,
]);
}
if (Array.isArray(graph.groups) && graph.groups.length > MAX_GRAPH_GROUPS) {
throw new GraphValidationError([
`graph has ${graph.groups.length} groups (max ${MAX_GRAPH_GROUPS})`,
]);
}
const nodeIds = new Set<string>();
const groupIds = new Set<string>();
for (const g of graph.groups ?? []) {
if (!g.id) issues.push("a group is missing its id");
else if (groupIds.has(g.id)) issues.push(`duplicate group id "${g.id}"`);
groupIds.add(g.id);
}
for (const n of graph.nodes) {
if (!n.id) issues.push("a node is missing its id");
else if (nodeIds.has(n.id)) issues.push(`duplicate node id "${n.id}"`);
else if (groupIds.has(n.id))
issues.push(`node id "${n.id}" collides with a group id`);
nodeIds.add(n.id);
if (typeof n.label !== "string" || n.label === "")
issues.push(`node "${n.id}" is missing a label`);
if (n.group != null && !groupIds.has(n.group))
issues.push(`node "${n.id}" references unknown group "${n.group}"`);
if (n.pinned != null) {
if (
typeof n.pinned.x !== "number" ||
typeof n.pinned.y !== "number" ||
!Number.isFinite(n.pinned.x) ||
!Number.isFinite(n.pinned.y)
)
issues.push(`node "${n.id}" has an invalid pinned {x,y}`);
}
if (n.layer != null && (!Number.isInteger(n.layer) || n.layer < 0))
issues.push(`node "${n.id}" has an invalid layer (must be a >=0 integer)`);
}
// sameLayerAs must reference an existing node (checked after all ids known).
for (const n of graph.nodes) {
if (n.sameLayerAs != null && !nodeIds.has(n.sameLayerAs))
issues.push(
`node "${n.id}" sameLayerAs references unknown node "${n.sameLayerAs}"`,
);
}
for (const e of graph.edges ?? []) {
if (!e.from || !e.to) {
issues.push("an edge is missing from/to");
continue;
}
if (!nodeIds.has(e.from) && !groupIds.has(e.from))
issues.push(`edge from "${e.from}" resolves to no node/group`);
if (!nodeIds.has(e.to) && !groupIds.has(e.to))
issues.push(`edge to "${e.to}" resolves to no node/group`);
}
if (issues.length > 0) throw new GraphValidationError(issues);
}
// --- icon resolution -------------------------------------------------------
/**
* Resolve a node's `icon` reference to a concrete style-string + size via the
* shape catalog. Accepts "aws:lambda", "azure:cosmos", or a bare "lambda". An
* AWS `resIcon` name is built directly (exact service-icon template). Anything
* else goes through searchShapes. Returns null when nothing resolves the
* caller then falls back to a generic shape by kind (never an empty box).
*/
export function resolveIcon(icon: string): ShapeResult | null {
const raw = icon.trim();
if (raw === "") return null;
let provider = "";
let name = raw;
const colon = raw.indexOf(":");
if (colon !== -1) {
provider = raw.slice(0, colon).trim().toLowerCase();
name = raw.slice(colon + 1).trim();
}
if (provider === "aws") {
// Prefer an exact resIcon match from the catalog (carries the right size and
// any rebrand/blocklist note); if the underscore/space name doesn't hit,
// build the canonical service-icon style directly so it is never an empty box.
const results = searchShapes(name.replace(/_/g, " "), { limit: 5 });
const aws4 = results.find((r) => r.style.includes("mxgraph.aws4"));
if (aws4) return aws4;
return {
style: awsServiceStyle(name.replace(/\s+/g, "_")),
w: 78,
h: 78,
title: name,
type: "vertex",
};
}
// Non-AWS or bare name: fuzzy search the catalog. Take the top vertex hit,
// but REJECT a weak match (the fuzzy scorer can prefix-match an unrelated
// stencil, e.g. "not..." -> "Notebook"); require the hit's title to actually
// share a meaningful token with the query, otherwise degrade to generic-by-kind.
const q = provider ? `${provider} ${name}` : name;
const results = searchShapes(q, { limit: 8 });
const hit = results.find((r) => r.type !== "edge") ?? results[0];
if (!hit) return null;
if (!isRelevantMatch(name, hit.title)) return null;
return hit;
}
/**
* Whether a resolved stencil is a genuine match for the requested icon name (as
* opposed to a loose prefix hit on an unrelated shape). True if any 3+ char
* token of the query appears in the stencil title, or vice-versa.
*/
function isRelevantMatch(name: string, title: string): boolean {
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
const qTokens = norm(name).split(/\s+/).filter((t) => t.length >= 3);
if (qTokens.length === 0) return true; // very short names: trust the scorer
const t = norm(title);
const tTokens = new Set(t.split(/\s+/));
for (const qt of qTokens) {
if (tTokens.has(qt)) return true;
if (t.includes(qt)) return true;
for (const tt of tTokens) if (tt.length >= 3 && qt.includes(tt)) return true;
}
return false;
}
/**
* Decide the final style + size for a node. When `icon` resolves, use the icon
* style (overlaid with a dark-preset font fix); otherwise a GENERIC shape by
* `kind` carrying the label. `resolved` reports whether an icon was found (used
* by the acceptance test that asserts no empty squares).
*/
export function resolveNodeStyle(
preset: PresetData,
node: GraphNode,
): { style: string; w: number; h: number; iconResolved: boolean } {
if (node.icon) {
const shape = resolveIcon(node.icon);
if (shape) {
return {
style: iconNodeStyle(preset, shape.style),
w: shape.w,
h: shape.h,
iconResolved: true,
};
}
}
// Generic shape by kind, sized to the label so a long label never overflows.
const w = Math.max(120, estimateLabelWidth(node.label) + 32);
return { style: genericNodeStyle(preset, node.kind), w, h: 60, iconResolved: false };
}
/** Rough rendered width of the longest label line at 12px (~0.6em/glyph). */
function estimateLabelWidth(label: string): number {
const lines = label.split(/\r?\n|&#xa;|<br\s*\/?>/i);
let longest = 0;
for (const l of lines) longest = Math.max(longest, l.trim().length);
return Math.ceil(longest * 12 * 0.6);
}
// --- graph -> ELK-JSON -----------------------------------------------------
interface ElkNode {
id: string;
width?: number;
height?: number;
x?: number;
y?: number;
children?: ElkNode[];
layoutOptions?: Record<string, string>;
}
interface ElkEdge {
id: string;
sources: string[];
targets: string[];
}
interface ElkGraph extends ElkNode {
edges?: ElkEdge[];
}
const ELK_DIRECTION: Record<string, string> = {
LR: "RIGHT",
RL: "LEFT",
TB: "DOWN",
BT: "UP",
};
/** Sizes resolved per node id (from resolveNodeStyle), fed to the ELK mapper. */
export interface NodeSize {
w: number;
h: number;
}
/**
* Build the ELK graph from the semantic graph + resolved node sizes. Compound
* group nodes nest their members (a group may itself nest in another group).
* `only` restricts the graph to a subset of node ids (used by the incremental
* path to lay out ONLY the new nodes). Layout HINTS (`layer`/`sameLayerAs`/
* `pinned`) are NOT encoded as ELK constraints here ELK's constraint knobs are
* unreliable across versions they are enforced deterministically AFTER layout
* by applyHints, which is exact and testable.
*/
export function graphToElk(
graph: Graph,
sizes: Map<string, NodeSize>,
opts: { only?: Set<string> } = {},
): ElkGraph {
const direction = ELK_DIRECTION[graph.direction ?? "LR"] ?? "RIGHT";
const only = opts.only;
const include = (id: string) => !only || only.has(id);
const makeNode = (n: GraphNode): ElkNode => {
const size = sizes.get(n.id) ?? { w: 140, h: 60 };
return { id: n.id, width: size.w, height: size.h };
};
// Group children nest under their group node; ungrouped nodes are roots.
const groupNode = new Map<string, ElkNode>();
const usedGroups = new Set<string>();
for (const g of graph.groups ?? []) {
const size = sizes.get(g.id) ?? { w: 200, h: 150 };
groupNode.set(g.id, {
id: g.id,
width: size.w,
height: size.h,
children: [],
layoutOptions: {
"elk.algorithm": "layered",
"elk.direction": direction,
"elk.padding": "[top=40,left=30,bottom=30,right=30]",
"elk.layered.spacing.nodeNodeBetweenLayers": "170",
"elk.spacing.nodeNode": "170",
},
});
}
const roots: ElkNode[] = [];
for (const n of graph.nodes) {
if (!include(n.id)) continue;
const en = makeNode(n);
if (n.group && groupNode.has(n.group)) {
groupNode.get(n.group)!.children!.push(en);
usedGroups.add(n.group);
} else {
roots.push(en);
}
}
// Nest group nodes into their parent group (a subnet inside a VPC); groups
// with no parent group become roots. Only groups that hold an included node.
const groupIdSet = new Set((graph.groups ?? []).map((g) => g.id));
for (const g of graph.groups ?? []) {
if (only && !usedGroups.has(g.id)) continue;
const en = groupNode.get(g.id)!;
if (g.group && groupIdSet.has(g.group) && g.group !== g.id && (!only || usedGroups.has(g.group))) {
groupNode.get(g.group)!.children!.push(en);
} else {
roots.push(en);
}
}
// Edges: endpoints may be nodes or groups; INCLUDE_CHILDREN spans the nesting.
const validIds = new Set<string>([
...graph.nodes.filter((n) => include(n.id)).map((n) => n.id),
...(graph.groups ?? []).map((g) => g.id),
]);
const edges: ElkEdge[] = [];
(graph.edges ?? []).forEach((e, i) => {
if (!validIds.has(e.from) || !validIds.has(e.to)) return;
edges.push({ id: `e${i}`, sources: [e.from], targets: [e.to] });
});
const rootOptions: Record<string, string> = {
"elk.algorithm": "layered",
"elk.direction": direction,
"elk.hierarchyHandling": "INCLUDE_CHILDREN",
"elk.layered.spacing.nodeNodeBetweenLayers": "170",
"elk.spacing.nodeNode": "170",
"elk.spacing.edgeNode": "40",
"elk.spacing.edgeEdge": "30",
"elk.padding": "[top=20,left=20,bottom=20,right=20]",
};
return { id: "root", layoutOptions: rootOptions, children: roots, edges };
}
/**
* Enforce the layout hints DETERMINISTICALLY on ELK's output (mutates `geo`):
* - `sameLayerAs`: snap the dependent node's LAYER-AXIS coordinate to its
* anchor's, so the pair lands in the same layer (x for LR/RL, y for TB/BT).
* `layer` groups nodes with the same index onto the same anchor coordinate.
* - `pinned`: override the node's coordinate with the exact pinned {x,y}.
* Applied only to top-level (ungrouped) nodes, whose ELK coords are absolute.
*/
export function applyHints(
graph: Graph,
geo: Map<string, { x: number; y: number; w: number; h: number }>,
): void {
const dir = graph.direction ?? "LR";
const layerAxis: "x" | "y" = dir === "TB" || dir === "BT" ? "y" : "x";
// The perpendicular (cross-layer) axis: members snapped onto one layer must be
// spread along THIS axis so they don't stack onto the same point.
const crossAxis: "x" | "y" = layerAxis === "x" ? "y" : "x";
const crossSize: "w" | "h" = crossAxis === "x" ? "w" : "h";
const grouped = new Set(
graph.nodes.filter((n) => n.group).map((n) => n.id),
);
// sameLayerAs / layer: co-assign the layer-axis coordinate.
// Build the effective layer key per node, then pick a representative coord.
const layerKeyOf = new Map<string, string>();
const explicitLayer = new Map<string, number>();
for (const n of graph.nodes) if (n.layer != null) explicitLayer.set(n.id, n.layer);
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
const resolveKey = (n: GraphNode): string | null => {
if (explicitLayer.has(n.id)) return `L${explicitLayer.get(n.id)}`;
const seen = new Set<string>([n.id]);
let cur: GraphNode | undefined = n;
while (cur && cur.sameLayerAs != null && !seen.has(cur.sameLayerAs)) {
seen.add(cur.sameLayerAs);
const t = byId.get(cur.sameLayerAs);
if (!t) break;
if (explicitLayer.has(t.id)) return `L${explicitLayer.get(t.id)}`;
cur = t;
}
// A sameLayerAs chain with no explicit layer: key on the chain's root id.
if (n.sameLayerAs != null) {
let root = n.id;
const s2 = new Set<string>([n.id]);
let c: GraphNode | undefined = n;
while (c && c.sameLayerAs != null && !s2.has(c.sameLayerAs)) {
s2.add(c.sameLayerAs);
root = c.sameLayerAs;
c = byId.get(c.sameLayerAs);
}
return `C${root}`;
}
return null;
};
for (const n of graph.nodes) {
if (grouped.has(n.id)) continue; // group children are relative — skip
const key = resolveKey(n);
if (key) layerKeyOf.set(n.id, key);
}
// Group members of each layer key so we can snap AND spread them together.
const membersOf = new Map<string, string[]>();
for (const n of graph.nodes) {
const key = layerKeyOf.get(n.id);
if (key == null) continue;
if (!geo.has(n.id)) continue;
(membersOf.get(key) ?? membersOf.set(key, []).get(key)!).push(n.id);
}
// For each layer key: snap every member to the FIRST member's layer-axis coord,
// then SPREAD them along the perpendicular (cross-layer) axis with a >=
// MIN_SHAPE_GAP gap. Without the spread, a sameLayerAs chain whose nodes ELK
// happened to give the same cross-axis coordinate would collapse onto one point
// -> shape-overlap + edge-through-shape quality warnings (breaking the
// "0 warnings by construction" guarantee for these AUTO-positioned hints). We
// start from the members' minimum cross-axis coord and stack them with a gap
// of MIN_SHAPE_GAP beyond each shape's cross-axis size.
for (const [key, members] of membersOf) {
if (members.length === 0) continue;
// Snap layer-axis coord to the first member.
const repCoord = geo.get(members[0])![layerAxis];
// Preserve the members' existing relative order along the cross axis so the
// spread stays visually stable, then re-lay them contiguously.
const sorted = [...members].sort(
(a, b) => geo.get(a)![crossAxis] - geo.get(b)![crossAxis],
);
let cursor = geo.get(sorted[0])![crossAxis];
for (const id of sorted) {
const g = geo.get(id)!;
g[layerAxis] = repCoord;
g[crossAxis] = cursor;
cursor += g[crossSize] + MIN_SHAPE_GAP;
}
void key;
}
// pinned: exact override (wins over any layer snap). Explicit user coordinates
// are user intent, but CLAMP to non-negative so an out-of-bounds pin (e.g.
// x:-500) never renders off-canvas. Two user-pinned nodes at the same point is
// user error the server can't silently relocate — the assembler docstring
// documents that explicit pins are user-directed and MAY warn (see #423/#425
// acceptance: the "0 quality-warnings by construction" guarantee is for
// AUTO-LAYOUT, not for coordinates the user pinned by hand).
for (const n of graph.nodes) {
if (!n.pinned) continue;
const px = Math.max(0, n.pinned.x);
const py = Math.max(0, n.pinned.y);
const g = geo.get(n.id);
if (g) {
g.x = px;
g.y = py;
} else {
const sz = { w: 140, h: 60 };
geo.set(n.id, { x: px, y: py, w: sz.w, h: sz.h });
}
}
}
/**
* Incremental variant of applyHints: apply `pinned` only to NEW nodes (those
* absent from `existing`); an existing node's coordinates are NEVER changed
* (acceptance #3). sameLayerAs/layer snapping is intentionally skipped in the
* incremental path moving a new node's layer axis could still be desired, but
* it must never move an existing cell, so we keep the incremental contract
* simple: existing cells are frozen, new pinned nodes honour their pin.
*/
export function applyHintsForNew(
graph: Graph,
geo: Map<string, { x: number; y: number; w: number; h: number }>,
existing: Map<string, { x: number; y: number }>,
): void {
for (const n of graph.nodes) {
if (existing.has(n.id)) continue; // never move an existing cell
if (!n.pinned) continue;
const px = Math.max(0, n.pinned.x); // clamp out-of-bounds pins non-negative
const py = Math.max(0, n.pinned.y);
const g = geo.get(n.id);
if (g) {
g.x = px;
g.y = py;
} else {
geo.set(n.id, { x: px, y: py, w: 140, h: 60 });
}
}
}
// --- layout runner ---------------------------------------------------------
const ELK_TIMEOUT_MS = 5000;
/**
* Run ELK over the mapped graph and return computed geometry per id (coords are
* parent-relative, matching mxGraph's convention for container children). On any
* ELK failure/timeout the returned map is empty and the caller falls back to a
* deterministic grid placement (so the write never fails on a layout hiccup).
*/
export async function runElk(
elk: ElkGraph,
): Promise<Map<string, { x: number; y: number; w: number; h: number }>> {
const geo = new Map<string, { x: number; y: number; w: number; h: number }>();
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const Ctor: any = (ELK as any).default ?? ELK;
const inst = new Ctor();
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error("ELK timed out")), ELK_TIMEOUT_MS);
});
const laid = (await Promise.race([inst.layout(elk as any), timeout])) as ElkGraph;
const walk = (n: ElkNode) => {
if (n.id !== "root") {
geo.set(n.id, {
x: Math.round(n.x ?? 0),
y: Math.round(n.y ?? 0),
w: Math.round(n.width ?? 140),
h: Math.round(n.height ?? 60),
});
}
for (const c of n.children ?? []) walk(c);
};
walk(laid);
} catch {
return new Map(); // best-effort: empty -> caller uses fallback grid.
} finally {
if (timer) clearTimeout(timer);
}
return geo;
}
// --- XML assembler ---------------------------------------------------------
/** Order groups so a parent group always precedes its nested children. */
function topoSortGroups(groups: GraphGroup[], groupIds: Set<string>): GraphGroup[] {
const byId = new Map(groups.map((g) => [g.id, g]));
const out: GraphGroup[] = [];
const done = new Set<string>();
const visit = (g: GraphGroup, stack: Set<string>) => {
if (done.has(g.id)) return;
if (stack.has(g.id)) return; // cycle guard
stack.add(g.id);
if (g.group && groupIds.has(g.group) && g.group !== g.id) {
const parent = byId.get(g.group);
if (parent) visit(parent, stack);
}
stack.delete(g.id);
if (!done.has(g.id)) {
done.add(g.id);
out.push(g);
}
};
for (const g of groups) visit(g, new Set());
return out;
}
function xmlEscapeAttr(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/\r\n|\r|\n/g, "&#xa;"); // literal newline -> the linter-approved entity
}
export interface AssembleResult {
modelXml: string;
iconsResolved: number;
iconsMissing: string[];
}
/**
* Assemble the final mxGraphModel XML from the graph + resolved styles/coords.
* Guarantees BY CONSTRUCTION that the #423 linter passes:
* - id=0 and id=1(parent=0) sentinels;
* - each node/group is vertex="1" (containers get container=1 via the style);
* - each edge is edge="1" with a child <mxGeometry relative="1" as="geometry"/>;
* - group children set parent=<groupId> and RELATIVE coords; an edge between
* two different parents is parent="1";
* - labels are XML-escaped and any newline is &#xa;.
* `geo` may be empty (ELK failed) then a deterministic grid is used so the
* output is still valid and non-overlapping (>=170px stride).
*
* QUALITY-WARNING GUARANTEE: the "0 quality-warnings by construction" promise
* holds for AUTO-LAYOUT ELK spacing plus applyHints' cross-axis spread for the
* server-positioned `layer`/`sameLayerAs` hints keep shapes >=MIN_SHAPE_GAP
* apart. It does NOT extend to explicit `pinned` coordinates: those are
* user-directed, so two nodes the user pins to the same/overlapping point are
* user error the server honours verbatim (only clamped non-negative) and MAY
* therefore produce a quality warning.
*/
export function assembleModel(
graph: Graph,
opts: {
preset: PresetData;
styles: Map<string, { style: string; w: number; h: number; iconResolved: boolean }>;
geo: Map<string, { x: number; y: number; w: number; h: number }>;
},
): AssembleResult {
const { preset, styles, geo } = opts;
const groupIds = new Set((graph.groups ?? []).map((g) => g.id));
const nodeById = new Map(graph.nodes.map((n) => [n.id, n]));
// Fallback grid when ELK produced nothing: lay ungrouped nodes on a grid with
// a 190px stride (>150 gap). Grouped nodes/groups are placed inside their group.
const fallback = geo.size === 0;
const gridPos = (i: number) => ({ x: 40 + (i % 5) * 200, y: 40 + Math.floor(i / 5) * 140 });
const cells: string[] = ['<mxCell id="0"/>', '<mxCell id="1" parent="0"/>'];
// Groups first (they are parents of their members). A nested group sets
// parent=<parentGroupId>; emit parents before children so parent-exists holds.
let gi = 0;
const groupGeo = new Map<string, { x: number; y: number; w: number; h: number }>();
const orderedGroups = topoSortGroups(graph.groups ?? [], groupIds);
for (const g of orderedGroups) {
const gg = geo.get(g.id) ?? { ...gridPos(gi++), w: 320, h: 220 };
groupGeo.set(g.id, gg);
const style = groupStyle(preset);
const gParent = g.group && groupIds.has(g.group) && g.group !== g.id ? g.group : "1";
cells.push(
`<mxCell id="${xmlEscapeAttr(g.id)}" value="${xmlEscapeAttr(g.label)}" style="${style}" vertex="1" parent="${xmlEscapeAttr(gParent)}">` +
`<mxGeometry x="${gg.x}" y="${gg.y}" width="${gg.w}" height="${gg.h}" as="geometry"/></mxCell>`,
);
}
// Nodes. A grouped node's coords are RELATIVE to its group (ELK already
// returns child coords relative to the parent; for the fallback grid we place
// children on a small in-group grid).
let ungrouped = (graph.groups?.length ?? 0);
const inGroupIndex = new Map<string, number>();
let iconsResolved = 0;
const iconsMissing: string[] = [];
for (const n of graph.nodes) {
const st = styles.get(n.id)!;
if (n.icon) {
if (st.iconResolved) iconsResolved++;
else iconsMissing.push(n.id);
}
let x: number;
let y: number;
const g = geo.get(n.id);
if (g && !fallback) {
x = g.x;
y = g.y;
} else if (n.group && groupIds.has(n.group)) {
const k = inGroupIndex.get(n.group) ?? 0;
inGroupIndex.set(n.group, k + 1);
x = 30 + (k % 3) * 180;
y = 40 + Math.floor(k / 3) * 120;
} else {
const p = gridPos(ungrouped++);
x = p.x;
y = p.y;
}
const parent = n.group && groupIds.has(n.group) ? n.group : "1";
cells.push(
`<mxCell id="${xmlEscapeAttr(n.id)}" value="${xmlEscapeAttr(n.label)}" style="${st.style}" vertex="1" parent="${xmlEscapeAttr(parent)}">` +
`<mxGeometry x="${x}" y="${y}" width="${st.w}" height="${st.h}" as="geometry"/></mxCell>`,
);
}
// Edges. parent="1" whenever the two endpoints have different container
// parents (or either is a group); otherwise the shared group id.
(graph.edges ?? []).forEach((e, i) => {
const style = edgeStyle(preset, e.kind);
const fromNode = nodeById.get(e.from);
const toNode = nodeById.get(e.to);
const fromParent = fromNode?.group && groupIds.has(fromNode.group) ? fromNode.group : "1";
const toParent = toNode?.group && groupIds.has(toNode.group) ? toNode.group : "1";
const parent = fromParent === toParent ? fromParent : "1";
const label = e.label ? ` value="${xmlEscapeAttr(e.label)}"` : "";
cells.push(
`<mxCell id="ge${i}"${label} style="${style}" edge="1" parent="${xmlEscapeAttr(parent)}" ` +
`source="${xmlEscapeAttr(e.from)}" target="${xmlEscapeAttr(e.to)}">` +
`<mxGeometry relative="1" as="geometry"/></mxCell>`,
);
});
const modelAttrs =
'dx="0" dy="0" grid="1" gridSize="10" page="1" pageWidth="850" pageHeight="1100" adaptiveColors="auto"';
const modelXml = `<mxGraphModel ${modelAttrs}><root>${cells.join("")}</root></mxGraphModel>`;
return { modelXml, iconsResolved, iconsMissing };
}
// --- incremental merge -----------------------------------------------------
let _mergeWindow: any = null;
function mergeWindow(): any {
if (!_mergeWindow) _mergeWindow = new JSDOM("").window;
return _mergeWindow;
}
/**
* Merge the freshly-assembled graph XML with the EXISTING diagram model so an
* incremental "add a node" call never drops a hand-placed cell. `assembleModel`
* emits ONLY the passed graph's cells; on its own it would replace the whole
* model, wiping any existing cell the caller didn't re-list. This splices every
* existing cell that the graph does NOT re-list (preserved verbatim: coords,
* style, edges) into the assembled root:
* - id in the graph -> the graph's (re-laid) cell wins (already assembled;
* coords are frozen for existing ids via the incremental geo path);
* - id NOT in the graph -> the existing cell is preserved verbatim;
* - a graph node absent from the existing model -> added (offset clear).
* The sentinels ("0"/"1") come from the assembled model and are never doubled.
*/
function mergeExistingCells(
assembledXml: string,
existingModelXml: string,
graph: Graph,
): string {
const win = mergeWindow();
const parser = new win.DOMParser();
const existingDoc = parser.parseFromString(existingModelXml, "application/xml");
if (existingDoc.getElementsByTagName("parsererror").length > 0) {
// Existing model unreadable: fall back to the assembled model alone (still a
// valid diagram — better than throwing on a corrupt prior file).
return assembledXml;
}
const assembledDoc = parser.parseFromString(assembledXml, "application/xml");
const root = assembledDoc.getElementsByTagName("root")[0];
if (!root) return assembledXml;
// Ids the assembled model already emitted (graph nodes/groups/edges + sentinels).
const assembledIds = new Set<string>();
for (const el of Array.from(root.getElementsByTagName("mxCell")) as any[]) {
const id = el.getAttribute("id");
if (id) assembledIds.add(id);
}
// The graph's own ids: any existing cell with one of these is superseded by the
// assembled version and must NOT be re-imported.
const graphIds = new Set<string>([
...graph.nodes.map((n) => n.id),
...(graph.groups ?? []).map((g) => g.id),
]);
const existingCells = Array.from(
existingDoc.getElementsByTagName("mxCell"),
) as any[];
for (const el of existingCells) {
const id = el.getAttribute("id") ?? "";
if (id === "0" || id === "1") continue; // sentinels come from the assembled model
if (graphIds.has(id)) continue; // graph re-lists it -> assembled version wins
if (assembledIds.has(id)) continue; // id collision guard -> keep assembled
root.appendChild(assembledDoc.importNode(el, true));
assembledIds.add(id);
}
const ser = new win.XMLSerializer();
return ser.serializeToString(assembledDoc.documentElement);
}
// --- top-level: graph -> mxGraphModel XML ----------------------------------
export interface BuildFromGraphResult {
modelXml: string;
iconsResolved: number;
iconsMissing: string[];
layout: LayoutMode;
}
/**
* The full server-side pipeline: validate -> resolve styles/icons -> map to ELK
* -> run ELK (or fall back) -> assemble linter-clean XML. `existingCoords` is
* supplied for `layout:"incremental"` (the coordinates of the diagram's current
* cells, so they are preserved and only new nodes are placed). `existingModelXml`
* is the current diagram's full model XML in incremental mode every existing
* cell the graph does NOT re-list is MERGED back in verbatim so a hand-placed
* cell is never dropped (WARNING #4). Pure no network.
*/
export async function buildFromGraph(
graph: Graph,
layout: LayoutMode = "full",
existingCoords?: Map<string, { x: number; y: number }>,
existingModelXml?: string,
): Promise<BuildFromGraphResult> {
validateGraph(graph);
const preset = getPreset(graph.preset);
// Resolve every node's style + size (icon or generic-by-kind).
const styles = new Map<
string,
{ style: string; w: number; h: number; iconResolved: boolean }
>();
const sizes = new Map<string, NodeSize>();
for (const n of graph.nodes) {
const s = resolveNodeStyle(preset, n);
styles.set(n.id, s);
sizes.set(n.id, { w: s.w, h: s.h });
}
// Group sizes: seed a min box; ELK computes the real size when it lays out.
for (const g of graph.groups ?? []) sizes.set(g.id, { w: 240, h: 180 });
let geo = new Map<string, { x: number; y: number; w: number; h: number }>();
if (layout === "incremental" && existingCoords && existingCoords.size > 0) {
// INCREMENTAL: keep every existing cell's coords VERBATIM (acceptance #3 —
// never move a hand-arranged cell) and lay out ONLY the new nodes, then
// offset that block clear of the existing bbox so nothing overlaps.
for (const [id, c] of existingCoords) {
const sz = sizes.get(id) ?? { w: 140, h: 60 };
geo.set(id, { x: c.x, y: c.y, w: sz.w, h: sz.h });
}
const newIds = new Set(
graph.nodes.filter((n) => !existingCoords.has(n.id)).map((n) => n.id),
);
if (newIds.size > 0) {
const elk = graphToElk(graph, sizes, { only: newIds });
const laid = await runElk(elk);
// Place the new block below the existing content (a clear >=170px gap).
let maxY = 0;
for (const c of existingCoords.values()) maxY = Math.max(maxY, c.y);
const offsetY = maxY + 200;
for (const [id, g] of laid) {
if (newIds.has(id)) geo.set(id, { ...g, y: g.y + offsetY });
else if (!geo.has(id)) geo.set(id, g); // a new group container
}
}
// Hints still apply to NEW pinned nodes only (existing ones stay put).
applyHintsForNew(graph, geo, existingCoords);
} else if (layout !== "none") {
const elk = graphToElk(graph, sizes);
geo = await runElk(elk);
applyHints(graph, geo);
} else if (existingCoords) {
// layout:"none" with prior coords -> keep them verbatim.
for (const [id, c] of existingCoords) {
const sz = sizes.get(id) ?? { w: 140, h: 60 };
geo.set(id, { x: c.x, y: c.y, w: sz.w, h: sz.h });
}
}
const assembled = assembleModel(graph, { preset, styles, geo });
// In incremental mode, splice back every existing cell the graph didn't
// re-list so an "add one node" call preserves the user's manual layout.
let modelXml = assembled.modelXml;
if (
layout === "incremental" &&
existingModelXml &&
existingCoords &&
existingCoords.size > 0
) {
modelXml = mergeExistingCells(modelXml, existingModelXml, graph);
}
return {
modelXml,
iconsResolved: assembled.iconsResolved,
iconsMissing: assembled.iconsMissing,
layout,
};
}
+347
View File
@@ -0,0 +1,347 @@
// Pure Mermaid `flowchart` -> graph-JSON parser for `drawioFromMermaid` (issue
// #425, stage 3, OPTIONAL). The escape clause in the issue: convert WITHOUT
// Electron/draw.io-CLI, so a pure text parser only. It handles the common wiki
// flowchart subset — node shapes, labelled/dashed edges, subgraphs (-> groups),
// and the direction header — and emits a Graph the drawioFromGraph pipeline
// renders as an EDITABLE draw.io diagram. Anything beyond flowchart (sequence /
// class / state) throws a clear error so the model falls back to drawioFromGraph.
//
// DELIBERATELY NARROW: this is not a full Mermaid grammar (Mermaid's own parser
// is a 100KB+ browser dependency). It covers `flowchart`/`graph` with the node
// shapes and edge arrows that show up in practice; unusual syntax is skipped
// rather than mis-parsed, and a diagram that yields no nodes throws.
import type { Graph, GraphNode, GraphEdge, GraphGroup } from "./drawio-graph.js";
export class MermaidParseError extends Error {
constructor(message: string) {
super(`drawioFromMermaid: ${message}`);
this.name = "MermaidParseError";
}
}
// Input-size bounds applied BEFORE parsing. Without them a pathological mermaid
// string (e.g. 300000 connection lines, or 20000 nested `subgraph`s) builds a
// huge intermediate node/edge/group structure that OOM-crashes the worker — the
// downstream validateGraph caps in drawio-graph can't help because the parser
// exhausts the heap constructing the intermediate FIRST. These caps reject the
// over-limit input fast, before a single line is parsed.
const MAX_MERMAID_CHARS = 200_000; // ~200 KB of source is far beyond any real diagram.
const MAX_MERMAID_LINES = 20_000;
const MAX_MERMAID_GROUPS = 500; // parity with drawio-graph's MAX_GRAPH_GROUPS.
// Per connection line, the number of chained nodes we will expand (`A-->B-->C`).
const MAX_CHAIN_NODES = 500;
const DIRECTIONS: Record<string, Graph["direction"]> = {
LR: "LR",
RL: "RL",
TB: "TB",
TD: "TB",
BT: "BT",
};
/**
* Node-shape delimiters -> a semantic `kind`. Mermaid encodes shape in the
* bracket style; we map the common ones to the palette kinds so the diagram is
* colored meaningfully (a decision/diamond -> queue, a database cylinder -> db,
* a rounded/stadium -> service, a subroutine/hexagon -> gateway, default rect ->
* service). The label text lives between the delimiters.
*/
interface ShapeDef {
open: string;
close: string;
kind: string;
}
// Order matters: longer/multi-char delimiters first so "([" beats "(".
const SHAPES: ShapeDef[] = [
{ open: "([", close: "])", kind: "service" }, // stadium
{ open: "[[", close: "]]", kind: "gateway" }, // subroutine
{ open: "[(", close: ")]", kind: "db" }, // cylinder-ish / database
{ open: "((", close: "))", kind: "external" }, // circle
{ open: "{{", close: "}}", kind: "gateway" }, // hexagon
{ open: "[", close: "]", kind: "service" }, // rectangle
{ open: "(", close: ")", kind: "service" }, // rounded
{ open: "{", close: "}", kind: "queue" }, // rhombus / decision
{ open: ">", close: "]", kind: "external" }, // asymmetric flag
];
/** Strip Mermaid label quoting/escapes and normalise whitespace. */
function cleanLabel(raw: string): string {
let s = raw.trim();
if (
(s.startsWith('"') && s.endsWith('"')) ||
(s.startsWith("'") && s.endsWith("'"))
) {
s = s.slice(1, -1);
}
return s.replace(/<br\s*\/?>/gi, " ").replace(/\s+/g, " ").trim();
}
/** A single edge-arrow spec: its regex and the resulting edge `kind`. */
interface ArrowDef {
re: RegExp;
kind: string;
}
// Dotted arrows (`-.->`) -> async; thick (`==>`) stay sync; normal `-->`/`---`.
// Each captures an optional `|label|` OR inline label between the two arrow
// halves. Applied to the segment between two node tokens.
const ARROWS: ArrowDef[] = [
{ re: /-\.->|-\.-/, kind: "async" },
{ re: /==>|===/, kind: "sync" },
{ re: /-->|---/, kind: "sync" },
];
interface ParsedRef {
id: string;
node?: GraphNode;
}
/**
* Parse a single node token like `A`, `A[Label]`, `db[(Orders)]`, `d{Choose}`.
* Returns the id and, when the token declares a shape/label, a GraphNode.
*/
function parseNodeToken(token: string): ParsedRef | null {
const t = token.trim();
if (t === "") return null;
for (const shape of SHAPES) {
const oi = t.indexOf(shape.open);
if (oi <= 0) continue;
if (!t.endsWith(shape.close)) continue;
const id = t.slice(0, oi).trim();
const label = cleanLabel(t.slice(oi + shape.open.length, t.length - shape.close.length));
if (!id) return null;
return { id, node: { id, label: label || id, kind: shape.kind } };
}
// Bare id (no shape declared here — may be defined elsewhere).
if (/^[A-Za-z0-9_.-]+$/.test(t)) return { id: t };
return null;
}
/**
* Split a connection line into [leftToken, arrowSegment, rightToken]. Returns
* null if the line has no arrow. The arrow segment may embed a label as
* `-->|text|` or `-- text -->`.
*/
function splitConnection(
line: string,
): { left: string; right: string; kind: string; label?: string } | null {
for (const arrow of ARROWS) {
// Find the arrow occurrence. Support a mid-arrow label: `A -- text --> B`.
const m = arrow.re.exec(line);
if (!m) continue;
const idx = m.index;
let left = line.slice(0, idx).trim();
let rest = line.slice(idx + m[0].length).trim();
let label: string | undefined;
// Pipe label: `-->|HTTPS| B`.
const pipe = /^\|([^|]*)\|\s*(.*)$/.exec(rest);
if (pipe) {
label = cleanLabel(pipe[1]);
rest = pipe[2].trim();
}
// Mid-arrow label on the left side: `A -- text` before the arrow half.
const midLeft = /^(.*?)\s*--\s*(.+)$/.exec(left);
if (!label && midLeft && /-\.|--|==/.test(line.slice(0, idx))) {
// Only treat as a label when there's clearly text after `--`.
if (!/[\[\](){}]/.test(midLeft[2])) {
left = midLeft[1].trim();
label = cleanLabel(midLeft[2]);
}
}
if (!left || !rest) return null;
return { left, right: rest, kind: arrow.kind, label };
}
return null;
}
/**
* Parse Mermaid flowchart text into a Graph. Handles the header
* (`flowchart LR` / `graph TD`), `subgraph <id>[title] … end` blocks (-> groups),
* node declarations, and connection lines. Throws MermaidParseError for a
* non-flowchart diagram or when nothing parses.
*/
export function mermaidToGraph(mermaid: string): Graph {
if (typeof mermaid !== "string" || mermaid.trim() === "") {
throw new MermaidParseError("empty mermaid input");
}
// Size guards FIRST — bound the raw input before building any intermediate.
if (mermaid.length > MAX_MERMAID_CHARS) {
throw new MermaidParseError(
`input is ${mermaid.length} chars (max ${MAX_MERMAID_CHARS}); split the diagram or use drawioFromGraph`,
);
}
const rawLines = mermaid.split(/\r?\n/);
if (rawLines.length > MAX_MERMAID_LINES) {
throw new MermaidParseError(
`input has ${rawLines.length} lines (max ${MAX_MERMAID_LINES}); split the diagram or use drawioFromGraph`,
);
}
const nodes = new Map<string, GraphNode>();
const groups: GraphGroup[] = [];
const edges: GraphEdge[] = [];
let direction: Graph["direction"] = "LR";
let sawHeader = false;
// Stack of active subgraph ids (nesting); the top is the current group.
const groupStack: string[] = [];
let anonGroup = 0;
const ensureNode = (ref: ParsedRef) => {
const existing = nodes.get(ref.id);
if (ref.node) {
if (existing) {
// Fill in a label/kind if this token declared a shape and the prior didn't.
if (existing.label === existing.id && ref.node.label !== ref.node.id)
existing.label = ref.node.label;
if (!existing.kind) existing.kind = ref.node.kind;
} else {
nodes.set(ref.id, { ...ref.node });
}
} else if (!existing) {
nodes.set(ref.id, { id: ref.id, label: ref.id, kind: "service" });
}
// Assign to the current subgraph if inside one and not yet grouped.
const cur = groupStack[groupStack.length - 1];
const n = nodes.get(ref.id)!;
if (cur && n.group == null) n.group = cur;
};
for (const raw of rawLines) {
let line = raw.trim();
if (line === "" || line.startsWith("%%")) continue; // blank / comment
// Header.
const header = /^(flowchart|graph)\s+([A-Za-z]{2})\b/.exec(line);
if (header) {
sawHeader = true;
const dir = DIRECTIONS[header[2].toUpperCase()];
if (dir) direction = dir;
continue;
}
if (/^(sequenceDiagram|classDiagram|stateDiagram|erDiagram|gantt|pie|journey)\b/.test(line)) {
throw new MermaidParseError(
`only 'flowchart'/'graph' is supported (got '${line.split(/\s+/)[0]}'); use drawioFromGraph instead`,
);
}
// Subgraph open: `subgraph id [Title]` or `subgraph Title`.
const sg = /^subgraph\s+(.+)$/.exec(line);
if (sg) {
const spec = sg[1].trim();
let id: string;
let label: string;
const bracket = /^([A-Za-z0-9_.-]+)\s*\[(.+)\]$/.exec(spec);
if (bracket) {
id = bracket[1];
label = cleanLabel(bracket[2]);
} else if (/^[A-Za-z0-9_.-]+$/.test(spec)) {
id = spec;
label = spec;
} else {
id = `sg${anonGroup++}`;
label = cleanLabel(spec);
}
if (!groups.some((g) => g.id === id)) {
if (groups.length >= MAX_MERMAID_GROUPS) {
throw new MermaidParseError(
`too many subgraphs (max ${MAX_MERMAID_GROUPS}); use drawioFromGraph for a diagram this large`,
);
}
groups.push({ id, label, kind: "group" });
}
groupStack.push(id);
continue;
}
if (/^end\b/.test(line)) {
groupStack.pop();
continue;
}
// `direction LR` inside a subgraph — apply to the top-level direction.
const innerDir = /^direction\s+([A-Za-z]{2})\b/.exec(line);
if (innerDir) {
const dir = DIRECTIONS[innerDir[1].toUpperCase()];
if (dir) direction = dir;
continue;
}
// Style/class/click directives: ignore (no visual mapping in our palette).
if (/^(style|classDef|class|click|linkStyle)\b/.test(line)) continue;
// Strip a trailing semicolon.
if (line.endsWith(";")) line = line.slice(0, -1).trim();
// Connection line (possibly chained: A --> B --> C).
const conn = splitConnection(line);
if (conn) {
// Handle a simple chain by re-splitting the right side.
let leftTok = conn.left;
let seg: typeof conn | null = conn;
let guard = 0;
while (seg) {
if (guard++ >= MAX_CHAIN_NODES) {
// Don't silently drop the tail of an over-long chain — surface it so
// the model knows the diagram was too large rather than getting a
// quietly-truncated result.
throw new MermaidParseError(
`a single connection chain exceeds ${MAX_CHAIN_NODES} nodes; split it or use drawioFromGraph`,
);
}
const leftRef = parseNodeToken(leftTok);
// The right side may itself contain another arrow (a chain).
const nextSeg = splitConnection(seg.right);
const rightTokenStr = nextSeg ? seg.right.slice(0, splitIndex(seg.right)) : seg.right;
const rightRef = parseNodeToken(nextSeg ? nextSeg.left : seg.right);
if (leftRef && rightRef) {
ensureNode(leftRef);
ensureNode(rightRef);
edges.push({
from: leftRef.id,
to: rightRef.id,
label: seg.label,
kind: seg.kind,
});
}
if (!nextSeg) break;
leftTok = nextSeg.left;
seg = nextSeg;
void rightTokenStr;
}
continue;
}
// Standalone node declaration `A[Label]` OR a bare member ref `C` inside a
// subgraph (which claims that node for the current group).
const nodeRef = parseNodeToken(line);
if (nodeRef && (nodeRef.node || groupStack.length > 0)) {
ensureNode(nodeRef);
continue;
}
// Unknown line: skip silently (robustness over strictness).
}
if (!sawHeader && nodes.size === 0) {
throw new MermaidParseError(
"input does not look like a mermaid flowchart (no 'flowchart'/'graph' header and no nodes)",
);
}
if (nodes.size === 0) {
throw new MermaidParseError("no nodes parsed from the flowchart");
}
const graph: Graph = {
nodes: Array.from(nodes.values()),
direction,
};
if (groups.length > 0) graph.groups = groups;
if (edges.length > 0) graph.edges = edges;
return graph;
}
/** Index of the first arrow in a segment (for chain splitting). */
function splitIndex(s: string): number {
let best = -1;
for (const arrow of ARROWS) {
const m = arrow.re.exec(s);
if (m && (best === -1 || m.index < best)) best = m.index;
}
return best === -1 ? s.length : best;
}
+151
View File
@@ -0,0 +1,151 @@
// Semantic color/line presets for the graph tools (issue #425, stage 3). The
// PALETTE is DATA (packages/mcp/data/drawio-presets.json), not code: a node
// `kind` maps to a { fillColor, strokeColor, fontColor } slot and an edge `kind`
// maps to line-style props, per named preset (`default` / `dark` /
// `colorblind-safe`). This module only loads that data and turns a slot into a
// draw.io style fragment. The INVARIANT of the graph tools is that the model
// never sees a style string — it names a `kind`, the server picks the slot.
//
// Loading mirrors drawio-shapes.ts: the JSON is read once via `import.meta.url`
// relative to the built module. That is why this module (and drawio-graph.ts
// which imports it) is reached ONLY through client.ts's ESM build and never
// value-imported into the zod-agnostic tool-specs.ts (which the in-app server
// type-checks under module:commonjs, where `import.meta` is a TS1343 error).
import { readFileSync } from "node:fs";
/** A node color slot: the three draw.io color values for a `kind`. */
export interface NodeSlot {
fillColor: string;
strokeColor: string;
fontColor: string;
}
/** An edge line style: the extra style props appended for an edge `kind`. */
export interface EdgeStyle {
props: string;
}
export interface PresetData {
canvasDark: boolean;
okabeIto?: string[];
nodes: Record<string, NodeSlot>;
edges: Record<string, EdgeStyle>;
edgeDefault: { strokeColor: string; fontColor: string };
group: { strokeColor: string; fontColor: string };
}
interface PresetsFile {
presets: Record<string, PresetData>;
}
/** The three shipped preset names. */
export const PRESET_NAMES = ["default", "dark", "colorblind-safe"] as const;
export type PresetName = (typeof PRESET_NAMES)[number];
/** Every node `kind` the base palette defines (also the generic-shape kinds). */
export const NODE_KINDS = [
"service",
"db",
"queue",
"gateway",
"error",
"external",
"security",
] as const;
export type NodeKind = (typeof NODE_KINDS)[number];
/** Edge `kind`s the palette styles; anything else falls back to `sync`. */
export const EDGE_KINDS = ["sync", "async", "error"] as const;
export type EdgeKind = (typeof EDGE_KINDS)[number];
let _presets: Record<string, PresetData> | null = null;
function presetsPath(): URL {
// build/lib/drawio-presets.js -> ../../data/drawio-presets.json
return new URL("../../data/drawio-presets.json", import.meta.url);
}
/** Load + parse the bundled preset table once, then cache it. */
export function loadPresets(): Record<string, PresetData> {
if (_presets) return _presets;
const json = readFileSync(presetsPath(), "utf-8");
const parsed = JSON.parse(json) as PresetsFile;
_presets = parsed.presets;
return _presets;
}
/** Resolve a preset by name, defaulting to `default` for an unknown name. */
export function getPreset(name?: string): PresetData {
const presets = loadPresets();
if (name && presets[name]) return presets[name];
return presets["default"];
}
/** The slot for a node `kind` in a preset, falling back to `service`. */
export function nodeSlot(preset: PresetData, kind?: string): NodeSlot {
if (kind && preset.nodes[kind]) return preset.nodes[kind];
return preset.nodes["service"];
}
/**
* Build the draw.io style string for a GENERIC (no-icon) node of a given kind.
* A rounded rectangle carrying the slot's fill/stroke/font. `whiteSpace=wrap`
* and `html=1` let a long label wrap inside the shape (the assembler also sizes
* the shape to the label, so the linter's label-overflow warning never fires).
*/
export function genericNodeStyle(preset: PresetData, kind?: string): string {
const s = nodeSlot(preset, kind);
return (
`rounded=1;whiteSpace=wrap;html=1;` +
`fillColor=${s.fillColor};strokeColor=${s.strokeColor};fontColor=${s.fontColor};`
);
}
/**
* Overlay the preset's node slot colors onto a resolved ICON style-string
* (from the shape catalog). An AWS/Azure icon carries its OWN mandatory
* fill/stroke (the category color / white outline) that MUST NOT be recolored,
* so for an icon we only ensure a readable fontColor when the preset is dark;
* otherwise the icon style is returned verbatim. Keeping the icon's own colors
* is deliberate: recoloring an AWS service icon breaks its category semantics.
*/
export function iconNodeStyle(preset: PresetData, iconStyle: string): string {
if (!preset.canvasDark) return iconStyle;
// On a dark canvas an icon's fontColor is usually a dark ink that vanishes;
// append a light fontColor (icons put their label BELOW the glyph, so this
// only affects the caption, never the glyph fill).
if (/fontColor=/.test(iconStyle)) {
return iconStyle.replace(/fontColor=[^;]*/, "fontColor=#e0e0e0");
}
return iconStyle + (iconStyle.endsWith(";") ? "" : ";") + "fontColor=#e0e0e0;";
}
/**
* Build the draw.io style for an edge of a given `kind`. Base is an orthogonal
* connector (edgeStyle=orthogonalEdgeStyle) with rounded corners and an open
* arrowhead, plus the preset's default stroke/font, then the kind's extra props
* (dashed / colored) overlaid. An unknown kind falls back to `sync` (solid).
*/
export function edgeStyle(preset: PresetData, kind?: string): string {
const k = kind && preset.edges[kind] ? kind : "sync";
const base =
`edgeStyle=orthogonalEdgeStyle;rounded=1;html=1;endArrow=open;` +
`strokeColor=${preset.edgeDefault.strokeColor};fontColor=${preset.edgeDefault.fontColor};`;
return base + preset.edges[k].props;
}
/**
* Group (container) style: ALWAYS transparent (`fillColor=none;container=1;`)
* per the spec, carrying the preset's group stroke/font. `dropTarget=1` marks it
* a drop target in the editor; `verticalAlign=top;align=left;spacingLeft=8;` puts
* the group label in the top-left like draw.io's own boundary containers.
*/
export function groupStyle(preset: PresetData): string {
return (
`rounded=0;whiteSpace=wrap;html=1;` +
`fillColor=none;container=1;dropTarget=1;collapsible=0;` +
`strokeColor=${preset.group.strokeColor};fontColor=${preset.group.fontColor};` +
`verticalAlign=top;align=left;spacingLeft=8;spacingTop=4;`
);
}
+85 -8
View File
@@ -1,11 +1,30 @@
/**
* Options for `buildPageTree`. Fully OPTIONAL so the existing call form
* `buildPageTree(nodes)` keeps its historic behaviour (lean `{id, slugId,
* title, children?}` output, no depth cut) unchanged.
*
* - `shape: "getTree"` emit the #443 `getTree` output node shape
* `{pageId, title, children?, hasChildren?}` instead of the lean
* `{id, slugId, title, children?}` shape. `slugId`/`icon`/`position` are
* never exposed (INVARIANT: only the UUID `pageId` leaves the MCP layer).
* - `maxDepth` trim the built tree to this many levels (root nodes are
* depth 1). Only meaningful together with `shape: "getTree"` (the lean shape
* has no `hasChildren` to signal a cut). See the depth logic below.
*/
export interface BuildPageTreeOptions {
shape?: "lean" | "getTree";
maxDepth?: number;
}
/**
* Pure tree-builder: turn a flat array of sidebar-style page nodes (as produced
* by `enumerateSpacePages`) into a nested tree.
*
* Input: a flat array of nodes. Each node is expected to carry at least
* { id, slugId, title, position, parentPageId } (extra fields are ignored).
* { id, slugId, title, position, parentPageId } (extra fields are ignored),
* plus a server `hasChildren` boolean used by the `getTree` shape below.
*
* Output: an array of ROOT nodes, each shaped as
* Output (default / `shape: "lean"`): an array of ROOT nodes, each shaped as
* { id, slugId, title, children? }
* where `children` is the array of child nodes (same shape, recursively). The
* `children` key is OMITTED entirely when a node has no children consistent
@@ -13,6 +32,14 @@
* lean (nesting alone conveys the structure; parentPageId/position/hasChildren
* are intentionally dropped from the output).
*
* Output (`shape: "getTree"`, the #443 tool shape): each node is
* { pageId, title, children?, hasChildren? }
* the server `id` is exposed as `pageId` (never `slugId`/`icon`/`position`).
* `children` is omitted for leaves and for nodes trimmed by `maxDepth`.
* `hasChildren: true` is set ONLY on a node whose children exist on the server
* (per the flat item's `hasChildren`) but were CUT by `maxDepth`; on leaves and
* on fully-expanded interior nodes the field is omitted (see `maxDepth` below).
*
* Linking rule: a node is attached as a child of `parentPageId` only when that
* parent id is actually present in the input. Otherwise including a null /
* undefined `parentPageId`, or a parent that was capped out of the bounded walk
@@ -26,18 +53,42 @@
* fractional-index ASCII keys (e.g. "a0", "a1"). Nodes with a missing/undefined
* `position` sort last.
*
* maxDepth (getTree shape only): the tree is built in FULL first, then trimmed
* on the way out. Root nodes are depth 1. `maxDepth: N` keeps nodes at depth
* <= N and drops the `children` of any node AT depth N. A node whose children
* were dropped this way gets `hasChildren: true` when it actually had children
* in the flat input (source of truth = the server `hasChildren` flag), so the
* caller knows it can descend further with a follow-up `rootPageId` call. An
* absent/undefined `maxDepth` means no cut (whole tree). `maxDepth <= 0` is
* treated as "no cut" (defensive; the tool schema clamps to >= 1).
*
* Pure: no I/O, no network, deterministic.
*/
export function buildPageTree(nodes: any[]): any[] {
type OutputNode = {
export function buildPageTree(
nodes: any[],
options: BuildPageTreeOptions = {},
): any[] {
const getTreeShape = options.shape === "getTree";
// A finite, positive cut only; anything else means "no cut".
const maxDepth =
typeof options.maxDepth === "number" &&
Number.isFinite(options.maxDepth) &&
options.maxDepth > 0
? Math.floor(options.maxDepth)
: undefined;
type InternalNode = {
id: string;
// Retained internally for shaping; never all emitted at once.
slugId: any;
title: any;
children?: OutputNode[];
hasServerChildren: boolean;
children?: InternalNode[];
};
// Map id -> output node. Build the lean output shape up front.
const byId = new Map<string, OutputNode>();
// Map id -> internal node. Build up front; the output shape is projected at
// the very end so the maxDepth cut can consult `hasServerChildren`.
const byId = new Map<string, InternalNode>();
// Preserve the original position string for sorting (kept off the output).
const positionById = new Map<string, string | undefined>();
@@ -49,6 +100,7 @@ export function buildPageTree(nodes: any[]): any[] {
id: node.id,
slugId: node.slugId,
title: node.title,
hasServerChildren: node.hasChildren === true,
});
positionById.set(node.id, node.position);
}
@@ -90,5 +142,30 @@ export function buildPageTree(nodes: any[]): any[] {
}
roots.sort(byPosition);
return roots.map((id) => byId.get(id)!);
const rootNodes = roots.map((id) => byId.get(id)!);
// Project the internal nodes into the requested OUTPUT shape, applying the
// maxDepth cut for the getTree shape. `depth` is 1-based (roots = depth 1).
const project = (node: InternalNode, depth: number): any => {
if (getTreeShape) {
const out: any = { pageId: node.id, title: node.title };
const atCut = maxDepth !== undefined && depth >= maxDepth;
if (!atCut && node.children && node.children.length > 0) {
out.children = node.children.map((c) => project(c, depth + 1));
} else if (atCut && node.hasServerChildren) {
// Children exist on the server but were trimmed by maxDepth: signal it
// so the caller can descend with a follow-up rootPageId call.
out.hasChildren = true;
}
return out;
}
// Lean (historic) shape: cycle-safe, no depth cut, no hasChildren.
const out: any = { id: node.id, slugId: node.slugId, title: node.title };
if (node.children && node.children.length > 0) {
out.children = node.children.map((c) => project(c, depth + 1));
}
return out;
};
return rootNodes.map((n) => project(n, 1));
}
+7 -2
View File
@@ -40,8 +40,8 @@ import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";
*/
export const ROUTING_PROSE =
"Docmost editing guide — choose the tool by intent. The <tool_inventory> at the end lists every tool with a one-line purpose; the notes below are the routing hints for WHEN to reach for each.\n" +
"READ: find a page by a fragment of a technical string (hostname/IP/ID like srv.local, 10.0.12, WB-MGE-30D86B) -> search — hybrid substring + full-text, returns each hit's location (path: root->parent titles) and a snippet around the match, so you rarely need a follow-up getPage; scope with spaceId or parentPageId (a subtree), titleOnly to match titles only. list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block, for editing -> getNode (by attrs.id, or \"#<index>\" for tables, which carry no id) — returns MARKDOWN by default (comment anchors kept for safe write-back); pass format:\"json\" for the raw ProseMirror subtree. Find every occurrence of a string/regex ON a page (and where each is) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, canonical for text; drops only block ids, resolved-comment anchors, and a fixed no-md-representation attr set: table spans/colwidth/bg, indent, callout.icon, orderedList.type, link internal/target/rel/class; inline <span data-comment-id> tags are comment anchors — markup, not text) or getPageJson (full ProseMirror with block ids, for those dropped attrs). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (returns a short-lived anonymous URL).\n" +
"EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#<index>\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> drawioCreate (create from mxGraph XML and insert), drawioGet (read a diagram as mxGraph XML + a hash), drawioUpdate (replace a diagram; pass the hash from drawioGet as baseHash for optimistic locking); before authoring a diagram, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
"READ: find a page by a fragment of a technical string (hostname/IP/ID like srv.local, 10.0.12, WB-MGE-30D86B) -> search — hybrid substring + full-text, returns each hit's location (path: root->parent titles) and a snippet around the match, so you rarely need a follow-up getPage; scope with spaceId or parentPageId (a subtree), titleOnly to match titles only. A space's page HIERARCHY (or one subtree) -> getTree (one request, complete, `{pageId,title,children?}`; rootPageId for a subtree, maxDepth to trim depth — a trimmed node gets hasChildren:true); prefer it over listPages tree:true (deprecated). Have a pageId, need WHERE-AM-I / what's around it (its breadcrumbs + direct children, metadata only) -> getPageContext (one call; parent = last breadcrumb, [] for a root page). list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block, for editing -> getNode (by attrs.id, or \"#<index>\" for tables, which carry no id) — returns MARKDOWN by default (comment anchors kept for safe write-back); pass format:\"json\" for the raw ProseMirror subtree. Find every occurrence of a string/regex ON a page (and where each is) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, canonical for text; drops only block ids, resolved-comment anchors, and a fixed no-md-representation attr set: table spans/colwidth/bg, indent, callout.icon, orderedList.type, link internal/target/rel/class; inline <span data-comment-id> tags are comment anchors — markup, not text) or getPageJson (full ProseMirror with block ids, for those dropped attrs). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (returns a short-lived anonymous URL).\n" +
"EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#<index>\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> PREFER the high-level semantic tools that hide coordinates/styles: drawioFromGraph (architecture/cloud/network diagrams — describe nodes/groups/edges by kind+icon, the server picks layout, colors and verified icons; hints layer/sameLayerAs/pinned and layout:full|incremental|none) and drawioFromMermaid (standard flowcharts — write Mermaid, get an editable diagram). For targeted tweaks of an existing diagram use drawioEditCells (id-based add/update/delete with cascade delete + baseHash lock). Raw mxGraph XML via drawioCreate/drawioUpdate is the escape-hatch for exotic/wireframe diagrams; drawioGet reads a diagram as mxGraph XML + a hash (pass it as baseHash to drawioUpdate/drawioEditCells for optimistic locking). Before authoring raw XML, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
"PAGES: new -> createPage (Markdown). Rename (title only) -> renamePage. Move -> movePage. Delete -> deletePage (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copyPageContent. Sharing -> sharePage / unsharePage / listShares; sharePage makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
"COMMENTS: createComment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> createComment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> listComments, updateComment, resolveComment (resolve/reopen, reversible — prefer over delete to close), deleteComment, checkNewComments.\n" +
"HISTORY: review what changed -> diffPageVersions (a historyId vs current, or two versions). List saved versions -> listPageHistory. Undo a bad edit -> restorePageVersion (writes a past version back as current; itself revertible). Export a page to self-contained Docmost Markdown (with comment anchors) -> exportPageMarkdown.";
@@ -82,6 +82,8 @@ const TOOL_FAMILY: Record<string, Family> = {
// READ
search: "READ",
listPages: "READ",
getTree: "READ",
getPageContext: "READ",
listSpaces: "READ",
getOutline: "READ",
getNode: "READ",
@@ -107,6 +109,9 @@ const TOOL_FAMILY: Record<string, Family> = {
drawioGet: "EDIT",
drawioCreate: "EDIT",
drawioUpdate: "EDIT",
drawioEditCells: "EDIT",
drawioFromGraph: "EDIT",
drawioFromMermaid: "EDIT",
drawioShapes: "EDIT",
drawioGuide: "EDIT",
docmostTransform: "EDIT",
+312 -4
View File
@@ -63,6 +63,8 @@ export type DocmostClientLike = Pick<
| 'getSpaces'
| 'listShares'
| 'listPages'
| 'getTree'
| 'getPageContext'
| 'getPage'
| 'getPageJson'
| 'getOutline'
@@ -98,6 +100,9 @@ export type DocmostClientLike = Pick<
| 'drawioGet'
| 'drawioCreate'
| 'drawioUpdate'
| 'drawioEditCells'
| 'drawioFromGraph'
| 'drawioFromMermaid'
| 'createComment'
| 'resolveComment'
>;
@@ -917,11 +922,13 @@ export const SHARED_TOOL_SPECS = {
description:
'List the most recent pages (ordered by updatedAt, descending), ' +
'optionally scoped to a single space. Returns a bounded list (default ' +
'50, max 100) — use search for lookups in large spaces. Pass tree:true ' +
"(with spaceId) to instead get the space's full page hierarchy as a " +
'nested tree.',
'50, max 100) — use search for lookups in large spaces. tree:true (with ' +
"spaceId) returns the space's full page hierarchy as a nested tree, but " +
'is DEPRECATED — use getTree instead (leaner nodes, plus rootPageId / ' +
'maxDepth).',
tier: 'core',
catalogLine: "listPages — list recent pages, or a space's full page tree.",
catalogLine:
"listPages — list recent pages (tree:true is deprecated; use getTree for the hierarchy).",
buildShape: (z) => ({
spaceId: z
.string()
@@ -954,6 +961,79 @@ export const SHARED_TOOL_SPECS = {
),
},
getTree: {
mcpName: 'getTree',
inAppKey: 'getTree',
description:
"Get a space's page hierarchy (or one subtree) as a nested tree in a " +
'SINGLE request — completely and without loss. Each node is ' +
'`{ pageId, title, children? }`; children are ordered as in the sidebar. ' +
'Pass rootPageId to return only that page and its descendants (exactly ' +
'one root). Pass maxDepth to trim depth and save tokens (root nodes are ' +
'depth 1, so maxDepth:1 returns only the roots); a node whose children ' +
'were trimmed carries `hasChildren:true` so you can descend later with ' +
'getTree(rootPageId=that page). Prefer this over listPages tree:true.',
tier: 'core',
catalogLine:
"getTree — a space's page hierarchy (or a subtree) as a nested tree in one request.",
buildShape: (z) => ({
spaceId: z
.string()
.min(1)
.describe('The id of the space whose page tree to return.'),
rootPageId: z
.string()
.optional()
.describe(
'Optional page id: return only this page and its descendants (one root).',
),
maxDepth: z
.number()
.int()
.min(1)
.optional()
.describe(
'Optional depth cap (roots are depth 1). maxDepth:1 returns only the ' +
'roots; trimmed nodes carry hasChildren:true.',
),
}),
execute: (client, { spaceId, rootPageId, maxDepth }) =>
client.getTree(
spaceId as string,
rootPageId as string | undefined,
maxDepth as number | undefined,
),
},
getPageContext: {
mcpName: 'getPageContext',
inAppKey: 'getPageContext',
description:
'Given a pageId, get its LOCATION and immediate surroundings (metadata ' +
'only, no page content) in one call — answers "where am I / what is ' +
"around this page\". Returns `{ page: { pageId, title, spaceId }, " +
'breadcrumbs: [{ pageId, title }], children: [{ pageId, title, ' +
'hasChildren }] }`. `breadcrumbs` is the ancestor chain from the space ' +
'root down to the PARENT (the parent is its last element; a root page ' +
'has `breadcrumbs: []`). `children` are the direct children in sidebar ' +
'order, each flagged `hasChildren` so you know which can be expanded ' +
'(descend with getTree(rootPageId=that child) or another getPageContext). ' +
'Ids, titles and child order are consistent with getTree.',
tier: 'core',
catalogLine:
'getPageContext — a page’s breadcrumbs + direct children (where-am-I) in one call.',
buildShape: (z) => ({
pageId: z
.string()
.min(1)
.describe(
'The id of the page to locate (a pageId/UUID, or a slugId from a URL).',
),
}),
execute: (client, { pageId }) =>
client.getPageContext(pageId as string),
},
createPage: {
mcpName: 'createPage',
inAppKey: 'createPage',
@@ -1936,6 +2016,234 @@ export const SHARED_TOOL_SPECS = {
),
},
drawioEditCells: {
mcpName: 'drawioEditCells',
inAppKey: 'drawioEditCells',
description:
'Make TARGETED, id-based edits to an existing draw.io diagram instead of ' +
'resending the whole XML (a full-XML diff is fragile — draw.io reorders ' +
'attributes). `operations` is an ordered list of: ' +
'{ op:"add", xml:"<mxCell .../>" } (append a new cell), ' +
'{ op:"update", cellId:"n3", xml:"<mxCell id=\\"n3\\" .../>" } (replace that ' +
'cell; the id MUST stay the same), or { op:"delete", cellId:"n5" } — a ' +
'delete CASCADES to the cell\'s container children AND to every edge whose ' +
'source/target is deleted. Ids are STABLE across edits so diffs stay ' +
'meaningful. `baseHash` is MANDATORY: pass the hash from the drawioGet you ' +
'based the edit on; if the diagram changed since, the edit is refused with ' +
'a conflict error — re-read with drawioGet and retry. The edited model goes ' +
'through the same lint + quality-warning pipeline as drawioUpdate. `node` is ' +
'the drawio node attrs.id or "#<index>". Use this to tweak a diagram (move ' +
'or restyle a few cells, add/remove nodes); to (re)generate a whole diagram ' +
'from a description use drawioFromGraph.' +
DRAWIO_HARD_RULES,
tier: 'deferred',
catalogLine:
'drawioEditCells — id-based add/update/delete edits to a draw.io diagram (cascade delete).',
buildShape: (z) => ({
pageId: z.string().min(1),
node: z
.string()
.min(1)
.describe('The drawio node attrs.id, or "#<index>" for a top-level block.'),
operations: z
.array(
z.object({
op: z.enum(['add', 'update', 'delete']),
cellId: z
.string()
.optional()
.describe('Target cell id (required for update/delete).'),
xml: z
.string()
.optional()
.describe('The <mxCell> element (required for add/update).'),
}),
)
.describe('Ordered add/update/delete operations keyed by cell id.'),
baseHash: z
.string()
.min(1)
.describe('The meta.hash from the drawioGet this edit is based on.'),
}),
execute: (client, { pageId, node, operations, baseHash }) =>
client.drawioEditCells(
pageId as string,
node as string,
operations as any,
baseHash as string,
),
},
drawioFromGraph: {
mcpName: 'drawioFromGraph',
inAppKey: 'drawioFromGraph',
description:
'Build a draw.io diagram from a SEMANTIC graph — you describe nodes, groups ' +
'and edges by MEANING and the server picks every coordinate, color and icon ' +
'so the whole class of layout/icon mistakes (overlaps, edges through shapes, ' +
'empty-box stencils) cannot happen. This is the PREFERRED tool for ' +
'architecture / cloud / network diagrams. `graph` = { nodes:[{ id, label, ' +
'kind?, icon?, group?, layer?, sameLayerAs?, pinned? }], groups?:[{ id, ' +
'label, kind? }], edges?:[{ from, to, label?, kind? }] }. Node `kind` picks ' +
'a palette color (service/db/queue/gateway/error/external/security); `icon` ' +
'(e.g. "aws:lambda", "aws:dynamodb", "azure:cosmos") resolves to the exact ' +
'verified stencil — an unknown icon degrades to a labelled generic shape, ' +
'never an empty box. Edge `kind` sets the line style (sync=solid, ' +
'async=dashed, error=red-dashed). Groups are TRANSPARENT containers. ' +
'`direction` (LR/RL/TB/BT) and `preset` (default/dark/colorblind-safe) tune ' +
'the layout/palette. Layout hints: `layer` (column index), `sameLayerAs` ' +
'(align two nodes), `pinned:{x,y}` (fix a node). `layout`: "full" (default, ' +
'auto-place everything), "incremental" (with `node`: keep the existing ' +
'diagram\'s coordinates, place only new cells), "none" (no auto-layout). The ' +
'result reports { iconsResolved, iconsMissing } so you can verify all icons ' +
'resolved. For standard flowcharts you can also write Mermaid and call ' +
'drawioFromMermaid; for exotic/wireframe diagrams use raw XML via drawioCreate.',
tier: 'deferred',
catalogLine:
'drawioFromGraph — build a draw.io diagram from a semantic node/group/edge graph (server picks layout+icons).',
buildShape: (z) => {
const node = z.object({
id: z.string().min(1),
label: z.string().min(1),
kind: z
.string()
.optional()
.describe(
'Palette slot: service/db/queue/gateway/error/external/security.',
),
icon: z
.string()
.optional()
.describe('Icon ref, e.g. "aws:lambda", "aws:dynamodb", "azure:cosmos".'),
group: z.string().optional().describe('Id of the group (container) it sits in.'),
layer: z.number().optional().describe('Layer/column index hint (>=0).'),
sameLayerAs: z
.string()
.optional()
.describe('Put this node in the same layer as another node id.'),
pinned: z
.object({ x: z.number(), y: z.number() })
.optional()
.describe('Fix the node at these exact coordinates.'),
});
const group = z.object({
id: z.string().min(1),
label: z.string().min(1),
kind: z.string().optional(),
});
const edge = z.object({
from: z.string().min(1),
to: z.string().min(1),
label: z.string().optional(),
kind: z
.string()
.optional()
.describe('sync (solid), async (dashed), error (red-dashed).'),
});
return {
pageId: z.string().min(1),
graph: z
.object({
nodes: z.array(node),
groups: z.array(group).optional(),
edges: z.array(edge).optional(),
direction: z.enum(['LR', 'RL', 'TB', 'BT']).optional(),
preset: z.enum(['default', 'dark', 'colorblind-safe']).optional(),
})
.describe('The semantic graph: nodes, groups, edges.'),
position: z
.enum(['before', 'after', 'append'])
.describe('Where to insert relative to the anchor.'),
anchorNodeId: z.string().optional().describe('Anchor block id (for before/after).'),
anchorText: z.string().optional().describe('Anchor text fragment (for before/after).'),
direction: z
.enum(['LR', 'RL', 'TB', 'BT'])
.optional()
.describe('Layout direction (overrides graph.direction).'),
preset: z
.enum(['default', 'dark', 'colorblind-safe'])
.optional()
.describe('Color preset (overrides graph.preset).'),
layout: z
.enum(['none', 'full', 'incremental'])
.optional()
.describe(
'"full" (default) auto-places all; "incremental" (with node) keeps ' +
'existing coords and places only new cells; "none" no auto-layout.',
),
node: z
.string()
.optional()
.describe(
'An existing diagram to (re)build into — required for layout:"incremental".',
),
};
},
execute: (
client,
{ pageId, graph, position, anchorNodeId, anchorText, direction, preset, layout, node },
) =>
client.drawioFromGraph(
pageId as string,
{
position: position as 'before' | 'after' | 'append',
anchorNodeId: anchorNodeId as string | undefined,
anchorText: anchorText as string | undefined,
},
graph as any,
direction as 'LR' | 'RL' | 'TB' | 'BT' | undefined,
preset as string | undefined,
layout as 'none' | 'full' | 'incremental' | undefined,
node as string | undefined,
),
},
drawioFromMermaid: {
mcpName: 'drawioFromMermaid',
inAppKey: 'drawioFromMermaid',
description:
'Convert Mermaid `flowchart` text into an EDITABLE draw.io diagram (LLMs ' +
'write Mermaid reliably). Best for STANDARD flowcharts/decision trees: ' +
'write the mermaid, the server parses it (pure parser — no browser/CLI), ' +
'maps it to the same semantic pipeline as drawioFromGraph, and inserts a ' +
'real draw.io diagram you can then refine with drawioEditCells. Node shapes ' +
'map to palette colors (a `{decision}` -> yellow, a `[(db)]` -> green, etc.); ' +
'`subgraph … end` becomes a transparent group; dotted `-.->` edges become ' +
'dashed. ONLY flowchart/graph is supported — for sequence/class diagrams, or ' +
'for cloud/architecture diagrams with real service icons, use drawioFromGraph ' +
'instead. `where` positions the block like insertNode.',
tier: 'deferred',
catalogLine:
'drawioFromMermaid — turn Mermaid flowchart text into an editable draw.io diagram.',
buildShape: (z) => ({
pageId: z.string().min(1),
mermaid: z
.string()
.min(1)
.describe('Mermaid flowchart source (flowchart/graph LR|TB|...).'),
position: z
.enum(['before', 'after', 'append'])
.describe('Where to insert relative to the anchor.'),
anchorNodeId: z.string().optional().describe('Anchor block id (for before/after).'),
anchorText: z.string().optional().describe('Anchor text fragment (for before/after).'),
preset: z
.enum(['default', 'dark', 'colorblind-safe'])
.optional()
.describe('Color preset.'),
}),
execute: (client, { pageId, mermaid, position, anchorNodeId, anchorText, preset }) =>
client.drawioFromMermaid(
pageId as string,
{
position: position as 'before' | 'after' | 'append',
anchorNodeId: anchorNodeId as string | undefined,
anchorText: anchorText as string | undefined,
},
mermaid as string,
preset as string | undefined,
),
},
drawioShapes: {
mcpName: 'drawioShapes',
inAppKey: 'drawioShapes',
@@ -0,0 +1,272 @@
// Contract tests for the stage-3 drawio client methods (issue #425):
// drawioEditCells / drawioFromGraph / drawioFromMermaid. Same seam-override
// pattern as drawio-tools.test.mjs: a DocmostClient subclass stubs the I/O seams
// so the tool logic runs without a live Docmost / collab socket.
import { test } from "node:test";
import assert from "node:assert/strict";
import { DocmostClient } from "../../build/client.js";
import {
buildDrawioSvg,
normalizeXml,
mxHash,
decodeDrawioSvg,
parseCells,
} from "../../build/lib/drawio-xml.js";
const DRAWIO_SCHEMA_ATTRS = new Set([
"src", "title", "alt", "width", "height", "size", "aspectRatio", "align", "attachmentId",
]);
function applyDrawioSchemaDrop(node) {
if (!node || typeof node !== "object") return;
if (node.type === "drawio" && node.attrs && typeof node.attrs === "object") {
for (const key of Object.keys(node.attrs))
if (!DRAWIO_SCHEMA_ATTRS.has(key)) delete node.attrs[key];
}
if (Array.isArray(node.content)) for (const c of node.content) applyDrawioSchemaDrop(c);
}
function svgFor(model, bbox = { width: 400, height: 300 }) {
return buildDrawioSvg(normalizeXml(model), "<g/>", bbox);
}
function makeClient({ pageDoc, attachmentSvg } = {}) {
const calls = { uploads: [], mutations: [] };
class TestClient extends DocmostClient {
async ensureAuthenticated() {}
async getCollabTokenWithReauth() {
return "collab-token";
}
async resolvePageId(pageId) {
return `uuid-${pageId}`;
}
async getPageRaw(pageId) {
return {
id: pageId, slugId: "s", title: "P", spaceId: "sp",
content: pageDoc ?? { type: "doc", content: [] },
};
}
async uploadAttachmentBuffer(pageId, buffer, fileName) {
const id = `att-${calls.uploads.length + 1}`;
calls.uploads.push({ pageId, fileName, svg: buffer.toString("utf-8") });
return { id, fileName, fileSize: buffer.length };
}
async fetchAttachmentText() {
return attachmentSvg;
}
mutatePage(pageId, token, apiUrl, transform) {
const clone = structuredClone(pageDoc ?? { type: "doc", content: [] });
const doc = transform(clone);
if (doc) applyDrawioSchemaDrop(doc);
calls.mutations.push({ pageId, doc });
return Promise.resolve({ doc, verify: { changed: doc != null } });
}
}
const client = new TestClient("http://127.0.0.1:1/api", "e@x.com", "pw");
return { client, calls };
}
function findDrawio(node, acc = []) {
if (!node || typeof node !== "object") return acc;
if (node.type === "drawio") acc.push(node);
if (Array.isArray(node.content)) for (const c of node.content) findDrawio(c, acc);
return acc;
}
// A stored diagram: a group with two children and an edge.
const STORED =
"<mxGraphModel><root><mxCell id=\"0\"/><mxCell id=\"1\" parent=\"0\"/>" +
'<mxCell id="grp" value="G" style="container=1;fillColor=none;" vertex="1" parent="1">' +
'<mxGeometry x="0" y="0" width="300" height="200" as="geometry"/></mxCell>' +
'<mxCell id="a" value="A" style="rounded=1;" vertex="1" parent="grp">' +
'<mxGeometry x="10" y="10" width="80" height="40" as="geometry"/></mxCell>' +
'<mxCell id="b" value="B" style="rounded=1;" vertex="1" parent="grp">' +
'<mxGeometry x="10" y="90" width="80" height="40" as="geometry"/></mxCell>' +
'<mxCell id="e" style="" edge="1" parent="grp" source="a" target="b">' +
'<mxGeometry relative="1" as="geometry"/></mxCell>' +
"</root></mxGraphModel>";
function drawioPageDoc() {
return {
type: "doc",
content: [
{
type: "drawio",
attrs: {
id: "d1", src: "/api/files/att-1/diagram.drawio.svg",
attachmentId: "att-1", width: 400, height: 300,
},
},
],
};
}
// --- drawioEditCells --------------------------------------------------------
test("drawioEditCells: applies ops and repoints the node (current baseHash)", async () => {
const { client, calls } = makeClient({
pageDoc: drawioPageDoc(),
attachmentSvg: svgFor(STORED),
});
const baseHash = mxHash(normalizeXml(STORED));
const res = await client.drawioEditCells(
"page1",
"d1",
[
{
op: "update",
cellId: "a",
xml:
'<mxCell id="a" value="Renamed" style="rounded=1;" vertex="1" parent="grp">' +
'<mxGeometry x="10" y="10" width="80" height="40" as="geometry"/></mxCell>',
},
],
baseHash,
);
assert.equal(res.success, true);
assert.equal(calls.uploads.length, 1);
const written = decodeDrawioSvg(calls.uploads[0].svg);
const cells = parseCells(written);
assert.equal(cells.find((c) => c.id === "a").value, "Renamed");
assert.equal(cells.find((c) => c.id === "b").value, "B"); // untouched
const n = findDrawio(calls.mutations[0].doc)[0];
// The stub numbers uploads from 1; this edit is the first upload -> att-1.
assert.equal(n.attrs.attachmentId, "att-1");
});
test("drawioEditCells: delete of the container cascades to children + edge", async () => {
const { client, calls } = makeClient({
pageDoc: drawioPageDoc(),
attachmentSvg: svgFor(STORED),
});
const baseHash = mxHash(normalizeXml(STORED));
await client.drawioEditCells("page1", "d1", [{ op: "delete", cellId: "grp" }], baseHash);
const written = decodeDrawioSvg(calls.uploads[0].svg);
const ids = parseCells(written).filter((c) => c.id !== "0" && c.id !== "1").map((c) => c.id);
assert.deepEqual(ids, [], "grp + a + b + edge all cascaded away");
});
test("drawioEditCells: stale baseHash -> conflict, no upload", async () => {
const { client, calls } = makeClient({
pageDoc: drawioPageDoc(),
attachmentSvg: svgFor(STORED),
});
await assert.rejects(
() => client.drawioEditCells("page1", "d1", [{ op: "delete", cellId: "a" }], "stale"),
/conflict/,
);
assert.equal(calls.uploads.length, 0);
});
test("drawioEditCells: baseHash is mandatory", async () => {
const { client } = makeClient({ pageDoc: drawioPageDoc(), attachmentSvg: svgFor(STORED) });
await assert.rejects(
() => client.drawioEditCells("page1", "d1", [{ op: "delete", cellId: "a" }], ""),
/baseHash is mandatory/,
);
});
// --- drawioFromGraph --------------------------------------------------------
test("drawioFromGraph: builds a diagram from a graph and inserts a node", async () => {
const pageDoc = { type: "doc", content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }] };
const { client, calls } = makeClient({ pageDoc });
const res = await client.drawioFromGraph(
"page1",
{ position: "append" },
{
nodes: [
{ id: "api", label: "API", kind: "gateway", icon: "aws:api_gateway", group: "vpc" },
{ id: "fn", label: "Handler", kind: "service", icon: "aws:lambda", group: "vpc" },
{ id: "db", label: "Orders", kind: "db", icon: "aws:dynamodb" },
],
groups: [{ id: "vpc", label: "VPC" }],
edges: [{ from: "api", to: "fn", kind: "sync" }, { from: "fn", to: "db", kind: "async" }],
},
"LR",
"default",
);
assert.equal(res.success, true);
assert.equal(res.nodeId, "#1");
assert.equal(res.iconsMissing.length, 0, `unresolved: ${res.iconsMissing}`);
assert.equal(res.iconsResolved, 3);
// The uploaded model decodes back and carries the group + nodes.
const written = decodeDrawioSvg(calls.uploads[0].svg);
const cells = parseCells(written);
assert.ok(cells.some((c) => c.id === "vpc"));
assert.ok(cells.some((c) => c.id === "api"));
// Group is transparent.
const vpc = cells.find((c) => c.id === "vpc");
assert.equal(vpc.styleMap.fillColor, "none");
assert.equal(vpc.styleMap.container, "1");
});
test("drawioFromGraph: an invalid graph throws before any upload", async () => {
const { client, calls } = makeClient({ pageDoc: { type: "doc", content: [] } });
await assert.rejects(
() => client.drawioFromGraph("page1", { position: "append" }, { nodes: [] }),
/non-empty/,
);
assert.equal(calls.uploads.length, 0);
});
test("drawioFromGraph incremental into an existing node keeps prior coords", async () => {
// The stored diagram has a,b at known coords; add a new node c incrementally.
const { client, calls } = makeClient({
pageDoc: drawioPageDoc(),
attachmentSvg: svgFor(STORED),
});
const res = await client.drawioFromGraph(
"page1",
{ position: "append" },
{
nodes: [
{ id: "a", label: "A" },
{ id: "b", label: "B" },
{ id: "c", label: "C new" },
],
edges: [{ from: "b", to: "c" }],
},
undefined,
undefined,
"incremental",
"d1", // target the existing diagram
);
assert.equal(res.success, true);
const written = decodeDrawioSvg(calls.uploads[0].svg);
const cells = parseCells(written);
const a = cells.find((c) => c.id === "a");
const b = cells.find((c) => c.id === "b");
// Existing coords preserved (the stored a/b absolute coords from STORED).
assert.equal(a.geometry.x, 10);
assert.equal(a.geometry.y, 10);
assert.equal(b.geometry.x, 10);
assert.equal(b.geometry.y, 90);
assert.ok(cells.some((c) => c.id === "c"), "new node c added");
});
// --- drawioFromMermaid ------------------------------------------------------
test("drawioFromMermaid: converts a flowchart and inserts a diagram", async () => {
const pageDoc = { type: "doc", content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }] };
const { client, calls } = makeClient({ pageDoc });
const res = await client.drawioFromMermaid(
"page1",
{ position: "append" },
"flowchart LR\n A[Start] --> B{Choose}\n B -->|yes| C[Done]\n B -->|no| D[Stop]",
);
assert.equal(res.success, true);
const written = decodeDrawioSvg(calls.uploads[0].svg);
const cells = parseCells(written);
for (const id of ["A", "B", "C", "D"]) {
assert.ok(cells.some((c) => c.id === id), `node ${id} present`);
}
});
test("drawioFromMermaid: a non-flowchart is rejected, no upload", async () => {
const { client, calls } = makeClient({ pageDoc: { type: "doc", content: [] } });
await assert.rejects(
() => client.drawioFromMermaid("page1", { position: "append" }, "sequenceDiagram\n A->>B: x"),
/only 'flowchart'\/'graph' is supported/,
);
assert.equal(calls.uploads.length, 0);
});
@@ -0,0 +1,375 @@
// Mock-HTTP tests for DocmostClient.getPageContext — the #443 "where am I /
// what's around" read tool. A local http.createServer stands in for Docmost
// (same harness style as pagination-cursor.test.mjs) so everything is
// deterministic and offline.
//
// Contract pinned here:
// - Two requests: POST /pages/breadcrumbs (ancestor chain root->page, page
// INCLUDED as the LAST element) + listSidebarPages (direct children).
// - Split: last chain element -> `page`; the rest (root->parent) ->
// `breadcrumbs`. A ROOT page (chain length 1) -> breadcrumbs: [].
// - children: {pageId, title, hasChildren} in sidebar order.
// - INVARIANT: only the UUID `pageId` is exposed, never `slugId`.
// - A slugId input is resolved via /pages/info first (adds one request); a
// UUID input short-circuits (stays at two requests).
// - A bad/inaccessible pageId throws a CLEAR error, not an empty object.
import { test, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { DocmostClient } from "../../build/client.js";
function readBody(req) {
return new Promise((resolve) => {
let raw = "";
req.on("data", (chunk) => {
raw += chunk;
});
req.on("end", () => resolve(raw));
});
}
function startServer(handler) {
return new Promise((resolve) => {
const server = http.createServer(handler);
server.listen(0, "127.0.0.1", () => {
const { port } = server.address();
resolve({ server, baseURL: `http://127.0.0.1:${port}/api` });
});
});
}
function closeServer(server) {
return new Promise((resolve) => server.close(resolve));
}
function sendJson(res, status, obj, extraHeaders = {}) {
res.writeHead(status, { "Content-Type": "application/json", ...extraHeaders });
res.end(JSON.stringify(obj));
}
const openServers = [];
async function spawn(handler) {
const { server, baseURL } = await startServer(handler);
openServers.push(server);
return { server, baseURL };
}
after(async () => {
await Promise.all(openServers.map((s) => closeServer(s)));
});
function handleLogin(req, res) {
if (req.url === "/api/auth/login") {
sendJson(res, 200, { success: true }, {
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
});
return true;
}
return false;
}
// Two real UUIDs so resolvePageId short-circuits (no /pages/info round-trip).
const ROOT_UUID = "00000000-0000-4000-8000-000000000001";
const MID_UUID = "00000000-0000-4000-8000-000000000002";
const PAGE_UUID = "00000000-0000-4000-8000-000000000003";
const CHILD_A = "00000000-0000-4000-8000-00000000000a";
const CHILD_B = "00000000-0000-4000-8000-00000000000b";
// Build a breadcrumbs response as the server sends it: root->page order, page
// LAST, wrapped in the {data,success} envelope. slugId/icon/position are present
// on the wire (they must NOT leak into the tool output).
function breadcrumbsEnvelope(chain) {
return { success: true, data: chain };
}
// -----------------------------------------------------------------------------
// 1) 3rd-level page: page = last chain element; breadcrumbs = the two ancestors
// root->parent; children mapped {pageId,title,hasChildren} in order; no leak;
// exactly two requests for a UUID input.
// -----------------------------------------------------------------------------
test("getPageContext: 3rd-level page splits chain, maps children, no slugId leak, 2 requests", async () => {
let breadcrumbReqs = 0;
let sidebarReqs = 0;
let infoReqs = 0;
let breadcrumbBody = null;
const { baseURL } = await spawn(async (req, res) => {
const raw = await readBody(req);
if (handleLogin(req, res)) return;
if (req.url === "/api/pages/info") {
infoReqs++;
sendJson(res, 404, {});
return;
}
if (req.url === "/api/pages/breadcrumbs") {
breadcrumbReqs++;
breadcrumbBody = JSON.parse(raw || "{}");
// root -> parent -> page (page LAST). slugId/icon/position on the wire.
sendJson(
res,
200,
breadcrumbsEnvelope([
{ id: ROOT_UUID, slugId: "rootSlug", title: "Infrastructure", spaceId: "sp1", position: "a", icon: null, parentPageId: null, hasChildren: true },
{ id: MID_UUID, slugId: "midSlug", title: "Datacenter A", spaceId: "sp1", position: "a", icon: null, parentPageId: ROOT_UUID, hasChildren: true },
{ id: PAGE_UUID, slugId: "pageSlug", title: "Rack 12", spaceId: "sp1", position: "b", icon: null, parentPageId: MID_UUID, hasChildren: true },
]),
);
return;
}
if (req.url === "/api/pages/sidebar-pages") {
sidebarReqs++;
const body = JSON.parse(raw || "{}");
assert.equal(body.pageId, PAGE_UUID, "children scoped to the page UUID");
assert.equal(body.spaceId, "sp1", "children scoped to the page's space");
sendJson(res, 200, {
success: true,
data: {
items: [
{ id: CHILD_A, slugId: "aSlug", title: "Servers", parentPageId: PAGE_UUID, hasChildren: true, position: "a" },
{ id: CHILD_B, slugId: "bSlug", title: "Network", parentPageId: PAGE_UUID, hasChildren: false, position: "b" },
],
meta: { hasNextPage: false, nextCursor: null },
},
});
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
const result = await client.getPageContext(PAGE_UUID);
assert.equal(infoReqs, 0, "UUID input short-circuits resolvePageId (no /pages/info)");
assert.equal(breadcrumbReqs, 1, "exactly one breadcrumbs request");
assert.equal(sidebarReqs, 1, "exactly one sidebar request");
assert.deepEqual(breadcrumbBody, { pageId: PAGE_UUID }, "breadcrumbs posts the UUID");
// page = the LAST chain element.
assert.deepEqual(result.page, {
pageId: PAGE_UUID,
title: "Rack 12",
spaceId: "sp1",
});
// breadcrumbs = root->parent (the chain minus the page itself).
assert.deepEqual(result.breadcrumbs, [
{ pageId: ROOT_UUID, title: "Infrastructure" },
{ pageId: MID_UUID, title: "Datacenter A" },
]);
// children mapped in order, hasChildren coerced to boolean.
assert.deepEqual(result.children, [
{ pageId: CHILD_A, title: "Servers", hasChildren: true },
{ pageId: CHILD_B, title: "Network", hasChildren: false },
]);
// No slugId anywhere in the output.
const dump = JSON.stringify(result);
assert.ok(!dump.includes("Slug"), "no slugId leaks into the output");
assert.ok(!/\bslugId\b/.test(dump), "no slugId key in the output");
});
// -----------------------------------------------------------------------------
// 2) ROOT page: chain has ONE element (the page itself) -> breadcrumbs: [].
// -----------------------------------------------------------------------------
test("getPageContext: a root page has breadcrumbs: []", async () => {
const { baseURL } = await spawn(async (req, res) => {
const raw = await readBody(req);
if (handleLogin(req, res)) return;
if (req.url === "/api/pages/breadcrumbs") {
// A root page: the CTE returns only the page itself.
sendJson(
res,
200,
breadcrumbsEnvelope([
{ id: ROOT_UUID, slugId: "rootSlug", title: "Infrastructure", spaceId: "sp1", parentPageId: null, hasChildren: false },
]),
);
return;
}
if (req.url === "/api/pages/sidebar-pages") {
sendJson(res, 200, {
success: true,
data: { items: [], meta: { hasNextPage: false, nextCursor: null } },
});
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
const result = await client.getPageContext(ROOT_UUID);
assert.deepEqual(result.page, {
pageId: ROOT_UUID,
title: "Infrastructure",
spaceId: "sp1",
});
assert.deepEqual(result.breadcrumbs, [], "root page: no ancestors");
assert.deepEqual(result.children, [], "no children");
});
// -----------------------------------------------------------------------------
// 3) A slugId input is resolved via /pages/info first (one extra request), then
// breadcrumbs/sidebar use the resolved UUID.
// -----------------------------------------------------------------------------
test("getPageContext: a slugId input is resolved via /pages/info", async () => {
let infoReqs = 0;
let infoBody = null;
let breadcrumbBody = null;
const { baseURL } = await spawn(async (req, res) => {
const raw = await readBody(req);
if (handleLogin(req, res)) return;
if (req.url === "/api/pages/info") {
infoReqs++;
infoBody = JSON.parse(raw || "{}");
// getPageRaw: slugId -> canonical UUID.
sendJson(res, 200, {
success: true,
data: { id: PAGE_UUID, slugId: "pageSlug", title: "Rack 12", spaceId: "sp1" },
});
return;
}
if (req.url === "/api/pages/breadcrumbs") {
breadcrumbBody = JSON.parse(raw || "{}");
sendJson(
res,
200,
breadcrumbsEnvelope([
{ id: ROOT_UUID, slugId: "rootSlug", title: "Infrastructure", spaceId: "sp1", parentPageId: null },
{ id: PAGE_UUID, slugId: "pageSlug", title: "Rack 12", spaceId: "sp1", parentPageId: ROOT_UUID },
]),
);
return;
}
if (req.url === "/api/pages/sidebar-pages") {
sendJson(res, 200, {
success: true,
data: { items: [], meta: { hasNextPage: false, nextCursor: null } },
});
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
const result = await client.getPageContext("pageSlug");
assert.equal(infoReqs, 1, "slugId resolved via one /pages/info");
assert.deepEqual(infoBody, { pageId: "pageSlug" }, "resolve posts the raw slugId");
assert.deepEqual(
breadcrumbBody,
{ pageId: PAGE_UUID },
"breadcrumbs posts the RESOLVED uuid, not the slugId",
);
assert.equal(result.page.pageId, PAGE_UUID, "page.pageId is the UUID");
assert.deepEqual(result.breadcrumbs, [
{ pageId: ROOT_UUID, title: "Infrastructure" },
]);
});
// -----------------------------------------------------------------------------
// 4) >20 children: cursor pagination returns ALL of them, no dupes (regression
// on the #442 bug class — getPageContext must not re-introduce a cap).
// -----------------------------------------------------------------------------
test("getPageContext: a page with >20 children returns ALL of them (no cap, no dupes)", async () => {
// 45 children spread over three cursor pages.
const all = Array.from({ length: 45 }, (_, i) => ({
id: `child-${i}`,
slugId: `slug-${i}`,
title: `Child ${i}`,
parentPageId: PAGE_UUID,
hasChildren: i % 2 === 0,
}));
const PAGES = {
"": { items: all.slice(0, 20), nextCursor: "c1" },
c1: { items: all.slice(20, 40), nextCursor: "c2" },
c2: { items: all.slice(40), nextCursor: null },
};
const { baseURL } = await spawn(async (req, res) => {
const raw = await readBody(req);
if (handleLogin(req, res)) return;
if (req.url === "/api/pages/breadcrumbs") {
sendJson(
res,
200,
breadcrumbsEnvelope([
{ id: PAGE_UUID, slugId: "pageSlug", title: "Big Parent", spaceId: "sp1", parentPageId: null },
]),
);
return;
}
if (req.url === "/api/pages/sidebar-pages") {
const body = JSON.parse(raw || "{}");
const page = PAGES[body.cursor ?? ""] ?? { items: [], nextCursor: null };
sendJson(res, 200, {
success: true,
data: {
items: page.items,
meta: { hasNextPage: page.nextCursor != null, nextCursor: page.nextCursor },
},
});
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
const result = await client.getPageContext(PAGE_UUID);
assert.equal(result.children.length, 45, "all 45 children returned");
const ids = result.children.map((c) => c.pageId);
assert.equal(new Set(ids).size, 45, "no duplicate children");
assert.deepEqual(ids, all.map((c) => c.id), "children in server order across cursor pages");
assert.equal(result.children[0].hasChildren, true, "hasChildren preserved (child 0)");
assert.equal(result.children[1].hasChildren, false, "hasChildren preserved (child 1)");
});
// -----------------------------------------------------------------------------
// 5) A nonexistent / inaccessible pageId -> a CLEAR error, NOT an empty object.
// -----------------------------------------------------------------------------
test("getPageContext: a bad/inaccessible pageId throws a clear error (not {})", async () => {
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (handleLogin(req, res)) return;
if (req.url === "/api/pages/breadcrumbs") {
// Server rejects an unknown/forbidden page.
sendJson(res, 404, { message: "Page not found" });
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
await assert.rejects(
() => client.getPageContext(PAGE_UUID),
(err) => {
assert.ok(err instanceof Error, "throws an Error");
return true;
},
"a 404 from breadcrumbs propagates as a thrown error, not a hollow {}",
);
});
// -----------------------------------------------------------------------------
// 6) An empty breadcrumbs chain (should never happen — the endpoint always
// includes the page itself) is treated as not-found, not a hollow {page:...}.
// -----------------------------------------------------------------------------
test("getPageContext: an empty breadcrumbs chain throws (defensive)", async () => {
const { baseURL } = await spawn(async (req, res) => {
await readBody(req);
if (handleLogin(req, res)) return;
if (req.url === "/api/pages/breadcrumbs") {
sendJson(res, 200, breadcrumbsEnvelope([]));
return;
}
sendJson(res, 404, {});
});
const client = new DocmostClient(baseURL, "user@example.com", "pw");
await assert.rejects(
() => client.getPageContext(PAGE_UUID),
/not found or inaccessible/,
"an empty chain is a clear error, not {}",
);
});
@@ -0,0 +1,128 @@
// Unit tests for the drawioEditCells operations (issue #425, acceptance #4):
// add / update / delete applied to the parsed model, with a cascade delete that
// removes container children AND every connected edge.
import { test } from "node:test";
import assert from "node:assert/strict";
import { applyCellOps, CellOpsError } from "../../build/lib/drawio-cell-ops.js";
import { parseCells } from "../../build/lib/drawio-xml.js";
const MODEL =
"<mxGraphModel><root><mxCell id=\"0\"/><mxCell id=\"1\" parent=\"0\"/>" +
'<mxCell id="grp" value="G" style="container=1;fillColor=none;" vertex="1" parent="1">' +
'<mxGeometry x="0" y="0" width="300" height="200" as="geometry"/></mxCell>' +
'<mxCell id="c1" value="Child1" style="rounded=1;" vertex="1" parent="grp">' +
'<mxGeometry x="10" y="10" width="80" height="40" as="geometry"/></mxCell>' +
'<mxCell id="c2" value="Child2" style="rounded=1;" vertex="1" parent="grp">' +
'<mxGeometry x="10" y="80" width="80" height="40" as="geometry"/></mxCell>' +
'<mxCell id="out" value="Outside" style="rounded=1;" vertex="1" parent="1">' +
'<mxGeometry x="400" y="10" width="80" height="40" as="geometry"/></mxCell>' +
'<mxCell id="e1" style="" edge="1" parent="1" source="c1" target="out">' +
'<mxGeometry relative="1" as="geometry"/></mxCell>' +
'<mxCell id="e2" style="" edge="1" parent="1" source="out" target="c2">' +
'<mxGeometry relative="1" as="geometry"/></mxCell>' +
"</root></mxGraphModel>";
const ids = (xml) =>
parseCells(xml)
.filter((c) => c.id !== "0" && c.id !== "1")
.map((c) => c.id)
.sort();
test("update changes ONLY the targeted cell", () => {
const out = applyCellOps(MODEL, [
{
op: "update",
cellId: "c1",
xml:
'<mxCell id="c1" value="Renamed" style="rounded=1;" vertex="1" parent="grp">' +
'<mxGeometry x="10" y="10" width="80" height="40" as="geometry"/></mxCell>',
},
]);
const cells = parseCells(out);
assert.equal(cells.find((c) => c.id === "c1").value, "Renamed");
// Every OTHER cell is untouched.
assert.equal(cells.find((c) => c.id === "c2").value, "Child2");
assert.equal(cells.find((c) => c.id === "out").value, "Outside");
assert.deepEqual(ids(out), ids(MODEL));
});
test("delete of a container removes its children AND the connected edges", () => {
const out = applyCellOps(MODEL, [{ op: "delete", cellId: "grp" }]);
// grp + c1 + c2 gone (cascade to children); e1 (c1->out) and e2 (out->c2)
// gone (cascade to connected edges); "out" survives.
assert.deepEqual(ids(out), ["out"]);
});
test("delete of a leaf only cascades to its connected edges, not siblings", () => {
const out = applyCellOps(MODEL, [{ op: "delete", cellId: "c1" }]);
// c1 gone + e1 (c1->out) gone; c2, out, grp, e2 survive.
assert.deepEqual(ids(out), ["c2", "e2", "grp", "out"]);
});
test("add appends a new cell", () => {
const out = applyCellOps(MODEL, [
{
op: "add",
xml:
'<mxCell id="new1" value="N" style="rounded=1;" vertex="1" parent="1">' +
'<mxGeometry x="500" y="10" width="80" height="40" as="geometry"/></mxCell>',
},
]);
assert.ok(parseCells(out).some((c) => c.id === "new1"));
});
test("delete never removes the sentinels", () => {
const out = applyCellOps(MODEL, [{ op: "delete", cellId: "out" }]);
const cells = parseCells(out);
assert.ok(cells.some((c) => c.id === "0"));
assert.ok(cells.some((c) => c.id === "1"));
});
test("errors: unknown update/delete target, duplicate add id, id mismatch", () => {
assert.throws(
() => applyCellOps(MODEL, [{ op: "update", cellId: "ghost", xml: '<mxCell id="ghost"/>' }]),
/does not exist/,
);
assert.throws(
() => applyCellOps(MODEL, [{ op: "delete", cellId: "ghost" }]),
/does not exist/,
);
assert.throws(
() => applyCellOps(MODEL, [{ op: "add", xml: '<mxCell id="c1"/>' }]),
/already exists/,
);
assert.throws(
() =>
applyCellOps(MODEL, [
{ op: "update", cellId: "c1", xml: '<mxCell id="c2"/>' },
]),
/ids are stable/,
);
assert.throws(() => applyCellOps(MODEL, []), CellOpsError);
});
test("an add op with two cells or a missing id is rejected", () => {
assert.throws(
() => applyCellOps(MODEL, [{ op: "add", xml: '<mxCell id="a"/><mxCell id="b"/>' }]),
/exactly one <mxCell>/,
);
assert.throws(
() => applyCellOps(MODEL, [{ op: "add", xml: '<mxCell value="x"/>' }]),
/missing an id/,
);
});
// --- SUGGESTION #5: sentinel cells are protected from delete ------------------
test("delete targeting a sentinel id is rejected (no wipe of the diagram body)", () => {
for (const sid of ["0", "1"]) {
assert.throws(
() => applyCellOps(MODEL, [{ op: "delete", cellId: sid }]),
(e) => e instanceof CellOpsError && /cannot delete sentinel cell/.test(e.message),
);
}
// A normal delete still works and the sentinels remain intact.
const out = applyCellOps(MODEL, [{ op: "delete", cellId: "out" }]);
const remaining = parseCells(out).map((c) => c.id);
assert.ok(remaining.includes("0") && remaining.includes("1"), "sentinels survive");
});
@@ -0,0 +1,380 @@
// Unit tests for the drawioFromGraph pipeline (issue #425, stage 3): the
// semantic graph -> ELK -> linter-clean XML assembler, plus the layout hints
// (pinned / sameLayerAs / layer) and incremental layout. Pure — no client, no
// network — so they run under `node --test` against the built lib.
import { test } from "node:test";
import assert from "node:assert/strict";
import {
buildFromGraph,
validateGraph,
resolveNodeStyle,
GraphValidationError,
} from "../../build/lib/drawio-graph.js";
import { getPreset } from "../../build/lib/drawio-presets.js";
import {
prepareModel,
parseCells,
computeQualityWarnings,
} from "../../build/lib/drawio-xml.js";
function cellsOf(xml) {
return parseCells(xml).filter((c) => c.id !== "0" && c.id !== "1");
}
function byId(xml) {
const m = new Map();
for (const c of parseCells(xml)) m.set(c.id, c);
return m;
}
// --- Acceptance #1: 15-node graph, 2 nested groups, AWS icons ---------------
test("from_graph: 15+ nodes, 2 nested groups, AWS icons -> 0 lint errors, 0 warnings, all icons resolve", async () => {
const awsIcons = [
"aws:lambda", "aws:dynamodb", "aws:api_gateway", "aws:s3", "aws:sqs",
"aws:sns", "aws:ec2", "aws:rds", "aws:cloudfront", "aws:elasticache",
"aws:kinesis", "aws:cognito", "aws:secrets_manager", "aws:cloudwatch",
];
const kinds = [
"service", "db", "gateway", "service", "queue", "queue", "service", "db",
"gateway", "db", "queue", "security", "security", "external",
];
const nodes = [];
for (let i = 0; i < 14; i++) {
nodes.push({
id: "n" + i,
label: "Node " + i,
kind: kinds[i],
icon: awsIcons[i],
group: i < 6 ? "sub1" : i < 10 ? "vpc1" : undefined,
});
}
nodes.push({ id: "ext1", label: "External Service", kind: "external" });
const graph = {
nodes,
groups: [
{ id: "vpc1", label: "VPC 10.0.0.0/16", kind: "vpc" },
{ id: "sub1", label: "Private Subnet", kind: "subnet", group: "vpc1" }, // NESTED
],
edges: [
{ from: "n0", to: "n1", kind: "sync" },
{ from: "n1", to: "n2", kind: "async" },
{ from: "n2", to: "n3" },
{ from: "n3", to: "n7", kind: "sync" },
{ from: "n7", to: "n8" },
{ from: "n8", to: "ext1", kind: "error" },
{ from: "n4", to: "n5" },
{ from: "n10", to: "n11" },
],
direction: "LR",
preset: "default",
};
const r = await buildFromGraph(graph, "full");
assert.equal(graph.nodes.length >= 15, true, "at least 15 nodes");
// All icons resolved — NO empty squares.
assert.equal(r.iconsMissing.length, 0, `unresolved icons: ${r.iconsMissing}`);
assert.equal(r.iconsResolved, 14);
// 0 lint errors + 0 quality-warnings.
const prepared = prepareModel(r.modelXml);
assert.equal(prepared.warnings.length, 0, prepared.warnings.join("\n"));
// Groups are TRANSPARENT containers.
const cells = byId(r.modelXml);
for (const gid of ["vpc1", "sub1"]) {
const g = cells.get(gid);
assert.ok(g, `${gid} present`);
assert.equal(g.styleMap.container, "1", `${gid} container=1`);
assert.equal(g.styleMap.fillColor, "none", `${gid} fillColor=none`);
assert.equal(g.styleMap.dropTarget, "1", `${gid} dropTarget=1`);
}
// The nested group sub1's parent IS vpc1 (nesting honoured).
assert.equal(cells.get("sub1").parent, "vpc1");
// A grouped node's parent is its group (relative coords).
assert.equal(cells.get("n0").parent, "sub1");
});
test("from_graph: an UNKNOWN icon degrades to a labelled generic shape (never empty)", async () => {
const graph = {
nodes: [
{ id: "a", label: "Mystery", kind: "service", icon: "not:a-real-icon-xyz" },
{ id: "b", label: "Plain", kind: "db" },
],
edges: [{ from: "a", to: "b" }],
};
const r = await buildFromGraph(graph, "full");
const cells = byId(r.modelXml);
// The node still carries its label and a real (non-empty) style with a fill.
assert.equal(cells.get("a").value, "Mystery");
assert.match(cells.get("a").style, /fillColor=/);
// It is reported as missing so the model can see the degradation.
assert.ok(r.iconsMissing.includes("a"));
});
// --- Acceptance #2: hints -----------------------------------------------------
test("from_graph: a pinned node stays at its exact coordinates", async () => {
const graph = {
nodes: [
{ id: "a", label: "A", pinned: { x: 40, y: 900 } },
{ id: "b", label: "B" },
{ id: "c", label: "C" },
],
edges: [{ from: "a", to: "b" }, { from: "b", to: "c" }],
};
const a = byId((await buildFromGraph(graph, "full")).modelXml).get("a");
assert.equal(a.geometry.x, 40);
assert.equal(a.geometry.y, 900);
});
test("from_graph: a sameLayerAs pair lands in the same layer (equal layer-axis coord)", async () => {
const graph = {
nodes: [
{ id: "x", label: "X" },
{ id: "y", label: "Y", sameLayerAs: "x" },
{ id: "z", label: "Z" },
],
edges: [{ from: "z", to: "x" }, { from: "z", to: "y" }],
direction: "LR", // layer axis = x
};
const cells = byId((await buildFromGraph(graph, "full")).modelXml);
assert.equal(cells.get("x").geometry.x, cells.get("y").geometry.x);
});
test("from_graph: an explicit layer index co-aligns nodes on the layer axis", async () => {
const graph = {
nodes: [
{ id: "p", label: "P", layer: 0 },
{ id: "q", label: "Q", layer: 0 },
{ id: "r", label: "R", layer: 1 },
],
edges: [{ from: "p", to: "r" }],
direction: "LR",
};
const cells = byId((await buildFromGraph(graph, "full")).modelXml);
assert.equal(cells.get("p").geometry.x, cells.get("q").geometry.x);
});
test("from_graph: TB direction snaps sameLayerAs on the Y axis", async () => {
const graph = {
nodes: [
{ id: "x", label: "X" },
{ id: "y", label: "Y", sameLayerAs: "x" },
{ id: "z", label: "Z" },
],
edges: [{ from: "z", to: "x" }, { from: "z", to: "y" }],
direction: "TB", // layer axis = y
};
const cells = byId((await buildFromGraph(graph, "full")).modelXml);
assert.equal(cells.get("x").geometry.y, cells.get("y").geometry.y);
});
// --- Acceptance #3: incremental never moves existing cells --------------------
test("from_graph incremental: adding a node does NOT move existing cells", async () => {
const existing = new Map([
["a", { x: 100, y: 100 }],
["b", { x: 400, y: 100 }],
]);
const graph = {
nodes: [
{ id: "a", label: "A" },
{ id: "b", label: "B" },
{ id: "cnew", label: "New" },
],
edges: [{ from: "a", to: "b" }, { from: "b", to: "cnew" }],
};
const cells = byId((await buildFromGraph(graph, "incremental", existing)).modelXml);
assert.deepEqual(
[cells.get("a").geometry.x, cells.get("a").geometry.y],
[100, 100],
);
assert.deepEqual(
[cells.get("b").geometry.x, cells.get("b").geometry.y],
[400, 100],
);
// The new node was placed and does not overlap the frozen block.
const cn = cells.get("cnew");
assert.ok(cn.geometry.y >= 200, "new node placed clear of the existing block");
});
// --- direction honoured -------------------------------------------------------
test("from_graph: LR vs RL flip the layout axis order", async () => {
const mk = (dir) => ({
nodes: [{ id: "s", label: "S" }, { id: "t", label: "T" }],
edges: [{ from: "s", to: "t" }],
direction: dir,
});
const lr = byId((await buildFromGraph(mk("LR"), "full")).modelXml);
// In LR the target sits to the RIGHT of the source.
assert.ok(lr.get("t").geometry.x > lr.get("s").geometry.x, "LR: t right of s");
const rl = byId((await buildFromGraph(mk("RL"), "full")).modelXml);
assert.ok(rl.get("t").geometry.x < rl.get("s").geometry.x, "RL: t left of s");
});
// --- validation ---------------------------------------------------------------
test("validateGraph: rejects duplicate node ids, unknown group/edge refs", () => {
assert.throws(
() => validateGraph({ nodes: [{ id: "a", label: "A" }, { id: "a", label: "B" }] }),
GraphValidationError,
);
assert.throws(
() => validateGraph({ nodes: [{ id: "a", label: "A", group: "ghost" }] }),
/unknown group/,
);
assert.throws(
() =>
validateGraph({
nodes: [{ id: "a", label: "A" }],
edges: [{ from: "a", to: "ghost" }],
}),
/resolves to no node/,
);
assert.throws(() => validateGraph({ nodes: [] }), /non-empty/);
});
test("resolveNodeStyle: kind maps to the preset palette slot for a generic node", () => {
const preset = getPreset("default");
const s = resolveNodeStyle(preset, { id: "d", label: "DB", kind: "db" });
assert.equal(s.iconResolved, false);
assert.match(s.style, /fillColor=#d5e8d4;strokeColor=#82b366/);
});
// --- the assembled XML is always linter-clean --------------------------------
test("from_graph: a plain cross-container-edge graph is linter-clean", async () => {
const graph = {
nodes: [
{ id: "a", label: "A", group: "g1" },
{ id: "b", label: "B", group: "g2" },
],
groups: [
{ id: "g1", label: "G1" },
{ id: "g2", label: "G2" },
],
edges: [{ from: "a", to: "b", label: "x" }],
};
const r = await buildFromGraph(graph, "full");
const prepared = prepareModel(r.modelXml); // throws on any lint error
assert.equal(prepared.warnings.length, 0, prepared.warnings.join("\n"));
// A cross-container edge is parented at the layer sentinel "1".
const edge = cellsOf(r.modelXml).find((c) => c.edge);
assert.equal(edge.parent, "1");
});
// --- CRITICAL #1: edge / group caps reject FAST (no layout, no OOM) -----------
test("validateGraph: an over-limit EDGE count is rejected before any layout", () => {
// 2 nodes, 200000 edges: passes node validation, would OOM graphToElk/runElk.
const edges = [];
for (let i = 0; i < 200_000; i++) edges.push({ from: "a", to: "b" });
const graph = { nodes: [{ id: "a", label: "A" }, { id: "b", label: "B" }], edges };
const t0 = Date.now();
assert.throws(
() => validateGraph(graph),
(e) => e instanceof GraphValidationError && /200000 edges .*max 1000/.test(e.message),
);
assert.ok(Date.now() - t0 < 1000, "must reject in well under a second (no layout)");
});
test("validateGraph: an over-limit GROUP count is rejected fast", () => {
const groups = [];
for (let i = 0; i < 600; i++) groups.push({ id: "g" + i, label: "G" });
const t0 = Date.now();
assert.throws(
() => validateGraph({ nodes: [{ id: "a", label: "A" }], groups }),
(e) => e instanceof GraphValidationError && /600 groups .*max 500/.test(e.message),
);
assert.ok(Date.now() - t0 < 1000);
});
test("validateGraph: exactly-at-cap edges/groups are accepted", () => {
const edges = [];
for (let i = 0; i < 1000; i++) edges.push({ from: "a", to: "b" });
assert.doesNotThrow(() =>
validateGraph({ nodes: [{ id: "a", label: "A" }, { id: "b", label: "B" }], edges }),
);
});
// --- WARNING #3: sameLayerAs spread -> 0 quality-warnings by construction -----
test("from_graph: a sameLayerAs chain of 5 yields 0 quality-warnings (cross-axis spread)", async () => {
const graph = {
nodes: [
{ id: "a", label: "A" },
{ id: "b", label: "B", sameLayerAs: "a" },
{ id: "c", label: "C", sameLayerAs: "b" },
{ id: "d", label: "D", sameLayerAs: "c" },
{ id: "e", label: "E", sameLayerAs: "d" },
],
edges: [{ from: "a", to: "b" }],
direction: "LR",
};
const r = await buildFromGraph(graph, "full");
const cells = parseCells(r.modelXml);
const warnings = computeQualityWarnings(cells);
assert.equal(warnings.length, 0, warnings.join("\n"));
// The four chained dependents share one layer-axis (x) coordinate...
const by = new Map(cells.map((c) => [c.id, c]));
const xs = ["b", "c", "d", "e"].map((id) => by.get(id).geometry.x);
assert.equal(new Set(xs).size, 1, "chained nodes must share the layer axis");
// ...but are spread on the cross axis (y) with distinct coordinates.
const ys = ["b", "c", "d", "e"].map((id) => by.get(id).geometry.y);
assert.equal(new Set(ys).size, 4, "chained nodes must not stack on the cross axis");
});
test("from_graph: pinned coords are honored verbatim; a negative pin is clamped non-negative", async () => {
const graph = {
nodes: [
{ id: "p1", label: "P1", pinned: { x: 100, y: 100 } },
// Two user-pinned nodes at (nearly) the same point: user intent, honored.
{ id: "p2", label: "P2", pinned: { x: -500, y: 100 } }, // out-of-bounds x -> clamped to 0
],
direction: "LR",
};
const r = await buildFromGraph(graph, "full");
const by = new Map(parseCells(r.modelXml).map((c) => [c.id, c]));
assert.equal(by.get("p1").geometry.x, 100);
assert.equal(by.get("p1").geometry.y, 100);
assert.equal(by.get("p2").geometry.x, 0, "negative pin x clamped to 0");
assert.equal(by.get("p2").geometry.y, 100, "pin y honored");
// A pinned overlap MAY warn — that's user-directed and documented; we only
// assert the coords are honored (the guarantee softening), not warning count.
});
// --- WARNING #4: incremental MERGE preserves unlisted existing cells ----------
test("from_graph incremental: an existing cell not in the new graph SURVIVES the add", async () => {
const existingModelXml =
'<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>' +
'<mxCell id="manual" value="Hand Placed" style="rounded=1;" vertex="1" parent="1">' +
'<mxGeometry x="900" y="900" width="120" height="60" as="geometry"/></mxCell>' +
'<mxCell id="old1" value="Old One" style="rounded=1;" vertex="1" parent="1">' +
'<mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell>' +
"</root></mxGraphModel>";
const existingCoords = new Map([
["manual", { x: 900, y: 900 }],
["old1", { x: 40, y: 40 }],
]);
// The model sends ONLY the re-listed old1 + the new node — NOT "manual".
const graph = {
nodes: [
{ id: "old1", label: "Old One" },
{ id: "new1", label: "Added Node" },
],
edges: [{ from: "old1", to: "new1" }],
direction: "LR",
};
const r = await buildFromGraph(graph, "incremental", existingCoords, existingModelXml);
const by = new Map(parseCells(r.modelXml).map((c) => [c.id, c]));
// The unlisted hand-placed cell survives, verbatim coords.
assert.ok(by.has("manual"), "unlisted existing cell must not be dropped");
assert.equal(by.get("manual").geometry.x, 900);
assert.equal(by.get("manual").geometry.y, 900);
// The re-listed existing cell keeps its frozen coords; the new node is added.
assert.equal(by.get("old1").geometry.x, 40);
assert.equal(by.get("old1").geometry.y, 40);
assert.ok(by.has("new1"), "the newly added node is present");
});
@@ -0,0 +1,114 @@
// Unit tests for the Mermaid flowchart -> graph parser (issue #425, acceptance
// #6, OPTIONAL). Verifies a flowchart with a branch + a subgraph parses to a
// graph the from_graph pipeline renders as valid, editable drawio, and that a
// non-flowchart diagram is rejected with a clear error.
import { test } from "node:test";
import assert from "node:assert/strict";
import { mermaidToGraph, MermaidParseError } from "../../build/lib/drawio-mermaid.js";
import { buildFromGraph } from "../../build/lib/drawio-graph.js";
import { prepareModel } from "../../build/lib/drawio-xml.js";
test("flowchart with a branch + subgraph -> a valid, editable drawio", async () => {
const mm = `flowchart LR
A[Start] --> B{Decision}
B -->|yes| C[(Database)]
B -->|no| D[End]
subgraph backend [Backend Services]
C
E([Cache])
end
D -.-> E`;
const graph = mermaidToGraph(mm);
// Direction + nodes + a group + labelled/dashed edges parsed.
assert.equal(graph.direction, "LR");
const nodeIds = graph.nodes.map((n) => n.id).sort();
assert.deepEqual(nodeIds, ["A", "B", "C", "D", "E"]);
// The decision `{}` maps to the queue palette; the `[(db)]` to db.
assert.equal(graph.nodes.find((n) => n.id === "B").kind, "queue");
assert.equal(graph.nodes.find((n) => n.id === "C").kind, "db");
// The subgraph became a group and claimed its members.
assert.equal(graph.groups.length, 1);
assert.equal(graph.groups[0].id, "backend");
assert.equal(graph.nodes.find((n) => n.id === "C").group, "backend");
assert.equal(graph.nodes.find((n) => n.id === "E").group, "backend");
// A pipe label and a dotted (async) edge.
const yes = graph.edges.find((e) => e.from === "B" && e.to === "C");
assert.equal(yes.label, "yes");
const dotted = graph.edges.find((e) => e.from === "D" && e.to === "E");
assert.equal(dotted.kind, "async");
// The whole thing renders linter-clean.
const built = await buildFromGraph(graph, "full");
const prepared = prepareModel(built.modelXml);
assert.equal(prepared.warnings.length, 0, prepared.warnings.join("\n"));
});
test("graph TD header sets a top-down direction", () => {
const g = mermaidToGraph("graph TD\n X --> Y");
assert.equal(g.direction, "TB");
assert.deepEqual(g.nodes.map((n) => n.id).sort(), ["X", "Y"]);
});
test("a chained connection A --> B --> C yields two edges", () => {
const g = mermaidToGraph("flowchart LR\n A[a] --> B[b] --> C[c]");
const pairs = g.edges.map((e) => `${e.from}->${e.to}`).sort();
assert.deepEqual(pairs, ["A->B", "B->C"]);
});
test("a non-flowchart diagram is rejected with a clear error", () => {
assert.throws(
() => mermaidToGraph("sequenceDiagram\n Alice->>Bob: Hi"),
/only 'flowchart'\/'graph' is supported/,
);
assert.throws(() => mermaidToGraph(""), MermaidParseError);
});
// --- CRITICAL #2 / NIT: input-size bounds reject FAST (no OOM) ----------------
test("mermaidToGraph: an over-length input is rejected before parsing (fast)", () => {
const huge = "flowchart LR\n" + "A-->B\n".repeat(60_000); // ~360 KB > 200 KB cap
const t0 = Date.now();
assert.throws(
() => mermaidToGraph(huge),
(e) => e instanceof MermaidParseError && /max 200000/.test(e.message),
);
assert.ok(Date.now() - t0 < 1000, "must reject in well under a second (no parse)");
});
test("mermaidToGraph: an over-line-count input is rejected fast", () => {
const many = "flowchart LR\n" + "A\n".repeat(25_000); // > 20000 line cap
const t0 = Date.now();
assert.throws(
() => mermaidToGraph(many),
(e) => e instanceof MermaidParseError && /max 20000/.test(e.message),
);
assert.ok(Date.now() - t0 < 1000);
});
test("mermaidToGraph: too many subgraphs is rejected", () => {
let src = "flowchart LR\n";
for (let i = 0; i < 600; i++) src += `subgraph s${i}\nend\n`;
assert.throws(
() => mermaidToGraph(src),
(e) => e instanceof MermaidParseError && /too many subgraphs .*max 500/.test(e.message),
);
});
test("mermaidToGraph: an over-long connection chain throws (NON-silent truncation)", () => {
const chain =
"flowchart LR\n" +
Array.from({ length: 600 }, (_, i) => "N" + i).join("-->");
assert.throws(
() => mermaidToGraph(chain),
(e) => e instanceof MermaidParseError && /chain exceeds 500 nodes/.test(e.message),
);
});
test("mermaidToGraph: a chain of 60 nodes parses (no silent 50-node truncation)", () => {
const chain =
"flowchart LR\n" +
Array.from({ length: 60 }, (_, i) => "N" + i).join("-->");
const g = mermaidToGraph(chain);
assert.equal(g.nodes.length, 60, "all 60 chained nodes are kept");
});
@@ -0,0 +1,117 @@
// Snapshot + invariant tests for the semantic presets (issue #425, acceptance
// #5): every node kind has a style-string per preset, and colorblind-safe uses
// only the Okabe-Ito palette (no problematic color pairs).
import { test } from "node:test";
import assert from "node:assert/strict";
import {
getPreset,
genericNodeStyle,
edgeStyle,
groupStyle,
NODE_KINDS,
EDGE_KINDS,
PRESET_NAMES,
} from "../../build/lib/drawio-presets.js";
// The Okabe-Ito qualitative palette (8 colours distinguishable under the common
// colour-vision deficiencies). colorblind-safe MUST draw its strokes from here.
const OKABE_ITO = [
"#000000", "#e69f00", "#56b4e9", "#009e73",
"#f0e442", "#0072b2", "#d55e00", "#cc79a7",
].map((s) => s.toLowerCase());
// The exact base-palette fills from the issue's table (a snapshot: a change to
// the default palette is a deliberate, reviewed edit — this catches accidents).
const DEFAULT_FILLS = {
service: "#dae8fc",
db: "#d5e8d4",
queue: "#fff2cc",
gateway: "#ffe6cc",
error: "#f8cecc",
external: "#f5f5f5",
security: "#e1d5e7",
};
const DEFAULT_STROKES = {
service: "#6c8ebf",
db: "#82b366",
queue: "#d6b656",
gateway: "#d79b00",
error: "#b85450",
external: "#666666",
security: "#9673a6",
};
test("every node kind has a style-string in every preset", () => {
for (const p of PRESET_NAMES) {
const preset = getPreset(p);
for (const kind of NODE_KINDS) {
const style = genericNodeStyle(preset, kind);
assert.match(style, /fillColor=#[0-9a-fA-F]{6}/, `${p}/${kind} has a fill`);
assert.match(style, /strokeColor=#[0-9a-fA-F]{6}/, `${p}/${kind} has a stroke`);
assert.match(style, /fontColor=#[0-9a-fA-F]{6}/, `${p}/${kind} has a font`);
}
}
});
test("default preset matches the issue's palette snapshot", () => {
const preset = getPreset("default");
for (const kind of NODE_KINDS) {
const style = genericNodeStyle(preset, kind);
assert.ok(
style.includes(`fillColor=${DEFAULT_FILLS[kind]}`),
`default/${kind} fill ${DEFAULT_FILLS[kind]}`,
);
assert.ok(
style.includes(`strokeColor=${DEFAULT_STROKES[kind]}`),
`default/${kind} stroke ${DEFAULT_STROKES[kind]}`,
);
}
});
test("colorblind-safe strokes come ONLY from the Okabe-Ito palette", () => {
const preset = getPreset("colorblind-safe");
const usedStrokes = new Set();
for (const kind of NODE_KINDS) {
const stroke = preset.nodes[kind].strokeColor.toLowerCase();
assert.ok(
OKABE_ITO.includes(stroke),
`colorblind-safe/${kind} stroke ${stroke} is NOT Okabe-Ito`,
);
usedStrokes.add(stroke);
}
// No two kinds share a stroke (each is a distinct, distinguishable hue) — the
// "no problematic color pairs" acceptance: distinct Okabe-Ito hues.
assert.equal(
usedStrokes.size,
NODE_KINDS.length,
"each kind gets a distinct Okabe-Ito stroke",
);
});
test("edge kinds: sync solid, async dashed, error red-dashed", () => {
const preset = getPreset("default");
const sync = edgeStyle(preset, "sync");
const async_ = edgeStyle(preset, "async");
const error = edgeStyle(preset, "error");
assert.doesNotMatch(sync, /dashed=1/, "sync is solid");
assert.match(async_, /dashed=1/, "async is dashed");
assert.match(error, /dashed=1/, "error is dashed");
assert.match(error, /strokeColor=#DD344C/i, "error is red");
// An unknown edge kind falls back to sync (solid).
assert.doesNotMatch(edgeStyle(preset, "weird"), /dashed=1/);
});
test("group style is always transparent (fillColor=none;container=1;dropTarget=1)", () => {
for (const p of PRESET_NAMES) {
const style = groupStyle(getPreset(p));
assert.match(style, /fillColor=none/, `${p} group transparent`);
assert.match(style, /container=1/, `${p} group is a container`);
assert.match(style, /dropTarget=1/, `${p} group is a drop target`);
}
});
test("EDGE_KINDS / NODE_KINDS constants match the palette", () => {
const preset = getPreset("default");
for (const k of NODE_KINDS) assert.ok(preset.nodes[k], `node kind ${k}`);
for (const k of EDGE_KINDS) assert.ok(preset.edges[k], `edge kind ${k}`);
});
@@ -0,0 +1,85 @@
// Drift guards for the stage-3 drawio tools (issue #425): the three high-level
// tools must be in the shared registry, routed in SERVER_INSTRUCTIONS, expose
// the right schema fields, and carry an `execute` (they call CLIENT methods, so
// unlike drawioShapes/drawioGuide they are NOT inlineBothHosts).
import { test } from "node:test";
import assert from "node:assert/strict";
import { SERVER_INSTRUCTIONS } from "../../build/index.js";
import { SHARED_TOOL_SPECS } from "../../build/tool-specs.js";
const NEW = ["drawioEditCells", "drawioFromGraph", "drawioFromMermaid"];
test("the three stage-3 tools are in the shared registry (deferred, camelCase)", () => {
for (const name of NEW) {
const spec = SHARED_TOOL_SPECS[name];
assert.ok(spec, `${name} missing from registry`);
assert.equal(spec.mcpName, name);
assert.equal(spec.inAppKey, name);
assert.equal(spec.tier, "deferred");
}
});
test("the stage-3 tools carry an execute (client-backed, NOT inlineBothHosts)", () => {
for (const name of NEW) {
const spec = SHARED_TOOL_SPECS[name];
assert.equal(typeof spec.execute, "function", `${name} needs an execute`);
assert.notEqual(spec.inlineBothHosts, true, `${name} must not be inlineBothHosts`);
}
});
test("the stage-3 tools are routed in SERVER_INSTRUCTIONS", () => {
for (const name of NEW) {
assert.match(SERVER_INSTRUCTIONS, new RegExp(`\\b${name}\\b`), `${name} missing from guide`);
}
});
test("drawioFromGraph exposes graph + direction/preset/layout params", () => {
const shape = SHARED_TOOL_SPECS.drawioFromGraph.buildShape(makeZodStub());
for (const key of ["pageId", "graph", "position", "direction", "preset", "layout", "node"]) {
assert.ok(key in shape, `drawioFromGraph missing ${key}`);
}
});
test("drawioEditCells exposes operations + baseHash", () => {
const shape = SHARED_TOOL_SPECS.drawioEditCells.buildShape(makeZodStub());
for (const key of ["pageId", "node", "operations", "baseHash"]) {
assert.ok(key in shape, `drawioEditCells missing ${key}`);
}
});
test("drawioFromMermaid exposes a mermaid param", () => {
const shape = SHARED_TOOL_SPECS.drawioFromMermaid.buildShape(makeZodStub());
assert.ok("mermaid" in shape);
});
test("the hard-rules block is injected into edit_cells (raw <mxCell> ops) but NOT from_graph/from_mermaid", () => {
// edit_cells takes raw <mxCell> xml in add/update ops, so it surfaces the XML
// rules. from_graph/from_mermaid NEVER expose XML to the model (the whole point
// is the model never writes a style/coord), so the hard rules would be noise.
assert.match(SHARED_TOOL_SPECS.drawioEditCells.description, /sentinels are MANDATORY/);
assert.doesNotMatch(SHARED_TOOL_SPECS.drawioFromGraph.description, /sentinels are MANDATORY/);
assert.doesNotMatch(SHARED_TOOL_SPECS.drawioFromMermaid.description, /sentinels are MANDATORY/);
});
test("routing prose distinguishes from_graph (architectures) vs from_mermaid (standard)", () => {
// The EDIT-section routing sentence must mention the semantic tools' intents.
assert.match(SERVER_INSTRUCTIONS, /drawioFromGraph[\s\S]*architecture|architecture[\s\S]*drawioFromGraph/i);
assert.match(SERVER_INSTRUCTIONS, /drawioFromMermaid[\s\S]*flowchart|flowchart[\s\S]*drawioFromMermaid/i);
});
// Tiny zod stub (buildShape only calls string/number/enum/array/object +
// chained min/optional/describe — all return `this`; object() returns a chain
// too so nested schemas resolve).
function makeZodStub() {
const chain = new Proxy(
{},
{ get: (_t, p) => (p === "parse" ? () => ({}) : () => chain) },
);
return {
string: () => chain,
number: () => chain,
enum: () => chain,
array: () => chain,
object: () => chain,
};
}
@@ -165,6 +165,70 @@ test("insertNode spec exists, describes markdown+node XOR, builds the full ancho
);
});
// #443: getTree — a space's page hierarchy (or a subtree) in one request.
test("getTree spec exists on both hosts, builds { spaceId, rootPageId?, maxDepth? }", () => {
const spec = SHARED_TOOL_SPECS.getTree;
assert.ok(spec, "getTree spec missing");
assert.equal(spec.mcpName, "getTree");
assert.equal(spec.inAppKey, "getTree");
// Shared spec: registered on BOTH hosts.
assert.notEqual(spec.inAppOnly, true);
assert.notEqual(spec.mcpOnly, true);
const shape = spec.buildShape(z);
assert.deepEqual(Object.keys(shape).sort(), ["maxDepth", "rootPageId", "spaceId"]);
const schema = z.object(shape);
// spaceId required; rootPageId + maxDepth optional.
assert.doesNotThrow(() => schema.parse({ spaceId: "sp1" }));
assert.throws(() => schema.parse({}));
assert.doesNotThrow(() =>
schema.parse({ spaceId: "sp1", rootPageId: "p1", maxDepth: 2 }),
);
// maxDepth is an integer >= 1.
assert.throws(() => schema.parse({ spaceId: "sp1", maxDepth: 0 }));
assert.throws(() => schema.parse({ spaceId: "sp1", maxDepth: 1.5 }));
// The description advertises the output node shape, rootPageId, maxDepth, and
// steers away from the deprecated listPages tree:true.
assert.match(spec.description, /pageId/);
assert.match(spec.description, /rootPageId/);
assert.match(spec.description, /maxDepth/);
assert.match(spec.description, /hasChildren/);
assert.match(spec.description, /listPages tree:true/);
});
// #443: getPageContext — a page's breadcrumbs + direct children in one call.
test("getPageContext spec exists on both hosts, builds { pageId }", () => {
const spec = SHARED_TOOL_SPECS.getPageContext;
assert.ok(spec, "getPageContext spec missing");
assert.equal(spec.mcpName, "getPageContext");
assert.equal(spec.inAppKey, "getPageContext");
// Shared spec: registered on BOTH hosts.
assert.notEqual(spec.inAppOnly, true);
assert.notEqual(spec.mcpOnly, true);
const shape = spec.buildShape(z);
assert.deepEqual(Object.keys(shape).sort(), ["pageId"]);
const schema = z.object(shape);
// pageId required.
assert.doesNotThrow(() => schema.parse({ pageId: "p1" }));
assert.throws(() => schema.parse({}));
// The description advertises the output shape (page/breadcrumbs/children) and
// the root-page empty-breadcrumbs contract.
assert.match(spec.description, /breadcrumbs/);
assert.match(spec.description, /children/);
assert.match(spec.description, /hasChildren/);
assert.match(spec.description, /getTree/);
});
// #443: listPages tree:true is deprecated in favour of getTree.
test("listPages description deprecates tree:true and points at getTree", () => {
const spec = SHARED_TOOL_SPECS.listPages;
assert.match(spec.description, /DEPRECATED/i);
assert.match(spec.description, /getTree/);
});
test("no-arg specs (getWorkspace/listSpaces/listShares) omit buildShape", () => {
for (const key of ["getWorkspace", "listSpaces", "listShares"]) {
assert.equal(SHARED_TOOL_SPECS[key].buildShape, undefined, `${key} should be no-arg`);
+161
View File
@@ -137,3 +137,164 @@ test("buildPageTree output shape is lean (drops position/parentPageId/hasChildre
assert.equal("hasChildren" in node, false);
assert.equal("spaceId" in node, false);
});
// ---------------------------------------------------------------------------
// #443 getTree output shape: { pageId, title, children?, hasChildren? }
// ---------------------------------------------------------------------------
// A small representative space used across the getTree tests:
// r1 (Infrastructure)
// c1 (Datacenter A)
// g1 (Servers) [leaf]
// c2 (Datacenter B) [leaf]
// r2 (Notes) [leaf]
const SAMPLE = [
{ id: "r2", slugId: "s-r2", title: "Notes", position: "a1", icon: "📝", hasChildren: false },
{ id: "r1", slugId: "s-r1", title: "Infrastructure", position: "a0", icon: "🏢", hasChildren: true },
{ id: "c2", slugId: "s-c2", title: "Datacenter B", position: "b1", parentPageId: "r1", icon: "🅱️", hasChildren: false },
{ id: "c1", slugId: "s-c1", title: "Datacenter A", position: "b0", parentPageId: "r1", icon: "🅰️", hasChildren: true },
{ id: "g1", slugId: "s-g1", title: "Servers", position: "c0", parentPageId: "c1", icon: "🖥️", hasChildren: false },
];
test("getTree shape: correct nesting + order-by-position, only {pageId,title,children?}, no leak", () => {
const tree = buildPageTree(SAMPLE, { shape: "getTree" });
// Roots sorted by position: r1 (a0) before r2 (a1).
assert.deepEqual(
tree.map((n) => n.pageId),
["r1", "r2"],
);
// r1's children sorted by position: c1 (b0) before c2 (b1).
assert.deepEqual(
tree[0].children.map((n) => n.pageId),
["c1", "c2"],
);
// Deep nesting: g1 under c1.
assert.deepEqual(
tree[0].children[0].children.map((n) => n.pageId),
["g1"],
);
// No slugId/icon/position/parentPageId/hasChildren leak on any node.
const walk = (nodes) => {
for (const n of nodes) {
assert.deepEqual(
Object.keys(n).sort(),
n.children ? ["children", "pageId", "title"] : ["pageId", "title"],
`unexpected keys on ${n.pageId}: ${Object.keys(n)}`,
);
assert.equal("slugId" in n, false);
assert.equal("icon" in n, false);
assert.equal("position" in n, false);
assert.equal("parentPageId" in n, false);
// Fully-expanded tree (no maxDepth): hasChildren never set.
assert.equal("hasChildren" in n, false);
if (n.children) walk(n.children);
}
};
walk(tree);
});
test("getTree maxDepth:1 returns roots only, each with hasChildren from the flat item", () => {
const tree = buildPageTree(SAMPLE, { shape: "getTree", maxDepth: 1 });
assert.deepEqual(
tree.map((n) => n.pageId),
["r1", "r2"],
);
// No children arrays at depth 1 when maxDepth:1.
for (const n of tree) assert.equal("children" in n, false);
// r1 has children on the server -> hasChildren:true; r2 is a leaf -> omitted.
assert.equal(tree[0].hasChildren, true);
assert.equal("hasChildren" in tree[1], false);
});
test("getTree maxDepth:2 cuts grandchildren; hasChildren only on the cut interior node", () => {
const tree = buildPageTree(SAMPLE, { shape: "getTree", maxDepth: 2 });
const r1 = tree[0];
// Depth-1 node r1 was EXPANDED (its children are present) -> no hasChildren.
assert.equal("hasChildren" in r1, false);
assert.equal(r1.children.length, 2);
const [c1, c2] = r1.children;
// c1 is at depth 2 (the cut) and has children on the server -> hasChildren:true,
// and its grandchild g1 is NOT present.
assert.equal(c1.pageId, "c1");
assert.equal("children" in c1, false);
assert.equal(c1.hasChildren, true);
// c2 is at depth 2 but is a leaf on the server -> hasChildren omitted.
assert.equal(c2.pageId, "c2");
assert.equal("children" in c2, false);
assert.equal("hasChildren" in c2, false);
// r2 is a depth-1 leaf -> no hasChildren, no children.
assert.equal("hasChildren" in tree[1], false);
});
test("getTree hasChildren is set ONLY on depth-cut nodes (not leaves, not expanded interior nodes)", () => {
// Full tree (no cut): NO node anywhere carries hasChildren.
const full = buildPageTree(SAMPLE, { shape: "getTree" });
const anyHasChildren = (nodes) =>
nodes.some((n) => "hasChildren" in n || (n.children && anyHasChildren(n.children)));
assert.equal(anyHasChildren(full), false);
});
test("getTree orphan (parent filtered out) surfaces as a root, not dropped", () => {
const tree = buildPageTree(
[
{ id: "root", slugId: "s-root", title: "Root", position: "a0", hasChildren: true },
// parentPageId points at an id NOT in the flat list (parent filtered by perms).
{ id: "orphan", slugId: "s-orphan", title: "Orphan", position: "a1", parentPageId: "gone", hasChildren: false },
],
{ shape: "getTree" },
);
assert.deepEqual(
tree.map((n) => n.pageId).sort(),
["orphan", "root"],
);
const orphan = tree.find((n) => n.pageId === "orphan");
assert.equal("children" in orphan, false);
assert.equal("hasChildren" in orphan, false);
});
test("getTree rootPageId path: a seeded single-root subtree keeps the getTree shape", () => {
// Simulate the server seeding the CTE with the subtree root c1: the flat list
// it returns contains c1 (now a root, parent absent) + its descendant g1.
const subtree = [
{ id: "c1", slugId: "s-c1", title: "Datacenter A", position: "b0", hasChildren: true },
{ id: "g1", slugId: "s-g1", title: "Servers", position: "c0", parentPageId: "c1", hasChildren: false },
];
const tree = buildPageTree(subtree, { shape: "getTree" });
assert.equal(tree.length, 1);
assert.equal(tree[0].pageId, "c1");
assert.deepEqual(
tree[0].children.map((n) => n.pageId),
["g1"],
);
assert.equal("slugId" in tree[0], false);
});
test("getTree maxDepth<=0 / non-finite is treated as no cut (whole tree)", () => {
for (const bad of [0, -3, NaN, Infinity, undefined]) {
const tree = buildPageTree(SAMPLE, { shape: "getTree", maxDepth: bad });
// Grandchild g1 present -> no cut applied.
assert.deepEqual(
tree[0].children[0].children.map((n) => n.pageId),
["g1"],
`maxDepth=${bad} should not cut`,
);
}
});
test("buildPageTree() with no options is byte-identical to the historic lean call", () => {
// Guard the existing callers: buildPageTree(pages) must be unchanged by the
// additive options param.
const withoutOpts = buildPageTree(SAMPLE);
const withEmptyOpts = buildPageTree(SAMPLE, {});
assert.deepEqual(withoutOpts, withEmptyOpts);
// And it is the lean {id,slugId,title,children?} shape, not the getTree shape.
assert.deepEqual(Object.keys(withoutOpts[0]).sort(), ["children", "id", "slugId", "title"]);
});