Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2194f423a1 | |||
| 5a6009c750 |
@@ -52,6 +52,7 @@
|
||||
"form-data": "^4.0.0",
|
||||
"jsdom": "^27.4.0",
|
||||
"marked": "^17.0.1",
|
||||
"pako": "^2.0.3",
|
||||
"re2": "^1.21.0",
|
||||
"ws": "^8.19.0",
|
||||
"y-prosemirror": "1.3.7",
|
||||
|
||||
+472
-118
@@ -40,7 +40,6 @@ import {
|
||||
deleteNodeById,
|
||||
assertUnambiguousMatch,
|
||||
insertNodeRelative,
|
||||
blockPlainText,
|
||||
buildOutline,
|
||||
getNodeByRef,
|
||||
readTable,
|
||||
@@ -50,6 +49,15 @@ import {
|
||||
} from "./lib/node-ops.js";
|
||||
import { searchInDoc, SearchOptions } from "./lib/page-search.js";
|
||||
import { withPageLock } from "./lib/page-lock.js";
|
||||
import {
|
||||
prepareModel,
|
||||
decodeDrawioSvg,
|
||||
buildDrawioSvg,
|
||||
mxHash,
|
||||
normalizeXml,
|
||||
countUserCells,
|
||||
} from "./lib/drawio-xml.js";
|
||||
import { renderDiagramShapes } from "./lib/drawio-preview.js";
|
||||
import {
|
||||
applyTextEdits,
|
||||
TextEdit,
|
||||
@@ -60,12 +68,10 @@ import { getCollabToken, performLogin } from "./lib/auth-utils.js";
|
||||
import { diffDocs, summarizeChange } from "./lib/diff.js";
|
||||
import {
|
||||
applyAnchorInDoc,
|
||||
canAnchorInDoc,
|
||||
countAnchorMatches,
|
||||
getAnchoredText,
|
||||
resolveAnchorSelection,
|
||||
normalizeForMatch,
|
||||
} from "./lib/comment-anchor.js";
|
||||
import { closestBlockHint } from "./lib/text-normalize.js";
|
||||
import {
|
||||
blockText,
|
||||
walk,
|
||||
@@ -2477,64 +2483,6 @@ export class DocmostClient {
|
||||
};
|
||||
}
|
||||
|
||||
/** Plain text of each TOP-LEVEL block of `doc`, for anchor-failure hints. */
|
||||
private topLevelBlockTexts(doc: any): string[] {
|
||||
const content = doc && Array.isArray(doc.content) ? doc.content : [];
|
||||
return content
|
||||
.map((b: any) => blockPlainText(b))
|
||||
.filter((t: string) => t.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when per-block anchoring failed but the (normalized) selection DOES
|
||||
* appear in the blocks' joined plain text — i.e. it straddles a block
|
||||
* boundary. Blocks are joined with a newline (collapsed to one space by
|
||||
* normalizeForMatch) so a selection whose parts are separated by a paragraph
|
||||
* break still matches. Callers only reach here after single-block anchoring
|
||||
* (incl. the markdown-strip fallback) has already failed.
|
||||
*/
|
||||
private selectionSpansMultipleBlocks(
|
||||
blockTexts: string[],
|
||||
selection: string,
|
||||
): boolean {
|
||||
const normSel = normalizeForMatch(selection).norm.trim();
|
||||
if (normSel.length === 0) return false;
|
||||
const joined = normalizeForMatch(blockTexts.join("\n")).norm;
|
||||
return joined.indexOf(normSel) !== -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the actionable error for a create_comment anchor MISS, porting
|
||||
* edit_page_text's self-correction affordances: an explicit "spans multiple
|
||||
* blocks" message when the selection straddles a block boundary, otherwise a
|
||||
* "closest block text" hint quoting the block that holds the selection's
|
||||
* longest token. `live` switches the wording between the pre-check (reading the
|
||||
* persisted page) and the post-create live-anchor failure (which rolls back).
|
||||
*/
|
||||
private anchorNotFoundError(
|
||||
doc: any,
|
||||
selection: string,
|
||||
live: boolean,
|
||||
): Error {
|
||||
const blockTexts = this.topLevelBlockTexts(doc);
|
||||
const rolled = live ? " The comment was rolled back." : "";
|
||||
if (this.selectionSpansMultipleBlocks(blockTexts, selection)) {
|
||||
return new Error(
|
||||
"create_comment: the selection spans multiple blocks; anchor on a " +
|
||||
"contiguous fragment within a SINGLE paragraph/block (<=250 chars)." +
|
||||
rolled,
|
||||
);
|
||||
}
|
||||
const where = live ? "in the live document" : "in the page";
|
||||
return new Error(
|
||||
`create_comment: could not find the selection text ${where} to anchor ` +
|
||||
"the comment. Provide the EXACT contiguous text from a single " +
|
||||
"paragraph/block (<=250 chars)." +
|
||||
closestBlockHint(blockTexts, selection) +
|
||||
rolled,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an inline comment anchored to its `selection` text, or a reply.
|
||||
*
|
||||
@@ -2596,10 +2544,6 @@ export class DocmostClient {
|
||||
// Captured in the pre-check below (which already reads the page) and used as
|
||||
// payload.selection. Ordinary comments keep sending the raw agent selection.
|
||||
let anchoredSelection: string | null = null;
|
||||
// Set when the anchor matched only after stripping markdown from the
|
||||
// selection (the strip fallback); surfaced as a soft warning like
|
||||
// edit_page_text does, so a stale-markdown selection is flagged.
|
||||
let anchorNormalized = false;
|
||||
|
||||
// For a top-level comment, fail BEFORE creating anything when the selection
|
||||
// is not present in the persisted document — this avoids leaving an orphan
|
||||
@@ -2615,7 +2559,10 @@ export class DocmostClient {
|
||||
// rejected BEFORE creating the comment.
|
||||
const matches = countAnchorMatches(page.content, selection);
|
||||
if (matches === 0) {
|
||||
throw this.anchorNotFoundError(page.content, selection, false);
|
||||
throw new Error(
|
||||
"create_comment: could not find the selection text in the page to anchor the comment. " +
|
||||
"Provide the EXACT contiguous text from a single paragraph/block (<=250 chars).",
|
||||
);
|
||||
}
|
||||
if (matches >= 2) {
|
||||
throw new Error(
|
||||
@@ -2629,27 +2576,18 @@ export class DocmostClient {
|
||||
// null despite countAnchorMatches===1 (shouldn't happen), fall back to
|
||||
// the raw agent selection below rather than crash.
|
||||
anchoredSelection = getAnchoredText(page.content, selection);
|
||||
anchorNormalized = resolveAnchorSelection(
|
||||
page.content,
|
||||
selection,
|
||||
).normalized;
|
||||
} else {
|
||||
const resolved = resolveAnchorSelection(page.content, selection);
|
||||
if (!resolved.found) {
|
||||
throw this.anchorNotFoundError(page.content, selection, false);
|
||||
}
|
||||
anchorNormalized = resolved.normalized;
|
||||
} else if (!canAnchorInDoc(page.content, selection)) {
|
||||
throw new Error(
|
||||
"create_comment: could not find the selection text in the page to anchor the comment. " +
|
||||
"Provide the EXACT contiguous text from a single paragraph/block (<=250 chars).",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
// Rethrow our own "not found"/"ambiguous"/"spans multiple blocks" errors;
|
||||
// swallow read/network errors so the live anchor step can still try (and
|
||||
// enforce) anchoring.
|
||||
// Rethrow our own "not found"/"ambiguous" errors; swallow read/network
|
||||
// errors so the live anchor step can still try (and enforce) anchoring.
|
||||
if (
|
||||
e instanceof Error &&
|
||||
(e.message.startsWith("create_comment: could not find the selection") ||
|
||||
e.message.startsWith(
|
||||
"create_comment: the selection spans multiple blocks",
|
||||
) ||
|
||||
e.message.startsWith(
|
||||
"create_comment: the suggestion's selection is ambiguous",
|
||||
))
|
||||
@@ -2721,10 +2659,6 @@ export class DocmostClient {
|
||||
// Set inside the transform when a suggestion's live anchor is ambiguous
|
||||
// (>=2 occurrences), so the rollback path can surface the right error.
|
||||
let ambiguousInLiveDoc = false;
|
||||
// Captured inside the transform on a not-found abort, so the rollback path
|
||||
// can surface the closest-block / spans-multiple-blocks hint built from the
|
||||
// LIVE document (the pre-check page is not in scope there).
|
||||
let liveNotFoundError: Error | null = null;
|
||||
try {
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||
@@ -2752,13 +2686,6 @@ export class DocmostClient {
|
||||
const liveCount = countAnchorMatches(doc, selection as string);
|
||||
if (liveCount !== 1) {
|
||||
ambiguousInLiveDoc = liveCount >= 2;
|
||||
if (liveCount === 0) {
|
||||
liveNotFoundError = this.anchorNotFoundError(
|
||||
doc,
|
||||
selection as string,
|
||||
true,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -2768,11 +2695,6 @@ export class DocmostClient {
|
||||
}
|
||||
// Selection text not found in the LIVE document: abort the write. The
|
||||
// rollback + throw below turns this into a hard error.
|
||||
liveNotFoundError = this.anchorNotFoundError(
|
||||
doc,
|
||||
selection as string,
|
||||
true,
|
||||
);
|
||||
return null;
|
||||
},
|
||||
);
|
||||
@@ -2789,28 +2711,13 @@ export class DocmostClient {
|
||||
// suggestion, was ambiguous) in the live document. Roll back the comment
|
||||
// and surface a hard error.
|
||||
await this.safeDeleteComment(newCommentId);
|
||||
if (ambiguousInLiveDoc) {
|
||||
throw new Error(
|
||||
"create_comment: the suggestion's selection is ambiguous in the live document (multiple occurrences); the comment was rolled back. Expand the selection with surrounding context so it is unique.",
|
||||
);
|
||||
}
|
||||
throw (
|
||||
liveNotFoundError ??
|
||||
new Error(
|
||||
"create_comment: failed to anchor the comment (selection not found in the live document); the comment was rolled back",
|
||||
)
|
||||
throw new Error(
|
||||
ambiguousInLiveDoc
|
||||
? "create_comment: the suggestion's selection is ambiguous in the live document (multiple occurrences); the comment was rolled back. Expand the selection with surrounding context so it is unique."
|
||||
: "create_comment: failed to anchor the comment (selection not found in the live document); the comment was rolled back",
|
||||
);
|
||||
}
|
||||
|
||||
// Soft warning (like edit_page_text): the selection only matched after
|
||||
// stripping markdown, so the caller likely quoted a styled fragment.
|
||||
if (anchorNormalized) {
|
||||
result.warning =
|
||||
"The selection matched only after stripping markdown syntax; the comment " +
|
||||
"was anchored on the document's plain text. Copy the selection verbatim " +
|
||||
"from get_page / search_in_page output to avoid this.";
|
||||
}
|
||||
|
||||
result.anchored = true;
|
||||
return result;
|
||||
}
|
||||
@@ -3534,6 +3441,453 @@ export class DocmostClient {
|
||||
});
|
||||
}
|
||||
|
||||
// --- draw.io diagrams (issue #423) ---
|
||||
|
||||
/**
|
||||
* Upload a ready-made byte buffer as a page attachment via the same
|
||||
* multipart /files/upload endpoint uploadImage uses. Split out as its own
|
||||
* (overridable) seam so drawio_create/update can upload the generated
|
||||
* `.drawio.svg` without going through the URL-fetch path, and so tests can
|
||||
* stub the network. Mirrors uploadImage's fresh-FormData + one-shot 401/403
|
||||
* re-auth handling (a FormData body is single-use, so it must be rebuilt per
|
||||
* attempt).
|
||||
*/
|
||||
protected async uploadAttachmentBuffer(
|
||||
pageId: string,
|
||||
buffer: Buffer,
|
||||
fileName: string,
|
||||
mime: string,
|
||||
): Promise<{ id: string; fileName: string; fileSize: number }> {
|
||||
await this.ensureAuthenticated();
|
||||
const buildForm = () => {
|
||||
const form = new FormData();
|
||||
form.append("pageId", pageId);
|
||||
form.append("file", buffer, { filename: fileName, contentType: mime });
|
||||
return form;
|
||||
};
|
||||
const uploadUrl = `${this.apiUrl}/files/upload`;
|
||||
let response;
|
||||
try {
|
||||
const form = buildForm();
|
||||
response = await axios.post(uploadUrl, form, {
|
||||
headers: {
|
||||
...form.getHeaders(),
|
||||
Authorization: this.client.defaults.headers.common["Authorization"],
|
||||
},
|
||||
timeout: 60000,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
axios.isAxiosError(error) &&
|
||||
(error.response?.status === 401 || error.response?.status === 403)
|
||||
) {
|
||||
await this.login();
|
||||
const form2 = buildForm();
|
||||
response = await axios.post(uploadUrl, form2, {
|
||||
headers: {
|
||||
...form2.getHeaders(),
|
||||
Authorization: this.client.defaults.headers.common["Authorization"],
|
||||
},
|
||||
timeout: 60000,
|
||||
});
|
||||
} else if (axios.isAxiosError(error)) {
|
||||
if (process.env.DEBUG) {
|
||||
console.error(
|
||||
"Attachment upload failed; response body:",
|
||||
JSON.stringify(error.response?.data),
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Attachment upload failed: ${error.response?.status} ${error.response?.statusText}`,
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const att = response.data?.data ?? response.data;
|
||||
if (!att?.id || !att?.fileName) {
|
||||
throw new Error(
|
||||
"Unexpected /files/upload response: " + JSON.stringify(response.data),
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: att.id,
|
||||
fileName: att.fileName,
|
||||
fileSize: att.fileSize ?? buffer.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a stored `.drawio.svg` attachment as text. Overridable seam over
|
||||
* fetchInternalFile (the authed loopback fetch, which also rejects any
|
||||
* traversal/SSRF src) so drawio_get/update can read the current diagram and
|
||||
* tests can stub the bytes.
|
||||
*/
|
||||
protected async fetchAttachmentText(src: string): Promise<string> {
|
||||
const { buffer } = await this.fetchInternalFile(src);
|
||||
return buffer.toString("utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a drawio node on a page by `attrs.id` or `#<index>` and return the
|
||||
* node plus its ref. Throws a clear error if the ref does not resolve to a
|
||||
* drawio node.
|
||||
*/
|
||||
private async resolveDrawioNode(
|
||||
pageId: string,
|
||||
node: string,
|
||||
): Promise<{ node: any; ref: string }> {
|
||||
const data = await this.getPageRaw(pageId);
|
||||
const hit = getNodeByRef(
|
||||
data.content ?? { type: "doc", content: [] },
|
||||
node,
|
||||
);
|
||||
if (!hit) {
|
||||
throw new Error(
|
||||
`drawio: no node found for "${node}" on page ${pageId} (use the drawio node's attrs.id or "#<index>" from get_outline)`,
|
||||
);
|
||||
}
|
||||
if (hit.type !== "drawio") {
|
||||
throw new Error(
|
||||
`drawio: node "${node}" on page ${pageId} is a ${hit.type}, not a drawio diagram`,
|
||||
);
|
||||
}
|
||||
return { node: hit.node, ref: node };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a drawio diagram as mxGraph XML (default) or as the raw `.drawio.svg`.
|
||||
* Runs the decode chain (base64/entity content= → drawio file → nested XML or
|
||||
* pako-inflated compressed <diagram>). The returned `hash` is the
|
||||
* optimistic-lock key for drawio_update.
|
||||
*/
|
||||
async drawioGet(
|
||||
pageId: string,
|
||||
node: string,
|
||||
format: "xml" | "svg" = "xml",
|
||||
): Promise<{
|
||||
pageId: string;
|
||||
nodeId: string;
|
||||
format: "xml" | "svg";
|
||||
content: string;
|
||||
meta: {
|
||||
attachmentId: string | null;
|
||||
title: string | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
cellCount: number;
|
||||
hash: string;
|
||||
};
|
||||
}> {
|
||||
await this.ensureAuthenticated();
|
||||
const { node: drawio } = await this.resolveDrawioNode(pageId, node);
|
||||
const attrs = drawio.attrs || {};
|
||||
const src = attrs.src;
|
||||
if (!src) {
|
||||
throw new Error(
|
||||
`drawio: node "${node}" on page ${pageId} has no src to read`,
|
||||
);
|
||||
}
|
||||
const svg = await this.fetchAttachmentText(src);
|
||||
const modelXml = decodeDrawioSvg(svg);
|
||||
const meta = {
|
||||
attachmentId: attrs.attachmentId ?? null,
|
||||
title: attrs.title ?? null,
|
||||
width: attrs.width != null ? Number(attrs.width) : null,
|
||||
height: attrs.height != null ? Number(attrs.height) : null,
|
||||
cellCount: countUserCells(modelXml),
|
||||
hash: mxHash(modelXml),
|
||||
};
|
||||
return {
|
||||
pageId,
|
||||
nodeId: attrs.id ?? node,
|
||||
format,
|
||||
content: format === "svg" ? svg : normalizeXml(modelXml),
|
||||
meta,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a drawio diagram from mxGraph XML: lint → schematic SVG preview
|
||||
* (pure TS) → build the `.drawio.svg` (createDrawioSvg contract) → create the
|
||||
* attachment → insert a `drawio` node before/after an anchor or appended.
|
||||
* `xml` is a bare `<mxGraphModel>` or a list of `<mxCell>` (the server wraps
|
||||
* it and adds the id=0/id=1 sentinels).
|
||||
*/
|
||||
async drawioCreate(
|
||||
pageId: string,
|
||||
where: {
|
||||
position: "before" | "after" | "append";
|
||||
anchorNodeId?: string;
|
||||
anchorText?: string;
|
||||
},
|
||||
xml: string,
|
||||
title?: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
nodeId: string;
|
||||
attachmentId: string;
|
||||
warnings: string[];
|
||||
verify?: any;
|
||||
}> {
|
||||
await this.ensureAuthenticated();
|
||||
if (
|
||||
!where ||
|
||||
(where.position !== "before" &&
|
||||
where.position !== "after" &&
|
||||
where.position !== "append")
|
||||
) {
|
||||
throw new Error(
|
||||
'drawio_create: `where.position` must be one of "before", "after", "append"',
|
||||
);
|
||||
}
|
||||
if (where.position === "before" || where.position === "after") {
|
||||
const hasId =
|
||||
typeof where.anchorNodeId === "string" && where.anchorNodeId.length > 0;
|
||||
const hasText =
|
||||
typeof where.anchorText === "string" && where.anchorText.length > 0;
|
||||
if (hasId === hasText) {
|
||||
throw new Error(
|
||||
`drawio_create: position "${where.position}" requires exactly one of anchorNodeId or anchorText`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-write pipeline (throws a structured DrawioLintError on any violation).
|
||||
const prepared = prepareModel(xml);
|
||||
const inner = renderDiagramShapes(prepared.cells, prepared.bbox);
|
||||
const diagramTitle = 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",
|
||||
);
|
||||
|
||||
// NOTE: no `id` attribute is set here. The vendored `drawio` node schema
|
||||
// (diagramAttributes) declares no `id`, so any block id would be silently
|
||||
// dropped by PMNode.fromJSON on save and the returned handle would fail to
|
||||
// resolve. The addressable handle is the node's "#<index>" (like image/table
|
||||
// nodes), computed after the insert below.
|
||||
const drawioNode: any = {
|
||||
type: "drawio",
|
||||
attrs: {
|
||||
src: `/api/files/${att.id}/${att.fileName}`,
|
||||
attachmentId: att.id,
|
||||
width: prepared.bbox.width,
|
||||
height: prepared.bbox.height,
|
||||
align: "center",
|
||||
},
|
||||
};
|
||||
if (title) drawioNode.attrs.title = title;
|
||||
// Reuse the existing URL trust boundary (rejects unsafe src schemes).
|
||||
this.validateDocUrls(drawioNode);
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
let inserted = false;
|
||||
let insertedIndex = -1;
|
||||
const mutation = await this.mutatePage(
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
inserted = false;
|
||||
insertedIndex = -1;
|
||||
const { doc: nd, inserted: ins } = insertNodeRelative(
|
||||
liveDoc,
|
||||
drawioNode,
|
||||
where,
|
||||
);
|
||||
inserted = ins;
|
||||
if (!inserted) return null; // anchor not found -> skip the write
|
||||
// Locate the freshly-inserted node to derive its "#<index>" handle. The
|
||||
// just-uploaded attachmentId is unique, so it identifies our node.
|
||||
if (Array.isArray(nd.content)) {
|
||||
insertedIndex = nd.content.findIndex(
|
||||
(b: any) =>
|
||||
b &&
|
||||
b.type === "drawio" &&
|
||||
b.attrs &&
|
||||
b.attrs.attachmentId === att.id,
|
||||
);
|
||||
}
|
||||
return nd;
|
||||
},
|
||||
);
|
||||
|
||||
if (!inserted) {
|
||||
const anchorDesc = where.anchorNodeId
|
||||
? `anchorNodeId "${where.anchorNodeId}"`
|
||||
: `anchorText "${where.anchorText}"`;
|
||||
throw new Error(
|
||||
`drawio_create: anchor not found (${anchorDesc}) on page ${pageId}. The diagram attachment ${att.id} is now an unreferenced orphan.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (insertedIndex < 0) {
|
||||
// The node was inserted nested (e.g. inside a callout/table cell via an
|
||||
// anchor), where "#<index>" — which addresses only top-level blocks —
|
||||
// cannot reference it. drawio nodes carry no persisted id, so there is no
|
||||
// stable handle for a nested diagram.
|
||||
throw new Error(
|
||||
`drawio_create: the diagram was inserted on page ${pageId} but not as a ` +
|
||||
`top-level block, so it has no addressable "#<index>" handle. Anchor ` +
|
||||
`on a top-level block (or append) so the diagram can be re-read.`,
|
||||
);
|
||||
}
|
||||
|
||||
// The returned handle is POSITIONAL ("#<index>"): valid for the immediate
|
||||
// create -> get/update flow, but re-resolve via get_outline if the document
|
||||
// structure changes (blocks added/removed before it shift the index).
|
||||
const nodeId = `#${insertedIndex}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
nodeId,
|
||||
attachmentId: att.id,
|
||||
warnings: prepared.warnings,
|
||||
verify: mutation.verify,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-replacement update of a drawio diagram. `baseHash` is MANDATORY: it is
|
||||
* compared against the hash of the diagram's CURRENT XML (from drawio_get);
|
||||
* any mismatch means a human or another agent edited the diagram after the
|
||||
* read, so the write is refused with a conflict error. On success the new
|
||||
* `.drawio.svg` is uploaded as a FRESH attachment (in-place byte overwrite is
|
||||
* avoided — some Docmost versions corrupt an attachment on overwrite, exactly
|
||||
* as replaceImage documents) and the node is repointed with new dimensions.
|
||||
*/
|
||||
async drawioUpdate(
|
||||
pageId: string,
|
||||
node: string,
|
||||
xml: string,
|
||||
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(
|
||||
"drawio_update: baseHash is mandatory — read the diagram with drawio_get first and pass back its meta.hash",
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve the node and read the CURRENT diagram to enforce the optimistic
|
||||
// lock before doing any write or upload.
|
||||
const { node: drawio, ref } = await this.resolveDrawioNode(pageId, node);
|
||||
const oldAttrs = drawio.attrs || {};
|
||||
const oldSrc = oldAttrs.src;
|
||||
// The returned handle is the caller-supplied reference. drawio nodes carry
|
||||
// no persisted id, so `ref` (an "#<index>" or a rare legacy attrs.id) is the
|
||||
// honest identifier to hand back.
|
||||
const nodeId = oldAttrs.id ?? ref;
|
||||
if (!oldSrc) {
|
||||
throw new Error(
|
||||
`drawio_update: node "${node}" on page ${pageId} has no src to compare against`,
|
||||
);
|
||||
}
|
||||
const currentSvg = await this.fetchAttachmentText(oldSrc);
|
||||
const currentHash = mxHash(decodeDrawioSvg(currentSvg));
|
||||
if (currentHash !== baseHash) {
|
||||
throw new Error(
|
||||
`drawio_update: conflict — the diagram changed since it was read ` +
|
||||
`(baseHash ${baseHash} != current ${currentHash}). Re-read it with drawio_get and retry.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Pipeline for the new content (throws a structured DrawioLintError).
|
||||
const prepared = prepareModel(xml);
|
||||
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 repoint = (n: any) => {
|
||||
n.attrs = {
|
||||
...n.attrs,
|
||||
src: newSrc,
|
||||
attachmentId: att.id,
|
||||
width: prepared.bbox.width,
|
||||
height: prepared.bbox.height,
|
||||
};
|
||||
repointed++;
|
||||
};
|
||||
|
||||
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 = [];
|
||||
// Repoint ONLY the resolved node — never every node that happens to
|
||||
// share this attachmentId (a copied diagram is two nodes with one
|
||||
// attachmentId; keying on it would clobber both). Re-resolve the same
|
||||
// handle against the live doc and walk to its exact position.
|
||||
const hit = getNodeByRef(doc, ref);
|
||||
if (!hit || hit.type !== "drawio") return null; // vanished/changed -> skip
|
||||
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;
|
||||
repoint(target);
|
||||
if (repointed === 0) return null; // node vanished concurrently -> skip
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
// --- Page history / diff / transform ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -47,7 +47,7 @@ const VERSION = packageJson.version;
|
||||
export const SERVER_INSTRUCTIONS =
|
||||
"Docmost editing guide — choose the tool by intent.\n" +
|
||||
"READ: find a page -> search (workspace-wide full-text); list -> list_pages / list_spaces. Locate blocks and their ids CHEAPLY -> get_outline (compact top-level map; start here, not get_page_json). One block's subtree -> get_node (by attrs.id, or \"#<index>\" for tables, which carry no id). Find every occurrence of a string/regex ON a page (and where each is) -> search_in_page, NOT block-by-block get_node — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> get_page (Markdown, lossy; inline <span data-comment-id> tags are comment anchors — markup, not text) or get_page_json (lossless ProseMirror with block ids). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stash_page (returns a short-lived anonymous URL).\n" +
|
||||
"EDIT: fix wording/typos/numbers -> edit_page_text (find/replace inside blocks, no node id needed). Change ONE block (paragraph/heading/callout/etc.) structurally -> patch_node (by attrs.id from get_outline). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Tables -> table_get / table_update_cell / table_insert_row / table_delete_row (address by \"#<index>\" from get_outline; table nodes have no attrs.id). Images -> insert_image (add from a web URL) / replace_image (swap an existing image). Footnotes -> insert_footnote. Bulk/structural rewrite -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmost_transform: 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" +
|
||||
"EDIT: fix wording/typos/numbers -> edit_page_text (find/replace inside blocks, no node id needed). Change ONE block (paragraph/heading/callout/etc.) structurally -> patch_node (by attrs.id from get_outline). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Tables -> table_get / table_update_cell / table_insert_row / table_delete_row (address by \"#<index>\" from get_outline; table nodes have no attrs.id). Images -> insert_image (add from a web URL) / replace_image (swap an existing image). Draw.io diagrams -> drawio_create (create from mxGraph XML and insert), drawio_get (read a diagram as mxGraph XML + a hash), drawio_update (replace a diagram; pass the hash from drawio_get as baseHash for optimistic locking). Footnotes -> insert_footnote. Bulk/structural rewrite -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmost_transform: 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 -> create_page (Markdown). Rename (title only) -> rename_page. Move -> move_page. Delete -> delete_page (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) -> copy_page_content. Sharing -> share_page / unshare_page / list_shares; share_page makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
|
||||
"COMMENTS: create_comment 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 -> create_comment 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 -> list_comments, update_comment, resolve_comment (resolve/reopen, reversible — prefer over delete to close), delete_comment, check_new_comments.\n" +
|
||||
"HISTORY: review what changed -> diff_page_versions (a historyId vs current, or two versions). List saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown.";
|
||||
@@ -460,6 +460,38 @@ registerShared(
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: drawio_get — read a draw.io diagram as mxGraph XML (or the raw SVG).
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.drawioGet,
|
||||
async ({ pageId, node, format }) => {
|
||||
const result = await docmostClient.drawioGet(pageId, node, format ?? "xml");
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: drawio_create — lint mxGraph XML, build the .drawio.svg, insert a node.
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.drawioCreate,
|
||||
async ({ pageId, xml, position, anchorNodeId, anchorText, title }) => {
|
||||
const result = await docmostClient.drawioCreate(
|
||||
pageId,
|
||||
{ position, anchorNodeId, anchorText },
|
||||
xml,
|
||||
title,
|
||||
);
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: drawio_update — optimistic-locked full replacement of a diagram.
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.drawioUpdate,
|
||||
async ({ pageId, node, xml, baseHash }) => {
|
||||
const result = await docmostClient.drawioUpdate(pageId, node, xml, baseHash);
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Tool: share_page
|
||||
// Schema + description now live in the shared registry (#294). The execute body
|
||||
// keeps this transport's own `searchIndexing ?? true` default.
|
||||
|
||||
@@ -17,23 +17,8 @@
|
||||
* comparing and match across maximal runs of consecutive text nodes within a
|
||||
* single block, while mapping every normalized character back to its raw index
|
||||
* so the mark lands on the exact original characters.
|
||||
*
|
||||
* MARKDOWN-STRIP FALLBACK: when the agent copies a selection that still carries
|
||||
* inline markdown (`**bold**`, `` `code` ``, `[t](u)`), the raw locator will not
|
||||
* match the document's plain text. Exactly like edit_page_text's json-edit
|
||||
* fallback, we first try the verbatim selection and, ONLY if it anchors nowhere
|
||||
* in the whole document, retry with `stripInlineMarkdown` applied. `canAnchorInDoc`,
|
||||
* `getAnchoredText` and `applyAnchorInDoc` share this decision via
|
||||
* `resolveAnchorSelection`. `countAnchorMatches` keeps its OWN parallel exact-wins
|
||||
* implementation (it needs a raw match COUNT, not a single resolved locator), kept
|
||||
* deliberately in sync with `resolveAnchorSelection`: raw match ⇒ use raw, else fall
|
||||
* back to the stripped count. All four therefore agree on which locator matched —
|
||||
* the suggestion-uniqueness gate depends on count and can/get never disagreeing, so
|
||||
* these two exact-wins implementations MUST stay in sync if either is changed.
|
||||
*/
|
||||
|
||||
import { stripInlineMarkdown } from "./text-normalize.js";
|
||||
|
||||
/** Typographic double-quote variants mapped to ASCII `"`. */
|
||||
const DOUBLE_QUOTES = "«»„“”‟〝〞"";
|
||||
/** Typographic single-quote/apostrophe variants mapped to ASCII `'`. */
|
||||
@@ -229,17 +214,15 @@ function reconstructRawText(blockContent: any[], match: AnchorMatch): string {
|
||||
* un-appliable (spurious 409).
|
||||
*/
|
||||
export function getAnchoredText(doc: any, selection: string): string | null {
|
||||
const { selection: effective, found } = resolveAnchorSelection(doc, selection);
|
||||
if (!found) return null;
|
||||
const visit = (node: any, depth: number): string | null => {
|
||||
if (depth > MAX_DEPTH || !node || typeof node !== "object") return null;
|
||||
if (!Array.isArray(node.content)) return null;
|
||||
const match = findAnchorInBlock(node.content, effective);
|
||||
const match = findAnchorInBlock(node.content, selection);
|
||||
if (match) return reconstructRawText(node.content, match);
|
||||
for (const child of node.content) {
|
||||
if (child && typeof child === "object" && Array.isArray(child.content)) {
|
||||
const foundText = visit(child, depth + 1);
|
||||
if (foundText !== null) return foundText;
|
||||
const found = visit(child, depth + 1);
|
||||
if (found !== null) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -248,11 +231,12 @@ export function getAnchoredText(doc: any, selection: string): string | null {
|
||||
}
|
||||
|
||||
/**
|
||||
* RAW (no markdown-strip fallback) depth-first check that `selection` anchors
|
||||
* somewhere in `doc`. This is the primitive `resolveAnchorSelection` builds on;
|
||||
* public callers should use `canAnchorInDoc`, which adds the strip fallback.
|
||||
* Depth-first, document-order check for whether `selection` can be anchored
|
||||
* anywhere in `doc`. At each node with an array `content`, first try to match
|
||||
* within that node's own content, then recurse into children that themselves
|
||||
* have a `content` array.
|
||||
*/
|
||||
function rawCanAnchorInDoc(doc: any, selection: string): boolean {
|
||||
export function canAnchorInDoc(doc: any, selection: string): boolean {
|
||||
const visit = (node: any, depth: number): boolean => {
|
||||
if (depth > MAX_DEPTH || !node || typeof node !== "object") return false;
|
||||
if (!Array.isArray(node.content)) return false;
|
||||
@@ -267,43 +251,6 @@ function rawCanAnchorInDoc(doc: any, selection: string): boolean {
|
||||
return visit(doc, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide the locator that ACTUALLY anchors `selection` in `doc`, applying the
|
||||
* markdown-strip fallback once (so every public entry point agrees):
|
||||
* - EXACT WINS: if the verbatim selection anchors anywhere, use it as-is.
|
||||
* - FALLBACK: only if the verbatim selection anchors nowhere, and the
|
||||
* markdown-stripped form differs and DOES anchor, use the stripped form and
|
||||
* flag `normalized` so callers can surface a soft warning.
|
||||
* - otherwise `found` is false and `selection` is returned unchanged.
|
||||
*
|
||||
* The stripped form is used ONLY to LOCATE the anchor; getAnchoredText still
|
||||
* reconstructs and stores the RAW document substring, so the strip never leaks
|
||||
* into what gets persisted.
|
||||
*/
|
||||
export function resolveAnchorSelection(
|
||||
doc: any,
|
||||
selection: string,
|
||||
): { selection: string; found: boolean; normalized: boolean } {
|
||||
if (rawCanAnchorInDoc(doc, selection)) {
|
||||
return { selection, found: true, normalized: false };
|
||||
}
|
||||
const stripped = stripInlineMarkdown(selection);
|
||||
if (stripped !== selection && rawCanAnchorInDoc(doc, stripped)) {
|
||||
return { selection: stripped, found: true, normalized: true };
|
||||
}
|
||||
return { selection, found: false, normalized: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Depth-first, document-order check for whether `selection` can be anchored
|
||||
* anywhere in `doc` (with the markdown-strip fallback). At each node with an
|
||||
* array `content`, first try to match within that node's own content, then
|
||||
* recurse into children that themselves have a `content` array.
|
||||
*/
|
||||
export function canAnchorInDoc(doc: any, selection: string): boolean {
|
||||
return resolveAnchorSelection(doc, selection).found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the matched text nodes and splice the comment mark across the range.
|
||||
* `blockContent` is mutated IN PLACE. `match.startChild..endChild` are all text
|
||||
@@ -368,7 +315,7 @@ function spliceCommentMark(
|
||||
* not use this. (Note: counts OCCURRENCES, not just matching blocks, so two
|
||||
* occurrences inside one block are correctly reported as 2.)
|
||||
*/
|
||||
function rawCountAnchorMatches(doc: any, selection: string): number {
|
||||
export function countAnchorMatches(doc: any, selection: string): number {
|
||||
const normSel = normalizeForMatch(selection).norm.trim();
|
||||
if (normSel.length === 0) return 0;
|
||||
|
||||
@@ -422,25 +369,6 @@ function rawCountAnchorMatches(doc: any, selection: string): number {
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uniqueness gate for suggestions, with the SAME markdown-strip fallback as the
|
||||
* other entry points so count never disagrees with can/get/apply. EXACT WINS: if
|
||||
* the verbatim selection occurs at all, return its raw occurrence count (so a
|
||||
* selection that is unique raw stays unique — the fallback never runs and cannot
|
||||
* introduce a spurious second match). Only when the verbatim selection is absent
|
||||
* do we count occurrences of the markdown-stripped form.
|
||||
*/
|
||||
export function countAnchorMatches(doc: any, selection: string): number {
|
||||
const raw = rawCountAnchorMatches(doc, selection);
|
||||
if (raw > 0) return raw;
|
||||
const stripped = stripInlineMarkdown(selection);
|
||||
if (stripped !== selection) {
|
||||
const strippedCount = rawCountAnchorMatches(doc, stripped);
|
||||
if (strippedCount > 0) return strippedCount;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Depth-first (same order as canAnchorInDoc) over `doc`; on the FIRST block
|
||||
* whose content matches `selection`, splice the comment mark across the matched
|
||||
@@ -452,12 +380,10 @@ export function applyAnchorInDoc(
|
||||
selection: string,
|
||||
commentId: string,
|
||||
): boolean {
|
||||
const { selection: effective, found } = resolveAnchorSelection(doc, selection);
|
||||
if (!found) return false;
|
||||
const visit = (node: any, depth: number): boolean => {
|
||||
if (depth > MAX_DEPTH || !node || typeof node !== "object") return false;
|
||||
if (!Array.isArray(node.content)) return false;
|
||||
const match = findAnchorInBlock(node.content, effective);
|
||||
const match = findAnchorInBlock(node.content, selection);
|
||||
if (match) {
|
||||
spliceCommentMark(node.content, match, commentId);
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
// Pure-TS schematic SVG preview for draw.io diagrams (issue #423, stage 1).
|
||||
//
|
||||
// HARD CONSTRAINT: no backend rendering. This is a dependency-free string
|
||||
// builder — given the parsed mxGraph cells it draws a rough schematic (rects,
|
||||
// ellipses, diamonds, edges + labels) that stands in as the diagram's visible
|
||||
// image UNTIL a human first opens it in the draw.io editor and saves, at which
|
||||
// point the client replaces this with the pixel-perfect export SVG. It is
|
||||
// deliberately approximate: it exists so a freshly-agent-created diagram is not
|
||||
// an empty box in the page.
|
||||
|
||||
import type { DrawioCell, DrawioBBox } from "./drawio-xml.js";
|
||||
import { absolutePos } from "./drawio-xml.js";
|
||||
|
||||
function esc(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML markup from a cell value (draw.io labels are HTML when html=1),
|
||||
* decode the handful of entities we care about, and collapse whitespace so the
|
||||
* label fits on the schematic. `<br>` becomes a space (this is a one-line
|
||||
* preview label, not a faithful multi-line render).
|
||||
*/
|
||||
function labelText(value: string): string {
|
||||
return value
|
||||
.replace(/<br\s*\/?>/gi, " ")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'|'/g, "'")
|
||||
.replace(/
| /gi, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function centeredLabel(cx: number, cy: number, value: string, color = "#000000"): string {
|
||||
const text = labelText(value);
|
||||
if (!text) return "";
|
||||
return (
|
||||
`<text x="${round(cx)}" y="${round(cy)}" ` +
|
||||
`font-family="Helvetica, Arial, sans-serif" font-size="12" ` +
|
||||
`text-anchor="middle" dominant-baseline="middle" fill="${esc(color)}">` +
|
||||
`${esc(text)}</text>`
|
||||
);
|
||||
}
|
||||
|
||||
function round(n: number): number {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
interface ShapeKind {
|
||||
kind: "ellipse" | "rhombus" | "triangle" | "rect";
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which schematic primitive to draw for a vertex. A shape can be named
|
||||
* either as the style's base token (e.g. "ellipse;…") or as a key (e.g.
|
||||
* "shape=rhombus" / "ellipse=1"), so both the base style and the map are
|
||||
* checked.
|
||||
*/
|
||||
function shapeKind(
|
||||
styleMap: Record<string, string>,
|
||||
baseStyle?: string,
|
||||
): ShapeKind {
|
||||
const shape = styleMap.shape ?? baseStyle;
|
||||
const has = (name: string) => shape === name || styleMap[name] != null;
|
||||
if (has("ellipse")) return { kind: "ellipse" };
|
||||
if (has("rhombus")) return { kind: "rhombus" };
|
||||
if (has("triangle")) return { kind: "triangle" };
|
||||
// Everything else — including unknown stencils (shape=mxgraph.*), swimlanes,
|
||||
// and plain boxes — is drawn as a (rounded) rectangle.
|
||||
return { kind: "rect" };
|
||||
}
|
||||
|
||||
function fill(styleMap: Record<string, string>): string {
|
||||
const c = styleMap.fillColor;
|
||||
if (!c || c.toLowerCase() === "none") return "#ffffff";
|
||||
return c;
|
||||
}
|
||||
|
||||
function stroke(styleMap: Record<string, string>): string {
|
||||
const c = styleMap.strokeColor;
|
||||
if (!c || c.toLowerCase() === "none") return "#000000";
|
||||
return c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the schematic shapes as the INNER content of the `.drawio.svg` (the
|
||||
* outer <svg> wrapper is added by drawio-xml.buildDrawioSvg). Coordinates are
|
||||
* absolute (container children are resolved via the parent chain).
|
||||
*/
|
||||
export function renderDiagramShapes(cells: DrawioCell[], _bbox: DrawioBBox): string {
|
||||
const byId = new Map(cells.map((c) => [c.id, c]));
|
||||
const parts: string[] = [];
|
||||
|
||||
// Edges first so vertices sit on top of their connectors.
|
||||
for (const c of cells) {
|
||||
if (!c.edge) continue;
|
||||
parts.push(renderEdge(c, byId));
|
||||
}
|
||||
|
||||
for (const c of cells) {
|
||||
if (!c.vertex || !c.geometry.hasGeometry) continue;
|
||||
const g = c.geometry;
|
||||
if (g.width == null || g.height == null) continue;
|
||||
const { x, y } = absolutePos(c, byId);
|
||||
parts.push(renderVertex(c, x, y, g.width, g.height));
|
||||
}
|
||||
|
||||
return `<g>${parts.filter(Boolean).join("")}</g>`;
|
||||
}
|
||||
|
||||
function renderVertex(
|
||||
c: DrawioCell,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
): string {
|
||||
const f = esc(fill(c.styleMap));
|
||||
const s = esc(stroke(c.styleMap));
|
||||
const { kind } = shapeKind(c.styleMap, c.baseStyle);
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
let shape = "";
|
||||
switch (kind) {
|
||||
case "ellipse":
|
||||
shape =
|
||||
`<ellipse cx="${round(cx)}" cy="${round(cy)}" rx="${round(w / 2)}" ` +
|
||||
`ry="${round(h / 2)}" fill="${f}" stroke="${s}"/>`;
|
||||
break;
|
||||
case "rhombus": {
|
||||
const pts = [
|
||||
`${round(cx)},${round(y)}`,
|
||||
`${round(x + w)},${round(cy)}`,
|
||||
`${round(cx)},${round(y + h)}`,
|
||||
`${round(x)},${round(cy)}`,
|
||||
].join(" ");
|
||||
shape = `<polygon points="${pts}" fill="${f}" stroke="${s}"/>`;
|
||||
break;
|
||||
}
|
||||
case "triangle": {
|
||||
const pts = [
|
||||
`${round(x)},${round(y)}`,
|
||||
`${round(x + w)},${round(cy)}`,
|
||||
`${round(x)},${round(y + h)}`,
|
||||
].join(" ");
|
||||
shape = `<polygon points="${pts}" fill="${f}" stroke="${s}"/>`;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
const rounded = c.styleMap.rounded === "1";
|
||||
const rx = rounded ? Math.min(12, w / 2, h / 2) : 0;
|
||||
shape =
|
||||
`<rect x="${round(x)}" y="${round(y)}" width="${round(w)}" ` +
|
||||
`height="${round(h)}" rx="${round(rx)}" ry="${round(rx)}" ` +
|
||||
`fill="${f}" stroke="${s}"/>`;
|
||||
}
|
||||
}
|
||||
return shape + centeredLabel(cx, cy, c.value, c.styleMap.fontColor || "#000000");
|
||||
}
|
||||
|
||||
function renderEdge(c: DrawioCell, byId: Map<string, DrawioCell>): string {
|
||||
const src = c.source != null ? byId.get(c.source) : undefined;
|
||||
const tgt = c.target != null ? byId.get(c.target) : undefined;
|
||||
const p1 = anchorPoint(src, byId);
|
||||
const p2 = anchorPoint(tgt, byId);
|
||||
if (!p1 || !p2) return ""; // a floating endpoint with no fixed point: skip
|
||||
const line =
|
||||
`<line x1="${round(p1.x)}" y1="${round(p1.y)}" ` +
|
||||
`x2="${round(p2.x)}" y2="${round(p2.y)}" ` +
|
||||
`stroke="#000000" stroke-width="1"/>`;
|
||||
const mid = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
|
||||
return line + centeredLabel(mid.x, mid.y, c.value);
|
||||
}
|
||||
|
||||
/** Center point of a vertex used as an edge anchor (approximate). */
|
||||
function anchorPoint(
|
||||
cell: DrawioCell | undefined,
|
||||
byId: Map<string, DrawioCell>,
|
||||
): { x: number; y: number } | null {
|
||||
if (!cell || !cell.geometry.hasGeometry) return null;
|
||||
const g = cell.geometry;
|
||||
if (g.width == null || g.height == null) return null;
|
||||
const { x, y } = absolutePos(cell, byId);
|
||||
return { x: x + g.width / 2, y: y + g.height / 2 };
|
||||
}
|
||||
@@ -0,0 +1,771 @@
|
||||
// draw.io (mxGraph) XML support for the MCP drawio tools (issue #423, stage 1).
|
||||
//
|
||||
// This module owns everything that is pure data-plumbing for draw.io diagrams:
|
||||
// - the DECODE CHAIN that turns a stored `diagram.drawio.svg` attachment back
|
||||
// into mxGraph XML (handles both the plain nested-XML form Docmost writes
|
||||
// and draw.io's own COMPRESSED `<diagram>` payload — base64 + raw-deflate);
|
||||
// - the ENCODE side that wraps mxGraph XML into the `.drawio.svg` attachment
|
||||
// using the exact same contract as the import service's createDrawioSvg;
|
||||
// - a deterministic LINTER that rejects the structural mistakes generators
|
||||
// make before anything is written (each violation carries the offending
|
||||
// cellId + position so the model can auto-retry);
|
||||
// - a stable HASH over the normalized XML, used as the optimistic-lock key.
|
||||
//
|
||||
// HARD CONSTRAINT: no backend rendering. Nothing here shells out or renders a
|
||||
// bitmap; the only runtime dependencies are jsdom (already used across this
|
||||
// package for XML parsing) and pako (raw-inflate for the compressed format).
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { JSDOM } from "jsdom";
|
||||
import pako from "pako";
|
||||
|
||||
// --- shared XML parser -----------------------------------------------------
|
||||
|
||||
// A single reusable JSDOM window; constructing one per parse is wasteful and
|
||||
// these tools are low-frequency. Only the DOMParser is used.
|
||||
let _window: any = null;
|
||||
function xmlWindow(): any {
|
||||
if (!_window) _window = new JSDOM("").window;
|
||||
return _window;
|
||||
}
|
||||
|
||||
/** Default mxGraphModel attributes used when the server wraps a cell list. */
|
||||
const DEFAULT_MODEL_ATTRS =
|
||||
'dx="0" dy="0" grid="1" gridSize="10" page="1" pageWidth="850" pageHeight="1100"';
|
||||
|
||||
// --- structured lint errors ------------------------------------------------
|
||||
|
||||
export interface DrawioLintIssue {
|
||||
/** Machine-readable rule id, e.g. "edge-geometry". */
|
||||
rule: string;
|
||||
/** Human-readable explanation the model can act on. */
|
||||
message: string;
|
||||
/** The offending cell's id, when the rule is cell-scoped. */
|
||||
cellId?: string;
|
||||
/** Extra location info: cell index in <root>, or a parser line:col. */
|
||||
position?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by the linter and by decode/prepare when the input is unusable. Carries
|
||||
* the full list of issues so the caller can surface a structured tool-error the
|
||||
* model auto-retries against.
|
||||
*/
|
||||
export class DrawioLintError extends Error {
|
||||
issues: DrawioLintIssue[];
|
||||
constructor(issues: DrawioLintIssue[]) {
|
||||
const summary = issues
|
||||
.map((i) => {
|
||||
const where = [
|
||||
i.cellId != null ? `cellId=${i.cellId}` : null,
|
||||
i.position != null ? `at ${i.position}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
return `[${i.rule}] ${i.message}${where ? ` (${where})` : ""}`;
|
||||
})
|
||||
.join("; ");
|
||||
super(`drawio lint failed: ${summary}`);
|
||||
this.name = "DrawioLintError";
|
||||
this.issues = issues;
|
||||
}
|
||||
}
|
||||
|
||||
// --- parsed-cell model -----------------------------------------------------
|
||||
|
||||
export interface DrawioGeometry {
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
relative: boolean;
|
||||
hasGeometry: boolean;
|
||||
}
|
||||
|
||||
export interface DrawioCell {
|
||||
id: string;
|
||||
parent?: string;
|
||||
source?: string;
|
||||
target?: string;
|
||||
vertex: boolean;
|
||||
edge: boolean;
|
||||
value: string;
|
||||
style: string;
|
||||
styleMap: Record<string, string>;
|
||||
/** Non-key/value leading token of the style (a base stylename), if any. */
|
||||
baseStyle?: string;
|
||||
geometry: DrawioGeometry;
|
||||
}
|
||||
|
||||
export interface DrawioBBox {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
// --- style parsing ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a draw.io style string into { baseStyle, map }. Grammar:
|
||||
* [stylename;]key=value;key=value;...
|
||||
* A single leading token without '=' is the base stylename (e.g. "text" or
|
||||
* "ellipse"). Every other non-empty segment must be exactly one key=value pair.
|
||||
* Returns `null` (the segment index) on the first malformed segment so the
|
||||
* linter can report a precise error.
|
||||
*/
|
||||
export function parseStyle(
|
||||
style: string,
|
||||
): { baseStyle?: string; map: Record<string, string>; badSegment?: string } {
|
||||
const map: Record<string, string> = {};
|
||||
let baseStyle: string | undefined;
|
||||
const segments = style.split(";");
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const seg = segments[i].trim();
|
||||
if (seg === "") continue; // trailing/empty segments are fine
|
||||
const eq = seg.indexOf("=");
|
||||
if (eq === -1) {
|
||||
// A bare token is only valid as the FIRST meaningful segment (base style).
|
||||
if (baseStyle === undefined && Object.keys(map).length === 0) {
|
||||
baseStyle = seg;
|
||||
continue;
|
||||
}
|
||||
return { baseStyle, map, badSegment: seg };
|
||||
}
|
||||
// A second '=' inside the same segment is malformed.
|
||||
if (seg.indexOf("=", eq + 1) !== -1) {
|
||||
return { baseStyle, map, badSegment: seg };
|
||||
}
|
||||
const key = seg.slice(0, eq).trim();
|
||||
const val = seg.slice(eq + 1).trim();
|
||||
if (key === "") return { baseStyle, map, badSegment: seg };
|
||||
map[key] = val;
|
||||
}
|
||||
return { baseStyle, map };
|
||||
}
|
||||
|
||||
// --- low-level XML helpers -------------------------------------------------
|
||||
|
||||
function parseXml(xml: string): { doc: any; error: string | null } {
|
||||
const parser = new (xmlWindow().DOMParser)();
|
||||
const doc = parser.parseFromString(xml, "application/xml");
|
||||
const err = doc.getElementsByTagName("parsererror");
|
||||
if (err.length > 0) {
|
||||
// jsdom prefixes the message with "line:col:" — keep it as the position.
|
||||
return { doc, error: (err[0].textContent || "malformed XML").trim() };
|
||||
}
|
||||
return { doc, error: null };
|
||||
}
|
||||
|
||||
function num(v: string | null): number | undefined {
|
||||
if (v == null || v === "") return undefined;
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
/** Extract the raw `<mxGraphModel …>…</mxGraphModel>` substring, or null. */
|
||||
function sliceModel(xml: string): string | null {
|
||||
const open = xml.indexOf("<mxGraphModel");
|
||||
if (open === -1) return null;
|
||||
const close = xml.indexOf("</mxGraphModel>", open);
|
||||
if (close === -1) {
|
||||
// Self-closed empty model, e.g. `<mxGraphModel .../>`.
|
||||
const selfClose = xml.indexOf("/>", open);
|
||||
if (selfClose !== -1) return xml.slice(open, selfClose + 2);
|
||||
return null;
|
||||
}
|
||||
return xml.slice(open, close + "</mxGraphModel>".length);
|
||||
}
|
||||
|
||||
// --- decode chain ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Read the `content=` attribute out of a `.drawio.svg` string. Docmost stores a
|
||||
* base64 payload there (createDrawioSvg); draw.io's own SVG export may store the
|
||||
* XML entity-encoded instead. The DOM decodes entities for us, so the caller
|
||||
* only has to distinguish "starts with '<'" (raw XML) from base64.
|
||||
*/
|
||||
export function extractContentAttr(svg: string): string {
|
||||
const { doc, error } = parseXml(svg);
|
||||
if (!error) {
|
||||
const root = doc.documentElement;
|
||||
if (root && root.hasAttribute && root.hasAttribute("content")) {
|
||||
return root.getAttribute("content") || "";
|
||||
}
|
||||
}
|
||||
// Fallback for a malformed wrapper: pull the attribute directly. The content
|
||||
// value itself never contains a double-quote (base64 / entity-encoded XML).
|
||||
const m = /content="([^"]*)"/.exec(svg);
|
||||
if (m) {
|
||||
// Decode the handful of XML entities a raw regex would leave encoded.
|
||||
return m[1]
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, "&");
|
||||
}
|
||||
throw new Error("drawio: SVG has no content= attribute to decode");
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a decoded draw.io file (`<mxfile>` or a bare `<mxGraphModel>`, possibly
|
||||
* with a COMPRESSED `<diagram>` payload) into the mxGraphModel XML. For the
|
||||
* plain form the raw substring is returned verbatim so a round-trip stays
|
||||
* byte-stable; the compressed form is inflated (base64 → raw-deflate →
|
||||
* decodeURIComponent), which is how draw.io stores diagrams by default.
|
||||
*/
|
||||
export function decodeDrawioFileToModel(fileXml: string): string {
|
||||
// Plain, nested XML: return the model substring untouched (byte-stable).
|
||||
const sliced = sliceModel(fileXml);
|
||||
if (sliced) return sliced;
|
||||
|
||||
// Otherwise it must be the compressed `<diagram>…</diagram>` text payload.
|
||||
const open = fileXml.indexOf("<diagram");
|
||||
if (open !== -1) {
|
||||
const gt = fileXml.indexOf(">", open);
|
||||
const close = fileXml.indexOf("</diagram>", gt);
|
||||
if (gt !== -1 && close !== -1) {
|
||||
const payload = fileXml.slice(gt + 1, close).trim();
|
||||
if (payload) {
|
||||
const inflated = inflateDiagramPayload(payload);
|
||||
const model = sliceModel(inflated);
|
||||
if (model) return model;
|
||||
return inflated;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
"drawio: could not decode file — no <mxGraphModel> and no compressed <diagram> payload",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Upper bound on the inflated size of a compressed `<diagram>` payload
|
||||
* (decompression-bomb guard). `fetchInternalFile` caps the DOWNLOAD at 64 MiB,
|
||||
* but a tiny crafted compressed payload can inflate to gigabytes and OOM the
|
||||
* process. A real diagram's mxGraphModel XML is small (KBs to low MBs even for
|
||||
* large diagrams), so 16 MiB is far above any legitimate payload while keeping
|
||||
* memory bounded. Chars ~= bytes for the (mostly ASCII) URI-encoded XML.
|
||||
*/
|
||||
export const MAX_INFLATED_DIAGRAM_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Inflate draw.io's compressed diagram payload:
|
||||
* base64-decode → raw-inflate (raw deflate, windowBits -15) →
|
||||
* decodeURIComponent.
|
||||
*
|
||||
* Uses pako's streaming Inflate so we can abort as soon as the decompressed
|
||||
* output exceeds MAX_INFLATED_DIAGRAM_BYTES — the full bomb is never
|
||||
* materialised in memory.
|
||||
*/
|
||||
export function inflateDiagramPayload(base64: string): string {
|
||||
const bytes = Buffer.from(base64, "base64");
|
||||
const inflator = new pako.Inflate({ raw: true, to: "string" });
|
||||
let total = 0;
|
||||
const passthrough = inflator.onData.bind(inflator);
|
||||
inflator.onData = (chunk: string | Uint8Array) => {
|
||||
total += chunk.length;
|
||||
if (total > MAX_INFLATED_DIAGRAM_BYTES) {
|
||||
// Throwing here propagates out of push(), aborting inflation immediately.
|
||||
throw new Error(
|
||||
`drawio: refusing to decode diagram — decompressed size exceeds ` +
|
||||
`${MAX_INFLATED_DIAGRAM_BYTES} bytes (possible decompression bomb)`,
|
||||
);
|
||||
}
|
||||
passthrough(chunk);
|
||||
};
|
||||
inflator.push(bytes, true);
|
||||
if (inflator.err) {
|
||||
throw new Error(
|
||||
`drawio: failed to inflate compressed <diagram> payload (${inflator.msg || inflator.err})`,
|
||||
);
|
||||
}
|
||||
const uriEncoded = inflator.result as string;
|
||||
return decodeURIComponent(uriEncoded);
|
||||
}
|
||||
|
||||
/** Full decode chain: `.drawio.svg` string → mxGraphModel XML. */
|
||||
export function decodeDrawioSvg(svg: string): string {
|
||||
const content = extractContentAttr(svg).trim();
|
||||
const fileXml = content.startsWith("<")
|
||||
? content
|
||||
: Buffer.from(content, "base64").toString("utf-8");
|
||||
return decodeDrawioFileToModel(fileXml);
|
||||
}
|
||||
|
||||
// --- encode side -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wrap an mxGraphModel in the plain (uncompressed) `<mxfile><diagram>` envelope.
|
||||
* draw.io opens uncompressed XML fine, and staying uncompressed keeps the
|
||||
* write path deterministic and the round-trip byte-stable.
|
||||
*/
|
||||
export function encodeDrawioFile(modelXml: string, title = "Page-1"): string {
|
||||
const safeTitle = xmlEscape(title);
|
||||
return `<mxfile host="drawio"><diagram id="page-1" name="${safeTitle}">${modelXml}</diagram></mxfile>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `diagram.drawio.svg` attachment. Mirrors the import service's
|
||||
* createDrawioSvg contract exactly:
|
||||
* <svg xmlns=… xmlns:xlink=… content="${base64(drawioFile)}">${inner}</svg>
|
||||
* plus width/height/viewBox from the diagram bounding box and the schematic
|
||||
* preview as the visible children (`inner`).
|
||||
*/
|
||||
export function buildDrawioSvg(
|
||||
modelXml: string,
|
||||
inner: string,
|
||||
bbox: DrawioBBox,
|
||||
title = "Page-1",
|
||||
): string {
|
||||
const file = encodeDrawioFile(modelXml, title);
|
||||
const base64 = Buffer.from(file, "utf-8").toString("base64");
|
||||
const w = Math.max(1, Math.round(bbox.width));
|
||||
const h = Math.max(1, Math.round(bbox.height));
|
||||
return (
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" ` +
|
||||
`xmlns:xlink="http://www.w3.org/1999/xlink" ` +
|
||||
`width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" ` +
|
||||
`content="${base64}">${inner}</svg>`
|
||||
);
|
||||
}
|
||||
|
||||
function xmlEscape(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
// --- normalization + hash --------------------------------------------------
|
||||
|
||||
/**
|
||||
* Normalize mxGraph XML for hashing / stable comparison: drop the whitespace
|
||||
* between tags and trim. This is intentionally conservative — it never reorders
|
||||
* attributes or cells (that would be lossy) — so two documents hash equal iff
|
||||
* they differ only in inter-tag formatting.
|
||||
*/
|
||||
export function normalizeXml(xml: string): string {
|
||||
return xml.replace(/>\s+</g, "><").trim();
|
||||
}
|
||||
|
||||
/** Stable optimistic-lock hash over the normalized model XML (sha256, hex). */
|
||||
export function mxHash(modelXml: string): string {
|
||||
return createHash("sha256").update(normalizeXml(modelXml), "utf-8").digest("hex");
|
||||
}
|
||||
|
||||
// --- cell parsing ----------------------------------------------------------
|
||||
|
||||
/** Parse every `<mxCell>` in a model into a structured DrawioCell list. */
|
||||
export function parseCells(modelXml: string): DrawioCell[] {
|
||||
const { doc, error } = parseXml(modelXml);
|
||||
if (error) {
|
||||
throw new DrawioLintError([
|
||||
{ rule: "well-formed-xml", message: error, position: firstLineCol(error) },
|
||||
]);
|
||||
}
|
||||
const cells: DrawioCell[] = [];
|
||||
const els = doc.getElementsByTagName("mxCell");
|
||||
for (let i = 0; i < els.length; i++) {
|
||||
cells.push(readCell(els[i]));
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function readCell(el: any): DrawioCell {
|
||||
const style = el.getAttribute("style") || "";
|
||||
const parsed = parseStyle(style);
|
||||
const geoEl = firstChildByTag(el, "mxGeometry");
|
||||
const geometry: DrawioGeometry = geoEl
|
||||
? {
|
||||
x: num(geoEl.getAttribute("x")),
|
||||
y: num(geoEl.getAttribute("y")),
|
||||
width: num(geoEl.getAttribute("width")),
|
||||
height: num(geoEl.getAttribute("height")),
|
||||
relative: geoEl.getAttribute("relative") === "1",
|
||||
hasGeometry: true,
|
||||
}
|
||||
: { relative: false, hasGeometry: false };
|
||||
return {
|
||||
id: el.getAttribute("id") ?? "",
|
||||
parent: el.getAttribute("parent") ?? undefined,
|
||||
source: el.getAttribute("source") ?? undefined,
|
||||
target: el.getAttribute("target") ?? undefined,
|
||||
vertex: el.getAttribute("vertex") === "1",
|
||||
edge: el.getAttribute("edge") === "1",
|
||||
value: el.getAttribute("value") ?? "",
|
||||
style,
|
||||
styleMap: parsed.map,
|
||||
baseStyle: parsed.baseStyle,
|
||||
geometry,
|
||||
};
|
||||
}
|
||||
|
||||
function firstChildByTag(el: any, tag: string): any {
|
||||
for (let i = 0; i < el.childNodes.length; i++) {
|
||||
const c = el.childNodes[i];
|
||||
if (c.nodeType === 1 && c.tagName === tag) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function firstLineCol(msg: string): string | undefined {
|
||||
const m = /^(\d+:\d+)/.exec(msg);
|
||||
return m ? m[1] : undefined;
|
||||
}
|
||||
|
||||
// --- bounding box ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Absolute bounding box of the diagram from its vertex geometries. Container
|
||||
* children are relative, so absolute positions are resolved along the parent
|
||||
* chain before taking the extent. Falls back to a default canvas when empty.
|
||||
*/
|
||||
export function computeBBox(cells: DrawioCell[]): DrawioBBox {
|
||||
const byId = new Map(cells.map((c) => [c.id, c]));
|
||||
let maxX = 0;
|
||||
let maxY = 0;
|
||||
let any = false;
|
||||
for (const c of cells) {
|
||||
if (!c.vertex || !c.geometry.hasGeometry) continue;
|
||||
const g = c.geometry;
|
||||
if (g.width == null || g.height == null) continue;
|
||||
const { x, y } = absolutePos(c, byId);
|
||||
maxX = Math.max(maxX, x + g.width);
|
||||
maxY = Math.max(maxY, y + g.height);
|
||||
any = true;
|
||||
}
|
||||
if (!any) return { width: 300, height: 200 };
|
||||
// A small margin so borders/labels are not clipped at the edge.
|
||||
return { width: Math.ceil(maxX) + 20, height: Math.ceil(maxY) + 20 };
|
||||
}
|
||||
|
||||
/** Absolute (x,y) of a vertex, following its parent chain (containers). */
|
||||
export function absolutePos(
|
||||
cell: DrawioCell,
|
||||
byId: Map<string, DrawioCell>,
|
||||
): { x: number; y: number } {
|
||||
let x = cell.geometry.x ?? 0;
|
||||
let y = cell.geometry.y ?? 0;
|
||||
const seen = new Set<string>([cell.id]);
|
||||
let parentId = cell.parent;
|
||||
while (parentId && !seen.has(parentId)) {
|
||||
seen.add(parentId);
|
||||
const p = byId.get(parentId);
|
||||
// Sentinels (0/1) carry no geometry; stop there.
|
||||
if (!p || !p.vertex || !p.geometry.hasGeometry) break;
|
||||
x += p.geometry.x ?? 0;
|
||||
y += p.geometry.y ?? 0;
|
||||
parentId = p.parent;
|
||||
}
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
// --- linter ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Run every deterministic pre-write rule over a full mxGraphModel string. On any
|
||||
* violation it throws a DrawioLintError carrying one issue per violation, each
|
||||
* with the offending cellId + position. Returns the parsed cells on success.
|
||||
*/
|
||||
export function lintModel(modelXml: string): {
|
||||
cells: DrawioCell[];
|
||||
warnings: string[];
|
||||
} {
|
||||
const issues: DrawioLintIssue[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Rule: no XML comments. Checked on the raw string (a comment survives DOM
|
||||
// parsing as a comment node, but the intent is to reject them outright — they
|
||||
// routinely wrap "TODO" cruft that breaks downstream tooling).
|
||||
if (modelXml.includes("<!--")) {
|
||||
issues.push({
|
||||
rule: "no-comments",
|
||||
message: "XML comments (<!-- -->) are not allowed in diagram XML",
|
||||
});
|
||||
}
|
||||
|
||||
// Rule: value escaping + literal newline. Scan raw <mxCell> tags so the error
|
||||
// can name the cell id even when the whole document is otherwise malformed.
|
||||
scanRawValues(modelXml, issues);
|
||||
|
||||
// Well-formedness — everything below needs a parsed DOM.
|
||||
const { doc, error } = parseXml(modelXml);
|
||||
if (error) {
|
||||
issues.push({
|
||||
rule: "well-formed-xml",
|
||||
message: error,
|
||||
position: firstLineCol(error),
|
||||
});
|
||||
throw new DrawioLintError(issues);
|
||||
}
|
||||
|
||||
const root = doc.documentElement;
|
||||
if (!root || root.tagName !== "mxGraphModel") {
|
||||
issues.push({
|
||||
rule: "structure",
|
||||
message: `root element must be <mxGraphModel>, got <${root ? root.tagName : "?"}>`,
|
||||
});
|
||||
throw new DrawioLintError(issues);
|
||||
}
|
||||
if (!firstChildByTag(root, "root")) {
|
||||
issues.push({
|
||||
rule: "structure",
|
||||
message: "<mxGraphModel> must contain a <root> element",
|
||||
});
|
||||
throw new DrawioLintError(issues);
|
||||
}
|
||||
|
||||
const cells = parseCells(modelXml);
|
||||
const ids = new Set<string>();
|
||||
|
||||
// Rule: sentinel cells id="0" and id="1"(parent="0").
|
||||
const cell0 = cells.find((c) => c.id === "0");
|
||||
const cell1 = cells.find((c) => c.id === "1");
|
||||
if (!cell0) {
|
||||
issues.push({
|
||||
rule: "sentinel-cells",
|
||||
message: 'missing the root sentinel cell <mxCell id="0"/>',
|
||||
cellId: "0",
|
||||
});
|
||||
}
|
||||
if (!cell1) {
|
||||
issues.push({
|
||||
rule: "sentinel-cells",
|
||||
message: 'missing the layer sentinel cell <mxCell id="1" parent="0"/>',
|
||||
cellId: "1",
|
||||
});
|
||||
} else if (cell1.parent !== "0") {
|
||||
issues.push({
|
||||
rule: "sentinel-cells",
|
||||
message: 'the layer sentinel <mxCell id="1"> must have parent="0"',
|
||||
cellId: "1",
|
||||
});
|
||||
}
|
||||
|
||||
cells.forEach((c, index) => {
|
||||
const pos = `cell #${index}`;
|
||||
const isSentinel = c.id === "0" || c.id === "1";
|
||||
|
||||
// Rule: unique, non-empty ids; user cells must not reuse 0/1.
|
||||
if (c.id === "") {
|
||||
issues.push({ rule: "cell-id", message: "cell has an empty id", position: pos });
|
||||
} else if (ids.has(c.id)) {
|
||||
issues.push({
|
||||
rule: "duplicate-id",
|
||||
message: `duplicate cell id "${c.id}"`,
|
||||
cellId: c.id,
|
||||
position: pos,
|
||||
});
|
||||
}
|
||||
ids.add(c.id);
|
||||
|
||||
if (isSentinel) return; // sentinels are exempt from the shape rules below
|
||||
|
||||
// Rule: vertex XOR edge (a cell may be neither: groups/containers).
|
||||
if (c.vertex && c.edge) {
|
||||
issues.push({
|
||||
rule: "vertex-edge-exclusive",
|
||||
message: 'a cell cannot be both vertex="1" and edge="1"',
|
||||
cellId: c.id,
|
||||
position: pos,
|
||||
});
|
||||
}
|
||||
|
||||
// Rule: every edge has a child <mxGeometry as="geometry"/>.
|
||||
if (c.edge && !c.geometry.hasGeometry) {
|
||||
issues.push({
|
||||
rule: "edge-geometry",
|
||||
message:
|
||||
'edge is missing its child <mxGeometry relative="1" as="geometry"/> — it will not render',
|
||||
cellId: c.id,
|
||||
position: pos,
|
||||
});
|
||||
}
|
||||
|
||||
// Rule: edge endpoints resolve to existing ids.
|
||||
if (c.edge) {
|
||||
for (const end of ["source", "target"] as const) {
|
||||
const ref = c[end];
|
||||
if (ref != null && ref !== "" && !cellExists(cells, ref)) {
|
||||
issues.push({
|
||||
rule: "edge-endpoint",
|
||||
message: `edge ${end} "${ref}" does not resolve to any cell`,
|
||||
cellId: c.id,
|
||||
position: pos,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rule: parent must exist.
|
||||
if (c.parent != null && c.parent !== "" && !cellExists(cells, c.parent)) {
|
||||
issues.push({
|
||||
rule: "parent-exists",
|
||||
message: `parent "${c.parent}" does not resolve to any cell`,
|
||||
cellId: c.id,
|
||||
position: pos,
|
||||
});
|
||||
}
|
||||
|
||||
// Rule: style parses as key=value; pairs.
|
||||
if (c.style !== "") {
|
||||
const parsed = parseStyle(c.style);
|
||||
if (parsed.badSegment !== undefined) {
|
||||
issues.push({
|
||||
rule: "style-format",
|
||||
message: `malformed style segment "${parsed.badSegment}" (expected key=value)`,
|
||||
cellId: c.id,
|
||||
position: pos,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (issues.length > 0) throw new DrawioLintError(issues);
|
||||
return { cells, warnings };
|
||||
}
|
||||
|
||||
function cellExists(cells: DrawioCell[], id: string): boolean {
|
||||
return cells.some((c) => c.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw-string scan of every `value="…"`/`value='…'` on an mxCell tag. Catches an
|
||||
* unescaped `&`/`<`/`>` and a literal newline character inside a value, keyed to
|
||||
* the cell's id. Runs before DOM parsing so a value bug is reported with its
|
||||
* cellId even when the document is otherwise malformed.
|
||||
*/
|
||||
function scanRawValues(xml: string, issues: DrawioLintIssue[]): void {
|
||||
const tagRe = /<mxCell\b([^>]*?)\/?>/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = tagRe.exec(xml)) !== null) {
|
||||
const attrs = m[1];
|
||||
const idM = /\bid\s*=\s*"([^"]*)"/.exec(attrs);
|
||||
const cellId = idM ? idM[1] : undefined;
|
||||
const valM = /\bvalue\s*=\s*"([^"]*)"/.exec(attrs) || /\bvalue\s*=\s*'([^']*)'/.exec(attrs);
|
||||
if (!valM) continue;
|
||||
const raw = valM[1];
|
||||
// Literal newline (0x0A / 0x0D) inside the attribute value.
|
||||
if (/[\n\r]/.test(raw)) {
|
||||
issues.push({
|
||||
rule: "value-newline",
|
||||
message:
|
||||
"value contains a literal newline; use 
 (or <br> with html=1) instead",
|
||||
cellId,
|
||||
});
|
||||
}
|
||||
// Unescaped '<' or '>' inside a value.
|
||||
if (raw.includes("<") || raw.includes(">")) {
|
||||
issues.push({
|
||||
rule: "value-escaping",
|
||||
message: "value contains an unescaped '<' or '>'; use < / >",
|
||||
cellId,
|
||||
});
|
||||
}
|
||||
// '&' that does not begin a valid entity.
|
||||
const badAmp = /&(?!(amp|lt|gt|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/.test(raw);
|
||||
if (badAmp) {
|
||||
issues.push({
|
||||
rule: "value-escaping",
|
||||
message: "value contains an unescaped '&'; use &",
|
||||
cellId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- input normalization + prepare -----------------------------------------
|
||||
|
||||
/**
|
||||
* Normalize an accepted tool input into a full mxGraphModel string:
|
||||
* - a bare `<mxGraphModel>` is used as-is;
|
||||
* - an `<mxfile>` is decoded to its first page's model;
|
||||
* - a list of `<mxCell>` is wrapped with the mxGraphModel/root envelope and
|
||||
* the sentinel cells (id=0, id=1 parent=0) are added when absent.
|
||||
*/
|
||||
export function normalizeInput(inputXml: string): string {
|
||||
let xml = inputXml.trim();
|
||||
// Strip an optional XML prolog.
|
||||
if (xml.startsWith("<?xml")) {
|
||||
const end = xml.indexOf("?>");
|
||||
if (end !== -1) xml = xml.slice(end + 2).trim();
|
||||
}
|
||||
if (xml.startsWith("<mxfile")) {
|
||||
return decodeDrawioFileToModel(xml);
|
||||
}
|
||||
if (xml.startsWith("<mxGraphModel")) {
|
||||
return xml;
|
||||
}
|
||||
if (xml.includes("<mxCell")) {
|
||||
return wrapCellFragment(xml);
|
||||
}
|
||||
throw new DrawioLintError([
|
||||
{
|
||||
rule: "unrecognized-input",
|
||||
message:
|
||||
"input must be a <mxGraphModel>, an <mxfile>, or a list of <mxCell> elements",
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
function wrapCellFragment(fragment: string): string {
|
||||
// Validate the fragment is well-formed (wrapped so a bare list parses) and
|
||||
// discover which sentinels are already present.
|
||||
const { doc, error } = parseXml(`<root>${fragment}</root>`);
|
||||
if (error) {
|
||||
throw new DrawioLintError([
|
||||
{
|
||||
rule: "well-formed-xml",
|
||||
message: error,
|
||||
position: firstLineCol(error),
|
||||
},
|
||||
]);
|
||||
}
|
||||
const existing = new Set<string>();
|
||||
const els = doc.getElementsByTagName("mxCell");
|
||||
for (let i = 0; i < els.length; i++) {
|
||||
existing.add(els[i].getAttribute("id") ?? "");
|
||||
}
|
||||
let prefix = "";
|
||||
if (!existing.has("0")) prefix += '<mxCell id="0"/>';
|
||||
if (!existing.has("1")) prefix += '<mxCell id="1" parent="0"/>';
|
||||
return `<mxGraphModel ${DEFAULT_MODEL_ATTRS}><root>${prefix}${fragment}</root></mxGraphModel>`;
|
||||
}
|
||||
|
||||
export interface PreparedModel {
|
||||
/** Canonical (normalized) mxGraphModel XML that gets written. */
|
||||
modelXml: string;
|
||||
cells: DrawioCell[];
|
||||
bbox: DrawioBBox;
|
||||
/** Number of user cells (excludes the id=0/id=1 sentinels). */
|
||||
cellCount: number;
|
||||
warnings: string[];
|
||||
hash: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full pre-write pipeline for create/update: normalize the input into a model,
|
||||
* lint it (throws DrawioLintError on any violation), then compute the canonical
|
||||
* form, bounding box, cell count and hash. Never touches the network.
|
||||
*/
|
||||
export function prepareModel(inputXml: string): PreparedModel {
|
||||
const rawModel = normalizeInput(inputXml);
|
||||
const { cells, warnings } = lintModel(rawModel);
|
||||
const modelXml = normalizeXml(rawModel);
|
||||
const bbox = computeBBox(cells);
|
||||
const cellCount = cells.filter((c) => c.id !== "0" && c.id !== "1").length;
|
||||
return {
|
||||
modelXml,
|
||||
cells,
|
||||
bbox,
|
||||
cellCount,
|
||||
warnings,
|
||||
hash: mxHash(modelXml),
|
||||
};
|
||||
}
|
||||
|
||||
/** Cell count of a decoded model (user cells only) — used by drawio_get meta. */
|
||||
export function countUserCells(modelXml: string): number {
|
||||
return parseCells(modelXml).filter((c) => c.id !== "0" && c.id !== "1").length;
|
||||
}
|
||||
@@ -12,11 +12,7 @@
|
||||
* re-import for small wording fixes.
|
||||
*/
|
||||
|
||||
import {
|
||||
stripInlineMarkdown,
|
||||
stripBalancedWrappers,
|
||||
closestBlockHint,
|
||||
} from "./text-normalize.js";
|
||||
import { stripInlineMarkdown, stripBalancedWrappers } from "./text-normalize.js";
|
||||
|
||||
export interface TextEdit {
|
||||
find: string;
|
||||
@@ -385,9 +381,29 @@ export function applyTextEdits(
|
||||
} else {
|
||||
// Append a bounded "closest text" hint: find the FIRST block that
|
||||
// contains the longest whitespace-delimited token (>= 3 chars) of the
|
||||
// (stripped, then raw) locator, and quote that block's plain text. Shared
|
||||
// with create_comment via closestBlockHint so both give the same hint.
|
||||
reason = "text not found in the document." + closestBlockHint(blockPlain, edit.find);
|
||||
// (stripped, then raw) locator, and quote that block's plain text.
|
||||
reason = "text not found in the document.";
|
||||
const tokenSource = stripped.length > 0 ? stripped : edit.find;
|
||||
const longestToken = tokenSource
|
||||
.split(/\s+/)
|
||||
.filter((t) => t.length >= 3)
|
||||
.sort((a, b) => b.length - a.length)[0];
|
||||
if (longestToken) {
|
||||
const hitBlock = blockPlain.find((plain) =>
|
||||
plain.includes(longestToken),
|
||||
);
|
||||
if (hitBlock) {
|
||||
// Truncate by code point (spread iterates by code point) so a
|
||||
// surrogate pair is never split; append the ellipsis only when the
|
||||
// text was actually longer than the limit.
|
||||
const points = [...hitBlock];
|
||||
const snippet =
|
||||
points.length > 120
|
||||
? points.slice(0, 120).join("") + "…"
|
||||
: hitBlock;
|
||||
reason += ` Closest block text: "${snippet}".`;
|
||||
}
|
||||
}
|
||||
}
|
||||
failed.push({ find: edit.find, reason });
|
||||
continue;
|
||||
|
||||
Vendored
+61
@@ -0,0 +1,61 @@
|
||||
// Minimal ambient type declaration for `pako` (no @types/pako is installed and
|
||||
// pako 2.x ships no bundled .d.ts). We only use the raw-deflate codec to read
|
||||
// draw.io's compressed `<diagram>` payload, so declare just that surface.
|
||||
declare module "pako" {
|
||||
interface RawOptions {
|
||||
/** When "string", the result is returned as a (binary/UTF-8) string. */
|
||||
to?: "string";
|
||||
/** Raw-deflate window bits; draw.io uses raw deflate (no zlib header). */
|
||||
windowBits?: number;
|
||||
level?: number;
|
||||
}
|
||||
|
||||
/** Raw-inflate (windowBits: -15). `to:"string"` yields a string. */
|
||||
export function inflateRaw(
|
||||
data: Uint8Array | ArrayBuffer | number[],
|
||||
options: RawOptions & { to: "string" },
|
||||
): string;
|
||||
export function inflateRaw(
|
||||
data: Uint8Array | ArrayBuffer | number[],
|
||||
options?: RawOptions,
|
||||
): Uint8Array;
|
||||
|
||||
/** Raw-deflate (windowBits: -15). Used only by tests to build fixtures. */
|
||||
export function deflateRaw(
|
||||
data: Uint8Array | string,
|
||||
options?: RawOptions,
|
||||
): Uint8Array;
|
||||
|
||||
interface InflateStreamOptions {
|
||||
to?: "string";
|
||||
windowBits?: number;
|
||||
/** Raw deflate (no zlib header) — equivalent to windowBits: -15. */
|
||||
raw?: boolean;
|
||||
chunkSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming inflate. We use it to bound the decompressed size: `onData` is
|
||||
* invoked per output chunk, letting us abort a decompression bomb before the
|
||||
* full output is materialised.
|
||||
*/
|
||||
export class Inflate {
|
||||
constructor(options?: InflateStreamOptions);
|
||||
onData: (chunk: string | Uint8Array) => void;
|
||||
onEnd: (status: number) => void;
|
||||
push(
|
||||
data: Uint8Array | ArrayBuffer | number[] | string,
|
||||
flushMode?: boolean | number,
|
||||
): boolean;
|
||||
result: string | Uint8Array;
|
||||
err: number;
|
||||
msg: string;
|
||||
}
|
||||
|
||||
const _default: {
|
||||
inflateRaw: typeof inflateRaw;
|
||||
deflateRaw: typeof deflateRaw;
|
||||
Inflate: typeof Inflate;
|
||||
};
|
||||
export default _default;
|
||||
}
|
||||
@@ -114,37 +114,3 @@ export function stripInlineMarkdown(s: string): string {
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a bounded "closest text" hint for an anchor/find MISS, shared by
|
||||
* edit_page_text (json-edit) and create_comment (client) so both surface the
|
||||
* same self-correction affordance.
|
||||
*
|
||||
* Take the longest whitespace-delimited token (>= 3 chars) of the locator
|
||||
* (markdown-stripped first, so `**bold**` contributes `bold`), find the FIRST
|
||||
* of `blockTexts` that contains it, and return ` Closest block text: "…".` with
|
||||
* the block quoted (truncated to 120 code points + ellipsis). Returns "" when
|
||||
* no token qualifies or no block contains it, so the caller can append it
|
||||
* unconditionally.
|
||||
*/
|
||||
export function closestBlockHint(
|
||||
blockTexts: string[],
|
||||
locator: string,
|
||||
): string {
|
||||
if (typeof locator !== "string" || locator.length === 0) return "";
|
||||
const stripped = stripInlineMarkdown(locator);
|
||||
const tokenSource = stripped.length > 0 ? stripped : locator;
|
||||
const longestToken = tokenSource
|
||||
.split(/\s+/)
|
||||
.filter((t) => t.length >= 3)
|
||||
.sort((a, b) => b.length - a.length)[0];
|
||||
if (!longestToken) return "";
|
||||
const hitBlock = blockTexts.find((plain) => plain.includes(longestToken));
|
||||
if (!hitBlock) return "";
|
||||
// Truncate by code point (spread iterates by code point) so a surrogate pair
|
||||
// is never split; append the ellipsis only when the text was actually longer.
|
||||
const points = [...hitBlock];
|
||||
const snippet =
|
||||
points.length > 120 ? points.slice(0, 120).join("") + "…" : hitBlock;
|
||||
return ` Closest block text: "${snippet}".`;
|
||||
}
|
||||
|
||||
@@ -771,13 +771,9 @@ export const SHARED_TOOL_SPECS = {
|
||||
'The comment is anchored inline to the given exact `selection` text ' +
|
||||
'(which gets highlighted); page-level comments are NOT supported. A ' +
|
||||
'new top-level comment REQUIRES a `selection`. Replies inherit the ' +
|
||||
"parent's anchor and take no selection. Always COPY the `selection` " +
|
||||
'VERBATIM from get_page / search_in_page output — do NOT quote it from ' +
|
||||
'memory (stale-memory quoting is the top cause of anchor misses). If the ' +
|
||||
'call fails with a "selection not found" error, the error quotes the ' +
|
||||
"closest block text (or says the selection spans multiple blocks); retry " +
|
||||
"with a corrected EXACT selection copied verbatim from a single " +
|
||||
'paragraph/block. You may also attach a ' +
|
||||
"parent's anchor and take no selection. If the call fails with a " +
|
||||
'"selection not found" error, retry with a corrected EXACT selection ' +
|
||||
'copied verbatim from a single paragraph/block. You may also attach a ' +
|
||||
'`suggestedText` proposing a replacement for the `selection` (a human ' +
|
||||
'applies it from the UI); when set, the `selection` must occur exactly ' +
|
||||
'once in the page. Reversible via the comment UI.',
|
||||
@@ -1119,4 +1115,112 @@ export const SHARED_TOOL_SPECS = {
|
||||
alt: z.string().optional(),
|
||||
}),
|
||||
},
|
||||
|
||||
// --- draw.io diagrams (issue #423, stage 1) ---
|
||||
|
||||
drawioGet: {
|
||||
mcpName: 'drawio_get',
|
||||
inAppKey: 'drawioGet',
|
||||
description:
|
||||
'Read a draw.io diagram on a page as mxGraph XML (default) or as its raw ' +
|
||||
'`.drawio.svg`. `node` is the drawio node\'s attrs.id (from get_outline / ' +
|
||||
'get_page_json) or "#<index>" for a top-level block. Returns the decoded ' +
|
||||
'mxGraphModel XML plus meta { attachmentId, title, width, height, ' +
|
||||
'cellCount, hash }. `hash` is the optimistic-lock key you MUST pass back ' +
|
||||
'as baseHash to drawio_update. Diagrams a human saved from the editor ' +
|
||||
'(including draw.io\'s compressed format) decode losslessly.',
|
||||
tier: 'deferred',
|
||||
catalogLine:
|
||||
'drawioGet — read a draw.io diagram as mxGraph XML (+ hash for updates).',
|
||||
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.'),
|
||||
format: z
|
||||
.enum(['xml', 'svg'])
|
||||
.optional()
|
||||
.describe('"xml" (default) for mxGraph XML, or "svg" for the raw .drawio.svg.'),
|
||||
}),
|
||||
},
|
||||
|
||||
drawioCreate: {
|
||||
mcpName: 'drawio_create',
|
||||
inAppKey: 'drawioCreate',
|
||||
description:
|
||||
'Create a draw.io diagram from mxGraph XML and insert it as a diagram ' +
|
||||
'block. `xml` is a bare `<mxGraphModel>` OR a list of `<mxCell>` elements ' +
|
||||
'(the server wraps it and adds the id=0 / id=1 sentinel cells). The XML is ' +
|
||||
'LINTED first (well-formedness, sentinel cells, unique ids, vertex XOR ' +
|
||||
'edge, every edge has a child <mxGeometry as="geometry"/>, edge ' +
|
||||
'source/target and every parent resolve, style parses, no XML comments, ' +
|
||||
'value escaping) — a violation returns a structured error naming the rule ' +
|
||||
'and cellId so you can fix and retry. `where` positions the block like ' +
|
||||
'insert_node: position before/after (with exactly one of anchorNodeId or ' +
|
||||
'anchorText) or append. Returns { nodeId, attachmentId, warnings }. The ' +
|
||||
'returned `nodeId` is an index-based "#<index>" handle (drawio nodes carry ' +
|
||||
'no attrs.id): it addresses the new top-level block and can be fed straight ' +
|
||||
'back into drawio_get / drawio_update for THIS document. It is positional, ' +
|
||||
'so if you add or remove blocks before it, re-resolve via get_outline. The ' +
|
||||
'diagram is editable in the draw.io editor and can be re-read with ' +
|
||||
'drawio_get.',
|
||||
tier: 'deferred',
|
||||
catalogLine:
|
||||
'drawioCreate — create a draw.io diagram from mxGraph XML and insert it.',
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1),
|
||||
xml: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
'mxGraph XML: a bare <mxGraphModel> or a list of <mxCell> elements.',
|
||||
),
|
||||
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).'),
|
||||
title: z.string().optional().describe('Optional diagram title.'),
|
||||
}),
|
||||
},
|
||||
|
||||
drawioUpdate: {
|
||||
mcpName: 'drawio_update',
|
||||
inAppKey: 'drawioUpdate',
|
||||
description:
|
||||
'Replace a draw.io diagram\'s content with new mxGraph XML (same lint ' +
|
||||
'pipeline as drawio_create). `baseHash` is MANDATORY: pass the hash from ' +
|
||||
'the drawio_get you based the edit on. If the diagram changed since ' +
|
||||
'(a human or another agent edited it) the hash mismatches and the update ' +
|
||||
'is refused with a conflict error — re-read with drawio_get and retry. On ' +
|
||||
'success it overwrites the diagram attachment and updates the node ' +
|
||||
'width/height. `node` is the drawio node attrs.id or "#<index>".',
|
||||
tier: 'deferred',
|
||||
catalogLine:
|
||||
'drawioUpdate — replace a draw.io diagram (optimistic-locked by baseHash).',
|
||||
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.'),
|
||||
xml: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
'New mxGraph XML: a bare <mxGraphModel> or a list of <mxCell> elements.',
|
||||
),
|
||||
baseHash: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe('The meta.hash from the drawio_get this edit is based on.'),
|
||||
}),
|
||||
},
|
||||
} satisfies Record<string, SharedToolSpec>;
|
||||
|
||||
@@ -548,94 +548,3 @@ test("suggestedText: the stored selection is the doc's RAW typographic substring
|
||||
);
|
||||
assert.equal(createPayload.suggestedText, "goodbye");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 8) #408: a not-found selection error QUOTES the closest block text so the
|
||||
// model can self-correct instead of blind-retrying.
|
||||
// -----------------------------------------------------------------------------
|
||||
test("a not-found selection error includes a 'Closest block text' hint", async () => {
|
||||
let createCalls = 0;
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
await readBody(req);
|
||||
if (req.url === "/api/auth/login") {
|
||||
sendJson(res, 200, { success: true }, {
|
||||
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/info") {
|
||||
sendJson(res, 200, {
|
||||
data: {
|
||||
id: "page-1",
|
||||
content: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "The quick brown fox jumps" }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/comments/create") {
|
||||
createCalls++;
|
||||
sendJson(res, 200, { data: { id: "should-not-happen" } });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, { message: "not found" });
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
await assert.rejects(
|
||||
() => client.createComment("page-1", "body", "inline", "quick brown cat"),
|
||||
/Closest block text: "The quick brown fox jumps"/,
|
||||
"a not-found selection must quote the closest block text",
|
||||
);
|
||||
assert.equal(createCalls, 0, "/comments/create must NOT be called on a miss");
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 9) #408: a selection that straddles two blocks gets the explicit
|
||||
// "spans multiple blocks" message instead of a bare not-found.
|
||||
// -----------------------------------------------------------------------------
|
||||
test("a selection spanning multiple blocks gets the explicit spans-multiple-blocks message", async () => {
|
||||
let createCalls = 0;
|
||||
const { baseURL } = await spawn(async (req, res) => {
|
||||
await readBody(req);
|
||||
if (req.url === "/api/auth/login") {
|
||||
sendJson(res, 200, { success: true }, {
|
||||
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/pages/info") {
|
||||
sendJson(res, 200, {
|
||||
data: {
|
||||
id: "page-1",
|
||||
content: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "the quick brown" }] },
|
||||
{ type: "paragraph", content: [{ type: "text", text: "fox jumps over" }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (req.url === "/api/comments/create") {
|
||||
createCalls++;
|
||||
sendJson(res, 200, { data: { id: "should-not-happen" } });
|
||||
return;
|
||||
}
|
||||
sendJson(res, 404, { message: "not found" });
|
||||
});
|
||||
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
await assert.rejects(
|
||||
() => client.createComment("page-1", "body", "inline", "brown fox"),
|
||||
/spans multiple blocks/,
|
||||
"a cross-block selection must report the spans-multiple-blocks hint",
|
||||
);
|
||||
assert.equal(createCalls, 0, "/comments/create must NOT be called on a miss");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
// Contract tests for the drawio_get / drawio_create / drawio_update client
|
||||
// methods (issue #423). Follows the repo's seam-override pattern (see
|
||||
// full-doc-write-canonicalize.test.mjs): a DocmostClient subclass stubs the I/O
|
||||
// seams (auth, collab token, page read, attachment upload/fetch, the mutatePage
|
||||
// write) so the tool logic is exercised without a live Docmost or collab socket.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import pako from "pako";
|
||||
import { DocmostClient } from "../../build/client.js";
|
||||
import {
|
||||
buildDrawioSvg,
|
||||
encodeDrawioFile,
|
||||
normalizeXml,
|
||||
mxHash,
|
||||
decodeDrawioSvg,
|
||||
} from "../../build/lib/drawio-xml.js";
|
||||
|
||||
const MODEL =
|
||||
'<mxGraphModel><root>' +
|
||||
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
|
||||
'<mxCell id="2" value="Hi" style="rounded=1;" vertex="1" parent="1">' +
|
||||
'<mxGeometry x="20" y="20" width="120" height="60" as="geometry"/></mxCell>' +
|
||||
'</root></mxGraphModel>';
|
||||
|
||||
// Build a Docmost-style `.drawio.svg` (base64 content) for a model.
|
||||
function svgFor(model) {
|
||||
return buildDrawioSvg(normalizeXml(model), "<g/>", { width: 200, height: 120 });
|
||||
}
|
||||
|
||||
// Build a human/compressed-export `.drawio.svg` (base64 content wrapping a
|
||||
// compressed <diagram> payload), mimicking a diagram a person saved.
|
||||
function compressedSvgFor(model) {
|
||||
const compressed = Buffer.from(
|
||||
pako.deflateRaw(encodeURIComponent(normalizeXml(model))),
|
||||
).toString("base64");
|
||||
const file = `<mxfile host="Electron"><diagram id="a" name="Page-1">${compressed}</diagram></mxfile>`;
|
||||
const content = Buffer.from(file, "utf-8").toString("base64");
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" content="${content}"><image href="x"/></svg>`;
|
||||
}
|
||||
|
||||
// The vendored `drawio` node schema (diagramAttributes) declares ONLY these
|
||||
// attributes; PMNode.fromJSON drops anything else on save. Mirror that here so
|
||||
// the mock write path behaves like the real one — in particular, a block `id`
|
||||
// set on a drawio node does NOT survive the save, so a handle keyed on it is
|
||||
// un-resolvable. This is exactly what the production bug (issue #423 Fix 1) was.
|
||||
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 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, mime) {
|
||||
const id = `att-${calls.uploads.length + 1}`;
|
||||
calls.uploads.push({ pageId, fileName, mime, svg: buffer.toString("utf-8") });
|
||||
return { id, fileName, fileSize: buffer.length };
|
||||
}
|
||||
async fetchAttachmentText(src) {
|
||||
return attachmentSvg;
|
||||
}
|
||||
mutatePage(pageId, token, apiUrl, transform) {
|
||||
// Run the transform against a clone of the source doc, capture the result.
|
||||
const clone = structuredClone(pageDoc ?? { type: "doc", content: [] });
|
||||
const doc = transform(clone);
|
||||
// Mirror the real schema: unknown drawio attrs (e.g. a block `id`) are
|
||||
// dropped on save, so callers can never rely on them to address the node.
|
||||
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;
|
||||
}
|
||||
|
||||
// --- drawio_create ---------------------------------------------------------
|
||||
|
||||
test("drawio_create: lints, builds the .drawio.svg, uploads 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.drawioCreate("page1", { position: "append" }, MODEL, "My diagram");
|
||||
|
||||
assert.equal(res.success, true);
|
||||
// The returned handle is an index-based "#<index>" ref (drawio nodes carry no
|
||||
// persisted attrs.id), addressing the appended top-level block (index 1, after
|
||||
// the existing paragraph).
|
||||
assert.equal(res.nodeId, "#1");
|
||||
assert.equal(res.attachmentId, "att-1");
|
||||
assert.equal(calls.uploads.length, 1);
|
||||
assert.equal(calls.uploads[0].fileName, "diagram.drawio.svg");
|
||||
assert.equal(calls.uploads[0].mime, "image/svg+xml");
|
||||
// The uploaded SVG carries the model back (round-trips through the decode chain).
|
||||
assert.equal(decodeDrawioSvg(calls.uploads[0].svg), normalizeXml(MODEL));
|
||||
|
||||
// A drawio node was appended with src/attachmentId/dimensions and the title.
|
||||
const drawios = findDrawio(calls.mutations[0].doc);
|
||||
assert.equal(drawios.length, 1);
|
||||
const n = drawios[0];
|
||||
// No `id` attribute is set/persisted on the node (schema has none).
|
||||
assert.equal(n.attrs.id, undefined);
|
||||
assert.equal(n.attrs.attachmentId, "att-1");
|
||||
assert.match(n.attrs.src, /^\/api\/files\/att-1\//);
|
||||
assert.ok(n.attrs.width > 0 && n.attrs.height > 0);
|
||||
assert.equal(n.attrs.title, "My diagram");
|
||||
});
|
||||
|
||||
test("drawio_create: a lint violation throws before any upload", async () => {
|
||||
const { client, calls } = makeClient({ pageDoc: { type: "doc", content: [] } });
|
||||
// Edge with no child geometry -> edge-geometry rule.
|
||||
const bad =
|
||||
'<mxGraphModel><root><mxCell id="0"/><mxCell id="1" parent="0"/>' +
|
||||
'<mxCell id="2" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>' +
|
||||
'<mxCell id="3" edge="1" parent="1" source="2" target="2"/></root></mxGraphModel>';
|
||||
await assert.rejects(
|
||||
() => client.drawioCreate("page1", { position: "append" }, bad, undefined),
|
||||
/edge-geometry/,
|
||||
);
|
||||
assert.equal(calls.uploads.length, 0, "no attachment uploaded on lint failure");
|
||||
});
|
||||
|
||||
test("drawio_create: before/after requires exactly one anchor", async () => {
|
||||
const { client } = makeClient({ pageDoc: { type: "doc", content: [] } });
|
||||
await assert.rejects(
|
||||
() => client.drawioCreate("page1", { position: "before" }, MODEL),
|
||||
/exactly one of anchorNodeId or anchorText/,
|
||||
);
|
||||
});
|
||||
|
||||
// --- drawio_get ------------------------------------------------------------
|
||||
|
||||
test("drawio_get: decodes the model and returns meta with a hash", async () => {
|
||||
const pageDoc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "drawio",
|
||||
attrs: {
|
||||
id: "d1",
|
||||
src: "/api/files/att-1/diagram.drawio.svg",
|
||||
attachmentId: "att-1",
|
||||
title: "T",
|
||||
width: 200,
|
||||
height: 120,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const { client } = makeClient({ pageDoc, attachmentSvg: svgFor(MODEL) });
|
||||
const res = await client.drawioGet("page1", "d1", "xml");
|
||||
assert.equal(res.content, normalizeXml(MODEL));
|
||||
assert.equal(res.meta.attachmentId, "att-1");
|
||||
assert.equal(res.meta.title, "T");
|
||||
assert.equal(res.meta.cellCount, 1);
|
||||
assert.equal(res.meta.hash, mxHash(normalizeXml(MODEL)));
|
||||
});
|
||||
|
||||
test("drawio_get: format=svg returns the raw .drawio.svg", async () => {
|
||||
const svg = svgFor(MODEL);
|
||||
const pageDoc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "drawio", attrs: { id: "d1", src: "/api/files/att-1/x.svg", attachmentId: "att-1" } },
|
||||
],
|
||||
};
|
||||
const { client } = makeClient({ pageDoc, attachmentSvg: svg });
|
||||
const res = await client.drawioGet("page1", "d1", "svg");
|
||||
assert.equal(res.content, svg);
|
||||
});
|
||||
|
||||
test("drawio_get: reads a HUMAN-saved compressed diagram losslessly (pako)", async () => {
|
||||
const pageDoc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "drawio", attrs: { id: "d1", src: "/api/files/att-1/x.svg", attachmentId: "att-1" } },
|
||||
],
|
||||
};
|
||||
const { client } = makeClient({ pageDoc, attachmentSvg: compressedSvgFor(MODEL) });
|
||||
const res = await client.drawioGet("page1", "d1", "xml");
|
||||
assert.equal(res.content, normalizeXml(MODEL));
|
||||
});
|
||||
|
||||
// --- drawio_update ---------------------------------------------------------
|
||||
|
||||
const UPDATED_MODEL =
|
||||
'<mxGraphModel><root>' +
|
||||
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
|
||||
'<mxCell id="2" value="Changed" style="rounded=1;" vertex="1" parent="1">' +
|
||||
'<mxGeometry x="20" y="20" width="300" height="200" as="geometry"/></mxCell>' +
|
||||
'</root></mxGraphModel>';
|
||||
|
||||
function updatePageDoc() {
|
||||
return {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "drawio",
|
||||
attrs: {
|
||||
id: "d1",
|
||||
src: "/api/files/att-1/diagram.drawio.svg",
|
||||
attachmentId: "att-1",
|
||||
width: 200,
|
||||
height: 120,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test("drawio_update: stale baseHash -> conflict, no upload", async () => {
|
||||
const { client, calls } = makeClient({
|
||||
pageDoc: updatePageDoc(),
|
||||
attachmentSvg: svgFor(MODEL),
|
||||
});
|
||||
await assert.rejects(
|
||||
() => client.drawioUpdate("page1", "d1", UPDATED_MODEL, "deadbeef-stale"),
|
||||
/conflict/,
|
||||
);
|
||||
assert.equal(calls.uploads.length, 0, "no upload on conflict");
|
||||
});
|
||||
|
||||
test("drawio_update: current baseHash -> uploads new attachment and repoints node dims", async () => {
|
||||
const currentHash = mxHash(normalizeXml(MODEL));
|
||||
const { client, calls } = makeClient({
|
||||
pageDoc: updatePageDoc(),
|
||||
attachmentSvg: svgFor(MODEL),
|
||||
});
|
||||
const res = await client.drawioUpdate("page1", "d1", UPDATED_MODEL, currentHash);
|
||||
assert.equal(res.success, true);
|
||||
assert.equal(res.attachmentId, "att-1"); // fresh id from the stub sequence
|
||||
assert.equal(calls.uploads.length, 1);
|
||||
// The uploaded SVG carries the NEW model.
|
||||
assert.equal(decodeDrawioSvg(calls.uploads[0].svg), normalizeXml(UPDATED_MODEL));
|
||||
// The node was repointed with the new bounding-box dimensions:
|
||||
// vertex maxX=320,maxY=220 + the 20px preview margin -> 340 x 240.
|
||||
const n = findDrawio(calls.mutations[0].doc)[0];
|
||||
assert.equal(n.attrs.attachmentId, "att-1");
|
||||
assert.equal(n.attrs.width, 340);
|
||||
assert.equal(n.attrs.height, 240);
|
||||
// The block `id` used as the legacy resolution handle is dropped on save
|
||||
// (schema declares no `id`); the update still targeted the correct node.
|
||||
assert.equal(n.attrs.id, undefined);
|
||||
});
|
||||
|
||||
test("drawio_update: baseHash is mandatory", async () => {
|
||||
const { client } = makeClient({ pageDoc: updatePageDoc(), attachmentSvg: svgFor(MODEL) });
|
||||
await assert.rejects(
|
||||
() => client.drawioUpdate("page1", "d1", UPDATED_MODEL, ""),
|
||||
/baseHash is mandatory/,
|
||||
);
|
||||
});
|
||||
|
||||
// --- Fix 1: the create handle must resolve on the SAVED doc (no id) ---------
|
||||
|
||||
test("drawio_create -> get/update: returned #<index> handle resolves on the saved doc (id dropped)", async () => {
|
||||
// Create appends a drawio node after the existing paragraph.
|
||||
const createDoc = {
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }],
|
||||
};
|
||||
const create = makeClient({ pageDoc: createDoc });
|
||||
const res = await create.client.drawioCreate(
|
||||
"page1",
|
||||
{ position: "append" },
|
||||
MODEL,
|
||||
"T",
|
||||
);
|
||||
// The handle is index-based, not a block id.
|
||||
assert.equal(res.nodeId, "#1");
|
||||
|
||||
// Take the document EXACTLY as it was saved: the schema drop stripped the
|
||||
// node's id, so no id-based handle could ever resolve against it.
|
||||
const savedDoc = create.calls.mutations[0].doc;
|
||||
assert.equal(findDrawio(savedDoc)[0].attrs.id, undefined);
|
||||
|
||||
// drawio_get with the returned handle resolves the just-created node.
|
||||
const getClient = makeClient({ pageDoc: savedDoc, attachmentSvg: svgFor(MODEL) });
|
||||
const got = await getClient.client.drawioGet("page1", res.nodeId, "xml");
|
||||
assert.equal(got.nodeId, res.nodeId);
|
||||
assert.equal(got.content, normalizeXml(MODEL));
|
||||
|
||||
// drawio_update with the same handle + the hash from get repoints that node.
|
||||
const upClient = makeClient({ pageDoc: savedDoc, attachmentSvg: svgFor(MODEL) });
|
||||
const upd = await upClient.client.drawioUpdate(
|
||||
"page1",
|
||||
res.nodeId,
|
||||
UPDATED_MODEL,
|
||||
got.meta.hash,
|
||||
);
|
||||
assert.equal(upd.success, true);
|
||||
assert.equal(upd.nodeId, res.nodeId);
|
||||
const updated = findDrawio(upClient.calls.mutations[0].doc)[0];
|
||||
assert.equal(
|
||||
decodeDrawioSvg(upClient.calls.uploads[0].svg),
|
||||
normalizeXml(UPDATED_MODEL),
|
||||
);
|
||||
assert.equal(updated.attrs.width, 340);
|
||||
});
|
||||
|
||||
// --- error paths: the LLM must get a clean error, not a crash --------------
|
||||
|
||||
test("drawio_get: a bad node ref -> clean 'no node found' error", async () => {
|
||||
// Page has one paragraph; the requested ref resolves to nothing.
|
||||
const pageDoc = {
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }],
|
||||
};
|
||||
const { client } = makeClient({ pageDoc, attachmentSvg: svgFor(MODEL) });
|
||||
await assert.rejects(
|
||||
() => client.drawioGet("page1", "does-not-exist", "xml"),
|
||||
/no node found for "does-not-exist"/,
|
||||
);
|
||||
});
|
||||
|
||||
test("drawio_get: a drawio node with no src -> clean 'has no src to read' error", async () => {
|
||||
const pageDoc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
// A drawio node that carries no `src` (e.g. a half-written node).
|
||||
{ type: "drawio", attrs: { id: "d1", attachmentId: "att-1" } },
|
||||
],
|
||||
};
|
||||
const { client } = makeClient({ pageDoc, attachmentSvg: svgFor(MODEL) });
|
||||
await assert.rejects(
|
||||
() => client.drawioGet("page1", "d1", "xml"),
|
||||
/node "d1" on page page1 has no src to read/,
|
||||
);
|
||||
});
|
||||
|
||||
test("drawio_update: the resolved node is NOT a drawio node -> clean error, no upload", async () => {
|
||||
// "#0" resolves to a paragraph. The update must refuse cleanly rather than
|
||||
// crash or repoint the wrong node.
|
||||
const pageDoc = {
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }],
|
||||
};
|
||||
const { client, calls } = makeClient({ pageDoc, attachmentSvg: svgFor(MODEL) });
|
||||
await assert.rejects(
|
||||
() => client.drawioUpdate("page1", "#0", UPDATED_MODEL, "any-nonempty-hash"),
|
||||
/node "#0" on page page1 is a paragraph, not a drawio diagram/,
|
||||
);
|
||||
assert.equal(calls.uploads.length, 0, "no upload when the node is not a diagram");
|
||||
assert.equal(calls.mutations.length, 0, "no write when the node is not a diagram");
|
||||
});
|
||||
|
||||
test("drawio_create: anchor not found -> clean error that reports the orphan attachment", async () => {
|
||||
// The upload happens before the mutate transform; when the anchor cannot be
|
||||
// found the write is skipped and the (now unreferenced) attachment is named
|
||||
// in the error, exactly as the code documents.
|
||||
const pageDoc = {
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }],
|
||||
};
|
||||
const { client, calls } = makeClient({ pageDoc });
|
||||
await assert.rejects(
|
||||
() =>
|
||||
client.drawioCreate(
|
||||
"page1",
|
||||
{ position: "after", anchorNodeId: "nope" },
|
||||
MODEL,
|
||||
"T",
|
||||
),
|
||||
(err) =>
|
||||
/anchor not found/.test(err.message) &&
|
||||
/unreferenced orphan/.test(err.message) &&
|
||||
/att-1/.test(err.message),
|
||||
);
|
||||
// The orphan was uploaded (and reported), but no node was written.
|
||||
assert.equal(calls.uploads.length, 1, "attachment uploaded before the failed insert");
|
||||
const drawios = calls.mutations.length ? findDrawio(calls.mutations[0].doc) : [];
|
||||
assert.equal(drawios.length, 0, "no drawio node written when the anchor is missing");
|
||||
});
|
||||
|
||||
// --- Fix 2: update targets ONLY the resolved node --------------------------
|
||||
|
||||
test("drawio_update: repoints ONLY the addressed node, not siblings sharing an attachmentId", async () => {
|
||||
// A copied diagram: two drawio nodes share one attachmentId. Updating via the
|
||||
// "#0" handle must touch node #0 only, never the sibling copy.
|
||||
const shared = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "drawio",
|
||||
attrs: {
|
||||
src: "/api/files/shared/x.svg",
|
||||
attachmentId: "shared",
|
||||
width: 200,
|
||||
height: 120,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "drawio",
|
||||
attrs: {
|
||||
src: "/api/files/shared/x.svg",
|
||||
attachmentId: "shared",
|
||||
width: 200,
|
||||
height: 120,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
const { client, calls } = makeClient({
|
||||
pageDoc: shared,
|
||||
attachmentSvg: svgFor(MODEL),
|
||||
});
|
||||
const res = await client.drawioUpdate(
|
||||
"page1",
|
||||
"#0",
|
||||
UPDATED_MODEL,
|
||||
mxHash(normalizeXml(MODEL)),
|
||||
);
|
||||
assert.equal(res.success, true);
|
||||
|
||||
const drawios = findDrawio(calls.mutations[0].doc);
|
||||
assert.equal(drawios.length, 2);
|
||||
// Node #0 repointed to the NEW attachment ("att-1" from the stub) and dims.
|
||||
assert.equal(drawios[0].attrs.attachmentId, "att-1");
|
||||
assert.equal(drawios[0].attrs.width, 340);
|
||||
assert.match(drawios[0].attrs.src, /^\/api\/files\/att-1\//);
|
||||
// Node #1 (the sibling copy) is untouched despite sharing the old attachmentId.
|
||||
assert.equal(drawios[1].attrs.attachmentId, "shared");
|
||||
assert.equal(drawios[1].attrs.width, 200);
|
||||
assert.equal(drawios[1].attrs.src, "/api/files/shared/x.svg");
|
||||
});
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
applyAnchorInDoc,
|
||||
countAnchorMatches,
|
||||
getAnchoredText,
|
||||
resolveAnchorSelection,
|
||||
} from "../../build/lib/comment-anchor.js";
|
||||
|
||||
const COMMENT_ID = "cmt-123";
|
||||
@@ -309,70 +308,3 @@ test("getAnchoredText returns null when the selection does not anchor", () => {
|
||||
const doc = paragraphDoc([{ type: "text", text: "hello world" }]);
|
||||
assert.equal(getAnchoredText(doc, "not present"), null);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #408 MARKDOWN-STRIP FALLBACK. A selection copied with inline markdown still
|
||||
// carries `**`/`` ` ``/`[t](u)` markers the plain document text lacks. When the
|
||||
// verbatim selection anchors nowhere, all four entry points retry with the
|
||||
// markdown stripped — consistently, so the suggestion-uniqueness gate stays
|
||||
// coherent — while what gets STORED remains the raw document substring.
|
||||
// ---------------------------------------------------------------------------
|
||||
test("a markdown-styled selection anchors against plain doc text via the strip fallback", () => {
|
||||
const doc = paragraphDoc([{ type: "text", text: "a bold word here" }]);
|
||||
// The agent quoted "**bold** word" from a styled view; the doc is plain text.
|
||||
const sel = "**bold** word";
|
||||
const resolved = resolveAnchorSelection(doc, sel);
|
||||
assert.equal(resolved.found, true, "strip fallback finds the anchor");
|
||||
assert.equal(resolved.normalized, true, "reports the soft-warning flag");
|
||||
assert.equal(canAnchorInDoc(doc, sel), true);
|
||||
assert.equal(countAnchorMatches(doc, sel), 1);
|
||||
|
||||
const ok = applyAnchorInDoc(doc, sel, COMMENT_ID);
|
||||
assert.equal(ok, true);
|
||||
const marked = doc.content[0].content.filter((p) => commentMark(p));
|
||||
assert.equal(marked.map((m) => m.text).join(""), "bold word",
|
||||
"the mark lands on the plain-text span");
|
||||
});
|
||||
|
||||
test("getAnchoredText stores the RAW doc substring even when matched via the strip fallback", () => {
|
||||
// Doc uses a smart apostrophe; the agent typed ASCII + markdown emphasis.
|
||||
const doc = paragraphDoc([{ type: "text", text: "it’s bold now" }]);
|
||||
const stored = getAnchoredText(doc, "it's **bold**");
|
||||
assert.equal(stored, "it’s bold",
|
||||
"stored selection is the raw document text, not the stripped/ASCII locator");
|
||||
});
|
||||
|
||||
test("the strip fallback does not flip a raw-unique selection to ambiguous", () => {
|
||||
// "config" appears twice, but the raw phrase "config value" appears once.
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "the config value here" }] },
|
||||
{ type: "paragraph", content: [{ type: "text", text: "another config here" }] },
|
||||
],
|
||||
};
|
||||
// Raw phrase is unique -> exactly 1, and no strip happens (nothing to strip).
|
||||
assert.equal(countAnchorMatches(doc, "config value"), 1);
|
||||
assert.equal(resolveAnchorSelection(doc, "config value").normalized, false);
|
||||
});
|
||||
|
||||
test("EXACT WINS: a raw match short-circuits the strip fallback (count reflects raw)", () => {
|
||||
// A literal "**" run exists raw once; its stripped form would also appear.
|
||||
const doc = paragraphDoc([{ type: "text", text: "use **stars** and stars" }]);
|
||||
// Raw "**stars**" occurs once -> count 1 from the verbatim locator; the
|
||||
// fallback (which would find two "stars") never runs.
|
||||
assert.equal(countAnchorMatches(doc, "**stars**"), 1);
|
||||
assert.equal(resolveAnchorSelection(doc, "**stars**").normalized, false);
|
||||
});
|
||||
|
||||
test("a markdown selection whose stripped form is ambiguous is counted as ambiguous", () => {
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "first config here" }] },
|
||||
{ type: "paragraph", content: [{ type: "text", text: "second config here" }] },
|
||||
],
|
||||
};
|
||||
// Verbatim "**config**" matches nothing; stripped "config" matches twice.
|
||||
assert.equal(countAnchorMatches(doc, "**config**"), 2);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// Unit tests for the pure-TS schematic SVG preview (issue #423). Asserts that
|
||||
// the preview emits well-formed SVG covering each primitive (rect/ellipse/
|
||||
// rhombus/edge), resolves container-relative coordinates to absolute, and that
|
||||
// the full `.drawio.svg` wrapper parses.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { renderDiagramShapes } from "../../build/lib/drawio-preview.js";
|
||||
import {
|
||||
parseCells,
|
||||
computeBBox,
|
||||
buildDrawioSvg,
|
||||
normalizeXml,
|
||||
} from "../../build/lib/drawio-xml.js";
|
||||
|
||||
const { window } = new JSDOM("");
|
||||
function parseSvg(svg) {
|
||||
const doc = new window.DOMParser().parseFromString(svg, "application/xml");
|
||||
const err = doc.getElementsByTagName("parsererror");
|
||||
assert.equal(err.length, 0, `SVG did not parse: ${err[0]?.textContent}`);
|
||||
return doc;
|
||||
}
|
||||
|
||||
const MODEL =
|
||||
'<mxGraphModel><root>' +
|
||||
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
|
||||
'<mxCell id="2" value="Box & Co" style="rounded=1;fillColor=#dae8fc;strokeColor=#6c8ebf;" vertex="1" parent="1">' +
|
||||
'<mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell>' +
|
||||
'<mxCell id="3" value="Circle" style="ellipse;fillColor=#d5e8d4;" vertex="1" parent="1">' +
|
||||
'<mxGeometry x="240" y="40" width="80" height="80" as="geometry"/></mxCell>' +
|
||||
'<mxCell id="5" value="Dec" style="rhombus;" vertex="1" parent="1">' +
|
||||
'<mxGeometry x="40" y="160" width="100" height="60" as="geometry"/></mxCell>' +
|
||||
'<mxCell id="4" value="link" edge="1" parent="1" source="2" target="3"><mxGeometry relative="1" as="geometry"/></mxCell>' +
|
||||
'</root></mxGraphModel>';
|
||||
|
||||
test("renderDiagramShapes emits rect, ellipse, polygon and a line", () => {
|
||||
const cells = parseCells(MODEL);
|
||||
const inner = renderDiagramShapes(cells, computeBBox(cells));
|
||||
assert.ok(inner.includes("<rect"), "has a rect");
|
||||
assert.ok(inner.includes("<ellipse"), "has an ellipse");
|
||||
assert.ok(inner.includes("<polygon"), "has a polygon (rhombus)");
|
||||
assert.ok(inner.includes("<line"), "has an edge line");
|
||||
// Labels are HTML-escaped.
|
||||
assert.ok(inner.includes("Box & Co"));
|
||||
});
|
||||
|
||||
test("the full .drawio.svg wrapper parses as valid XML", () => {
|
||||
const cells = parseCells(MODEL);
|
||||
const bbox = computeBBox(cells);
|
||||
const inner = renderDiagramShapes(cells, bbox);
|
||||
const svg = buildDrawioSvg(normalizeXml(MODEL), inner, bbox);
|
||||
const doc = parseSvg(svg);
|
||||
assert.equal(doc.documentElement.tagName, "svg");
|
||||
assert.ok(doc.documentElement.getAttribute("content"), "carries content=");
|
||||
// The visible children exist.
|
||||
assert.ok(doc.getElementsByTagName("rect").length >= 1);
|
||||
});
|
||||
|
||||
test("container children resolve to absolute coordinates", () => {
|
||||
// A group at (100,100) with a child rect at relative (10,10,20,20) -> abs 110,110.
|
||||
const model =
|
||||
'<mxGraphModel><root>' +
|
||||
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
|
||||
'<mxCell id="g" style="group;" vertex="1" parent="1"><mxGeometry x="100" y="100" width="200" height="200" as="geometry"/></mxCell>' +
|
||||
'<mxCell id="c" value="in" vertex="1" parent="g"><mxGeometry x="10" y="10" width="20" height="20" as="geometry"/></mxCell>' +
|
||||
'</root></mxGraphModel>';
|
||||
const cells = parseCells(model);
|
||||
const inner = renderDiagramShapes(cells, computeBBox(cells));
|
||||
// The child rect must be placed at absolute x=110,y=110.
|
||||
assert.ok(/<rect x="110" y="110"/.test(inner), `expected abs child rect, got: ${inner}`);
|
||||
});
|
||||
|
||||
test("unknown stencil (shape=mxgraph.*) degrades to a labeled rectangle", () => {
|
||||
const model =
|
||||
'<mxGraphModel><root>' +
|
||||
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
|
||||
'<mxCell id="2" value="AWS" style="shape=mxgraph.aws4.lambda;" vertex="1" parent="1">' +
|
||||
'<mxGeometry x="0" y="0" width="60" height="60" as="geometry"/></mxCell>' +
|
||||
'</root></mxGraphModel>';
|
||||
const cells = parseCells(model);
|
||||
const inner = renderDiagramShapes(cells, computeBBox(cells));
|
||||
assert.ok(inner.includes("<rect"), "unknown stencil -> rect");
|
||||
assert.ok(inner.includes(">AWS<"), "keeps the label");
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
// Unit tests for the drawio-xml module (issue #423): the linter (a positive
|
||||
// baseline + a negative case per rule, each asserting rule + cellId), the
|
||||
// decode chain (plain nested XML AND draw.io's compressed <diagram> via pako),
|
||||
// encode/round-trip byte-stability, hash stability, style parsing and the
|
||||
// bounding box.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import pako from "pako";
|
||||
import {
|
||||
parseStyle,
|
||||
lintModel,
|
||||
prepareModel,
|
||||
normalizeInput,
|
||||
normalizeXml,
|
||||
mxHash,
|
||||
computeBBox,
|
||||
parseCells,
|
||||
decodeDrawioSvg,
|
||||
decodeDrawioFileToModel,
|
||||
buildDrawioSvg,
|
||||
encodeDrawioFile,
|
||||
countUserCells,
|
||||
DrawioLintError,
|
||||
inflateDiagramPayload,
|
||||
MAX_INFLATED_DIAGRAM_BYTES,
|
||||
} from "../../build/lib/drawio-xml.js";
|
||||
|
||||
// A well-formed model with one vertex and a valid edge to it.
|
||||
const VALID_MODEL =
|
||||
'<mxGraphModel><root>' +
|
||||
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
|
||||
'<mxCell id="2" value="Hello" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">' +
|
||||
'<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/></mxCell>' +
|
||||
'<mxCell id="3" value="Two" style="ellipse;" vertex="1" parent="1">' +
|
||||
'<mxGeometry x="300" y="100" width="80" height="80" as="geometry"/></mxCell>' +
|
||||
'<mxCell id="4" edge="1" parent="1" source="2" target="3">' +
|
||||
'<mxGeometry relative="1" as="geometry"/></mxCell>' +
|
||||
'</root></mxGraphModel>';
|
||||
|
||||
function issuesOf(fn) {
|
||||
try {
|
||||
fn();
|
||||
return null;
|
||||
} catch (e) {
|
||||
assert.ok(e instanceof DrawioLintError, `expected DrawioLintError, got ${e}`);
|
||||
return e.issues;
|
||||
}
|
||||
}
|
||||
const hasRule = (issues, rule, cellId) =>
|
||||
issues.some(
|
||||
(i) => i.rule === rule && (cellId === undefined || i.cellId === cellId),
|
||||
);
|
||||
|
||||
// --- style parsing ---------------------------------------------------------
|
||||
|
||||
test("parseStyle: base stylename + key=value pairs", () => {
|
||||
const r = parseStyle("ellipse;fillColor=#ff0000;whiteSpace=wrap;");
|
||||
assert.equal(r.baseStyle, "ellipse");
|
||||
assert.equal(r.map.fillColor, "#ff0000");
|
||||
assert.equal(r.map.whiteSpace, "wrap");
|
||||
assert.equal(r.badSegment, undefined);
|
||||
});
|
||||
|
||||
test("parseStyle: flags a segment with two '='", () => {
|
||||
const r = parseStyle("a=b=c;");
|
||||
assert.equal(r.badSegment, "a=b=c");
|
||||
});
|
||||
|
||||
test("parseStyle: a second bare token is malformed", () => {
|
||||
const r = parseStyle("rounded=1;bareword");
|
||||
assert.equal(r.badSegment, "bareword");
|
||||
});
|
||||
|
||||
// --- linter: positive baseline ---------------------------------------------
|
||||
|
||||
test("lintModel: the canonical valid model passes", () => {
|
||||
const { cells } = lintModel(VALID_MODEL);
|
||||
assert.equal(cells.length, 5);
|
||||
});
|
||||
|
||||
// --- linter: one negative case per rule ------------------------------------
|
||||
|
||||
test("rule well-formed-xml: malformed XML", () => {
|
||||
const issues = issuesOf(() => lintModel("<mxGraphModel><root><mxCell id=\"0\"></root>"));
|
||||
assert.ok(hasRule(issues, "well-formed-xml"));
|
||||
assert.ok(issues[0].position, "carries a line:col position");
|
||||
});
|
||||
|
||||
test("rule structure: root is not mxGraphModel", () => {
|
||||
const issues = issuesOf(() => lintModel("<foo><root/></foo>"));
|
||||
assert.ok(hasRule(issues, "structure"));
|
||||
});
|
||||
|
||||
test("rule sentinel-cells: missing id=0 / id=1", () => {
|
||||
const m =
|
||||
'<mxGraphModel><root>' +
|
||||
'<mxCell id="2" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>' +
|
||||
'</root></mxGraphModel>';
|
||||
const issues = issuesOf(() => lintModel(m));
|
||||
assert.ok(hasRule(issues, "sentinel-cells", "0"));
|
||||
assert.ok(hasRule(issues, "sentinel-cells", "1"));
|
||||
});
|
||||
|
||||
test("rule duplicate-id: two cells share an id", () => {
|
||||
const m =
|
||||
'<mxGraphModel><root>' +
|
||||
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
|
||||
'<mxCell id="2" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>' +
|
||||
'<mxCell id="2" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>' +
|
||||
'</root></mxGraphModel>';
|
||||
const issues = issuesOf(() => lintModel(m));
|
||||
assert.ok(hasRule(issues, "duplicate-id", "2"));
|
||||
});
|
||||
|
||||
test("rule vertex-edge-exclusive: cell is both vertex and edge", () => {
|
||||
const m =
|
||||
'<mxGraphModel><root>' +
|
||||
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
|
||||
'<mxCell id="2" vertex="1" edge="1" parent="1"><mxGeometry as="geometry"/></mxCell>' +
|
||||
'</root></mxGraphModel>';
|
||||
const issues = issuesOf(() => lintModel(m));
|
||||
assert.ok(hasRule(issues, "vertex-edge-exclusive", "2"));
|
||||
});
|
||||
|
||||
test("rule edge-geometry: self-closed edge without child mxGeometry", () => {
|
||||
const m =
|
||||
'<mxGraphModel><root>' +
|
||||
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
|
||||
'<mxCell id="2" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>' +
|
||||
'<mxCell id="3" vertex="1" parent="1"><mxGeometry x="30" y="0" width="10" height="10" as="geometry"/></mxCell>' +
|
||||
'<mxCell id="4" edge="1" parent="1" source="2" target="3"/>' +
|
||||
'</root></mxGraphModel>';
|
||||
const issues = issuesOf(() => lintModel(m));
|
||||
assert.ok(hasRule(issues, "edge-geometry", "4"));
|
||||
});
|
||||
|
||||
test("rule edge-endpoint: source/target does not resolve", () => {
|
||||
const m =
|
||||
'<mxGraphModel><root>' +
|
||||
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
|
||||
'<mxCell id="2" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>' +
|
||||
'<mxCell id="4" edge="1" parent="1" source="2" target="99"><mxGeometry relative="1" as="geometry"/></mxCell>' +
|
||||
'</root></mxGraphModel>';
|
||||
const issues = issuesOf(() => lintModel(m));
|
||||
assert.ok(hasRule(issues, "edge-endpoint", "4"));
|
||||
});
|
||||
|
||||
test("rule parent-exists: parent points at a missing id", () => {
|
||||
const m =
|
||||
'<mxGraphModel><root>' +
|
||||
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
|
||||
'<mxCell id="2" vertex="1" parent="42"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>' +
|
||||
'</root></mxGraphModel>';
|
||||
const issues = issuesOf(() => lintModel(m));
|
||||
assert.ok(hasRule(issues, "parent-exists", "2"));
|
||||
});
|
||||
|
||||
test("rule no-comments: XML comment present", () => {
|
||||
const m =
|
||||
'<mxGraphModel><root>' +
|
||||
'<!-- a comment --><mxCell id="0"/><mxCell id="1" parent="0"/>' +
|
||||
'</root></mxGraphModel>';
|
||||
const issues = issuesOf(() => lintModel(m));
|
||||
assert.ok(hasRule(issues, "no-comments"));
|
||||
});
|
||||
|
||||
test("rule style-format: malformed style segment (cellId reported)", () => {
|
||||
const m =
|
||||
'<mxGraphModel><root>' +
|
||||
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
|
||||
'<mxCell id="2" style="rounded=1;a=b=c;" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>' +
|
||||
'</root></mxGraphModel>';
|
||||
const issues = issuesOf(() => lintModel(m));
|
||||
assert.ok(hasRule(issues, "style-format", "2"));
|
||||
});
|
||||
|
||||
test("rule value-newline: literal newline in a value (cellId reported)", () => {
|
||||
const m =
|
||||
'<mxGraphModel><root>' +
|
||||
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
|
||||
'<mxCell id="2" value="line1\nline2" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>' +
|
||||
'</root></mxGraphModel>';
|
||||
const issues = issuesOf(() => lintModel(m));
|
||||
assert.ok(hasRule(issues, "value-newline", "2"));
|
||||
});
|
||||
|
||||
test("rule value-escaping: unescaped ampersand in a value (cellId reported)", () => {
|
||||
const m =
|
||||
'<mxGraphModel><root>' +
|
||||
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
|
||||
'<mxCell id="2" value="A & B" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>' +
|
||||
'</root></mxGraphModel>';
|
||||
const issues = issuesOf(() => lintModel(m));
|
||||
assert.ok(hasRule(issues, "value-escaping", "2"));
|
||||
});
|
||||
|
||||
test("rule reserved id: escaped entity value passes (no false positive)", () => {
|
||||
const m =
|
||||
'<mxGraphModel><root>' +
|
||||
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
|
||||
'<mxCell id="2" value="A & B <ok>" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>' +
|
||||
'</root></mxGraphModel>';
|
||||
assert.doesNotThrow(() => lintModel(m));
|
||||
});
|
||||
|
||||
// --- input normalization ---------------------------------------------------
|
||||
|
||||
test("normalizeInput: a list of <mxCell> is wrapped and sentinels added", () => {
|
||||
const frag =
|
||||
'<mxCell id="2" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>';
|
||||
const model = normalizeInput(frag);
|
||||
assert.ok(model.startsWith("<mxGraphModel"));
|
||||
assert.ok(model.includes('<mxCell id="0"/>'));
|
||||
assert.ok(model.includes('<mxCell id="1" parent="0"/>'));
|
||||
// And it lints clean.
|
||||
assert.doesNotThrow(() => lintModel(model));
|
||||
});
|
||||
|
||||
test("normalizeInput: an existing sentinel is not duplicated", () => {
|
||||
const frag =
|
||||
'<mxCell id="0"/><mxCell id="1" parent="0"/>' +
|
||||
'<mxCell id="2" vertex="1" parent="1"><mxGeometry x="0" y="0" width="10" height="10" as="geometry"/></mxCell>';
|
||||
const model = normalizeInput(frag);
|
||||
const count0 = (model.match(/id="0"/g) || []).length;
|
||||
assert.equal(count0, 1);
|
||||
});
|
||||
|
||||
test("prepareModel: returns bbox, cellCount, hash and lints", () => {
|
||||
const p = prepareModel(VALID_MODEL);
|
||||
assert.equal(p.cellCount, 3); // 2, 3, 4 (sentinels excluded)
|
||||
assert.ok(p.bbox.width > 0 && p.bbox.height > 0);
|
||||
assert.equal(p.hash, mxHash(normalizeXml(VALID_MODEL)));
|
||||
});
|
||||
|
||||
// --- decode chain: plain -----------------------------------------------------
|
||||
|
||||
test("decode chain (plain): buildDrawioSvg -> decodeDrawioSvg round-trips byte-stable", () => {
|
||||
const model = normalizeXml(VALID_MODEL);
|
||||
const svg = buildDrawioSvg(model, "<g/>", { width: 400, height: 200 });
|
||||
const decoded = decodeDrawioSvg(svg);
|
||||
assert.equal(decoded, model);
|
||||
});
|
||||
|
||||
test("decode chain: entity-encoded content= (draw.io export style) is read directly", () => {
|
||||
const model = normalizeXml(VALID_MODEL);
|
||||
const file = encodeDrawioFile(model, "Page-1");
|
||||
const escaped = file
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" content="${escaped}"></svg>`;
|
||||
const decoded = decodeDrawioSvg(svg);
|
||||
assert.equal(decoded, model);
|
||||
});
|
||||
|
||||
// --- decode chain: compressed (pako) ---------------------------------------
|
||||
|
||||
test("decode chain (compressed pako): human-saved <diagram> payload decodes losslessly", () => {
|
||||
const model = normalizeXml(VALID_MODEL);
|
||||
// Reproduce draw.io's compression: encodeURIComponent -> raw deflate -> base64.
|
||||
const compressed = Buffer.from(
|
||||
pako.deflateRaw(encodeURIComponent(model)),
|
||||
).toString("base64");
|
||||
const file = `<mxfile host="Electron"><diagram id="abc" name="Page-1">${compressed}</diagram></mxfile>`;
|
||||
// Docmost stores the file base64 in content=.
|
||||
const contentB64 = Buffer.from(file, "utf-8").toString("base64");
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" content="${contentB64}"><image href="x"/></svg>`;
|
||||
const decoded = decodeDrawioSvg(svg);
|
||||
assert.equal(decoded, model);
|
||||
});
|
||||
|
||||
test("decodeDrawioFileToModel: bare mxGraphModel file returns the model substring", () => {
|
||||
const model = normalizeXml(VALID_MODEL);
|
||||
assert.equal(decodeDrawioFileToModel(model), model);
|
||||
});
|
||||
|
||||
// --- hash stability --------------------------------------------------------
|
||||
|
||||
test("mxHash: stable across inter-tag whitespace, sensitive to content", () => {
|
||||
const a = VALID_MODEL;
|
||||
const b = VALID_MODEL.replace(/></g, ">\n <"); // reformat only
|
||||
assert.equal(mxHash(a), mxHash(b));
|
||||
const c = VALID_MODEL.replace('value="Hello"', 'value="Changed"');
|
||||
assert.notEqual(mxHash(a), mxHash(c));
|
||||
});
|
||||
|
||||
// --- bounding box + cell count ---------------------------------------------
|
||||
|
||||
test("computeBBox + countUserCells", () => {
|
||||
const cells = parseCells(VALID_MODEL);
|
||||
const bbox = computeBBox(cells);
|
||||
// Vertex 3 spans to x=380,y=180; plus the 20px margin.
|
||||
assert.equal(bbox.width, 400);
|
||||
assert.equal(bbox.height, 200);
|
||||
assert.equal(countUserCells(VALID_MODEL), 3);
|
||||
});
|
||||
|
||||
// --- decompression-bomb guard (Fix 3) --------------------------------------
|
||||
|
||||
test("inflateDiagramPayload: a small legitimate payload inflates fine", () => {
|
||||
const xml = normalizeXml(VALID_MODEL);
|
||||
const base64 = Buffer.from(
|
||||
pako.deflateRaw(encodeURIComponent(xml)),
|
||||
).toString("base64");
|
||||
assert.equal(inflateDiagramPayload(base64), xml);
|
||||
});
|
||||
|
||||
test("inflateDiagramPayload: rejects an over-cap decompression bomb", () => {
|
||||
// A tiny compressed payload that inflates to just over the cap. Highly
|
||||
// compressible (all one byte) -> the base64 is small, but the inflated output
|
||||
// exceeds MAX_INFLATED_DIAGRAM_BYTES and must be refused before it is fully
|
||||
// materialised.
|
||||
const bombSize = MAX_INFLATED_DIAGRAM_BYTES + 1024;
|
||||
const base64 = Buffer.from(
|
||||
pako.deflateRaw(Buffer.alloc(bombSize, 0x41 /* 'A' */)),
|
||||
).toString("base64");
|
||||
assert.ok(
|
||||
base64.length < 1024 * 1024,
|
||||
"the compressed bomb is tiny relative to its inflated size",
|
||||
);
|
||||
assert.throws(
|
||||
() => inflateDiagramPayload(base64),
|
||||
/decompression bomb/,
|
||||
);
|
||||
});
|
||||
|
||||
test("encode/build: a title with < > \" & round-trips without corrupting the SVG", async () => {
|
||||
// A user-supplied title full of XML metacharacters must be escaped so the
|
||||
// inner <mxfile> stays well-formed and the outer content="..." attribute is
|
||||
// never broken out of. Prove it survives the encode -> build -> decode chain.
|
||||
const title = 'A < B > C " D & E';
|
||||
const model = normalizeXml(VALID_MODEL);
|
||||
const svg = buildDrawioSvg(model, "<g/>", { width: 200, height: 120 }, title);
|
||||
|
||||
// The outer content="..." attribute must not be broken by the title: the raw
|
||||
// title metacharacters never appear literally in the SVG markup (they are
|
||||
// base64-encoded inside content=, and escaped inside the file XML).
|
||||
const contentMatch = /content="([^"]*)"/.exec(svg);
|
||||
assert.ok(contentMatch, "SVG has a single well-formed content= attribute");
|
||||
|
||||
// The diagram model still decodes losslessly despite the exotic title.
|
||||
assert.equal(decodeDrawioSvg(svg), model);
|
||||
|
||||
// The file XML is well-formed: the title lives in name="..." as escaped
|
||||
// entities, so unescaping recovers the original title byte-for-byte.
|
||||
const fileXml = Buffer.from(contentMatch[1], "base64").toString("utf-8");
|
||||
const nameMatch = /<diagram id="[^"]*" name="([^"]*)">/.exec(fileXml);
|
||||
assert.ok(nameMatch, "the diagram name attribute is intact and quote-safe");
|
||||
const decodedTitle = nameMatch[1]
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&/g, "&");
|
||||
assert.equal(decodedTitle, title);
|
||||
|
||||
// encodeDrawioFile alone produces the same escaped, well-formed envelope.
|
||||
const file = encodeDrawioFile(model, title);
|
||||
assert.match(file, /name="A < B > C " D & E">/);
|
||||
});
|
||||
Generated
+3
@@ -1041,6 +1041,9 @@ importers:
|
||||
marked:
|
||||
specifier: ^17.0.1
|
||||
version: 17.0.5
|
||||
pako:
|
||||
specifier: ^2.0.3
|
||||
version: 2.0.3
|
||||
re2:
|
||||
specifier: ^1.21.0
|
||||
version: 1.25.0
|
||||
|
||||
Reference in New Issue
Block a user