Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b2af3d34a |
@@ -17,7 +17,7 @@ import {
|
||||
resolveCurrentPageResult,
|
||||
type SelectionContext,
|
||||
} from './current-page.util';
|
||||
import { parseNodeArg } from './parse-node-arg';
|
||||
import { parseNodeArg } from '@docmost/prosemirror-markdown';
|
||||
import { modelFriendlyInput } from './model-friendly-input';
|
||||
import { SandboxStore } from '../../../integrations/sandbox/sandbox.store';
|
||||
import {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { parseNodeArg } from './parse-node-arg';
|
||||
import { parseNodeArg } from '@docmost/prosemirror-markdown';
|
||||
|
||||
/**
|
||||
* Unit tests for the in-app `parseNodeArg` helper. It mirrors the standalone
|
||||
* MCP helper (packages/mcp/src/lib/parse-node-arg.ts) and is used by the
|
||||
* patchNode / insertNode / updatePageJson tool adapters. Behavior must be
|
||||
* byte-identical: object passthrough, valid-string parse, invalid-string throw.
|
||||
* Unit tests for the shared `parseNodeArg` helper (#414: now the single copy in
|
||||
* `@docmost/prosemirror-markdown`, imported by both the server tool adapters and
|
||||
* `@docmost/mcp`). Used by the patchNode / insertNode / updatePageJson adapters.
|
||||
* Behavior: object passthrough, valid-string parse, invalid-string throw.
|
||||
*/
|
||||
describe('parseNodeArg', () => {
|
||||
it('passes an object through unchanged', () => {
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
// The model sometimes serializes a ProseMirror node arg as a JSON string
|
||||
// instead of an object. Normalize: parse a string to an object (throwing on
|
||||
// invalid JSON), pass an object through unchanged. Shared by patchNode /
|
||||
// insertNode (and the analogous updatePageJson content parsing).
|
||||
//
|
||||
// This is behaviorally identical to `packages/mcp/src/lib/parse-node-arg.ts`
|
||||
// (the function logic, default/explicit throw messages and branch order match;
|
||||
// only comments and quote style differ). We cannot import that helper here:
|
||||
// `@docmost/mcp` is ESM-only and this server
|
||||
// compiles with module:commonjs, so it is loaded at runtime via the
|
||||
// `new Function('import()')` trick (see docmost-client.loader.ts). Sharing
|
||||
// runtime code across that ESM/CJS boundary by a normal import is impossible,
|
||||
// hence the mirrored copy.
|
||||
export function parseNodeArg(
|
||||
node: unknown,
|
||||
errMsg = 'node was a string but not valid JSON',
|
||||
): unknown {
|
||||
if (typeof node === 'string') {
|
||||
try {
|
||||
return JSON.parse(node);
|
||||
} catch {
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
@@ -52,7 +52,6 @@
|
||||
"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",
|
||||
|
||||
+1
-457
@@ -46,18 +46,9 @@ import {
|
||||
insertTableRow,
|
||||
deleteTableRow,
|
||||
updateTableCell,
|
||||
} from "./lib/node-ops.js";
|
||||
} from "@docmost/prosemirror-markdown";
|
||||
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,
|
||||
@@ -3441,453 +3432,6 @@ 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 ---
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,7 @@ import { readFileSync } from "fs";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname, join } from "path";
|
||||
import { DocmostClient, DocmostMcpConfig } from "./client.js";
|
||||
import { parseNodeArg } from "./lib/parse-node-arg.js";
|
||||
import { parseNodeArg } from "@docmost/prosemirror-markdown";
|
||||
import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";
|
||||
|
||||
// Re-export the client and its config type so embedding hosts (e.g. the gitmost
|
||||
@@ -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). 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" +
|
||||
"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" +
|
||||
"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,38 +460,6 @@ 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.
|
||||
|
||||
@@ -14,7 +14,7 @@ import { JSDOM } from "jsdom";
|
||||
import { markdownToProseMirror } from "@docmost/prosemirror-markdown";
|
||||
import { docmostExtensions, docmostSchema } from "./docmost-schema.js";
|
||||
import { withPageLock } from "./page-lock.js";
|
||||
import { sanitizeForYjs, findUnstorableAttr } from "./node-ops.js";
|
||||
import { sanitizeForYjs, findUnstorableAttr } from "@docmost/prosemirror-markdown";
|
||||
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
|
||||
import { summarizeChange, VerifyReport } from "./diff.js";
|
||||
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
// 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 };
|
||||
}
|
||||
@@ -1,771 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -1,136 +1,62 @@
|
||||
/**
|
||||
* Legacy footnote diagnostics for imported Markdown (issue #166).
|
||||
* Legacy footnote advisory for imported Markdown (issue #166, reduced in #414).
|
||||
*
|
||||
* A PURE, fence-aware text scan (independent of the Markdown->ProseMirror
|
||||
* conversion path, so it reports the same problems for `create_page`,
|
||||
* `update_page` and `import_page_markdown`). It never changes the document — the
|
||||
* importer still creates the page; this only surfaces footnote problems to the
|
||||
* caller so an agent can fix its own markup instead of shipping broken footnotes.
|
||||
* Since #293 STEP 5 the canonical import form is inline `^[body]` footnotes
|
||||
* (handled by `@docmost/prosemirror-markdown`). LEGACY reference-style
|
||||
* `[^id]: …` definition markup is now INERT on import — the importer leaves it as
|
||||
* literal text — so authoring it silently produces broken footnotes (the #410
|
||||
* incident class). Rather than the old, elaborate diagnostics of every problem
|
||||
* SHAPE (dangling/duplicate/empty/in-table) that no longer describe what the
|
||||
* importer builds, this module surfaces ONE advisory warning whenever legacy
|
||||
* reference-style definition syntax is present, nudging the author to the inline
|
||||
* form. It never changes the document — the importer still creates the page.
|
||||
*
|
||||
* SCOPE after #293 STEP 5: the canonical import form is now inline `^[body]`
|
||||
* footnotes (handled by `@docmost/prosemirror-markdown`), where these problems
|
||||
* cannot arise. This scan therefore targets the LEGACY reference-style
|
||||
* (`[^id]` / `[^id]:`) markup, which is now inert on import (left as literal
|
||||
* text). The warnings remain useful as an advisory nudge when an agent still
|
||||
* authors the old syntax, but they no longer describe what the importer builds.
|
||||
*
|
||||
* Detected problems:
|
||||
* - danglingReferences: a `[^id]` reference with no `[^id]:` definition.
|
||||
* - emptyDefinitions: a `[^id]:` whose (kept) text is empty/whitespace.
|
||||
* - duplicateDefinitions: an id defined by two or more `[^id]:` lines (only the
|
||||
* first would have been kept under the old first-wins import).
|
||||
* - referencesInTables: a `[^id]` marker found in a GFM table row (heuristic:
|
||||
* the line, trimmed, starts with `|`) — footnotes in table cells often do not
|
||||
* render as expected.
|
||||
* The scan is fence-aware: a `[^id]:` line inside a ``` / ~~~ code block is
|
||||
* example text, not markup, so it never triggers the warning.
|
||||
*/
|
||||
|
||||
import {
|
||||
lexFootnoteLines,
|
||||
forEachFootnoteReference,
|
||||
} from "./footnote-lex.js";
|
||||
/** A legacy footnote DEFINITION line: `[^id]:` at the start of a (non-fenced) line. */
|
||||
const FOOTNOTE_DEF_RE = /^\[\^[^\]\s]+\]:/;
|
||||
/** Opening/closing code fence marker (``` or ~~~). */
|
||||
const FENCE_RE = /^\s*(`{3,}|~{3,})/;
|
||||
|
||||
export interface FootnoteDiagnostics {
|
||||
/** Reference ids (distinct, document order) with no matching definition. */
|
||||
danglingReferences: string[];
|
||||
/** Definition ids whose first (kept) text is empty/whitespace. */
|
||||
emptyDefinitions: string[];
|
||||
/** Ids defined by two or more `[^id]:` lines (only the first is kept). */
|
||||
duplicateDefinitions: string[];
|
||||
/** Reference ids found inside a GFM table row (heuristic). */
|
||||
referencesInTables: string[];
|
||||
/** Human-readable warning lines for the tool result (one per problem class). */
|
||||
warnings: string[];
|
||||
}
|
||||
/** The single advisory shown when legacy reference-style footnotes are present. */
|
||||
export const LEGACY_FOOTNOTE_WARNING =
|
||||
"Reference-style footnotes (`[^id]: …`) are not parsed on import and will " +
|
||||
"appear as literal text. Use inline footnotes instead: `^[footnote text]`.";
|
||||
|
||||
/**
|
||||
* Analyze the footnotes in a Markdown string. Pure; safe to call on any body.
|
||||
* True when `markdown` contains a legacy `[^id]:` definition line OUTSIDE any
|
||||
* code fence. Pure; safe to call on any body.
|
||||
*/
|
||||
export function analyzeFootnotes(markdown: string): FootnoteDiagnostics {
|
||||
// Distinct reference ids in first-appearance order, plus the set of ids seen
|
||||
// inside a table row.
|
||||
const refIds: string[] = [];
|
||||
const refIdSet = new Set<string>();
|
||||
const referencesInTables = new Set<string>();
|
||||
const addRef = (id: string, inTable: boolean) => {
|
||||
if (!refIdSet.has(id)) {
|
||||
refIdSet.add(id);
|
||||
refIds.push(id);
|
||||
}
|
||||
if (inTable) referencesInTables.add(id);
|
||||
};
|
||||
|
||||
// Definition texts per id, in first-appearance order of the id.
|
||||
const defTextsById = new Map<string, string[]>();
|
||||
|
||||
// Same lexer the importer uses, so the analysis matches exactly what import
|
||||
// keeps/strips (#166): fenced lines are inert, definition lines are pulled.
|
||||
for (const tok of lexFootnoteLines(markdown)) {
|
||||
if (tok.inFence) continue;
|
||||
if (tok.definition) {
|
||||
const { id, text } = tok.definition;
|
||||
const arr = defTextsById.get(id);
|
||||
if (arr) arr.push(text);
|
||||
else defTextsById.set(id, [text]);
|
||||
// A definition's TEXT can itself reference another footnote (`[^a]: see
|
||||
// [^b]`); count those so such a `[^b]` is not falsely reported dangling.
|
||||
forEachFootnoteReference(text, (rid) => addRef(rid, false));
|
||||
export function hasLegacyFootnoteDefinition(markdown: string): boolean {
|
||||
if (typeof markdown !== "string" || !markdown.includes("[^")) return false;
|
||||
let fence: string | null = null;
|
||||
for (const line of markdown.split("\n")) {
|
||||
const fenceMatch = FENCE_RE.exec(line);
|
||||
if (fenceMatch) {
|
||||
const marker = fenceMatch[1][0];
|
||||
if (fence === null) fence = marker; // opening fence
|
||||
else if (marker === fence) fence = null; // matching closing fence
|
||||
continue;
|
||||
}
|
||||
const inTable = tok.line.trimStart().startsWith("|");
|
||||
forEachFootnoteReference(tok.line, (id) => addRef(id, inTable));
|
||||
if (fence !== null) continue; // inside a fence: inert example text
|
||||
if (FOOTNOTE_DEF_RE.test(line)) return true;
|
||||
}
|
||||
|
||||
const danglingReferences = refIds.filter((id) => !defTextsById.has(id));
|
||||
const duplicateDefinitions: string[] = [];
|
||||
const emptyDefinitions: string[] = [];
|
||||
for (const [id, texts] of defTextsById) {
|
||||
if (texts.length >= 2) duplicateDefinitions.push(id);
|
||||
// First-wins: the kept definition is the first one; flag it if it is blank.
|
||||
if ((texts[0] ?? "").trim().length === 0) emptyDefinitions.push(id);
|
||||
}
|
||||
const tableRefs = [...referencesInTables];
|
||||
|
||||
const warnings: string[] = [];
|
||||
const list = (ids: string[]) => ids.map((id) => `[^${id}]`).join(", ");
|
||||
if (danglingReferences.length > 0) {
|
||||
warnings.push(
|
||||
`Footnote reference(s) with no matching definition: ${list(danglingReferences)} (each will render as an empty footnote in the editor).`,
|
||||
);
|
||||
}
|
||||
if (emptyDefinitions.length > 0) {
|
||||
warnings.push(
|
||||
`Footnote definition(s) with empty text: ${list(emptyDefinitions)}.`,
|
||||
);
|
||||
}
|
||||
if (duplicateDefinitions.length > 0) {
|
||||
warnings.push(
|
||||
`Footnote id(s) defined more than once (only the first definition was kept): ${list(duplicateDefinitions)}.`,
|
||||
);
|
||||
}
|
||||
if (tableRefs.length > 0) {
|
||||
warnings.push(
|
||||
`Footnote marker(s) inside a table row (footnotes in table cells may not render as expected): ${list(tableRefs)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
danglingReferences,
|
||||
emptyDefinitions,
|
||||
duplicateDefinitions,
|
||||
referencesInTables: tableRefs,
|
||||
warnings,
|
||||
};
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The optional `footnoteWarnings` field for a page-write tool result: present
|
||||
* (with the warning lines) only when `markdown` has footnote problems, omitted
|
||||
* otherwise. One helper so all three call sites (create/update/import) attach the
|
||||
* field identically. Spread into the result: `{ ...result, ...footnoteWarningsField(text) }`.
|
||||
* (with the single advisory) only when `markdown` uses legacy reference-style
|
||||
* footnote syntax, omitted otherwise. One helper so all three call sites
|
||||
* (create/update/import) attach the field identically. Spread into the result:
|
||||
* `{ ...result, ...footnoteWarningsField(text) }`.
|
||||
*/
|
||||
export function footnoteWarningsField(markdown: string): {
|
||||
footnoteWarnings?: string[];
|
||||
} {
|
||||
const { warnings } = analyzeFootnotes(markdown);
|
||||
return warnings.length > 0 ? { footnoteWarnings: warnings } : {};
|
||||
return hasLegacyFootnoteDefinition(markdown)
|
||||
? { footnoteWarnings: [LEGACY_FOOTNOTE_WARNING] }
|
||||
: {};
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* Inline-authoring helpers for footnotes (MCP).
|
||||
*
|
||||
* These build/identify footnote DEFINITION nodes for the author-inline tool
|
||||
* (`insertInlineFootnote` in transforms.ts): a content key to de-duplicate notes
|
||||
* by text, a definition-node factory, and a fresh uuidv7-style id generator.
|
||||
*
|
||||
* Split out of `footnote-canonicalize.ts` so that module stays a pure MIRROR of
|
||||
* the editor-ext canonicalizer (compositionally symmetric to the editor-ext
|
||||
* copy, which keeps its authoring helpers in `footnote-util.ts`). The pure
|
||||
* canonicalizer has no dependency on these.
|
||||
*/
|
||||
|
||||
const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition";
|
||||
|
||||
function cloneJson<T>(v: T): T {
|
||||
if (typeof structuredClone === "function") return structuredClone(v);
|
||||
return JSON.parse(JSON.stringify(v)) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized content key for de-duplicating footnote DEFINITIONS by their text.
|
||||
*
|
||||
* Two definitions with the same key are the SAME footnote — so the inline
|
||||
* authoring tool reuses one id (one number, one definition, several references)
|
||||
* instead of minting a second definition. Key = plaintext (whitespace-collapsed,
|
||||
* trimmed) PLUS a signature of the inline mark types in order, so two notes that
|
||||
* read the same but differ in formatting (one bold, one plain) are NOT merged.
|
||||
* Conservative: only an exact match merges.
|
||||
*/
|
||||
export function footnoteContentKey(defNode: any): string {
|
||||
const parts: string[] = [];
|
||||
const visit = (n: any): void => {
|
||||
if (!n || typeof n !== "object") return;
|
||||
if (n.type === "text" && typeof n.text === "string") {
|
||||
const marks = Array.isArray(n.marks)
|
||||
? n.marks.map((m: any) => m?.type).filter(Boolean).sort().join(",")
|
||||
: "";
|
||||
parts.push(`${n.text}${marks}`);
|
||||
}
|
||||
if (Array.isArray(n.content)) for (const c of n.content) visit(c);
|
||||
};
|
||||
visit(defNode);
|
||||
// Collapse the assembled text's whitespace and trim, keeping the mark
|
||||
// signature attached so formatting differences still distinguish notes.
|
||||
return parts
|
||||
.join("")
|
||||
.replace(/[ \t\r\n]+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a footnoteDefinition node from inline ProseMirror nodes, keyed by id.
|
||||
*/
|
||||
export function makeFootnoteDefinition(id: string, inlineNodes: any[]): any {
|
||||
const content = Array.isArray(inlineNodes) ? cloneJson(inlineNodes) : [];
|
||||
return {
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id },
|
||||
content: [{ type: "paragraph", content }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a uuidv7-style id (time-ordered), matching editor-ext's
|
||||
* `generateFootnoteId`. Used for a genuinely-new inline footnote id.
|
||||
*/
|
||||
export function generateFootnoteId(): string {
|
||||
const now = Date.now();
|
||||
const timeHex = now.toString(16).padStart(12, "0");
|
||||
const rand = (length: number) => {
|
||||
let s = "";
|
||||
for (let i = 0; i < length; i++)
|
||||
s += Math.floor(Math.random() * 16).toString(16);
|
||||
return s;
|
||||
};
|
||||
const versioned = "7" + rand(3);
|
||||
const variantNibble = (8 + Math.floor(Math.random() * 4)).toString(16);
|
||||
const variant = variantNibble + rand(3);
|
||||
return (
|
||||
timeHex.slice(0, 8) +
|
||||
"-" +
|
||||
timeHex.slice(8, 12) +
|
||||
"-" +
|
||||
versioned +
|
||||
"-" +
|
||||
variant +
|
||||
"-" +
|
||||
rand(12)
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
* `canonicalizeFootnotes(doc)` is a pure ProseMirror-JSON port of the editor's
|
||||
* `footnoteSyncPlugin` end-state, identical in behaviour to
|
||||
* `@docmost/editor-ext`'s `canonicalizeFootnotes`. It is mirrored here — rather
|
||||
* than imported from editor-ext — for the SAME reason `footnote-lex.ts` and the
|
||||
* `docmost-schema.ts` nodes are mirrored: the MCP package is deliberately
|
||||
* than imported from editor-ext — for the SAME reason the `docmost-schema.ts`
|
||||
* nodes are mirrored: the MCP package is deliberately
|
||||
* decoupled from the browser/React-heavy editor barrel and operates on plain
|
||||
* JSON. The editor-ext copy owns the golden test against the live plugin; this
|
||||
* copy must stay behaviourally identical (a SHARED golden corpus, exercised by
|
||||
@@ -13,8 +13,8 @@
|
||||
*
|
||||
* This module is the pure MIRROR only. The inline-authoring helpers
|
||||
* (`footnoteContentKey`, `makeFootnoteDefinition`, `generateFootnoteId`) used by
|
||||
* `insertInlineFootnote` live in the sibling `footnote-authoring.ts`, so this
|
||||
* file is compositionally symmetric to the editor-ext copy.
|
||||
* `insertInlineFootnote` live in `@docmost/prosemirror-markdown` (next to the
|
||||
* importer's `assembleFootnotes`, #414), so this file stays a pure mirror.
|
||||
*
|
||||
* Why it exists: every NON-editor write path (markdown import, update_page_json,
|
||||
* docmost_transform, insert_footnote) builds ProseMirror JSON directly, so the
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
/**
|
||||
* Shared, fence-aware line lexer for legacy footnote markdown (MCP-internal).
|
||||
*
|
||||
* Since #293 STEP 5 the markdown -> ProseMirror IMPORT path lives in the shared
|
||||
* `@docmost/prosemirror-markdown` package (inline `^[body]` footnotes), so this
|
||||
* lexer no longer backs an mcp importer. It now backs ONLY the import-time
|
||||
* diagnostics (`analyzeFootnotes` in footnote-analyze.ts), which still scan the
|
||||
* raw markdown for legacy reference-style `[^id]:` definition lines and surface
|
||||
* advisory warnings (duplicate/orphan definitions) about content that is now
|
||||
* inert on import. Fence-awareness (a `[^id]:` line inside a ``` / ~~~ block is
|
||||
* NOT a definition) is the property the analyzer relies on.
|
||||
*
|
||||
* NOTE: this is deliberately NOT shared with editor-ext's
|
||||
* `extractFootnoteDefinitions` — that lives in a different package and the
|
||||
* decoupling between the editor and the MCP mirror is intentional.
|
||||
*/
|
||||
|
||||
/** A footnote DEFINITION line: `[^id]: text` (id + text captured). */
|
||||
export const FOOTNOTE_DEF_RE = /^\[\^([^\]\s]+)\]:[ \t]*(.*)$/;
|
||||
/** Every footnote REFERENCE `[^id]` in a line (global; id captured). */
|
||||
export const FOOTNOTE_REF_RE_G = /\[\^([^\]\s]+)\]/g;
|
||||
/** Opening/closing code fence marker (``` or ~~~). */
|
||||
const FENCE_RE = /^(\s*)(`{3,}|~{3,})/;
|
||||
|
||||
export interface FootnoteLine {
|
||||
/** The raw line, verbatim. */
|
||||
line: string;
|
||||
/**
|
||||
* True for a code-fence marker line AND every line inside a fence — footnote
|
||||
* syntax on such lines is inert (example text, not real markup). The importer
|
||||
* keeps these in the body; the analyzer skips them.
|
||||
*/
|
||||
inFence: boolean;
|
||||
/** The parsed definition, when this is a `[^id]: text` line OUTSIDE any fence. */
|
||||
definition: { id: string; text: string } | null;
|
||||
}
|
||||
|
||||
/** Classify every line of `markdown`, tracking fenced-code state. Pure. */
|
||||
export function lexFootnoteLines(markdown: string): FootnoteLine[] {
|
||||
const out: FootnoteLine[] = [];
|
||||
let fence: string | null = null;
|
||||
for (const line of markdown.split("\n")) {
|
||||
const fenceMatch = FENCE_RE.exec(line);
|
||||
if (fenceMatch) {
|
||||
const marker = fenceMatch[2][0];
|
||||
if (fence === null) fence = marker; // opening fence
|
||||
else if (marker === fence) fence = null; // matching closing fence
|
||||
out.push({ line, inFence: true, definition: null });
|
||||
continue;
|
||||
}
|
||||
if (fence !== null) {
|
||||
out.push({ line, inFence: true, definition: null });
|
||||
continue;
|
||||
}
|
||||
const m = FOOTNOTE_DEF_RE.exec(line);
|
||||
out.push({
|
||||
line,
|
||||
inFence: false,
|
||||
definition: m ? { id: m[1], text: m[2] } : null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Scan a line for every `[^id]` reference, invoking `onRef(id)` for each. */
|
||||
export function forEachFootnoteReference(
|
||||
line: string,
|
||||
onRef: (id: string) => void,
|
||||
): void {
|
||||
FOOTNOTE_REF_RE_G.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = FOOTNOTE_REF_RE_G.exec(line)) !== null) onRef(m[1]);
|
||||
}
|
||||
@@ -1,963 +0,0 @@
|
||||
/**
|
||||
* Pure, network-free helpers for manipulating a ProseMirror/TipTap document
|
||||
* tree by node id.
|
||||
*
|
||||
* A ProseMirror node here is a plain JSON object of the shape produced by
|
||||
* Docmost: `{ type, attrs?, content?, text?, marks? }`. Children live in the
|
||||
* `content` array; a node carries a stable id in `attrs.id`. Callouts and
|
||||
* table cells hold their children in `content` just like any other block, so a
|
||||
* single recursive walk reaches them all.
|
||||
*
|
||||
* Every exported function operates on a DEEP CLONE of the input document and
|
||||
* returns the new document. The input doc and any `newNode`/`node` argument are
|
||||
* never mutated. All functions are defensively null-safe: missing/!Array
|
||||
* `content`, non-object nodes, and absent `attrs` are tolerated.
|
||||
*/
|
||||
|
||||
import { stripInlineMarkdown } from "./text-normalize.js";
|
||||
|
||||
/** Deep-clone a JSON-serializable value without mutating the original. */
|
||||
function clone<T>(value: T): T {
|
||||
if (typeof structuredClone === "function") {
|
||||
return structuredClone(value);
|
||||
}
|
||||
// Fallback for environments without structuredClone.
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
/** True if `value` is a non-null object (and not an array). */
|
||||
function isObject(value: any): value is Record<string, any> {
|
||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** True if `node` carries the given id in `node.attrs.id`. */
|
||||
function matchesId(node: any, nodeId: string): boolean {
|
||||
return isObject(node) && isObject(node.attrs) && node.attrs.id === nodeId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively concatenate all text contained in a node.
|
||||
*
|
||||
* Text nodes contribute their `text` string; container nodes contribute the
|
||||
* joined `blockPlainText` of their `content` children. Returns "" for nullish
|
||||
* or non-object inputs.
|
||||
*/
|
||||
export function blockPlainText(node: any): string {
|
||||
if (!isObject(node)) return "";
|
||||
let out = "";
|
||||
if (typeof node.text === "string") {
|
||||
out += node.text;
|
||||
}
|
||||
if (Array.isArray(node.content)) {
|
||||
for (const child of node.content) {
|
||||
out += blockPlainText(child);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Truncate `text` to at most `n` chars, appending an ellipsis when cut. */
|
||||
function truncate(text: string, n: number): string {
|
||||
return text.length > n ? text.slice(0, n) + "…" : text;
|
||||
}
|
||||
|
||||
/** One compact outline entry for a single top-level block. */
|
||||
export interface OutlineEntry {
|
||||
index: number;
|
||||
type: string | undefined;
|
||||
id: string | null;
|
||||
firstText: string;
|
||||
/** Present for headings only. */
|
||||
level?: number | null;
|
||||
/** Present for tables only. */
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
header?: string[];
|
||||
/** Present for list blocks only (bulletList/orderedList/taskList). */
|
||||
items?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a COMPACT outline of the TOP-LEVEL blocks of `doc` (the entries in
|
||||
* `doc.content`). Deliberately does NOT recurse into paragraphs, list items, or
|
||||
* table cells — compactness is the point; use `getNodeByRef` to drill into a
|
||||
* specific block.
|
||||
*
|
||||
* Each entry carries `{ index, type, id, firstText }`, plus type-specific
|
||||
* extras: headings add `level`; tables add `rows`/`cols` and the first row's
|
||||
* cell texts as `header`; list blocks (types ending in "List") add `items`.
|
||||
* `firstText` is the block's plain text truncated to 100 chars. Null-safe:
|
||||
* a missing or non-object doc/content yields `[]`.
|
||||
*/
|
||||
export function buildOutline(doc: any): OutlineEntry[] {
|
||||
if (!isObject(doc) || !Array.isArray(doc.content)) return [];
|
||||
|
||||
const out: OutlineEntry[] = [];
|
||||
for (let i = 0; i < doc.content.length; i++) {
|
||||
const block = doc.content[i];
|
||||
const type = isObject(block) ? block.type : undefined;
|
||||
const entry: OutlineEntry = {
|
||||
index: i,
|
||||
type,
|
||||
id:
|
||||
isObject(block) && isObject(block.attrs)
|
||||
? (block.attrs.id ?? null)
|
||||
: null,
|
||||
firstText: truncate(blockPlainText(block), 100),
|
||||
};
|
||||
|
||||
if (type === "heading") {
|
||||
entry.level = isObject(block.attrs) ? (block.attrs.level ?? null) : null;
|
||||
} else if (type === "table") {
|
||||
const headerRow = block.content?.[0]?.content ?? [];
|
||||
entry.rows = block.content?.length ?? 0;
|
||||
entry.cols = block.content?.[0]?.content?.length ?? 0;
|
||||
entry.header = headerRow.map((cell: any) =>
|
||||
truncate(blockPlainText(cell), 40),
|
||||
);
|
||||
} else if (typeof type === "string" && type.endsWith("List")) {
|
||||
entry.items = block.content?.length ?? 0;
|
||||
}
|
||||
|
||||
out.push(entry);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a single node by reference and return `{ node, path, type }`, or
|
||||
* `null` when nothing matches.
|
||||
*
|
||||
* - `ref` of the form `#<n>` (e.g. `#2`) selects the TOP-LEVEL block at index
|
||||
* `n` in `doc.content`. This is the only way to address table/tableRow/
|
||||
* tableCell nodes, which carry no `attrs.id`.
|
||||
* - Otherwise `ref` is treated as a block id: the FIRST node anywhere in the
|
||||
* tree with `attrs.id === ref` is returned.
|
||||
*
|
||||
* `path` is the array of child indices from the doc root down to the node
|
||||
* (so a top-level block is `[index]`). The returned `node` is a DEEP CLONE,
|
||||
* so callers can mutate it without touching the input doc. Null-safe.
|
||||
*/
|
||||
export function getNodeByRef(
|
||||
doc: any,
|
||||
ref: string,
|
||||
): { node: any; path: number[]; type: string | undefined } | null {
|
||||
if (!isObject(doc)) return null;
|
||||
|
||||
// "#<n>": index into the top-level content array.
|
||||
const indexMatch = typeof ref === "string" ? ref.match(/^#(\d+)$/) : null;
|
||||
if (indexMatch) {
|
||||
const index = Number(indexMatch[1]);
|
||||
const block = Array.isArray(doc.content) ? doc.content[index] : undefined;
|
||||
if (!isObject(block)) return null;
|
||||
return { node: clone(block), path: [index], type: block.type };
|
||||
}
|
||||
|
||||
// Otherwise: depth-first search for the first node with attrs.id === ref.
|
||||
const search = (
|
||||
node: any,
|
||||
trail: number[],
|
||||
): { node: any; path: number[]; type: string } | null => {
|
||||
if (!isObject(node)) return null;
|
||||
if (Array.isArray(node.content)) {
|
||||
for (let i = 0; i < node.content.length; i++) {
|
||||
const child = node.content[i];
|
||||
const path = [...trail, i];
|
||||
if (matchesId(child, ref)) {
|
||||
return { node: clone(child), path, type: child.type };
|
||||
}
|
||||
const hit = search(child, path);
|
||||
if (hit != null) return hit;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return search(doc, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace EVERY node whose `attrs.id === nodeId` with a deep clone of
|
||||
* `newNode`, anywhere in the tree (including inside callouts and table cells).
|
||||
*
|
||||
* Operates on a clone of `doc`; returns `{ doc, replaced }` where `replaced`
|
||||
* is the number of nodes substituted. A fresh clone of `newNode` is used for
|
||||
* each match so they do not share references.
|
||||
*/
|
||||
export function replaceNodeById(
|
||||
doc: any,
|
||||
nodeId: string,
|
||||
newNode: any,
|
||||
): { doc: any; replaced: number } {
|
||||
const out = clone(doc);
|
||||
let replaced = 0;
|
||||
|
||||
// Walk a content array, replacing direct matches and recursing into the
|
||||
// (possibly new) children of non-matching nodes.
|
||||
const walkContent = (content: any[]): void => {
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const child = content[i];
|
||||
if (matchesId(child, nodeId)) {
|
||||
content[i] = clone(newNode);
|
||||
replaced++;
|
||||
// Do not recurse into a freshly substituted node.
|
||||
continue;
|
||||
}
|
||||
if (isObject(child) && Array.isArray(child.content)) {
|
||||
walkContent(child.content);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isObject(out) && Array.isArray(out.content)) {
|
||||
walkContent(out.content);
|
||||
}
|
||||
return { doc: out, replaced };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove EVERY node whose `attrs.id === nodeId` from its parent `content`
|
||||
* array, anywhere in the tree (recursive, including callouts and tables).
|
||||
*
|
||||
* Operates on a clone of `doc`; returns `{ doc, deleted }` where `deleted` is
|
||||
* the number of nodes removed.
|
||||
*/
|
||||
export function deleteNodeById(
|
||||
doc: any,
|
||||
nodeId: string,
|
||||
): { doc: any; deleted: number } {
|
||||
const out = clone(doc);
|
||||
let deleted = 0;
|
||||
|
||||
// Filter a content array in place, dropping matches and recursing into the
|
||||
// surviving children.
|
||||
const walkContent = (content: any[]): any[] => {
|
||||
const kept: any[] = [];
|
||||
for (const child of content) {
|
||||
if (matchesId(child, nodeId)) {
|
||||
deleted++;
|
||||
continue;
|
||||
}
|
||||
if (isObject(child) && Array.isArray(child.content)) {
|
||||
child.content = walkContent(child.content);
|
||||
}
|
||||
kept.push(child);
|
||||
}
|
||||
return kept;
|
||||
};
|
||||
|
||||
if (isObject(out) && Array.isArray(out.content)) {
|
||||
out.content = walkContent(out.content);
|
||||
}
|
||||
return { doc: out, deleted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw a clear, model-actionable error when a node-id write op did NOT match
|
||||
* exactly one node (#159). `count === 0` -> "no node found"; `count > 1` ->
|
||||
* "ambiguous, refused" — Docmost duplicates block ids on copy/paste, so a write
|
||||
* by id could clobber/remove EVERY duplicate. The caller skips the write for any
|
||||
* `count !== 1` (the transform returns null), so this only REPORTS; nothing was
|
||||
* changed. No-op for the unambiguous single-match case.
|
||||
*/
|
||||
export function assertUnambiguousMatch(
|
||||
op: "patch_node" | "delete_node",
|
||||
verb: "replace" | "delete",
|
||||
count: number,
|
||||
nodeId: string,
|
||||
pageId: string,
|
||||
): void {
|
||||
if (count === 0) {
|
||||
throw new Error(
|
||||
`${op}: no node with id "${nodeId}" found on page ${pageId}`,
|
||||
);
|
||||
}
|
||||
if (count > 1) {
|
||||
throw new Error(
|
||||
`${op}: id "${nodeId}" is ambiguous — ${count} nodes on page ${pageId} share it (block ids are duplicated on copy/paste). Refusing to ${verb} all of them; nothing was changed. Re-target with a more specific anchor.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-clone `doc` and strip every node/mark attribute whose value is strictly
|
||||
* `undefined`, so the result is safe to hand to Yjs (which throws an opaque
|
||||
* "Unexpected content type" when asked to store an `undefined` attribute value).
|
||||
*
|
||||
* Only `undefined` keys are removed; `null`, `false`, `0`, and `""` are all
|
||||
* legitimate JSON-storable values and are preserved. Operates on a clone and
|
||||
* returns it; the input is never mutated. Defensively null-safe like the rest
|
||||
* of the file.
|
||||
*/
|
||||
export function sanitizeForYjs(doc: any): any {
|
||||
const out = clone(doc);
|
||||
|
||||
// Drop every key whose value is strictly `undefined` from an attrs object.
|
||||
const stripUndefined = (attrs: any): void => {
|
||||
if (!isObject(attrs)) return;
|
||||
for (const key of Object.keys(attrs)) {
|
||||
if (attrs[key] === undefined) {
|
||||
delete attrs[key];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const walk = (node: any): void => {
|
||||
if (!isObject(node)) return;
|
||||
stripUndefined(node.attrs);
|
||||
if (Array.isArray(node.marks)) {
|
||||
for (const mark of node.marks) {
|
||||
if (isObject(mark)) stripUndefined(mark.attrs);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(node.content)) {
|
||||
for (const child of node.content) {
|
||||
walk(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diagnostics helper: walk the tree and return a human-readable path string for
|
||||
* the FIRST attribute value (in any `node.attrs` or `mark.attrs`) that Yjs
|
||||
* cannot store — i.e. `undefined`, a `function`, a `symbol`, or a `bigint`
|
||||
* (e.g. `content[3].content[0].attrs.indent (undefined)`). Returns `null` when
|
||||
* every attribute is storable. Null-safe.
|
||||
*/
|
||||
export function findUnstorableAttr(doc: any): string | null {
|
||||
const isUnstorable = (value: any): string | null => {
|
||||
if (value === undefined) return "undefined";
|
||||
const t = typeof value;
|
||||
if (t === "function") return "function";
|
||||
if (t === "symbol") return "symbol";
|
||||
if (t === "bigint") return "bigint";
|
||||
return null;
|
||||
};
|
||||
|
||||
// Check an attrs object; return the offending sub-path or null.
|
||||
const checkAttrs = (attrs: any, basePath: string): string | null => {
|
||||
if (!isObject(attrs)) return null;
|
||||
for (const key of Object.keys(attrs)) {
|
||||
const kind = isUnstorable(attrs[key]);
|
||||
if (kind != null) return `${basePath}.${key} (${kind})`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const walk = (node: any, path: string): string | null => {
|
||||
if (!isObject(node)) return null;
|
||||
const attrHit = checkAttrs(node.attrs, `${path}.attrs`);
|
||||
if (attrHit != null) return attrHit;
|
||||
if (Array.isArray(node.marks)) {
|
||||
for (let i = 0; i < node.marks.length; i++) {
|
||||
const markHit = checkAttrs(
|
||||
node.marks[i]?.attrs,
|
||||
`${path}.marks[${i}].attrs`,
|
||||
);
|
||||
if (markHit != null) return markHit;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(node.content)) {
|
||||
for (let i = 0; i < node.content.length; i++) {
|
||||
const childHit = walk(node.content[i], `${path}.content[${i}]`);
|
||||
if (childHit != null) return childHit;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// The root doc node carries no useful index, so start the path at "doc".
|
||||
if (!isObject(doc)) return null;
|
||||
const attrHit = checkAttrs(doc.attrs, "attrs");
|
||||
if (attrHit != null) return attrHit;
|
||||
if (Array.isArray(doc.content)) {
|
||||
for (let i = 0; i < doc.content.length; i++) {
|
||||
const childHit = walk(doc.content[i], `content[${i}]`);
|
||||
if (childHit != null) return childHit;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Table structural node types and the container each must live directly inside.
|
||||
* Used by `insertNodeRelative` to splice rows/cells into the correct ancestor
|
||||
* rather than blindly into the anchor's direct parent (which would corrupt the
|
||||
* table's nesting).
|
||||
*/
|
||||
const STRUCTURAL_TYPES = new Set(["tableRow", "tableCell", "tableHeader"]);
|
||||
const REQUIRED_CONTAINER: Record<string, string> = {
|
||||
tableRow: "table",
|
||||
tableCell: "tableRow",
|
||||
tableHeader: "tableRow",
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the index of the first TOP-LEVEL block whose plain text includes the
|
||||
* anchor, with a markdown-stripping FALLBACK. Returns -1 when none matches.
|
||||
*
|
||||
* Two passes preserve "exact wins globally":
|
||||
* - Pass 1: first block containing the verbatim `anchorText`.
|
||||
* - Pass 2 (only if pass 1 found nothing): first block containing the
|
||||
* markdown-stripped anchor, when stripping actually changed it.
|
||||
*/
|
||||
function findAnchorTextIndex(content: any[], anchorText: string): number {
|
||||
if (!Array.isArray(content)) return -1;
|
||||
// Pass 1: exact.
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
if (blockPlainText(content[i]).includes(anchorText)) return i;
|
||||
}
|
||||
// Pass 2: markdown-stripped fallback.
|
||||
const a = stripInlineMarkdown(anchorText);
|
||||
if (a !== anchorText && a.length > 0) {
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
if (blockPlainText(content[i]).includes(a)) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate an anchor and return its ancestor chain (from `doc` down to and
|
||||
* including the matched node). Each chain entry is `{ node, index }` where
|
||||
* `index` is the node's position inside its parent's `content` array (the root
|
||||
* doc has index -1). Returns `null` when the anchor cannot be resolved.
|
||||
*/
|
||||
function findAnchorChain(
|
||||
doc: any,
|
||||
opts: InsertOptions,
|
||||
): { node: any; index: number }[] | null {
|
||||
if (!isObject(doc)) return null;
|
||||
|
||||
// DFS by id anywhere in the tree, accumulating the path.
|
||||
if (opts.anchorNodeId != null) {
|
||||
const targetId = opts.anchorNodeId;
|
||||
const search = (
|
||||
node: any,
|
||||
index: number,
|
||||
trail: { node: any; index: number }[],
|
||||
): { node: any; index: number }[] | null => {
|
||||
if (!isObject(node)) return null;
|
||||
const here = [...trail, { node, index }];
|
||||
if (matchesId(node, targetId)) return here;
|
||||
if (Array.isArray(node.content)) {
|
||||
for (let i = 0; i < node.content.length; i++) {
|
||||
const hit = search(node.content[i], i, here);
|
||||
if (hit != null) return hit;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return search(doc, -1, []);
|
||||
}
|
||||
|
||||
// By text: only top-level blocks are scanned (same rule as the JSON path).
|
||||
// Exact match wins; a markdown-stripped fallback is tried only on a miss.
|
||||
if (opts.anchorText != null && Array.isArray(doc.content)) {
|
||||
const i = findAnchorTextIndex(doc.content, opts.anchorText);
|
||||
if (i !== -1) {
|
||||
return [
|
||||
{ node: doc, index: -1 },
|
||||
{ node: doc.content[i], index: i },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Options controlling where `insertNodeRelative` places the new node. */
|
||||
export interface InsertOptions {
|
||||
position: "before" | "after" | "append";
|
||||
/** Resolve the anchor by node id anywhere in the tree (preferred). */
|
||||
anchorNodeId?: string;
|
||||
/** Fallback: first TOP-LEVEL block whose plain text includes this string. */
|
||||
anchorText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a deep clone of `node` relative to an anchor.
|
||||
*
|
||||
* - position "append": push the node onto the top-level `doc.content`.
|
||||
* - position "before"/"after": locate the anchor and splice the node into the
|
||||
* anchor's parent `content` array immediately before / after it.
|
||||
*
|
||||
* Anchor resolution for before/after:
|
||||
* - if `anchorNodeId` is given, find the node with `attrs.id === anchorNodeId`
|
||||
* anywhere in the tree (recursive);
|
||||
* - otherwise, if `anchorText` is given, scan only TOP-LEVEL `doc.content`
|
||||
* blocks and pick the first whose `blockPlainText` includes `anchorText`.
|
||||
*
|
||||
* Operates on a clone of `doc`; returns `{ doc, inserted }`. `inserted` is
|
||||
* false when the anchor could not be resolved (the doc is returned unchanged
|
||||
* apart from being cloned).
|
||||
*/
|
||||
export function insertNodeRelative(
|
||||
doc: any,
|
||||
node: any,
|
||||
opts: InsertOptions,
|
||||
): { doc: any; inserted: boolean } {
|
||||
const out = clone(doc);
|
||||
const fresh = clone(node);
|
||||
|
||||
// Defensive: stay null-safe like the other exports — a missing opts means
|
||||
// there is nothing actionable to do.
|
||||
if (!isObject(opts)) return { doc: out, inserted: false };
|
||||
|
||||
const isStructural = isObject(node) && STRUCTURAL_TYPES.has(node.type);
|
||||
|
||||
// "append": top-level push.
|
||||
if (opts.position === "append") {
|
||||
// Structural table nodes (tableRow/tableCell/tableHeader) cannot live at the
|
||||
// top level — appending one would produce invalid nesting.
|
||||
if (isStructural) {
|
||||
throw new Error(
|
||||
`insert_node: cannot append a ${node.type} at the top level; use ` +
|
||||
`position before/after with an anchor inside the target table`,
|
||||
);
|
||||
}
|
||||
if (isObject(out)) {
|
||||
if (!Array.isArray(out.content)) out.content = [];
|
||||
out.content.push(fresh);
|
||||
return { doc: out, inserted: true };
|
||||
}
|
||||
return { doc: out, inserted: false };
|
||||
}
|
||||
|
||||
const offset = opts.position === "after" ? 1 : 0;
|
||||
|
||||
// Structural insert (before/after a tableRow/tableCell/tableHeader): splice
|
||||
// into the nearest enclosing table/tableRow rather than the anchor's direct
|
||||
// parent, so the row/cell lands at the correct level of the table.
|
||||
if (isStructural) {
|
||||
const containerType = REQUIRED_CONTAINER[node.type];
|
||||
const chain = findAnchorChain(out, opts);
|
||||
// Anchor not resolved at all — keep the existing "anchor not found" path.
|
||||
if (chain == null) return { doc: out, inserted: false };
|
||||
|
||||
// Find the DEEPEST ancestor (including the anchor itself) of the required
|
||||
// container type.
|
||||
let containerIdx = -1;
|
||||
for (let i = chain.length - 1; i >= 0; i--) {
|
||||
if (isObject(chain[i].node) && chain[i].node.type === containerType) {
|
||||
containerIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (containerIdx === -1) {
|
||||
throw new Error(
|
||||
`insert_node: cannot insert a ${node.type} here — the anchor is not ` +
|
||||
`inside a ${containerType}. Anchor on a cell's text or a block id ` +
|
||||
`that lives inside the target table.`,
|
||||
);
|
||||
}
|
||||
|
||||
const container = chain[containerIdx].node;
|
||||
if (!Array.isArray(container.content)) container.content = [];
|
||||
|
||||
if (containerIdx === chain.length - 1) {
|
||||
// The matched container IS the anchor node itself (e.g. anchorText
|
||||
// resolved to the table block): append/prepend within it.
|
||||
const at = opts.position === "after" ? container.content.length : 0;
|
||||
container.content.splice(at, 0, fresh);
|
||||
} else {
|
||||
// The immediate child on the path leading to the anchor is the row/cell
|
||||
// to splice next to.
|
||||
const enclosingChildIndex = chain[containerIdx + 1].index;
|
||||
container.content.splice(enclosingChildIndex + offset, 0, fresh);
|
||||
}
|
||||
return { doc: out, inserted: true };
|
||||
}
|
||||
|
||||
// Resolve by id anywhere in the tree: splice into the parent content array.
|
||||
if (opts.anchorNodeId != null) {
|
||||
let inserted = false;
|
||||
const walkContent = (content: any[]): void => {
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const child = content[i];
|
||||
if (matchesId(child, opts.anchorNodeId as string)) {
|
||||
content.splice(i + offset, 0, fresh);
|
||||
inserted = true;
|
||||
return;
|
||||
}
|
||||
if (isObject(child) && Array.isArray(child.content)) {
|
||||
walkContent(child.content);
|
||||
if (inserted) return;
|
||||
}
|
||||
}
|
||||
};
|
||||
if (isObject(out) && Array.isArray(out.content)) {
|
||||
walkContent(out.content);
|
||||
}
|
||||
return { doc: out, inserted };
|
||||
}
|
||||
|
||||
// Resolve by text: only top-level doc.content blocks are scanned. Exact
|
||||
// match wins; a markdown-stripped fallback is tried only on a miss.
|
||||
if (opts.anchorText != null && isObject(out) && Array.isArray(out.content)) {
|
||||
const i = findAnchorTextIndex(out.content, opts.anchorText);
|
||||
if (i !== -1) {
|
||||
out.content.splice(i + offset, 0, fresh);
|
||||
return { doc: out, inserted: true };
|
||||
}
|
||||
}
|
||||
|
||||
return { doc: out, inserted: false };
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Table editing helpers
|
||||
//
|
||||
// A Docmost table is a ProseMirror subtree with NO ids on the structural nodes:
|
||||
// table -> { type:"table", content:[tableRow...] }
|
||||
// row -> { type:"tableRow", content:[tableCell|tableHeader...] }
|
||||
// cell -> { type:"tableCell"|"tableHeader", attrs:{colspan,rowspan,colwidth},
|
||||
// content:[paragraph...] }
|
||||
// para -> { type:"paragraph", attrs:{id,indent}, content:[textNode...] }
|
||||
// Only paragraphs/headings carry an `attrs.id`, so a cell is addressed via the
|
||||
// id of the paragraph inside it. The helpers below all operate on a DEEP CLONE
|
||||
// of the input doc (via `clone`) and never mutate their inputs.
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Collect EVERY `attrs.id` present anywhere in `node` into `used`. Used to seed
|
||||
* `makeFreshId` so generated paragraph ids never collide with existing ones.
|
||||
*/
|
||||
function collectIds(node: any, used: Set<string>): void {
|
||||
if (!isObject(node)) return;
|
||||
if (isObject(node.attrs) && typeof node.attrs.id === "string") {
|
||||
used.add(node.attrs.id);
|
||||
}
|
||||
if (Array.isArray(node.content)) {
|
||||
for (const child of node.content) collectIds(child, used);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fresh-id generator: returns a random Docmost-style id (12 chars from
|
||||
* lowercase `a-z0-9`) that is not already in `used`, and records it. On the
|
||||
* rare collision the id is regenerated. Callers rely on uniqueness, not on the
|
||||
* exact string, so randomness is fine — and unlike a module-local counter it
|
||||
* needs no reset and cannot become predictable across calls.
|
||||
*/
|
||||
function makeFreshId(used: Set<string>): string {
|
||||
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let id: string;
|
||||
do {
|
||||
id = "";
|
||||
for (let i = 0; i < 12; i++) {
|
||||
id += alphabet[Math.floor(Math.random() * alphabet.length)];
|
||||
}
|
||||
} while (used.has(id) || id === "");
|
||||
used.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a table reference against an ALREADY-CLONED doc and return the LIVE
|
||||
* table node (a reference inside `rootClone`, so the caller may mutate it) plus
|
||||
* its index path. Returns null when no table matches.
|
||||
*
|
||||
* - `#<n>`: the top-level block at index `n`, only if its `type === "table"`.
|
||||
* - otherwise: DFS for the node with `attrs.id === tableRef`, then walk UP its
|
||||
* ancestor chain to the nearest `type === "table"` ancestor.
|
||||
*/
|
||||
function locateTable(
|
||||
rootClone: any,
|
||||
tableRef: string,
|
||||
): { table: any; path: number[] } | null {
|
||||
if (!isObject(rootClone)) return null;
|
||||
|
||||
// "#<n>": index into the top-level content array; must be a table.
|
||||
const indexMatch =
|
||||
typeof tableRef === "string" ? tableRef.match(/^#(\d+)$/) : null;
|
||||
if (indexMatch) {
|
||||
const index = Number(indexMatch[1]);
|
||||
const block = Array.isArray(rootClone.content)
|
||||
? rootClone.content[index]
|
||||
: undefined;
|
||||
if (isObject(block) && block.type === "table") {
|
||||
return { table: block, path: [index] };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Otherwise: DFS for attrs.id === tableRef, tracking the ancestor chain, then
|
||||
// climb to the nearest enclosing table.
|
||||
const search = (
|
||||
node: any,
|
||||
trail: { node: any; index: number }[],
|
||||
): { table: any; path: number[] } | null => {
|
||||
if (!isObject(node)) return null;
|
||||
if (Array.isArray(node.content)) {
|
||||
for (let i = 0; i < node.content.length; i++) {
|
||||
const child = node.content[i];
|
||||
const here = [...trail, { node: child, index: i }];
|
||||
if (matchesId(child, tableRef)) {
|
||||
// Walk UP to the nearest table ancestor (including the match itself).
|
||||
for (let j = here.length - 1; j >= 0; j--) {
|
||||
if (isObject(here[j].node) && here[j].node.type === "table") {
|
||||
return {
|
||||
table: here[j].node,
|
||||
path: here.slice(0, j + 1).map((e) => e.index),
|
||||
};
|
||||
}
|
||||
}
|
||||
return null; // id found but no enclosing table
|
||||
}
|
||||
const hit = search(child, here);
|
||||
if (hit != null) return hit;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return search(rootClone, []);
|
||||
}
|
||||
|
||||
/** Build the plain-text → single-paragraph cell content used by all writers. */
|
||||
function makeCellParagraph(id: string, text: string): any {
|
||||
return {
|
||||
type: "paragraph",
|
||||
attrs: { id, indent: 0 },
|
||||
// Empty string → a paragraph with an empty content array.
|
||||
content: text ? [{ type: "text", text }] : [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a table as a matrix. Returns null when `tableRef` resolves to no table.
|
||||
*
|
||||
* - `rows`/`cols`: the table's row count and the column count of its FIRST row.
|
||||
* Tables may be ragged (rows of differing length), so `cols` reflects only
|
||||
* row 0; use the per-row length of `cells`/`cellIds` for each row's actual
|
||||
* width.
|
||||
* - `cells`: `string[][]` of each cell's `blockPlainText`.
|
||||
* - `cellIds`: `(string|null)[][]` of each cell's FIRST paragraph id (or null),
|
||||
* so callers can `patch_node` a cell for rich-formatted edits.
|
||||
* - `path`: index path of the table within the doc.
|
||||
*/
|
||||
export function readTable(
|
||||
doc: any,
|
||||
tableRef: string,
|
||||
): {
|
||||
rows: number;
|
||||
cols: number;
|
||||
cells: string[][];
|
||||
cellIds: (string | null)[][];
|
||||
path: number[];
|
||||
} | null {
|
||||
const root = clone(doc);
|
||||
const located = locateTable(root, tableRef);
|
||||
if (located == null) return null;
|
||||
const { table, path } = located;
|
||||
|
||||
const rowNodes = Array.isArray(table.content) ? table.content : [];
|
||||
const rows = rowNodes.length;
|
||||
const cols = rowNodes[0]?.content?.length ?? 0;
|
||||
|
||||
const cells: string[][] = [];
|
||||
const cellIds: (string | null)[][] = [];
|
||||
for (const rowNode of rowNodes) {
|
||||
const cellNodes = Array.isArray(rowNode?.content) ? rowNode.content : [];
|
||||
const rowText: string[] = [];
|
||||
const rowIds: (string | null)[] = [];
|
||||
for (const cellNode of cellNodes) {
|
||||
rowText.push(blockPlainText(cellNode));
|
||||
// The cell's first paragraph carries the id used for patch_node.
|
||||
const firstPara = Array.isArray(cellNode?.content)
|
||||
? cellNode.content[0]
|
||||
: undefined;
|
||||
const id =
|
||||
isObject(firstPara) && isObject(firstPara.attrs)
|
||||
? (firstPara.attrs.id ?? null)
|
||||
: null;
|
||||
rowIds.push(id);
|
||||
}
|
||||
cells.push(rowText);
|
||||
cellIds.push(rowIds);
|
||||
}
|
||||
|
||||
return { rows, cols, cells, cellIds, path };
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a row of plain-text cells into a table. Returns `{ doc, inserted }`.
|
||||
*
|
||||
* The row is padded to the table's column count (`cells[i] ?? ""`); supplying
|
||||
* MORE cells than columns throws. Each new cell copies `colwidth` for its
|
||||
* column from the header row when present, gets a fresh-id paragraph, and a
|
||||
* `colspan:1, rowspan:1` attrs. `index` (when an integer in `[0, rows]`) splices
|
||||
* the row there; otherwise the row is appended at the end.
|
||||
*/
|
||||
export function insertTableRow(
|
||||
doc: any,
|
||||
tableRef: string,
|
||||
cells: string[],
|
||||
index?: number,
|
||||
): { doc: any; inserted: boolean } {
|
||||
const out = clone(doc);
|
||||
const located = locateTable(out, tableRef);
|
||||
if (located == null) return { doc: out, inserted: false };
|
||||
const { table } = located;
|
||||
|
||||
if (!Array.isArray(table.content)) table.content = [];
|
||||
const rows = table.content.length;
|
||||
const headerRow = table.content[0];
|
||||
const headerCells = Array.isArray(headerRow?.content)
|
||||
? headerRow.content
|
||||
: [];
|
||||
|
||||
// Column count is the WIDEST existing row, so the guard below stays
|
||||
// meaningful for ragged tables and the new row matches the table's width.
|
||||
// Fall back to the supplied cell count only when the table has no rows.
|
||||
let colCount = 0;
|
||||
for (const r of table.content) {
|
||||
if (isObject(r) && Array.isArray(r.content))
|
||||
colCount = Math.max(colCount, r.content.length);
|
||||
}
|
||||
if (colCount === 0) colCount = Array.isArray(cells) ? cells.length : 0;
|
||||
|
||||
if (Array.isArray(cells) && cells.length > colCount) {
|
||||
throw new Error(
|
||||
`table_insert_row: got ${cells.length} cell(s) but the table has ${colCount} column(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve the landing index up front so the cell-type decision and the splice
|
||||
// below agree: a valid integer in [0, rows] splices there, else we append.
|
||||
const landingIndex =
|
||||
typeof index === "number" &&
|
||||
Number.isInteger(index) &&
|
||||
index >= 0 &&
|
||||
index <= rows
|
||||
? index
|
||||
: rows;
|
||||
|
||||
// Seed the id generator with every id already in the doc so the new cell
|
||||
// paragraph ids are unique within the whole document.
|
||||
const used = new Set<string>();
|
||||
collectIds(out, used);
|
||||
|
||||
const newCells: any[] = [];
|
||||
for (let i = 0; i < colCount; i++) {
|
||||
const text = (Array.isArray(cells) ? cells[i] : undefined) ?? "";
|
||||
const attrs: Record<string, any> = { colspan: 1, rowspan: 1 };
|
||||
// Copy this column's colwidth from the header row's cell when present.
|
||||
const colwidth = headerCells[i]?.attrs?.colwidth;
|
||||
if (colwidth !== undefined) attrs.colwidth = colwidth;
|
||||
// A row landing at index 0 becomes the new header row, so inherit the
|
||||
// current header cell's type per column (Docmost uses "tableHeader" there);
|
||||
// every other position is a plain data cell.
|
||||
const cellType =
|
||||
landingIndex === 0 ? (headerCells[i]?.type ?? "tableCell") : "tableCell";
|
||||
newCells.push({
|
||||
type: cellType,
|
||||
attrs,
|
||||
content: [makeCellParagraph(makeFreshId(used), text)],
|
||||
});
|
||||
}
|
||||
|
||||
const newRow = { type: "tableRow", content: newCells };
|
||||
|
||||
// Splice at the resolved landing index (append when index was omitted/invalid).
|
||||
table.content.splice(landingIndex, 0, newRow);
|
||||
|
||||
return { doc: out, inserted: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the row at 0-based `index` from a table. Returns `{ doc, deleted }`.
|
||||
* `deleted` is false only when the table cannot be located. Throws on an
|
||||
* out-of-range index, and refuses to delete the table's only row.
|
||||
*/
|
||||
export function deleteTableRow(
|
||||
doc: any,
|
||||
tableRef: string,
|
||||
index: number,
|
||||
): { doc: any; deleted: boolean } {
|
||||
const out = clone(doc);
|
||||
const located = locateTable(out, tableRef);
|
||||
if (located == null) return { doc: out, deleted: false };
|
||||
const { table } = located;
|
||||
|
||||
if (!Array.isArray(table.content)) table.content = [];
|
||||
const rows = table.content.length;
|
||||
|
||||
if (!Number.isInteger(index) || index < 0 || index >= rows) {
|
||||
throw new Error(
|
||||
`table_delete_row: row index ${index} out of range (table has ${rows} row(s))`,
|
||||
);
|
||||
}
|
||||
if (rows <= 1) {
|
||||
throw new Error(
|
||||
"table_delete_row: refusing to delete the only row of the table",
|
||||
);
|
||||
}
|
||||
|
||||
table.content.splice(index, 1);
|
||||
return { doc: out, deleted: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the plain-text content of cell `[row, col]` (0-based) to `text`. Returns
|
||||
* `{ doc, updated }`; `updated` is false only when the table cannot be located.
|
||||
* Throws when `row`/`col` is out of range. The cell's own attrs (colspan/
|
||||
* rowspan/colwidth) are preserved; its content becomes a single text paragraph
|
||||
* that reuses the cell's existing first-paragraph id when present, else a fresh
|
||||
* one.
|
||||
*/
|
||||
export function updateTableCell(
|
||||
doc: any,
|
||||
tableRef: string,
|
||||
row: number,
|
||||
col: number,
|
||||
text: string,
|
||||
): { doc: any; updated: boolean } {
|
||||
const out = clone(doc);
|
||||
const located = locateTable(out, tableRef);
|
||||
if (located == null) return { doc: out, updated: false };
|
||||
const { table } = located;
|
||||
|
||||
const rowNodes = Array.isArray(table.content) ? table.content : [];
|
||||
const rows = rowNodes.length;
|
||||
const rowNode = rowNodes[row];
|
||||
const cols =
|
||||
isObject(rowNode) && Array.isArray(rowNode.content)
|
||||
? rowNode.content.length
|
||||
: 0;
|
||||
|
||||
if (
|
||||
!Number.isInteger(row) ||
|
||||
row < 0 ||
|
||||
row >= rows ||
|
||||
!Number.isInteger(col) ||
|
||||
col < 0 ||
|
||||
col >= cols
|
||||
) {
|
||||
throw new Error(`table_update_cell: cell [${row},${col}] out of range`);
|
||||
}
|
||||
|
||||
const cellNode = rowNode.content[col];
|
||||
// Reuse the cell's existing first-paragraph id, or mint a fresh unique one.
|
||||
const existingPara = Array.isArray(cellNode?.content)
|
||||
? cellNode.content[0]
|
||||
: undefined;
|
||||
let id =
|
||||
isObject(existingPara) && isObject(existingPara.attrs)
|
||||
? existingPara.attrs.id
|
||||
: undefined;
|
||||
if (typeof id !== "string" || id.length === 0) {
|
||||
const used = new Set<string>();
|
||||
collectIds(out, used);
|
||||
id = makeFreshId(used);
|
||||
}
|
||||
|
||||
cellNode.content = [makeCellParagraph(id, text)];
|
||||
return { doc: out, updated: true };
|
||||
}
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
import RE2 from "re2";
|
||||
|
||||
import { blockPlainText } from "./node-ops.js";
|
||||
import { blockPlainText } from "@docmost/prosemirror-markdown";
|
||||
|
||||
/** An RE2 regex instance (RE2 extends `RegExp`, so it is usable as one). */
|
||||
type Re2Regex = InstanceType<typeof RE2>;
|
||||
|
||||
Vendored
-61
@@ -1,61 +0,0 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -14,13 +14,13 @@
|
||||
* - `marks` arrays are preserved verbatim when fragments are split/reordered.
|
||||
*/
|
||||
|
||||
import { blockPlainText } from "./node-ops.js";
|
||||
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
|
||||
import {
|
||||
blockPlainText,
|
||||
footnoteContentKey,
|
||||
makeFootnoteDefinition,
|
||||
generateFootnoteId,
|
||||
} from "./footnote-authoring.js";
|
||||
} from "@docmost/prosemirror-markdown";
|
||||
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
|
||||
|
||||
export { canonicalizeFootnotes } from "./footnote-canonicalize.js";
|
||||
|
||||
@@ -365,7 +365,7 @@ export function noteItem(inlineNodes: any[]): any {
|
||||
* { type:"footnoteDefinition", attrs:{id}, content:[{ type:"paragraph", content }] }
|
||||
* (mirrors the editor-ext / docmost-schema FootnoteDefinition node).
|
||||
*
|
||||
* Built on the shared `makeFootnoteDefinition` factory (footnote-authoring.ts);
|
||||
* Built on the shared `makeFootnoteDefinition` factory (`@docmost/prosemirror-markdown`);
|
||||
* the only extra is a fresh block id on the inner paragraph (Docmost stamps one,
|
||||
* and the canonicalizer preserves attrs as-is). Single factory, one place to
|
||||
* change the definition shape.
|
||||
|
||||
@@ -1115,112 +1115,4 @@ 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>;
|
||||
|
||||
@@ -1,467 +0,0 @@
|
||||
// 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");
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
// Mock-HTTP test for the footnoteWarnings plumbing (#166). createPage is the
|
||||
// representative path that is fully plain-HTTP (import + getPage) and so is
|
||||
// mockable here; updatePage / importPageMarkdown attach footnoteWarnings with the
|
||||
// IDENTICAL wiring (`analyzeFootnotes(...)` + spread-when-non-empty) but run their
|
||||
// IDENTICAL wiring (`footnoteWarningsField(...)` spread-when-non-empty) but run their
|
||||
// mutation over the Hocuspocus collab WebSocket, which this plain-HTTP harness
|
||||
// does not stand up. The analyzer itself is unit-tested in footnote-analyze.test.
|
||||
import { test, after } from "node:test";
|
||||
@@ -76,35 +76,29 @@ function pageHandler() {
|
||||
};
|
||||
}
|
||||
|
||||
test("createPage attaches footnoteWarnings when the content has footnote problems", async () => {
|
||||
test("createPage attaches footnoteWarnings when the content uses legacy footnote syntax", async () => {
|
||||
const baseURL = await spawn(pageHandler());
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
// A dangling reference + a duplicate definition + a table marker.
|
||||
const content = [
|
||||
"Intro[^missing] and| cell[^t] |.",
|
||||
"",
|
||||
"[^d]: one",
|
||||
"[^d]: two",
|
||||
"[^t]: in table",
|
||||
].join("\n");
|
||||
// Legacy reference-style `[^id]:` definitions — inert on import since #293.
|
||||
const content = ["Intro[^a].", "", "[^a]: a definition"].join("\n");
|
||||
const result = await client.createPage("T", content, "sp-1");
|
||||
assert.ok(Array.isArray(result.footnoteWarnings), "footnoteWarnings present");
|
||||
const joined = result.footnoteWarnings.join("\n");
|
||||
assert.match(joined, /no matching definition/); // dangling [^missing]
|
||||
assert.match(joined, /defined more than once/); // duplicate [^d]
|
||||
assert.match(joined, /reference-style footnotes/i);
|
||||
assert.match(joined, /\^\[footnote text\]/); // nudge to the inline form
|
||||
// The page itself is still returned.
|
||||
assert.equal(result.success, true);
|
||||
});
|
||||
|
||||
test("createPage omits footnoteWarnings when the content is clean", async () => {
|
||||
test("createPage omits footnoteWarnings when the content uses the inline form", async () => {
|
||||
const baseURL = await spawn(pageHandler());
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
const content = ["A[^a] and reuse[^a].", "", "[^a]: fine"].join("\n");
|
||||
const content = "A note.^[the body] and reuse.^[the body]";
|
||||
const result = await client.createPage("T", content, "sp-1");
|
||||
assert.equal(
|
||||
"footnoteWarnings" in result,
|
||||
false,
|
||||
"no footnoteWarnings field on clean input",
|
||||
"no footnoteWarnings field on inline-footnote input",
|
||||
);
|
||||
assert.equal(result.success, true);
|
||||
});
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
// 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");
|
||||
});
|
||||
@@ -1,360 +0,0 @@
|
||||
// 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">/);
|
||||
});
|
||||
@@ -1,64 +1,45 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { analyzeFootnotes } from "../../build/lib/footnote-analyze.js";
|
||||
import {
|
||||
footnoteWarningsField,
|
||||
hasLegacyFootnoteDefinition,
|
||||
} from "../../build/lib/footnote-analyze.js";
|
||||
|
||||
test("clean footnotes produce no diagnostics", () => {
|
||||
const md = ["A[^a] and B[^b].", "", "[^a]: first", "[^b]: second"].join("\n");
|
||||
const d = analyzeFootnotes(md);
|
||||
assert.deepEqual(d.danglingReferences, []);
|
||||
assert.deepEqual(d.emptyDefinitions, []);
|
||||
assert.deepEqual(d.duplicateDefinitions, []);
|
||||
assert.deepEqual(d.referencesInTables, []);
|
||||
assert.deepEqual(d.warnings, []);
|
||||
// #414: the legacy footnote diagnostics were reduced to ONE advisory that fires
|
||||
// on the PRESENCE of legacy reference-style `[^id]:` definition syntax (inert on
|
||||
// import since #293), nudging the author to inline `^[...]` footnotes.
|
||||
|
||||
test("inline `^[...]` footnotes produce no warning", () => {
|
||||
const md = "A note here.^[the body] and reuse elsewhere.^[the body]";
|
||||
assert.equal(hasLegacyFootnoteDefinition(md), false);
|
||||
assert.deepEqual(footnoteWarningsField(md), {});
|
||||
});
|
||||
|
||||
test("reuse (repeated references to one definition) is NOT a warning", () => {
|
||||
const md = ["A[^a] B[^a] C[^a].", "", "[^a]: shared"].join("\n");
|
||||
const d = analyzeFootnotes(md);
|
||||
assert.deepEqual(d.danglingReferences, []);
|
||||
assert.deepEqual(d.warnings, []);
|
||||
test("no footnotes at all produce no warning", () => {
|
||||
const md = "Just a paragraph with [a link](https://x) and no footnotes.";
|
||||
assert.equal(hasLegacyFootnoteDefinition(md), false);
|
||||
assert.deepEqual(footnoteWarningsField(md), {});
|
||||
});
|
||||
|
||||
test("dangling reference (no definition) is reported", () => {
|
||||
const md = ["See[^missing] and[^a].", "", "[^a]: defined"].join("\n");
|
||||
const d = analyzeFootnotes(md);
|
||||
assert.deepEqual(d.danglingReferences, ["missing"]);
|
||||
assert.equal(d.warnings.length, 1);
|
||||
assert.match(d.warnings[0], /no matching definition/);
|
||||
assert.match(d.warnings[0], /\[\^missing\]/);
|
||||
test("a legacy `[^id]:` definition triggers the single advisory", () => {
|
||||
const md = ["See[^a].", "", "[^a]: defined"].join("\n");
|
||||
assert.equal(hasLegacyFootnoteDefinition(md), true);
|
||||
const field = footnoteWarningsField(md);
|
||||
assert.equal(field.footnoteWarnings.length, 1);
|
||||
assert.match(field.footnoteWarnings[0], /reference-style footnotes/i);
|
||||
assert.match(field.footnoteWarnings[0], /\^\[footnote text\]/);
|
||||
});
|
||||
|
||||
test("empty definition text is reported", () => {
|
||||
const md = ["See[^a].", "", "[^a]: "].join("\n");
|
||||
const d = analyzeFootnotes(md);
|
||||
assert.deepEqual(d.emptyDefinitions, ["a"]);
|
||||
assert.match(d.warnings.join("\n"), /empty text/);
|
||||
test("a bare `[^id]` reference (no definition line) is not flagged", () => {
|
||||
// Only the definition syntax `[^id]:` is a reliable signal of legacy authoring;
|
||||
// a lone `[^x]` in prose is too ambiguous to warn on.
|
||||
const md = "A sentence mentioning [^x] with no definition.";
|
||||
assert.equal(hasLegacyFootnoteDefinition(md), false);
|
||||
assert.deepEqual(footnoteWarningsField(md), {});
|
||||
});
|
||||
|
||||
test("duplicate definition id is reported (first-wins)", () => {
|
||||
const md = ["See[^d].", "", "[^d]: first", "[^d]: second"].join("\n");
|
||||
const d = analyzeFootnotes(md);
|
||||
assert.deepEqual(d.duplicateDefinitions, ["d"]);
|
||||
assert.match(d.warnings.join("\n"), /defined more than once/);
|
||||
});
|
||||
|
||||
test("reference inside a GFM table row is reported (heuristic)", () => {
|
||||
const md = [
|
||||
"| Col |",
|
||||
"| --- |",
|
||||
"| cell[^t] |",
|
||||
"",
|
||||
"[^t]: table note",
|
||||
].join("\n");
|
||||
const d = analyzeFootnotes(md);
|
||||
assert.deepEqual(d.referencesInTables, ["t"]);
|
||||
assert.match(d.warnings.join("\n"), /table/);
|
||||
// It is defined, so it is NOT also dangling.
|
||||
assert.deepEqual(d.danglingReferences, []);
|
||||
});
|
||||
|
||||
test("footnote syntax inside a code fence is ignored", () => {
|
||||
test("legacy syntax inside a code fence is ignored (fence-aware)", () => {
|
||||
const md = [
|
||||
"Intro.",
|
||||
"",
|
||||
@@ -67,40 +48,22 @@ test("footnote syntax inside a code fence is ignored", () => {
|
||||
"[^demo]: not a real definition",
|
||||
"```",
|
||||
"",
|
||||
"Outro[^a].",
|
||||
"",
|
||||
"[^a]: real",
|
||||
"Outro with an inline note.^[real]",
|
||||
].join("\n");
|
||||
const d = analyzeFootnotes(md);
|
||||
// `[^demo]` lives only in the fenced block, so it is neither a reference nor a
|
||||
// dangling one, and `[^demo]:` is not counted as a definition.
|
||||
assert.deepEqual(d.danglingReferences, []);
|
||||
assert.deepEqual(d.duplicateDefinitions, []);
|
||||
assert.deepEqual(d.warnings, []);
|
||||
assert.equal(hasLegacyFootnoteDefinition(md), false);
|
||||
assert.deepEqual(footnoteWarningsField(md), {});
|
||||
});
|
||||
|
||||
test("a reference that only appears inside a definition's text is not dangling", () => {
|
||||
// `[^b]` is referenced from within [^a]'s text and has its own definition.
|
||||
const md = ["See[^a].", "", "[^a]: see also [^b]", "[^b]: the other"].join(
|
||||
"\n",
|
||||
);
|
||||
const d = analyzeFootnotes(md);
|
||||
assert.deepEqual(d.danglingReferences, []);
|
||||
});
|
||||
|
||||
test("multiple problem classes accumulate distinct warnings", () => {
|
||||
test("a legacy definition OUTSIDE a fence still warns even with a fenced sample", () => {
|
||||
const md = [
|
||||
"Ref[^x] and[^dup].",
|
||||
"```",
|
||||
"[^demo]: example inside a fence",
|
||||
"```",
|
||||
"",
|
||||
"[^dup]: one",
|
||||
"[^dup]: two",
|
||||
"[^empty]:",
|
||||
"See[^a].",
|
||||
"",
|
||||
"[^a]: real definition outside the fence",
|
||||
].join("\n");
|
||||
const d = analyzeFootnotes(md);
|
||||
// x has no definition; dup is defined twice; empty is empty AND has no ref.
|
||||
assert.ok(d.danglingReferences.includes("x"));
|
||||
assert.deepEqual(d.duplicateDefinitions, ["dup"]);
|
||||
assert.deepEqual(d.emptyDefinitions, ["empty"]);
|
||||
// One warning line per problem class present.
|
||||
assert.ok(d.warnings.length >= 3);
|
||||
assert.equal(hasLegacyFootnoteDefinition(md), true);
|
||||
assert.equal(footnoteWarningsField(md).footnoteWarnings.length, 1);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import { canonicalizeFootnotes } from "../../build/lib/footnote-canonicalize.js"
|
||||
import {
|
||||
footnoteContentKey,
|
||||
generateFootnoteId,
|
||||
} from "../../build/lib/footnote-authoring.js";
|
||||
} from "@docmost/prosemirror-markdown";
|
||||
import { insertInlineFootnote } from "../../build/lib/transforms.js";
|
||||
import { markdownToProseMirrorCanonical } from "../../build/lib/collaboration.js";
|
||||
|
||||
|
||||
@@ -1,39 +1,37 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
analyzeFootnotes,
|
||||
footnoteWarningsField,
|
||||
} from "../../build/lib/footnote-analyze.js";
|
||||
import { footnoteWarningsField } from "../../build/lib/footnote-analyze.js";
|
||||
import {
|
||||
serializeDocmostMarkdown,
|
||||
parseDocmostMarkdown,
|
||||
} from "../../build/lib/markdown-document.js";
|
||||
|
||||
// Pins the footnoteWarnings PLUMBING contract (#169 review): the field is
|
||||
// present only on problems and omitted on clean input, AND `import_page_markdown`
|
||||
// analyzes the BODY (after the docmost:meta / docmost:comments blocks) — so a
|
||||
// footnote-like token inside those JSON blocks never warns, while a real marker
|
||||
// in the body does. importPageMarkdown does exactly
|
||||
// `footnoteWarningsField(parseDocmostMarkdown(full).body)` over a collab socket
|
||||
// this harness does not stand up, so we test the same pure composition directly.
|
||||
// Pins the footnoteWarnings PLUMBING contract (#169 review; reduced in #414): the
|
||||
// field is present only when legacy reference-style `[^id]:` syntax is used and
|
||||
// omitted otherwise, AND `import_page_markdown` analyzes the BODY (after the
|
||||
// docmost:meta / docmost:comments blocks) — so a footnote-like token inside those
|
||||
// JSON blocks never warns, while a real definition in the body does.
|
||||
// importPageMarkdown does exactly `footnoteWarningsField(parseDocmostMarkdown(full).body)`
|
||||
// over a collab socket this harness does not stand up, so we test the same pure
|
||||
// composition directly.
|
||||
|
||||
test("footnoteWarningsField is present on problems and omitted on clean input", () => {
|
||||
const problem = footnoteWarningsField("See[^missing].\n\n[^a]: defined");
|
||||
assert.ok(Array.isArray(problem.footnoteWarnings));
|
||||
assert.match(problem.footnoteWarnings.join("\n"), /no matching definition/);
|
||||
test("footnoteWarningsField is present on legacy syntax and omitted on the inline form", () => {
|
||||
const legacy = footnoteWarningsField("See[^a].\n\n[^a]: defined");
|
||||
assert.ok(Array.isArray(legacy.footnoteWarnings));
|
||||
assert.match(legacy.footnoteWarnings.join("\n"), /reference-style footnotes/i);
|
||||
|
||||
const clean = footnoteWarningsField("A[^a] and reuse[^a].\n\n[^a]: fine");
|
||||
assert.deepEqual(clean, {}); // no key at all on clean input
|
||||
const inline = footnoteWarningsField("A note.^[the body] reused.^[the body]");
|
||||
assert.deepEqual(inline, {}); // no key at all on inline-footnote input
|
||||
});
|
||||
|
||||
test("import analyzes the BODY only — tokens inside meta/comments never warn", () => {
|
||||
// meta + comments JSON carry `[^metaonly]` / `[^commentonly]`-looking text; the
|
||||
// BODY has a genuinely dangling `[^bodyref]`.
|
||||
// meta + comments JSON carry `[^metaonly]:` / `[^commentonly]:`-looking text;
|
||||
// the BODY has a genuine legacy `[^bodyref]:` definition.
|
||||
const full = serializeDocmostMarkdown(
|
||||
{ pageId: "p1", note: "front-matter mentions [^metaonly] in text" },
|
||||
"Body with a dangling[^bodyref] marker.",
|
||||
[{ id: "c1", content: "a comment that says [^commentonly]" }],
|
||||
{ pageId: "p1", note: "front-matter mentions [^metaonly]: in text" },
|
||||
"Body with a legacy[^bodyref] marker.\n\n[^bodyref]: the definition",
|
||||
[{ id: "c1", content: "a comment that says [^commentonly]: text" }],
|
||||
);
|
||||
|
||||
const { body } = parseDocmostMarkdown(full);
|
||||
@@ -42,20 +40,19 @@ test("import analyzes the BODY only — tokens inside meta/comments never warn",
|
||||
assert.ok(!body.includes("[^commentonly]"));
|
||||
|
||||
const field = footnoteWarningsField(body);
|
||||
const joined = (field.footnoteWarnings ?? []).join("\n");
|
||||
// ONLY the body's dangling reference is flagged.
|
||||
assert.match(joined, /\[\^bodyref\]/);
|
||||
assert.ok(!joined.includes("metaonly"));
|
||||
assert.ok(!joined.includes("commentonly"));
|
||||
// ONLY the body's legacy definition triggers the advisory.
|
||||
assert.ok(Array.isArray(field.footnoteWarnings));
|
||||
assert.match(field.footnoteWarnings.join("\n"), /reference-style footnotes/i);
|
||||
|
||||
// Cross-check against analyzeFootnotes directly (same composition the importer uses).
|
||||
assert.deepEqual(analyzeFootnotes(body).danglingReferences, ["bodyref"]);
|
||||
// The meta/comments tokens, analyzed on their own, would NOT have warned in a
|
||||
// way that leaks here — the field is computed over the body only.
|
||||
assert.deepEqual(footnoteWarningsField("front-matter mentions text"), {});
|
||||
});
|
||||
|
||||
test("import on a clean body yields no footnoteWarnings field", () => {
|
||||
test("import on an inline-footnote body yields no footnoteWarnings field", () => {
|
||||
const full = serializeDocmostMarkdown(
|
||||
{ pageId: "p1" },
|
||||
"Clean body[^a] reusing[^a].\n\n[^a]: ok",
|
||||
"Clean body.^[a note] reusing.^[a note]",
|
||||
[],
|
||||
);
|
||||
const { body } = parseDocmostMarkdown(full);
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
insertNodeRelative,
|
||||
sanitizeForYjs,
|
||||
findUnstorableAttr,
|
||||
} from "../../build/lib/node-ops.js";
|
||||
} from "@docmost/prosemirror-markdown";
|
||||
|
||||
// ProseMirror builders. Blocks carry a stable id in attrs.id.
|
||||
const textNode = (text) => ({ type: "text", text });
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
deleteNodeById,
|
||||
assertUnambiguousMatch,
|
||||
insertNodeRelative,
|
||||
} from "../../build/lib/node-ops.js";
|
||||
} from "@docmost/prosemirror-markdown";
|
||||
|
||||
// ProseMirror builders. Blocks carry a stable id in attrs.id.
|
||||
const textNode = (text) => ({ type: "text", text });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildOutline, getNodeByRef } from "../../build/lib/node-ops.js";
|
||||
import { buildOutline, getNodeByRef } from "@docmost/prosemirror-markdown";
|
||||
|
||||
// Helpers to build the small fixture doc.
|
||||
const textNode = (text) => ({ type: "text", text });
|
||||
|
||||
@@ -2,7 +2,7 @@ import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { searchInDoc } from "../../build/lib/page-search.js";
|
||||
import { getNodeByRef } from "../../build/lib/node-ops.js";
|
||||
import { getNodeByRef } from "@docmost/prosemirror-markdown";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Document builders. Mirror the Docmost ProseMirror shape: paragraphs/headings
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { parseNodeArg } from "../../build/lib/parse-node-arg.js";
|
||||
import { parseNodeArg } from "@docmost/prosemirror-markdown";
|
||||
|
||||
test("parseNodeArg passes an object through unchanged", () => {
|
||||
const obj = { type: "paragraph", content: [] };
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
insertTableRow,
|
||||
deleteTableRow,
|
||||
updateTableCell,
|
||||
} from "../../build/lib/node-ops.js";
|
||||
} from "@docmost/prosemirror-markdown";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builders. Tables/rows/cells carry NO attrs.id — only the paragraph inside a
|
||||
|
||||
@@ -59,3 +59,89 @@ export function splitFootnoteParagraphs(encoded: string): string[] {
|
||||
paragraphs.push(current);
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline-authoring helpers (#414: moved here from the mcp `footnote-authoring.ts`
|
||||
// fork so the dedup convention — content-key + definition factory + id gen —
|
||||
// has ONE home next to the importer that shares the convention). Used by the
|
||||
// mcp author-inline tool (`insertInlineFootnote` in transforms.ts).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition";
|
||||
|
||||
function cloneJson<T>(v: T): T {
|
||||
if (typeof structuredClone === "function") return structuredClone(v);
|
||||
return JSON.parse(JSON.stringify(v)) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized content key for de-duplicating footnote DEFINITIONS by their text.
|
||||
*
|
||||
* Two definitions with the same key are the SAME footnote — so the inline
|
||||
* authoring tool reuses one id (one number, one definition, several references)
|
||||
* instead of minting a second definition. Key = plaintext (whitespace-collapsed,
|
||||
* trimmed) PLUS a signature of the inline mark types in order, so two notes that
|
||||
* read the same but differ in formatting (one bold, one plain) are NOT merged.
|
||||
* Conservative: only an exact match merges.
|
||||
*/
|
||||
export function footnoteContentKey(defNode: any): string {
|
||||
const parts: string[] = [];
|
||||
const visit = (n: any): void => {
|
||||
if (!n || typeof n !== "object") return;
|
||||
if (n.type === "text" && typeof n.text === "string") {
|
||||
const marks = Array.isArray(n.marks)
|
||||
? n.marks.map((m: any) => m?.type).filter(Boolean).sort().join(",")
|
||||
: "";
|
||||
parts.push(`${n.text}${marks}`);
|
||||
}
|
||||
if (Array.isArray(n.content)) for (const c of n.content) visit(c);
|
||||
};
|
||||
visit(defNode);
|
||||
// Collapse the assembled text's whitespace and trim, keeping the mark
|
||||
// signature attached so formatting differences still distinguish notes.
|
||||
return parts
|
||||
.join("")
|
||||
.replace(/[ \t\r\n]+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a footnoteDefinition node from inline ProseMirror nodes, keyed by id.
|
||||
*/
|
||||
export function makeFootnoteDefinition(id: string, inlineNodes: any[]): any {
|
||||
const content = Array.isArray(inlineNodes) ? cloneJson(inlineNodes) : [];
|
||||
return {
|
||||
type: FOOTNOTE_DEFINITION_NAME,
|
||||
attrs: { id },
|
||||
content: [{ type: "paragraph", content }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a uuidv7-style id (time-ordered), matching editor-ext's
|
||||
* `generateFootnoteId`. Used for a genuinely-new inline footnote id.
|
||||
*/
|
||||
export function generateFootnoteId(): string {
|
||||
const now = Date.now();
|
||||
const timeHex = now.toString(16).padStart(12, "0");
|
||||
const rand = (length: number) => {
|
||||
let s = "";
|
||||
for (let i = 0; i < length; i++)
|
||||
s += Math.floor(Math.random() * 16).toString(16);
|
||||
return s;
|
||||
};
|
||||
const versioned = "7" + rand(3);
|
||||
const variantNibble = (8 + Math.floor(Math.random() * 4)).toString(16);
|
||||
const variant = variantNibble + rand(3);
|
||||
return (
|
||||
timeHex.slice(0, 8) +
|
||||
"-" +
|
||||
timeHex.slice(8, 12) +
|
||||
"-" +
|
||||
versioned +
|
||||
"-" +
|
||||
variant +
|
||||
"-" +
|
||||
rand(12)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,3 +44,35 @@ export {
|
||||
docsCanonicallyEqual,
|
||||
} from "./canonicalize.js";
|
||||
export { parsePageFile, serializePageFile } from "./page-file.js";
|
||||
|
||||
// Pure, network-free helpers for manipulating a ProseMirror/TipTap document
|
||||
// tree by node id (#414: the single canonical copy, formerly forked into mcp).
|
||||
// Consumed by `@docmost/mcp` (patch/insert/delete node, table tools, outline).
|
||||
export {
|
||||
blockPlainText,
|
||||
buildOutline,
|
||||
getNodeByRef,
|
||||
replaceNodeById,
|
||||
deleteNodeById,
|
||||
sanitizeForYjs,
|
||||
findUnstorableAttr,
|
||||
insertNodeRelative,
|
||||
readTable,
|
||||
insertTableRow,
|
||||
deleteTableRow,
|
||||
updateTableCell,
|
||||
assertUnambiguousMatch,
|
||||
} from "./node-ops.js";
|
||||
export type { OutlineEntry } from "./node-ops.js";
|
||||
|
||||
// Normalize a ProseMirror node arg that the model may have serialized as a JSON
|
||||
// string (#414: single copy shared by mcp and the CommonJS server app).
|
||||
export { parseNodeArg } from "./parse-node-arg.js";
|
||||
|
||||
// Inline-footnote authoring convention (#414: single copy, formerly the mcp
|
||||
// `footnote-authoring.ts` fork), shared with the importer's `assembleFootnotes`.
|
||||
export {
|
||||
footnoteContentKey,
|
||||
makeFootnoteDefinition,
|
||||
generateFootnoteId,
|
||||
} from "./footnote.js";
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* `content`, non-object nodes, and absent `attrs` are tolerated.
|
||||
*/
|
||||
|
||||
import { stripInlineMarkdown } from "./text-normalize.js";
|
||||
|
||||
/** Deep-clone a JSON-serializable value without mutating the original. */
|
||||
function clone<T>(value: T): T {
|
||||
if (typeof structuredClone === "function") {
|
||||
@@ -97,12 +99,15 @@ export function buildOutline(doc: any): OutlineEntry[] {
|
||||
const entry: OutlineEntry = {
|
||||
index: i,
|
||||
type,
|
||||
id: isObject(block) && isObject(block.attrs) ? block.attrs.id ?? null : null,
|
||||
id:
|
||||
isObject(block) && isObject(block.attrs)
|
||||
? (block.attrs.id ?? null)
|
||||
: null,
|
||||
firstText: truncate(blockPlainText(block), 100),
|
||||
};
|
||||
|
||||
if (type === "heading") {
|
||||
entry.level = isObject(block.attrs) ? block.attrs.level ?? null : null;
|
||||
entry.level = isObject(block.attrs) ? (block.attrs.level ?? null) : null;
|
||||
} else if (type === "table") {
|
||||
const headerRow = block.content?.[0]?.content ?? [];
|
||||
entry.rows = block.content?.length ?? 0;
|
||||
@@ -247,6 +252,33 @@ export function deleteNodeById(
|
||||
return { doc: out, deleted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw a clear, model-actionable error when a node-id write op did NOT match
|
||||
* exactly one node (#159). `count === 0` -> "no node found"; `count > 1` ->
|
||||
* "ambiguous, refused" — Docmost duplicates block ids on copy/paste, so a write
|
||||
* by id could clobber/remove EVERY duplicate. The caller skips the write for any
|
||||
* `count !== 1` (the transform returns null), so this only REPORTS; nothing was
|
||||
* changed. No-op for the unambiguous single-match case.
|
||||
*/
|
||||
export function assertUnambiguousMatch(
|
||||
op: "patch_node" | "delete_node",
|
||||
verb: "replace" | "delete",
|
||||
count: number,
|
||||
nodeId: string,
|
||||
pageId: string,
|
||||
): void {
|
||||
if (count === 0) {
|
||||
throw new Error(
|
||||
`${op}: no node with id "${nodeId}" found on page ${pageId}`,
|
||||
);
|
||||
}
|
||||
if (count > 1) {
|
||||
throw new Error(
|
||||
`${op}: id "${nodeId}" is ambiguous — ${count} nodes on page ${pageId} share it (block ids are duplicated on copy/paste). Refusing to ${verb} all of them; nothing was changed. Re-target with a more specific anchor.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-clone `doc` and strip every node/mark attribute whose value is strictly
|
||||
* `undefined`, so the result is safe to hand to Yjs (which throws an opaque
|
||||
@@ -364,6 +396,31 @@ const REQUIRED_CONTAINER: Record<string, string> = {
|
||||
tableHeader: "tableRow",
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the index of the first TOP-LEVEL block whose plain text includes the
|
||||
* anchor, with a markdown-stripping FALLBACK. Returns -1 when none matches.
|
||||
*
|
||||
* Two passes preserve "exact wins globally":
|
||||
* - Pass 1: first block containing the verbatim `anchorText`.
|
||||
* - Pass 2 (only if pass 1 found nothing): first block containing the
|
||||
* markdown-stripped anchor, when stripping actually changed it.
|
||||
*/
|
||||
function findAnchorTextIndex(content: any[], anchorText: string): number {
|
||||
if (!Array.isArray(content)) return -1;
|
||||
// Pass 1: exact.
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
if (blockPlainText(content[i]).includes(anchorText)) return i;
|
||||
}
|
||||
// Pass 2: markdown-stripped fallback.
|
||||
const a = stripInlineMarkdown(anchorText);
|
||||
if (a !== anchorText && a.length > 0) {
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
if (blockPlainText(content[i]).includes(a)) return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate an anchor and return its ancestor chain (from `doc` down to and
|
||||
* including the matched node). Each chain entry is `{ node, index }` where
|
||||
@@ -399,14 +456,14 @@ function findAnchorChain(
|
||||
}
|
||||
|
||||
// By text: only top-level blocks are scanned (same rule as the JSON path).
|
||||
// Exact match wins; a markdown-stripped fallback is tried only on a miss.
|
||||
if (opts.anchorText != null && Array.isArray(doc.content)) {
|
||||
for (let i = 0; i < doc.content.length; i++) {
|
||||
if (blockPlainText(doc.content[i]).includes(opts.anchorText)) {
|
||||
return [
|
||||
{ node: doc, index: -1 },
|
||||
{ node: doc.content[i], index: i },
|
||||
];
|
||||
}
|
||||
const i = findAnchorTextIndex(doc.content, opts.anchorText);
|
||||
if (i !== -1) {
|
||||
return [
|
||||
{ node: doc, index: -1 },
|
||||
{ node: doc.content[i], index: i },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,13 +597,13 @@ export function insertNodeRelative(
|
||||
return { doc: out, inserted };
|
||||
}
|
||||
|
||||
// Resolve by text: only top-level doc.content blocks are scanned.
|
||||
// Resolve by text: only top-level doc.content blocks are scanned. Exact
|
||||
// match wins; a markdown-stripped fallback is tried only on a miss.
|
||||
if (opts.anchorText != null && isObject(out) && Array.isArray(out.content)) {
|
||||
for (let i = 0; i < out.content.length; i++) {
|
||||
if (blockPlainText(out.content[i]).includes(opts.anchorText)) {
|
||||
out.content.splice(i + offset, 0, fresh);
|
||||
return { doc: out, inserted: true };
|
||||
}
|
||||
const i = findAnchorTextIndex(out.content, opts.anchorText);
|
||||
if (i !== -1) {
|
||||
out.content.splice(i + offset, 0, fresh);
|
||||
return { doc: out, inserted: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,7 +674,8 @@ function locateTable(
|
||||
if (!isObject(rootClone)) return null;
|
||||
|
||||
// "#<n>": index into the top-level content array; must be a table.
|
||||
const indexMatch = typeof tableRef === "string" ? tableRef.match(/^#(\d+)$/) : null;
|
||||
const indexMatch =
|
||||
typeof tableRef === "string" ? tableRef.match(/^#(\d+)$/) : null;
|
||||
if (indexMatch) {
|
||||
const index = Number(indexMatch[1]);
|
||||
const block = Array.isArray(rootClone.content)
|
||||
@@ -717,7 +775,7 @@ export function readTable(
|
||||
: undefined;
|
||||
const id =
|
||||
isObject(firstPara) && isObject(firstPara.attrs)
|
||||
? firstPara.attrs.id ?? null
|
||||
? (firstPara.attrs.id ?? null)
|
||||
: null;
|
||||
rowIds.push(id);
|
||||
}
|
||||
@@ -751,14 +809,17 @@ export function insertTableRow(
|
||||
if (!Array.isArray(table.content)) table.content = [];
|
||||
const rows = table.content.length;
|
||||
const headerRow = table.content[0];
|
||||
const headerCells = Array.isArray(headerRow?.content) ? headerRow.content : [];
|
||||
const headerCells = Array.isArray(headerRow?.content)
|
||||
? headerRow.content
|
||||
: [];
|
||||
|
||||
// Column count is the WIDEST existing row, so the guard below stays
|
||||
// meaningful for ragged tables and the new row matches the table's width.
|
||||
// Fall back to the supplied cell count only when the table has no rows.
|
||||
let colCount = 0;
|
||||
for (const r of table.content) {
|
||||
if (isObject(r) && Array.isArray(r.content)) colCount = Math.max(colCount, r.content.length);
|
||||
if (isObject(r) && Array.isArray(r.content))
|
||||
colCount = Math.max(colCount, r.content.length);
|
||||
}
|
||||
if (colCount === 0) colCount = Array.isArray(cells) ? cells.length : 0;
|
||||
|
||||
@@ -771,7 +832,10 @@ export function insertTableRow(
|
||||
// Resolve the landing index up front so the cell-type decision and the splice
|
||||
// below agree: a valid integer in [0, rows] splices there, else we append.
|
||||
const landingIndex =
|
||||
typeof index === "number" && Number.isInteger(index) && index >= 0 && index <= rows
|
||||
typeof index === "number" &&
|
||||
Number.isInteger(index) &&
|
||||
index >= 0 &&
|
||||
index <= rows
|
||||
? index
|
||||
: rows;
|
||||
|
||||
@@ -790,7 +854,8 @@ export function insertTableRow(
|
||||
// A row landing at index 0 becomes the new header row, so inherit the
|
||||
// current header cell's type per column (Docmost uses "tableHeader" there);
|
||||
// every other position is a plain data cell.
|
||||
const cellType = landingIndex === 0 ? headerCells[i]?.type ?? "tableCell" : "tableCell";
|
||||
const cellType =
|
||||
landingIndex === 0 ? (headerCells[i]?.type ?? "tableCell") : "tableCell";
|
||||
newCells.push({
|
||||
type: cellType,
|
||||
attrs,
|
||||
@@ -862,9 +927,10 @@ export function updateTableCell(
|
||||
const rowNodes = Array.isArray(table.content) ? table.content : [];
|
||||
const rows = rowNodes.length;
|
||||
const rowNode = rowNodes[row];
|
||||
const cols = isObject(rowNode) && Array.isArray(rowNode.content)
|
||||
? rowNode.content.length
|
||||
: 0;
|
||||
const cols =
|
||||
isObject(rowNode) && Array.isArray(rowNode.content)
|
||||
? rowNode.content.length
|
||||
: 0;
|
||||
|
||||
if (
|
||||
!Number.isInteger(row) ||
|
||||
|
||||
+5
@@ -2,6 +2,11 @@
|
||||
// instead of an object. Normalize: parse a string to an object (throwing on
|
||||
// invalid JSON), pass an object through unchanged. Shared by patch_node /
|
||||
// insert_node (and the analogous update_page_json content parsing).
|
||||
//
|
||||
// This lives in the converter package (#414) so BOTH consumers import the ONE
|
||||
// copy: `@docmost/mcp` (ESM) and the CommonJS server app. The server cannot
|
||||
// import `@docmost/mcp` directly (ESM-only, no declaration files), but it does
|
||||
// import `@docmost/prosemirror-markdown` natively — so this is the shared home.
|
||||
export function parseNodeArg(
|
||||
node: unknown,
|
||||
errMsg = "node was a string but not valid JSON",
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Locator normalization: strip inline markdown wrappers and trailing
|
||||
* decoration from a LOCATOR string so a find/anchor that the model wrote with
|
||||
* markdown (or a stray emoji) can still match the document's plain text.
|
||||
*
|
||||
* This is used ONLY as a fallback for LOCATING (after an exact match fails);
|
||||
* it is never applied to replacement text or inserted node content, so no
|
||||
* formatting is ever lost.
|
||||
*
|
||||
* Scope note (#414): this package-local copy exists so `node-ops.ts` — which
|
||||
* lives here now (the single canonical copy) — can resolve its markdown-tolerant
|
||||
* anchor fallback without a circular dependency back on `@docmost/mcp`. It
|
||||
* intentionally carries ONLY `stripInlineMarkdown` (the primitive `node-ops`
|
||||
* needs); the mcp-side `text-normalize.ts` (which additionally serves
|
||||
* `json-edit.ts` via `stripBalancedWrappers`) is the subject of a separate
|
||||
* dedup task and is left untouched here.
|
||||
*/
|
||||
|
||||
/** Maximum unwrap passes, so pathological/nested input cannot loop forever. */
|
||||
const MAX_PASSES = 8;
|
||||
|
||||
/**
|
||||
* Inline emphasis/code/strikethrough wrappers, strong BEFORE emphasis so
|
||||
* `**x**` collapses to `x` rather than leaving a stray `*x*`. Each pattern is
|
||||
* non-greedy and capture group 1 is the inner text. Applied repeatedly until
|
||||
* the string stops changing (nested wrappers like `**_x_**`).
|
||||
*/
|
||||
const WRAPPER_PATTERNS: RegExp[] = [
|
||||
/\*\*([^*]+?)\*\*/g, // **x**
|
||||
/__([^_]+?)__/g, // __x__
|
||||
/~~([^~]+?)~~/g, // ~~x~~
|
||||
/\*([^*]+?)\*/g, // *x*
|
||||
/_([^_]+?)_/g, // _x_
|
||||
/``([^`]+?)``/g, // ``x``
|
||||
/`([^`]+?)`/g, // `x`
|
||||
];
|
||||
|
||||
/** Links/images -> their visible text. `!?` covers both `[t](u)` and ``. */
|
||||
const LINK_IMAGE_RE = /!?\[([^\]]*)\]\([^)]*\)/g;
|
||||
|
||||
/**
|
||||
* Apply the two balanced/link passes: first collapse links/images to their
|
||||
* visible text, then collapse balanced inline wrappers repeatedly until stable.
|
||||
* Does NOT trim decoration, does NOT guard against an empty result — it returns
|
||||
* exactly the transformed string.
|
||||
*/
|
||||
function stripWrappersAndLinks(s: string): string {
|
||||
// 1. Links/images -> their visible text.
|
||||
let out = s.replace(LINK_IMAGE_RE, "$1");
|
||||
|
||||
// 2. Strip balanced wrappers, repeating until the string is stable so nested
|
||||
// wrappers (`**_x_**`) and adjacent runs both collapse.
|
||||
for (let pass = 0; pass < MAX_PASSES; pass++) {
|
||||
const before = out;
|
||||
for (const re of WRAPPER_PATTERNS) {
|
||||
out = out.replace(re, "$1");
|
||||
}
|
||||
if (out === before) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conservatively strip inline markdown from a locator string.
|
||||
*
|
||||
* Deterministic, order-fixed steps:
|
||||
* 1. Links/images: `[text](url)` -> `text`, `` -> `alt`.
|
||||
* 2. Balanced inline wrappers (strong before emphasis, code, strikethrough),
|
||||
* applied repeatedly until stable for nested cases.
|
||||
* 3. Trim leading/trailing decoration only: whitespace, leftover marker chars
|
||||
* (`* _ ~ \``) and emoji. Letters/digits and sentence punctuation (`.`/`,`
|
||||
* etc.) are NEVER trimmed.
|
||||
*
|
||||
* If the result is empty (e.g. the input was only markers like `***`), the
|
||||
* ORIGINAL string is returned so a locator can never normalize down to "" and
|
||||
* match everything.
|
||||
*/
|
||||
export function stripInlineMarkdown(s: string): string {
|
||||
if (typeof s !== "string" || s.length === 0) return s;
|
||||
|
||||
// 1 + 2. Shared link/image and balanced-wrapper passes.
|
||||
let out = stripWrappersAndLinks(s);
|
||||
|
||||
// 3. Trim leading/trailing decoration: whitespace, leftover markdown markers,
|
||||
// and emoji (Extended_Pictographic plus the VS16 / ZWJ joiners, plus the
|
||||
// regional-indicator range U+1F1E6–U+1F1FF for flag emoji, which are NOT
|
||||
// Extended_Pictographic). The `u` flag enables the Unicode property escape.
|
||||
// Anchored runs only — interior text and sentence punctuation are untouched.
|
||||
const DECORATION =
|
||||
"[\\s*_~\\x60\\p{Extended_Pictographic}\\u{1F1E6}-\\u{1F1FF}\\u{FE0F}\\u{200D}]+";
|
||||
out = out
|
||||
.replace(new RegExp("^" + DECORATION, "u"), "")
|
||||
.replace(new RegExp(DECORATION + "$", "u"), "");
|
||||
|
||||
// 4. Never normalize a locator down to nothing.
|
||||
if (out.length === 0) return s;
|
||||
|
||||
return out;
|
||||
}
|
||||
Generated
-3
@@ -1041,9 +1041,6 @@ 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