Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 443cc0e88b | |||
| 5dc7a2703f | |||
| bfb4c8d8d0 | |||
| f794ac6d6c | |||
| e4e788f151 | |||
| 4be4a75fa3 | |||
| e6171a1810 | |||
| d269bd9efe | |||
| 7cb3199d09 |
@@ -157,12 +157,6 @@ jobs:
|
|||||||
- name: Build prosemirror-markdown
|
- name: Build prosemirror-markdown
|
||||||
run: pnpm --filter @docmost/prosemirror-markdown build
|
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||||
|
|
||||||
# docmost-client.loader.ts type-imports from @docmost/mcp (issue #446); its
|
|
||||||
# build/ is gitignored and `test:e2e` type-checks, so build it here or tsc
|
|
||||||
# fails with TS2307 (mirrors the e2e-mcp / mcp-server-parity jobs).
|
|
||||||
- name: Build mcp
|
|
||||||
run: pnpm --filter @docmost/mcp build
|
|
||||||
|
|
||||||
- name: Run migrations
|
- name: Run migrations
|
||||||
run: pnpm --filter ./apps/server migration:latest
|
run: pnpm --filter ./apps/server migration:latest
|
||||||
|
|
||||||
|
|||||||
+111
-7
@@ -12,20 +12,108 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Breaking Changes
|
### Breaking Changes
|
||||||
|
|
||||||
|
- **External MCP tool names are now camelCase (all renamed).** Every tool on the
|
||||||
|
external `/mcp` surface was renamed from `snake_case` to `camelCase`, so the
|
||||||
|
external MCP name now matches the in-app tool name exactly (one logical tool,
|
||||||
|
one name everywhere). For example `get_node` → `getNode`, `edit_page_text` →
|
||||||
|
`editPageText`, `patch_node` → `patchNode`. The tools' behaviour, inputs and
|
||||||
|
outputs are unchanged — only the names change. The single-word `search`
|
||||||
|
keeps its name.
|
||||||
|
|
||||||
|
*Migration (external MCP clients only — the in-app AI agent already used these
|
||||||
|
names and is unaffected):* update anything that refers to a tool by its
|
||||||
|
string name — permission allowlists (`mcp__gitmost-*__get_node` →
|
||||||
|
`mcp__gitmost-*__getNode`), saved prompts/skills, `.mcp.json` tool filters,
|
||||||
|
and metrics dashboards that group by the `tool` label — and roll it out in
|
||||||
|
lockstep with this deploy, because the old snake_case names stop resolving.
|
||||||
|
Released together with the `import_page_markdown`/`update_page_markdown`
|
||||||
|
change below so external configs break exactly once.
|
||||||
|
|
||||||
|
Full mapping (old → new):
|
||||||
|
|
||||||
|
| Old (snake_case) | New (camelCase) |
|
||||||
|
| --- | --- |
|
||||||
|
| `check_new_comments` | `checkNewComments` |
|
||||||
|
| `copy_page_content` | `copyPageContent` |
|
||||||
|
| `create_comment` | `createComment` |
|
||||||
|
| `create_page` | `createPage` |
|
||||||
|
| `delete_comment` | `deleteComment` |
|
||||||
|
| `delete_node` | `deleteNode` |
|
||||||
|
| `delete_page` | `deletePage` |
|
||||||
|
| `diff_page_versions` | `diffPageVersions` |
|
||||||
|
| `docmost_transform` | `docmostTransform` |
|
||||||
|
| `drawio_create` | `drawioCreate` |
|
||||||
|
| `drawio_get` | `drawioGet` |
|
||||||
|
| `drawio_guide` | `drawioGuide` |
|
||||||
|
| `drawio_shapes` | `drawioShapes` |
|
||||||
|
| `drawio_update` | `drawioUpdate` |
|
||||||
|
| `edit_page_text` | `editPageText` |
|
||||||
|
| `export_page_markdown` | `exportPageMarkdown` |
|
||||||
|
| `get_node` | `getNode` |
|
||||||
|
| `get_outline` | `getOutline` |
|
||||||
|
| `get_page` | `getPage` |
|
||||||
|
| `get_page_json` | `getPageJson` |
|
||||||
|
| `get_workspace` | `getWorkspace` |
|
||||||
|
| `insert_footnote` | `insertFootnote` |
|
||||||
|
| `insert_image` | `insertImage` |
|
||||||
|
| `insert_node` | `insertNode` |
|
||||||
|
| `list_comments` | `listComments` |
|
||||||
|
| `list_page_history` | `listPageHistory` |
|
||||||
|
| `list_pages` | `listPages` |
|
||||||
|
| `list_shares` | `listShares` |
|
||||||
|
| `list_spaces` | `listSpaces` |
|
||||||
|
| `move_page` | `movePage` |
|
||||||
|
| `patch_node` | `patchNode` |
|
||||||
|
| `rename_page` | `renamePage` |
|
||||||
|
| `replace_image` | `replaceImage` |
|
||||||
|
| `resolve_comment` | `resolveComment` |
|
||||||
|
| `restore_page_version` | `restorePageVersion` |
|
||||||
|
| `search` | `search` (unchanged) |
|
||||||
|
| `search_in_page` | `searchInPage` |
|
||||||
|
| `share_page` | `sharePage` |
|
||||||
|
| `stash_page` | `stashPage` |
|
||||||
|
| `table_delete_row` | `tableDeleteRow` |
|
||||||
|
| `table_get` | `tableGet` |
|
||||||
|
| `table_insert_row` | `tableInsertRow` |
|
||||||
|
| `table_update_cell` | `tableUpdateCell` |
|
||||||
|
| `unshare_page` | `unsharePage` |
|
||||||
|
| `update_comment` | `updateComment` |
|
||||||
|
| `update_page_json` | `updatePageJson` |
|
||||||
|
| `update_page_markdown` | `updatePageMarkdown` |
|
||||||
|
|
||||||
|
(#412)
|
||||||
|
|
||||||
- **External MCP: `import_page_markdown` removed, `update_page_markdown` added.**
|
- **External MCP: `import_page_markdown` removed, `update_page_markdown` added.**
|
||||||
The external `/mcp` surface no longer exposes `import_page_markdown` (the
|
The external `/mcp` surface no longer exposes `importPageMarkdown` (the
|
||||||
round-trip parser for a self-contained *exported* Docmost-Markdown file). In
|
round-trip parser for a self-contained *exported* Docmost-Markdown file). In
|
||||||
its place it now exposes **`update_page_markdown`** — a plain-Markdown
|
its place it now exposes **`updatePageMarkdown`** — a plain-Markdown
|
||||||
full-body replace (`{pageId, content, title?}`) that pairs with
|
full-body replace (`{pageId, content, title?}`) that pairs with
|
||||||
`update_page_json`, re-imports the whole body (block ids regenerate) and
|
`updatePageJson`, re-imports the whole body (block ids regenerate) and
|
||||||
parses Docmost-flavoured markdown including `^[...]` inline footnotes.
|
parses Docmost-flavoured markdown including `^[...]` inline footnotes.
|
||||||
*Migration:* MCP clients that called `import_page_markdown` to overwrite a
|
*Migration:* MCP clients that called `importPageMarkdown` to overwrite a
|
||||||
page's body from Markdown should call `update_page_markdown` instead (pass the
|
page's body from Markdown should call `updatePageMarkdown` instead (pass the
|
||||||
markdown as `content`). Round-tripping an exported Docmost-Markdown file with
|
markdown as `content`). Round-tripping an exported Docmost-Markdown file with
|
||||||
comment anchors/diagrams is no longer available on the external MCP surface;
|
comment anchors/diagrams is no longer available on the external MCP surface;
|
||||||
export remains via `export_page_markdown`. The in-app AI agent is unaffected —
|
export remains via `exportPageMarkdown`. The in-app AI agent is unaffected —
|
||||||
it keeps both `importPageMarkdown` and the renamed `updatePageMarkdown` (was
|
it keeps both `importPageMarkdown` and the renamed `updatePageMarkdown` (was
|
||||||
`updatePageContent`). The total MCP tool count is unchanged (−1 / +1). (#411)
|
`updatePageContent`). The total MCP tool count is unchanged (−1 / +1). The
|
||||||
|
external names shown here are the post-#412 camelCase names. (#411)
|
||||||
|
|
||||||
|
- **`getNode` now returns Markdown by default (was ProseMirror JSON).** The
|
||||||
|
block-level read/write tools default to Markdown so a block round trip is
|
||||||
|
`getNode` (markdown) → edit → `patchNode` (markdown). `getNode` now returns
|
||||||
|
`{ …, format: "markdown", markdown }` unless you pass `format: "json"` (which
|
||||||
|
restores the previous `{ …, node }` ProseMirror subtree); comment anchors —
|
||||||
|
including resolved ones — are preserved in the markdown so a write-back never
|
||||||
|
orphans a thread, and a node that cannot be a document top-level block
|
||||||
|
(`tableRow`/`tableCell`/`tableHeader` addressed via `#<index>`) auto-falls back
|
||||||
|
to JSON with `format: "json"` in the response. `patchNode`/`insertNode` gain a
|
||||||
|
`markdown` input alongside `node` (provide exactly one): the markdown fragment
|
||||||
|
may rewrite/insert several blocks at once and supports `^[...]` footnotes.
|
||||||
|
*Migration (external MCP clients only):* a client that consumed `getNode`'s
|
||||||
|
`node` field must now either read `markdown`, or pass `format: "json"` to keep
|
||||||
|
the old ProseMirror-JSON output. Released together with the `#411`/`#412`
|
||||||
|
breaking window so external configs break exactly once. (#413)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@@ -163,6 +251,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
by physical key position and matched against the commands; genuine Cyrillic
|
by physical key position and matched against the commands; genuine Cyrillic
|
||||||
search terms keep priority over remapped candidates, and short wrong-layout
|
search terms keep priority over remapped candidates, and short wrong-layout
|
||||||
prefixes match by command title. (#283, #285, #287)
|
prefixes match by command title. (#283, #285, #287)
|
||||||
|
- **Opt-in substring "lookup" search mode for agents.** `/api/search` gains an
|
||||||
|
additive, opt-in mode (guarded by a new `substring` flag) that matches literal
|
||||||
|
substrings of page titles and body text — so technical tokens the full-text
|
||||||
|
tokenizer mangles (`backup-srv.local`, `10.0.12.5`, `WB-MGE-30D86B`) are found
|
||||||
|
even when the FTS query is empty. It returns a location `path`, a windowed
|
||||||
|
`snippet` and a per-response relevance `score`, supports `titleOnly` and a
|
||||||
|
`parentPageId` subtree scope, and applies the page-level permission filter
|
||||||
|
before the limit. The web UI never sets `substring`, so its full-text search
|
||||||
|
behaviour is byte-for-byte unchanged. The leading-wildcard `LIKE` predicates
|
||||||
|
are backed by GIN trigram indexes on `LOWER(f_unaccent(title))` and
|
||||||
|
`LOWER(f_unaccent(text_content))` so lookups use a bitmap index scan instead of
|
||||||
|
a sequential scan. (#443)
|
||||||
|
- **MCP `search` tool returns richer, agent-oriented results.** The external MCP
|
||||||
|
`search` response shape changes for the agent surface: each hit now carries
|
||||||
|
`pageId` (renamed from `id`), plus `path`, `snippet` and `score`; the
|
||||||
|
UI-oriented `spaceId`, `rank` and `highlight` fields are dropped. (#443)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const mockLoaded = (DocmostClient: loader.DocmostClientCtor) => ({
|
|||||||
DocmostClient,
|
DocmostClient,
|
||||||
sharedToolSpecs: SHARED_TOOL_SPECS as unknown as Record<string, loader.SharedToolSpec>,
|
sharedToolSpecs: SHARED_TOOL_SPECS as unknown as Record<string, loader.SharedToolSpec>,
|
||||||
// Pure no-network draw.io helpers (#424). Type-correct stubs: these tests
|
// Pure no-network draw.io helpers (#424). Type-correct stubs: these tests
|
||||||
// never execute the drawio_shapes / drawio_guide tool bodies.
|
// never execute the drawioShapes / drawioGuide tool bodies.
|
||||||
searchShapes: (() => []) as unknown as loader.SearchShapesFn,
|
searchShapes: (() => []) as unknown as loader.SearchShapesFn,
|
||||||
getGuideSection: (() => ({
|
getGuideSection: (() => ({
|
||||||
section: 'index',
|
section: 'index',
|
||||||
@@ -355,23 +355,32 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
|
|||||||
content: [{ type: 'text', text: 'Hello' }],
|
content: [{ type: 'text', text: 'Hello' }],
|
||||||
};
|
};
|
||||||
|
|
||||||
it('patchNode parses a JSON-string node and forwards it as an object', async () => {
|
it('patchNode parses a JSON-string node and forwards it as { node } (object)', async () => {
|
||||||
const tools = await buildTools();
|
const tools = await buildTools();
|
||||||
await tools.patchNode.execute(
|
await tools.patchNode.execute(
|
||||||
{ pageId: 'p1', nodeId: 'n1', node: JSON.stringify(NODE_OBJ) } as never,
|
{ pageId: 'p1', nodeId: 'n1', node: JSON.stringify(NODE_OBJ) } as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
);
|
);
|
||||||
expect(patchNodeCalls).toHaveLength(1);
|
expect(patchNodeCalls).toHaveLength(1);
|
||||||
expect(patchNodeCalls[0]).toEqual(['p1', 'n1', NODE_OBJ]);
|
// #413: the 3rd arg is now the XOR input { markdown?, node? }.
|
||||||
|
expect(patchNodeCalls[0]).toEqual([
|
||||||
|
'p1',
|
||||||
|
'n1',
|
||||||
|
{ markdown: undefined, node: NODE_OBJ },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('patchNode passes an object node through unchanged', async () => {
|
it('patchNode passes an object node through unchanged inside { node }', async () => {
|
||||||
const tools = await buildTools();
|
const tools = await buildTools();
|
||||||
await tools.patchNode.execute(
|
await tools.patchNode.execute(
|
||||||
{ pageId: 'p1', nodeId: 'n1', node: NODE_OBJ } as never,
|
{ pageId: 'p1', nodeId: 'n1', node: NODE_OBJ } as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
);
|
);
|
||||||
expect(patchNodeCalls[0]).toEqual(['p1', 'n1', NODE_OBJ]);
|
expect(patchNodeCalls[0]).toEqual([
|
||||||
|
'p1',
|
||||||
|
'n1',
|
||||||
|
{ markdown: undefined, node: NODE_OBJ },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('patchNode throws the documented message on invalid JSON string', async () => {
|
it('patchNode throws the documented message on invalid JSON string', async () => {
|
||||||
@@ -385,7 +394,7 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
|
|||||||
expect(patchNodeCalls).toHaveLength(0);
|
expect(patchNodeCalls).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('insertNode parses a JSON-string node and forwards it as an object', async () => {
|
it('insertNode parses a JSON-string node and forwards it inside { node }', async () => {
|
||||||
const tools = await buildTools();
|
const tools = await buildTools();
|
||||||
await tools.insertNode.execute(
|
await tools.insertNode.execute(
|
||||||
{
|
{
|
||||||
@@ -396,9 +405,15 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
);
|
);
|
||||||
expect(insertNodeCalls).toHaveLength(1);
|
expect(insertNodeCalls).toHaveLength(1);
|
||||||
const [pageId, node] = insertNodeCalls[0];
|
// #413: the 2nd arg is the XOR input { markdown?, node? }, the 3rd is opts.
|
||||||
|
const [pageId, input, opts] = insertNodeCalls[0] as [
|
||||||
|
string,
|
||||||
|
{ markdown?: unknown; node?: unknown },
|
||||||
|
{ position?: string },
|
||||||
|
];
|
||||||
expect(pageId).toBe('p1');
|
expect(pageId).toBe('p1');
|
||||||
expect(node).toEqual(NODE_OBJ);
|
expect(input).toEqual({ markdown: undefined, node: NODE_OBJ });
|
||||||
|
expect(opts.position).toBe('append');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('insertNode throws the documented message on invalid JSON string', async () => {
|
it('insertNode throws the documented message on invalid JSON string', async () => {
|
||||||
@@ -912,7 +927,7 @@ describe('AiChatToolsService getCurrentPage selection (#388)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* #440 review: the in-app drawio_create / drawio_update handlers must forward
|
* #440 review: the in-app drawioCreate / drawioUpdate handlers must forward
|
||||||
* the optional `layout:"elk"` param to the client (5th positional arg), exactly
|
* the optional `layout:"elk"` param to the client (5th positional arg), exactly
|
||||||
* like the MCP host. It was silently dropped, so ELK auto-layout worked only via
|
* like the MCP host. It was silently dropped, so ELK auto-layout worked only via
|
||||||
* the standalone MCP server, not in-app. These tests pin per-host parity.
|
* the standalone MCP server, not in-app. These tests pin per-host parity.
|
||||||
|
|||||||
@@ -59,10 +59,12 @@ function __assertClientCallContract(client: DocmostClientLike): void {
|
|||||||
void client.getWorkspace();
|
void client.getWorkspace();
|
||||||
void client.getSpaces();
|
void client.getSpaces();
|
||||||
void client.listPages(s, n, true);
|
void client.listPages(s, n, true);
|
||||||
|
void client.getTree(s, s, n);
|
||||||
|
void client.getPageContext(s);
|
||||||
void client.listSidebarPages(s, s);
|
void client.listSidebarPages(s, s);
|
||||||
void client.getOutline(s);
|
void client.getOutline(s);
|
||||||
void client.getPageJson(s);
|
void client.getPageJson(s);
|
||||||
void client.getNode(s, s);
|
void client.getNode(s, s, 'markdown');
|
||||||
void client.searchInPage(s, s, {
|
void client.searchInPage(s, s, {
|
||||||
regex: true,
|
regex: true,
|
||||||
caseSensitive: true,
|
caseSensitive: true,
|
||||||
@@ -84,12 +86,16 @@ function __assertClientCallContract(client: DocmostClientLike): void {
|
|||||||
void client.movePage(s, s, s);
|
void client.movePage(s, s, s);
|
||||||
void client.deletePage(s);
|
void client.deletePage(s);
|
||||||
void client.editPageText(s, edits);
|
void client.editPageText(s, edits);
|
||||||
void client.patchNode(s, s, node);
|
void client.patchNode(s, s, { markdown: s, node });
|
||||||
void client.insertNode(s, node, {
|
void client.insertNode(
|
||||||
position: 'append',
|
s,
|
||||||
anchorNodeId: s,
|
{ markdown: s, node },
|
||||||
anchorText: s,
|
{
|
||||||
});
|
position: 'append',
|
||||||
|
anchorNodeId: s,
|
||||||
|
anchorText: s,
|
||||||
|
},
|
||||||
|
);
|
||||||
void client.deleteNode(s, s);
|
void client.deleteNode(s, s);
|
||||||
void client.updatePageJson(s, node, s);
|
void client.updatePageJson(s, node, s);
|
||||||
void client.tableInsertRow(s, s, cells, n);
|
void client.tableInsertRow(s, s, cells, n);
|
||||||
@@ -266,7 +272,7 @@ export class AiChatToolsService {
|
|||||||
// construction is shared with the page-change detection path (#274) via
|
// construction is shared with the page-change detection path (#274) via
|
||||||
// buildDocmostClient so both go over the exact same authenticated route.
|
// buildDocmostClient so both go over the exact same authenticated route.
|
||||||
// searchShapes / getGuideSection (#424) are the PURE, no-network helpers
|
// searchShapes / getGuideSection (#424) are the PURE, no-network helpers
|
||||||
// backing drawio_shapes / drawio_guide. They are `inlineBothHosts` specs (no
|
// backing drawioShapes / drawioGuide. They are `inlineBothHosts` specs (no
|
||||||
// canonical execute — their catalog loader uses import.meta and can't be
|
// canonical execute — their catalog loader uses import.meta and can't be
|
||||||
// value-imported into the zod-agnostic tool-specs.ts under the server's
|
// value-imported into the zod-agnostic tool-specs.ts under the server's
|
||||||
// commonjs type-check), so the shared registry loop below SKIPS them and this
|
// commonjs type-check), so the shared registry loop below SKIPS them and this
|
||||||
@@ -308,9 +314,10 @@ export class AiChatToolsService {
|
|||||||
// The in-app toolset. It starts with the tools kept INLINE here for a
|
// The in-app toolset. It starts with the tools kept INLINE here for a
|
||||||
// documented per-layer reason: an intentional behaviour/schema divergence from
|
// documented per-layer reason: an intentional behaviour/schema divergence from
|
||||||
// the standalone MCP surface (searchPages' hybrid RRF,
|
// the standalone MCP surface (searchPages' hybrid RRF,
|
||||||
// transformPage's guardrailed shorter schema), a
|
// transformPage's guardrailed shorter schema), a name clash the shared
|
||||||
// snake_case/camelCase naming clash the shared registry forbids (getTable vs
|
// registry forbids (in-app `getTable` verb-first vs the MCP noun-first
|
||||||
// the MCP `table_get`), per-request state the registry loop cannot provide
|
// `tableGet` — the registry requires mcpName === inAppKey), per-request
|
||||||
|
// state the registry loop cannot provide
|
||||||
// (getCurrentPage reads the resolved openedPage; searchPages closes over the
|
// (getCurrentPage reads the resolved openedPage; searchPages closes over the
|
||||||
// per-request user/embedding deps), or a tool with no MCP twin
|
// per-request user/embedding deps), or a tool with no MCP twin
|
||||||
// (listSidebarPages/getComment/getPageHistory). Every SHARED tool is then added
|
// (listSidebarPages/getComment/getPageHistory). Every SHARED tool is then added
|
||||||
@@ -477,9 +484,9 @@ export class AiChatToolsService {
|
|||||||
await client.listSidebarPages(spaceId, pageId),
|
await client.listSidebarPages(spaceId, pageId),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// NOT shared (kept inline): the MCP tool name `table_get` is noun-first
|
// NOT shared (kept inline): the MCP tool name `tableGet` is noun-first
|
||||||
// while this key is `getTable` (verb-first), breaking the
|
// while this key is `getTable` (verb-first), so it cannot satisfy the
|
||||||
// snake_case(inAppKey) convention the shared registry enforces. Its
|
// shared registry's `mcpName === inAppKey` convention (#412). Its
|
||||||
// reference parameter is still named `table` (was `tableRef`) so it matches
|
// reference parameter is still named `table` (was `tableRef`) so it matches
|
||||||
// the migrated table row/cell tools below.
|
// the migrated table row/cell tools below.
|
||||||
getTable: tool({
|
getTable: tool({
|
||||||
@@ -522,7 +529,7 @@ export class AiChatToolsService {
|
|||||||
|
|
||||||
// INTENTIONAL per-transport divergence (not shared): deliberately omits the
|
// INTENTIONAL per-transport divergence (not shared): deliberately omits the
|
||||||
// `deleteComments` schema field (comment-deletion guardrail) and carries a
|
// `deleteComments` schema field (comment-deletion guardrail) and carries a
|
||||||
// much shorter description; the standalone MCP `docmost_transform` exposes
|
// much shorter description; the standalone MCP `docmostTransform` exposes
|
||||||
// the full helper catalogue. Different schema, so kept per-layer.
|
// the full helper catalogue. Different schema, so kept per-layer.
|
||||||
transformPage: tool({
|
transformPage: tool({
|
||||||
description:
|
description:
|
||||||
@@ -553,7 +560,7 @@ export class AiChatToolsService {
|
|||||||
// WHICH mapping to run and returns its value directly (no envelope). For each
|
// WHICH mapping to run and returns its value directly (no envelope). For each
|
||||||
// spec:
|
// spec:
|
||||||
// - skip `mcpOnly` specs (they belong to the standalone MCP host only);
|
// - skip `mcpOnly` specs (they belong to the standalone MCP host only);
|
||||||
// - skip `inlineBothHosts` specs (drawio_shapes / drawio_guide): they carry
|
// - skip `inlineBothHosts` specs (drawioShapes / drawioGuide): they carry
|
||||||
// no execute and are wired INLINE just below, calling the pure helpers;
|
// no execute and are wired INLINE just below, calling the pure helpers;
|
||||||
// - use `inAppExecute` when the spec declares a DELIBERATE per-layer
|
// - use `inAppExecute` when the spec declares a DELIBERATE per-layer
|
||||||
// difference (a projected result shape, a different guardrail message);
|
// difference (a projected result shape, a different guardrail message);
|
||||||
@@ -574,7 +581,7 @@ export class AiChatToolsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// drawio_shapes / drawio_guide (#424): `inlineBothHosts` registry specs wired
|
// drawioShapes / drawioGuide (#424): `inlineBothHosts` registry specs wired
|
||||||
// here with the SAME schema+description the shared spec pins, but calling the
|
// here with the SAME schema+description the shared spec pins, but calling the
|
||||||
// pure searchShapes / getGuideSection helpers off the loaded @docmost/mcp
|
// pure searchShapes / getGuideSection helpers off the loaded @docmost/mcp
|
||||||
// module — they are not client methods and their catalog loader uses
|
// module — they are not client methods and their catalog loader uses
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ type DocmostClientMethod =
|
|||||||
| 'getWorkspace'
|
| 'getWorkspace'
|
||||||
| 'getSpaces'
|
| 'getSpaces'
|
||||||
| 'listPages'
|
| 'listPages'
|
||||||
|
| 'getTree'
|
||||||
|
| 'getPageContext'
|
||||||
| 'listSidebarPages'
|
| 'listSidebarPages'
|
||||||
| 'getOutline'
|
| 'getOutline'
|
||||||
| 'getPageJson'
|
| 'getPageJson'
|
||||||
@@ -146,7 +148,7 @@ export type CommentSignalTrackerFactory = (options: {
|
|||||||
|
|
||||||
// Pure, no-network draw.io helpers (#424). These are plain functions on the
|
// Pure, no-network draw.io helpers (#424). These are plain functions on the
|
||||||
// module (NOT DocmostClient methods) — the in-app AI-SDK service calls them
|
// module (NOT DocmostClient methods) — the in-app AI-SDK service calls them
|
||||||
// directly to wire drawio_shapes / drawio_guide, mirroring the MCP server.
|
// directly to wire drawioShapes / drawioGuide, mirroring the MCP server.
|
||||||
export type SearchShapesFn = (
|
export type SearchShapesFn = (
|
||||||
query: string,
|
query: string,
|
||||||
opts?: { category?: string; limit?: number },
|
opts?: { category?: string; limit?: number },
|
||||||
@@ -169,7 +171,7 @@ interface DocmostMcpModule {
|
|||||||
// the mocked loader in unit tests) — the stale-check below is a NO-OP when it
|
// the mocked loader in unit tests) — the stale-check below is a NO-OP when it
|
||||||
// is missing, so an older build never wrongly fails startup.
|
// is missing, so an older build never wrongly fails startup.
|
||||||
REGISTRY_STAMP?: string;
|
REGISTRY_STAMP?: string;
|
||||||
// Pure, no-network draw.io helpers (#424) backing drawio_shapes / drawio_guide.
|
// Pure, no-network draw.io helpers (#424) backing drawioShapes / drawioGuide.
|
||||||
// Those two specs are `inlineBothHosts` (they stay in SHARED_TOOL_SPECS for the
|
// Those two specs are `inlineBothHosts` (they stay in SHARED_TOOL_SPECS for the
|
||||||
// shared contract but carry no execute — their catalog loader uses import.meta
|
// shared contract but carry no execute — their catalog loader uses import.meta
|
||||||
// and can't be value-imported into the zod-agnostic tool-specs.ts), so the
|
// and can't be value-imported into the zod-agnostic tool-specs.ts), so the
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import { SHARED_TOOL_SPECS } from '../../../../../../packages/mcp/src/tool-specs
|
|||||||
* This test fails the build if a spec is added to the registry but never wired
|
* This test fails the build if a spec is added to the registry but never wired
|
||||||
* in-app, if an `inAppKey` is renamed without updating the service, if the
|
* in-app, if an `inAppKey` is renamed without updating the service, if the
|
||||||
* description drifts between the registry and the exposed tool, if the
|
* description drifts between the registry and the exposed tool, if the
|
||||||
* snake_case `mcpName` <-> camelCase `inAppKey` convention is broken, or if the
|
* `mcpName === inAppKey` convention is broken (issue #412 unified the external
|
||||||
* exposed tool's input-schema keys diverge from the spec's `buildShape`.
|
* MCP tool name with the in-app key — both are the same camelCase identifier),
|
||||||
|
* or if the exposed tool's input-schema keys diverge from the spec's
|
||||||
|
* `buildShape`.
|
||||||
*
|
*
|
||||||
* It does NOT need @docmost/mcp built: the registry is imported from TS source,
|
* It does NOT need @docmost/mcp built: the registry is imported from TS source,
|
||||||
* and the ESM loader is mocked so `forUser()` never dynamically imports the
|
* and the ESM loader is mocked so `forUser()` never dynamically imports the
|
||||||
@@ -74,10 +76,6 @@ describe('SHARED_TOOL_SPECS contract parity', () => {
|
|||||||
|
|
||||||
afterAll(() => jest.restoreAllMocks());
|
afterAll(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
// camelCase -> snake_case, matching the registry's mcpName convention.
|
|
||||||
const toSnake = (s: string) =>
|
|
||||||
s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
||||||
|
|
||||||
// Type as the (optional-buildShape) SharedToolSpec; the `satisfies` literal
|
// Type as the (optional-buildShape) SharedToolSpec; the `satisfies` literal
|
||||||
// above otherwise narrows to a union where some members lack buildShape.
|
// above otherwise narrows to a union where some members lack buildShape.
|
||||||
const specEntries = Object.entries(SHARED_TOOL_SPECS) as unknown as Array<
|
const specEntries = Object.entries(SHARED_TOOL_SPECS) as unknown as Array<
|
||||||
@@ -96,8 +94,8 @@ describe('SHARED_TOOL_SPECS contract parity', () => {
|
|||||||
expect(spec.inAppKey).toBe(registryKey);
|
expect(spec.inAppKey).toBe(registryKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('mcpName is the snake_case form of inAppKey', () => {
|
it('mcpName equals inAppKey (unified camelCase name, #412)', () => {
|
||||||
expect(spec.mcpName).toBe(toSnake(spec.inAppKey));
|
expect(spec.mcpName).toBe(spec.inAppKey);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is exposed in-app under its inAppKey', () => {
|
it('is exposed in-app under its inAppKey', () => {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ describe('tool tier metadata (#332)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('#410 image tools are DEFERRED, footnote tool is CORE', () => {
|
it('#410 image tools are DEFERRED, footnote tool is CORE', () => {
|
||||||
// insert_footnote is core (symmetric with editPageText); the image tools stay
|
// insertFootnote is core (symmetric with editPageText); the image tools stay
|
||||||
// deferred (rare, fat — loaded on demand). Assert both the spec tier and the
|
// deferred (rare, fat — loaded on demand). Assert both the spec tier and the
|
||||||
// CORE_TOOL_SET membership so a future tier edit that desyncs them fails here.
|
// CORE_TOOL_SET membership so a future tier edit that desyncs them fails here.
|
||||||
expect(SHARED_TOOL_SPECS.insertFootnote.tier).toBe('core');
|
expect(SHARED_TOOL_SPECS.insertFootnote.tier).toBe('core');
|
||||||
|
|||||||
@@ -60,10 +60,10 @@ export const CORE_TOOL_KEYS = [
|
|||||||
'listComments',
|
'listComments',
|
||||||
'resolveComment',
|
'resolveComment',
|
||||||
'editPageText',
|
'editPageText',
|
||||||
// #330 search_in_page — frequent for editorial sweeps; core despite predating
|
// #330 searchInPage — frequent for editorial sweeps; core despite predating
|
||||||
// the issue's tier list.
|
// the issue's tier list.
|
||||||
'searchInPage',
|
'searchInPage',
|
||||||
// #410 insert_footnote — core so pinpoint citations to already-written text
|
// #410 insertFootnote — core so pinpoint citations to already-written text
|
||||||
// don't degrade into literal `^[...]`; kept symmetric with editPageText.
|
// don't degrade into literal `^[...]`; kept symmetric with editPageText.
|
||||||
'insertFootnote',
|
'insertFootnote',
|
||||||
] as const;
|
] as const;
|
||||||
@@ -138,7 +138,7 @@ export const INLINE_TOOL_TIERS: Record<
|
|||||||
},
|
},
|
||||||
// NOTE: tableInsertRow, tableDeleteRow and tableUpdateCell moved to
|
// NOTE: tableInsertRow, tableDeleteRow and tableUpdateCell moved to
|
||||||
// @docmost/mcp's SHARED_TOOL_SPECS (#294); they carry their own deferred tier +
|
// @docmost/mcp's SHARED_TOOL_SPECS (#294); they carry their own deferred tier +
|
||||||
// catalogLine there. getTable stays inline (its MCP name table_get breaks the
|
// catalogLine there. getTable stays inline (its MCP name tableGet breaks the
|
||||||
// snake_case(inAppKey) convention, so it has no shared spec).
|
// snake_case(inAppKey) convention, so it has no shared spec).
|
||||||
// NOTE: checkNewComments moved to @docmost/mcp's SHARED_TOOL_SPECS (#294);
|
// NOTE: checkNewComments moved to @docmost/mcp's SHARED_TOOL_SPECS (#294);
|
||||||
// it carries its own deferred tier + catalogLine there.
|
// it carries its own deferred tier + catalogLine there.
|
||||||
@@ -150,7 +150,7 @@ export const INLINE_TOOL_TIERS: Record<
|
|||||||
// NOTE: sharePage moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); it carries
|
// NOTE: sharePage moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); it carries
|
||||||
// its own deferred tier + catalogLine there. transformPage stays inline (its
|
// its own deferred tier + catalogLine there. transformPage stays inline (its
|
||||||
// schema deliberately diverges — it omits the deleteComments field the MCP
|
// schema deliberately diverges — it omits the deleteComments field the MCP
|
||||||
// docmost_transform exposes, a comment-deletion guardrail).
|
// docmostTransform exposes, a comment-deletion guardrail).
|
||||||
transformPage: {
|
transformPage: {
|
||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine: "transformPage — run a sandboxed JS transform over a page's document.",
|
catalogLine: "transformPage — run a sandboxed JS transform over a page's document.",
|
||||||
|
|||||||
@@ -12,3 +12,22 @@ export class SearchResponseDto {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
space: Partial<Space>;
|
space: Partial<Space>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Response shape for the opt-in agent-lookup mode (#443, `substring: true`).
|
||||||
|
// Additive to the FTS response: carries the location (`path`), a windowed
|
||||||
|
// `snippet` around the first match and a per-response sort `score`. The MCP
|
||||||
|
// layer maps `id → pageId`; `slugId` is never exposed.
|
||||||
|
export class SearchLookupResponseDto {
|
||||||
|
id: string;
|
||||||
|
slugId: string;
|
||||||
|
title: string;
|
||||||
|
parentPageId: string | null;
|
||||||
|
// Ancestor titles from the space root down to the direct parent; [] for a
|
||||||
|
// root page.
|
||||||
|
path: string[];
|
||||||
|
// ~300–500 chars around the first match (or a leading text window / extended
|
||||||
|
// ts_headline fallback).
|
||||||
|
snippet: string;
|
||||||
|
// 0..1 float, meaningful ONLY for sorting within one response.
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,31 @@ export class SearchDTO {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
|
||||||
|
// --- Opt-in agent-lookup mode (#443). ------------------------------------
|
||||||
|
// These fields are ADDITIVE and default-off: a web client that sends none of
|
||||||
|
// them gets byte-identical FTS behaviour and result shape. They are only read
|
||||||
|
// by the substring/path/snippet code path in SearchService.searchPage.
|
||||||
|
//
|
||||||
|
// NOTE (standalone stdio vs stock upstream): stock upstream validates this DTO
|
||||||
|
// with `whitelist: true`, so an older server silently strips these unknown
|
||||||
|
// fields and the request degrades gracefully to the plain FTS behaviour.
|
||||||
|
|
||||||
|
// Enables the hybrid substring branch (title + text_content LIKE) merged with
|
||||||
|
// the existing FTS branch, plus tiered ranking, path and windowed snippet.
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
substring?: boolean;
|
||||||
|
|
||||||
|
// Restrict the search to a page and all of its descendants (inclusive).
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
parentPageId?: string;
|
||||||
|
|
||||||
|
// Match titles only; do not scan text_content.
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
titleOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SearchShareDTO extends SearchDTO {
|
export class SearchShareDTO extends SearchDTO {
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ export class SearchController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #443 graceful degradation: on EE/Typesense instances the request routes to
|
||||||
|
// the Typesense backend, which does NOT implement the opt-in agent-lookup
|
||||||
|
// mode. The `substring`/`parentPageId`/`titleOnly` fields are silently ignored
|
||||||
|
// and the response carries no `path`/`snippet`/`score` and no substring/tier
|
||||||
|
// ranking — it degrades to plain Typesense FTS. The native lookup mode below
|
||||||
|
// is Postgres-search-driver only.
|
||||||
if (this.environmentService.getSearchDriver() === 'typesense') {
|
if (this.environmentService.getSearchDriver() === 'typesense') {
|
||||||
return this.searchTypesense(searchDto, {
|
return this.searchTypesense(searchDto, {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import {
|
||||||
|
computeLookupScore,
|
||||||
|
escapeLikePattern,
|
||||||
|
SearchLookupTier,
|
||||||
|
} from './search.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-function coverage for the #443 agent-lookup helpers:
|
||||||
|
* - escapeLikePattern: LIKE-metacharacter escaping so `%`/`_`/`\` are literals
|
||||||
|
* (the acceptance-table requirement that a query of `%` or `_` does NOT match
|
||||||
|
* everything);
|
||||||
|
* - computeLookupScore: the tiered 0..1 ranking score, where a stronger tier
|
||||||
|
* always outranks a weaker one regardless of the in-tier secondary signal.
|
||||||
|
*
|
||||||
|
* The DB-touching branch (substring UNION FTS, path CTE, snippet window) is
|
||||||
|
* covered by the integration spec against the real schema.
|
||||||
|
*/
|
||||||
|
describe('escapeLikePattern', () => {
|
||||||
|
it('escapes the LIKE metacharacters % _ and \\', () => {
|
||||||
|
expect(escapeLikePattern('%')).toBe('\\%');
|
||||||
|
expect(escapeLikePattern('_')).toBe('\\_');
|
||||||
|
expect(escapeLikePattern('\\')).toBe('\\\\');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes the backslash FIRST so it does not double-escape %/_', () => {
|
||||||
|
// Input `\%` must become `\\` + `\%` = `\\\%`, not `\\%`.
|
||||||
|
expect(escapeLikePattern('\\%')).toBe('\\\\\\%');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves ordinary technical chars (. - / digits) untouched', () => {
|
||||||
|
expect(escapeLikePattern('backup-srv.local')).toBe('backup-srv.local');
|
||||||
|
expect(escapeLikePattern('10.0.12')).toBe('10.0.12');
|
||||||
|
expect(escapeLikePattern('WB-MGE-30D86B')).toBe('WB-MGE-30D86B');
|
||||||
|
expect(escapeLikePattern('a/b')).toBe('a/b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes only the metacharacters in a mixed string', () => {
|
||||||
|
expect(escapeLikePattern('50%_off.zip')).toBe('50\\%\\_off.zip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is null/undefined-safe', () => {
|
||||||
|
expect(escapeLikePattern(undefined as any)).toBe('');
|
||||||
|
expect(escapeLikePattern(null as any)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeLookupScore', () => {
|
||||||
|
it('keeps every score within (0, 1]', () => {
|
||||||
|
for (const tier of [
|
||||||
|
SearchLookupTier.TITLE_EXACT,
|
||||||
|
SearchLookupTier.TITLE_SUBSTRING,
|
||||||
|
SearchLookupTier.TEXT,
|
||||||
|
]) {
|
||||||
|
for (const secondary of [0, 0.001, 1, 100, 1e6]) {
|
||||||
|
const s = computeLookupScore({ tier, secondary });
|
||||||
|
expect(s).toBeGreaterThan(0);
|
||||||
|
expect(s).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a stronger tier ALWAYS outranks a weaker tier, whatever the secondary', () => {
|
||||||
|
// Weak tier with a huge secondary must still lose to a strong tier with a
|
||||||
|
// tiny secondary — tiers dominate.
|
||||||
|
const strongLowSecondary = computeLookupScore({
|
||||||
|
tier: SearchLookupTier.TITLE_EXACT,
|
||||||
|
secondary: 0,
|
||||||
|
});
|
||||||
|
const weakHighSecondary = computeLookupScore({
|
||||||
|
tier: SearchLookupTier.TEXT,
|
||||||
|
secondary: 1e9,
|
||||||
|
});
|
||||||
|
expect(strongLowSecondary).toBeGreaterThan(weakHighSecondary);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('within a tier a larger secondary sorts higher', () => {
|
||||||
|
const lo = computeLookupScore({
|
||||||
|
tier: SearchLookupTier.TEXT,
|
||||||
|
secondary: 0.1,
|
||||||
|
});
|
||||||
|
const hi = computeLookupScore({
|
||||||
|
tier: SearchLookupTier.TEXT,
|
||||||
|
secondary: 5,
|
||||||
|
});
|
||||||
|
expect(hi).toBeGreaterThan(lo);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a negative/absent secondary as 0', () => {
|
||||||
|
const zero = computeLookupScore({ tier: SearchLookupTier.TEXT, secondary: 0 });
|
||||||
|
expect(computeLookupScore({ tier: SearchLookupTier.TEXT })).toBe(zero);
|
||||||
|
expect(
|
||||||
|
computeLookupScore({ tier: SearchLookupTier.TEXT, secondary: -5 }),
|
||||||
|
).toBe(zero);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { SearchDTO, SearchSuggestionDTO } from './dto/search.dto';
|
import { SearchDTO, SearchSuggestionDTO } from './dto/search.dto';
|
||||||
import { SearchResponseDto } from './dto/search-response.dto';
|
import {
|
||||||
|
SearchLookupResponseDto,
|
||||||
|
SearchResponseDto,
|
||||||
|
} from './dto/search-response.dto';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
import { sql } from 'kysely';
|
import { sql } from 'kysely';
|
||||||
@@ -34,6 +37,53 @@ export function buildTsQuery(raw: string): string {
|
|||||||
return tsquery(cleaned + '*');
|
return tsquery(cleaned + '*');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Escape the LIKE metacharacters (`%`, `_`, `\`) in a raw user query so every
|
||||||
|
// character — including `.`, `-`, `_`, `%`, `/` — is matched LITERALLY by a
|
||||||
|
// `col LIKE '%' || q || '%'` predicate. Without this, a query of `%` or `_`
|
||||||
|
// would match every row (see the #443 acceptance table). The backslash is the
|
||||||
|
// escape char (Postgres LIKE default), so it must be escaped first.
|
||||||
|
export function escapeLikePattern(raw: string): string {
|
||||||
|
return (raw ?? '')
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/%/g, '\\%')
|
||||||
|
.replace(/_/g, '\\_');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ranking tiers for the agent-lookup mode (#443), highest first. A hit's tier
|
||||||
|
// is the strongest way it matched; ties inside a tier break on a secondary
|
||||||
|
// signal (FTS rank, or first-match position). The numeric `score` returned to
|
||||||
|
// the caller is derived from (tier, secondary) and is meaningful ONLY for
|
||||||
|
// ordering within a single response.
|
||||||
|
export enum SearchLookupTier {
|
||||||
|
// Title equals the query, case-insensitively.
|
||||||
|
TITLE_EXACT = 3,
|
||||||
|
// Query is a substring of the title.
|
||||||
|
TITLE_SUBSTRING = 2,
|
||||||
|
// Query matched in the text (substring or FTS).
|
||||||
|
TEXT = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RankableHit {
|
||||||
|
tier: SearchLookupTier;
|
||||||
|
// Secondary in-tier signal, higher = better (e.g. ts_rank, or a
|
||||||
|
// position-derived closeness score). Defaults to 0.
|
||||||
|
secondary?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map (tier, secondary) → a 0..1 float used ONLY to sort one response.
|
||||||
|
//
|
||||||
|
// Formula: score = (tier + squash(secondary)) / (maxTier + 1), where
|
||||||
|
// squash(x) = x / (1 + x) maps any non-negative secondary into [0, 1)
|
||||||
|
// so a stronger tier ALWAYS outranks a weaker one regardless of the secondary
|
||||||
|
// value, and within a tier a larger secondary sorts higher. maxTier is the top
|
||||||
|
// enum value (TITLE_EXACT = 3), so the divisor keeps the result in (0, 1].
|
||||||
|
export function computeLookupScore(hit: RankableHit): number {
|
||||||
|
const maxTier = SearchLookupTier.TITLE_EXACT;
|
||||||
|
const secondary = Math.max(0, hit.secondary ?? 0);
|
||||||
|
const squashed = secondary / (1 + secondary);
|
||||||
|
return (hit.tier + squashed) / (maxTier + 1);
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SearchService {
|
export class SearchService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -50,12 +100,19 @@ export class SearchService {
|
|||||||
userId?: string;
|
userId?: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
},
|
},
|
||||||
): Promise<{ items: SearchResponseDto[] }> {
|
): Promise<{ items: SearchResponseDto[] | SearchLookupResponseDto[] }> {
|
||||||
const { query } = searchParams;
|
const { query } = searchParams;
|
||||||
|
|
||||||
if (query.length < 1) {
|
if (query.length < 1) {
|
||||||
return { items: [] };
|
return { items: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Opt-in agent-lookup mode (#443). Guarded by the `substring` flag so the
|
||||||
|
// web-UI (which never sets it) keeps byte-identical FTS behaviour below.
|
||||||
|
if (searchParams.substring) {
|
||||||
|
return this.searchPageLookup(searchParams, opts);
|
||||||
|
}
|
||||||
|
|
||||||
const searchQuery = buildTsQuery(query);
|
const searchQuery = buildTsQuery(query);
|
||||||
|
|
||||||
let queryResults = this.db
|
let queryResults = this.db
|
||||||
@@ -175,6 +232,348 @@ export class SearchService {
|
|||||||
return { items: searchResults };
|
return { items: searchResults };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent-lookup search (#443, opt-in via `SearchDTO.substring`).
|
||||||
|
*
|
||||||
|
* ADDITIVE to the FTS path: runs a substring branch (title + optionally
|
||||||
|
* text_content, LIKE with metacharacters escaped) MERGED with the existing
|
||||||
|
* FTS branch, so technical tokens that the `english` tokenizer mangles
|
||||||
|
* (`backup-srv.local`, `10.0.12.5`, `WB-MGE-30D86B`) are still found — even
|
||||||
|
* when `buildTsQuery()` returns '' for a dotted/numeric query. Results carry a
|
||||||
|
* location (`path`), a windowed `snippet` and a per-response `score`.
|
||||||
|
*
|
||||||
|
* The whole method is only reached when `substring: true`; the web-UI never
|
||||||
|
* sets it, so its behaviour is unchanged.
|
||||||
|
*/
|
||||||
|
private async searchPageLookup(
|
||||||
|
searchParams: SearchDTO,
|
||||||
|
opts: { userId?: string; workspaceId: string },
|
||||||
|
): Promise<{ items: SearchLookupResponseDto[] }> {
|
||||||
|
const rawQuery = searchParams.query.trim();
|
||||||
|
if (!rawQuery) {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = Math.min(Math.max(searchParams.limit || 10, 1), 50);
|
||||||
|
|
||||||
|
// Normalize the query the same way as the FTS / suggest path: f_unaccent +
|
||||||
|
// lower, done in SQL. `q` is the escaped LIKE pattern body (literal chars).
|
||||||
|
const likeBody = escapeLikePattern(rawQuery);
|
||||||
|
// Compare against `LOWER(f_unaccent(col))`; unaccent+lower the needle too.
|
||||||
|
const needle = sql<string>`LOWER(f_unaccent(${rawQuery}))`;
|
||||||
|
const likePattern = sql<string>`LOWER(f_unaccent(${'%' + likeBody + '%'}))`;
|
||||||
|
const tsQuery = buildTsQuery(rawQuery);
|
||||||
|
const hasTsQuery = tsQuery.length > 0;
|
||||||
|
|
||||||
|
// --- Resolve the space scope. ---------------------------------------------
|
||||||
|
// Mirrors searchPage: explicit spaceId, else the authenticated user's member
|
||||||
|
// spaces. The share path is not exposed to this opt-in mode.
|
||||||
|
let spaceIds: string[] = [];
|
||||||
|
if (searchParams.spaceId) {
|
||||||
|
spaceIds = [searchParams.spaceId];
|
||||||
|
} else if (opts.userId) {
|
||||||
|
spaceIds = await this.spaceMemberRepo.getUserSpaceIds(opts.userId);
|
||||||
|
} else {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
if (spaceIds.length === 0) {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Optional parentPageId subtree scope (inclusive). ---------------------
|
||||||
|
// Reuse the same recursive-descendants pattern used for share-scope.
|
||||||
|
let descendantIds: string[] | null = null;
|
||||||
|
if (searchParams.parentPageId) {
|
||||||
|
const descendants = await this.pageRepo.getPageAndDescendants(
|
||||||
|
searchParams.parentPageId,
|
||||||
|
{ includeContent: false },
|
||||||
|
);
|
||||||
|
descendantIds = descendants.map((p: any) => p.id);
|
||||||
|
if (descendantIds.length === 0) {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Candidate query: substring (title + text) UNION FTS. -----------------
|
||||||
|
// We compute everything the ranker needs in SQL and pull only small columns
|
||||||
|
// (never the whole text_content) into Node:
|
||||||
|
// - titleExact / titleSub: tier signals
|
||||||
|
// - textMatchPos: 1-based position of the first text match (0 = none)
|
||||||
|
// - ftsRank: ts_rank for the FTS secondary signal (0 when no tsquery)
|
||||||
|
// - snippet: windowed ~500 chars around the first text match, or a leading
|
||||||
|
// text window (title-only hit), or an extended ts_headline fallback.
|
||||||
|
const N_BEFORE = 60; // chars of context before the first match
|
||||||
|
const SNIPPET_LEN = 500;
|
||||||
|
|
||||||
|
let candidates = this.db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select([
|
||||||
|
'pages.id as id',
|
||||||
|
'pages.slugId as slugId',
|
||||||
|
'pages.title as title',
|
||||||
|
'pages.parentPageId as parentPageId',
|
||||||
|
// Tier signals.
|
||||||
|
sql<boolean>`LOWER(f_unaccent(coalesce(pages.title, ''))) = ${needle}`.as(
|
||||||
|
'titleExact',
|
||||||
|
),
|
||||||
|
sql<boolean>`LOWER(f_unaccent(coalesce(pages.title, ''))) LIKE ${likePattern} ESCAPE '\\'`.as(
|
||||||
|
'titleSub',
|
||||||
|
),
|
||||||
|
// 1-based position of the first text match (0 = no text match).
|
||||||
|
sql<number>`strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle})`.as(
|
||||||
|
'textMatchPos',
|
||||||
|
),
|
||||||
|
// FTS secondary signal (0 when the tsquery is empty).
|
||||||
|
hasTsQuery
|
||||||
|
? sql<number>`ts_rank(pages.tsv, to_tsquery('english', f_unaccent(${tsQuery})))`.as(
|
||||||
|
'ftsRank',
|
||||||
|
)
|
||||||
|
: sql<number>`0`.as('ftsRank'),
|
||||||
|
// Windowed snippet, computed entirely in SQL. Priority:
|
||||||
|
// 1. window around the first text match;
|
||||||
|
// 2. otherwise (titleOnly: no snippet; else) a leading window of the
|
||||||
|
// page text (title-only hit);
|
||||||
|
// 3. otherwise an extended ts_headline for pure-FTS hits.
|
||||||
|
//
|
||||||
|
// #443 snippet-position fix: the match position (`strpos`) is computed in
|
||||||
|
// the LOWER(f_unaccent(...)) space, but f_unaccent is NOT length-
|
||||||
|
// preserving (ß→ss, æ→ae, …→..., ½→ 1/2, full-width forms), so slicing
|
||||||
|
// the ORIGINAL text at that position was misaligned — a single expanding
|
||||||
|
// char before the match shifted the window (or ran it past end → empty).
|
||||||
|
// We now slice from the SAME LOWER(f_unaccent(...)) string so position
|
||||||
|
// and slice share one coordinate space. DELIBERATE trade-off: the snippet
|
||||||
|
// loses original case/diacritics — acceptable for an agent-facing snippet
|
||||||
|
// (position accuracy over original-glyph fidelity). The ts_headline branch
|
||||||
|
// matches over the ORIGINAL text itself, so it is unaffected and kept as-is.
|
||||||
|
searchParams.titleOnly
|
||||||
|
? sql<string>`''`.as('snippet')
|
||||||
|
: sql<string>`
|
||||||
|
coalesce(
|
||||||
|
case
|
||||||
|
when strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle}) > 0
|
||||||
|
then substring(
|
||||||
|
LOWER(f_unaccent(coalesce(pages.text_content, '')))
|
||||||
|
from greatest(1, strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle}) - ${N_BEFORE})
|
||||||
|
for ${SNIPPET_LEN}
|
||||||
|
)
|
||||||
|
when coalesce(pages.text_content, '') <> ''
|
||||||
|
then substring(LOWER(f_unaccent(pages.text_content)) from 1 for 300)
|
||||||
|
${
|
||||||
|
hasTsQuery
|
||||||
|
? sql`else ts_headline('english', coalesce(pages.text_content, ''), to_tsquery('english', f_unaccent(${tsQuery})), 'MinWords=25, MaxWords=40, MaxFragments=3')`
|
||||||
|
: sql``
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
''
|
||||||
|
)
|
||||||
|
`.as('snippet'),
|
||||||
|
])
|
||||||
|
.where('pages.deletedAt', 'is', null)
|
||||||
|
.where('pages.spaceId', 'in', spaceIds);
|
||||||
|
|
||||||
|
if (descendantIds) {
|
||||||
|
candidates = candidates.where('pages.id', 'in', descendantIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match predicate: title substring OR (unless titleOnly) text substring OR
|
||||||
|
// (unless titleOnly) FTS. The substring branch runs even when the tsquery is
|
||||||
|
// empty — that is the dotted/numeric-token case the FTS path misses.
|
||||||
|
//
|
||||||
|
// #443 dead-index fix: these two LIKE predicates MUST match the GIN trgm
|
||||||
|
// index expressions EXACTLY for Postgres to use them. The indexes are on the
|
||||||
|
// coalesce-FREE expressions `LOWER(f_unaccent(title))` (#348's
|
||||||
|
// idx_pages_title_trgm) and `LOWER(f_unaccent(text_content))` (this PR's
|
||||||
|
// idx_pages_text_content_trgm). A `coalesce(col,'')` wrapper here would make
|
||||||
|
// the query expression differ from the index expression and force a Seq Scan
|
||||||
|
// on pages for every lookup. Dropping coalesce is SEMANTICALLY EQUIVALENT:
|
||||||
|
// `NULL LIKE '%q%'` is NULL (falsy), so a NULL title/text simply doesn't
|
||||||
|
// match — exactly as an empty string wouldn't match `%q%`.
|
||||||
|
candidates = candidates.where((eb) => {
|
||||||
|
const ors = [
|
||||||
|
eb(
|
||||||
|
sql`LOWER(f_unaccent(pages.title))`,
|
||||||
|
'like',
|
||||||
|
sql`${likePattern} ESCAPE '\\'`,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (!searchParams.titleOnly) {
|
||||||
|
ors.push(
|
||||||
|
eb(
|
||||||
|
sql`LOWER(f_unaccent(pages.text_content))`,
|
||||||
|
'like',
|
||||||
|
sql`${likePattern} ESCAPE '\\'`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (hasTsQuery) {
|
||||||
|
ors.push(
|
||||||
|
sql<boolean>`pages.tsv @@ to_tsquery('english', f_unaccent(${tsQuery}))` as any,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return eb.or(ors);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pull a generous candidate set (before permission filtering + limit).
|
||||||
|
// Cap it so a pathological match set cannot blow up memory; 200 >> limit
|
||||||
|
// (max 50) leaves ample headroom for the post-permission truncation.
|
||||||
|
//
|
||||||
|
// #443 cap-ordering fix: the 200-cap MUST be deterministic and relevance-
|
||||||
|
// biased. Without an ORDER BY, Postgres returns an ARBITRARY 200 rows, so on
|
||||||
|
// a broad match set (common word / short substring) a strong TITLE_EXACT hit
|
||||||
|
// could be among the dropped rows while 200 low-tier TEXT hits fill the cap.
|
||||||
|
// We order by the SAME SQL tier proxies the Node ranker uses — title-exact,
|
||||||
|
// then title-substring, then fts-rank (nulls last), then earliest text-match
|
||||||
|
// position — so the cap keeps the strongest candidates. The Node-side final
|
||||||
|
// tier sort + slice(0, limit) below still runs and stays authoritative; this
|
||||||
|
// ORDER BY only decides WHICH candidates survive the 200-cap.
|
||||||
|
// NB: a BARE integer literal in ORDER BY is read by Postgres as an ordinal
|
||||||
|
// column position (`ORDER BY 0` → "position 0 is not in select list"), so the
|
||||||
|
// no-tsquery fallback is `0::float`, not `0`.
|
||||||
|
const ftsRankExpr = hasTsQuery
|
||||||
|
? sql`ts_rank(pages.tsv, to_tsquery('english', f_unaccent(${tsQuery})))`
|
||||||
|
: sql`0::float`;
|
||||||
|
const candidatesCapped = candidates
|
||||||
|
// Raw-SQL ORDER BY expressions: pass the full `<expr> <dir>` as ONE arg
|
||||||
|
// (the two-arg form treats a raw-SQL second arg as an ORDER BY position).
|
||||||
|
.orderBy(
|
||||||
|
sql`(LOWER(f_unaccent(coalesce(pages.title, ''))) = ${needle}) desc`,
|
||||||
|
)
|
||||||
|
.orderBy(
|
||||||
|
sql`(LOWER(f_unaccent(coalesce(pages.title, ''))) LIKE ${likePattern} ESCAPE '\\') desc`,
|
||||||
|
)
|
||||||
|
.orderBy(sql`${ftsRankExpr} desc nulls last`)
|
||||||
|
// Earlier text match first; strpos returns 0 for "no match", which would
|
||||||
|
// sort BEFORE a real (>=1) position under plain ASC, so push 0 to the end.
|
||||||
|
.orderBy(
|
||||||
|
sql`case when strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle}) = 0 then 2147483647 else strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle}) end asc`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let rows: any[] = await candidatesCapped.limit(200).execute();
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Permissions BEFORE limit. --------------------------------------------
|
||||||
|
// Apply the existing page-level post-filter to the MERGED set, then rank and
|
||||||
|
// only THEN truncate to `limit` — never lose the permission filter.
|
||||||
|
if (opts.userId) {
|
||||||
|
const accessibleIds =
|
||||||
|
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||||
|
pageIds: rows.map((r) => r.id),
|
||||||
|
userId: opts.userId,
|
||||||
|
spaceId: searchParams.spaceId,
|
||||||
|
workspaceId: opts.workspaceId,
|
||||||
|
});
|
||||||
|
const accessibleSet = new Set(accessibleIds);
|
||||||
|
rows = rows.filter((r) => accessibleSet.has(r.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tiered ranking + dedup. ----------------------------------------------
|
||||||
|
// Rows are already unique by id (single pages scan), so no cross-branch
|
||||||
|
// dedup is needed here; the tier captures the strongest match reason.
|
||||||
|
const ranked = rows.map((r) => {
|
||||||
|
let tier: SearchLookupTier;
|
||||||
|
let secondary: number;
|
||||||
|
if (r.titleExact) {
|
||||||
|
tier = SearchLookupTier.TITLE_EXACT;
|
||||||
|
secondary = Number(r.ftsRank) || 0;
|
||||||
|
} else if (r.titleSub) {
|
||||||
|
tier = SearchLookupTier.TITLE_SUBSTRING;
|
||||||
|
secondary = Number(r.ftsRank) || 0;
|
||||||
|
} else {
|
||||||
|
tier = SearchLookupTier.TEXT;
|
||||||
|
// Prefer earlier text matches; map position → closeness in (0, 1].
|
||||||
|
const pos = Number(r.textMatchPos) || 0;
|
||||||
|
secondary =
|
||||||
|
pos > 0 ? 1 / (1 + (pos - 1) / 100) : Number(r.ftsRank) || 0;
|
||||||
|
}
|
||||||
|
return { row: r, tier, score: computeLookupScore({ tier, secondary }) };
|
||||||
|
});
|
||||||
|
|
||||||
|
ranked.sort((a, b) => b.score - a.score);
|
||||||
|
const top = ranked.slice(0, limit);
|
||||||
|
|
||||||
|
// --- Batch ancestor path (ONE recursive CTE, not N+1). --------------------
|
||||||
|
const pathById = await this.buildAncestorPaths(top.map((t) => t.row.id));
|
||||||
|
|
||||||
|
const items: SearchLookupResponseDto[] = top.map((t) => ({
|
||||||
|
id: t.row.id,
|
||||||
|
slugId: t.row.slugId,
|
||||||
|
title: t.row.title,
|
||||||
|
parentPageId: t.row.parentPageId ?? null,
|
||||||
|
path: pathById.get(t.row.id) ?? [],
|
||||||
|
snippet: (t.row.snippet ?? '')
|
||||||
|
.replace(/\r\n|\r|\n/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim(),
|
||||||
|
score: t.score,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { items };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch ancestor-titles helper (#443): ONE recursive CTE seeded with ALL hit
|
||||||
|
* ids, walking UP parentPageId. Returns a map hitId → ancestor titles ordered
|
||||||
|
* root → direct parent (the hit's own title is excluded). Root pages map to
|
||||||
|
* an empty array. Avoids the N+1 of a per-page breadcrumb call.
|
||||||
|
*/
|
||||||
|
private async buildAncestorPaths(
|
||||||
|
hitIds: string[],
|
||||||
|
): Promise<Map<string, string[]>> {
|
||||||
|
const result = new Map<string, string[]>();
|
||||||
|
if (hitIds.length === 0) return result;
|
||||||
|
|
||||||
|
// ancestry(hit_id, page_id, title, parent_page_id, depth): seed one row per
|
||||||
|
// hit at depth 0 (the hit itself), then walk to parents (increasing depth).
|
||||||
|
const rows = await this.db
|
||||||
|
.withRecursive('ancestry', (db) =>
|
||||||
|
db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select([
|
||||||
|
'pages.id as hitId',
|
||||||
|
'pages.id as pageId',
|
||||||
|
'pages.title as title',
|
||||||
|
'pages.parentPageId as parentPageId',
|
||||||
|
sql<number>`0`.as('depth'),
|
||||||
|
])
|
||||||
|
.where('pages.id', 'in', hitIds)
|
||||||
|
.unionAll((exp) =>
|
||||||
|
exp
|
||||||
|
.selectFrom('pages as p')
|
||||||
|
.innerJoin('ancestry as a', 'p.id', 'a.parentPageId')
|
||||||
|
.select([
|
||||||
|
'a.hitId as hitId',
|
||||||
|
'p.id as pageId',
|
||||||
|
'p.title as title',
|
||||||
|
'p.parentPageId as parentPageId',
|
||||||
|
sql<number>`a.depth + 1`.as('depth'),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.selectFrom('ancestry')
|
||||||
|
.select(['hitId', 'title', 'depth'])
|
||||||
|
// depth 0 is the hit itself — excluded from the path.
|
||||||
|
.where('depth', '>', 0)
|
||||||
|
.orderBy('hitId')
|
||||||
|
// Larger depth = closer to the space root. Ordering DESC gives
|
||||||
|
// root → parent once collected.
|
||||||
|
.orderBy('depth', 'desc')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
for (const r of rows as any[]) {
|
||||||
|
const list = result.get(r.hitId) ?? [];
|
||||||
|
list.push(r.title);
|
||||||
|
result.set(r.hitId, list);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async searchSuggestions(
|
async searchSuggestions(
|
||||||
suggestion: SearchSuggestionDTO,
|
suggestion: SearchSuggestionDTO,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { type Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #443 — trigram indexes for the opt-in agent-lookup search mode.
|
||||||
|
*
|
||||||
|
* The lookup mode adds a substring branch that runs leading-wildcard
|
||||||
|
* `LOWER(f_unaccent(col)) LIKE '%q%'` predicates on pages.title and
|
||||||
|
* pages.text_content. A leading wildcard cannot use a b-tree index, so without a
|
||||||
|
* GIN trigram index each such predicate is a sequential scan.
|
||||||
|
*
|
||||||
|
* - TITLE: the lookup-mode title predicate is `LOWER(f_unaccent(title)) LIKE
|
||||||
|
* '%q%'` (coalesce-free, so it can use a functional index), which is IDENTICAL
|
||||||
|
* to the one added for /search/suggest (#348). #348's perf-indexes migration
|
||||||
|
* already created `idx_pages_title_trgm` on `(LOWER(f_unaccent(title)))
|
||||||
|
* gin_trgm_ops`, so the title predicate is already covered — we do NOT
|
||||||
|
* re-create that index here (it would be redundant).
|
||||||
|
*
|
||||||
|
* - TEXT_CONTENT: NEW. The substring branch scans text_content when the query
|
||||||
|
* is not titleOnly. text_content is the large column, so a GIN trigram index
|
||||||
|
* on it is the meaningful acceleration for the lookup mode. The lookup search
|
||||||
|
* is ALWAYS space-scoped (spaceId or the user's member spaces), so on small
|
||||||
|
* instances a per-space sequential scan is tolerable — but the index turns the
|
||||||
|
* `%q%` text predicate into a Bitmap Index Scan and removes the only
|
||||||
|
* unbounded-per-space cost of the feature. We add it. The trade-off is disk +
|
||||||
|
* write amplification on page edits (GIN trigram indexes are larger and slower
|
||||||
|
* to update than b-trees); on the small instances this fork targets that cost
|
||||||
|
* is acceptable and the read win on agent lookups is the priority.
|
||||||
|
*
|
||||||
|
* DEPLOY-TIME LOCK WARNING: plain (non-CONCURRENT) CREATE INDEX — Kysely runs
|
||||||
|
* each migration in a transaction, so CONCURRENTLY is impossible. The build takes
|
||||||
|
* a SHARE lock that BLOCKS writes on `pages` for its duration. The text_content
|
||||||
|
* GIN build is the slow one and can take minutes on a large tenant. For large
|
||||||
|
* installations, run this in a maintenance window or build the index out-of-band
|
||||||
|
* with CREATE INDEX CONCURRENTLY before deploying (then `IF NOT EXISTS` no-ops
|
||||||
|
* here). Small/typical tenants are unaffected.
|
||||||
|
*/
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
// The title predicate is served by #348's idx_pages_title_trgm — see header.
|
||||||
|
// Only the text_content index is introduced here.
|
||||||
|
|
||||||
|
// text_content trigram index. Its expression is coalesce-free —
|
||||||
|
// `LOWER(f_unaccent(text_content))` — to EXACTLY match the coalesce-free
|
||||||
|
// lookup-mode text substring predicate in search.service.ts, so Postgres can
|
||||||
|
// use it (a `coalesce(...)` mismatch would silently fall back to a Seq Scan).
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pages_text_content_trgm
|
||||||
|
ON pages USING gin ((LOWER(f_unaccent(text_content))) gin_trgm_ops)
|
||||||
|
`.execute(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
// Only drop the index this migration introduced. idx_pages_title_trgm is owned
|
||||||
|
// by the #348 perf-indexes migration, so leave it for that migration's down().
|
||||||
|
await sql`DROP INDEX IF EXISTS idx_pages_text_content_trgm`.execute(db);
|
||||||
|
}
|
||||||
@@ -143,7 +143,7 @@ export type DocmostMcpConfig = (
|
|||||||
buf: Buffer,
|
buf: Buffer,
|
||||||
mime: string,
|
mime: string,
|
||||||
) => { uri: string; sha256: string; size: number };
|
) => { uri: string; sha256: string; size: number };
|
||||||
// Optional live/evict probes the package uses to keep stash_page's mirror
|
// Optional live/evict probes the package uses to keep stashPage's mirror
|
||||||
// counts honest under the store's FIFO eviction (mirror of the package's
|
// counts honest under the store's FIFO eviction (mirror of the package's
|
||||||
// sink type); older bindings omit them.
|
// sink type); older bindings omit them.
|
||||||
has?: (uri: string) => boolean;
|
has?: (uri: string) => boolean;
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ export class McpService implements OnModuleDestroy {
|
|||||||
// Should never happen: handle() always stashes before delegating.
|
// Should never happen: handle() always stashes before delegating.
|
||||||
throw new UnauthorizedException('MCP authentication missing.');
|
throw new UnauthorizedException('MCP authentication missing.');
|
||||||
}
|
}
|
||||||
// Inject the blob-sandbox sink after the auth decision so stash_page
|
// Inject the blob-sandbox sink after the auth decision so stashPage
|
||||||
// can store blobs in the shared in-RAM store regardless of which
|
// can store blobs in the shared in-RAM store regardless of which
|
||||||
// credential variant resolved. The sink (put/has/evict + uri↔id
|
// credential variant resolved. The sink (put/has/evict + uri↔id
|
||||||
// mapping) is owned by SandboxStore.asSink().
|
// mapping) is owned by SandboxStore.asSink().
|
||||||
|
|||||||
@@ -182,7 +182,6 @@ describe('AiChatService run-stream attach [integration]', () => {
|
|||||||
{
|
{
|
||||||
isAiChatDeferredToolsEnabled: () => false,
|
isAiChatDeferredToolsEnabled: () => false,
|
||||||
isAiChatResumableStreamEnabled: () => true,
|
isAiChatResumableStreamEnabled: () => true,
|
||||||
isAiChatFinalStepLockdownEnabled: () => false,
|
|
||||||
} as any,
|
} as any,
|
||||||
registry,
|
registry,
|
||||||
);
|
);
|
||||||
@@ -500,7 +499,6 @@ describe('AiChatService run-stream attach [integration]', () => {
|
|||||||
{
|
{
|
||||||
isAiChatDeferredToolsEnabled: () => false,
|
isAiChatDeferredToolsEnabled: () => false,
|
||||||
isAiChatResumableStreamEnabled: () => true,
|
isAiChatResumableStreamEnabled: () => true,
|
||||||
isAiChatFinalStepLockdownEnabled: () => false,
|
|
||||||
} as any,
|
} as any,
|
||||||
registry,
|
registry,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ describe('AiChatService.stream [integration]', () => {
|
|||||||
{} as any, // pageAccess (idem)
|
{} as any, // pageAccess (idem)
|
||||||
// environment (#332): keep deferred tool loading OFF for this lifecycle
|
// environment (#332): keep deferred tool loading OFF for this lifecycle
|
||||||
// harness so the toolset/behavior is exactly as before.
|
// harness so the toolset/behavior is exactly as before.
|
||||||
{ isAiChatDeferredToolsEnabled: () => false, isAiChatFinalStepLockdownEnabled: () => false } as any,
|
{ isAiChatDeferredToolsEnabled: () => false } as any,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,7 +378,7 @@ describe('AiChatService.stream [integration]', () => {
|
|||||||
{} as any,
|
{} as any,
|
||||||
{} as any,
|
{} as any,
|
||||||
// #332: deferred tool loading ON — the property under test.
|
// #332: deferred tool loading ON — the property under test.
|
||||||
{ isAiChatDeferredToolsEnabled: () => true, isAiChatFinalStepLockdownEnabled: () => false } as any,
|
{ isAiChatDeferredToolsEnabled: () => true } as any,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { Kysely, sql } from 'kysely';
|
||||||
|
import {
|
||||||
|
getTestDb,
|
||||||
|
destroyTestDb,
|
||||||
|
createWorkspace,
|
||||||
|
createSpace,
|
||||||
|
} from './db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #443 dead-index guard — EXPLAIN on the REAL DB.
|
||||||
|
*
|
||||||
|
* The lookup mode's substring predicates run a leading-wildcard
|
||||||
|
* `LOWER(f_unaccent(col)) LIKE '%q%'`. Those are only fast when Postgres uses
|
||||||
|
* the GIN trigram indexes:
|
||||||
|
* - idx_pages_title_trgm on (LOWER(f_unaccent(title))) [#348]
|
||||||
|
* - idx_pages_text_content_trgm on (LOWER(f_unaccent(text_content))) [#443]
|
||||||
|
*
|
||||||
|
* Postgres uses a functional index ONLY when the query expression matches the
|
||||||
|
* index expression EXACTLY. The original lookup query wrapped the columns in
|
||||||
|
* `coalesce(col,'')`, which differs from the coalesce-FREE index expression and
|
||||||
|
* silently forced a Seq Scan on pages for EVERY lookup (the MCP client always
|
||||||
|
* sends substring:true). This test locks that in.
|
||||||
|
*
|
||||||
|
* Discriminator: `SET enable_seqscan = off` asks the planner "CAN this predicate
|
||||||
|
* use the index at all?" — which is exactly what the coalesce bug breaks. With
|
||||||
|
* seqscan disabled:
|
||||||
|
* - the coalesce-FREE (fixed) predicate plans a Bitmap Index Scan on the trgm
|
||||||
|
* index (no Seq Scan on pages);
|
||||||
|
* - the coalesce-WRAPPED (buggy) predicate cannot use the index and falls back
|
||||||
|
* to a Seq Scan on pages even though seqscan is disabled.
|
||||||
|
* We assert both to prove the fix and to keep the regression from silently
|
||||||
|
* returning.
|
||||||
|
*/
|
||||||
|
describe('SearchService agent-lookup EXPLAIN — trgm index is live [integration]', () => {
|
||||||
|
let db: Kysely<any>;
|
||||||
|
let workspaceId: string;
|
||||||
|
let spaceId: string;
|
||||||
|
|
||||||
|
async function insertPage(title: string, textContent: string): Promise<void> {
|
||||||
|
const id = randomUUID();
|
||||||
|
await db
|
||||||
|
.insertInto('pages')
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
slugId: `slug-${id.slice(0, 12)}`,
|
||||||
|
title,
|
||||||
|
textContent,
|
||||||
|
spaceId,
|
||||||
|
workspaceId,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run EXPLAIN (no ANALYZE — we only inspect the chosen plan) and return the
|
||||||
|
// concatenated plan text.
|
||||||
|
async function explain(query: string): Promise<string> {
|
||||||
|
const rows = await sql<{ 'QUERY PLAN': string }>`EXPLAIN ${sql.raw(query)}`.execute(
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
return (rows.rows as any[]).map((r) => r['QUERY PLAN']).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = getTestDb();
|
||||||
|
workspaceId = (await createWorkspace(db)).id;
|
||||||
|
spaceId = (await createSpace(db, workspaceId)).id;
|
||||||
|
|
||||||
|
// Seed enough rows that a trigram index is a plausible plan. The content is
|
||||||
|
// varied so the '%needle%' pattern is selective.
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
await insertPage(
|
||||||
|
`seed-title-${i}`,
|
||||||
|
`seed body content number ${i} lorem ipsum dolor sit amet ${i}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await insertPage('backup-srv.local', 'the needle-token-xyz lives here');
|
||||||
|
|
||||||
|
// Keep the trgm indexes' stats fresh so the planner costs them correctly.
|
||||||
|
await sql`ANALYZE pages`.execute(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await destroyTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force the planner to answer "can the index be used?" rather than "is it
|
||||||
|
// cheaper than a seq scan on this size?". Restored after each test.
|
||||||
|
beforeEach(async () => {
|
||||||
|
await sql`SET enable_seqscan = off`.execute(db);
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await sql`RESET enable_seqscan`.execute(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title predicate (coalesce-FREE, as fixed) uses idx_pages_title_trgm, not a Seq Scan', async () => {
|
||||||
|
const plan = await explain(
|
||||||
|
`SELECT id FROM pages WHERE LOWER(f_unaccent(title)) LIKE '%srv.local%'`,
|
||||||
|
);
|
||||||
|
expect(plan).toContain('idx_pages_title_trgm');
|
||||||
|
expect(plan).not.toMatch(/Seq Scan on pages/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('text_content predicate (coalesce-FREE, as fixed) uses idx_pages_text_content_trgm, not a Seq Scan', async () => {
|
||||||
|
const plan = await explain(
|
||||||
|
`SELECT id FROM pages WHERE LOWER(f_unaccent(text_content)) LIKE '%needle-token%'`,
|
||||||
|
);
|
||||||
|
expect(plan).toContain('idx_pages_text_content_trgm');
|
||||||
|
expect(plan).not.toMatch(/Seq Scan on pages/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Negative control: the OLD coalesce-wrapped predicate must NOT be able to use
|
||||||
|
// the index — even with seqscan disabled it can only Seq Scan pages. If this
|
||||||
|
// ever stops seq-scanning, the coalesce/index expressions have re-aligned and
|
||||||
|
// the guard above is no longer meaningful.
|
||||||
|
it('coalesce-WRAPPED text predicate (the bug) cannot use the index — falls to Seq Scan', async () => {
|
||||||
|
const plan = await explain(
|
||||||
|
`SELECT id FROM pages WHERE LOWER(f_unaccent(coalesce(text_content,''))) LIKE '%needle-token%'`,
|
||||||
|
);
|
||||||
|
expect(plan).not.toContain('idx_pages_text_content_trgm');
|
||||||
|
expect(plan).toMatch(/Seq Scan on pages/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { Kysely } from 'kysely';
|
||||||
|
import { SearchService } from 'src/core/search/search.service';
|
||||||
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
|
import {
|
||||||
|
getTestDb,
|
||||||
|
destroyTestDb,
|
||||||
|
createWorkspace,
|
||||||
|
createSpace,
|
||||||
|
} from './db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #443 — agent-lookup search mode, acceptance on the REAL DB schema.
|
||||||
|
*
|
||||||
|
* Exercises SearchService.searchPage(..., { substring: true }) against a
|
||||||
|
* migrated Postgres: substring matching of technical tokens the FTS tokenizer
|
||||||
|
* mangles (backup-srv.local, 10.0.12.5, WB-MGE-30D86B, "Теги: Docker"), the
|
||||||
|
* populated path + snippet, parentPageId subtree scoping, titleOnly, the empty
|
||||||
|
* result, LIKE-metacharacter escaping (`%`/`_` must NOT match everything), the
|
||||||
|
* permission post-filter applied BEFORE the limit, and the web-UI path staying
|
||||||
|
* on the legacy FTS shape when `substring` is absent.
|
||||||
|
*
|
||||||
|
* The tsv column is populated by the pages_tsvector_trigger on insert, so the
|
||||||
|
* FTS branch is exercised too.
|
||||||
|
*/
|
||||||
|
describe('SearchService agent-lookup mode [integration]', () => {
|
||||||
|
let db: Kysely<any>;
|
||||||
|
let service: SearchService;
|
||||||
|
let workspaceId: string;
|
||||||
|
let spaceId: string;
|
||||||
|
|
||||||
|
// Direct page insert (the shared createPage seeder omits text_content /
|
||||||
|
// parent_page_id, both of which this mode depends on). Returns the id.
|
||||||
|
async function insertPage(args: {
|
||||||
|
title: string;
|
||||||
|
textContent?: string;
|
||||||
|
parentPageId?: string | null;
|
||||||
|
spaceId?: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const id = randomUUID();
|
||||||
|
await db
|
||||||
|
.insertInto('pages')
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
slugId: `slug-${id.slice(0, 12)}`,
|
||||||
|
title: args.title,
|
||||||
|
textContent: args.textContent ?? null,
|
||||||
|
parentPageId: args.parentPageId ?? null,
|
||||||
|
spaceId: args.spaceId ?? spaceId,
|
||||||
|
workspaceId,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a SearchService wired to the real DB + a real PageRepo (only its
|
||||||
|
// recursive-descendants method is used by this mode, and it needs only `db`),
|
||||||
|
// with lightweight stubs for the space-membership and permission repos so a
|
||||||
|
// test can drive scope + the permission post-filter explicitly.
|
||||||
|
function buildService(opts?: {
|
||||||
|
userSpaceIds?: string[];
|
||||||
|
// ids to KEEP after the permission post-filter; undefined = keep all.
|
||||||
|
accessibleIds?: string[];
|
||||||
|
}): SearchService {
|
||||||
|
const pageRepo = new PageRepo(db as any, null as any, null as any);
|
||||||
|
const spaceMemberRepo = {
|
||||||
|
getUserSpaceIds: async () => opts?.userSpaceIds ?? [spaceId],
|
||||||
|
};
|
||||||
|
const pagePermissionRepo = {
|
||||||
|
filterAccessiblePageIds: async ({ pageIds }: { pageIds: string[] }) =>
|
||||||
|
opts?.accessibleIds
|
||||||
|
? pageIds.filter((id) => opts.accessibleIds!.includes(id))
|
||||||
|
: pageIds,
|
||||||
|
};
|
||||||
|
return new SearchService(
|
||||||
|
db as any,
|
||||||
|
pageRepo as any,
|
||||||
|
{} as any, // shareRepo — unused by the lookup path
|
||||||
|
spaceMemberRepo as any,
|
||||||
|
pagePermissionRepo as any,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = getTestDb();
|
||||||
|
workspaceId = (await createWorkspace(db)).id;
|
||||||
|
spaceId = (await createSpace(db, workspaceId)).id;
|
||||||
|
service = buildService();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await destroyTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds `backup-srv.local` by the fragment `srv.local`', async () => {
|
||||||
|
const pageId = await insertPage({
|
||||||
|
title: 'backup-srv.local',
|
||||||
|
textContent: 'A backup server node.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items } = (await service.searchPage(
|
||||||
|
{ query: 'srv.local', spaceId, substring: true } as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
expect(items.map((i: any) => i.id)).toContain(pageId);
|
||||||
|
const hit = items.find((i: any) => i.id === pageId);
|
||||||
|
expect(hit.title).toBe('backup-srv.local');
|
||||||
|
// slugId must never be part of the server response shape.
|
||||||
|
expect('slugId' in hit).toBe(true); // server carries it; MCP strips it
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds a page whose TEXT contains `10.0.12.5` by the fragment `10.0.12` (empty-tsquery case)', async () => {
|
||||||
|
const pageId = await insertPage({
|
||||||
|
title: 'Server inventory',
|
||||||
|
textContent: 'The backup box lives at IP: 10.0.12.5. Debian 12, backups.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items } = (await service.searchPage(
|
||||||
|
{ query: '10.0.12', spaceId, substring: true } as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
const hit = items.find((i: any) => i.id === pageId);
|
||||||
|
expect(hit).toBeDefined();
|
||||||
|
// The windowed snippet must include the matched text.
|
||||||
|
expect(hit.snippet).toContain('10.0.12.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds `WB-MGE-30D86B` (alphanumeric token with dashes) by title', async () => {
|
||||||
|
const pageId = await insertPage({
|
||||||
|
title: 'WB-MGE-30D86B',
|
||||||
|
textContent: 'Device page.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items } = (await service.searchPage(
|
||||||
|
{ query: 'WB-MGE-30D86B', spaceId, substring: true } as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
const hit = items.find((i: any) => i.id === pageId);
|
||||||
|
expect(hit).toBeDefined();
|
||||||
|
// Exact title match → top tier (TITLE_EXACT=3) → score in [0.75, 1].
|
||||||
|
expect(hit.score).toBeGreaterThanOrEqual(0.75);
|
||||||
|
// And it is the top-ranked hit of its own result set.
|
||||||
|
expect(items[0].id).toBe(pageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds every page whose text literally contains `Теги: Docker`', async () => {
|
||||||
|
const a = await insertPage({
|
||||||
|
title: 'Container host A',
|
||||||
|
textContent: 'Some notes.\nТеги: Docker, compose\nmore.',
|
||||||
|
});
|
||||||
|
const b = await insertPage({
|
||||||
|
title: 'Container host B',
|
||||||
|
textContent: 'Prelude.\nТеги: Docker\nepilogue.',
|
||||||
|
});
|
||||||
|
const noise = await insertPage({
|
||||||
|
title: 'Unrelated',
|
||||||
|
textContent: 'Теги: Kubernetes',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items } = (await service.searchPage(
|
||||||
|
{ query: 'Теги: Docker', spaceId, substring: true, limit: 50 } as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
const ids = items.map((i: any) => i.id);
|
||||||
|
expect(ids).toContain(a);
|
||||||
|
expect(ids).toContain(b);
|
||||||
|
expect(ids).not.toContain(noise);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('populates a non-empty `path` for a nested hit and `[]` for a root hit', async () => {
|
||||||
|
const root = await insertPage({ title: 'Infrastructure' });
|
||||||
|
const mid = await insertPage({ title: 'Datacenter A', parentPageId: root });
|
||||||
|
const leaf = await insertPage({
|
||||||
|
title: 'unique-nested-host',
|
||||||
|
parentPageId: mid,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items } = (await service.searchPage(
|
||||||
|
{ query: 'unique-nested-host', spaceId, substring: true } as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
const hit = items.find((i: any) => i.id === leaf);
|
||||||
|
expect(hit.path).toEqual(['Infrastructure', 'Datacenter A']);
|
||||||
|
|
||||||
|
const rootHits = (await service.searchPage(
|
||||||
|
{ query: 'Infrastructure', spaceId, substring: true } as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
const rootHit = rootHits.items.find((i: any) => i.id === root);
|
||||||
|
expect(rootHit.path).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scopes to a subtree with parentPageId (cutting off sibling branches)', async () => {
|
||||||
|
const branchA = await insertPage({ title: 'BranchA-root' });
|
||||||
|
const inA = await insertPage({
|
||||||
|
title: 'scoped-target-xyz',
|
||||||
|
parentPageId: branchA,
|
||||||
|
});
|
||||||
|
const branchB = await insertPage({ title: 'BranchB-root' });
|
||||||
|
const inB = await insertPage({
|
||||||
|
title: 'scoped-target-xyz',
|
||||||
|
parentPageId: branchB,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items } = (await service.searchPage(
|
||||||
|
{
|
||||||
|
query: 'scoped-target-xyz',
|
||||||
|
spaceId,
|
||||||
|
substring: true,
|
||||||
|
parentPageId: branchA,
|
||||||
|
} as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
const ids = items.map((i: any) => i.id);
|
||||||
|
expect(ids).toContain(inA);
|
||||||
|
expect(ids).not.toContain(inB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes the parent page itself in the parentPageId subtree', async () => {
|
||||||
|
const parent = await insertPage({ title: 'self-included-parent' });
|
||||||
|
await insertPage({ title: 'child-of-self', parentPageId: parent });
|
||||||
|
|
||||||
|
const { items } = (await service.searchPage(
|
||||||
|
{
|
||||||
|
query: 'self-included-parent',
|
||||||
|
spaceId,
|
||||||
|
substring: true,
|
||||||
|
parentPageId: parent,
|
||||||
|
} as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
expect(items.map((i: any) => i.id)).toContain(parent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('titleOnly does NOT match on text_content', async () => {
|
||||||
|
const pageId = await insertPage({
|
||||||
|
title: 'Plain title',
|
||||||
|
textContent: 'body mentions the-secret-token here',
|
||||||
|
});
|
||||||
|
|
||||||
|
const withText = (await service.searchPage(
|
||||||
|
{ query: 'the-secret-token', spaceId, substring: true } as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
expect(withText.items.map((i: any) => i.id)).toContain(pageId);
|
||||||
|
|
||||||
|
const titleOnly = (await service.searchPage(
|
||||||
|
{
|
||||||
|
query: 'the-secret-token',
|
||||||
|
spaceId,
|
||||||
|
substring: true,
|
||||||
|
titleOnly: true,
|
||||||
|
} as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
expect(titleOnly.items.map((i: any) => i.id)).not.toContain(pageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #443 Fix #1 regression: f_unaccent is NOT length-preserving, so an
|
||||||
|
// expanding char (ß→ss, …→...) BEFORE the match shifted the strpos position
|
||||||
|
// relative to the ORIGINAL text and the snippet slice ran past end → empty.
|
||||||
|
// The position and the slice now share the LOWER(f_unaccent(...)) space, so
|
||||||
|
// the window is aligned and always contains the matched (unaccented) token.
|
||||||
|
it('returns a populated snippet when an unaccent-EXPANDING char precedes the match', async () => {
|
||||||
|
// 300 × `ß` (each f_unaccent-expands to `ss`) before the needle. Under the
|
||||||
|
// old code strpos returned a position ~593 in the expanded space but the
|
||||||
|
// slice ran over the ORIGINAL (~360 char) text → empty snippet, match lost.
|
||||||
|
const prefix = 'ß'.repeat(300);
|
||||||
|
const pageId = await insertPage({
|
||||||
|
title: 'Expanding-unaccent page',
|
||||||
|
textContent: `${prefix} needle-token-xyz trailing.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items } = (await service.searchPage(
|
||||||
|
{ query: 'needle-token-xyz', spaceId, substring: true } as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
const hit = items.find((i: any) => i.id === pageId);
|
||||||
|
expect(hit).toBeDefined();
|
||||||
|
// Snippet must be non-empty AND contain the matched token (unaccented form).
|
||||||
|
expect(hit.snippet.length).toBeGreaterThan(0);
|
||||||
|
expect(hit.snippet).toContain('needle-token-xyz');
|
||||||
|
});
|
||||||
|
|
||||||
|
// #443 Fix #2 regression: >200 matching pages for a broad substring, with
|
||||||
|
// exactly ONE exact-title hit. Without an ORDER BY on the 200-cap the exact
|
||||||
|
// hit could be among the arbitrarily-dropped rows; the ORDER BY keeps the
|
||||||
|
// strongest candidates so it must survive the cap and rank at the top.
|
||||||
|
it('keeps an exact-title hit through the 200-cap on a >200-row match set', async () => {
|
||||||
|
const isoSpace = (await createSpace(db, workspaceId)).id;
|
||||||
|
const svc = buildService({ userSpaceIds: [isoSpace] });
|
||||||
|
|
||||||
|
// 250 low-tier TEXT hits: the shared substring `capword` appears only in the
|
||||||
|
// body, never the title, so each is a TEXT-tier match (weakest tier).
|
||||||
|
for (let i = 0; i < 250; i++) {
|
||||||
|
await insertPage({
|
||||||
|
title: `filler-page-${i}`,
|
||||||
|
textContent: `body contains capword here #${i}`,
|
||||||
|
spaceId: isoSpace,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Exactly one EXACT-title hit for the same query token.
|
||||||
|
const exact = await insertPage({
|
||||||
|
title: 'capword',
|
||||||
|
textContent: 'unrelated body text',
|
||||||
|
spaceId: isoSpace,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items } = (await svc.searchPage(
|
||||||
|
{ query: 'capword', spaceId: isoSpace, substring: true, limit: 10 } as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
const ids = items.map((i: any) => i.id);
|
||||||
|
// The exact-title hit must survive the 200-cap and appear in the top `limit`.
|
||||||
|
expect(ids).toContain(exact);
|
||||||
|
// And, being TITLE_EXACT, it must be the single strongest hit.
|
||||||
|
expect(items[0].id).toBe(exact);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #443 Fix #3: titleOnly matches only the title, so it must not leak the page
|
||||||
|
// body as the snippet (the old "first 300 chars of text_content" fallback).
|
||||||
|
it('titleOnly does NOT return a text-body snippet', async () => {
|
||||||
|
const pageId = await insertPage({
|
||||||
|
title: 'titleonly-snippet-page',
|
||||||
|
textContent: 'SECRET-BODY-CONTENT-NOT-IN-TITLE that must not leak.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items } = (await service.searchPage(
|
||||||
|
{
|
||||||
|
query: 'titleonly-snippet-page',
|
||||||
|
spaceId,
|
||||||
|
substring: true,
|
||||||
|
titleOnly: true,
|
||||||
|
} as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
const hit = items.find((i: any) => i.id === pageId);
|
||||||
|
expect(hit).toBeDefined();
|
||||||
|
// The body text must not appear in the snippet; titleOnly → empty snippet.
|
||||||
|
expect(hit.snippet).not.toContain('SECRET-BODY-CONTENT-NOT-IN-TITLE');
|
||||||
|
expect(hit.snippet).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] (not an error) for a query that matches nothing', async () => {
|
||||||
|
const { items } = (await service.searchPage(
|
||||||
|
{
|
||||||
|
query: 'zzz-no-such-string-anywhere-42',
|
||||||
|
spaceId,
|
||||||
|
substring: true,
|
||||||
|
} as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
expect(items).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a `%` query does NOT match everything (LIKE metacharacter escaped)', async () => {
|
||||||
|
// Fresh space so we can assert on total counts without cross-test noise.
|
||||||
|
const isoSpace = (await createSpace(db, workspaceId)).id;
|
||||||
|
const svc = buildService({ userSpaceIds: [isoSpace] });
|
||||||
|
await insertPage({ title: 'alpha', spaceId: isoSpace });
|
||||||
|
await insertPage({ title: 'beta', spaceId: isoSpace });
|
||||||
|
const literal = await insertPage({
|
||||||
|
title: '100%-coverage',
|
||||||
|
spaceId: isoSpace,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items } = (await svc.searchPage(
|
||||||
|
{ query: '%', spaceId: isoSpace, substring: true, limit: 50 } as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
const ids = items.map((i: any) => i.id);
|
||||||
|
// `%` is a literal → matches only the page that actually contains '%'.
|
||||||
|
expect(ids).toContain(literal);
|
||||||
|
expect(ids).not.toContain(
|
||||||
|
items.find((i: any) => i.title === 'alpha')?.id,
|
||||||
|
);
|
||||||
|
expect(items.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an `_` query does NOT match everything (LIKE metacharacter escaped)', async () => {
|
||||||
|
const isoSpace = (await createSpace(db, workspaceId)).id;
|
||||||
|
const svc = buildService({ userSpaceIds: [isoSpace] });
|
||||||
|
await insertPage({ title: 'gamma', spaceId: isoSpace });
|
||||||
|
const literal = await insertPage({
|
||||||
|
title: 'snake_case_name',
|
||||||
|
spaceId: isoSpace,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items } = (await svc.searchPage(
|
||||||
|
{ query: '_', spaceId: isoSpace, substring: true, limit: 50 } as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
const ids = items.map((i: any) => i.id);
|
||||||
|
expect(ids).toContain(literal);
|
||||||
|
expect(items.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the permission post-filter to the MERGED set BEFORE the limit', async () => {
|
||||||
|
const isoSpace = (await createSpace(db, workspaceId)).id;
|
||||||
|
const keep = await insertPage({
|
||||||
|
title: 'perm-visible-target',
|
||||||
|
spaceId: isoSpace,
|
||||||
|
});
|
||||||
|
const hidden = await insertPage({
|
||||||
|
title: 'perm-hidden-target',
|
||||||
|
spaceId: isoSpace,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Authenticated (userId set) so the permission filter runs; only `keep` is
|
||||||
|
// accessible. limit 1 must NOT be able to select `hidden`.
|
||||||
|
const svc = buildService({
|
||||||
|
userSpaceIds: [isoSpace],
|
||||||
|
accessibleIds: [keep],
|
||||||
|
});
|
||||||
|
const { items } = (await svc.searchPage(
|
||||||
|
{
|
||||||
|
query: 'perm-',
|
||||||
|
spaceId: isoSpace,
|
||||||
|
substring: true,
|
||||||
|
limit: 1,
|
||||||
|
} as any,
|
||||||
|
{ userId: 'user-1', workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
const ids = items.map((i: any) => i.id);
|
||||||
|
expect(ids).toContain(keep);
|
||||||
|
expect(ids).not.toContain(hidden);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('web-UI path (no `substring` flag) keeps the legacy FTS response shape', async () => {
|
||||||
|
await insertPage({
|
||||||
|
title: 'legacy shape page',
|
||||||
|
textContent: 'searchable legacyword content',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items } = (await service.searchPage(
|
||||||
|
{ query: 'legacyword', spaceId } as any,
|
||||||
|
{ userId: 'user-1', workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
// Legacy hits carry rank + highlight + space, and NO path/snippet/score.
|
||||||
|
const hit = items[0];
|
||||||
|
expect(hit).toBeDefined();
|
||||||
|
expect('rank' in hit).toBe(true);
|
||||||
|
expect('highlight' in hit).toBe(true);
|
||||||
|
expect('path' in hit).toBe(false);
|
||||||
|
expect('snippet' in hit).toBe(false);
|
||||||
|
expect('score' in hit).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+93
-86
@@ -12,7 +12,7 @@ license.
|
|||||||
> better at *writing a small function that fixes the text* than at re-reading and
|
> better at *writing a small function that fixes the text* than at re-reading and
|
||||||
> re-emitting a whole document. So this server is built around the way a model actually
|
> re-emitting a whole document. So this server is built around the way a model actually
|
||||||
> wants to edit: address a block by id, run a find/replace, or hand it a
|
> wants to edit: address a block by id, run a find/replace, or hand it a
|
||||||
> `(doc, ctx) => doc` transform and let it *program* the change. `docmost_transform` is
|
> `(doc, ctx) => doc` transform and let it *program* the change. `docmostTransform` is
|
||||||
> that interface. Other Docmost MCPs are human-shaped — they expose "open the page" and
|
> that interface. Other Docmost MCPs are human-shaped — they expose "open the page" and
|
||||||
> "replace the page"; this one exposes the editing primitives a model is good at.
|
> "replace the page"; this one exposes the editing primitives a model is good at.
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ There are several Docmost MCPs. Here is a capability-by-capability comparison.
|
|||||||
| **Enterprise license required** | **No** | **Yes** | No | No | No |
|
| **Enterprise license required** | **No** | **Yes** | No | No | No |
|
||||||
| Authentication | email + password, **auto re-auth** | API key | email + password | cookie `authToken` (copy from DevTools) | Docmost API / **direct PostgreSQL** |
|
| Authentication | email + password, **auto re-auth** | API key | email + password | cookie `authToken` (copy from DevTools) | Docmost API / **direct PostgreSQL** |
|
||||||
| Read page as Markdown | ✅ | ✅ | ✅ | ✅ | ✅ (read-only) |
|
| Read page as Markdown | ✅ | ✅ | ✅ | ✅ | ✅ (read-only) |
|
||||||
| **Lossless Markdown round-trip** (export / import, keeps comment anchors) | ✅ | — | — | — | — |
|
| **Markdown round-trip** (export / import, keeps comment anchors) | ✅ | — | — | — | — |
|
||||||
| Read **lossless ProseMirror JSON** (with block ids) | ✅ | — | — | — | — |
|
| Read **lossless ProseMirror JSON** (with block ids) | ✅ | — | — | — | — |
|
||||||
| **Compact page outline** (cheap block-id lookup) | ✅ | — | — | — | — |
|
| **Compact page outline** (cheap block-id lookup) | ✅ | — | — | — | — |
|
||||||
| **Fetch a single block** (by id or index) | ✅ | — | — | — | — |
|
| **Fetch a single block** (by id or index) | ✅ | — | — | — | — |
|
||||||
@@ -69,9 +69,9 @@ There are several Docmost MCPs. Here is a capability-by-capability comparison.
|
|||||||
- **Token-efficient editing.** Most Docmost MCPs (and the official one) only offer
|
- **Token-efficient editing.** Most Docmost MCPs (and the official one) only offer
|
||||||
"replace the whole page" writes — the agent must download the entire document, mutate
|
"replace the whole page" writes — the agent must download the entire document, mutate
|
||||||
it, and upload it back, paying for the full document **twice** on every tiny fix.
|
it, and upload it back, paying for the full document **twice** on every tiny fix.
|
||||||
This server lets the agent change exactly one block (`patch_node` / `insert_node` /
|
This server lets the agent change exactly one block (`patchNode` / `insertNode` /
|
||||||
`delete_node`), do a structure-preserving find/replace (`edit_page_text`), or copy a
|
`deleteNode`), do a structure-preserving find/replace (`editPageText`), or copy a
|
||||||
whole page server-side (`copy_page_content`) — **without the document ever passing
|
whole page server-side (`copyPageContent`) — **without the document ever passing
|
||||||
through the model**.
|
through the model**.
|
||||||
|
|
||||||
- **Writes that don't fight the editor.** Naive REST writes race with whatever a human
|
- **Writes that don't fight the editor.** Naive REST writes race with whatever a human
|
||||||
@@ -85,12 +85,12 @@ There are several Docmost MCPs. Here is a capability-by-capability comparison.
|
|||||||
- **Agent-native editing model.** Human-facing servers expose "open the page" and "replace
|
- **Agent-native editing model.** Human-facing servers expose "open the page" and "replace
|
||||||
the page", because that mirrors how a person works. A model edits better by *programming*
|
the page", because that mirrors how a person works. A model edits better by *programming*
|
||||||
the change — addressing blocks by id, running a find/replace, or supplying a
|
the change — addressing blocks by id, running a find/replace, or supplying a
|
||||||
`(doc, ctx) => doc` transform (`docmost_transform`, with a dry-run diff before it
|
`(doc, ctx) => doc` transform (`docmostTransform`, with a dry-run diff before it
|
||||||
commits). This server is shaped around that, which is why it has editing primitives the
|
commits). This server is shaped around that, which is why it has editing primitives the
|
||||||
others simply don't.
|
others simply don't.
|
||||||
|
|
||||||
- **An editing safety net the others lack.** `list_page_history` → `diff_page_versions`
|
- **An editing safety net the others lack.** `listPageHistory` → `diffPageVersions`
|
||||||
→ `restore_page_version` give an agent (and you) a full view-and-undo loop. The diff
|
→ `restorePageVersion` give an agent (and you) a full view-and-undo loop. The diff
|
||||||
uses the *same* `recreateTransform → ChangeSet → simplifyChanges` pipeline Docmost's
|
uses the *same* `recreateTransform → ChangeSet → simplifyChanges` pipeline Docmost's
|
||||||
own history viewer uses, so what you see matches the product.
|
own history viewer uses, so what you see matches the product.
|
||||||
|
|
||||||
@@ -110,56 +110,58 @@ All 41 tools, grouped by what you'd reach for them.
|
|||||||
|
|
||||||
### Exploration & retrieval
|
### Exploration & retrieval
|
||||||
|
|
||||||
- **`get_workspace`** — Information about the current Docmost workspace.
|
- **`getWorkspace`** — Information about the current Docmost workspace.
|
||||||
- **`list_spaces`** — All spaces in the workspace.
|
- **`listSpaces`** — All spaces in the workspace.
|
||||||
- **`list_pages`** — Recent pages in a space, ordered by `updatedAt` desc (default 50,
|
- **`listPages`** — Recent pages in a space, ordered by `updatedAt` desc (default 50,
|
||||||
max 100). Use `search` for lookups in large spaces.
|
max 100). Use `search` for lookups in large spaces.
|
||||||
- **`search`** — Full-text search across pages and content (bounded by `limit`, max 100).
|
- **`search`** — Full-text search across pages and content (bounded by `limit`, max 100).
|
||||||
- **`get_page`** — A page's content as clean **Markdown** (convenient, but a *lossy*
|
- **`getPage`** — A page's content as clean **Markdown** (canonical for text; drops only
|
||||||
view — block ids and exact table/callout structure are approximated).
|
block ids, resolved-comment anchors, and a fixed no-Markdown-representation attr set —
|
||||||
- **`get_page_json`** — A page's **lossless ProseMirror/TipTap JSON**, including every
|
table spans/colwidth/background, indent, `callout.icon`, `orderedList.type`, and link
|
||||||
|
`internal`/`target`/`rel`/`class`; use `getPageJson` when you need those).
|
||||||
|
- **`getPageJson`** — A page's **lossless ProseMirror/TipTap JSON**, including every
|
||||||
block's `attrs.id` and the `slugId` used in URLs. This is what the per-block editing
|
block's `attrs.id` and the `slugId` used in URLs. This is what the per-block editing
|
||||||
tools consume.
|
tools consume.
|
||||||
- **`get_outline`** — A compact outline of a page's top-level blocks (`{index, type, id,
|
- **`getOutline`** — A compact outline of a page's top-level blocks (`{index, type, id,
|
||||||
level, firstText}`; tables add row/column counts and their header-cell texts, lists add
|
level, firstText}`; tables add row/column counts and their header-cell texts, lists add
|
||||||
item counts) **without** the document body. The cheap way to locate a section or table
|
item counts) **without** the document body. The cheap way to locate a section or table
|
||||||
and grab its block id before
|
and grab its block id before
|
||||||
`get_node` / `patch_node` / `insert_node`.
|
`getNode` / `patchNode` / `insertNode`.
|
||||||
- **`get_node`** — Fetch a single block's full ProseMirror subtree (lossless) without
|
- **`getNode`** — Fetch a single block's full ProseMirror subtree (lossless) without
|
||||||
pulling the whole page. Address it by a block id (from `get_outline` / `get_page_json`),
|
pulling the whole page. Address it by a block id (from `getOutline` / `getPageJson`),
|
||||||
or by `#<index>` for a top-level block — use the `#<index>` form for tables/rows/cells,
|
or by `#<index>` for a top-level block — use the `#<index>` form for tables/rows/cells,
|
||||||
which carry no id.
|
which carry no id.
|
||||||
|
|
||||||
### Page lifecycle
|
### Page lifecycle
|
||||||
|
|
||||||
- **`create_page`** — Create a page from Markdown and place it in the hierarchy (optional
|
- **`createPage`** — Create a page from Markdown and place it in the hierarchy (optional
|
||||||
`parentPageId`) in one call. Uses Docmost's import API for clean Markdown→ProseMirror.
|
`parentPageId`) in one call. Uses Docmost's import API for clean Markdown→ProseMirror.
|
||||||
- **`rename_page`** — Change a page's title only, without touching or resending content.
|
- **`renamePage`** — Change a page's title only, without touching or resending content.
|
||||||
- **`move_page`** — Re-parent a page (nest it, or move to root); supports fractional-index
|
- **`movePage`** — Re-parent a page (nest it, or move to root); supports fractional-index
|
||||||
positioning. Returns only on a *positively confirmed* success.
|
positioning. Returns only on a *positively confirmed* success.
|
||||||
- **`delete_page`** — Delete a single page.
|
- **`deletePage`** — Delete a single page.
|
||||||
- **`copy_page_content`** — Replace one page's body with a copy of another's, **entirely
|
- **`copyPageContent`** — Replace one page's body with a copy of another's, **entirely
|
||||||
server-side** — the document never passes through the model. The target keeps its own
|
server-side** — the document never passes through the model. The target keeps its own
|
||||||
title and slug (so its URL is preserved).
|
title and slug (so its URL is preserved).
|
||||||
|
|
||||||
### Editing
|
### Editing
|
||||||
|
|
||||||
- **`edit_page_text`** — Surgical find/replace inside a page's text. Preserves **all**
|
- **`editPageText`** — Surgical find/replace inside a page's text. Preserves **all**
|
||||||
structure: block ids, marks, links, callouts, tables. The preferred tool for fixing
|
structure: block ids, marks, links, callouts, tables. The preferred tool for fixing
|
||||||
wording, typos, numbers and names.
|
wording, typos, numbers and names.
|
||||||
- **`patch_node`** — Replace a single block addressed by its `attrs.id` (from
|
- **`patchNode`** — Replace a single block addressed by its `attrs.id` (from
|
||||||
`get_page_json`), without resending the document.
|
`getPageJson`), without resending the document.
|
||||||
- **`insert_node`** — Insert a block before/after another (by `attrs.id` or anchor text),
|
- **`insertNode`** — Insert a block before/after another (by `attrs.id` or anchor text),
|
||||||
or append at the end.
|
or append at the end.
|
||||||
- **`delete_node`** — Remove a single block by its `attrs.id`.
|
- **`deleteNode`** — Remove a single block by its `attrs.id`.
|
||||||
- **`update_page_json`** — Replace a page's entire content with a ProseMirror document
|
- **`updatePageJson`** — Replace a page's entire content with a ProseMirror document
|
||||||
(bulk rewrites, or when nodes lack ids). `content` is optional — omit it to update only
|
(bulk rewrites, or when nodes lack ids). `content` is optional — omit it to update only
|
||||||
the title. Keeps the block ids you pass in, so heading anchors and history stay stable.
|
the title. Keeps the block ids you pass in, so heading anchors and history stay stable.
|
||||||
- **`update_page_markdown`** — Replace a page's body (and optionally its title) with new
|
- **`updatePageMarkdown`** — Replace a page's body (and optionally its title) with new
|
||||||
**plain Markdown**. The whole body is re-imported (block ids regenerate — for surgical or
|
**plain Markdown**. The whole body is re-imported (block ids regenerate — for surgical or
|
||||||
id-preserving edits prefer `edit_page_text` / `patch_node` / `update_page_json`).
|
id-preserving edits prefer `editPageText` / `patchNode` / `updatePageJson`).
|
||||||
Docmost-flavoured markdown is parsed, including `^[...]` inline footnotes.
|
Docmost-flavoured markdown is parsed, including `^[...]` inline footnotes.
|
||||||
- **`docmost_transform`** — The agent-native editing interface: instead of retyping a
|
- **`docmostTransform`** — The agent-native editing interface: instead of retyping a
|
||||||
document, the agent **writes a function that fixes it**. Edit a page by running an
|
document, the agent **writes a function that fixes it**. Edit a page by running an
|
||||||
arbitrary **`(doc, ctx) => doc` JavaScript transform** against its *live* ProseMirror
|
arbitrary **`(doc, ctx) => doc` JavaScript transform** against its *live* ProseMirror
|
||||||
document. Runs **sandboxed**
|
document. Runs **sandboxed**
|
||||||
@@ -172,42 +174,46 @@ All 41 tools, grouped by what you'd reach for them.
|
|||||||
|
|
||||||
### Tables
|
### Tables
|
||||||
|
|
||||||
- **`table_get`** — Read a table as a matrix: `{rows, cols, cells (text[][]), cellIds}`
|
- **`tableGet`** — Read a table as a matrix: `{rows, cols, cells (text[][]), cellIds}`
|
||||||
(a paragraph id per cell, or `null`). Address the table by `#<index>` (from
|
(a paragraph id per cell, or `null`). Address the table by `#<index>` (from
|
||||||
`get_outline`) or any block id inside it. Use `cellIds` with `patch_node` for
|
`getOutline`) or any block id inside it. Use `cellIds` with `patchNode` for
|
||||||
rich-formatted cell edits.
|
rich-formatted cell edits.
|
||||||
- **`table_insert_row`** — Insert a row of plain-text cells, padded to the table's column
|
- **`tableInsertRow`** — Insert a row of plain-text cells, padded to the table's column
|
||||||
count (passing more cells than columns is an error). `index` is the 0-based insert
|
count (passing more cells than columns is an error). `index` is the 0-based insert
|
||||||
position (0 inserts before the header); omit it to append at the end.
|
position (0 inserts before the header); omit it to append at the end.
|
||||||
- **`table_delete_row`** — Delete the row at a 0-based `index`. Refuses to delete a table's
|
- **`tableDeleteRow`** — Delete the row at a 0-based `index`. Refuses to delete a table's
|
||||||
only row; deleting row 0 promotes the next row to header.
|
only row; deleting row 0 promotes the next row to header.
|
||||||
- **`table_update_cell`** — Set the plain-text content of cell `[row, col]` (0-based). For
|
- **`tableUpdateCell`** — Set the plain-text content of cell `[row, col]` (0-based). For
|
||||||
rich formatting, `patch_node` the cell's paragraph id from `table_get`.
|
rich formatting, `patchNode` the cell's paragraph id from `tableGet`.
|
||||||
|
|
||||||
### Markdown round-trip
|
### Markdown round-trip
|
||||||
|
|
||||||
- **`export_page_markdown`** — Export a page to a single self-contained, **lossless
|
- **`exportPageMarkdown`** — Export a page to a single self-contained
|
||||||
Docmost-flavoured Markdown** file: a meta header, the body with inline comment anchors
|
**Docmost-flavoured Markdown** file: a meta header, the body with inline comment anchors
|
||||||
and diagrams, and a trailing comments-thread block. To replace a page's body from plain
|
and diagrams, and a trailing comments-thread block. The download → edit → import
|
||||||
authoring Markdown, use `update_page_markdown`.
|
round-trip regenerates block ids and **silently drops** the no-Markdown-representation
|
||||||
|
attr set (table merge spans/colwidth/background, indent, `callout.icon`,
|
||||||
|
`orderedList.type`, link `internal`/`target`/`rel`/`class`); keep those in ProseMirror
|
||||||
|
JSON if they must survive. To replace a page's body from plain authoring Markdown, use
|
||||||
|
`updatePageMarkdown`.
|
||||||
|
|
||||||
> **Removed in this release:** `import_page_markdown` (the round-trip parser for an
|
> **Removed in this release:** `importPageMarkdown` (the round-trip parser for an
|
||||||
> exported Docmost-Markdown file) is **no longer exposed on the external MCP surface**.
|
> exported Docmost-Markdown file) is **no longer exposed on the external MCP surface**.
|
||||||
> To replace a page's body from Markdown, use **`update_page_markdown`** (plain Markdown
|
> To replace a page's body from Markdown, use **`updatePageMarkdown`** (plain Markdown
|
||||||
> body replace). See the CHANGELOG for the migration note.
|
> body replace). See the CHANGELOG for the migration note.
|
||||||
|
|
||||||
### Images
|
### Images
|
||||||
|
|
||||||
- **`insert_image`** — Download an image from a web (http/https) URL and insert it in one
|
- **`insertImage`** — Download an image from a web (http/https) URL and insert it in one
|
||||||
step: append it, drop it in place of a text placeholder (`replaceText`), or put it after
|
step: append it, drop it in place of a text placeholder (`replaceText`), or put it after
|
||||||
a given block (`afterText`). Preserves all other block ids.
|
a given block (`afterText`). Preserves all other block ids.
|
||||||
- **`replace_image`** — Swap an existing image for one fetched from a web (http/https) URL.
|
- **`replaceImage`** — Swap an existing image for one fetched from a web (http/https) URL.
|
||||||
Uploads the new file as a **fresh
|
Uploads the new file as a **fresh
|
||||||
attachment** (clean URL that renders and busts browser caches), then re-points every
|
attachment** (clean URL that renders and busts browser caches), then re-points every
|
||||||
node referencing the old attachment (recursively, including callouts/tables) via the
|
node referencing the old attachment (recursively, including callouts/tables) via the
|
||||||
live document, preserving comments, alignment and alt text. (In-place overwrite is
|
live document, preserving comments, alignment and alt text. (In-place overwrite is
|
||||||
deliberately avoided — some Docmost versions corrupt the attachment on overwrite.)
|
deliberately avoided — some Docmost versions corrupt the attachment on overwrite.)
|
||||||
- **`stash_page`** — Serialize a whole page (its full ProseMirror JSON) into an ephemeral
|
- **`stashPage`** — Serialize a whole page (its full ProseMirror JSON) into an ephemeral
|
||||||
in-RAM blob and return ONLY a short anonymous URL — the body never enters the model
|
in-RAM blob and return ONLY a short anonymous URL — the body never enters the model
|
||||||
context, so it is the way to hand a large page (and its images) to an external consumer
|
context, so it is the way to hand a large page (and its images) to an external consumer
|
||||||
without truncation. Every internal file/image attachment is mirrored into the same
|
without truncation. Every internal file/image attachment is mirrored into the same
|
||||||
@@ -218,35 +224,35 @@ All 41 tools, grouped by what you'd reach for them.
|
|||||||
|
|
||||||
### Comments
|
### Comments
|
||||||
|
|
||||||
- **`create_comment`** — Add a page comment, optionally **anchored inline** to an exact
|
- **`createComment`** — Add a page comment, optionally **anchored inline** to an exact
|
||||||
span of text (the first occurrence is wrapped in a comment mark).
|
span of text (the first occurrence is wrapped in a comment mark).
|
||||||
- **`list_comments`** — List a page's comments (content returned as Markdown).
|
- **`listComments`** — List a page's comments (content returned as Markdown).
|
||||||
- **`update_comment`** — Edit an existing comment.
|
- **`updateComment`** — Edit an existing comment.
|
||||||
- **`delete_comment`** — Delete a comment.
|
- **`deleteComment`** — Delete a comment.
|
||||||
- **`resolve_comment`** — Resolve (close) or reopen a comment thread (reversible). Only top-level
|
- **`resolveComment`** — Resolve (close) or reopen a comment thread (reversible). Only top-level
|
||||||
comments can be resolved; the thread and its replies are kept, unlike `delete_comment`.
|
comments can be resolved; the thread and its replies are kept, unlike `deleteComment`.
|
||||||
- **`check_new_comments`** — Find comments created after a given ISO-8601 timestamp across
|
- **`checkNewComments`** — Find comments created after a given ISO-8601 timestamp across
|
||||||
a space, optionally scoped to a page subtree — ideal for an agent that watches a doc for
|
a space, optionally scoped to a page subtree — ideal for an agent that watches a doc for
|
||||||
feedback.
|
feedback.
|
||||||
|
|
||||||
### Versioning & history
|
### Versioning & history
|
||||||
|
|
||||||
- **`list_page_history`** — A page's saved versions (Docmost auto-snapshots on save),
|
- **`listPageHistory`** — A page's saved versions (Docmost auto-snapshots on save),
|
||||||
newest first, cursor-paginated. Each item's id is the `historyId`.
|
newest first, cursor-paginated. Each item's id is the `historyId`.
|
||||||
- **`diff_page_versions`** — Diff two versions (or a version against the live page).
|
- **`diffPageVersions`** — Diff two versions (or a version against the live page).
|
||||||
Returns inserted/deleted text, integrity counts (images, links, tables, callouts,
|
Returns inserted/deleted text, integrity counts (images, links, tables, callouts,
|
||||||
footnote markers), and a human-readable Markdown summary — computed with the same
|
footnote markers), and a human-readable Markdown summary — computed with the same
|
||||||
pipeline Docmost's own history viewer uses.
|
pipeline Docmost's own history viewer uses.
|
||||||
- **`restore_page_version`** — Write a saved version back as the current content. Docmost
|
- **`restorePageVersion`** — Write a saved version back as the current content. Docmost
|
||||||
has no restore endpoint, so this creates a **new** snapshot — the restore is itself
|
has no restore endpoint, so this creates a **new** snapshot — the restore is itself
|
||||||
revertible.
|
revertible.
|
||||||
|
|
||||||
### Sharing
|
### Sharing
|
||||||
|
|
||||||
- **`share_page`** — Make a page publicly accessible (idempotent) and return its public
|
- **`sharePage`** — Make a page publicly accessible (idempotent) and return its public
|
||||||
URL (`<app>/share/<key>/p/<slugId>`); optional search-engine indexing.
|
URL (`<app>/share/<key>/p/<slugId>`); optional search-engine indexing.
|
||||||
- **`unshare_page`** — Revoke a page's public share.
|
- **`unsharePage`** — Revoke a page's public share.
|
||||||
- **`list_shares`** — All public shares in the workspace, with titles and public URLs.
|
- **`listShares`** — All public shares in the workspace, with titles and public URLs.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -255,27 +261,27 @@ All 41 tools, grouped by what you'd reach for them.
|
|||||||
This same guidance is also delivered at runtime via the MCP server `instructions` field,
|
This same guidance is also delivered at runtime via the MCP server `instructions` field,
|
||||||
so capable clients steer the model automatically.
|
so capable clients steer the model automatically.
|
||||||
|
|
||||||
- **Text fixes** (wording, typos, numbers): `edit_page_text`.
|
- **Text fixes** (wording, typos, numbers): `editPageText`.
|
||||||
- **One block** (paragraph/heading/callout/table cell): `patch_node` / `insert_node` /
|
- **One block** (paragraph/heading/callout/table cell): `patchNode` / `insertNode` /
|
||||||
`delete_node`, addressing the node by its `attrs.id` from `get_page_json`.
|
`deleteNode`, addressing the node by its `attrs.id` from `getPageJson`.
|
||||||
- **Images**: `insert_image` / `replace_image`.
|
- **Images**: `insertImage` / `replaceImage`.
|
||||||
- **A new page**: `create_page`.
|
- **A new page**: `createPage`.
|
||||||
- **Bulk rewrite, or nodes without ids**: `update_page_json` (ProseMirror) or
|
- **Bulk rewrite, or nodes without ids**: `updatePageJson` (ProseMirror) or
|
||||||
`update_page_markdown` (plain Markdown body replace).
|
`updatePageMarkdown` (plain Markdown body replace).
|
||||||
- **Multi-step / scripted rewrite** (renumbering, footnotes, coordinated edits):
|
- **Multi-step / scripted rewrite** (renumbering, footnotes, coordinated edits):
|
||||||
`docmost_transform` — preview with `dryRun`, then apply.
|
`docmostTransform` — preview with `dryRun`, then apply.
|
||||||
- **Copy a whole page's content from another page** (server-side): `copy_page_content`.
|
- **Copy a whole page's content from another page** (server-side): `copyPageContent`.
|
||||||
- **Rename a page** (title only): `rename_page`.
|
- **Rename a page** (title only): `renamePage`.
|
||||||
- **Reads**: `get_page` (Markdown) / `get_page_json` (lossless ProseMirror with ids).
|
- **Reads**: `getPage` (Markdown) / `getPageJson` (lossless ProseMirror with ids).
|
||||||
- **Review changes**: `list_page_history` → `diff_page_versions` → `restore_page_version`.
|
- **Review changes**: `listPageHistory` → `diffPageVersions` → `restorePageVersion`.
|
||||||
- **Comments**: `create_comment` (with optional inline anchoring) / `list_comments` /
|
- **Comments**: `createComment` (with optional inline anchoring) / `listComments` /
|
||||||
`update_comment` / `resolve_comment` / `delete_comment` / `check_new_comments`.
|
`updateComment` / `resolveComment` / `deleteComment` / `checkNewComments`.
|
||||||
- **Navigate a page cheaply** (find a section/table, grab a block id): `get_outline` →
|
- **Navigate a page cheaply** (find a section/table, grab a block id): `getOutline` →
|
||||||
`get_node`.
|
`getNode`.
|
||||||
- **Tables** (add/remove a row, set a cell): `table_get` / `table_insert_row` /
|
- **Tables** (add/remove a row, set a cell): `tableGet` / `tableInsertRow` /
|
||||||
`table_delete_row` / `table_update_cell`.
|
`tableDeleteRow` / `tableUpdateCell`.
|
||||||
- **Export a page as self-contained Markdown** (with comment anchors): `export_page_markdown`.
|
- **Export a page as self-contained Markdown** (with comment anchors): `exportPageMarkdown`.
|
||||||
- **Replace a page's body from Markdown**: `update_page_markdown`.
|
- **Replace a page's body from Markdown**: `updatePageMarkdown`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -293,19 +299,20 @@ so capable clients steer the model automatically.
|
|||||||
refreshed automatically on the first 401/403 (covering JSON, multipart upload, and the
|
refreshed automatically on the first 401/403 (covering JSON, multipart upload, and the
|
||||||
collaboration-token path), with in-flight login de-duplication so a burst of calls
|
collaboration-token path), with in-flight login de-duplication so a burst of calls
|
||||||
triggers a single re-login.
|
triggers a single re-login.
|
||||||
- **Lossless and lossy reads.** `get_page_json` returns the exact ProseMirror tree with
|
- **Precise reads.** `getPageJson` returns the exact ProseMirror tree with block ids;
|
||||||
block ids; `get_page` returns clean Markdown for convenience.
|
`getPage` returns canonical Markdown that drops only a fixed, documented attr set.
|
||||||
- **Full Docmost schema.** Markdown↔ProseMirror conversion supports callouts (including
|
- **Full Docmost schema.** Markdown↔ProseMirror conversion supports callouts (including
|
||||||
nested), task lists (bullet *and* numbered checklists), tables, math blocks, embeds,
|
nested), task lists (bullet *and* numbered checklists), tables, math blocks, embeds,
|
||||||
highlights, sub/superscript and more, with defensive caps against pathological input.
|
highlights, sub/superscript and more, with defensive caps against pathological input.
|
||||||
- **Structured tables & lossless Markdown round-trip.** Tables can be edited as a matrix
|
- **Structured tables & Markdown round-trip.** Tables can be edited as a matrix
|
||||||
(read, insert/delete rows, set cells by `[row,col]`) without resending the document, and
|
(read, insert/delete rows, set cells by `[row,col]`) without resending the document, and
|
||||||
a page can be exported to and re-imported from a self-contained Docmost-flavoured
|
a page can be exported to and re-imported from a self-contained Docmost-flavoured
|
||||||
Markdown file that preserves inline comment anchors and diagrams.
|
Markdown file that preserves inline comment anchors and diagrams (block ids regenerate
|
||||||
|
and a fixed no-Markdown-representation attr set is dropped — see `exportPageMarkdown`).
|
||||||
- **Token-optimized responses.** API responses are filtered down to the fields agents
|
- **Token-optimized responses.** API responses are filtered down to the fields agents
|
||||||
actually need, and large collections (spaces, pages, comments, history) are paginated.
|
actually need, and large collections (spaces, pages, comments, history) are paginated.
|
||||||
- **Hardened runtime.** Global handlers keep a stray socket error from tearing down the
|
- **Hardened runtime.** Global handlers keep a stray socket error from tearing down the
|
||||||
stdio server; `move_page` requires a positively confirmed success; the diff engine
|
stdio server; `movePage` requires a positively confirmed success; the diff engine
|
||||||
falls back to a coarse block diff rather than hard-failing on a pathological document.
|
falls back to a coarse block diff rather than hard-failing on a pathological document.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -363,7 +370,7 @@ npm run test:e2e
|
|||||||
|
|
||||||
This project began as a fork of [MrMartiniMo/docmost-mcp](https://github.com/MrMartiniMo/docmost-mcp)
|
This project began as a fork of [MrMartiniMo/docmost-mcp](https://github.com/MrMartiniMo/docmost-mcp)
|
||||||
(by Moritz Krause) and extends it substantially — adding per-block node editing,
|
(by Moritz Krause) and extends it substantially — adding per-block node editing,
|
||||||
surgical text edits, the sandboxed `docmost_transform`, version history / diff / restore,
|
surgical text edits, the sandboxed `docmostTransform`, version history / diff / restore,
|
||||||
comments, image insert/replace, public sharing, server-side page copy, dual
|
comments, image insert/replace, public sharing, server-side page copy, dual
|
||||||
JSON/Markdown reads, transparent re-authentication and significant hardening. The comment
|
JSON/Markdown reads, transparent re-authentication and significant hardening. The comment
|
||||||
tools were ported from upstream PR #3 by Max Nikitin. Thanks to both.
|
tools were ported from upstream PR #3 by Max Nikitin. Thanks to both.
|
||||||
|
|||||||
+97
-87
@@ -12,7 +12,7 @@
|
|||||||
> небольшую функцию, которая чинит текст*, чем перечитывать и заново выдавать весь
|
> небольшую функцию, которая чинит текст*, чем перечитывать и заново выдавать весь
|
||||||
> документ. Поэтому сервер построен вокруг того, как модели на самом деле удобно
|
> документ. Поэтому сервер построен вокруг того, как модели на самом деле удобно
|
||||||
> редактировать: адресовать блок по id, сделать find/replace или передать трансформ
|
> редактировать: адресовать блок по id, сделать find/replace или передать трансформ
|
||||||
> `(doc, ctx) => doc` и позволить модели *запрограммировать* правку. `docmost_transform` —
|
> `(doc, ctx) => doc` и позволить модели *запрограммировать* правку. `docmostTransform` —
|
||||||
> это и есть такой интерфейс. Другие Docmost-MCP «заточены под человека» — они дают
|
> это и есть такой интерфейс. Другие Docmost-MCP «заточены под человека» — они дают
|
||||||
> «открыть страницу» и «заменить страницу»; этот даёт примитивы редактирования, в которых
|
> «открыть страницу» и «заменить страницу»; этот даёт примитивы редактирования, в которых
|
||||||
> модель сильна.
|
> модель сильна.
|
||||||
@@ -43,7 +43,7 @@ Docmost-MCP не сочетают:
|
|||||||
| **Нужна enterprise-лицензия** | **Нет** | **Да** | Нет | Нет | Нет |
|
| **Нужна enterprise-лицензия** | **Нет** | **Да** | Нет | Нет | Нет |
|
||||||
| Аутентификация | email + пароль, **авто-переавторизация** | API-ключ | email + пароль | cookie `authToken` (копировать из DevTools) | API Docmost / **напрямую PostgreSQL** |
|
| Аутентификация | email + пароль, **авто-переавторизация** | API-ключ | email + пароль | cookie `authToken` (копировать из DevTools) | API Docmost / **напрямую PostgreSQL** |
|
||||||
| Чтение страницы как Markdown | ✅ | ✅ | ✅ | ✅ | ✅ (только чтение) |
|
| Чтение страницы как Markdown | ✅ | ✅ | ✅ | ✅ | ✅ (только чтение) |
|
||||||
| **Lossless Markdown round-trip** (экспорт/импорт, сохраняет якоря комментариев) | ✅ | — | — | — | — |
|
| **Markdown round-trip** (экспорт/импорт, сохраняет якоря комментариев) | ✅ | — | — | — | — |
|
||||||
| Чтение **lossless ProseMirror JSON** (с id блоков) | ✅ | — | — | — | — |
|
| Чтение **lossless ProseMirror JSON** (с id блоков) | ✅ | — | — | — | — |
|
||||||
| **Компактная структура страницы** (дешёвый поиск id блока) | ✅ | — | — | — | — |
|
| **Компактная структура страницы** (дешёвый поиск id блока) | ✅ | — | — | — | — |
|
||||||
| **Получение одного блока** (по id или индексу) | ✅ | — | — | — | — |
|
| **Получение одного блока** (по id или индексу) | ✅ | — | — | — | — |
|
||||||
@@ -71,9 +71,9 @@ Docmost-MCP не сочетают:
|
|||||||
- **Экономия токенов при редактировании.** Большинство Docmost-MCP (и официальный)
|
- **Экономия токенов при редактировании.** Большинство Docmost-MCP (и официальный)
|
||||||
предлагают только запись «заменить всю страницу» — агент вынужден скачать весь документ,
|
предлагают только запись «заменить всю страницу» — агент вынужден скачать весь документ,
|
||||||
изменить и загрузить обратно, оплачивая весь документ **дважды** на каждой мелкой
|
изменить и загрузить обратно, оплачивая весь документ **дважды** на каждой мелкой
|
||||||
правке. Этот сервер позволяет агенту изменить ровно один блок (`patch_node` /
|
правке. Этот сервер позволяет агенту изменить ровно один блок (`patchNode` /
|
||||||
`insert_node` / `delete_node`), сделать find/replace с сохранением структуры
|
`insertNode` / `deleteNode`), сделать find/replace с сохранением структуры
|
||||||
(`edit_page_text`) или скопировать страницу на стороне сервера (`copy_page_content`) —
|
(`editPageText`) или скопировать страницу на стороне сервера (`copyPageContent`) —
|
||||||
**причём документ ни разу не проходит через модель**.
|
**причём документ ни разу не проходит через модель**.
|
||||||
|
|
||||||
- **Записи, которые не воюют с редактором.** Наивная запись через REST конфликтует с тем,
|
- **Записи, которые не воюют с редактором.** Наивная запись через REST конфликтует с тем,
|
||||||
@@ -87,12 +87,12 @@ Docmost-MCP не сочетают:
|
|||||||
- **Агентоориентированная модель редактирования.** Серверы «под человека» дают «открыть
|
- **Агентоориентированная модель редактирования.** Серверы «под человека» дают «открыть
|
||||||
страницу» и «заменить страницу», потому что это отражает то, как работает человек. Модель
|
страницу» и «заменить страницу», потому что это отражает то, как работает человек. Модель
|
||||||
редактирует лучше, *программируя* правку — адресуя блоки по id, делая find/replace или
|
редактирует лучше, *программируя* правку — адресуя блоки по id, делая find/replace или
|
||||||
передавая трансформ `(doc, ctx) => doc` (`docmost_transform`, с dry-run диффом перед
|
передавая трансформ `(doc, ctx) => doc` (`docmostTransform`, с dry-run диффом перед
|
||||||
коммитом). Этот сервер построен вокруг этого — поэтому у него есть примитивы
|
коммитом). Этот сервер построен вокруг этого — поэтому у него есть примитивы
|
||||||
редактирования, которых у остальных просто нет.
|
редактирования, которых у остальных просто нет.
|
||||||
|
|
||||||
- **Страховка при редактировании, которой нет у других.** `list_page_history` →
|
- **Страховка при редактировании, которой нет у других.** `listPageHistory` →
|
||||||
`diff_page_versions` → `restore_page_version` дают агенту (и вам) полный цикл «посмотреть
|
`diffPageVersions` → `restorePageVersion` дают агенту (и вам) полный цикл «посмотреть
|
||||||
и откатить». Дифф использует *тот же* конвейер `recreateTransform → ChangeSet →
|
и откатить». Дифф использует *тот же* конвейер `recreateTransform → ChangeSet →
|
||||||
simplifyChanges`, что и встроенный просмотр истории Docmost, так что результат совпадает
|
simplifyChanges`, что и встроенный просмотр истории Docmost, так что результат совпадает
|
||||||
с продуктом.
|
с продуктом.
|
||||||
@@ -113,59 +113,62 @@ Docmost-MCP не сочетают:
|
|||||||
|
|
||||||
### Чтение и поиск
|
### Чтение и поиск
|
||||||
|
|
||||||
- **`get_workspace`** — Информация о текущем воркспейсе Docmost.
|
- **`getWorkspace`** — Информация о текущем воркспейсе Docmost.
|
||||||
- **`list_spaces`** — Все пространства воркспейса.
|
- **`listSpaces`** — Все пространства воркспейса.
|
||||||
- **`list_pages`** — Недавние страницы пространства, по убыванию `updatedAt` (по умолчанию
|
- **`listPages`** — Недавние страницы пространства, по убыванию `updatedAt` (по умолчанию
|
||||||
50, максимум 100). Для поиска в больших пространствах используйте `search`.
|
50, максимум 100). Для поиска в больших пространствах используйте `search`.
|
||||||
- **`search`** — Полнотекстовый поиск по страницам и контенту (ограничен `limit`, максимум
|
- **`search`** — Полнотекстовый поиск по страницам и контенту (ограничен `limit`, максимум
|
||||||
100).
|
100).
|
||||||
- **`get_page`** — Контент страницы как чистый **Markdown** (удобно, но это
|
- **`getPage`** — Контент страницы как чистый **Markdown** (канонично для текста; теряет
|
||||||
*lossy*-представление — id блоков и точная структура таблиц/коллаутов аппроксимируются).
|
лишь id блоков, якоря разрешённых комментариев и фиксированный набор атрибутов без
|
||||||
- **`get_page_json`** — **Lossless ProseMirror/TipTap JSON** страницы, включая `attrs.id`
|
markdown-представления — спаны/colwidth/фон ячеек таблиц, отступы (indent),
|
||||||
|
`callout.icon`, `orderedList.type` и `internal`/`target`/`rel`/`class` у ссылок;
|
||||||
|
используйте `getPageJson`, когда они нужны).
|
||||||
|
- **`getPageJson`** — **Lossless ProseMirror/TipTap JSON** страницы, включая `attrs.id`
|
||||||
каждого блока и `slugId`, используемый в URL. Именно его потребляют инструменты
|
каждого блока и `slugId`, используемый в URL. Именно его потребляют инструменты
|
||||||
поблочного редактирования.
|
поблочного редактирования.
|
||||||
- **`get_outline`** — Компактная структура страницы из блоков верхнего уровня (`{index,
|
- **`getOutline`** — Компактная структура страницы из блоков верхнего уровня (`{index,
|
||||||
type, id, level, firstText}`; для таблиц добавляются число строк/столбцов и тексты ячеек
|
type, id, level, firstText}`; для таблиц добавляются число строк/столбцов и тексты ячеек
|
||||||
заголовка, для списков — число пунктов) **без** тела документа. Дешёвый способ найти раздел или таблицу и получить
|
заголовка, для списков — число пунктов) **без** тела документа. Дешёвый способ найти раздел или таблицу и получить
|
||||||
id блока перед `get_node` / `patch_node` / `insert_node`.
|
id блока перед `getNode` / `patchNode` / `insertNode`.
|
||||||
- **`get_node`** — Получить полное ProseMirror-поддерево одного блока (lossless), не
|
- **`getNode`** — Получить полное ProseMirror-поддерево одного блока (lossless), не
|
||||||
вытягивая всю страницу. Адресуйте его по id блока (из `get_outline` / `get_page_json`)
|
вытягивая всю страницу. Адресуйте его по id блока (из `getOutline` / `getPageJson`)
|
||||||
или формой `#<index>` для блока верхнего уровня — используйте `#<index>` для
|
или формой `#<index>` для блока верхнего уровня — используйте `#<index>` для
|
||||||
таблиц/строк/ячеек, у которых нет id.
|
таблиц/строк/ячеек, у которых нет id.
|
||||||
|
|
||||||
### Жизненный цикл страниц
|
### Жизненный цикл страниц
|
||||||
|
|
||||||
- **`create_page`** — Создать страницу из Markdown и поместить в иерархию (опционально
|
- **`createPage`** — Создать страницу из Markdown и поместить в иерархию (опционально
|
||||||
`parentPageId`) одним вызовом. Использует import API Docmost для чистой конвертации
|
`parentPageId`) одним вызовом. Использует import API Docmost для чистой конвертации
|
||||||
Markdown→ProseMirror.
|
Markdown→ProseMirror.
|
||||||
- **`rename_page`** — Изменить только заголовок страницы, не трогая и не пересылая контент.
|
- **`renamePage`** — Изменить только заголовок страницы, не трогая и не пересылая контент.
|
||||||
- **`move_page`** — Сменить родителя страницы (вложить или вынести в корень); поддерживает
|
- **`movePage`** — Сменить родителя страницы (вложить или вынести в корень); поддерживает
|
||||||
позиционирование по fractional-index. Возвращает успех только при *положительно
|
позиционирование по fractional-index. Возвращает успех только при *положительно
|
||||||
подтверждённом* результате.
|
подтверждённом* результате.
|
||||||
- **`delete_page`** — Удалить одну страницу.
|
- **`deletePage`** — Удалить одну страницу.
|
||||||
- **`copy_page_content`** — Заменить тело одной страницы копией тела другой, **полностью на
|
- **`copyPageContent`** — Заменить тело одной страницы копией тела другой, **полностью на
|
||||||
стороне сервера** — документ не проходит через модель. У целевой страницы сохраняются
|
стороне сервера** — документ не проходит через модель. У целевой страницы сохраняются
|
||||||
собственные заголовок и slug (URL не меняется).
|
собственные заголовок и slug (URL не меняется).
|
||||||
|
|
||||||
### Редактирование
|
### Редактирование
|
||||||
|
|
||||||
- **`edit_page_text`** — Хирургический find/replace внутри текста страницы. Сохраняет
|
- **`editPageText`** — Хирургический find/replace внутри текста страницы. Сохраняет
|
||||||
**всю** структуру: id блоков, marks, ссылки, коллауты, таблицы. Предпочтительный
|
**всю** структуру: id блоков, marks, ссылки, коллауты, таблицы. Предпочтительный
|
||||||
инструмент для правки формулировок, опечаток, чисел и имён.
|
инструмент для правки формулировок, опечаток, чисел и имён.
|
||||||
- **`patch_node`** — Заменить один блок, адресованный по `attrs.id` (из `get_page_json`),
|
- **`patchNode`** — Заменить один блок, адресованный по `attrs.id` (из `getPageJson`),
|
||||||
без пересылки документа.
|
без пересылки документа.
|
||||||
- **`insert_node`** — Вставить блок до/после другого (по `attrs.id` или по якорному тексту)
|
- **`insertNode`** — Вставить блок до/после другого (по `attrs.id` или по якорному тексту)
|
||||||
либо добавить в конец.
|
либо добавить в конец.
|
||||||
- **`delete_node`** — Удалить один блок по его `attrs.id`.
|
- **`deleteNode`** — Удалить один блок по его `attrs.id`.
|
||||||
- **`update_page_json`** — Заменить весь контент страницы документом ProseMirror (массовые
|
- **`updatePageJson`** — Заменить весь контент страницы документом ProseMirror (массовые
|
||||||
перезаписи или когда у узлов нет id). `content` опционален — опустите его, чтобы изменить
|
перезаписи или когда у узлов нет id). `content` опционален — опустите его, чтобы изменить
|
||||||
только заголовок. Сохраняет переданные id блоков, поэтому якоря заголовков и история
|
только заголовок. Сохраняет переданные id блоков, поэтому якоря заголовков и история
|
||||||
остаются стабильными.
|
остаются стабильными.
|
||||||
- **`update_page_markdown`** — Заменить тело страницы (и опционально заголовок) новым
|
- **`updatePageMarkdown`** — Заменить тело страницы (и опционально заголовок) новым
|
||||||
**обычным Markdown**. Всё тело переимпортируется (id блоков перегенерируются — для
|
**обычным Markdown**. Всё тело переимпортируется (id блоков перегенерируются — для
|
||||||
хирургических правок или сохранения id используйте `edit_page_text` / `patch_node` /
|
хирургических правок или сохранения id используйте `editPageText` / `patchNode` /
|
||||||
`update_page_json`). Markdown в диалекте Docmost разбирается, включая inline-сноски `^[...]`.
|
`updatePageJson`). Markdown в диалекте Docmost разбирается, включая inline-сноски `^[...]`.
|
||||||
- **`docmost_transform`** — Агентоориентированный интерфейс редактирования: вместо
|
- **`docmostTransform`** — Агентоориентированный интерфейс редактирования: вместо
|
||||||
перепечатывания документа агент **пишет функцию, которая его чинит**. Редактирует
|
перепечатывания документа агент **пишет функцию, которая его чинит**. Редактирует
|
||||||
страницу, запуская произвольный **JS-трансформ `(doc, ctx) => doc`** на её *живом*
|
страницу, запуская произвольный **JS-трансформ `(doc, ctx) => doc`** на её *живом*
|
||||||
документе ProseMirror. Работает в **песочнице** (без `require`/`process`/`fs`/сети,
|
документе ProseMirror. Работает в **песочнице** (без `require`/`process`/`fs`/сети,
|
||||||
@@ -177,42 +180,46 @@ Docmost-MCP не сочетают:
|
|||||||
|
|
||||||
### Таблицы
|
### Таблицы
|
||||||
|
|
||||||
- **`table_get`** — Прочитать таблицу как матрицу: `{rows, cols, cells (text[][]),
|
- **`tableGet`** — Прочитать таблицу как матрицу: `{rows, cols, cells (text[][]),
|
||||||
cellIds}` (id абзаца на ячейку или `null`). Адресуйте таблицу через `#<index>` (из
|
cellIds}` (id абзаца на ячейку или `null`). Адресуйте таблицу через `#<index>` (из
|
||||||
`get_outline`) или любой id блока внутри неё. Используйте `cellIds` вместе с `patch_node`
|
`getOutline`) или любой id блока внутри неё. Используйте `cellIds` вместе с `patchNode`
|
||||||
для правок ячеек с форматированием.
|
для правок ячеек с форматированием.
|
||||||
- **`table_insert_row`** — Вставить строку из текстовых ячеек, дополненную до числа
|
- **`tableInsertRow`** — Вставить строку из текстовых ячеек, дополненную до числа
|
||||||
столбцов таблицы (передать ячеек больше числа столбцов — ошибка). `index` — 0-based
|
столбцов таблицы (передать ячеек больше числа столбцов — ошибка). `index` — 0-based
|
||||||
позиция вставки (0 вставляет перед заголовком); опустите, чтобы добавить в конец.
|
позиция вставки (0 вставляет перед заголовком); опустите, чтобы добавить в конец.
|
||||||
- **`table_delete_row`** — Удалить строку по 0-based `index`. Отказывается удалять
|
- **`tableDeleteRow`** — Удалить строку по 0-based `index`. Отказывается удалять
|
||||||
единственную строку таблицы; удаление строки 0 делает заголовком следующую строку.
|
единственную строку таблицы; удаление строки 0 делает заголовком следующую строку.
|
||||||
- **`table_update_cell`** — Задать текстовое содержимое ячейки `[row, col]` (0-based). Для
|
- **`tableUpdateCell`** — Задать текстовое содержимое ячейки `[row, col]` (0-based). Для
|
||||||
форматирования используйте `patch_node` по id абзаца ячейки из `table_get`.
|
форматирования используйте `patchNode` по id абзаца ячейки из `tableGet`.
|
||||||
|
|
||||||
### Markdown: экспорт и импорт
|
### Markdown: экспорт и импорт
|
||||||
|
|
||||||
- **`export_page_markdown`** — Экспортировать страницу в один самодостаточный, **lossless
|
- **`exportPageMarkdown`** — Экспортировать страницу в один самодостаточный
|
||||||
Markdown в диалекте Docmost**: мета-заголовок, тело с inline-якорями комментариев и
|
**Markdown в диалекте Docmost**: мета-заголовок, тело с inline-якорями комментариев и
|
||||||
диаграммами и завершающий блок тредов комментариев. Чтобы заменить тело страницы из
|
диаграммами и завершающий блок тредов комментариев. Round-trip скачать → отредактировать →
|
||||||
обычного авторского Markdown, используйте `update_page_markdown`.
|
импортировать перегенерирует id блоков и **молча отбрасывает** набор атрибутов без
|
||||||
|
markdown-представления (спаны/colwidth/фон ячеек таблиц, отступы (indent), `callout.icon`,
|
||||||
|
`orderedList.type`, `internal`/`target`/`rel`/`class` у ссылок); держите их в ProseMirror
|
||||||
|
JSON, если они должны выжить. Чтобы заменить тело страницы из обычного авторского Markdown,
|
||||||
|
используйте `updatePageMarkdown`.
|
||||||
|
|
||||||
> **Удалено в этом релизе:** `import_page_markdown` (парсер round-trip для
|
> **Удалено в этом релизе:** `importPageMarkdown` (парсер round-trip для
|
||||||
> экспортированного Docmost-Markdown-файла) **больше не отдаётся на внешней MCP-поверхности**.
|
> экспортированного Docmost-Markdown-файла) **больше не отдаётся на внешней MCP-поверхности**.
|
||||||
> Чтобы заменить тело страницы из Markdown, используйте **`update_page_markdown`** (замена
|
> Чтобы заменить тело страницы из Markdown, используйте **`updatePageMarkdown`** (замена
|
||||||
> тела обычным Markdown). См. заметку о миграции в CHANGELOG.
|
> тела обычным Markdown). См. заметку о миграции в CHANGELOG.
|
||||||
|
|
||||||
### Изображения
|
### Изображения
|
||||||
|
|
||||||
- **`insert_image`** — Загрузить локальное изображение и вставить за один шаг: добавить в
|
- **`insertImage`** — Загрузить локальное изображение и вставить за один шаг: добавить в
|
||||||
конец, поставить вместо текстового плейсхолдера (`replaceText`) или после заданного блока
|
конец, поставить вместо текстового плейсхолдера (`replaceText`) или после заданного блока
|
||||||
(`afterText`). Сохраняет id всех остальных блоков.
|
(`afterText`). Сохраняет id всех остальных блоков.
|
||||||
- **`replace_image`** — Заменить существующее изображение. Загружает новый файл как **новое
|
- **`replaceImage`** — Заменить существующее изображение. Загружает новый файл как **новое
|
||||||
вложение** (чистый URL, который рендерится и сбрасывает кэш браузера), затем
|
вложение** (чистый URL, который рендерится и сбрасывает кэш браузера), затем
|
||||||
перенаправляет все узлы, ссылавшиеся на старое вложение (рекурсивно, включая
|
перенаправляет все узлы, ссылавшиеся на старое вложение (рекурсивно, включая
|
||||||
коллауты/таблицы), через живой документ, сохраняя комментарии, выравнивание и alt-текст.
|
коллауты/таблицы), через живой документ, сохраняя комментарии, выравнивание и alt-текст.
|
||||||
(Перезапись «по месту» намеренно не используется — некоторые версии Docmost портят
|
(Перезапись «по месту» намеренно не используется — некоторые версии Docmost портят
|
||||||
вложение при перезаписи.)
|
вложение при перезаписи.)
|
||||||
- **`stash_page`** — Сериализовать страницу целиком (её полный ProseMirror JSON) в
|
- **`stashPage`** — Сериализовать страницу целиком (её полный ProseMirror JSON) в
|
||||||
эфемерный blob в оперативной памяти и вернуть ТОЛЬКО короткий анонимный URL — тело
|
эфемерный blob в оперативной памяти и вернуть ТОЛЬКО короткий анонимный URL — тело
|
||||||
никогда не попадает в контекст модели, поэтому это способ передать большую страницу
|
никогда не попадает в контекст модели, поэтому это способ передать большую страницу
|
||||||
(вместе с её изображениями) внешнему потребителю без усечения. Каждое внутреннее
|
(вместе с её изображениями) внешнему потребителю без усечения. Каждое внутреннее
|
||||||
@@ -224,35 +231,35 @@ Docmost-MCP не сочетают:
|
|||||||
|
|
||||||
### Комментарии
|
### Комментарии
|
||||||
|
|
||||||
- **`create_comment`** — Добавить комментарий к странице, опционально **привязав inline** к
|
- **`createComment`** — Добавить комментарий к странице, опционально **привязав inline** к
|
||||||
точному фрагменту текста (первое вхождение оборачивается comment-маркой).
|
точному фрагменту текста (первое вхождение оборачивается comment-маркой).
|
||||||
- **`list_comments`** — Список комментариев страницы (контент возвращается как Markdown).
|
- **`listComments`** — Список комментариев страницы (контент возвращается как Markdown).
|
||||||
- **`update_comment`** — Изменить существующий комментарий.
|
- **`updateComment`** — Изменить существующий комментарий.
|
||||||
- **`delete_comment`** — Удалить комментарий.
|
- **`deleteComment`** — Удалить комментарий.
|
||||||
- **`resolve_comment`** — Закрыть (resolve) или переоткрыть тред комментария (обратимо). Resolve
|
- **`resolveComment`** — Закрыть (resolve) или переоткрыть тред комментария (обратимо). Resolve
|
||||||
доступен только для корневых комментариев; тред и ответы сохраняются, в отличие от `delete_comment`.
|
доступен только для корневых комментариев; тред и ответы сохраняются, в отличие от `deleteComment`.
|
||||||
- **`check_new_comments`** — Найти комментарии, созданные после заданной метки времени
|
- **`checkNewComments`** — Найти комментарии, созданные после заданной метки времени
|
||||||
ISO-8601, по пространству, опционально в рамках поддерева страниц — идеально для агента,
|
ISO-8601, по пространству, опционально в рамках поддерева страниц — идеально для агента,
|
||||||
который следит за обратной связью в документе.
|
который следит за обратной связью в документе.
|
||||||
|
|
||||||
### Версии и история
|
### Версии и история
|
||||||
|
|
||||||
- **`list_page_history`** — Сохранённые версии страницы (Docmost авто-снапшотит при каждом
|
- **`listPageHistory`** — Сохранённые версии страницы (Docmost авто-снапшотит при каждом
|
||||||
сохранении), новые сверху, курсорная пагинация. id каждого элемента — это `historyId`.
|
сохранении), новые сверху, курсорная пагинация. id каждого элемента — это `historyId`.
|
||||||
- **`diff_page_versions`** — Дифф двух версий (или версии против живой страницы).
|
- **`diffPageVersions`** — Дифф двух версий (или версии против живой страницы).
|
||||||
Возвращает вставленный/удалённый текст, счётчики целостности (изображения, ссылки,
|
Возвращает вставленный/удалённый текст, счётчики целостности (изображения, ссылки,
|
||||||
таблицы, коллауты, маркеры сносок) и человекочитаемую Markdown-сводку — посчитано тем же
|
таблицы, коллауты, маркеры сносок) и человекочитаемую Markdown-сводку — посчитано тем же
|
||||||
конвейером, что использует встроенный просмотр истории Docmost.
|
конвейером, что использует встроенный просмотр истории Docmost.
|
||||||
- **`restore_page_version`** — Записать сохранённую версию обратно как текущий контент. У
|
- **`restorePageVersion`** — Записать сохранённую версию обратно как текущий контент. У
|
||||||
Docmost нет эндпоинта восстановления, поэтому создаётся **новый** снапшот — само
|
Docmost нет эндпоинта восстановления, поэтому создаётся **новый** снапшот — само
|
||||||
восстановление тоже обратимо.
|
восстановление тоже обратимо.
|
||||||
|
|
||||||
### Публикация
|
### Публикация
|
||||||
|
|
||||||
- **`share_page`** — Сделать страницу публично доступной (идемпотентно) и вернуть её
|
- **`sharePage`** — Сделать страницу публично доступной (идемпотентно) и вернуть её
|
||||||
публичный URL (`<app>/share/<key>/p/<slugId>`); опционально индексирование поисковиками.
|
публичный URL (`<app>/share/<key>/p/<slugId>`); опционально индексирование поисковиками.
|
||||||
- **`unshare_page`** — Отозвать публичный доступ к странице.
|
- **`unsharePage`** — Отозвать публичный доступ к странице.
|
||||||
- **`list_shares`** — Все публичные ссылки воркспейса с заголовками и публичными URL.
|
- **`listShares`** — Все публичные ссылки воркспейса с заголовками и публичными URL.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -261,29 +268,29 @@ Docmost-MCP не сочетают:
|
|||||||
Та же подсказка отдаётся в рантайме через поле `instructions` MCP-сервера, так что
|
Та же подсказка отдаётся в рантайме через поле `instructions` MCP-сервера, так что
|
||||||
подходящие клиенты направляют модель автоматически.
|
подходящие клиенты направляют модель автоматически.
|
||||||
|
|
||||||
- **Правки текста** (формулировки, опечатки, числа): `edit_page_text`.
|
- **Правки текста** (формулировки, опечатки, числа): `editPageText`.
|
||||||
- **Один блок** (абзац/заголовок/коллаут/ячейка таблицы): `patch_node` / `insert_node` /
|
- **Один блок** (абзац/заголовок/коллаут/ячейка таблицы): `patchNode` / `insertNode` /
|
||||||
`delete_node`, адресуя узел по его `attrs.id` из `get_page_json`.
|
`deleteNode`, адресуя узел по его `attrs.id` из `getPageJson`.
|
||||||
- **Изображения**: `insert_image` / `replace_image`.
|
- **Изображения**: `insertImage` / `replaceImage`.
|
||||||
- **Новая страница**: `create_page`.
|
- **Новая страница**: `createPage`.
|
||||||
- **Массовая перезапись или узлы без id**: `update_page_json` (ProseMirror) или
|
- **Массовая перезапись или узлы без id**: `updatePageJson` (ProseMirror) или
|
||||||
`update_page_markdown` (замена тела обычным Markdown).
|
`updatePageMarkdown` (замена тела обычным Markdown).
|
||||||
- **Многошаговая / скриптовая перезапись** (перенумерация, сноски, согласованные правки):
|
- **Многошаговая / скриптовая перезапись** (перенумерация, сноски, согласованные правки):
|
||||||
`docmost_transform` — предпросмотр через `dryRun`, затем применение.
|
`docmostTransform` — предпросмотр через `dryRun`, затем применение.
|
||||||
- **Скопировать контент целой страницы из другой** (на стороне сервера):
|
- **Скопировать контент целой страницы из другой** (на стороне сервера):
|
||||||
`copy_page_content`.
|
`copyPageContent`.
|
||||||
- **Переименовать страницу** (только заголовок): `rename_page`.
|
- **Переименовать страницу** (только заголовок): `renamePage`.
|
||||||
- **Чтение**: `get_page` (Markdown) / `get_page_json` (lossless ProseMirror с id).
|
- **Чтение**: `getPage` (Markdown) / `getPageJson` (lossless ProseMirror с id).
|
||||||
- **Просмотр изменений**: `list_page_history` → `diff_page_versions` →
|
- **Просмотр изменений**: `listPageHistory` → `diffPageVersions` →
|
||||||
`restore_page_version`.
|
`restorePageVersion`.
|
||||||
- **Комментарии**: `create_comment` (с опциональной inline-привязкой) / `list_comments` /
|
- **Комментарии**: `createComment` (с опциональной inline-привязкой) / `listComments` /
|
||||||
`update_comment` / `resolve_comment` / `delete_comment` / `check_new_comments`.
|
`updateComment` / `resolveComment` / `deleteComment` / `checkNewComments`.
|
||||||
- **Дешёвая навигация по странице** (найти раздел/таблицу, получить id блока): `get_outline`
|
- **Дешёвая навигация по странице** (найти раздел/таблицу, получить id блока): `getOutline`
|
||||||
→ `get_node`.
|
→ `getNode`.
|
||||||
- **Таблицы** (добавить/удалить строку, задать ячейку): `table_get` / `table_insert_row` /
|
- **Таблицы** (добавить/удалить строку, задать ячейку): `tableGet` / `tableInsertRow` /
|
||||||
`table_delete_row` / `table_update_cell`.
|
`tableDeleteRow` / `tableUpdateCell`.
|
||||||
- **Экспорт страницы в самодостаточный Markdown** (с якорями комментариев): `export_page_markdown`.
|
- **Экспорт страницы в самодостаточный Markdown** (с якорями комментариев): `exportPageMarkdown`.
|
||||||
- **Заменить тело страницы из Markdown**: `update_page_markdown`.
|
- **Заменить тело страницы из Markdown**: `updatePageMarkdown`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -302,21 +309,24 @@ Docmost-MCP не сочетают:
|
|||||||
автоматически на первом 401/403 (покрывая JSON, multipart-загрузку и путь токена
|
автоматически на первом 401/403 (покрывая JSON, multipart-загрузку и путь токена
|
||||||
коллаборации), с дедупликацией параллельных логинов, так что пачка вызовов вызывает один
|
коллаборации), с дедупликацией параллельных логинов, так что пачка вызовов вызывает один
|
||||||
повторный логин.
|
повторный логин.
|
||||||
- **Lossless- и lossy-чтение.** `get_page_json` возвращает точное дерево ProseMirror с id
|
- **Точные чтения.** `getPageJson` возвращает точное дерево ProseMirror с id блоков;
|
||||||
блоков; `get_page` возвращает чистый Markdown для удобства.
|
`getPage` возвращает канонический Markdown, теряющий лишь фиксированный, документированный
|
||||||
|
набор атрибутов.
|
||||||
- **Полная схема Docmost.** Конвертация Markdown↔ProseMirror поддерживает коллауты
|
- **Полная схема Docmost.** Конвертация Markdown↔ProseMirror поддерживает коллауты
|
||||||
(включая вложенные), списки задач (маркированные *и* нумерованные чек-листы), таблицы,
|
(включая вложенные), списки задач (маркированные *и* нумерованные чек-листы), таблицы,
|
||||||
блоки формул, эмбеды, выделение, под/надстрочный текст и прочее, с защитными лимитами
|
блоки формул, эмбеды, выделение, под/надстрочный текст и прочее, с защитными лимитами
|
||||||
против патологического ввода.
|
против патологического ввода.
|
||||||
- **Структурные таблицы и lossless Markdown round-trip.** Таблицы можно редактировать как
|
- **Структурные таблицы и Markdown round-trip.** Таблицы можно редактировать как
|
||||||
матрицу (чтение, вставка/удаление строк, задание ячеек по `[row, col]`) без пересылки
|
матрицу (чтение, вставка/удаление строк, задание ячеек по `[row, col]`) без пересылки
|
||||||
документа, а страницу — экспортировать и заново импортировать как самодостаточный
|
документа, а страницу — экспортировать и заново импортировать как самодостаточный
|
||||||
Markdown-файл в диалекте Docmost, сохраняющий inline-якоря комментариев и диаграммы.
|
Markdown-файл в диалекте Docmost, сохраняющий inline-якоря комментариев и диаграммы
|
||||||
|
(id блоков перегенерируются, а фиксированный набор атрибутов без markdown-представления
|
||||||
|
отбрасывается — см. `exportPageMarkdown`).
|
||||||
- **Ответы, оптимизированные по токенам.** Ответы API урезаются до полей, действительно
|
- **Ответы, оптимизированные по токенам.** Ответы API урезаются до полей, действительно
|
||||||
нужных агентам, а большие коллекции (пространства, страницы, комментарии, история)
|
нужных агентам, а большие коллекции (пространства, страницы, комментарии, история)
|
||||||
пагинируются.
|
пагинируются.
|
||||||
- **Закалённый рантайм.** Глобальные обработчики не дают случайной ошибке сокета уронить
|
- **Закалённый рантайм.** Глобальные обработчики не дают случайной ошибке сокета уронить
|
||||||
stdio-сервер; `move_page` требует положительно подтверждённого успеха; движок диффа
|
stdio-сервер; `movePage` требует положительно подтверждённого успеха; движок диффа
|
||||||
откатывается к грубому поблочному диффу, а не падает на патологическом документе.
|
откатывается к грубому поблочному диффу, а не падает на патологическом документе.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -376,7 +386,7 @@ npm run test:e2e
|
|||||||
Проект начинался как форк
|
Проект начинался как форк
|
||||||
[MrMartiniMo/docmost-mcp](https://github.com/MrMartiniMo/docmost-mcp) (автор Moritz Krause)
|
[MrMartiniMo/docmost-mcp](https://github.com/MrMartiniMo/docmost-mcp) (автор Moritz Krause)
|
||||||
и существенно его расширяет — добавлены поблочное редактирование узлов, хирургические
|
и существенно его расширяет — добавлены поблочное редактирование узлов, хирургические
|
||||||
правки текста, песочница `docmost_transform`, история версий / дифф / восстановление,
|
правки текста, песочница `docmostTransform`, история версий / дифф / восстановление,
|
||||||
комментарии, вставка/замена изображений, публичные ссылки, серверное копирование страниц,
|
комментарии, вставка/замена изображений, публичные ссылки, серверное копирование страниц,
|
||||||
двойное чтение JSON/Markdown, прозрачная переавторизация и значительное упрочнение.
|
двойное чтение JSON/Markdown, прозрачная переавторизация и значительное упрочнение.
|
||||||
Инструменты комментариев портированы из upstream PR #3 от Max Nikitin. Спасибо обоим.
|
Инструменты комментариев портированы из upstream PR #3 от Max Nikitin. Спасибо обоим.
|
||||||
|
|||||||
+10
-10
@@ -22,14 +22,14 @@ are debounced server-side, so the script waits ~16 s before reading back via RES
|
|||||||
|
|
||||||
| # | Tool / path | What is checked | Expected |
|
| # | Tool / path | What is checked | Expected |
|
||||||
|---|-------------|-----------------|----------|
|
|---|-------------|-----------------|----------|
|
||||||
| 1 | `create_page` | title with spaces, slugId returned | page created, title intact |
|
| 1 | `createPage` | title with spaces, slugId returned | page created, title intact |
|
||||||
| 2 | `update_page` (markdown) | headings, **bold**/*italic*/~~strike~~/`code`/link, nested bullet + ordered lists, blockquote, code block, `:::callout:::`, table | all structures survive re-import |
|
| 2 | `update_page` (markdown) | headings, **bold**/*italic*/~~strike~~/`code`/link, nested bullet + ordered lists, blockquote, code block, `:::callout:::`, table | all structures survive re-import |
|
||||||
| 3 | `get_page_json` | lossless ProseMirror, block ids, callout/table nodes | present (note: reads the **debounced** REST snapshot — recent collab writes may lag a few seconds) |
|
| 3 | `getPageJson` | lossless ProseMirror, block ids, callout/table nodes | present (note: reads the **debounced** REST snapshot — recent collab writes may lag a few seconds) |
|
||||||
| 4 | `edit_page_text` | surgical replace; block ids + marks preserved; ambiguous match rejected; missing match reported | edits applied, ids stable, errors correct |
|
| 4 | `editPageText` | surgical replace; block ids + marks preserved; ambiguous match rejected; missing match reported | edits applied, ids stable, errors correct |
|
||||||
| 5 | `update_page_json` | full lossless write; custom block ids preserved; existing content (text edits, images, callout, table) not lost | round-trips intact |
|
| 5 | `updatePageJson` | full lossless write; custom block ids preserved; existing content (text edits, images, callout, table) not lost | round-trips intact |
|
||||||
| 6 | `upload_image` | uploads attachment, returns node | src is a **clean** `/api/files/<id>/<file>` URL, served `200 image/*` |
|
| 6 | `upload_image` | uploads attachment, returns node | src is a **clean** `/api/files/<id>/<file>` URL, served `200 image/*` |
|
||||||
| 7 | `insert_image` (append / `replaceText` / `afterText`) | three placements | image lands in the right place, all other block ids preserved |
|
| 7 | `insertImage` (append / `replaceText` / `afterText`) | three placements | image lands in the right place, all other block ids preserved |
|
||||||
| 8 | **`replace_image`** | swap an existing figure for new bytes; comments/align/alt preserved; **the new URL must actually serve the image** | new image renders (`200`), old node repointed |
|
| 8 | **`replaceImage`** | swap an existing figure for new bytes; comments/align/alt preserved; **the new URL must actually serve the image** | new image renders (`200`), old node repointed |
|
||||||
|
|
||||||
## Image-specific assertions (the recurring bug area)
|
## Image-specific assertions (the recurring bug area)
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ For every uploaded/inserted/replaced image, assert at the HTTP level that the
|
|||||||
* `GET <src>` → `200`, `Content-Type: image/*`, body starts with the image magic
|
* `GET <src>` → `200`, `Content-Type: image/*`, body starts with the image magic
|
||||||
(`89 50 4E 47` for PNG, etc.).
|
(`89 50 4E 47` for PNG, etc.).
|
||||||
* `src` does **not** contain a `?v=` query (see "Known pitfalls").
|
* `src` does **not** contain a `?v=` query (see "Known pitfalls").
|
||||||
* After `replace_image`: the returned `newAttachmentId` **differs** from the old
|
* After `replaceImage`: the returned `newAttachmentId` **differs** from the old
|
||||||
one (replacement uses a fresh attachment → fresh URL), and `GET <new src>` → `200`.
|
one (replacement uses a fresh attachment → fresh URL), and `GET <new src>` → `200`.
|
||||||
* The old image node on the page is repointed to the new attachmentId.
|
* The old image node on the page is repointed to the new attachmentId.
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ broken/empty figure.
|
|||||||
Uploading with an existing `attachmentId` (`POST /files/upload` + `attachmentId`)
|
Uploading with an existing `attachmentId` (`POST /files/upload` + `attachmentId`)
|
||||||
overwrites the bytes in place. On this Docmost the attachment then returns
|
overwrites the bytes in place. On this Docmost the attachment then returns
|
||||||
**500 for every URL** (clean, `?v=`, any filename) → broken image. Therefore
|
**500 for every URL** (clean, `?v=`, any filename) → broken image. Therefore
|
||||||
`replace_image` must upload a **new** attachment and repoint the nodes; the new
|
`replaceImage` must upload a **new** attachment and repoint the nodes; the new
|
||||||
id yields a new URL that both renders and busts the browser cache. The old
|
id yields a new URL that both renders and busts the browser cache. The old
|
||||||
attachment is left as an unreferenced orphan: Docmost exposes **no HTTP API to
|
attachment is left as an unreferenced orphan: Docmost exposes **no HTTP API to
|
||||||
delete a single content attachment** (verified against the attachment
|
delete a single content attachment** (verified against the attachment
|
||||||
@@ -80,9 +80,9 @@ broken/empty figure.
|
|||||||
from `?v=`. Image `src` is kept clean (`/api/files/<id>/<file>`); cache-busting
|
from `?v=`. Image `src` is kept clean (`/api/files/<id>/<file>`); cache-busting
|
||||||
on replace is achieved by the new attachment id.
|
on replace is achieved by the new attachment id.
|
||||||
|
|
||||||
3. **REST snapshot lag.** `get_page_json` reads the debounced DB snapshot, so a
|
3. **REST snapshot lag.** `getPageJson` reads the debounced DB snapshot, so a
|
||||||
write made moments earlier may not be visible yet. Wait (~16 s) before reading
|
write made moments earlier may not be visible yet. Wait (~16 s) before reading
|
||||||
back, and never feed a possibly-stale snapshot straight into `update_page_json`.
|
back, and never feed a possibly-stale snapshot straight into `updatePageJson`.
|
||||||
|
|
||||||
4. **Callout type narrowing (minor, open).** A `:::warning` callout is imported as
|
4. **Callout type narrowing (minor, open).** A `:::warning` callout is imported as
|
||||||
`type: "info"` — the markdown→callout conversion does not carry non-`info`
|
`type: "info"` — the markdown→callout conversion does not carry non-`info`
|
||||||
|
|||||||
+494
-122
File diff suppressed because it is too large
Load Diff
@@ -57,17 +57,14 @@ export const DEFAULT_COMMENT_SIGNAL_DEBOUNCE_MS = 20_000;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Tools whose OWN result must NOT carry the signal — it would be tautological
|
* Tools whose OWN result must NOT carry the signal — it would be tautological
|
||||||
* (the agent is already looking at comments) and noisy. Listed in BOTH the
|
* (the agent is already looking at comments) and noisy. Since issue #412 both
|
||||||
* standalone MCP snake_case names AND the in-app camelCase keys so a single set
|
* the standalone MCP surface and the in-app agent use the same camelCase tool
|
||||||
* covers both surfaces (the signal text itself uses the camelCase `listComments`
|
* names, so a single set of camelCase names covers both surfaces. `getComment`
|
||||||
* per roadmap #412). `getComment` (single fetch) is intentionally NOT excluded.
|
* (single fetch) is intentionally NOT excluded.
|
||||||
*/
|
*/
|
||||||
export const COMMENT_SIGNAL_EXCLUDED_TOOLS: ReadonlySet<string> = new Set([
|
export const COMMENT_SIGNAL_EXCLUDED_TOOLS: ReadonlySet<string> = new Set([
|
||||||
"list_comments",
|
|
||||||
"listComments",
|
"listComments",
|
||||||
"check_new_comments",
|
|
||||||
"checkNewComments",
|
"checkNewComments",
|
||||||
"create_comment",
|
|
||||||
"createComment",
|
"createComment",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
+63
-30
@@ -58,7 +58,7 @@ export type {
|
|||||||
CommentSignalTrackerOptions,
|
CommentSignalTrackerOptions,
|
||||||
} from "./comment-signal.js";
|
} from "./comment-signal.js";
|
||||||
// Re-export the pure, no-network draw.io helpers (#424) so the in-app AI-SDK
|
// Re-export the pure, no-network draw.io helpers (#424) so the in-app AI-SDK
|
||||||
// service can wire drawio_shapes / drawio_guide off the loaded module. These are
|
// service can wire drawioShapes / drawioGuide off the loaded module. These are
|
||||||
// NOT client methods (no page/backend hit) — the in-app handler calls them
|
// NOT client methods (no page/backend hit) — the in-app handler calls them
|
||||||
// directly, mirroring how the standalone MCP server wires them here.
|
// directly, mirroring how the standalone MCP server wires them here.
|
||||||
export { searchShapes } from "./lib/drawio-shapes.js";
|
export { searchShapes } from "./lib/drawio-shapes.js";
|
||||||
@@ -89,7 +89,7 @@ const VERSION = packageJson.version;
|
|||||||
// (SHARED_TOOL_SPECS + INLINE_MCP_INVENTORY), so it can no longer drift out of
|
// (SHARED_TOOL_SPECS + INLINE_MCP_INVENTORY), so it can no longer drift out of
|
||||||
// sync with the registered tools. Re-exported here (its old home) so existing
|
// sync with the registered tools. Re-exported here (its old home) so existing
|
||||||
// importers are unaffected; the composition lives in server-instructions.ts.
|
// importers are unaffected; the composition lives in server-instructions.ts.
|
||||||
// The drawio_shapes / drawio_guide tools (#424) stay in SHARED_TOOL_SPECS (so the
|
// The drawioShapes / drawioGuide tools (#424) stay in SHARED_TOOL_SPECS (so the
|
||||||
// generated <tool_inventory> picks them up from their catalogLine automatically)
|
// generated <tool_inventory> picks them up from their catalogLine automatically)
|
||||||
// but are flagged `inlineBothHosts` and registered inline below (their pure
|
// but are flagged `inlineBothHosts` and registered inline below (their pure
|
||||||
// helpers can't cross into tool-specs.ts); only the hand-written routing prose in
|
// helpers can't cross into tool-specs.ts); only the hand-written routing prose in
|
||||||
@@ -286,7 +286,7 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
|
|||||||
// the wrapping is typed loosely and cast — runtime behaviour is unchanged.
|
// the wrapping is typed loosely and cast — runtime behaviour is unchanged.
|
||||||
const registerSharedFromSpec = (spec: SharedToolSpec) => {
|
const registerSharedFromSpec = (spec: SharedToolSpec) => {
|
||||||
if (spec.inAppOnly) return;
|
if (spec.inAppOnly) return;
|
||||||
// `inlineBothHosts` specs (drawio_shapes / drawio_guide) carry no execute —
|
// `inlineBothHosts` specs (drawioShapes / drawioGuide) carry no execute —
|
||||||
// their pure helper cannot cross into the zod-agnostic tool-specs.ts, so they
|
// their pure helper cannot cross into the zod-agnostic tool-specs.ts, so they
|
||||||
// are registered INLINE below (searchShapes / getGuideSection). Skip them here
|
// are registered INLINE below (searchShapes / getGuideSection). Skip them here
|
||||||
// so the loop never dereferences a missing `execute`.
|
// so the loop never dereferences a missing `execute`.
|
||||||
@@ -316,7 +316,7 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- INLINE drawio helper tools (IN the shared registry, but inlineBothHosts) ---
|
// --- INLINE drawio helper tools (IN the shared registry, but inlineBothHosts) ---
|
||||||
// drawio_shapes / drawio_guide (#424) live in SHARED_TOOL_SPECS (so the shared
|
// drawioShapes / drawioGuide (#424) live in SHARED_TOOL_SPECS (so the shared
|
||||||
// contract pins their name/description/schema across both hosts) but carry the
|
// contract pins their name/description/schema across both hosts) but carry the
|
||||||
// `inlineBothHosts` flag and NO execute: their pure backing helpers
|
// `inlineBothHosts` flag and NO execute: their pure backing helpers
|
||||||
// (searchShapes / getGuideSection) cannot be value-imported into the
|
// (searchShapes / getGuideSection) cannot be value-imported into the
|
||||||
@@ -356,25 +356,25 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
|
|||||||
|
|
||||||
// --- INLINE tools kept per-transport (NOT in the shared registry) ---
|
// --- INLINE tools kept per-transport (NOT in the shared registry) ---
|
||||||
// Each stays inline for a documented reason: a snake_case/camelCase naming
|
// Each stays inline for a documented reason: a snake_case/camelCase naming
|
||||||
// clash the registry convention forbids (table_get), an intentional
|
// clash the registry convention forbids (tableGet), an intentional
|
||||||
// per-transport behaviour/schema divergence (search, docmost_transform), or a
|
// per-transport behaviour/schema divergence (search, docmostTransform), or a
|
||||||
// tool that exists ONLY on this standalone MCP surface (update_comment,
|
// tool that exists ONLY on this standalone MCP surface (updateComment,
|
||||||
// delete_comment — the in-app agent deliberately exposes no hard comment
|
// deleteComment — the in-app agent deliberately exposes no hard comment
|
||||||
// edit/delete tool).
|
// edit/delete tool).
|
||||||
|
|
||||||
// Tool: table_get
|
// Tool: tableGet
|
||||||
// NOT in the shared registry: the MCP tool name `table_get` is noun-first while
|
// NOT in the shared registry: the MCP tool name `tableGet` is noun-first while
|
||||||
// the in-app key is `getTable` (verb-first), breaking the snake_case(inAppKey)
|
// the in-app key is `getTable` (verb-first), breaking the snake_case(inAppKey)
|
||||||
// convention the shared registry enforces (shared-tool-specs.contract.spec.ts).
|
// convention the shared registry enforces (shared-tool-specs.contract.spec.ts).
|
||||||
// Renaming the public MCP tool would break external clients, so it stays inline.
|
// Renaming the public MCP tool would break external clients, so it stays inline.
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"table_get",
|
"tableGet",
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Read a table as a matrix. Returns {rows, cols, cells (text[][]), " +
|
"Read a table as a matrix. Returns {rows, cols, cells (text[][]), " +
|
||||||
"cellIds (paragraph id per cell, or null)}. `table` = `#<index>` from " +
|
"cellIds (paragraph id per cell, or null)}. `table` = `#<index>` from " +
|
||||||
"get_outline, or any block id inside the table. Use cellIds with " +
|
"getOutline, or any block id inside the table. Use cellIds with " +
|
||||||
"patch_node for rich-formatted cell edits. `cols` is the FIRST row's " +
|
"patchNode for rich-formatted cell edits. `cols` is the FIRST row's " +
|
||||||
"width; ragged tables may vary per row, so use the per-row length of " +
|
"width; ragged tables may vary per row, so use the per-row length of " +
|
||||||
"`cells` for each row.",
|
"`cells` for each row.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
@@ -388,9 +388,9 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Tool: update_comment
|
// Tool: updateComment
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"update_comment",
|
"updateComment",
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Update an existing comment's content. Only the comment creator can " +
|
"Update an existing comment's content. Only the comment creator can " +
|
||||||
@@ -409,9 +409,9 @@ server.registerTool(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Tool: delete_comment
|
// Tool: deleteComment
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"delete_comment",
|
"deleteComment",
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Delete a comment. Only the comment creator or space admin can delete it.",
|
"Delete a comment. Only the comment creator or space admin can delete it.",
|
||||||
@@ -435,41 +435,74 @@ server.registerTool(
|
|||||||
// Tool: search
|
// Tool: search
|
||||||
// INTENTIONAL per-transport divergence (not shared): the in-app `searchPages`
|
// INTENTIONAL per-transport divergence (not shared): the in-app `searchPages`
|
||||||
// runs a semantic + keyword hybrid (RRF) with in-process access control and a
|
// runs a semantic + keyword hybrid (RRF) with in-process access control and a
|
||||||
// different schema (limit 1-20); this transport is a plain REST full-text search
|
// different schema; this transport is the #443 agent-lookup search — a hybrid
|
||||||
// (limit up to 100). Different behaviour AND schema, so kept per-layer.
|
// substring + full-text search that also returns each hit's location (`path`)
|
||||||
|
// and a windowed `snippet`, so one call answers "where is it and what's in it".
|
||||||
|
// The in-app hybrid-RRF search is deliberately NOT touched. Different behaviour
|
||||||
|
// AND schema, so kept per-layer.
|
||||||
|
//
|
||||||
|
// STANDALONE-vs-STOCK-UPSTREAM: the client sends the opt-in `substring`/
|
||||||
|
// `parentPageId`/`titleOnly` DTO fields. A stock upstream server validates the
|
||||||
|
// DTO with `whitelist: true` and silently strips these unknown fields, so the
|
||||||
|
// request degrades gracefully to plain FTS (no path/snippet, current shape).
|
||||||
|
//
|
||||||
|
// EE/TYPESENSE DEGRADATION (#443): on an instance whose SEARCH_DRIVER is
|
||||||
|
// `typesense`, the server routes this request to the Typesense backend, which
|
||||||
|
// does NOT implement agent-lookup — the substring/path/snippet/tiering is
|
||||||
|
// ignored and the response degrades to plain Typesense FTS. The rich lookup
|
||||||
|
// shape is only produced by the native Postgres search driver.
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"search",
|
"search",
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Full-text search for pages and content across the whole workspace. " +
|
"Find pages by a fragment of a technical string (hostnames, IPs, IDs " +
|
||||||
"Results are bounded by `limit` (1-100; when omitted the server applies " +
|
"like `srv.local`, `10.0.12`, `WB-MGE-30D86B`) — one call returns each " +
|
||||||
"its own default).",
|
"hit's location (`path`: ancestor titles root→parent) and a `snippet` " +
|
||||||
|
"around the first match, so you rarely need a follow-up get_page. " +
|
||||||
|
"Matches substrings literally (dots/dashes/digits are not tokenized) as " +
|
||||||
|
"well as full-text. Returns `{ pageId, title, path, snippet, score }` " +
|
||||||
|
"sorted by `score` (a per-response relevance float).",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
query: z.string().min(1).describe("Search query"),
|
query: z.string().min(1).describe("Search query"),
|
||||||
|
spaceId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Restrict the search to a single space"),
|
||||||
|
parentPageId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Restrict to a page and all its descendants (the page itself included)",
|
||||||
|
),
|
||||||
|
titleOnly: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe("Match page titles only; skip page text"),
|
||||||
limit: z
|
limit: z
|
||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
.min(1)
|
.min(1)
|
||||||
.max(100)
|
.max(50)
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Max results to return (max 100)"),
|
.describe("Max results to return (1-50, default 10)"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ query, limit }) => {
|
async ({ query, spaceId, parentPageId, titleOnly, limit }) => {
|
||||||
// The tool exposes no spaceId filter, so pass undefined for the client's
|
const result = await docmostClient.search(query, spaceId, limit, {
|
||||||
// optional spaceId parameter and forward limit into its correct slot.
|
parentPageId,
|
||||||
const result = await docmostClient.search(query, undefined, limit);
|
titleOnly,
|
||||||
|
});
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Tool: docmost_transform
|
// Tool: docmostTransform
|
||||||
// INTENTIONAL per-transport divergence (not shared): the in-app `transformPage`
|
// INTENTIONAL per-transport divergence (not shared): the in-app `transformPage`
|
||||||
// deliberately omits the `deleteComments` schema field (comment-deletion
|
// deliberately omits the `deleteComments` schema field (comment-deletion
|
||||||
// guardrail) and carries a much shorter description; this transport exposes the
|
// guardrail) and carries a much shorter description; this transport exposes the
|
||||||
// full helper catalogue. Different schema, so kept per-layer.
|
// full helper catalogue. Different schema, so kept per-layer.
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"docmost_transform",
|
"docmostTransform",
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Edit a page by running an arbitrary JS transform `(doc, ctx) => doc` " +
|
"Edit a page by running an arbitrary JS transform `(doc, ctx) => doc` " +
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ global.WebSocket = WebSocket;
|
|||||||
* bodies merged. So the import output is ALREADY in canonical footnote
|
* bodies merged. So the import output is ALREADY in canonical footnote
|
||||||
* topology.
|
* topology.
|
||||||
* - `canonicalizeFootnotes` runs AFTER as the mcp write-path invariant shared
|
* - `canonicalizeFootnotes` runs AFTER as the mcp write-path invariant shared
|
||||||
* with every other full-document persist path (`update_page_json`,
|
* with every other full-document persist path (`updatePageJson`,
|
||||||
* `docmost_transform`, `insert_footnote`, …). Because the package output is
|
* `docmostTransform`, `insertFootnote`, …). Because the package output is
|
||||||
* already canonical, this layer is a no-op here (idempotent) — it exists so
|
* already canonical, this layer is a no-op here (idempotent) — it exists so
|
||||||
* the page-write contract is enforced uniformly regardless of how the PM doc
|
* the page-write contract is enforced uniformly regardless of how the PM doc
|
||||||
* was produced, not because the import needs fixing.
|
* was produced, not because the import needs fixing.
|
||||||
@@ -282,7 +282,7 @@ export async function mutatePageContent(
|
|||||||
* it was produced from markdown (ids regenerate) or edited in place
|
* it was produced from markdown (ids regenerate) or edited in place
|
||||||
* (existing block ids preserved).
|
* (existing block ids preserved).
|
||||||
*
|
*
|
||||||
* This is an intentional full replace (used by update_page / update_page_json),
|
* This is an intentional full replace (used by update_page / updatePageJson),
|
||||||
* but now runs under the per-page lock and waits for server persistence via
|
* but now runs under the per-page lock and waits for server persistence via
|
||||||
* mutatePageContent.
|
* mutatePageContent.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
*
|
*
|
||||||
* MARKDOWN-STRIP FALLBACK: when the agent copies a selection that still carries
|
* MARKDOWN-STRIP FALLBACK: when the agent copies a selection that still carries
|
||||||
* inline markdown (`**bold**`, `` `code` ``, `[t](u)`), the raw locator will not
|
* inline markdown (`**bold**`, `` `code` ``, `[t](u)`), the raw locator will not
|
||||||
* match the document's plain text. Exactly like edit_page_text's json-edit
|
* match the document's plain text. Exactly like editPageText's json-edit
|
||||||
* fallback, we first try the verbatim selection and, ONLY if it anchors nowhere
|
* fallback, we first try the verbatim selection and, ONLY if it anchors nowhere
|
||||||
* in the whole document, retry with `stripInlineMarkdown` applied. `canAnchorInDoc`,
|
* in the whole document, retry with `stripInlineMarkdown` applied. `canAnchorInDoc`,
|
||||||
* `getAnchoredText` and `applyAnchorInDoc` share this decision via
|
* `getAnchoredText` and `applyAnchorInDoc` share this decision via
|
||||||
|
|||||||
+53
-163
@@ -14,19 +14,7 @@
|
|||||||
* signature.
|
* signature.
|
||||||
*
|
*
|
||||||
* If recreateTransform / the changeset throws on a pathological document pair,
|
* If recreateTransform / the changeset throws on a pathological document pair,
|
||||||
* OR the pair is too large to diff cheaply (see the size guard below), we fall
|
* we fall back to a coarse block-level text diff so the tool never hard-fails.
|
||||||
* back to a coarse block-level text diff so the tool never hard-fails and never
|
|
||||||
* pins the event loop.
|
|
||||||
*
|
|
||||||
* SIZE GUARD (issue #464 — prod CPU-DoS). recreateTransform computes its diff via
|
|
||||||
* rfc6902.createPatch, whose array diff is O(n·m) Levenshtein per array pair and
|
|
||||||
* whose per-run word diff is O(w²); on a large/heavily-changed doc this runs for
|
|
||||||
* seconds-to-hours and starves the whole process (BullMQ, Redis lock renewals,
|
|
||||||
* embeddings). It never THROWS — it just never finishes — so the try/catch below
|
|
||||||
* cannot save us. Because diffDocs runs on EVERY in-app/MCP content edit's verify
|
|
||||||
* report, we PRE-FLIGHT the doc size and route anything above a cheap cap straight
|
|
||||||
* to the coarse fallback (the same shape the catch produces). Same cap+fallback
|
|
||||||
* pattern as the ELK-layout DoS fix (#440 / c917dcc3).
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Node } from "@tiptap/pm/model";
|
import { Node } from "@tiptap/pm/model";
|
||||||
@@ -84,56 +72,6 @@ function countNodes(doc: any, pred: (node: any) => boolean): number {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Issue #464: pre-flight size guard for the precise diff ------------------
|
|
||||||
// Defaults are BENCHMARK-derived on the recreateTransform(complexSteps:false,
|
|
||||||
// wordDiffs:true, simplifyDiff:true) pipeline, chosen so the WORST case (a fully
|
|
||||||
// re-written doc — the adversarial shape that drove the incident) keeps the
|
|
||||||
// synchronous block under ~200ms REGARDLESS of input:
|
|
||||||
// - 150 total nodes: worst-case pair ~176ms; the O(node²) array diff crosses
|
|
||||||
// 200ms at ~170 nodes and then explodes super-linearly (400 nodes ~1.3s,
|
|
||||||
// 800 ~5.5s), so cap just below the crossover.
|
|
||||||
// - 12 KiB serialized JSON: an independent axis, because the per-run word diff
|
|
||||||
// is O(words²) — a FEW nodes with very long text runs is dangerous even at a
|
|
||||||
// low node count (17 nodes / ~11 KiB ~176ms, / ~14 KiB ~290ms). A node-light
|
|
||||||
// but byte-heavy doc is still refused.
|
|
||||||
// Either metric over its cap routes to the coarse fallback. Both are env-tunable
|
|
||||||
// for operators who accept more CPU in exchange for exact diffs on larger docs.
|
|
||||||
const DEFAULT_MAX_NODES = 150;
|
|
||||||
const DEFAULT_MAX_BYTES = 12 * 1024;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Read a positive-integer env override, falling back to `dflt`. Garbage / unset /
|
|
||||||
* non-finite / non-positive all fall back (so the guard can never be accidentally
|
|
||||||
* disabled by a malformed value). Read fresh on every call so a test / operator
|
|
||||||
* can flip the knob without a restart.
|
|
||||||
*/
|
|
||||||
function readPositiveIntEnv(name: string, dflt: number): number {
|
|
||||||
const raw = parseInt(process.env[name] ?? "", 10);
|
|
||||||
return Number.isFinite(raw) && raw > 0 ? raw : dflt;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* True when the pair is too large for the precise (recreateTransform) diff and
|
|
||||||
* must degrade to the coarse fallback. Takes the MAX of the two docs on each
|
|
||||||
* metric so an ASYMMETRIC pair (a small new doc vs a huge old doc, or vice
|
|
||||||
* versa) — which still explodes rfc6902 — is caught. Cheap: one node walk +
|
|
||||||
* one JSON.stringify per doc, both O(size).
|
|
||||||
*/
|
|
||||||
function exceedsDiffSizeGuard(oldDoc: any, newDoc: any): boolean {
|
|
||||||
const maxNodes = readPositiveIntEnv("MCP_DIFF_MAX_NODES", DEFAULT_MAX_NODES);
|
|
||||||
const maxBytes = readPositiveIntEnv("MCP_DIFF_MAX_BYTES", DEFAULT_MAX_BYTES);
|
|
||||||
const nodes = Math.max(
|
|
||||||
countNodes(oldDoc, () => true),
|
|
||||||
countNodes(newDoc, () => true),
|
|
||||||
);
|
|
||||||
if (nodes > maxNodes) return true;
|
|
||||||
const bytes = Math.max(
|
|
||||||
JSON.stringify(oldDoc)?.length ?? 0,
|
|
||||||
JSON.stringify(newDoc)?.length ?? 0,
|
|
||||||
);
|
|
||||||
return bytes > maxBytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Count UNIQUE links in a JSON doc by their `href`. A single link can be split
|
* Count UNIQUE links in a JSON doc by their `href`. A single link can be split
|
||||||
* across several adjacent text runs (e.g. a "link+bold" run followed by a "link"
|
* across several adjacent text runs (e.g. a "link+bold" run followed by a "link"
|
||||||
@@ -288,81 +226,6 @@ function coarseDiff(oldDoc: any, newDoc: any): DiffChange[] {
|
|||||||
return changes;
|
return changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Accumulated textual changes plus their derived char/block tallies. */
|
|
||||||
interface DiffTally {
|
|
||||||
changes: DiffChange[];
|
|
||||||
inserted: number;
|
|
||||||
deleted: number;
|
|
||||||
changedBlocks: Set<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Produce the coarse-fallback tally for a pair. This is the SINGLE source of the
|
|
||||||
* `fellBack:true` result shape, shared by BOTH degrade paths in diffDocs (the
|
|
||||||
* pre-flight size guard and the recreateTransform catch) so they behave and
|
|
||||||
* report identically.
|
|
||||||
*/
|
|
||||||
function coarseDiffTally(oldDoc: any, newDoc: any): DiffTally {
|
|
||||||
const changes = coarseDiff(oldDoc, newDoc);
|
|
||||||
let inserted = 0;
|
|
||||||
let deleted = 0;
|
|
||||||
const changedBlocks = new Set<string>();
|
|
||||||
for (const c of changes) {
|
|
||||||
if (c.op === "insert") inserted += c.text.length;
|
|
||||||
else deleted += c.text.length;
|
|
||||||
if (c.block) changedBlocks.add(c.op[0] + ":" + c.block);
|
|
||||||
}
|
|
||||||
return { changes, inserted, deleted, changedBlocks };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute the PRECISE tally via the recreateTransform pipeline. Callers MUST
|
|
||||||
* gate this behind the size guard (it can block the event loop for a large pair)
|
|
||||||
* and wrap it in try/catch (a pathological pair can throw); on either the guard
|
|
||||||
* or a throw, use `coarseDiffTally` instead. Kept as a sibling of
|
|
||||||
* `coarseDiffTally` so both produce the same `DiffTally` shape.
|
|
||||||
*/
|
|
||||||
function preciseDiffTally(oldDocJson: any, newDocJson: any): DiffTally {
|
|
||||||
const oldNode = Node.fromJSON(docmostSchema, oldDocJson);
|
|
||||||
const newNode = Node.fromJSON(docmostSchema, newDocJson);
|
|
||||||
const tr = recreateTransform(oldNode, newNode, {
|
|
||||||
complexSteps: false,
|
|
||||||
wordDiffs: true,
|
|
||||||
simplifyDiff: true,
|
|
||||||
});
|
|
||||||
const changeSet = ChangeSet.create(oldNode).addSteps(tr.doc, tr.mapping.maps, []);
|
|
||||||
const simplified = simplifyChanges(changeSet.changes, newNode);
|
|
||||||
|
|
||||||
const changes: DiffChange[] = [];
|
|
||||||
let inserted = 0;
|
|
||||||
let deleted = 0;
|
|
||||||
const changedBlocks = new Set<string>();
|
|
||||||
|
|
||||||
for (const change of simplified) {
|
|
||||||
// Deleted text lives in the OLD doc coordinate range [fromA, toA).
|
|
||||||
if (change.toA > change.fromA) {
|
|
||||||
const text = oldNode.textBetween(change.fromA, change.toA, "\n", " ");
|
|
||||||
if (text.length > 0) {
|
|
||||||
deleted += text.length;
|
|
||||||
const block = blockContextAt(oldNode, change.fromA);
|
|
||||||
changes.push({ op: "delete", block, text });
|
|
||||||
if (block) changedBlocks.add("d:" + block);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Inserted text lives in the NEW doc coordinate range [fromB, toB).
|
|
||||||
if (change.toB > change.fromB) {
|
|
||||||
const text = newNode.textBetween(change.fromB, change.toB, "\n", " ");
|
|
||||||
if (text.length > 0) {
|
|
||||||
inserted += text.length;
|
|
||||||
const block = blockContextAt(newNode, change.fromB);
|
|
||||||
changes.push({ op: "insert", block, text });
|
|
||||||
if (block) changedBlocks.add("i:" + block);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { changes, inserted, deleted, changedBlocks };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build the human-readable unified-ish markdown summary. */
|
/** Build the human-readable unified-ish markdown summary. */
|
||||||
function renderMarkdown(
|
function renderMarkdown(
|
||||||
result: Omit<DiffResult, "markdown">,
|
result: Omit<DiffResult, "markdown">,
|
||||||
@@ -413,39 +276,66 @@ export function diffDocs(
|
|||||||
newDocJson: any,
|
newDocJson: any,
|
||||||
notesHeading: string = "Примечания переводчика",
|
notesHeading: string = "Примечания переводчика",
|
||||||
): DiffResult {
|
): DiffResult {
|
||||||
// computeIntegrity is cheap (linear node walks) and its counts are needed in
|
|
||||||
// BOTH the precise and coarse paths, so it always runs first.
|
|
||||||
const integrity = computeIntegrity(oldDocJson, newDocJson, notesHeading);
|
const integrity = computeIntegrity(oldDocJson, newDocJson, notesHeading);
|
||||||
|
|
||||||
|
let changes: DiffChange[] = [];
|
||||||
|
let inserted = 0;
|
||||||
|
let deleted = 0;
|
||||||
let fellBack = false;
|
let fellBack = false;
|
||||||
let tally: DiffTally;
|
const changedBlocks = new Set<string>();
|
||||||
|
|
||||||
// Pre-flight size guard (#464): a too-large pair would make recreateTransform
|
try {
|
||||||
// block the event loop for seconds-to-hours WITHOUT throwing, so route it to
|
const oldNode = Node.fromJSON(docmostSchema, oldDocJson);
|
||||||
// the coarse fallback BEFORE calling recreateTransform at all. Both this path
|
const newNode = Node.fromJSON(docmostSchema, newDocJson);
|
||||||
// and the catch below go through coarseDiffTally for an identical `fellBack`
|
const tr = recreateTransform(oldNode, newNode, {
|
||||||
// result shape.
|
complexSteps: false,
|
||||||
if (exceedsDiffSizeGuard(oldDocJson, newDocJson)) {
|
wordDiffs: true,
|
||||||
|
simplifyDiff: true,
|
||||||
|
});
|
||||||
|
const changeSet = ChangeSet.create(oldNode).addSteps(
|
||||||
|
tr.doc,
|
||||||
|
tr.mapping.maps,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const simplified = simplifyChanges(changeSet.changes, newNode);
|
||||||
|
|
||||||
|
for (const change of simplified) {
|
||||||
|
// Deleted text lives in the OLD doc coordinate range [fromA, toA).
|
||||||
|
if (change.toA > change.fromA) {
|
||||||
|
const text = oldNode.textBetween(change.fromA, change.toA, "\n", " ");
|
||||||
|
if (text.length > 0) {
|
||||||
|
deleted += text.length;
|
||||||
|
const block = blockContextAt(oldNode, change.fromA);
|
||||||
|
changes.push({ op: "delete", block, text });
|
||||||
|
if (block) changedBlocks.add("d:" + block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Inserted text lives in the NEW doc coordinate range [fromB, toB).
|
||||||
|
if (change.toB > change.fromB) {
|
||||||
|
const text = newNode.textBetween(change.fromB, change.toB, "\n", " ");
|
||||||
|
if (text.length > 0) {
|
||||||
|
inserted += text.length;
|
||||||
|
const block = blockContextAt(newNode, change.fromB);
|
||||||
|
changes.push({ op: "insert", block, text });
|
||||||
|
if (block) changedBlocks.add("i:" + block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Pathological pair: degrade to a coarse block-level diff so we never throw.
|
||||||
fellBack = true;
|
fellBack = true;
|
||||||
tally = coarseDiffTally(oldDocJson, newDocJson);
|
changes = coarseDiff(oldDocJson, newDocJson);
|
||||||
} else {
|
for (const c of changes) {
|
||||||
try {
|
if (c.op === "insert") inserted += c.text.length;
|
||||||
tally = preciseDiffTally(oldDocJson, newDocJson);
|
else deleted += c.text.length;
|
||||||
} catch {
|
if (c.block) changedBlocks.add(c.op[0] + ":" + c.block);
|
||||||
// Pathological pair: degrade to a coarse block-level diff so we never throw.
|
|
||||||
fellBack = true;
|
|
||||||
tally = coarseDiffTally(oldDocJson, newDocJson);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const partial: Omit<DiffResult, "markdown"> = {
|
const partial: Omit<DiffResult, "markdown"> = {
|
||||||
summary: {
|
summary: { inserted, deleted, blocksChanged: changedBlocks.size },
|
||||||
inserted: tally.inserted,
|
|
||||||
deleted: tally.deleted,
|
|
||||||
blocksChanged: tally.changedBlocks.size,
|
|
||||||
},
|
|
||||||
integrity,
|
integrity,
|
||||||
changes: tally.changes,
|
changes,
|
||||||
};
|
};
|
||||||
return { ...partial, markdown: renderMarkdown(partial, fellBack) };
|
return { ...partial, markdown: renderMarkdown(partial, fellBack) };
|
||||||
}
|
}
|
||||||
@@ -490,7 +380,7 @@ export interface VerifyReport {
|
|||||||
/**
|
/**
|
||||||
* ONLY structural integrity types whose count changed, as [before, after]
|
* ONLY structural integrity types whose count changed, as [before, after]
|
||||||
* (images/links/tables/callouts). Surfaces structural mutations that touch
|
* (images/links/tables/callouts). Surfaces structural mutations that touch
|
||||||
* neither text nor marks (e.g. insert_image, deleting a table) which diffDocs
|
* neither text nor marks (e.g. insertImage, deleting a table) which diffDocs
|
||||||
* — being TEXT-only — would otherwise report as "no content change".
|
* — being TEXT-only — would otherwise report as "no content change".
|
||||||
*/
|
*/
|
||||||
structure?: Record<string, [number, number]>;
|
structure?: Record<string, [number, number]>;
|
||||||
@@ -510,7 +400,7 @@ export interface VerifyReport {
|
|||||||
*
|
*
|
||||||
* The structural integrity delta (from diffDocs's `integrity` tuples) is what
|
* The structural integrity delta (from diffDocs's `integrity` tuples) is what
|
||||||
* makes `changed` true for an image/table/callout/link count change that diffs
|
* makes `changed` true for an image/table/callout/link count change that diffs
|
||||||
* to zero text — closing a verify blind spot for insert_image, delete_node on a
|
* to zero text — closing a verify blind spot for insertImage, deleteNode on a
|
||||||
* table, etc.
|
* table, etc.
|
||||||
*/
|
*/
|
||||||
export function summarizeChange(before: any, after: any): VerifyReport {
|
export function summarizeChange(before: any, after: any): VerifyReport {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Progressive-disclosure authoring reference for the `drawio_guide` tool
|
// Progressive-disclosure authoring reference for the `drawioGuide` tool
|
||||||
// (issue #424, stage 2). The FULL draw.io authoring guide would bloat every
|
// (issue #424, stage 2). The FULL draw.io authoring guide would bloat every
|
||||||
// context window, so it is split into small sections the model reads on demand:
|
// context window, so it is split into small sections the model reads on demand:
|
||||||
// skeleton | layout | containers | icons-aws | icons-azure
|
// skeleton | layout | containers | icons-aws | icons-azure
|
||||||
@@ -22,7 +22,7 @@ export const GUIDE_SECTIONS: GuideSection[] = [
|
|||||||
"icons-azure",
|
"icons-azure",
|
||||||
];
|
];
|
||||||
|
|
||||||
const SKELETON = `# drawio_guide: skeleton
|
const SKELETON = `# drawioGuide: skeleton
|
||||||
|
|
||||||
Canonical mxGraph skeleton. id="0" and id="1" are MANDATORY sentinels; every
|
Canonical mxGraph skeleton. id="0" and id="1" are MANDATORY sentinels; every
|
||||||
real cell has parent="1" (or a container id). Set adaptiveColors="auto" on the
|
real cell has parent="1" (or a container id). Set adaptiveColors="auto" on the
|
||||||
@@ -50,7 +50,7 @@ model so Docmost's dark theme adapts strokeColor/fillColor/fontColor="default".
|
|||||||
</mxGraphModel>
|
</mxGraphModel>
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
Three accepted inputs to drawio_create/drawio_update: a bare <mxGraphModel>, a
|
Three accepted inputs to drawioCreate/drawioUpdate: a bare <mxGraphModel>, a
|
||||||
full <mxfile> (decoded to its first page), or a raw list of <mxCell> (the server
|
full <mxfile> (decoded to its first page), or a raw list of <mxCell> (the server
|
||||||
wraps it and adds the id=0/id=1 sentinels).
|
wraps it and adds the id=0/id=1 sentinels).
|
||||||
|
|
||||||
@@ -58,12 +58,12 @@ Hard rules: a cell is vertex="1" XOR edge="1" (a container/group is neither);
|
|||||||
every edge has a child <mxGeometry relative="1" as="geometry"/>; ids are unique;
|
every edge has a child <mxGeometry relative="1" as="geometry"/>; ids are unique;
|
||||||
no XML comments; put html=1 in styles and XML-escape value (& -> &,
|
no XML comments; put html=1 in styles and XML-escape value (& -> &,
|
||||||
< -> <); a newline in a label is 
, never a literal \\n. Don't guess
|
< -> <); a newline in a label is 
, never a literal \\n. Don't guess
|
||||||
shape=mxgraph.* names — call drawio_shapes first (a wrong name renders empty).`;
|
shape=mxgraph.* names — call drawioShapes first (a wrong name renders empty).`;
|
||||||
|
|
||||||
const LAYOUT = `# drawio_guide: layout
|
const LAYOUT = `# drawioGuide: layout
|
||||||
|
|
||||||
Turn "make it look good" into checkable numbers. Or pass layout:"elk" to
|
Turn "make it look good" into checkable numbers. Or pass layout:"elk" to
|
||||||
drawio_create/drawio_update and the server computes coordinates for you (ELK
|
drawioCreate/drawioUpdate and the server computes coordinates for you (ELK
|
||||||
layered layout, honouring nested containers) — you declare structure, it places
|
layered layout, honouring nested containers) — you declare structure, it places
|
||||||
pixels.
|
pixels.
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ The linter returns quality WARNINGS (bbox overlap, edge through a shape,
|
|||||||
edge-on-edge, gap <150px, label wider than its shape, negative/off-page coords).
|
edge-on-edge, gap <150px, label wider than its shape, negative/off-page coords).
|
||||||
They do not block the write — fix them and retry, max 2 iterations.`;
|
They do not block the write — fix them and retry, max 2 iterations.`;
|
||||||
|
|
||||||
const CONTAINERS = `# drawio_guide: containers
|
const CONTAINERS = `# drawioGuide: containers
|
||||||
|
|
||||||
Groups/zones are TRANSPARENT containers. A coloured group fill is an instant
|
Groups/zones are TRANSPARENT containers. A coloured group fill is an instant
|
||||||
"AI-generated" tell — never fill a group.
|
"AI-generated" tell — never fill a group.
|
||||||
@@ -133,10 +133,10 @@ Example (transparent zone with two children and an internal edge):
|
|||||||
</mxCell>
|
</mxCell>
|
||||||
\`\`\``;
|
\`\`\``;
|
||||||
|
|
||||||
const ICONS_AWS = `# drawio_guide: icons-aws
|
const ICONS_AWS = `# drawioGuide: icons-aws
|
||||||
|
|
||||||
Two mutually-exclusive AWS icon patterns — mixing them is the #1 cause of empty
|
Two mutually-exclusive AWS icon patterns — mixing them is the #1 cause of empty
|
||||||
boxes. Always call drawio_shapes for the exact resIcon name; do not guess.
|
boxes. Always call drawioShapes for the exact resIcon name; do not guess.
|
||||||
|
|
||||||
| Level | style | strokeColor |
|
| Level | style | strokeColor |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -168,7 +168,7 @@ Group stencils (transparent containers): AWS Cloud group_aws_cloud_alt, VPC
|
|||||||
group_vpc2, Subnet group_security_group, Account group_account; subnets use
|
group_vpc2, Subnet group_security_group, Account group_account; subnets use
|
||||||
shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_public_subnet;.`;
|
shape=mxgraph.aws4.group;grIcon=mxgraph.aws4.group_public_subnet;.`;
|
||||||
|
|
||||||
const ICONS_AZURE = `# drawio_guide: icons-azure
|
const ICONS_AZURE = `# drawioGuide: icons-azure
|
||||||
|
|
||||||
shape=mxgraph.azure2.* does NOT render in every host. Use the portable
|
shape=mxgraph.azure2.* does NOT render in every host. Use the portable
|
||||||
image-style instead:
|
image-style instead:
|
||||||
@@ -190,7 +190,7 @@ an absolute URL fallback for the image:
|
|||||||
https://raw.githubusercontent.com/jgraph/drawio/dev/src/main/webapp/img/lib/azure2/<category>/<Icon>.svg
|
https://raw.githubusercontent.com/jgraph/drawio/dev/src/main/webapp/img/lib/azure2/<category>/<Icon>.svg
|
||||||
\`\`\`
|
\`\`\`
|
||||||
|
|
||||||
Call drawio_shapes with the service name (e.g. "cosmos", "api management",
|
Call drawioShapes with the service name (e.g. "cosmos", "api management",
|
||||||
"front door") to get the exact image-style string and default 68x68 size.`;
|
"front door") to get the exact image-style string and default 68x68 size.`;
|
||||||
|
|
||||||
const CONTENT: Record<GuideSection, string> = {
|
const CONTENT: Record<GuideSection, string> = {
|
||||||
@@ -216,13 +216,13 @@ export function getGuideSection(section?: string): {
|
|||||||
return { section: key, content: CONTENT[key], sections: GUIDE_SECTIONS };
|
return { section: key, content: CONTENT[key], sections: GUIDE_SECTIONS };
|
||||||
}
|
}
|
||||||
const index =
|
const index =
|
||||||
"# drawio_guide\n\nProgressive-disclosure draw.io authoring reference. " +
|
"# drawioGuide\n\nProgressive-disclosure draw.io authoring reference. " +
|
||||||
"Call drawio_guide(section) with one of:\n" +
|
"Call drawioGuide(section) with one of:\n" +
|
||||||
"- skeleton — canonical mxGraph XML, sentinels, the three accepted inputs, hard rules\n" +
|
"- skeleton — canonical mxGraph XML, sentinels, the three accepted inputs, hard rules\n" +
|
||||||
"- layout — spacing heuristics, edge routing, the layout:\"elk\" option, quality warnings\n" +
|
"- layout — spacing heuristics, edge routing, the layout:\"elk\" option, quality warnings\n" +
|
||||||
"- containers — transparent groups, relative child coords, cross-container edges, swimlanes\n" +
|
"- containers — transparent groups, relative child coords, cross-container edges, swimlanes\n" +
|
||||||
"- icons-aws — the service/resource icon patterns, category colors, rebrandings, blocklist\n" +
|
"- icons-aws — the service/resource icon patterns, category colors, rebrandings, blocklist\n" +
|
||||||
"- icons-azure — the portable image-style paths\n\n" +
|
"- icons-azure — the portable image-style paths\n\n" +
|
||||||
"Also call drawio_shapes(query) for verified stencil style-strings.";
|
"Also call drawioShapes(query) for verified stencil style-strings.";
|
||||||
return { section: "index", content: index, sections: GUIDE_SECTIONS };
|
return { section: "index", content: index, sections: GUIDE_SECTIONS };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const DEFAULT_W = 140;
|
|||||||
const DEFAULT_H = 60;
|
const DEFAULT_H = 60;
|
||||||
|
|
||||||
// DoS bounds for the in-process ELK layout. The mxGraph XML is LLM-supplied
|
// DoS bounds for the in-process ELK layout. The mxGraph XML is LLM-supplied
|
||||||
// (layout:"elk" in drawio_create/drawio_update) and elkjs runs synchronously on
|
// (layout:"elk" in drawioCreate/drawioUpdate) and elkjs runs synchronously on
|
||||||
// the MCP server's event loop, so an unbounded graph would block it for
|
// the MCP server's event loop, so an unbounded graph would block it for
|
||||||
// seconds-to-minutes. A ~1MB XML (well under the stage-1 16MB cap) can carry
|
// seconds-to-minutes. A ~1MB XML (well under the stage-1 16MB cap) can carry
|
||||||
// thousands of nodes. We cap the graph size and race the layout against a
|
// thousands of nodes. We cap the graph size and race the layout against a
|
||||||
@@ -81,7 +81,7 @@ interface ElkGraph extends ElkNode {
|
|||||||
/**
|
/**
|
||||||
* Apply an ELK layered layout to a drawio input and return a full mxGraphModel
|
* Apply an ELK layered layout to a drawio input and return a full mxGraphModel
|
||||||
* string with rewritten geometry. Accepts the same three input forms as
|
* string with rewritten geometry. Accepts the same three input forms as
|
||||||
* drawio_create (a bare model, an <mxfile>, or a <mxCell> list). Async because
|
* drawioCreate (a bare model, an <mxfile>, or a <mxCell> list). Async because
|
||||||
* elkjs' layout() is promise-based. On any layout failure the ORIGINAL
|
* elkjs' layout() is promise-based. On any layout failure the ORIGINAL
|
||||||
* (normalized) model is returned unchanged — layout is best-effort polish, never
|
* (normalized) model is returned unchanged — layout is best-effort polish, never
|
||||||
* a reason to fail the write.
|
* a reason to fail the write.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Verified draw.io shape catalog for the `drawio_shapes` tool (issue #424,
|
// Verified draw.io shape catalog for the `drawioShapes` tool (issue #424,
|
||||||
// stage 2). This is the fix for AI-generated diagrams' #1 defect: guessed
|
// stage 2). This is the fix for AI-generated diagrams' #1 defect: guessed
|
||||||
// `shape=mxgraph.*` names that render as EMPTY BOXES because the stencil does
|
// `shape=mxgraph.*` names that render as EMPTY BOXES because the stencil does
|
||||||
// not exist. Instead of guessing, the model queries this catalog and gets back
|
// not exist. Instead of guessing, the model queries this catalog and gets back
|
||||||
|
|||||||
@@ -1070,7 +1070,7 @@ export function prepareModel(inputXml: string): PreparedModel {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cell count of a decoded model (user cells only) — used by drawio_get meta. */
|
/** Cell count of a decoded model (user cells only) — used by drawioGet meta. */
|
||||||
export function countUserCells(modelXml: string): number {
|
export function countUserCells(modelXml: string): number {
|
||||||
return parseCells(modelXml).filter((c) => c.id !== "0" && c.id !== "1").length;
|
return parseCells(modelXml).filter((c) => c.id !== "0" && c.id !== "1").length;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,16 +83,32 @@ export function filterComment(comment: any, markdownContent?: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map one server search hit to the MCP output contract (#443):
|
||||||
|
// { pageId, title, path, snippet, score }
|
||||||
|
//
|
||||||
|
// INVARIANT: the only page identifier exposed is `pageId` (the server `id`
|
||||||
|
// UUID). The server also carries `slugId` — it is NEVER surfaced.
|
||||||
|
//
|
||||||
|
// GRACEFUL DEGRADATION: against a stock upstream server the opt-in lookup DTO
|
||||||
|
// fields are stripped, so the response is the legacy FTS shape (no path/snippet/
|
||||||
|
// score, a `highlight` + `rank` instead). We synthesize the contract from
|
||||||
|
// whatever is present: `snippet` falls back to the FTS `highlight`, `score` to
|
||||||
|
// the FTS `rank`, and `path` to [] (upstream has no path). This keeps the tool
|
||||||
|
// usable even when the server has not been upgraded.
|
||||||
export function filterSearchResult(result: any) {
|
export function filterSearchResult(result: any) {
|
||||||
return {
|
return {
|
||||||
id: result.id,
|
pageId: result.id,
|
||||||
title: result.title,
|
title: result.title,
|
||||||
parentPageId: result.parentPageId,
|
path: Array.isArray(result.path) ? result.path : [],
|
||||||
createdAt: result.createdAt,
|
snippet:
|
||||||
updatedAt: result.updatedAt,
|
typeof result.snippet === "string"
|
||||||
rank: result.rank,
|
? result.snippet
|
||||||
highlight: result.highlight,
|
: (result.highlight ?? ""),
|
||||||
spaceId: result.space?.id,
|
score:
|
||||||
spaceName: result.space?.name,
|
typeof result.score === "number"
|
||||||
|
? result.score
|
||||||
|
: typeof result.rank === "number"
|
||||||
|
? result.rank
|
||||||
|
: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
* `insertInlineFootnote` live in `@docmost/prosemirror-markdown` (next to the
|
* `insertInlineFootnote` live in `@docmost/prosemirror-markdown` (next to the
|
||||||
* importer's `assembleFootnotes`, #414), so this file stays a pure mirror.
|
* importer's `assembleFootnotes`, #414), so this file stays a pure mirror.
|
||||||
*
|
*
|
||||||
* Why it exists: every NON-editor write path (markdown import, update_page_json,
|
* Why it exists: every NON-editor write path (markdown import, updatePageJson,
|
||||||
* docmost_transform, insert_footnote) builds ProseMirror JSON directly, so the
|
* docmostTransform, insertFootnote) builds ProseMirror JSON directly, so the
|
||||||
* editor's footnote plugins never run and the canonical topology (sequential
|
* editor's footnote plugins never run and the canonical topology (sequential
|
||||||
* numbering by first reference, one trailing list, no orphans, no raw `[^id]`)
|
* numbering by first reference, one trailing list, no orphans, no raw `[^id]`)
|
||||||
* was never enforced. Running this at the end of every write path closes that
|
* was never enforced. Running this at the end of every write path closes that
|
||||||
@@ -28,8 +28,8 @@
|
|||||||
* `canonicalizeFootnotes(doc)` before writing — the current callers are
|
* `canonicalizeFootnotes(doc)` before writing — the current callers are
|
||||||
* `markdownToProseMirrorCanonical` (page markdown import/update; the plain
|
* `markdownToProseMirrorCanonical` (page markdown import/update; the plain
|
||||||
* `markdownToProseMirror` used for COMMENT bodies must NOT, or it would drop a
|
* `markdownToProseMirror` used for COMMENT bodies must NOT, or it would drop a
|
||||||
* reference-less definition), `update_page_json`, `docmost_transform`,
|
* reference-less definition), `updatePageJson`, `docmostTransform`,
|
||||||
* `insert_footnote`, and `copy_page_content`. Append/prepend FRAGMENT writes MUST
|
* `insertFootnote`, and `copyPageContent`. Append/prepend FRAGMENT writes MUST
|
||||||
* NOT canonicalize. This is deliberately per-call-site (the replace-vs-fragment
|
* NOT canonicalize. This is deliberately per-call-site (the replace-vs-fragment
|
||||||
* and comment-vs-page nuances make a single naive wrapper unsafe).
|
* and comment-vs-page nuances make a single naive wrapper unsafe).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -283,7 +283,7 @@ export function applyTextEdits(
|
|||||||
for (const edit of edits) {
|
for (const edit of edits) {
|
||||||
if (!edit.find) throw new Error("edit.find must be a non-empty string");
|
if (!edit.find) throw new Error("edit.find must be a non-empty string");
|
||||||
|
|
||||||
// HARD-REFUSE formatting changes. edit_page_text edits PLAIN TEXT only and
|
// HARD-REFUSE formatting changes. editPageText edits PLAIN TEXT only and
|
||||||
// writes the replacement verbatim, so it cannot add/remove marks. We refuse
|
// writes the replacement verbatim, so it cannot add/remove marks. We refuse
|
||||||
// only a pure formatting TOGGLE: find and replace differ ONLY by balanced
|
// only a pure formatting TOGGLE: find and replace differ ONLY by balanced
|
||||||
// markdown markers (e.g. find:"~~$69~~" / replace:"$69", or find:"M5Stack" /
|
// markdown markers (e.g. find:"~~$69~~" / replace:"$69", or find:"M5Stack" /
|
||||||
@@ -304,22 +304,22 @@ export function applyTextEdits(
|
|||||||
failed.push({
|
failed.push({
|
||||||
find: edit.find,
|
find: edit.find,
|
||||||
reason:
|
reason:
|
||||||
"edit_page_text edits plain text only and cannot add or remove formatting marks (bold/italic/strike/code/link); it writes the replacement as LITERAL text. This edit looks like a formatting change (markdown markers in find/replace). To change marks, read the block with get_page_json and use patch_node (or update_page_json) to set the node's marks array.",
|
"editPageText edits plain text only and cannot add or remove formatting marks (bold/italic/strike/code/link); it writes the replacement as LITERAL text. This edit looks like a formatting change (markdown markers in find/replace). To change marks, read the block with getPageJson and use patchNode (or updatePageJson) to set the node's marks array.",
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HARD-REFUSE inline footnote tokens (#410). `^[...]` in a `replace` is
|
// HARD-REFUSE inline footnote tokens (#410). `^[...]` in a `replace` is
|
||||||
// markdown that only becomes a real footnote when a whole markdown body is
|
// markdown that only becomes a real footnote when a whole markdown body is
|
||||||
// written (create_page / update_page_content / import_page_markdown). Written
|
// written (createPage / update_page_content / importPageMarkdown). Written
|
||||||
// through edit_page_text it stays a LITERAL string in the text — the exact
|
// through editPageText it stays a LITERAL string in the text — the exact
|
||||||
// failure mode #410 fixes — so refuse it here (defense-in-depth) and point the
|
// failure mode #410 fixes — so refuse it here (defense-in-depth) and point the
|
||||||
// caller at insert_footnote, mirroring the formatting-marker refusal above.
|
// caller at insertFootnote, mirroring the formatting-marker refusal above.
|
||||||
if (/\^\[[\s\S]*?\]/.test(edit.replace)) {
|
if (/\^\[[\s\S]*?\]/.test(edit.replace)) {
|
||||||
failed.push({
|
failed.push({
|
||||||
find: edit.find,
|
find: edit.find,
|
||||||
reason:
|
reason:
|
||||||
"edit_page_text writes the replacement as LITERAL text, so a `^[...]` footnote token does not parse into a real footnote (it would appear verbatim in the page). To add a footnote to existing text, use insert_footnote (anchorText = where, text = the note).",
|
"editPageText writes the replacement as LITERAL text, so a `^[...]` footnote token does not parse into a real footnote (it would appear verbatim in the page). To add a footnote to existing text, use insertFootnote (anchorText = where, text = the note).",
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -381,12 +381,12 @@ export function applyTextEdits(
|
|||||||
let reason: string;
|
let reason: string;
|
||||||
if (existsAcrossAtom) {
|
if (existsAcrossAtom) {
|
||||||
reason =
|
reason =
|
||||||
"match crosses a non-text inline node (image/break/mention); use update_page_json for structural changes.";
|
"match crosses a non-text inline node (image/break/mention); use updatePageJson for structural changes.";
|
||||||
} else {
|
} else {
|
||||||
// Append a bounded "closest text" hint: find the FIRST block that
|
// Append a bounded "closest text" hint: find the FIRST block that
|
||||||
// contains the longest whitespace-delimited token (>= 3 chars) of the
|
// contains the longest whitespace-delimited token (>= 3 chars) of the
|
||||||
// (stripped, then raw) locator, and quote that block's plain text. Shared
|
// (stripped, then raw) locator, and quote that block's plain text. Shared
|
||||||
// with create_comment via closestBlockHint so both give the same hint.
|
// with createComment via closestBlockHint so both give the same hint.
|
||||||
reason = "text not found in the document." + closestBlockHint(blockPlain, edit.find);
|
reason = "text not found in the document." + closestBlockHint(blockPlain, edit.find);
|
||||||
}
|
}
|
||||||
failed.push({ find: edit.find, reason });
|
failed.push({ find: edit.find, reason });
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* Single-BLOCK markdown fragment support for `patch_node` / `insert_node`
|
||||||
|
* (#413). These tools accept EITHER a raw ProseMirror `node` (fine attr/mark
|
||||||
|
* work) OR a `markdown` string (the recommended default): a small markdown
|
||||||
|
* fragment is run through the canonical importer, yielding the SAME topology a
|
||||||
|
* full-page markdown import would — so a block written via markdown is
|
||||||
|
* canonically identical to the same content imported whole (no "second canon").
|
||||||
|
*
|
||||||
|
* The importer produces a full `{type:"doc", content:[...blocks..., footnotesList?]}`.
|
||||||
|
* A fragment write needs the BLOCKS separately from the footnote DEFINITIONS so
|
||||||
|
* the caller can splice the blocks into the live document and merge the
|
||||||
|
* definitions into the page's TAIL footnote list via the existing footnote
|
||||||
|
* machinery (`insertInlineFootnote`'s `appendDefinition` + `canonicalizeFootnotes`).
|
||||||
|
*
|
||||||
|
* Footnote id-collision safety: the importer assigns sequential ids (`fn-1`,
|
||||||
|
* `fn-2`, …) starting from 1 for EVERY fragment, so a fragment's `fn-1` would
|
||||||
|
* collide with an existing page footnote also numbered `fn-1` — and
|
||||||
|
* `canonicalizeFootnotes` matches references to definitions BY id, so the
|
||||||
|
* fragment's reference would silently re-hang onto the page's unrelated
|
||||||
|
* definition. To make the merge safe regardless of the page's current numbering,
|
||||||
|
* every fragment footnote id is REMAPPED to a fresh uuid (via the importer's own
|
||||||
|
* `generateFootnoteId`) across BOTH the references (inside the blocks) and the
|
||||||
|
* definitions before either is handed back. Content-identical notes still merge
|
||||||
|
* downstream via `normalizeAndMergeFootnotes` (content-key), and the whole doc is
|
||||||
|
* renumbered by `canonicalizeFootnotes`, so the caller-visible numbering stays
|
||||||
|
* canonical.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { markdownToProseMirror } from "./collaboration.js";
|
||||||
|
import { generateFootnoteId } from "@docmost/prosemirror-markdown";
|
||||||
|
import { docmostSchema } from "./docmost-schema.js";
|
||||||
|
|
||||||
|
/** True if `value` is a non-null, non-array object. */
|
||||||
|
function isObject(value: any): value is Record<string, any> {
|
||||||
|
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep-walk `node` collecting every footnote id it uses (on `footnoteReference`
|
||||||
|
* and `footnoteDefinition` nodes) and build a stable OLD->NEW remap, minting a
|
||||||
|
* fresh uuid per distinct old id. The map is shared across a fragment's blocks
|
||||||
|
* and definitions so a reference and its definition receive the SAME new id.
|
||||||
|
*/
|
||||||
|
function buildFootnoteIdRemap(nodes: any[]): Map<string, string> {
|
||||||
|
const remap = new Map<string, string>();
|
||||||
|
const visit = (node: any): void => {
|
||||||
|
if (!isObject(node)) return;
|
||||||
|
if (
|
||||||
|
(node.type === "footnoteReference" ||
|
||||||
|
node.type === "footnoteDefinition") &&
|
||||||
|
isObject(node.attrs) &&
|
||||||
|
typeof node.attrs.id === "string" &&
|
||||||
|
node.attrs.id !== ""
|
||||||
|
) {
|
||||||
|
if (!remap.has(node.attrs.id)) {
|
||||||
|
remap.set(node.attrs.id, generateFootnoteId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.content)) {
|
||||||
|
for (const child of node.content) visit(child);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const n of nodes) visit(n);
|
||||||
|
return remap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rewrite every footnote id in `node` IN PLACE using `remap` (deep). */
|
||||||
|
function applyFootnoteIdRemap(node: any, remap: Map<string, string>): void {
|
||||||
|
if (!isObject(node)) return;
|
||||||
|
if (
|
||||||
|
(node.type === "footnoteReference" || node.type === "footnoteDefinition") &&
|
||||||
|
isObject(node.attrs) &&
|
||||||
|
typeof node.attrs.id === "string"
|
||||||
|
) {
|
||||||
|
const next = remap.get(node.attrs.id);
|
||||||
|
if (next) node.attrs.id = next;
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.content)) {
|
||||||
|
for (const child of node.content) applyFootnoteIdRemap(child, remap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a short random block id for an imported block that arrives without one
|
||||||
|
* (the markdown importer emits `attrs.id: null`). Mirrors the mcp `freshId`
|
||||||
|
* convention (base36 random, unique within one document). The patch path then
|
||||||
|
* OVERWRITES the first block's id with the target id; every other block keeps the
|
||||||
|
* fresh id minted here — so a 1 -> N section rewrite yields addressable,
|
||||||
|
* comment-anchorable blocks rather than a run of null-id paragraphs.
|
||||||
|
*/
|
||||||
|
function freshBlockId(): string {
|
||||||
|
return (
|
||||||
|
Math.random().toString(36).slice(2, 12) +
|
||||||
|
Math.random().toString(36).slice(2, 6)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign a fresh id to every top-level block whose `attrs.id` is null/missing,
|
||||||
|
* IN PLACE. Only the block's own id is touched (not descendants — those keep the
|
||||||
|
* importer's structure). Ensures each imported block is independently addressable.
|
||||||
|
*/
|
||||||
|
function assignFreshBlockIds(blocks: any[]): void {
|
||||||
|
for (const b of blocks) {
|
||||||
|
if (!isObject(b)) continue;
|
||||||
|
if (!isObject(b.attrs)) b.attrs = {};
|
||||||
|
if (b.attrs.id == null || b.attrs.id === "") {
|
||||||
|
b.attrs.id = freshBlockId();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The parsed shape of a markdown fragment: its blocks + footnote definitions. */
|
||||||
|
export interface MarkdownFragment {
|
||||||
|
/** Top-level blocks, in order, with the trailing `footnotesList` removed. */
|
||||||
|
blocks: any[];
|
||||||
|
/**
|
||||||
|
* The `footnoteDefinition` nodes lifted from the imported `footnotesList`, with
|
||||||
|
* ids already remapped to match the references left inside `blocks`. Empty when
|
||||||
|
* the fragment used no footnotes.
|
||||||
|
*/
|
||||||
|
definitions: any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a markdown fragment and return its blocks separately from its footnote
|
||||||
|
* definitions, with all footnote ids remapped to fresh uuids (see the file
|
||||||
|
* header). The importer's `^[body]` inline-footnote handling is used verbatim —
|
||||||
|
* `^[...]` in the fragment is a first-class footnote, NOT rejected — so the
|
||||||
|
* markdown path matches the full-page import exactly.
|
||||||
|
*
|
||||||
|
* Throws when the fragment imports to zero blocks (an empty / whitespace-only
|
||||||
|
* markdown string is not a valid block write).
|
||||||
|
*/
|
||||||
|
export async function importMarkdownFragment(
|
||||||
|
markdown: string,
|
||||||
|
): Promise<MarkdownFragment> {
|
||||||
|
const doc = await markdownToProseMirror(markdown);
|
||||||
|
const content: any[] = Array.isArray(doc?.content) ? doc.content : [];
|
||||||
|
|
||||||
|
const blocks: any[] = [];
|
||||||
|
const definitions: any[] = [];
|
||||||
|
for (const node of content) {
|
||||||
|
if (isObject(node) && node.type === "footnotesList") {
|
||||||
|
// Lift the definitions out of the list; the list wrapper itself is
|
||||||
|
// reconstructed on the page by the canonicalizer after the merge.
|
||||||
|
if (Array.isArray(node.content)) {
|
||||||
|
for (const def of node.content) {
|
||||||
|
if (isObject(def) && def.type === "footnoteDefinition") {
|
||||||
|
definitions.push(def);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
blocks.push(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blocks.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
"markdown fragment produced no blocks — provide non-empty markdown, or use `node` for a raw ProseMirror node",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remap footnote ids across BOTH blocks and definitions so a fragment `fn-1`
|
||||||
|
// cannot collide with a page footnote of the same number.
|
||||||
|
const remap = buildFootnoteIdRemap([...blocks, ...definitions]);
|
||||||
|
if (remap.size > 0) {
|
||||||
|
for (const b of blocks) applyFootnoteIdRemap(b, remap);
|
||||||
|
for (const d of definitions) applyFootnoteIdRemap(d, remap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every top-level block needs a stable id (the importer leaves them null). The
|
||||||
|
// patch path OVERWRITES the first block's id with the target id afterwards.
|
||||||
|
assignFreshBlockIds(blocks);
|
||||||
|
|
||||||
|
return { blocks, definitions };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `type` is a valid TOP-LEVEL child of the document node per the
|
||||||
|
* canonical schema's content model — i.e. `get_node` can serialize it to
|
||||||
|
* markdown by wrapping it in `{type:"doc",content:[node]}`. Derived from the
|
||||||
|
* schema's `doc` contentMatch (NOT a hand-written type list) so it tracks the
|
||||||
|
* schema automatically: `tableRow`/`tableCell`/`tableHeader` (addressed only via
|
||||||
|
* `#<index>`) are NOT doc children and yield false, so `get_node` auto-falls back
|
||||||
|
* to JSON for them.
|
||||||
|
*/
|
||||||
|
export function canBeDocChild(type: string | undefined): boolean {
|
||||||
|
if (typeof type !== "string") return false;
|
||||||
|
const nodeType = docmostSchema.nodes[type];
|
||||||
|
if (!nodeType) return false;
|
||||||
|
return docmostSchema.nodes.doc.contentMatch.matchType(nodeType) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table-cell attributes that CANNOT survive a markdown round-trip: the converter
|
||||||
|
* emits colspan/rowspan (and align) as HTML `<table>` cell attrs, but silently
|
||||||
|
* drops `colwidth`, `backgroundColor`, and `backgroundColorName`. A markdown
|
||||||
|
* `patch_node` on a block that carries any of these (a merged / colored /
|
||||||
|
* fixed-width cell) would therefore lose them — so it is REJECTED, pointing the
|
||||||
|
* caller at the table tools or the raw-`node` JSON path. `align` is intentionally
|
||||||
|
* absent: it round-trips as GFM alignment.
|
||||||
|
*/
|
||||||
|
function cellCarriesUnrepresentableAttrs(node: any): boolean {
|
||||||
|
if (!isObject(node)) return false;
|
||||||
|
if (node.type !== "tableCell" && node.type !== "tableHeader") return false;
|
||||||
|
const a = isObject(node.attrs) ? node.attrs : {};
|
||||||
|
if ((a.colspan ?? 1) > 1) return true;
|
||||||
|
if ((a.rowspan ?? 1) > 1) return true;
|
||||||
|
if (a.colwidth != null) return true;
|
||||||
|
if (a.backgroundColor != null) return true;
|
||||||
|
if (a.backgroundColorName != null) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan a target block (the node being replaced) for any table cell carrying an
|
||||||
|
* attribute markdown cannot represent (colspan/rowspan/colwidth/background). When
|
||||||
|
* one is found, return a human-readable list of the offending attr NAMES so the
|
||||||
|
* caller can build an actionable rejection message; return null when the block is
|
||||||
|
* safe to rewrite from markdown. Deep — a colored cell nested inside a table
|
||||||
|
* inside a callout is still caught.
|
||||||
|
*/
|
||||||
|
export function findUnrepresentableTableAttrs(node: any): string | null {
|
||||||
|
const found = new Set<string>();
|
||||||
|
const visit = (n: any): void => {
|
||||||
|
if (!isObject(n)) return;
|
||||||
|
if (cellCarriesUnrepresentableAttrs(n)) {
|
||||||
|
const a = isObject(n.attrs) ? n.attrs : {};
|
||||||
|
if ((a.colspan ?? 1) > 1) found.add("colspan");
|
||||||
|
if ((a.rowspan ?? 1) > 1) found.add("rowspan");
|
||||||
|
if (a.colwidth != null) found.add("colwidth");
|
||||||
|
if (a.backgroundColor != null) found.add("backgroundColor");
|
||||||
|
if (a.backgroundColorName != null) found.add("backgroundColorName");
|
||||||
|
}
|
||||||
|
if (Array.isArray(n.content)) {
|
||||||
|
for (const child of n.content) visit(child);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(node);
|
||||||
|
return found.size > 0 ? Array.from(found).sort().join(", ") : null;
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
*
|
*
|
||||||
* `searchInDoc(doc, query, opts)` finds every occurrence of a literal substring
|
* `searchInDoc(doc, query, opts)` finds every occurrence of a literal substring
|
||||||
* (default) or a regular expression across the page's TEXT CONTAINERS and
|
* (default) or a regular expression across the page's TEXT CONTAINERS and
|
||||||
* reports WHERE each match is — the container's ref (for get_node/patch_node;
|
* reports WHERE each match is — the container's ref (for getNode/patchNode;
|
||||||
* see the SearchMatch.nodeId note for the `#<index>` caveat), the top-level
|
* see the SearchMatch.nodeId note for the `#<index>` caveat), the top-level
|
||||||
* block index, and a short context window around the hit. It never touches the
|
* block index, and a short context window around the hit. It never touches the
|
||||||
* network, the DB, or the schema mirror; like `comment-anchor.ts` it is
|
* network, the DB, or the schema mirror; like `comment-anchor.ts` it is
|
||||||
@@ -69,24 +69,24 @@ export interface SearchOptions {
|
|||||||
/** One located occurrence. */
|
/** One located occurrence. */
|
||||||
export interface SearchMatch {
|
export interface SearchMatch {
|
||||||
/**
|
/**
|
||||||
* The container's ref, for addressing the block with get_node/patch_node: its
|
* The container's ref, for addressing the block with getNode/patchNode: its
|
||||||
* `attrs.id` when it has one, otherwise `#<topLevelIndex>` of the nearest
|
* `attrs.id` when it has one, otherwise `#<topLevelIndex>` of the nearest
|
||||||
* top-level block. Table-cell/list-item paragraphs that carry no id fall back
|
* top-level block. Table-cell/list-item paragraphs that carry no id fall back
|
||||||
* to the `#<index>` form.
|
* to the `#<index>` form.
|
||||||
*
|
*
|
||||||
* CAVEAT: the `#<index>` form is accepted by get_node (getNodeByRef resolves
|
* CAVEAT: the `#<index>` form is accepted by getNode (getNodeByRef resolves
|
||||||
* it by top-level index) but NOT by patch_node (replaceNodeById resolves only
|
* it by top-level index) but NOT by patchNode (replaceNodeById resolves only
|
||||||
* by `attrs.id`), so id-less table/cell content can be READ by this ref but
|
* by `attrs.id`), so id-less table/cell content can be READ by this ref but
|
||||||
* not PATCHED by it.
|
* not PATCHED by it.
|
||||||
*
|
*
|
||||||
* To anchor a comment, do NOT pass this ref to create_comment — it has no
|
* To anchor a comment, do NOT pass this ref to createComment — it has no
|
||||||
* nodeId parameter. A top-level comment needs an exact-text `selection` that
|
* nodeId parameter. A top-level comment needs an exact-text `selection` that
|
||||||
* occurs once on the page (it fails if the text isn't found), so build a
|
* occurs once on the page (it fails if the text isn't found), so build a
|
||||||
* UNIQUE `selection` from before+match+after and pass THAT as create_comment's
|
* UNIQUE `selection` from before+match+after and pass THAT as createComment's
|
||||||
* `selection`.
|
* `selection`.
|
||||||
*/
|
*/
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
/** The top-level block index (as in get_outline). */
|
/** The top-level block index (as in getOutline). */
|
||||||
blockIndex: number;
|
blockIndex: number;
|
||||||
/** The container node's type (paragraph/heading/...). */
|
/** The container node's type (paragraph/heading/...). */
|
||||||
type: string | undefined;
|
type: string | undefined;
|
||||||
@@ -188,12 +188,12 @@ export function searchInDoc(
|
|||||||
// --- edge-case guards (fail loudly so the agent can correct the call) ---
|
// --- edge-case guards (fail loudly so the agent can correct the call) ---
|
||||||
if (typeof query !== "string" || query.trim().length === 0) {
|
if (typeof query !== "string" || query.trim().length === 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"search_in_page: query is empty — pass the text (or regex) to look for.",
|
"searchInPage: query is empty — pass the text (or regex) to look for.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (query.length > MAX_PATTERN_LENGTH) {
|
if (query.length > MAX_PATTERN_LENGTH) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`search_in_page: query is too long (${query.length} chars; max ${MAX_PATTERN_LENGTH}). Shorten the search text/pattern.`,
|
`searchInPage: query is too long (${query.length} chars; max ${MAX_PATTERN_LENGTH}). Shorten the search text/pattern.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +212,7 @@ export function searchInDoc(
|
|||||||
re = new RE2(query, caseSensitive ? "g" : "gi");
|
re = new RE2(query, caseSensitive ? "g" : "gi");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`search_in_page: invalid or unsupported regular expression: ${
|
`searchInPage: invalid or unsupported regular expression: ${
|
||||||
e instanceof Error ? e.message : String(e)
|
e instanceof Error ? e.message : String(e)
|
||||||
} — RE2 does not support lookaround ((?=…)/(?<=…)) or backreferences (\\1); rewrite the pattern without them.`,
|
} — RE2 does not support lookaround ((?=…)/(?<=…)) or backreferences (\\1); rewrite the pattern without them.`,
|
||||||
);
|
);
|
||||||
@@ -237,9 +237,9 @@ export function searchInDoc(
|
|||||||
// in a very long container.
|
// in a very long container.
|
||||||
const text = blockPlainText(node);
|
const text = blockPlainText(node);
|
||||||
|
|
||||||
// The container's own id addresses it verbatim in get_node/patch_node; a
|
// The container's own id addresses it verbatim in getNode/patchNode; a
|
||||||
// container with no id (e.g. a table-cell paragraph) falls back to the
|
// container with no id (e.g. a table-cell paragraph) falls back to the
|
||||||
// top-level block's #<index> (readable via get_node, but not patchable —
|
// top-level block's #<index> (readable via getNode, but not patchable —
|
||||||
// see the SearchMatch.nodeId note).
|
// see the SearchMatch.nodeId note).
|
||||||
const id =
|
const id =
|
||||||
isObject(node.attrs) && typeof node.attrs.id === "string" && node.attrs.id.length > 0
|
isObject(node.attrs) && typeof node.attrs.id === "string" && node.attrs.id.length > 0
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ export function stripInlineMarkdown(s: string): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a bounded "closest text" hint for an anchor/find MISS, shared by
|
* Build a bounded "closest text" hint for an anchor/find MISS, shared by
|
||||||
* edit_page_text (json-edit) and create_comment (client) so both surface the
|
* editPageText (json-edit) and createComment (client) so both surface the
|
||||||
* same self-correction affordance.
|
* same self-correction affordance.
|
||||||
*
|
*
|
||||||
* Take the longest whitespace-delimited token (>= 3 chars) of the locator
|
* Take the longest whitespace-delimited token (>= 3 chars) of the locator
|
||||||
|
|||||||
@@ -740,7 +740,7 @@ export function insertInlineFootnote(
|
|||||||
// subtree, so a reference is never glued inside an existing definition (which
|
// subtree, so a reference is never glued inside an existing definition (which
|
||||||
// the canonicalizer would then drop as an orphan, losing that definition's
|
// the canonicalizer would then drop as an orphan, losing that definition's
|
||||||
// prose); and forbidBlockTypes refuses codeBlocks (an inline atom there is a
|
// prose); and forbidBlockTypes refuses codeBlocks (an inline atom there is a
|
||||||
// schema-invalid doc; insert_footnote skips validateDocStructure).
|
// schema-invalid doc; insertFootnote skips validateDocStructure).
|
||||||
// When the only anchor match is in such a place, the insert is refused and the
|
// When the only anchor match is in such a place, the insert is refused and the
|
||||||
// write aborts cleanly (inserted:false) instead of destroying content.
|
// write aborts cleanly (inserted:false) instead of destroying content.
|
||||||
const boundaryIdx = Array.isArray(doc?.content)
|
const boundaryIdx = Array.isArray(doc?.content)
|
||||||
@@ -774,6 +774,61 @@ export function insertInlineFootnote(
|
|||||||
return { doc: working, inserted: true, footnoteId, reused };
|
return { doc: working, inserted: true, footnoteId, reused };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge an ARRAY of footnote definitions (e.g. the definitions lifted from an
|
||||||
|
* imported markdown FRAGMENT) into `doc`\'s footnote list, then re-derive the
|
||||||
|
* canonical footnote topology — the SAME two-step machinery `insertInlineFootnote`
|
||||||
|
* uses (`appendDefinition` -> `normalizeAndMergeFootnotes` -> `canonicalizeFootnotes`).
|
||||||
|
*
|
||||||
|
* The fragment\'s `footnoteReference` nodes are assumed to ALREADY be spliced into
|
||||||
|
* `doc` (inside the just-inserted blocks) with ids matching these definitions, so
|
||||||
|
* after appending the definitions the canonicalizer orders/numbers everything by
|
||||||
|
* first-reference order, merges content-identical notes, and drops any orphan.
|
||||||
|
* Same documented caveat as every other write path: full canonicalization drops a
|
||||||
|
* definition no reference points at.
|
||||||
|
*
|
||||||
|
* NOT merely a no-op when `definitions` is empty: it still canonicalizes when
|
||||||
|
* the (post-splice) `doc` carries footnote artifacts (a `footnotesList` or any
|
||||||
|
* `footnoteReference`), so a splice that removed the LAST referrer of a page
|
||||||
|
* footnote drops the now-orphaned definition — matching a full page re-import
|
||||||
|
* (which always canonicalizes) and preserving the "canonically identical to the
|
||||||
|
* same content imported whole" invariant. A truly footnote-free doc (no artifacts
|
||||||
|
* and no definitions) is returned untouched — the fast path, no clone. When the
|
||||||
|
* work runs it goes through the pure passes (which clone), so the caller\'s `doc`
|
||||||
|
* is not mutated.
|
||||||
|
*/
|
||||||
|
export function mergeFootnoteDefinitions(doc: any, definitions: any[]): any {
|
||||||
|
const defs = Array.isArray(definitions) ? definitions : [];
|
||||||
|
// True fast path ONLY when there is nothing to merge AND nothing to canonicalize
|
||||||
|
// away; otherwise fall through so an orphan left by a splice is still dropped.
|
||||||
|
if (defs.length === 0 && !hasFootnoteArtifacts(doc)) return doc;
|
||||||
|
// Clone before appending: `appendDefinition` mutates in place, and the caller
|
||||||
|
// must not see a half-merged doc if a later pass throws.
|
||||||
|
let working = clone(doc);
|
||||||
|
for (const def of defs) {
|
||||||
|
appendDefinition(working, def);
|
||||||
|
}
|
||||||
|
// #419: normalize + merge glyph-forked definitions before canonicalizing.
|
||||||
|
working = normalizeAndMergeFootnotes(working);
|
||||||
|
working = canonicalizeFootnotes(working);
|
||||||
|
return working;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if `doc`'s tree contains any `footnotesList` node OR any
|
||||||
|
* `footnoteReference` node. Used to decide whether an empty-`definitions` merge
|
||||||
|
* must still canonicalize (to drop an orphan a splice left behind).
|
||||||
|
*/
|
||||||
|
function hasFootnoteArtifacts(doc: any): boolean {
|
||||||
|
let found = false;
|
||||||
|
walk(doc, (n) => {
|
||||||
|
if (isObject(n) && (n.type === "footnotesList" || n.type === "footnoteReference")) {
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Append a definition node so the canonicalizer can order/place it: into the
|
* Append a definition node so the canonicalizer can order/place it: into the
|
||||||
* first existing footnotesList, or a new trailing list when none exists.
|
* first existing footnotesList, or a new trailing list when none exists.
|
||||||
|
|||||||
@@ -1,11 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Options for `buildPageTree`. Fully OPTIONAL so the existing call form
|
||||||
|
* `buildPageTree(nodes)` keeps its historic behaviour (lean `{id, slugId,
|
||||||
|
* title, children?}` output, no depth cut) unchanged.
|
||||||
|
*
|
||||||
|
* - `shape: "getTree"` — emit the #443 `getTree` output node shape
|
||||||
|
* `{pageId, title, children?, hasChildren?}` instead of the lean
|
||||||
|
* `{id, slugId, title, children?}` shape. `slugId`/`icon`/`position` are
|
||||||
|
* never exposed (INVARIANT: only the UUID `pageId` leaves the MCP layer).
|
||||||
|
* - `maxDepth` — trim the built tree to this many levels (root nodes are
|
||||||
|
* depth 1). Only meaningful together with `shape: "getTree"` (the lean shape
|
||||||
|
* has no `hasChildren` to signal a cut). See the depth logic below.
|
||||||
|
*/
|
||||||
|
export interface BuildPageTreeOptions {
|
||||||
|
shape?: "lean" | "getTree";
|
||||||
|
maxDepth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure tree-builder: turn a flat array of sidebar-style page nodes (as produced
|
* Pure tree-builder: turn a flat array of sidebar-style page nodes (as produced
|
||||||
* by `enumerateSpacePages`) into a nested tree.
|
* by `enumerateSpacePages`) into a nested tree.
|
||||||
*
|
*
|
||||||
* Input: a flat array of nodes. Each node is expected to carry at least
|
* Input: a flat array of nodes. Each node is expected to carry at least
|
||||||
* { id, slugId, title, position, parentPageId } (extra fields are ignored).
|
* { id, slugId, title, position, parentPageId } (extra fields are ignored),
|
||||||
|
* plus a server `hasChildren` boolean used by the `getTree` shape below.
|
||||||
*
|
*
|
||||||
* Output: an array of ROOT nodes, each shaped as
|
* Output (default / `shape: "lean"`): an array of ROOT nodes, each shaped as
|
||||||
* { id, slugId, title, children? }
|
* { id, slugId, title, children? }
|
||||||
* where `children` is the array of child nodes (same shape, recursively). The
|
* where `children` is the array of child nodes (same shape, recursively). The
|
||||||
* `children` key is OMITTED entirely when a node has no children — consistent
|
* `children` key is OMITTED entirely when a node has no children — consistent
|
||||||
@@ -13,6 +32,14 @@
|
|||||||
* lean (nesting alone conveys the structure; parentPageId/position/hasChildren
|
* lean (nesting alone conveys the structure; parentPageId/position/hasChildren
|
||||||
* are intentionally dropped from the output).
|
* are intentionally dropped from the output).
|
||||||
*
|
*
|
||||||
|
* Output (`shape: "getTree"`, the #443 tool shape): each node is
|
||||||
|
* { pageId, title, children?, hasChildren? }
|
||||||
|
* — the server `id` is exposed as `pageId` (never `slugId`/`icon`/`position`).
|
||||||
|
* `children` is omitted for leaves and for nodes trimmed by `maxDepth`.
|
||||||
|
* `hasChildren: true` is set ONLY on a node whose children exist on the server
|
||||||
|
* (per the flat item's `hasChildren`) but were CUT by `maxDepth`; on leaves and
|
||||||
|
* on fully-expanded interior nodes the field is omitted (see `maxDepth` below).
|
||||||
|
*
|
||||||
* Linking rule: a node is attached as a child of `parentPageId` only when that
|
* Linking rule: a node is attached as a child of `parentPageId` only when that
|
||||||
* parent id is actually present in the input. Otherwise — including a null /
|
* parent id is actually present in the input. Otherwise — including a null /
|
||||||
* undefined `parentPageId`, or a parent that was capped out of the bounded walk
|
* undefined `parentPageId`, or a parent that was capped out of the bounded walk
|
||||||
@@ -26,18 +53,42 @@
|
|||||||
* fractional-index ASCII keys (e.g. "a0", "a1"). Nodes with a missing/undefined
|
* fractional-index ASCII keys (e.g. "a0", "a1"). Nodes with a missing/undefined
|
||||||
* `position` sort last.
|
* `position` sort last.
|
||||||
*
|
*
|
||||||
|
* maxDepth (getTree shape only): the tree is built in FULL first, then trimmed
|
||||||
|
* on the way out. Root nodes are depth 1. `maxDepth: N` keeps nodes at depth
|
||||||
|
* <= N and drops the `children` of any node AT depth N. A node whose children
|
||||||
|
* were dropped this way gets `hasChildren: true` when it actually had children
|
||||||
|
* in the flat input (source of truth = the server `hasChildren` flag), so the
|
||||||
|
* caller knows it can descend further with a follow-up `rootPageId` call. An
|
||||||
|
* absent/undefined `maxDepth` means no cut (whole tree). `maxDepth <= 0` is
|
||||||
|
* treated as "no cut" (defensive; the tool schema clamps to >= 1).
|
||||||
|
*
|
||||||
* Pure: no I/O, no network, deterministic.
|
* Pure: no I/O, no network, deterministic.
|
||||||
*/
|
*/
|
||||||
export function buildPageTree(nodes: any[]): any[] {
|
export function buildPageTree(
|
||||||
type OutputNode = {
|
nodes: any[],
|
||||||
|
options: BuildPageTreeOptions = {},
|
||||||
|
): any[] {
|
||||||
|
const getTreeShape = options.shape === "getTree";
|
||||||
|
// A finite, positive cut only; anything else means "no cut".
|
||||||
|
const maxDepth =
|
||||||
|
typeof options.maxDepth === "number" &&
|
||||||
|
Number.isFinite(options.maxDepth) &&
|
||||||
|
options.maxDepth > 0
|
||||||
|
? Math.floor(options.maxDepth)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
type InternalNode = {
|
||||||
id: string;
|
id: string;
|
||||||
|
// Retained internally for shaping; never all emitted at once.
|
||||||
slugId: any;
|
slugId: any;
|
||||||
title: any;
|
title: any;
|
||||||
children?: OutputNode[];
|
hasServerChildren: boolean;
|
||||||
|
children?: InternalNode[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map id -> output node. Build the lean output shape up front.
|
// Map id -> internal node. Build up front; the output shape is projected at
|
||||||
const byId = new Map<string, OutputNode>();
|
// the very end so the maxDepth cut can consult `hasServerChildren`.
|
||||||
|
const byId = new Map<string, InternalNode>();
|
||||||
// Preserve the original position string for sorting (kept off the output).
|
// Preserve the original position string for sorting (kept off the output).
|
||||||
const positionById = new Map<string, string | undefined>();
|
const positionById = new Map<string, string | undefined>();
|
||||||
|
|
||||||
@@ -49,6 +100,7 @@ export function buildPageTree(nodes: any[]): any[] {
|
|||||||
id: node.id,
|
id: node.id,
|
||||||
slugId: node.slugId,
|
slugId: node.slugId,
|
||||||
title: node.title,
|
title: node.title,
|
||||||
|
hasServerChildren: node.hasChildren === true,
|
||||||
});
|
});
|
||||||
positionById.set(node.id, node.position);
|
positionById.set(node.id, node.position);
|
||||||
}
|
}
|
||||||
@@ -90,5 +142,30 @@ export function buildPageTree(nodes: any[]): any[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
roots.sort(byPosition);
|
roots.sort(byPosition);
|
||||||
return roots.map((id) => byId.get(id)!);
|
const rootNodes = roots.map((id) => byId.get(id)!);
|
||||||
|
|
||||||
|
// Project the internal nodes into the requested OUTPUT shape, applying the
|
||||||
|
// maxDepth cut for the getTree shape. `depth` is 1-based (roots = depth 1).
|
||||||
|
const project = (node: InternalNode, depth: number): any => {
|
||||||
|
if (getTreeShape) {
|
||||||
|
const out: any = { pageId: node.id, title: node.title };
|
||||||
|
const atCut = maxDepth !== undefined && depth >= maxDepth;
|
||||||
|
if (!atCut && node.children && node.children.length > 0) {
|
||||||
|
out.children = node.children.map((c) => project(c, depth + 1));
|
||||||
|
} else if (atCut && node.hasServerChildren) {
|
||||||
|
// Children exist on the server but were trimmed by maxDepth: signal it
|
||||||
|
// so the caller can descend with a follow-up rootPageId call.
|
||||||
|
out.hasChildren = true;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// Lean (historic) shape: cycle-safe, no depth cut, no hasChildren.
|
||||||
|
const out: any = { id: node.id, slugId: node.slugId, title: node.title };
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
out.children = node.children.map((c) => project(c, depth + 1));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
return rootNodes.map((n) => project(n, 1));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,11 +40,11 @@ import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";
|
|||||||
*/
|
*/
|
||||||
export const ROUTING_PROSE =
|
export const ROUTING_PROSE =
|
||||||
"Docmost editing guide — choose the tool by intent. The <tool_inventory> at the end lists every tool with a one-line purpose; the notes below are the routing hints for WHEN to reach for each.\n" +
|
"Docmost editing guide — choose the tool by intent. The <tool_inventory> at the end lists every tool with a one-line purpose; the notes below are the routing hints for WHEN to reach for each.\n" +
|
||||||
"READ: find a page -> 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" +
|
"READ: find a page by a fragment of a technical string (hostname/IP/ID like srv.local, 10.0.12, WB-MGE-30D86B) -> search — hybrid substring + full-text, returns each hit's location (path: root->parent titles) and a snippet around the match, so you rarely need a follow-up getPage; scope with spaceId or parentPageId (a subtree), titleOnly to match titles only. A space's page HIERARCHY (or one subtree) -> getTree (one request, complete, `{pageId,title,children?}`; rootPageId for a subtree, maxDepth to trim depth — a trimmed node gets hasChildren:true); prefer it over listPages tree:true (deprecated). Have a pageId, need WHERE-AM-I / what's around it (its breadcrumbs + direct children, metadata only) -> getPageContext (one call; parent = last breadcrumb, [] for a root page). list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block, for editing -> getNode (by attrs.id, or \"#<index>\" for tables, which carry no id) — returns MARKDOWN by default (comment anchors kept for safe write-back); pass format:\"json\" for the raw ProseMirror subtree. Find every occurrence of a string/regex ON a page (and where each is) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, canonical for text; drops only block ids, resolved-comment anchors, and a fixed no-md-representation attr set: table spans/colwidth/bg, indent, callout.icon, orderedList.type, link internal/target/rel/class; inline <span data-comment-id> tags are comment anchors — markup, not text) or getPageJson (full ProseMirror with block ids, for those dropped attrs). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (returns a short-lived anonymous URL).\n" +
|
||||||
"EDIT: fix wording/typos/numbers -> 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); before authoring a diagram, drawio_shapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawio_guide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawio_create/drawio_update to auto-place nodes. Footnotes -> insert_footnote. Bulk/structural rewrite -> update_page_json (full ProseMirror replace) or update_page_markdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> 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 -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#<index>\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> drawioCreate (create from mxGraph XML and insert), drawioGet (read a diagram as mxGraph XML + a hash), drawioUpdate (replace a diagram; pass the hash from drawioGet as baseHash for optimistic locking); before authoring a diagram, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
|
||||||
"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" +
|
"PAGES: new -> createPage (Markdown). Rename (title only) -> renamePage. Move -> movePage. Delete -> deletePage (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copyPageContent. Sharing -> sharePage / unsharePage / listShares; sharePage makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
|
||||||
"COMMENTS: 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" +
|
"COMMENTS: createComment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> createComment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> listComments, updateComment, resolveComment (resolve/reopen, reversible — prefer over delete to close), deleteComment, checkNewComments.\n" +
|
||||||
"HISTORY: review what changed -> 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). Export a page to self-contained Docmost Markdown (with comment anchors) -> export_page_markdown.";
|
"HISTORY: review what changed -> diffPageVersions (a historyId vs current, or two versions). List saved versions -> listPageHistory. Undo a bad edit -> restorePageVersion (writes a past version back as current; itself revertible). Export a page to self-contained Docmost Markdown (with comment anchors) -> exportPageMarkdown.";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A single generated inventory line: the tool's registered NAME + a one-line
|
* A single generated inventory line: the tool's registered NAME + a one-line
|
||||||
@@ -81,57 +81,59 @@ type Family = (typeof FAMILY_ORDER)[number];
|
|||||||
const TOOL_FAMILY: Record<string, Family> = {
|
const TOOL_FAMILY: Record<string, Family> = {
|
||||||
// READ
|
// READ
|
||||||
search: "READ",
|
search: "READ",
|
||||||
list_pages: "READ",
|
listPages: "READ",
|
||||||
list_spaces: "READ",
|
getTree: "READ",
|
||||||
get_outline: "READ",
|
getPageContext: "READ",
|
||||||
get_node: "READ",
|
listSpaces: "READ",
|
||||||
search_in_page: "READ",
|
getOutline: "READ",
|
||||||
get_page: "READ",
|
getNode: "READ",
|
||||||
get_page_json: "READ",
|
searchInPage: "READ",
|
||||||
get_workspace: "READ",
|
getPage: "READ",
|
||||||
stash_page: "READ",
|
getPageJson: "READ",
|
||||||
|
getWorkspace: "READ",
|
||||||
|
stashPage: "READ",
|
||||||
// EDIT
|
// EDIT
|
||||||
edit_page_text: "EDIT",
|
editPageText: "EDIT",
|
||||||
patch_node: "EDIT",
|
patchNode: "EDIT",
|
||||||
insert_node: "EDIT",
|
insertNode: "EDIT",
|
||||||
delete_node: "EDIT",
|
deleteNode: "EDIT",
|
||||||
update_page_json: "EDIT",
|
updatePageJson: "EDIT",
|
||||||
update_page_markdown: "EDIT",
|
updatePageMarkdown: "EDIT",
|
||||||
table_get: "EDIT",
|
tableGet: "EDIT",
|
||||||
table_update_cell: "EDIT",
|
tableUpdateCell: "EDIT",
|
||||||
table_insert_row: "EDIT",
|
tableInsertRow: "EDIT",
|
||||||
table_delete_row: "EDIT",
|
tableDeleteRow: "EDIT",
|
||||||
insert_image: "EDIT",
|
insertImage: "EDIT",
|
||||||
replace_image: "EDIT",
|
replaceImage: "EDIT",
|
||||||
insert_footnote: "EDIT",
|
insertFootnote: "EDIT",
|
||||||
drawio_get: "EDIT",
|
drawioGet: "EDIT",
|
||||||
drawio_create: "EDIT",
|
drawioCreate: "EDIT",
|
||||||
drawio_update: "EDIT",
|
drawioUpdate: "EDIT",
|
||||||
drawio_shapes: "EDIT",
|
drawioShapes: "EDIT",
|
||||||
drawio_guide: "EDIT",
|
drawioGuide: "EDIT",
|
||||||
docmost_transform: "EDIT",
|
docmostTransform: "EDIT",
|
||||||
// PAGES
|
// PAGES
|
||||||
create_page: "PAGES",
|
createPage: "PAGES",
|
||||||
rename_page: "PAGES",
|
renamePage: "PAGES",
|
||||||
move_page: "PAGES",
|
movePage: "PAGES",
|
||||||
delete_page: "PAGES",
|
deletePage: "PAGES",
|
||||||
copy_page_content: "PAGES",
|
copyPageContent: "PAGES",
|
||||||
share_page: "PAGES",
|
sharePage: "PAGES",
|
||||||
unshare_page: "PAGES",
|
unsharePage: "PAGES",
|
||||||
list_shares: "PAGES",
|
listShares: "PAGES",
|
||||||
// COMMENTS
|
// COMMENTS
|
||||||
create_comment: "COMMENTS",
|
createComment: "COMMENTS",
|
||||||
list_comments: "COMMENTS",
|
listComments: "COMMENTS",
|
||||||
update_comment: "COMMENTS",
|
updateComment: "COMMENTS",
|
||||||
resolve_comment: "COMMENTS",
|
resolveComment: "COMMENTS",
|
||||||
delete_comment: "COMMENTS",
|
deleteComment: "COMMENTS",
|
||||||
check_new_comments: "COMMENTS",
|
checkNewComments: "COMMENTS",
|
||||||
// HISTORY
|
// HISTORY
|
||||||
diff_page_versions: "HISTORY",
|
diffPageVersions: "HISTORY",
|
||||||
list_page_history: "HISTORY",
|
listPageHistory: "HISTORY",
|
||||||
restore_page_version: "HISTORY",
|
restorePageVersion: "HISTORY",
|
||||||
export_page_markdown: "HISTORY",
|
exportPageMarkdown: "HISTORY",
|
||||||
// import_page_markdown is now inAppOnly (#411) — it is not registered on the
|
// importPageMarkdown is now inAppOnly (#411) — it is not registered on the
|
||||||
// external MCP host, so it no longer appears in the generated inventory.
|
// external MCP host, so it no longer appears in the generated inventory.
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -145,26 +147,26 @@ const TOOL_FAMILY: Record<string, Family> = {
|
|||||||
*/
|
*/
|
||||||
export const INLINE_MCP_INVENTORY: ToolInventoryLine[] = [
|
export const INLINE_MCP_INVENTORY: ToolInventoryLine[] = [
|
||||||
{
|
{
|
||||||
name: "table_get",
|
name: "tableGet",
|
||||||
purpose:
|
purpose:
|
||||||
"read a table as a matrix of cell texts + per-cell paragraph ids.",
|
"read a table as a matrix of cell texts + per-cell paragraph ids.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "search",
|
name: "search",
|
||||||
purpose:
|
purpose:
|
||||||
"full-text search for pages and content across the whole workspace.",
|
"find pages by a fragment of a technical string (hybrid substring + full-text); returns each hit's path and a snippet.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "docmost_transform",
|
name: "docmostTransform",
|
||||||
purpose:
|
purpose:
|
||||||
"edit a page by running a sandboxed JS `(doc, ctx) => doc` transform, with a dryRun diff preview.",
|
"edit a page by running a sandboxed JS `(doc, ctx) => doc` transform, with a dryRun diff preview.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "update_comment",
|
name: "updateComment",
|
||||||
purpose: "update an existing comment's content (creator only).",
|
purpose: "update an existing comment's content (creator only).",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "delete_comment",
|
name: "deleteComment",
|
||||||
purpose: "delete a comment (creator or space admin only).",
|
purpose: "delete a comment (creator or space admin only).",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
+310
-159
@@ -8,7 +8,7 @@
|
|||||||
// z.array() and z.object() — API identical across v3 and v4 — so a single
|
// z.array() and z.object() — API identical across v3 and v4 — so a single
|
||||||
// builder works with either namespace.
|
// builder works with either namespace.
|
||||||
//
|
//
|
||||||
// Only tools whose snake_case/camelCase name, input schema AND model-facing
|
// Only tools whose camelCase name, input schema AND model-facing
|
||||||
// description are genuinely identical across both layers live here. Tools that
|
// description are genuinely identical across both layers live here. Tools that
|
||||||
// diverge on purpose (security guardrails, tuned UX, "Reversible" framing on
|
// diverge on purpose (security guardrails, tuned UX, "Reversible" framing on
|
||||||
// some write tools, different limits, hybrid-RRF search, etc.) stay defined
|
// some write tools, different limits, hybrid-RRF search, etc.) stay defined
|
||||||
@@ -29,8 +29,8 @@
|
|||||||
// one of them. Each builder uses only the common, stable subset of the API.
|
// one of them. Each builder uses only the common, stable subset of the API.
|
||||||
type ZodLike = any;
|
type ZodLike = any;
|
||||||
|
|
||||||
// The `node` normalizer shared by BOTH hosts (patch_node / insert_node /
|
// The `node` normalizer shared by BOTH hosts (patchNode / insertNode /
|
||||||
// update_page_json): the model sometimes serializes a ProseMirror node arg as a
|
// updatePageJson): the model sometimes serializes a ProseMirror node arg as a
|
||||||
// JSON string, so we parse a string to an object (throwing a documented message
|
// JSON string, so we parse a string to an object (throwing a documented message
|
||||||
// on invalid JSON) and pass an object through. It lives in the converter package
|
// on invalid JSON) and pass an object through. It lives in the converter package
|
||||||
// (#414) so it is the ONE copy both the MCP server and the in-app server import;
|
// (#414) so it is the ONE copy both the MCP server and the in-app server import;
|
||||||
@@ -63,6 +63,8 @@ export type DocmostClientLike = Pick<
|
|||||||
| 'getSpaces'
|
| 'getSpaces'
|
||||||
| 'listShares'
|
| 'listShares'
|
||||||
| 'listPages'
|
| 'listPages'
|
||||||
|
| 'getTree'
|
||||||
|
| 'getPageContext'
|
||||||
| 'getPage'
|
| 'getPage'
|
||||||
| 'getPageJson'
|
| 'getPageJson'
|
||||||
| 'getOutline'
|
| 'getOutline'
|
||||||
@@ -117,7 +119,8 @@ export type SharedToolExecute = (
|
|||||||
) => Promise<unknown>;
|
) => Promise<unknown>;
|
||||||
|
|
||||||
export interface SharedToolSpec {
|
export interface SharedToolSpec {
|
||||||
/** snake_case tool name passed to McpServer.registerTool. */
|
/** camelCase tool name passed to McpServer.registerTool. Since issue #412 the
|
||||||
|
* external MCP name equals the in-app key (mcpName === inAppKey). */
|
||||||
mcpName: string;
|
mcpName: string;
|
||||||
/** camelCase key in the ai-SDK tools object (the in-app layer). */
|
/** camelCase key in the ai-SDK tools object (the in-app layer). */
|
||||||
inAppKey: string;
|
inAppKey: string;
|
||||||
@@ -178,7 +181,7 @@ export interface SharedToolSpec {
|
|||||||
* description / schema across both hosts) but carries NO `execute`/override and
|
* description / schema across both hosts) but carries NO `execute`/override and
|
||||||
* is registered INLINE by BOTH hosts instead of through the registry loop. Used
|
* is registered INLINE by BOTH hosts instead of through the registry loop. Used
|
||||||
* for tools whose implementation cannot cross into this zod-agnostic file — the
|
* for tools whose implementation cannot cross into this zod-agnostic file — the
|
||||||
* drawio_shapes / drawio_guide pure helpers, whose backing module resolves a
|
* drawioShapes / drawioGuide pure helpers, whose backing module resolves a
|
||||||
* bundled data file via `import.meta` and so cannot be value-imported here
|
* bundled data file via `import.meta` and so cannot be value-imported here
|
||||||
* without breaking the in-app server's commonjs type-check of this source. Both
|
* without breaking the in-app server's commonjs type-check of this source. Both
|
||||||
* registry loops SKIP a spec with this flag; the per-host inline registrations
|
* registry loops SKIP a spec with this flag; the per-host inline registrations
|
||||||
@@ -206,10 +209,10 @@ const mcpJson = (data: unknown) => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact HARD-RULES block injected into the drawio_create / drawio_update
|
* Compact HARD-RULES block injected into the drawioCreate / drawioUpdate
|
||||||
* descriptions (issue #424 — the jgraph/drawio-mcp pattern of putting the
|
* descriptions (issue #424 — the jgraph/drawio-mcp pattern of putting the
|
||||||
* must-follow rules right where the model reads them at call time). Deliberately
|
* must-follow rules right where the model reads them at call time). Deliberately
|
||||||
* terse; the long-form authoring guidance lives in drawio_guide.
|
* terse; the long-form authoring guidance lives in drawioGuide.
|
||||||
*/
|
*/
|
||||||
export const DRAWIO_HARD_RULES =
|
export const DRAWIO_HARD_RULES =
|
||||||
' RULES: id="0" and id="1"(parent="0") sentinels are MANDATORY; each cell is ' +
|
' RULES: id="0" and id="1"(parent="0") sentinels are MANDATORY; each cell is ' +
|
||||||
@@ -221,8 +224,8 @@ export const DRAWIO_HARD_RULES =
|
|||||||
'and RELATIVE coords, and an edge between different containers is parent="1"; set ' +
|
'and RELATIVE coords, and an edge between different containers is parent="1"; set ' +
|
||||||
'adaptiveColors="auto" on <mxGraphModel> (free dark-theme adaptation for ' +
|
'adaptiveColors="auto" on <mxGraphModel> (free dark-theme adaptation for ' +
|
||||||
'strokeColor/fillColor/fontColor="default"); do NOT guess shape=mxgraph.* names ' +
|
'strokeColor/fillColor/fontColor="default"); do NOT guess shape=mxgraph.* names ' +
|
||||||
"(a wrong name renders as an empty box) — call drawio_shapes first; call " +
|
"(a wrong name renders as an empty box) — call drawioShapes first; call " +
|
||||||
"drawio_guide(section) for authoring help. Pass layout:\"elk\" to let the server " +
|
"drawioGuide(section) for authoring help. Pass layout:\"elk\" to let the server " +
|
||||||
"compute coordinates from your rough placement. The result carries geometry " +
|
"compute coordinates from your rough placement. The result carries geometry " +
|
||||||
"WARNINGS (overlaps, an edge through a shape, edge-on-edge, gaps <150px, a label " +
|
"WARNINGS (overlaps, an edge through a shape, edge-on-edge, gaps <150px, a label " +
|
||||||
"wider than its shape, negative coords) — they do NOT block the write; fix them " +
|
"wider than its shape, negative coords) — they do NOT block the write; fix them " +
|
||||||
@@ -232,7 +235,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
// --- no-argument read tools ---
|
// --- no-argument read tools ---
|
||||||
|
|
||||||
getWorkspace: {
|
getWorkspace: {
|
||||||
mcpName: 'get_workspace',
|
mcpName: 'getWorkspace',
|
||||||
inAppKey: 'getWorkspace',
|
inAppKey: 'getWorkspace',
|
||||||
description: 'Fetch metadata about the current workspace (name, settings).',
|
description: 'Fetch metadata about the current workspace (name, settings).',
|
||||||
tier: 'core',
|
tier: 'core',
|
||||||
@@ -241,7 +244,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
listSpaces: {
|
listSpaces: {
|
||||||
mcpName: 'list_spaces',
|
mcpName: 'listSpaces',
|
||||||
inAppKey: 'listSpaces',
|
inAppKey: 'listSpaces',
|
||||||
description:
|
description:
|
||||||
'List the spaces the current user can access. Returns the array of ' +
|
'List the spaces the current user can access. Returns the array of ' +
|
||||||
@@ -252,7 +255,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
listShares: {
|
listShares: {
|
||||||
mcpName: 'list_shares',
|
mcpName: 'listShares',
|
||||||
inAppKey: 'listShares',
|
inAppKey: 'listShares',
|
||||||
description:
|
description:
|
||||||
'List all public shares in the workspace with page titles and public URLs.',
|
'List all public shares in the workspace with page titles and public URLs.',
|
||||||
@@ -264,7 +267,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
// --- single-pageId read tools ---
|
// --- single-pageId read tools ---
|
||||||
|
|
||||||
getPageJson: {
|
getPageJson: {
|
||||||
mcpName: 'get_page_json',
|
mcpName: 'getPageJson',
|
||||||
inAppKey: 'getPageJson',
|
inAppKey: 'getPageJson',
|
||||||
description:
|
description:
|
||||||
'Get page details with the raw ProseMirror JSON content (lossless: ' +
|
'Get page details with the raw ProseMirror JSON content (lossless: ' +
|
||||||
@@ -281,7 +284,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
getOutline: {
|
getOutline: {
|
||||||
mcpName: 'get_outline',
|
mcpName: 'getOutline',
|
||||||
inAppKey: 'getOutline',
|
inAppKey: 'getOutline',
|
||||||
description:
|
description:
|
||||||
"Return a COMPACT outline of a page's top-level blocks ({index, type, " +
|
"Return a COMPACT outline of a page's top-level blocks ({index, type, " +
|
||||||
@@ -301,43 +304,62 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
// --- two-id read tool ---
|
// --- two-id read tool ---
|
||||||
|
|
||||||
getNode: {
|
getNode: {
|
||||||
mcpName: 'get_node',
|
mcpName: 'getNode',
|
||||||
inAppKey: 'getNode',
|
inAppKey: 'getNode',
|
||||||
description:
|
description:
|
||||||
"Fetch a single node's full ProseMirror subtree (lossless) without " +
|
"Fetch a single block for editing. `nodeId` is a block id from the page " +
|
||||||
'pulling the whole document. `nodeId` is a block id from the page ' +
|
|
||||||
'outline or page-JSON view (works for headings/paragraphs/callouts/images), OR ' +
|
'outline or page-JSON view (works for headings/paragraphs/callouts/images), OR ' +
|
||||||
'`#<index>` to fetch a top-level block by its outline index — use the ' +
|
'`#<index>` to fetch a top-level block by its outline index — use the ' +
|
||||||
'`#<index>` form for tables/rows/cells, which carry no id.',
|
'`#<index>` form for tables/rows/cells, which carry no id. ' +
|
||||||
|
"`format` defaults to \"markdown\": the block is returned as a canonical " +
|
||||||
|
'markdown fragment (comment anchors are KEPT so a patchNode write-back does ' +
|
||||||
|
'not orphan a thread) — edit it and write it back with patchNode({markdown}). ' +
|
||||||
|
'Pass format:"json" for the raw lossless ProseMirror subtree (for precise ' +
|
||||||
|
'attr/mark work). A node that cannot be a document top-level block ' +
|
||||||
|
'(tableRow/tableCell/tableHeader via "#<index>") auto-falls back to JSON with ' +
|
||||||
|
'format:"json" in the response.',
|
||||||
tier: 'core',
|
tier: 'core',
|
||||||
catalogLine:
|
catalogLine:
|
||||||
"getNode — fetch one block's ProseMirror subtree by block id or #index.",
|
"getNode — fetch one block (markdown by default; json for the raw subtree).",
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1),
|
pageId: z.string().min(1),
|
||||||
nodeId: z.string().min(1),
|
nodeId: z.string().min(1),
|
||||||
|
format: z
|
||||||
|
.enum(['markdown', 'json'])
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Output format: "markdown" (default, for editing → patchNode) or ' +
|
||||||
|
'"json" (raw ProseMirror subtree). A non-top-level type auto-falls ' +
|
||||||
|
'back to json.',
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
execute: (client, { pageId, nodeId }) =>
|
execute: (client, { pageId, nodeId, format }) =>
|
||||||
client.getNode(pageId as string, nodeId as string),
|
client.getNode(
|
||||||
|
pageId as string,
|
||||||
|
nodeId as string,
|
||||||
|
format as 'markdown' | 'json' | undefined,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
// --- in-page occurrence search (client-side, over ProseMirror plain text) ---
|
// --- in-page occurrence search (client-side, over ProseMirror plain text) ---
|
||||||
|
|
||||||
searchInPage: {
|
searchInPage: {
|
||||||
mcpName: 'search_in_page',
|
mcpName: 'searchInPage',
|
||||||
inAppKey: 'searchInPage',
|
inAppKey: 'searchInPage',
|
||||||
description:
|
description:
|
||||||
'Find every occurrence of a string (or regex) INSIDE one page and get ' +
|
'Find every occurrence of a string (or regex) INSIDE one page and get ' +
|
||||||
'WHERE each is — instead of pulling blocks one-by-one with get_node. ' +
|
'WHERE each is — instead of pulling blocks one-by-one with getNode. ' +
|
||||||
'Searches the plain text of each text block/cell (marks glued, so a match ' +
|
'Searches the plain text of each text block/cell (marks glued, so a match ' +
|
||||||
'survives bold/italic/link splits; comment anchors do not interfere). ' +
|
'survives bold/italic/link splits; comment anchors do not interfere). ' +
|
||||||
'Returns { total, truncated, matches:[{ nodeId, blockIndex, type, before, ' +
|
'Returns { total, truncated, matches:[{ nodeId, blockIndex, type, before, ' +
|
||||||
'match, after }] }: `nodeId` is the block id (or "#<index>" for ' +
|
'match, after }] }: `nodeId` is the block id (or "#<index>" for ' +
|
||||||
'table/cell content) — pass it to get_node/patch_node (the "#<index>" ' +
|
'table/cell content) — pass it to getNode/patchNode (the "#<index>" ' +
|
||||||
'form resolves with get_node but NOT patch_node, which only accepts a real ' +
|
'form resolves with getNode but NOT patchNode, which only accepts a real ' +
|
||||||
'block id). To anchor a comment, do NOT pass nodeId to create_comment (it ' +
|
'block id). To anchor a comment, do NOT pass nodeId to createComment (it ' +
|
||||||
'has no nodeId param); build a UNIQUE text selection from before+match+' +
|
'has no nodeId param); build a UNIQUE text selection from before+match+' +
|
||||||
'after and pass it as create_comment\'s `selection`. `blockIndex` is the ' +
|
'after and pass it as createComment\'s `selection`. `blockIndex` is the ' +
|
||||||
'get_outline index; `before`/`after` give ~40 chars of context to build ' +
|
'getOutline index; `before`/`after` give ~40 chars of context to build ' +
|
||||||
'that unique selection. `total` counts all ' +
|
'that unique selection. `total` counts all ' +
|
||||||
'hits and `truncated` is true when more than `limit` were found (nothing ' +
|
'hits and `truncated` is true when more than `limit` were found (nothing ' +
|
||||||
'is silently dropped). Default is a literal, case-INSENSITIVE substring; ' +
|
'is silently dropped). Default is a literal, case-INSENSITIVE substring; ' +
|
||||||
@@ -386,7 +408,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
// --- node delete ---
|
// --- node delete ---
|
||||||
|
|
||||||
deleteNode: {
|
deleteNode: {
|
||||||
mcpName: 'delete_node',
|
mcpName: 'deleteNode',
|
||||||
inAppKey: 'deleteNode',
|
inAppKey: 'deleteNode',
|
||||||
description:
|
description:
|
||||||
'Remove a single block by its attrs.id (from the page outline or ' +
|
'Remove a single block by its attrs.id (from the page outline or ' +
|
||||||
@@ -408,30 +430,36 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
// AND the in-app copy's "keeps the same node id" + "Reversible via page
|
// AND the in-app copy's "keeps the same node id" + "Reversible via page
|
||||||
// history" framing — nothing either side conveyed is dropped. Sibling tools are
|
// history" framing — nothing either side conveyed is dropped. Sibling tools are
|
||||||
// named in transport-neutral prose ("the page-JSON view", "a full-document
|
// named in transport-neutral prose ("the page-JSON view", "a full-document
|
||||||
// replace") to match the rest of the registry, since the two layers expose
|
// replace") to match the rest of the registry, so a description reads the same
|
||||||
// those siblings under different (snake_case vs camelCase) identifiers.
|
// on both layers.
|
||||||
patchNode: {
|
patchNode: {
|
||||||
mcpName: 'patch_node',
|
mcpName: 'patchNode',
|
||||||
inAppKey: 'patchNode',
|
inAppKey: 'patchNode',
|
||||||
description:
|
description:
|
||||||
'Replace a single content block identified by its attrs.id with a new ' +
|
'Replace a single content block identified by its attrs.id, WITHOUT ' +
|
||||||
'ProseMirror node, WITHOUT resending the whole document; the replacement ' +
|
'resending the whole document; the replacement keeps the same block id. ' +
|
||||||
'keeps the same node id. Get the block id from the page outline (cheap) ' +
|
'Get the block id from the page outline (cheap) or the page-JSON view. ' +
|
||||||
'or the page-JSON view, then ' +
|
'Provide EXACTLY ONE of `markdown` or `node`. ' +
|
||||||
'pass a ProseMirror node to put in its place. Example node: a paragraph ' +
|
'`markdown` (RECOMMENDED for prose): a canonical markdown fragment — the ' +
|
||||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
|
'usual round trip is getNode (markdown) → edit the markdown → patchNode ' +
|
||||||
'heading {"type":"heading","attrs":{"level":2},"content":' +
|
'(markdown). The fragment may be SEVERAL blocks (a "1 → N" splice: rewrite a ' +
|
||||||
|
'whole section in one call) — the first block inherits this block id, the ' +
|
||||||
|
'rest get fresh ids. `^[...]` footnotes are supported (their definitions ' +
|
||||||
|
"merge into the page's footnote list). REJECTED when the target is a table " +
|
||||||
|
'cell with attributes markdown cannot represent (merged/colored/fixed-width) ' +
|
||||||
|
'— use the table tools or `node`. ' +
|
||||||
|
'`node` (for precise attr/mark work): a raw ProseMirror node, e.g. a ' +
|
||||||
|
'paragraph {"type":"paragraph","content":[{"type":"text","text":"Hello"}]} ' +
|
||||||
|
'or a heading {"type":"heading","attrs":{"level":2},"content":' +
|
||||||
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
|
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
|
||||||
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
|
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. EVERY node, including ' +
|
||||||
'JSON object or a JSON string (both accepted). EVERY node, including ' +
|
'nested children, must carry a string `type` from the Docmost schema; text ' +
|
||||||
'nested children, must carry a string `type` from the Docmost schema; ' +
|
'leaves are {"type":"text","text":"..."} (a bare {"text":"..."} is rejected). ' +
|
||||||
'text leaves are {"type":"text","text":"..."} (a bare {"text":"..."} is ' +
|
'The node may be a JSON object or a JSON string (both accepted). Reversible: ' +
|
||||||
'rejected up front). Cheaper and safer than ' +
|
|
||||||
'replacing the whole document for one-block structural edits. Reversible: ' +
|
|
||||||
'the previous version is kept in page history.',
|
'the previous version is kept in page history.',
|
||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine:
|
catalogLine:
|
||||||
'patchNode — replace one block with a new ProseMirror node, keeping its id.',
|
'patchNode — rewrite one block from markdown (or a raw node), keeping its id.',
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1).describe('ID of the page containing the block'),
|
pageId: z.string().min(1).describe('ID of the page containing the block'),
|
||||||
nodeId: z
|
nodeId: z
|
||||||
@@ -441,35 +469,53 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'attrs.id of the block to replace (from the page outline or ' +
|
'attrs.id of the block to replace (from the page outline or ' +
|
||||||
'page-JSON view)',
|
'page-JSON view)',
|
||||||
),
|
),
|
||||||
|
markdown: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'RECOMMENDED. Canonical markdown to replace the block with; may be ' +
|
||||||
|
'several blocks (the first inherits the id, the rest get fresh ids). ' +
|
||||||
|
'Exactly one of markdown / node.',
|
||||||
|
),
|
||||||
node: z
|
node: z
|
||||||
.any()
|
.any()
|
||||||
|
.optional()
|
||||||
.describe(
|
.describe(
|
||||||
'ProseMirror node to put in place of the node with this id, e.g. ' +
|
'For precise attr/mark work: a ProseMirror node to put in place of ' +
|
||||||
|
'the block, e.g. ' +
|
||||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
|
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
|
||||||
'JSON object or JSON string both accepted.',
|
'JSON object or JSON string both accepted. Exactly one of markdown / node.',
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
// parseNodeArg normalizes a JSON-string node into an object (the model
|
// parseNodeArg normalizes a JSON-string node into an object (the model
|
||||||
// sometimes serializes it as a string) before the client's typeof-object
|
// sometimes serializes it as a string) before the client's typeof-object
|
||||||
// guard rejects it — identical on both hosts.
|
// guard rejects it — identical on both hosts. The XOR (markdown vs node) is
|
||||||
execute: (client, { pageId, nodeId, node }) =>
|
// enforced at runtime in the client (both schema-optional).
|
||||||
client.patchNode(pageId as string, nodeId as string, parseNodeArg(node)),
|
execute: (client, { pageId, nodeId, markdown, node }) =>
|
||||||
|
client.patchNode(pageId as string, nodeId as string, {
|
||||||
|
markdown: markdown as string | undefined,
|
||||||
|
node: node == null ? undefined : parseNodeArg(node),
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
insertNode: {
|
insertNode: {
|
||||||
mcpName: 'insert_node',
|
mcpName: 'insertNode',
|
||||||
inAppKey: 'insertNode',
|
inAppKey: 'insertNode',
|
||||||
description:
|
description:
|
||||||
'Insert a block before/after another block (by attrs.id or anchor text) ' +
|
'Insert content before/after another block (by attrs.id or anchor text) ' +
|
||||||
'or append it at the end (top level). For before/after you MUST provide ' +
|
'or append it at the end (top level). For before/after you MUST provide ' +
|
||||||
'EXACTLY ONE of anchorNodeId or anchorText. Get anchor block ids from the ' +
|
'EXACTLY ONE of anchorNodeId or anchorText. Get anchor block ids from the ' +
|
||||||
'page outline or the page-JSON view. Avoids resending the whole document. ' +
|
'page outline or the page-JSON view. Avoids resending the whole document. ' +
|
||||||
'Can also insert ' +
|
'Provide EXACTLY ONE of `markdown` or `node`. ' +
|
||||||
'table structure: to add a tableRow, pass a tableRow node with position ' +
|
'`markdown` (RECOMMENDED): a canonical markdown fragment — may be SEVERAL ' +
|
||||||
'before/after and anchor INSIDE the target table — anchorNodeId of any ' +
|
'blocks, inserted in order at the anchor; `^[...]` footnotes supported. ' +
|
||||||
'block/cell in it, or anchorText matching the table; to add a ' +
|
'`node` (for precise attr/mark work OR table structure): a raw ProseMirror ' +
|
||||||
'tableCell/tableHeader, use anchorNodeId of a block inside the target row ' +
|
'node. Table structure is JSON-only (not expressible in markdown): to add a ' +
|
||||||
'(anchorText only resolves top-level blocks, so it cannot target a row). ' +
|
'tableRow, pass a tableRow node with position before/after and anchor INSIDE ' +
|
||||||
|
'the target table — anchorNodeId of any block/cell in it, or anchorText ' +
|
||||||
|
'matching the table; to add a tableCell/tableHeader, use anchorNodeId of a ' +
|
||||||
|
'block inside the target row (anchorText only resolves top-level blocks). ' +
|
||||||
"`anchorText` is matched against the block's literal rendered plain text " +
|
"`anchorText` is matched against the block's literal rendered plain text " +
|
||||||
'(no markdown); markdown/emoji are tolerated as a fallback; prefer plain ' +
|
'(no markdown); markdown/emoji are tolerated as a fallback; prefer plain ' +
|
||||||
'text or anchorNodeId. Note: append is top-level only and rejects ' +
|
'text or anchorNodeId. Note: append is top-level only and rejects ' +
|
||||||
@@ -484,15 +530,23 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'JSON object or a JSON string (both accepted). Reversible via page history.',
|
'JSON object or a JSON string (both accepted). Reversible via page history.',
|
||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine:
|
catalogLine:
|
||||||
'insertNode — insert a block before/after an anchor, or append at the end.',
|
'insertNode — insert markdown (or a raw node) before/after an anchor, or append.',
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1),
|
pageId: z.string().min(1),
|
||||||
|
markdown: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'RECOMMENDED. Canonical markdown to insert; may be several blocks ' +
|
||||||
|
'(inserted in order). Exactly one of markdown / node.',
|
||||||
|
),
|
||||||
node: z
|
node: z
|
||||||
.any()
|
.any()
|
||||||
|
.optional()
|
||||||
.describe(
|
.describe(
|
||||||
'ProseMirror node to insert, e.g. ' +
|
'For precise attr/mark work or table structure: a ProseMirror node, ' +
|
||||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
|
'e.g. {"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
|
||||||
'JSON object or JSON string both accepted.',
|
'JSON object or JSON string both accepted. Exactly one of markdown / node.',
|
||||||
),
|
),
|
||||||
position: z
|
position: z
|
||||||
.enum(['before', 'after', 'append'])
|
.enum(['before', 'after', 'append'])
|
||||||
@@ -510,14 +564,27 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'are tolerated as a fallback; prefer plain text or anchorNodeId.',
|
'are tolerated as a fallback; prefer plain text or anchorNodeId.',
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
execute: (client, { pageId, node, position, anchorNodeId, anchorText }) =>
|
// The XOR (markdown vs node) is enforced at runtime in the client (both
|
||||||
client.insertNode(pageId as string, parseNodeArg(node), {
|
// schema-optional). parseNodeArg only runs on the node path.
|
||||||
position: position as 'before' | 'after' | 'append',
|
execute: (
|
||||||
anchorNodeId: anchorNodeId as string | undefined,
|
client,
|
||||||
anchorText: anchorText as string | undefined,
|
{ pageId, markdown, node, position, anchorNodeId, anchorText },
|
||||||
}),
|
) =>
|
||||||
|
client.insertNode(
|
||||||
|
pageId as string,
|
||||||
|
{
|
||||||
|
markdown: markdown as string | undefined,
|
||||||
|
node: node == null ? undefined : parseNodeArg(node),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
position: position as 'before' | 'after' | 'append',
|
||||||
|
anchorNodeId: anchorNodeId as string | undefined,
|
||||||
|
anchorText: anchorText as string | undefined,
|
||||||
|
},
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
// --- share management ---
|
// --- share management ---
|
||||||
|
|
||||||
// Unified from the per-layer inline definitions (#294). Both layers already
|
// Unified from the per-layer inline definitions (#294). Both layers already
|
||||||
@@ -525,7 +592,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
// "per-transport divergence" note on the old inline copies was stale), so
|
// "per-transport divergence" note on the old inline copies was stale), so
|
||||||
// there was no real behavioral divergence to preserve — only wording drift.
|
// there was no real behavioral divergence to preserve — only wording drift.
|
||||||
sharePage: {
|
sharePage: {
|
||||||
mcpName: 'share_page',
|
mcpName: 'sharePage',
|
||||||
inAppKey: 'sharePage',
|
inAppKey: 'sharePage',
|
||||||
// CANONICAL: merges the MCP copy's URL-format + idempotency detail with the
|
// CANONICAL: merges the MCP copy's URL-format + idempotency detail with the
|
||||||
// in-app copy's reversibility note; keeps the security framing both had.
|
// in-app copy's reversibility note; keeps the security framing both had.
|
||||||
@@ -554,7 +621,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
unsharePage: {
|
unsharePage: {
|
||||||
mcpName: 'unshare_page',
|
mcpName: 'unsharePage',
|
||||||
inAppKey: 'unsharePage',
|
inAppKey: 'unsharePage',
|
||||||
description: 'Remove the public share of a page (revokes the public URL).',
|
description: 'Remove the public share of a page (revokes the public URL).',
|
||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
@@ -568,7 +635,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
// --- version history ---
|
// --- version history ---
|
||||||
|
|
||||||
diffPageVersions: {
|
diffPageVersions: {
|
||||||
mcpName: 'diff_page_versions',
|
mcpName: 'diffPageVersions',
|
||||||
inAppKey: 'diffPageVersions',
|
inAppKey: 'diffPageVersions',
|
||||||
description:
|
description:
|
||||||
'Diff two versions of a page and return a Docmost-equivalent change set ' +
|
'Diff two versions of a page and return a Docmost-equivalent change set ' +
|
||||||
@@ -600,7 +667,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
listPageHistory: {
|
listPageHistory: {
|
||||||
mcpName: 'list_page_history',
|
mcpName: 'listPageHistory',
|
||||||
inAppKey: 'listPageHistory',
|
inAppKey: 'listPageHistory',
|
||||||
description:
|
description:
|
||||||
"List a page's saved versions (Docmost auto-snapshots on every save), " +
|
"List a page's saved versions (Docmost auto-snapshots on every save), " +
|
||||||
@@ -621,7 +688,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
restorePageVersion: {
|
restorePageVersion: {
|
||||||
mcpName: 'restore_page_version',
|
mcpName: 'restorePageVersion',
|
||||||
inAppKey: 'restorePageVersion',
|
inAppKey: 'restorePageVersion',
|
||||||
description:
|
description:
|
||||||
'Restore a page to a saved version: writes that version\'s content back ' +
|
'Restore a page to a saved version: writes that version\'s content back ' +
|
||||||
@@ -641,13 +708,13 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
// --- markdown round-trip ---
|
// --- markdown round-trip ---
|
||||||
|
|
||||||
importPageMarkdown: {
|
importPageMarkdown: {
|
||||||
mcpName: 'import_page_markdown',
|
mcpName: 'importPageMarkdown',
|
||||||
inAppKey: 'importPageMarkdown',
|
inAppKey: 'importPageMarkdown',
|
||||||
// IN-APP ONLY (issue #411): the external /mcp surface no longer exposes
|
// IN-APP ONLY (issue #411): the external /mcp surface no longer exposes
|
||||||
// import_page_markdown — the registry loop in index.ts skips inAppOnly specs,
|
// importPageMarkdown — the registry loop in index.ts skips inAppOnly specs,
|
||||||
// so this stays available to the in-app agent (round-tripping an EXPORTED
|
// so this stays available to the in-app agent (round-tripping an EXPORTED
|
||||||
// Docmost-Markdown file) but is removed from the public MCP tool set. Plain
|
// Docmost-Markdown file) but is removed from the public MCP tool set. Plain
|
||||||
// authoring-markdown body replace on the MCP surface is update_page_markdown.
|
// authoring-markdown body replace on the MCP surface is updatePageMarkdown.
|
||||||
inAppOnly: true,
|
inAppOnly: true,
|
||||||
description:
|
description:
|
||||||
"Replace a page's content from a self-contained Docmost-flavoured " +
|
"Replace a page's content from a self-contained Docmost-flavoured " +
|
||||||
@@ -670,7 +737,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
// --- server-side content copy ---
|
// --- server-side content copy ---
|
||||||
|
|
||||||
copyPageContent: {
|
copyPageContent: {
|
||||||
mcpName: 'copy_page_content',
|
mcpName: 'copyPageContent',
|
||||||
inAppKey: 'copyPageContent',
|
inAppKey: 'copyPageContent',
|
||||||
description:
|
description:
|
||||||
"Replace targetPageId's content with a copy of sourcePageId's content, " +
|
"Replace targetPageId's content with a copy of sourcePageId's content, " +
|
||||||
@@ -698,7 +765,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
// stale MCP claim that "Markdown wrappers are tolerated via a strip-and-retry
|
// stale MCP claim that "Markdown wrappers are tolerated via a strip-and-retry
|
||||||
// fallback" is intentionally absent here.
|
// fallback" is intentionally absent here.
|
||||||
editPageText: {
|
editPageText: {
|
||||||
mcpName: 'edit_page_text',
|
mcpName: 'editPageText',
|
||||||
inAppKey: 'editPageText',
|
inAppKey: 'editPageText',
|
||||||
description:
|
description:
|
||||||
"Surgical find/replace inside a page's text, preserving all block " +
|
"Surgical find/replace inside a page's text, preserving all block " +
|
||||||
@@ -747,10 +814,10 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
|
|
||||||
// --- hand a large page to an external consumer without bloating context ---
|
// --- hand a large page to an external consumer without bloating context ---
|
||||||
stashPage: {
|
stashPage: {
|
||||||
mcpName: 'stash_page',
|
mcpName: 'stashPage',
|
||||||
inAppKey: 'stashPage',
|
inAppKey: 'stashPage',
|
||||||
description:
|
description:
|
||||||
'Serialize a whole page (the full ProseMirror JSON, as get_page_json ' +
|
'Serialize a whole page (the full ProseMirror JSON, as getPageJson ' +
|
||||||
'returns) into an ephemeral in-memory blob and return ONLY a short ' +
|
'returns) into an ephemeral in-memory blob and return ONLY a short ' +
|
||||||
'anonymous URL to it — the body NEVER enters the model context, so this ' +
|
'anonymous URL to it — the body NEVER enters the model context, so this ' +
|
||||||
'is the way to hand a large page (or its images) to an external consumer ' +
|
'is the way to hand a large page (or its images) to an external consumer ' +
|
||||||
@@ -808,15 +875,21 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
// in-app layer deliberately allowed a looser value (documented per field).
|
// in-app layer deliberately allowed a looser value (documented per field).
|
||||||
|
|
||||||
getPage: {
|
getPage: {
|
||||||
mcpName: 'get_page',
|
mcpName: 'getPage',
|
||||||
inAppKey: 'getPage',
|
inAppKey: 'getPage',
|
||||||
description:
|
description:
|
||||||
'Fetch a single page as Markdown by its id. Returns the page title and ' +
|
'Fetch a single page as Markdown by its id. Returns the page title and ' +
|
||||||
'its Markdown content. The Markdown conversion is LOSSY (block ids, exact ' +
|
'its Markdown content. The converter is canonical (round-trips text and ' +
|
||||||
'table/callout structure are approximated); for a lossless representation ' +
|
'block structure), so this is sufficient for text edits; use the ' +
|
||||||
'use the lossless page-JSON read tool. Inline <span data-comment-id> tags in the markdown ' +
|
'page-JSON read tool only when you need what Markdown cannot carry. The ' +
|
||||||
'are comment highlight anchors (also present for RESOLVED threads) — ' +
|
'Markdown drops exactly: (1) block ids (not visible in Markdown); ' +
|
||||||
'treat them as markup, not page text.',
|
'(2) resolved-comment anchors (hidden here; only active <span ' +
|
||||||
|
'data-comment-id> anchors remain); (3) a fixed set of attributes with no ' +
|
||||||
|
'Markdown representation — table-cell colspan/rowspan/colwidth/' +
|
||||||
|
'backgroundColor/backgroundColorName, heading/paragraph indent, ' +
|
||||||
|
'callout.icon, orderedList.type, and link internal/target/rel/class. ' +
|
||||||
|
'Inline <span data-comment-id> tags in the markdown are comment highlight ' +
|
||||||
|
'anchors — treat them as markup, not page text.',
|
||||||
tier: 'core',
|
tier: 'core',
|
||||||
catalogLine: 'getPage — fetch a page as Markdown by its id.',
|
catalogLine: 'getPage — fetch a page as Markdown by its id.',
|
||||||
// Reconciled: MCP's stricter .min(1) kept; in-app's more-informative
|
// Reconciled: MCP's stricter .min(1) kept; in-app's more-informative
|
||||||
@@ -841,16 +914,18 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
listPages: {
|
listPages: {
|
||||||
mcpName: 'list_pages',
|
mcpName: 'listPages',
|
||||||
inAppKey: 'listPages',
|
inAppKey: 'listPages',
|
||||||
description:
|
description:
|
||||||
'List the most recent pages (ordered by updatedAt, descending), ' +
|
'List the most recent pages (ordered by updatedAt, descending), ' +
|
||||||
'optionally scoped to a single space. Returns a bounded list (default ' +
|
'optionally scoped to a single space. Returns a bounded list (default ' +
|
||||||
'50, max 100) — use search for lookups in large spaces. Pass tree:true ' +
|
'50, max 100) — use search for lookups in large spaces. tree:true (with ' +
|
||||||
"(with spaceId) to instead get the space's full page hierarchy as a " +
|
"spaceId) returns the space's full page hierarchy as a nested tree, but " +
|
||||||
'nested tree.',
|
'is DEPRECATED — use getTree instead (leaner nodes, plus rootPageId / ' +
|
||||||
|
'maxDepth).',
|
||||||
tier: 'core',
|
tier: 'core',
|
||||||
catalogLine: "listPages — list recent pages, or a space's full page tree.",
|
catalogLine:
|
||||||
|
"listPages — list recent pages (tree:true is deprecated; use getTree for the hierarchy).",
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
spaceId: z
|
spaceId: z
|
||||||
.string()
|
.string()
|
||||||
@@ -883,8 +958,81 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getTree: {
|
||||||
|
mcpName: 'getTree',
|
||||||
|
inAppKey: 'getTree',
|
||||||
|
description:
|
||||||
|
"Get a space's page hierarchy (or one subtree) as a nested tree in a " +
|
||||||
|
'SINGLE request — completely and without loss. Each node is ' +
|
||||||
|
'`{ pageId, title, children? }`; children are ordered as in the sidebar. ' +
|
||||||
|
'Pass rootPageId to return only that page and its descendants (exactly ' +
|
||||||
|
'one root). Pass maxDepth to trim depth and save tokens (root nodes are ' +
|
||||||
|
'depth 1, so maxDepth:1 returns only the roots); a node whose children ' +
|
||||||
|
'were trimmed carries `hasChildren:true` so you can descend later with ' +
|
||||||
|
'getTree(rootPageId=that page). Prefer this over listPages tree:true.',
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine:
|
||||||
|
"getTree — a space's page hierarchy (or a subtree) as a nested tree in one request.",
|
||||||
|
buildShape: (z) => ({
|
||||||
|
spaceId: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe('The id of the space whose page tree to return.'),
|
||||||
|
rootPageId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Optional page id: return only this page and its descendants (one root).',
|
||||||
|
),
|
||||||
|
maxDepth: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Optional depth cap (roots are depth 1). maxDepth:1 returns only the ' +
|
||||||
|
'roots; trimmed nodes carry hasChildren:true.',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
execute: (client, { spaceId, rootPageId, maxDepth }) =>
|
||||||
|
client.getTree(
|
||||||
|
spaceId as string,
|
||||||
|
rootPageId as string | undefined,
|
||||||
|
maxDepth as number | undefined,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
getPageContext: {
|
||||||
|
mcpName: 'getPageContext',
|
||||||
|
inAppKey: 'getPageContext',
|
||||||
|
description:
|
||||||
|
'Given a pageId, get its LOCATION and immediate surroundings (metadata ' +
|
||||||
|
'only, no page content) in one call — answers "where am I / what is ' +
|
||||||
|
"around this page\". Returns `{ page: { pageId, title, spaceId }, " +
|
||||||
|
'breadcrumbs: [{ pageId, title }], children: [{ pageId, title, ' +
|
||||||
|
'hasChildren }] }`. `breadcrumbs` is the ancestor chain from the space ' +
|
||||||
|
'root down to the PARENT (the parent is its last element; a root page ' +
|
||||||
|
'has `breadcrumbs: []`). `children` are the direct children in sidebar ' +
|
||||||
|
'order, each flagged `hasChildren` so you know which can be expanded ' +
|
||||||
|
'(descend with getTree(rootPageId=that child) or another getPageContext). ' +
|
||||||
|
'Ids, titles and child order are consistent with getTree.',
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine:
|
||||||
|
'getPageContext — a page’s breadcrumbs + direct children (where-am-I) in one call.',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe(
|
||||||
|
'The id of the page to locate (a pageId/UUID, or a slugId from a URL).',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
execute: (client, { pageId }) =>
|
||||||
|
client.getPageContext(pageId as string),
|
||||||
|
},
|
||||||
|
|
||||||
createPage: {
|
createPage: {
|
||||||
mcpName: 'create_page',
|
mcpName: 'createPage',
|
||||||
inAppKey: 'createPage',
|
inAppKey: 'createPage',
|
||||||
description:
|
description:
|
||||||
'Create a new page with a Markdown body in a space, optionally under a ' +
|
'Create a new page with a Markdown body in a space, optionally under a ' +
|
||||||
@@ -896,7 +1044,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
// Reconciled schema DRIFT: the MCP copy pinned `content` to .min(1) while
|
// Reconciled schema DRIFT: the MCP copy pinned `content` to .min(1) while
|
||||||
// the in-app copy left it unbounded and DOCUMENTS an empty body as valid
|
// the in-app copy left it unbounded and DOCUMENTS an empty body as valid
|
||||||
// ("may be empty") — creating an empty page to fill in later is a real use
|
// ("may be empty") — creating an empty page to fill in later is a real use
|
||||||
// case. The looser (no-min) form is kept, so create_page now also accepts an
|
// case. The looser (no-min) form is kept, so createPage now also accepts an
|
||||||
// empty body (harmless — it creates an empty page) and no previously-valid
|
// empty body (harmless — it creates an empty page) and no previously-valid
|
||||||
// in-app input is ever rejected. `title`/`spaceId` keep the MCP .min(1)
|
// in-app input is ever rejected. `title`/`spaceId` keep the MCP .min(1)
|
||||||
// (an empty title or space is never valid).
|
// (an empty title or space is never valid).
|
||||||
@@ -934,7 +1082,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
movePage: {
|
movePage: {
|
||||||
mcpName: 'move_page',
|
mcpName: 'movePage',
|
||||||
inAppKey: 'movePage',
|
inAppKey: 'movePage',
|
||||||
description:
|
description:
|
||||||
'Move a page under a new parent page, or to the space root when no ' +
|
'Move a page under a new parent page, or to the space root when no ' +
|
||||||
@@ -1027,7 +1175,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
renamePage: {
|
renamePage: {
|
||||||
mcpName: 'rename_page',
|
mcpName: 'renamePage',
|
||||||
inAppKey: 'renamePage',
|
inAppKey: 'renamePage',
|
||||||
description:
|
description:
|
||||||
'Rename a page (change its title only; the body is untouched, never ' +
|
'Rename a page (change its title only; the body is untouched, never ' +
|
||||||
@@ -1048,7 +1196,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
deletePage: {
|
deletePage: {
|
||||||
mcpName: 'delete_page',
|
mcpName: 'deletePage',
|
||||||
inAppKey: 'deletePage',
|
inAppKey: 'deletePage',
|
||||||
description:
|
description:
|
||||||
'Move a page to the trash — SOFT delete only: the page can be restored ' +
|
'Move a page to the trash — SOFT delete only: the page can be restored ' +
|
||||||
@@ -1081,7 +1229,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
updatePageJson: {
|
updatePageJson: {
|
||||||
mcpName: 'update_page_json',
|
mcpName: 'updatePageJson',
|
||||||
inAppKey: 'updatePageJson',
|
inAppKey: 'updatePageJson',
|
||||||
description:
|
description:
|
||||||
"Replace a page's content with a raw ProseMirror JSON document (lossless " +
|
"Replace a page's content with a raw ProseMirror JSON document (lossless " +
|
||||||
@@ -1137,8 +1285,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
// registry loop registers it on BOTH hosts (external MCP + in-app agent) —
|
// registry loop registers it on BOTH hosts (external MCP + in-app agent) —
|
||||||
// #411 replaced the old inline in-app `updatePageContent` tool with this.
|
// #411 replaced the old inline in-app `updatePageContent` tool with this.
|
||||||
updatePageMarkdown: {
|
updatePageMarkdown: {
|
||||||
// snake_case for now; camelCase public MCP naming is the next issue (#412).
|
mcpName: 'updatePageMarkdown',
|
||||||
mcpName: 'update_page_markdown',
|
|
||||||
inAppKey: 'updatePageMarkdown',
|
inAppKey: 'updatePageMarkdown',
|
||||||
description:
|
description:
|
||||||
"Replace a page's body with new Markdown content (and optionally its " +
|
"Replace a page's body with new Markdown content (and optionally its " +
|
||||||
@@ -1172,17 +1319,21 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
exportPageMarkdown: {
|
exportPageMarkdown: {
|
||||||
mcpName: 'export_page_markdown',
|
mcpName: 'exportPageMarkdown',
|
||||||
inAppKey: 'exportPageMarkdown',
|
inAppKey: 'exportPageMarkdown',
|
||||||
// CANONICAL: the MCP copy (a strict superset of the terse in-app wording).
|
// CANONICAL: the MCP copy (a strict superset of the terse in-app wording).
|
||||||
description:
|
description:
|
||||||
'Export a page to a single self-contained, lossless Docmost-flavoured ' +
|
'Export a page to a single self-contained Docmost-flavoured Markdown ' +
|
||||||
'Markdown file (custom extensions): YAML-free meta header, body with ' +
|
'file (custom extensions): YAML-free meta header, body with inline ' +
|
||||||
'inline comment anchors and diagrams, and a trailing comments-thread ' +
|
'comment anchors (resolved ones kept) and diagrams, and a trailing ' +
|
||||||
'block. Designed for a download -> edit body -> page-Markdown import ' +
|
'comments-thread block. Designed for a download -> edit body -> ' +
|
||||||
'round-trip that preserves everything, including comment highlights. ' +
|
'page-Markdown import round-trip; block ids regenerate and comment ' +
|
||||||
'Comment THREADS are preserved in the file but are not re-pushed to the ' +
|
'THREADS, though kept in the file, are not re-pushed to the server on ' +
|
||||||
'server on import.',
|
'import. The round-trip SILENTLY DROPS a fixed set of attributes with no ' +
|
||||||
|
'Markdown representation — table-cell merge spans (colspan/rowspan), ' +
|
||||||
|
'colwidth, backgroundColor/backgroundColorName, heading/paragraph indent, ' +
|
||||||
|
'callout.icon, orderedList.type, and link internal/target/rel/class. Use ' +
|
||||||
|
'the page-JSON tools if those must survive.',
|
||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine:
|
catalogLine:
|
||||||
'exportPageMarkdown — export a page to self-contained Markdown (body + comments).',
|
'exportPageMarkdown — export a page to self-contained Markdown (body + comments).',
|
||||||
@@ -1204,19 +1355,19 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
|
|
||||||
// --- comment tools (unified from the per-layer inline definitions, #294) ---
|
// --- comment tools (unified from the per-layer inline definitions, #294) ---
|
||||||
//
|
//
|
||||||
// create_comment and resolve_comment previously carried a "per-transport
|
// createComment and resolveComment previously carried a "per-transport
|
||||||
// divergence" note in BOTH layers; #294 unifies their schema + description
|
// divergence" note in BOTH layers; #294 unifies their schema + description
|
||||||
// here. Only the four tools that genuinely exist in BOTH layers live in the
|
// here. Only the four tools that genuinely exist in BOTH layers live in the
|
||||||
// registry: create/list/resolve comment and check_new_comments.
|
// registry: create/list/resolve comment and checkNewComments.
|
||||||
//
|
//
|
||||||
// update_comment and delete_comment are intentionally NOT here: they exist
|
// updateComment and deleteComment are intentionally NOT here: they exist
|
||||||
// ONLY on the standalone MCP server. The in-app agent deliberately exposes no
|
// ONLY on the standalone MCP server. The in-app agent deliberately exposes no
|
||||||
// hard comment edit/delete tool (comment edits are irreversible / not
|
// hard comment edit/delete tool (comment edits are irreversible / not
|
||||||
// version-tracked; see the guardrail tests in ai-chat-tools.service.spec.ts),
|
// version-tracked; see the guardrail tests in ai-chat-tools.service.spec.ts),
|
||||||
// so there is nothing to unify — they stay inline in index.ts.
|
// so there is nothing to unify — they stay inline in index.ts.
|
||||||
|
|
||||||
createComment: {
|
createComment: {
|
||||||
mcpName: 'create_comment',
|
mcpName: 'createComment',
|
||||||
inAppKey: 'createComment',
|
inAppKey: 'createComment',
|
||||||
// CANONICAL: the in-app copy (the more-maintained one). It keeps the same
|
// CANONICAL: the in-app copy (the more-maintained one). It keeps the same
|
||||||
// rules as the MCP copy — inline-only, top-level requires a `selection`, no
|
// rules as the MCP copy — inline-only, top-level requires a `selection`, no
|
||||||
@@ -1231,7 +1382,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'(which gets highlighted); page-level comments are NOT supported. A ' +
|
'(which gets highlighted); page-level comments are NOT supported. A ' +
|
||||||
'new top-level comment REQUIRES a `selection`. Replies inherit the ' +
|
'new top-level comment REQUIRES a `selection`. Replies inherit the ' +
|
||||||
"parent's anchor and take no selection. Always COPY the `selection` " +
|
"parent's anchor and take no selection. Always COPY the `selection` " +
|
||||||
'VERBATIM from get_page / search_in_page output — do NOT quote it from ' +
|
'VERBATIM from getPage / searchInPage output — do NOT quote it from ' +
|
||||||
'memory (stale-memory quoting is the top cause of anchor misses). If the ' +
|
'memory (stale-memory quoting is the top cause of anchor misses). If the ' +
|
||||||
'call fails with a "selection not found" error, the error quotes the ' +
|
'call fails with a "selection not found" error, the error quotes the ' +
|
||||||
"closest block text (or says the selection spans multiple blocks); retry " +
|
"closest block text (or says the selection spans multiple blocks); retry " +
|
||||||
@@ -1284,25 +1435,25 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
}),
|
}),
|
||||||
// Both hosts enforce the SAME guardrails (a top-level comment requires a
|
// Both hosts enforce the SAME guardrails (a top-level comment requires a
|
||||||
// selection; suggestedText is forbidden on a reply / without a selection) but
|
// selection; suggestedText is forbidden on a reply / without a selection) but
|
||||||
// with per-layer error wording (snake_case 'create_comment:' on the MCP
|
// with per-layer error wording (both now prefixed 'createComment', the MCP
|
||||||
// surface, camelCase 'createComment' in-app) and different result shapes (MCP
|
// and in-app messages differing only in their trailing guidance) and different
|
||||||
// jsonContent, in-app projects `{ commentId, pageId }`). Preserved byte-for-
|
// result shapes (MCP jsonContent, in-app projects `{ commentId, pageId }`).
|
||||||
// byte via the two overrides.
|
// Preserved byte-for-byte via the two overrides.
|
||||||
mcpExecute: async (client, { pageId, content, selection, parentCommentId, suggestedText }) => {
|
mcpExecute: async (client, { pageId, content, selection, parentCommentId, suggestedText }) => {
|
||||||
if (!parentCommentId && (!selection || !(selection as string).trim())) {
|
if (!parentCommentId && (!selection || !(selection as string).trim())) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"create_comment: a 'selection' (exact text to anchor on) is required for a top-level comment; omit it only when replying via parentCommentId.",
|
"createComment: a 'selection' (exact text to anchor on) is required for a top-level comment; omit it only when replying via parentCommentId.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (suggestedText !== undefined) {
|
if (suggestedText !== undefined) {
|
||||||
if (parentCommentId) {
|
if (parentCommentId) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"create_comment: 'suggestedText' cannot be attached to a reply; it applies only to a top-level inline comment.",
|
"createComment: 'suggestedText' cannot be attached to a reply; it applies only to a top-level inline comment.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!selection || !(selection as string).trim()) {
|
if (!selection || !(selection as string).trim()) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"create_comment: 'suggestedText' requires a 'selection' to anchor and rewrite.",
|
"createComment: 'suggestedText' requires a 'selection' to anchor and rewrite.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1348,7 +1499,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
listComments: {
|
listComments: {
|
||||||
mcpName: 'list_comments',
|
mcpName: 'listComments',
|
||||||
inAppKey: 'listComments',
|
inAppKey: 'listComments',
|
||||||
// CANONICAL: the two copies are near-identical; the MCP copy is the
|
// CANONICAL: the two copies are near-identical; the MCP copy is the
|
||||||
// superset (it keeps the "(pagination is handled internally)" note the
|
// superset (it keeps the "(pagination is handled internally)" note the
|
||||||
@@ -1375,10 +1526,10 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
resolveComment: {
|
resolveComment: {
|
||||||
mcpName: 'resolve_comment',
|
mcpName: 'resolveComment',
|
||||||
inAppKey: 'resolveComment',
|
inAppKey: 'resolveComment',
|
||||||
// CANONICAL: the MCP copy's richer wording, minus its snake_case reference
|
// CANONICAL: the MCP copy's richer wording, minus its reference
|
||||||
// to `delete_comment` (a sibling tool that does NOT exist in the in-app
|
// to `deleteComment` (a sibling tool that does NOT exist in the in-app
|
||||||
// layer) — rephrased transport-neutrally per the registry convention.
|
// layer) — rephrased transport-neutrally per the registry convention.
|
||||||
description:
|
description:
|
||||||
'Resolve (close) or reopen a top-level comment thread (reversible — ' +
|
'Resolve (close) or reopen a top-level comment thread (reversible — ' +
|
||||||
@@ -1415,7 +1566,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
checkNewComments: {
|
checkNewComments: {
|
||||||
mcpName: 'check_new_comments',
|
mcpName: 'checkNewComments',
|
||||||
inAppKey: 'checkNewComments',
|
inAppKey: 'checkNewComments',
|
||||||
// CANONICAL: the MCP copy (the more detailed of the two). The MCP layer's
|
// CANONICAL: the MCP copy (the more detailed of the two). The MCP layer's
|
||||||
// execute-side guard that rejects an unparseable `since` timestamp stays in
|
// execute-side guard that rejects an unparseable `since` timestamp stays in
|
||||||
@@ -1486,15 +1637,15 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
// behavior) plus the in-app copy's "Reversible via page history" note; sibling
|
// behavior) plus the in-app copy's "Reversible via page history" note; sibling
|
||||||
// tool references are phrased transport-neutrally.
|
// tool references are phrased transport-neutrally.
|
||||||
//
|
//
|
||||||
// NOT here (kept inline in index.ts): table_get / getTable. Its MCP tool name
|
// NOT here (kept inline in index.ts): tableGet / getTable. Its MCP tool name
|
||||||
// is noun-first (`table_get`) while the in-app key is verb-first (`getTable`),
|
// is noun-first (`tableGet`) while the in-app key is verb-first (`getTable`),
|
||||||
// so it breaks the snake_case(inAppKey) naming convention the registry enforces
|
// so it breaks the mcpName === inAppKey naming convention the registry enforces
|
||||||
// (shared-tool-specs.contract.spec.ts). Renaming the public MCP tool would
|
// (shared-tool-specs.contract.spec.ts). Renaming either public name would break
|
||||||
// break external clients, so it stays per-transport (its in-app param was still
|
// external clients or the in-app tool key, so it stays per-transport (its in-app
|
||||||
// aligned to `table` for consistency with the migrated trio below).
|
// param was still aligned to `table` for consistency with the migrated trio below).
|
||||||
|
|
||||||
tableInsertRow: {
|
tableInsertRow: {
|
||||||
mcpName: 'table_insert_row',
|
mcpName: 'tableInsertRow',
|
||||||
inAppKey: 'tableInsertRow',
|
inAppKey: 'tableInsertRow',
|
||||||
description:
|
description:
|
||||||
'Insert a row of plain-text cells into a table. `table` is `#<index>` ' +
|
'Insert a row of plain-text cells into a table. `table` is `#<index>` ' +
|
||||||
@@ -1527,7 +1678,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
tableDeleteRow: {
|
tableDeleteRow: {
|
||||||
mcpName: 'table_delete_row',
|
mcpName: 'tableDeleteRow',
|
||||||
inAppKey: 'tableDeleteRow',
|
inAppKey: 'tableDeleteRow',
|
||||||
description:
|
description:
|
||||||
'Delete the row at 0-based `index` from a table (`table` is `#<index>` ' +
|
'Delete the row at 0-based `index` from a table (`table` is `#<index>` ' +
|
||||||
@@ -1550,7 +1701,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
tableUpdateCell: {
|
tableUpdateCell: {
|
||||||
mcpName: 'table_update_cell',
|
mcpName: 'tableUpdateCell',
|
||||||
inAppKey: 'tableUpdateCell',
|
inAppKey: 'tableUpdateCell',
|
||||||
description:
|
description:
|
||||||
'Set the plain-text content of cell [row, col] (0-based) in a table ' +
|
'Set the plain-text content of cell [row, col] (0-based) in a table ' +
|
||||||
@@ -1590,7 +1741,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
// external MCP clients see identical tool names, fields and text.
|
// external MCP clients see identical tool names, fields and text.
|
||||||
|
|
||||||
insertFootnote: {
|
insertFootnote: {
|
||||||
mcpName: 'insert_footnote',
|
mcpName: 'insertFootnote',
|
||||||
inAppKey: 'insertFootnote',
|
inAppKey: 'insertFootnote',
|
||||||
description:
|
description:
|
||||||
'Insert an AUTHOR-INLINE footnote: you specify only WHERE (anchorText) ' +
|
'Insert an AUTHOR-INLINE footnote: you specify only WHERE (anchorText) ' +
|
||||||
@@ -1627,7 +1778,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
insertImage: {
|
insertImage: {
|
||||||
mcpName: 'insert_image',
|
mcpName: 'insertImage',
|
||||||
inAppKey: 'insertImage',
|
inAppKey: 'insertImage',
|
||||||
description:
|
description:
|
||||||
'Download an image from a web (http/https) URL and insert it into ' +
|
'Download an image from a web (http/https) URL and insert it into ' +
|
||||||
@@ -1671,7 +1822,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
replaceImage: {
|
replaceImage: {
|
||||||
mcpName: 'replace_image',
|
mcpName: 'replaceImage',
|
||||||
inAppKey: 'replaceImage',
|
inAppKey: 'replaceImage',
|
||||||
description:
|
description:
|
||||||
'Replace an existing image on a page with a new image fetched from a web ' +
|
'Replace an existing image on a page with a new image fetched from a web ' +
|
||||||
@@ -1709,15 +1860,15 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
// --- draw.io diagrams (issue #423 stage 1, #424 stage 2) ---
|
// --- draw.io diagrams (issue #423 stage 1, #424 stage 2) ---
|
||||||
|
|
||||||
drawioGet: {
|
drawioGet: {
|
||||||
mcpName: 'drawio_get',
|
mcpName: 'drawioGet',
|
||||||
inAppKey: 'drawioGet',
|
inAppKey: 'drawioGet',
|
||||||
description:
|
description:
|
||||||
'Read a draw.io diagram on a page as mxGraph XML (default) or as its raw ' +
|
'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 / ' +
|
'`.drawio.svg`. `node` is the drawio node\'s attrs.id (from getOutline / ' +
|
||||||
'get_page_json) or "#<index>" for a top-level block. Returns the decoded ' +
|
'getPageJson) or "#<index>" for a top-level block. Returns the decoded ' +
|
||||||
'mxGraphModel XML plus meta { attachmentId, title, width, height, ' +
|
'mxGraphModel XML plus meta { attachmentId, title, width, height, ' +
|
||||||
'cellCount, hash }. `hash` is the optimistic-lock key you MUST pass back ' +
|
'cellCount, hash }. `hash` is the optimistic-lock key you MUST pass back ' +
|
||||||
'as baseHash to drawio_update. Diagrams a human saved from the editor ' +
|
'as baseHash to drawioUpdate. Diagrams a human saved from the editor ' +
|
||||||
'(including draw.io\'s compressed format) decode losslessly.',
|
'(including draw.io\'s compressed format) decode losslessly.',
|
||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine:
|
catalogLine:
|
||||||
@@ -1742,7 +1893,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
drawioCreate: {
|
drawioCreate: {
|
||||||
mcpName: 'drawio_create',
|
mcpName: 'drawioCreate',
|
||||||
inAppKey: 'drawioCreate',
|
inAppKey: 'drawioCreate',
|
||||||
description:
|
description:
|
||||||
'Create a draw.io diagram from mxGraph XML and insert it as a diagram ' +
|
'Create a draw.io diagram from mxGraph XML and insert it as a diagram ' +
|
||||||
@@ -1753,14 +1904,14 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'source/target and every parent resolve, style parses, no XML comments, ' +
|
'source/target and every parent resolve, style parses, no XML comments, ' +
|
||||||
'value escaping) — a violation returns a structured error naming the rule ' +
|
'value escaping) — a violation returns a structured error naming the rule ' +
|
||||||
'and cellId so you can fix and retry. `where` positions the block like ' +
|
'and cellId so you can fix and retry. `where` positions the block like ' +
|
||||||
'insert_node: position before/after (with exactly one of anchorNodeId or ' +
|
'insertNode: position before/after (with exactly one of anchorNodeId or ' +
|
||||||
'anchorText) or append. Returns { nodeId, attachmentId, warnings }. The ' +
|
'anchorText) or append. Returns { nodeId, attachmentId, warnings }. The ' +
|
||||||
'returned `nodeId` is an index-based "#<index>" handle (drawio nodes carry ' +
|
'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 ' +
|
'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, ' +
|
'back into drawioGet / drawioUpdate for THIS document. It is positional, ' +
|
||||||
'so if you add or remove blocks before it, re-resolve via get_outline. The ' +
|
'so if you add or remove blocks before it, re-resolve via getOutline. The ' +
|
||||||
'diagram is editable in the draw.io editor and can be re-read with ' +
|
'diagram is editable in the draw.io editor and can be re-read with ' +
|
||||||
'drawio_get.' +
|
'drawioGet.' +
|
||||||
DRAWIO_HARD_RULES,
|
DRAWIO_HARD_RULES,
|
||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine:
|
catalogLine:
|
||||||
@@ -1812,14 +1963,14 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
drawioUpdate: {
|
drawioUpdate: {
|
||||||
mcpName: 'drawio_update',
|
mcpName: 'drawioUpdate',
|
||||||
inAppKey: 'drawioUpdate',
|
inAppKey: 'drawioUpdate',
|
||||||
description:
|
description:
|
||||||
'Replace a draw.io diagram\'s content with new mxGraph XML (same lint ' +
|
'Replace a draw.io diagram\'s content with new mxGraph XML (same lint ' +
|
||||||
'pipeline as drawio_create). `baseHash` is MANDATORY: pass the hash from ' +
|
'pipeline as drawioCreate). `baseHash` is MANDATORY: pass the hash from ' +
|
||||||
'the drawio_get you based the edit on. If the diagram changed since ' +
|
'the drawioGet you based the edit on. If the diagram changed since ' +
|
||||||
'(a human or another agent edited it) the hash mismatches and the update ' +
|
'(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 ' +
|
'is refused with a conflict error — re-read with drawioGet and retry. On ' +
|
||||||
'success it overwrites the diagram attachment and updates the node ' +
|
'success it overwrites the diagram attachment and updates the node ' +
|
||||||
'width/height. `node` is the drawio node attrs.id or "#<index>".' +
|
'width/height. `node` is the drawio node attrs.id or "#<index>".' +
|
||||||
DRAWIO_HARD_RULES,
|
DRAWIO_HARD_RULES,
|
||||||
@@ -1841,7 +1992,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
baseHash: z
|
baseHash: z
|
||||||
.string()
|
.string()
|
||||||
.min(1)
|
.min(1)
|
||||||
.describe('The meta.hash from the drawio_get this edit is based on.'),
|
.describe('The meta.hash from the drawioGet this edit is based on.'),
|
||||||
layout: z
|
layout: z
|
||||||
.enum(['elk'])
|
.enum(['elk'])
|
||||||
.optional()
|
.optional()
|
||||||
@@ -1863,7 +2014,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
drawioShapes: {
|
drawioShapes: {
|
||||||
mcpName: 'drawio_shapes',
|
mcpName: 'drawioShapes',
|
||||||
inAppKey: 'drawioShapes',
|
inAppKey: 'drawioShapes',
|
||||||
description:
|
description:
|
||||||
'Look up VERIFIED draw.io stencil style-strings so you never guess a ' +
|
'Look up VERIFIED draw.io stencil style-strings so you never guess a ' +
|
||||||
@@ -1876,7 +2027,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'stencils to working replacements (e.g. dynamodb_table -> dynamodb) with a ' +
|
'stencils to working replacements (e.g. dynamodb_table -> dynamodb) with a ' +
|
||||||
'note. Each hit is { style, w, h, title, type, category?, note? } — copy ' +
|
'note. Each hit is { style, w, h, title, type, category?, note? } — copy ' +
|
||||||
'`style` verbatim onto the cell and use w/h as the default size. Call this ' +
|
'`style` verbatim onto the cell and use w/h as the default size. Call this ' +
|
||||||
'BEFORE drawio_create/drawio_update whenever you need a specific icon ' +
|
'BEFORE drawioCreate/drawioUpdate whenever you need a specific icon ' +
|
||||||
'(AWS/Azure/GCP/network/UML/flowchart).',
|
'(AWS/Azure/GCP/network/UML/flowchart).',
|
||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine:
|
catalogLine:
|
||||||
@@ -1895,7 +2046,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
.optional()
|
.optional()
|
||||||
.describe('Max results (default 12, capped at 50).'),
|
.describe('Max results (default 12, capped at 50).'),
|
||||||
}),
|
}),
|
||||||
// INLINE on both hosts (no `execute`): drawio_shapes calls the PURE helper
|
// INLINE on both hosts (no `execute`): drawioShapes calls the PURE helper
|
||||||
// searchShapes, which is NOT a client method — it reads the bundled shape
|
// searchShapes, which is NOT a client method — it reads the bundled shape
|
||||||
// catalog via `import.meta.url` (drawio-shapes.ts). tool-specs.ts is
|
// catalog via `import.meta.url` (drawio-shapes.ts). tool-specs.ts is
|
||||||
// type-checked FROM SOURCE by the in-app server under module:commonjs, where a
|
// type-checked FROM SOURCE by the in-app server under module:commonjs, where a
|
||||||
@@ -1909,7 +2060,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
drawioGuide: {
|
drawioGuide: {
|
||||||
mcpName: 'drawio_guide',
|
mcpName: 'drawioGuide',
|
||||||
inAppKey: 'drawioGuide',
|
inAppKey: 'drawioGuide',
|
||||||
description:
|
description:
|
||||||
'Progressive-disclosure draw.io authoring reference. Call with a `section` ' +
|
'Progressive-disclosure draw.io authoring reference. Call with a `section` ' +
|
||||||
@@ -1920,7 +2071,7 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'child coords, cross-container edges, swimlanes), "icons-aws" (the ' +
|
'child coords, cross-container edges, swimlanes), "icons-aws" (the ' +
|
||||||
'service/resource icon patterns, category colors, rebrandings, blocklist), ' +
|
'service/resource icon patterns, category colors, rebrandings, blocklist), ' +
|
||||||
'"icons-azure" (portable image-style paths). Omit `section` to get the ' +
|
'"icons-azure" (portable image-style paths). Omit `section` to get the ' +
|
||||||
'index of sections. Pair with drawio_shapes for exact stencil styles.',
|
'index of sections. Pair with drawioShapes for exact stencil styles.',
|
||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine:
|
catalogLine:
|
||||||
'drawioGuide — on-demand draw.io authoring reference (skeleton/layout/containers/icons).',
|
'drawioGuide — on-demand draw.io authoring reference (skeleton/layout/containers/icons).',
|
||||||
@@ -1930,10 +2081,10 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
.optional()
|
.optional()
|
||||||
.describe('Which section to read; omit for the section index.'),
|
.describe('Which section to read; omit for the section index.'),
|
||||||
}),
|
}),
|
||||||
// INLINE on both hosts (no `execute`) — same reason as drawio_shapes above:
|
// INLINE on both hosts (no `execute`) — same reason as drawioShapes above:
|
||||||
// drawio_guide calls the PURE helper getGuideSection (drawio-guide.ts, no
|
// drawioGuide calls the PURE helper getGuideSection (drawio-guide.ts, no
|
||||||
// client, no network). getGuideSection itself has no `import.meta`, but it is
|
// client, no network). getGuideSection itself has no `import.meta`, but it is
|
||||||
// kept inline for SYMMETRY with drawio_shapes (both drawio helper tools wired
|
// kept inline for SYMMETRY with drawioShapes (both drawio helper tools wired
|
||||||
// the same way in one place) and to avoid pulling any drawio lib source into
|
// the same way in one place) and to avoid pulling any drawio lib source into
|
||||||
// the in-app server's commonjs type-check. `inlineBothHosts` makes both loops
|
// the in-app server's commonjs type-check. `inlineBothHosts` makes both loops
|
||||||
// skip it; index.ts and ai-chat-tools.service.ts register it directly.
|
// skip it; index.ts and ai-chat-tools.service.ts register it directly.
|
||||||
|
|||||||
+76
-76
@@ -84,20 +84,20 @@ async function main() {
|
|||||||
let pageId = null;
|
let pageId = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. create_page: title with spaces must survive (was: underscores bug)
|
// 1. createPage: title with spaces must survive (was: underscores bug)
|
||||||
const created = await client.createPage("Тест апгрейда MCP сервера", MD, spaceId);
|
const created = await client.createPage("Тест апгрейда MCP сервера", MD, spaceId);
|
||||||
pageId = created.data.id;
|
pageId = created.data.id;
|
||||||
check("create_page: title keeps spaces", created.data.title === "Тест апгрейда MCP сервера", created.data.title);
|
check("createPage: title keeps spaces", created.data.title === "Тест апгрейда MCP сервера", created.data.title);
|
||||||
check("create_page: slugId exposed", typeof created.data.slugId === "string" && created.data.slugId.length > 0, created.data.slugId);
|
check("createPage: slugId exposed", typeof created.data.slugId === "string" && created.data.slugId.length > 0, created.data.slugId);
|
||||||
|
|
||||||
// 2. get_page_json: raw ProseMirror with callout + table
|
// 2. getPageJson: raw ProseMirror with callout + table
|
||||||
const pj = await client.getPageJson(pageId);
|
const pj = await client.getPageJson(pageId);
|
||||||
const types = pj.content.content.map((n) => n.type);
|
const types = pj.content.content.map((n) => n.type);
|
||||||
check("get_page_json: callout node present", types.includes("callout"), types.join(","));
|
check("getPageJson: callout node present", types.includes("callout"), types.join(","));
|
||||||
check("get_page_json: table node present", types.includes("table"));
|
check("getPageJson: table node present", types.includes("table"));
|
||||||
check("get_page_json: slugId present", !!pj.slugId);
|
check("getPageJson: slugId present", !!pj.slugId);
|
||||||
|
|
||||||
// 3. edit_page_text: surgical replace, ids preserved
|
// 3. editPageText: surgical replace, ids preserved
|
||||||
const idsBefore = JSON.stringify(
|
const idsBefore = JSON.stringify(
|
||||||
pj.content.content.filter((n) => n.attrs?.id).map((n) => n.attrs.id),
|
pj.content.content.filter((n) => n.attrs?.id).map((n) => n.attrs.id),
|
||||||
);
|
);
|
||||||
@@ -105,26 +105,26 @@ async function main() {
|
|||||||
{ find: "БУКВОЕД", replace: "КНИГОЛЮБ" },
|
{ find: "БУКВОЕД", replace: "КНИГОЛЮБ" },
|
||||||
{ find: "[1]", replace: "[42]" },
|
{ find: "[1]", replace: "[42]" },
|
||||||
]);
|
]);
|
||||||
check("edit_page_text: both edits applied", editRes.applied.every((e) => e.replacements === 1));
|
check("editPageText: both edits applied", editRes.applied.every((e) => e.replacements === 1));
|
||||||
await new Promise((r) => setTimeout(r, 16000)); // wait for server persistence
|
await new Promise((r) => setTimeout(r, 16000)); // wait for server persistence
|
||||||
const pj2 = await client.getPageJson(pageId);
|
const pj2 = await client.getPageJson(pageId);
|
||||||
const text2 = JSON.stringify(pj2.content);
|
const text2 = JSON.stringify(pj2.content);
|
||||||
check("edit_page_text: replacement visible", text2.includes("КНИГОЛЮБ") && text2.includes("[42]"));
|
check("editPageText: replacement visible", text2.includes("КНИГОЛЮБ") && text2.includes("[42]"));
|
||||||
check("edit_page_text: old text gone", !text2.includes("БУКВОЕД"));
|
check("editPageText: old text gone", !text2.includes("БУКВОЕД"));
|
||||||
const idsAfter = JSON.stringify(
|
const idsAfter = JSON.stringify(
|
||||||
pj2.content.content.filter((n) => n.attrs?.id).map((n) => n.attrs.id),
|
pj2.content.content.filter((n) => n.attrs?.id).map((n) => n.attrs.id),
|
||||||
);
|
);
|
||||||
check("edit_page_text: block ids preserved", idsBefore === idsAfter);
|
check("editPageText: block ids preserved", idsBefore === idsAfter);
|
||||||
check("edit_page_text: callout survived", JSON.stringify(pj2.content).includes('"callout"'));
|
check("editPageText: callout survived", JSON.stringify(pj2.content).includes('"callout"'));
|
||||||
check("edit_page_text: table survived", pj2.content.content.some((n) => n.type === "table"));
|
check("editPageText: table survived", pj2.content.content.some((n) => n.type === "table"));
|
||||||
|
|
||||||
// 4. error reporting: ambiguous and missing finds
|
// 4. error reporting: ambiguous and missing finds
|
||||||
let err1 = "";
|
let err1 = "";
|
||||||
try { await client.editPageText(pageId, [{ find: "Колонка", replace: "X" }]); } catch (e) { err1 = e.message; }
|
try { await client.editPageText(pageId, [{ find: "Колонка", replace: "X" }]); } catch (e) { err1 = e.message; }
|
||||||
check("edit_page_text: ambiguous match rejected", err1.includes("matches"), err1);
|
check("editPageText: ambiguous match rejected", err1.includes("matches"), err1);
|
||||||
let err2 = "";
|
let err2 = "";
|
||||||
try { await client.editPageText(pageId, [{ find: "НЕСУЩЕСТВУЮЩЕЕ", replace: "X" }]); } catch (e) { err2 = e.message; }
|
try { await client.editPageText(pageId, [{ find: "НЕСУЩЕСТВУЮЩЕЕ", replace: "X" }]); } catch (e) { err2 = e.message; }
|
||||||
check("edit_page_text: missing text reported", err2.includes("not found"), err2);
|
check("editPageText: missing text reported", err2.includes("not found"), err2);
|
||||||
|
|
||||||
// 5. update_page (markdown): table + callout must survive the re-import
|
// 5. update_page (markdown): table + callout must survive the re-import
|
||||||
await client.updatePage(pageId, MD + "\nДобавленный абзац.\n");
|
await client.updatePage(pageId, MD + "\nДобавленный абзац.\n");
|
||||||
@@ -137,21 +137,21 @@ async function main() {
|
|||||||
const cellText = JSON.stringify(tableNode);
|
const cellText = JSON.stringify(tableNode);
|
||||||
check("update_page md: table cells intact", cellText.includes("четыре") && cellText.includes("Колонка А"));
|
check("update_page md: table cells intact", cellText.includes("четыре") && cellText.includes("Колонка А"));
|
||||||
|
|
||||||
// 6. update_page_json: lossless write round-trip
|
// 6. updatePageJson: lossless write round-trip
|
||||||
pj3.content.content.push({
|
pj3.content.content.push({
|
||||||
type: "paragraph",
|
type: "paragraph",
|
||||||
attrs: { id: "testidjsonpush", indent: 0, textAlign: null },
|
attrs: { id: "testidjsonpush", indent: 0, textAlign: null },
|
||||||
content: [{ type: "text", text: "Абзац, добавленный через update_page_json." }],
|
content: [{ type: "text", text: "Абзац, добавленный через updatePageJson." }],
|
||||||
});
|
});
|
||||||
await client.updatePageJson(pageId, pj3.content);
|
await client.updatePageJson(pageId, pj3.content);
|
||||||
await new Promise((r) => setTimeout(r, 16000));
|
await new Promise((r) => setTimeout(r, 16000));
|
||||||
const pj4 = await client.getPageJson(pageId);
|
const pj4 = await client.getPageJson(pageId);
|
||||||
const lastNode = pj4.content.content[pj4.content.content.length - 1];
|
const lastNode = pj4.content.content[pj4.content.content.length - 1];
|
||||||
check("update_page_json: paragraph appended", JSON.stringify(pj4.content).includes("добавленный через update_page_json"));
|
check("updatePageJson: paragraph appended", JSON.stringify(pj4.content).includes("добавленный через updatePageJson"));
|
||||||
check("update_page_json: custom node id preserved", lastNode.attrs?.id === "testidjsonpush", lastNode.attrs?.id);
|
check("updatePageJson: custom node id preserved", lastNode.attrs?.id === "testidjsonpush", lastNode.attrs?.id);
|
||||||
|
|
||||||
// 6b. images: upload / insert / replace (clean src, fresh attachment on replace).
|
// 6b. images: upload / insert / replace (clean src, fresh attachment on replace).
|
||||||
// insert_image / replace_image take an http(s) URL that the SERVER fetches;
|
// insertImage / replaceImage take an http(s) URL that the SERVER fetches;
|
||||||
// local file paths are intentionally unsupported. The Docmost server runs on
|
// local file paths are intentionally unsupported. The Docmost server runs on
|
||||||
// the same host as this test, so serve the PNG bytes over a throwaway
|
// the same host as this test, so serve the PNG bytes over a throwaway
|
||||||
// localhost HTTP server it can reach.
|
// localhost HTTP server it can reach.
|
||||||
@@ -186,13 +186,13 @@ async function main() {
|
|||||||
validateStatus: () => true,
|
validateStatus: () => true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// insert_image: append the first PNG, src must be clean (no ?v=) and fetchable.
|
// insertImage: append the first PNG, src must be clean (no ?v=) and fetchable.
|
||||||
const ins = await client.insertImage(pageId, urlA);
|
const ins = await client.insertImage(pageId, urlA);
|
||||||
check("insert_image: src has no ?v= cache-buster", !ins.src.includes("?v="), ins.src);
|
check("insertImage: src has no ?v= cache-buster", !ins.src.includes("?v="), ins.src);
|
||||||
const fileA = await fetchFile(ins.src);
|
const fileA = await fetchFile(ins.src);
|
||||||
check("insert_image: file fetch returns 200", fileA.status === 200, `status=${fileA.status}`);
|
check("insertImage: file fetch returns 200", fileA.status === 200, `status=${fileA.status}`);
|
||||||
check(
|
check(
|
||||||
"insert_image: content-type is image/*",
|
"insertImage: content-type is image/*",
|
||||||
String(fileA.headers["content-type"] || "").startsWith("image/"),
|
String(fileA.headers["content-type"] || "").startsWith("image/"),
|
||||||
String(fileA.headers["content-type"]),
|
String(fileA.headers["content-type"]),
|
||||||
);
|
);
|
||||||
@@ -209,25 +209,25 @@ async function main() {
|
|||||||
};
|
};
|
||||||
const imgNode = findImage(pjImg.content.content);
|
const imgNode = findImage(pjImg.content.content);
|
||||||
const oldAttachmentId = imgNode?.attrs?.attachmentId;
|
const oldAttachmentId = imgNode?.attrs?.attachmentId;
|
||||||
check("insert_image: image node present after persist", !!oldAttachmentId, oldAttachmentId);
|
check("insertImage: image node present after persist", !!oldAttachmentId, oldAttachmentId);
|
||||||
|
|
||||||
// replace_image: must create a NEW attachment with a clean, fetchable URL.
|
// replaceImage: must create a NEW attachment with a clean, fetchable URL.
|
||||||
// The 200 fetch is the assertion that catches the in-place-overwrite HTTP 500 regression.
|
// The 200 fetch is the assertion that catches the in-place-overwrite HTTP 500 regression.
|
||||||
const rep = await client.replaceImage(pageId, oldAttachmentId, urlB);
|
const rep = await client.replaceImage(pageId, oldAttachmentId, urlB);
|
||||||
check("replace_image: new attachment id differs from old", rep.newAttachmentId !== oldAttachmentId, `${oldAttachmentId} -> ${rep.newAttachmentId}`);
|
check("replaceImage: new attachment id differs from old", rep.newAttachmentId !== oldAttachmentId, `${oldAttachmentId} -> ${rep.newAttachmentId}`);
|
||||||
check("replace_image: src has no ?v= cache-buster", !rep.src.includes("?v="), rep.src);
|
check("replaceImage: src has no ?v= cache-buster", !rep.src.includes("?v="), rep.src);
|
||||||
const fileB = await fetchFile(rep.src);
|
const fileB = await fetchFile(rep.src);
|
||||||
check("replace_image: new file fetch returns 200", fileB.status === 200, `status=${fileB.status}`);
|
check("replaceImage: new file fetch returns 200", fileB.status === 200, `status=${fileB.status}`);
|
||||||
check(
|
check(
|
||||||
"replace_image: new content-type is image/*",
|
"replaceImage: new content-type is image/*",
|
||||||
String(fileB.headers["content-type"] || "").startsWith("image/"),
|
String(fileB.headers["content-type"] || "").startsWith("image/"),
|
||||||
String(fileB.headers["content-type"]),
|
String(fileB.headers["content-type"]),
|
||||||
);
|
);
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 16000));
|
await new Promise((r) => setTimeout(r, 16000));
|
||||||
const pjImg2 = await client.getPageJson(pageId);
|
const pjImg2 = await client.getPageJson(pageId);
|
||||||
check("replace_image: page has new attachment id", !!findImage(pjImg2.content.content, rep.newAttachmentId), rep.newAttachmentId);
|
check("replaceImage: page has new attachment id", !!findImage(pjImg2.content.content, rep.newAttachmentId), rep.newAttachmentId);
|
||||||
check("replace_image: old attachment id repointed away", !findImage(pjImg2.content.content, oldAttachmentId), oldAttachmentId);
|
check("replaceImage: old attachment id repointed away", !findImage(pjImg2.content.content, oldAttachmentId), oldAttachmentId);
|
||||||
} finally {
|
} finally {
|
||||||
imgServer.close();
|
imgServer.close();
|
||||||
}
|
}
|
||||||
@@ -275,10 +275,10 @@ async function main() {
|
|||||||
await client.editPageText(fid, [{ find: "PRICEMARK", replace: "$& costs $100" }]);
|
await client.editPageText(fid, [{ find: "PRICEMARK", replace: "$& costs $100" }]);
|
||||||
await new Promise((r) => setTimeout(r, 16000));
|
await new Promise((r) => setTimeout(r, 16000));
|
||||||
const ftext = JSON.stringify((await client.getPageJson(fid)).content);
|
const ftext = JSON.stringify((await client.getPageJson(fid)).content);
|
||||||
check("feature: edit_page_text inserts $-pattern literally (no $& expansion)", ftext.includes("$& costs $100") && !ftext.includes("PRICEMARK costs"));
|
check("feature: editPageText inserts $-pattern literally (no $& expansion)", ftext.includes("$& costs $100") && !ftext.includes("PRICEMARK costs"));
|
||||||
let badThrew = false;
|
let badThrew = false;
|
||||||
try { await client.replaceImage(fid, "00000000-0000-0000-0000-000000000000", featPng); } catch (e) { badThrew = /no image with attachmentId/.test(e.message); }
|
try { await client.replaceImage(fid, "00000000-0000-0000-0000-000000000000", featPng); } catch (e) { badThrew = /no image with attachmentId/.test(e.message); }
|
||||||
check("feature: replace_image with unknown id throws (no orphan upload)", badThrew);
|
check("feature: replaceImage with unknown id throws (no orphan upload)", badThrew);
|
||||||
} finally {
|
} finally {
|
||||||
try { await client.deletePage(fid); } catch {}
|
try { await client.deletePage(fid); } catch {}
|
||||||
try { unlinkSync(featPng); } catch {}
|
try { unlinkSync(featPng); } catch {}
|
||||||
@@ -286,7 +286,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 6d. node ops: patch / insert / delete a block by id on a throwaway page.
|
// 6d. node ops: patch / insert / delete a block by id on a throwaway page.
|
||||||
// Three paragraphs are written with KNOWN ids via update_page_json so the
|
// Three paragraphs are written with KNOWN ids via updatePageJson so the
|
||||||
// ids can be targeted directly; each op is verified via getPageJson after
|
// ids can be targeted directly; each op is verified via getPageJson after
|
||||||
// the standard 16s persistence wait.
|
// the standard 16s persistence wait.
|
||||||
{
|
{
|
||||||
@@ -348,7 +348,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6e. rename_page: title-only update must leave the content untouched.
|
// 6e. renamePage: title-only update must leave the content untouched.
|
||||||
{
|
{
|
||||||
const rp = await client.createPage("E2E rename before " + Date.now(), "Rename body marker RENAMEBODY.", spaceId);
|
const rp = await client.createPage("E2E rename before " + Date.now(), "Rename body marker RENAMEBODY.", spaceId);
|
||||||
const rid = rp.data.id;
|
const rid = rp.data.id;
|
||||||
@@ -357,19 +357,19 @@ async function main() {
|
|||||||
const beforeContent = JSON.stringify(beforeJson);
|
const beforeContent = JSON.stringify(beforeJson);
|
||||||
const newTitle = "E2E rename AFTER " + Date.now();
|
const newTitle = "E2E rename AFTER " + Date.now();
|
||||||
const rr = await client.renamePage(rid, newTitle);
|
const rr = await client.renamePage(rid, newTitle);
|
||||||
check("rename_page: returns success+title", rr.success === true && rr.title === newTitle, JSON.stringify(rr));
|
check("renamePage: returns success+title", rr.success === true && rr.title === newTitle, JSON.stringify(rr));
|
||||||
await new Promise((r) => setTimeout(r, 16000));
|
await new Promise((r) => setTimeout(r, 16000));
|
||||||
const afterJson = await client.getPageJson(rid);
|
const afterJson = await client.getPageJson(rid);
|
||||||
check("rename_page: title changed", afterJson.title === newTitle, afterJson.title);
|
check("renamePage: title changed", afterJson.title === newTitle, afterJson.title);
|
||||||
check("rename_page: content unchanged", JSON.stringify(afterJson.content) === beforeContent && beforeContent.includes("RENAMEBODY"));
|
check("renamePage: content unchanged", JSON.stringify(afterJson.content) === beforeContent && beforeContent.includes("RENAMEBODY"));
|
||||||
const afterMd = (await client.getPage(rid)).data;
|
const afterMd = (await client.getPage(rid)).data;
|
||||||
check("rename_page: get_page reflects new title", afterMd.title === newTitle, afterMd.title);
|
check("renamePage: getPage reflects new title", afterMd.title === newTitle, afterMd.title);
|
||||||
} finally {
|
} finally {
|
||||||
try { await client.deletePage(rid); } catch {}
|
try { await client.deletePage(rid); } catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6f. update_page_json title-only: omitting content updates the title and
|
// 6f. updatePageJson title-only: omitting content updates the title and
|
||||||
// leaves the body intact; supplying neither content nor title throws.
|
// leaves the body intact; supplying neither content nor title throws.
|
||||||
{
|
{
|
||||||
const up = await client.createPage("E2E upj-title before " + Date.now(), "Title-only body marker UPJTITLEBODY.", spaceId);
|
const up = await client.createPage("E2E upj-title before " + Date.now(), "Title-only body marker UPJTITLEBODY.", spaceId);
|
||||||
@@ -378,20 +378,20 @@ async function main() {
|
|||||||
const beforeContent = JSON.stringify((await client.getPageJson(uid)).content);
|
const beforeContent = JSON.stringify((await client.getPageJson(uid)).content);
|
||||||
const newTitle = "E2E upj-title AFTER " + Date.now();
|
const newTitle = "E2E upj-title AFTER " + Date.now();
|
||||||
const ur = await client.updatePageJson(uid, undefined, newTitle);
|
const ur = await client.updatePageJson(uid, undefined, newTitle);
|
||||||
check("update_page_json title-only: succeeds", ur.success === true, JSON.stringify(ur));
|
check("updatePageJson title-only: succeeds", ur.success === true, JSON.stringify(ur));
|
||||||
await new Promise((r) => setTimeout(r, 16000));
|
await new Promise((r) => setTimeout(r, 16000));
|
||||||
const afterJson = await client.getPageJson(uid);
|
const afterJson = await client.getPageJson(uid);
|
||||||
check("update_page_json title-only: title updated", afterJson.title === newTitle, afterJson.title);
|
check("updatePageJson title-only: title updated", afterJson.title === newTitle, afterJson.title);
|
||||||
check("update_page_json title-only: content intact", JSON.stringify(afterJson.content) === beforeContent && beforeContent.includes("UPJTITLEBODY"));
|
check("updatePageJson title-only: content intact", JSON.stringify(afterJson.content) === beforeContent && beforeContent.includes("UPJTITLEBODY"));
|
||||||
let upjErr = "";
|
let upjErr = "";
|
||||||
try { await client.updatePageJson(uid); } catch (e) { upjErr = e.message; }
|
try { await client.updatePageJson(uid); } catch (e) { upjErr = e.message; }
|
||||||
check("update_page_json: neither content nor title throws", upjErr.includes("nothing to update"), upjErr);
|
check("updatePageJson: neither content nor title throws", upjErr.includes("nothing to update"), upjErr);
|
||||||
} finally {
|
} finally {
|
||||||
try { await client.deletePage(uid); } catch {}
|
try { await client.deletePage(uid); } catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6g. copy_page_content: B's body becomes a copy of A's body, server-side,
|
// 6g. copyPageContent: B's body becomes a copy of A's body, server-side,
|
||||||
// while B's title/slugId stay put. Both pages are throwaways.
|
// while B's title/slugId stay put. Both pages are throwaways.
|
||||||
{
|
{
|
||||||
let aid = null;
|
let aid = null;
|
||||||
@@ -409,24 +409,24 @@ async function main() {
|
|||||||
const aNodeCount = aJson.content.content.length;
|
const aNodeCount = aJson.content.content.length;
|
||||||
|
|
||||||
const cr = await client.copyPageContent(aid, bid);
|
const cr = await client.copyPageContent(aid, bid);
|
||||||
check("copy_page_content: returns success + node count", cr.success === true && cr.copiedNodes === aNodeCount, JSON.stringify(cr));
|
check("copyPageContent: returns success + node count", cr.success === true && cr.copiedNodes === aNodeCount, JSON.stringify(cr));
|
||||||
await new Promise((r) => setTimeout(r, 16000));
|
await new Promise((r) => setTimeout(r, 16000));
|
||||||
|
|
||||||
const bAfter = await client.getPageJson(bid);
|
const bAfter = await client.getPageJson(bid);
|
||||||
const bText = JSON.stringify(bAfter.content);
|
const bText = JSON.stringify(bAfter.content);
|
||||||
check("copy_page_content: B now has A's marker", bText.includes("COPYSOURCE"));
|
check("copyPageContent: B now has A's marker", bText.includes("COPYSOURCE"));
|
||||||
check("copy_page_content: B's old marker gone", !bText.includes("COPYTARGET"));
|
check("copyPageContent: B's old marker gone", !bText.includes("COPYTARGET"));
|
||||||
check("copy_page_content: B node count equals A's", bAfter.content.content.length === aNodeCount, `${bAfter.content.content.length} vs ${aNodeCount}`);
|
check("copyPageContent: B node count equals A's", bAfter.content.content.length === aNodeCount, `${bAfter.content.content.length} vs ${aNodeCount}`);
|
||||||
check("copy_page_content: B title unchanged", bAfter.title === bTitleBefore, bAfter.title);
|
check("copyPageContent: B title unchanged", bAfter.title === bTitleBefore, bAfter.title);
|
||||||
check("copy_page_content: B slugId unchanged", bAfter.slugId === bSlugBefore, bAfter.slugId);
|
check("copyPageContent: B slugId unchanged", bAfter.slugId === bSlugBefore, bAfter.slugId);
|
||||||
|
|
||||||
// Source must be left untouched by the copy.
|
// Source must be left untouched by the copy.
|
||||||
const aAfter = JSON.stringify((await client.getPageJson(aid)).content);
|
const aAfter = JSON.stringify((await client.getPageJson(aid)).content);
|
||||||
check("copy_page_content: source page unchanged", aAfter === JSON.stringify(aJson.content) && aAfter.includes("COPYSOURCE"));
|
check("copyPageContent: source page unchanged", aAfter === JSON.stringify(aJson.content) && aAfter.includes("COPYSOURCE"));
|
||||||
|
|
||||||
let copyErr = "";
|
let copyErr = "";
|
||||||
try { await client.copyPageContent(aid, aid); } catch (e) { copyErr = e.message; }
|
try { await client.copyPageContent(aid, aid); } catch (e) { copyErr = e.message; }
|
||||||
check("copy_page_content: self-copy rejected", copyErr.includes("same page"), copyErr);
|
check("copyPageContent: self-copy rejected", copyErr.includes("same page"), copyErr);
|
||||||
} finally {
|
} finally {
|
||||||
try { if (bid) await client.deletePage(bid); } catch {}
|
try { if (bid) await client.deletePage(bid); } catch {}
|
||||||
try { if (aid) await client.deletePage(aid); } catch {}
|
try { if (aid) await client.deletePage(aid); } catch {}
|
||||||
@@ -435,22 +435,22 @@ async function main() {
|
|||||||
|
|
||||||
// 7. shares: create (idempotent), public access, list, unshare
|
// 7. shares: create (idempotent), public access, list, unshare
|
||||||
const share = await client.sharePage(pageId);
|
const share = await client.sharePage(pageId);
|
||||||
check("share_page: returns public URL", share.publicUrl?.startsWith(`${APP}/share/`), share.publicUrl);
|
check("sharePage: returns public URL", share.publicUrl?.startsWith(`${APP}/share/`), share.publicUrl);
|
||||||
const share2 = await client.sharePage(pageId);
|
const share2 = await client.sharePage(pageId);
|
||||||
check("share_page: idempotent", share2.key === share.key);
|
check("sharePage: idempotent", share2.key === share.key);
|
||||||
const anon = await axios.post(`${API}/shares/page-info`, { pageId: pj4.slugId, shareId: share.key }, { validateStatus: () => true });
|
const anon = await axios.post(`${API}/shares/page-info`, { pageId: pj4.slugId, shareId: share.key }, { validateStatus: () => true });
|
||||||
check("share_page: anonymous access works", anon.status === 200);
|
check("sharePage: anonymous access works", anon.status === 200);
|
||||||
const shares = await client.listShares();
|
const shares = await client.listShares();
|
||||||
check("list_shares: contains our page", shares.some((s) => s.pageId === pageId && s.publicUrl === share.publicUrl));
|
check("listShares: contains our page", shares.some((s) => s.pageId === pageId && s.publicUrl === share.publicUrl));
|
||||||
const un = await client.unsharePage(pageId);
|
const un = await client.unsharePage(pageId);
|
||||||
check("unshare_page: success", un.success === true);
|
check("unsharePage: success", un.success === true);
|
||||||
const anon2 = await axios.post(`${API}/shares/page-info`, { pageId: pj4.slugId, shareId: share.key }, { validateStatus: () => true });
|
const anon2 = await axios.post(`${API}/shares/page-info`, { pageId: pj4.slugId, shareId: share.key }, { validateStatus: () => true });
|
||||||
check("unshare_page: public access revoked", anon2.status !== 200, `status=${anon2.status}`);
|
check("unsharePage: public access revoked", anon2.status !== 200, `status=${anon2.status}`);
|
||||||
|
|
||||||
// 8. get_page markdown round-trip sanity (table separator present)
|
// 8. getPage markdown round-trip sanity (table separator present)
|
||||||
const md = await client.getPage(pageId);
|
const md = await client.getPage(pageId);
|
||||||
check("get_page md: table separator emitted", md.data.content.includes("| --- |"), "");
|
check("getPage md: table separator emitted", md.data.content.includes("| --- |"), "");
|
||||||
check("get_page md: callout exported as Obsidian '> [!info]'", md.data.content.includes("> [!info]"));
|
check("getPage md: callout exported as Obsidian '> [!info]'", md.data.content.includes("> [!info]"));
|
||||||
|
|
||||||
// 9. comments: create / list / reply / update / check_new / delete
|
// 9. comments: create / list / reply / update / check_new / delete
|
||||||
const beforeComments = new Date(Date.now() - 1000).toISOString();
|
const beforeComments = new Date(Date.now() - 1000).toISOString();
|
||||||
@@ -458,34 +458,34 @@ async function main() {
|
|||||||
// that exists in the persisted page to anchor on. "Добавленный абзац." is a
|
// that exists in the persisted page to anchor on. "Добавленный абзац." is a
|
||||||
// plain paragraph re-imported in section 5 and still present here.
|
// plain paragraph re-imported in section 5 and still present here.
|
||||||
const c1 = await client.createComment(pageId, "Первый **комментарий** с [ссылкой](https://example.com).", "inline", "Добавленный абзац.");
|
const c1 = await client.createComment(pageId, "Первый **комментарий** с [ссылкой](https://example.com).", "inline", "Добавленный абзац.");
|
||||||
check("create_comment: created", !!c1.data.id, c1.data.id);
|
check("createComment: created", !!c1.data.id, c1.data.id);
|
||||||
check("create_comment: markdown round-trip", c1.data.content.includes("**комментарий**"), c1.data.content);
|
check("createComment: markdown round-trip", c1.data.content.includes("**комментарий**"), c1.data.content);
|
||||||
const reply = await client.createComment(pageId, "Ответ на комментарий.", "page", undefined, c1.data.id);
|
const reply = await client.createComment(pageId, "Ответ на комментарий.", "page", undefined, c1.data.id);
|
||||||
check("create_comment: reply has parent", reply.data.parentCommentId === c1.data.id);
|
check("createComment: reply has parent", reply.data.parentCommentId === c1.data.id);
|
||||||
const list = (await client.listComments(pageId)).items;
|
const list = (await client.listComments(pageId)).items;
|
||||||
check("list_comments: both visible", list.length === 2, `count=${list.length}`);
|
check("listComments: both visible", list.length === 2, `count=${list.length}`);
|
||||||
await client.updateComment(c1.data.id, "Обновлённый текст комментария.");
|
await client.updateComment(c1.data.id, "Обновлённый текст комментария.");
|
||||||
const got = await client.getComment(c1.data.id);
|
const got = await client.getComment(c1.data.id);
|
||||||
check("update_comment + get_comment: content updated", got.data.content.includes("Обновлённый"), got.data.content);
|
check("updateComment + get_comment: content updated", got.data.content.includes("Обновлённый"), got.data.content);
|
||||||
const news = await client.checkNewComments(spaceId, beforeComments, pageId);
|
const news = await client.checkNewComments(spaceId, beforeComments, pageId);
|
||||||
check("check_new_comments: finds new comments in subtree", news.totalNewComments >= 2, `total=${news.totalNewComments}`);
|
check("checkNewComments: finds new comments in subtree", news.totalNewComments >= 2, `total=${news.totalNewComments}`);
|
||||||
// resolve_comment: close the top-level thread, verify resolvedAt surfaces, then reopen
|
// resolveComment: close the top-level thread, verify resolvedAt surfaces, then reopen
|
||||||
const resolvedRes = await client.resolveComment(c1.data.id, true);
|
const resolvedRes = await client.resolveComment(c1.data.id, true);
|
||||||
check("resolve_comment: marks resolved", resolvedRes.success === true && resolvedRes.resolved === true);
|
check("resolveComment: marks resolved", resolvedRes.success === true && resolvedRes.resolved === true);
|
||||||
// c1 is now resolved; the default feed hides resolved threads, so pass
|
// c1 is now resolved; the default feed hides resolved threads, so pass
|
||||||
// includeResolved:true to still see it and assert its resolvedAt (#328).
|
// includeResolved:true to still see it and assert its resolvedAt (#328).
|
||||||
const listResolved = (await client.listComments(pageId, true)).items;
|
const listResolved = (await client.listComments(pageId, true)).items;
|
||||||
const c1Resolved = listResolved.find((c) => c.id === c1.data.id);
|
const c1Resolved = listResolved.find((c) => c.id === c1.data.id);
|
||||||
check("resolve_comment: resolvedAt set in list", !!c1Resolved?.resolvedAt, `resolvedAt=${c1Resolved?.resolvedAt}`);
|
check("resolveComment: resolvedAt set in list", !!c1Resolved?.resolvedAt, `resolvedAt=${c1Resolved?.resolvedAt}`);
|
||||||
const reopenedRes = await client.resolveComment(c1.data.id, false);
|
const reopenedRes = await client.resolveComment(c1.data.id, false);
|
||||||
check("resolve_comment: reopen succeeds", reopenedRes.resolved === false);
|
check("resolveComment: reopen succeeds", reopenedRes.resolved === false);
|
||||||
const listReopened = (await client.listComments(pageId)).items;
|
const listReopened = (await client.listComments(pageId)).items;
|
||||||
const c1Reopened = listReopened.find((c) => c.id === c1.data.id);
|
const c1Reopened = listReopened.find((c) => c.id === c1.data.id);
|
||||||
check("resolve_comment: resolvedAt cleared on reopen", !c1Reopened?.resolvedAt, `resolvedAt=${c1Reopened?.resolvedAt}`);
|
check("resolveComment: resolvedAt cleared on reopen", !c1Reopened?.resolvedAt, `resolvedAt=${c1Reopened?.resolvedAt}`);
|
||||||
await client.deleteComment(reply.data.id);
|
await client.deleteComment(reply.data.id);
|
||||||
await client.deleteComment(c1.data.id);
|
await client.deleteComment(c1.data.id);
|
||||||
const listAfter = (await client.listComments(pageId)).items;
|
const listAfter = (await client.listComments(pageId)).items;
|
||||||
check("delete_comment: comments removed", listAfter.length === 0, `count=${listAfter.length}`);
|
check("deleteComment: comments removed", listAfter.length === 0, `count=${listAfter.length}`);
|
||||||
} finally {
|
} finally {
|
||||||
if (pageId) {
|
if (pageId) {
|
||||||
await client.deletePage(pageId);
|
await client.deletePage(pageId);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Mock collab regression for the AMBIGUOUS-id refusal in patch_node / delete_node
|
// Mock collab regression for the AMBIGUOUS-id refusal in patchNode / deleteNode
|
||||||
// (#159, PR #185 review pt 1). When a page has TWO blocks sharing one attrs.id
|
// (#159, PR #185 review pt 1). When a page has TWO blocks sharing one attrs.id
|
||||||
// (Docmost duplicates block ids on copy/paste), the transform's
|
// (Docmost duplicates block ids on copy/paste), the transform's
|
||||||
// `if (replaced !== 1) return null` / `if (deleted !== 1) return null` guard must
|
// `if (replaced !== 1) return null` / `if (deleted !== 1) return null` guard must
|
||||||
@@ -126,18 +126,20 @@ after(async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("patch_node REFUSES an ambiguous (duplicate) id without writing to collab", async () => {
|
test("patchNode REFUSES an ambiguous (duplicate) id without writing to collab", async () => {
|
||||||
const { state, baseURL } = await spawnCollabStack();
|
const { state, baseURL } = await spawnCollabStack();
|
||||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() =>
|
() =>
|
||||||
client.patchNode("11111111-1111-4111-8111-111111111111", DUP_ID, {
|
client.patchNode("11111111-1111-4111-8111-111111111111", DUP_ID, {
|
||||||
type: "paragraph",
|
node: {
|
||||||
content: [{ type: "text", text: "replacement" }],
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "replacement" }],
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
/ambiguous/i,
|
/ambiguous/i,
|
||||||
"patch_node must reject a duplicate-id target with an 'ambiguous' error",
|
"patchNode must reject a duplicate-id target with an 'ambiguous' error",
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
@@ -147,14 +149,14 @@ test("patch_node REFUSES an ambiguous (duplicate) id without writing to collab",
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("delete_node REFUSES an ambiguous (duplicate) id without writing to collab", async () => {
|
test("deleteNode REFUSES an ambiguous (duplicate) id without writing to collab", async () => {
|
||||||
const { state, baseURL } = await spawnCollabStack();
|
const { state, baseURL } = await spawnCollabStack();
|
||||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => client.deleteNode("22222222-2222-4222-8222-222222222222", DUP_ID),
|
() => client.deleteNode("22222222-2222-4222-8222-222222222222", DUP_ID),
|
||||||
/ambiguous/i,
|
/ambiguous/i,
|
||||||
"delete_node must reject a duplicate-id target with an 'ambiguous' error",
|
"deleteNode must reject a duplicate-id target with an 'ambiguous' error",
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Contract tests for the drawio_get / drawio_create / drawio_update client
|
// Contract tests for the drawioGet / drawioCreate / drawioUpdate client
|
||||||
// methods (issue #423). Follows the repo's seam-override pattern (see
|
// methods (issue #423). Follows the repo's seam-override pattern (see
|
||||||
// full-doc-write-canonicalize.test.mjs): a DocmostClient subclass stubs the I/O
|
// full-doc-write-canonicalize.test.mjs): a DocmostClient subclass stubs the I/O
|
||||||
// seams (auth, collab token, page read, attachment upload/fetch, the mutatePage
|
// seams (auth, collab token, page read, attachment upload/fetch, the mutatePage
|
||||||
@@ -114,9 +114,9 @@ function findDrawio(node, acc = []) {
|
|||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- drawio_create ---------------------------------------------------------
|
// --- drawioCreate ---------------------------------------------------------
|
||||||
|
|
||||||
test("drawio_create: lints, builds the .drawio.svg, uploads and inserts a node", async () => {
|
test("drawioCreate: lints, builds the .drawio.svg, uploads and inserts a node", async () => {
|
||||||
const pageDoc = {
|
const pageDoc = {
|
||||||
type: "doc",
|
type: "doc",
|
||||||
content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }],
|
content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }],
|
||||||
@@ -148,7 +148,7 @@ test("drawio_create: lints, builds the .drawio.svg, uploads and inserts a node",
|
|||||||
assert.equal(n.attrs.title, "My diagram");
|
assert.equal(n.attrs.title, "My diagram");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("drawio_create: a lint violation throws before any upload", async () => {
|
test("drawioCreate: a lint violation throws before any upload", async () => {
|
||||||
const { client, calls } = makeClient({ pageDoc: { type: "doc", content: [] } });
|
const { client, calls } = makeClient({ pageDoc: { type: "doc", content: [] } });
|
||||||
// Edge with no child geometry -> edge-geometry rule.
|
// Edge with no child geometry -> edge-geometry rule.
|
||||||
const bad =
|
const bad =
|
||||||
@@ -162,7 +162,7 @@ test("drawio_create: a lint violation throws before any upload", async () => {
|
|||||||
assert.equal(calls.uploads.length, 0, "no attachment uploaded on lint failure");
|
assert.equal(calls.uploads.length, 0, "no attachment uploaded on lint failure");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("drawio_create: before/after requires exactly one anchor", async () => {
|
test("drawioCreate: before/after requires exactly one anchor", async () => {
|
||||||
const { client } = makeClient({ pageDoc: { type: "doc", content: [] } });
|
const { client } = makeClient({ pageDoc: { type: "doc", content: [] } });
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => client.drawioCreate("page1", { position: "before" }, MODEL),
|
() => client.drawioCreate("page1", { position: "before" }, MODEL),
|
||||||
@@ -170,9 +170,9 @@ test("drawio_create: before/after requires exactly one anchor", async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- drawio_get ------------------------------------------------------------
|
// --- drawioGet ------------------------------------------------------------
|
||||||
|
|
||||||
test("drawio_get: decodes the model and returns meta with a hash", async () => {
|
test("drawioGet: decodes the model and returns meta with a hash", async () => {
|
||||||
const pageDoc = {
|
const pageDoc = {
|
||||||
type: "doc",
|
type: "doc",
|
||||||
content: [
|
content: [
|
||||||
@@ -198,7 +198,7 @@ test("drawio_get: decodes the model and returns meta with a hash", async () => {
|
|||||||
assert.equal(res.meta.hash, mxHash(normalizeXml(MODEL)));
|
assert.equal(res.meta.hash, mxHash(normalizeXml(MODEL)));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("drawio_get: format=svg returns the raw .drawio.svg", async () => {
|
test("drawioGet: format=svg returns the raw .drawio.svg", async () => {
|
||||||
const svg = svgFor(MODEL);
|
const svg = svgFor(MODEL);
|
||||||
const pageDoc = {
|
const pageDoc = {
|
||||||
type: "doc",
|
type: "doc",
|
||||||
@@ -211,7 +211,7 @@ test("drawio_get: format=svg returns the raw .drawio.svg", async () => {
|
|||||||
assert.equal(res.content, svg);
|
assert.equal(res.content, svg);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("drawio_get: reads a HUMAN-saved compressed diagram losslessly (pako)", async () => {
|
test("drawioGet: reads a HUMAN-saved compressed diagram losslessly (pako)", async () => {
|
||||||
const pageDoc = {
|
const pageDoc = {
|
||||||
type: "doc",
|
type: "doc",
|
||||||
content: [
|
content: [
|
||||||
@@ -223,7 +223,7 @@ test("drawio_get: reads a HUMAN-saved compressed diagram losslessly (pako)", asy
|
|||||||
assert.equal(res.content, normalizeXml(MODEL));
|
assert.equal(res.content, normalizeXml(MODEL));
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- drawio_update ---------------------------------------------------------
|
// --- drawioUpdate ---------------------------------------------------------
|
||||||
|
|
||||||
const UPDATED_MODEL =
|
const UPDATED_MODEL =
|
||||||
'<mxGraphModel><root>' +
|
'<mxGraphModel><root>' +
|
||||||
@@ -250,7 +250,7 @@ function updatePageDoc() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test("drawio_update: stale baseHash -> conflict, no upload", async () => {
|
test("drawioUpdate: stale baseHash -> conflict, no upload", async () => {
|
||||||
const { client, calls } = makeClient({
|
const { client, calls } = makeClient({
|
||||||
pageDoc: updatePageDoc(),
|
pageDoc: updatePageDoc(),
|
||||||
attachmentSvg: svgFor(MODEL),
|
attachmentSvg: svgFor(MODEL),
|
||||||
@@ -262,7 +262,7 @@ test("drawio_update: stale baseHash -> conflict, no upload", async () => {
|
|||||||
assert.equal(calls.uploads.length, 0, "no upload on conflict");
|
assert.equal(calls.uploads.length, 0, "no upload on conflict");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("drawio_update: current baseHash -> uploads new attachment and repoints node dims", async () => {
|
test("drawioUpdate: current baseHash -> uploads new attachment and repoints node dims", async () => {
|
||||||
const currentHash = mxHash(normalizeXml(MODEL));
|
const currentHash = mxHash(normalizeXml(MODEL));
|
||||||
const { client, calls } = makeClient({
|
const { client, calls } = makeClient({
|
||||||
pageDoc: updatePageDoc(),
|
pageDoc: updatePageDoc(),
|
||||||
@@ -285,7 +285,7 @@ test("drawio_update: current baseHash -> uploads new attachment and repoints nod
|
|||||||
assert.equal(n.attrs.id, undefined);
|
assert.equal(n.attrs.id, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("drawio_update: baseHash is mandatory", async () => {
|
test("drawioUpdate: baseHash is mandatory", async () => {
|
||||||
const { client } = makeClient({ pageDoc: updatePageDoc(), attachmentSvg: svgFor(MODEL) });
|
const { client } = makeClient({ pageDoc: updatePageDoc(), attachmentSvg: svgFor(MODEL) });
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => client.drawioUpdate("page1", "d1", UPDATED_MODEL, ""),
|
() => client.drawioUpdate("page1", "d1", UPDATED_MODEL, ""),
|
||||||
@@ -295,7 +295,7 @@ test("drawio_update: baseHash is mandatory", async () => {
|
|||||||
|
|
||||||
// --- Fix 1: the create handle must resolve on the SAVED doc (no id) ---------
|
// --- 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 () => {
|
test("drawioCreate -> get/update: returned #<index> handle resolves on the saved doc (id dropped)", async () => {
|
||||||
// Create appends a drawio node after the existing paragraph.
|
// Create appends a drawio node after the existing paragraph.
|
||||||
const createDoc = {
|
const createDoc = {
|
||||||
type: "doc",
|
type: "doc",
|
||||||
@@ -316,13 +316,13 @@ test("drawio_create -> get/update: returned #<index> handle resolves on the save
|
|||||||
const savedDoc = create.calls.mutations[0].doc;
|
const savedDoc = create.calls.mutations[0].doc;
|
||||||
assert.equal(findDrawio(savedDoc)[0].attrs.id, undefined);
|
assert.equal(findDrawio(savedDoc)[0].attrs.id, undefined);
|
||||||
|
|
||||||
// drawio_get with the returned handle resolves the just-created node.
|
// drawioGet with the returned handle resolves the just-created node.
|
||||||
const getClient = makeClient({ pageDoc: savedDoc, attachmentSvg: svgFor(MODEL) });
|
const getClient = makeClient({ pageDoc: savedDoc, attachmentSvg: svgFor(MODEL) });
|
||||||
const got = await getClient.client.drawioGet("page1", res.nodeId, "xml");
|
const got = await getClient.client.drawioGet("page1", res.nodeId, "xml");
|
||||||
assert.equal(got.nodeId, res.nodeId);
|
assert.equal(got.nodeId, res.nodeId);
|
||||||
assert.equal(got.content, normalizeXml(MODEL));
|
assert.equal(got.content, normalizeXml(MODEL));
|
||||||
|
|
||||||
// drawio_update with the same handle + the hash from get repoints that node.
|
// drawioUpdate with the same handle + the hash from get repoints that node.
|
||||||
const upClient = makeClient({ pageDoc: savedDoc, attachmentSvg: svgFor(MODEL) });
|
const upClient = makeClient({ pageDoc: savedDoc, attachmentSvg: svgFor(MODEL) });
|
||||||
const upd = await upClient.client.drawioUpdate(
|
const upd = await upClient.client.drawioUpdate(
|
||||||
"page1",
|
"page1",
|
||||||
@@ -342,7 +342,7 @@ test("drawio_create -> get/update: returned #<index> handle resolves on the save
|
|||||||
|
|
||||||
// --- error paths: the LLM must get a clean error, not a crash --------------
|
// --- 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 () => {
|
test("drawioGet: a bad node ref -> clean 'no node found' error", async () => {
|
||||||
// Page has one paragraph; the requested ref resolves to nothing.
|
// Page has one paragraph; the requested ref resolves to nothing.
|
||||||
const pageDoc = {
|
const pageDoc = {
|
||||||
type: "doc",
|
type: "doc",
|
||||||
@@ -355,7 +355,7 @@ test("drawio_get: a bad node ref -> clean 'no node found' error", async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("drawio_get: a drawio node with no src -> clean 'has no src to read' error", async () => {
|
test("drawioGet: a drawio node with no src -> clean 'has no src to read' error", async () => {
|
||||||
const pageDoc = {
|
const pageDoc = {
|
||||||
type: "doc",
|
type: "doc",
|
||||||
content: [
|
content: [
|
||||||
@@ -370,7 +370,7 @@ test("drawio_get: a drawio node with no src -> clean 'has no src to read' error"
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("drawio_update: the resolved node is NOT a drawio node -> clean error, no upload", async () => {
|
test("drawioUpdate: 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
|
// "#0" resolves to a paragraph. The update must refuse cleanly rather than
|
||||||
// crash or repoint the wrong node.
|
// crash or repoint the wrong node.
|
||||||
const pageDoc = {
|
const pageDoc = {
|
||||||
@@ -386,7 +386,7 @@ test("drawio_update: the resolved node is NOT a drawio node -> clean error, no u
|
|||||||
assert.equal(calls.mutations.length, 0, "no write 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 () => {
|
test("drawioCreate: anchor not found -> clean error that reports the orphan attachment", async () => {
|
||||||
// The upload happens before the mutate transform; when the anchor cannot be
|
// The upload happens before the mutate transform; when the anchor cannot be
|
||||||
// found the write is skipped and the (now unreferenced) attachment is named
|
// found the write is skipped and the (now unreferenced) attachment is named
|
||||||
// in the error, exactly as the code documents.
|
// in the error, exactly as the code documents.
|
||||||
@@ -416,7 +416,7 @@ test("drawio_create: anchor not found -> clean error that reports the orphan att
|
|||||||
|
|
||||||
// --- Fix 2: update targets ONLY the resolved node --------------------------
|
// --- Fix 2: update targets ONLY the resolved node --------------------------
|
||||||
|
|
||||||
test("drawio_update: repoints ONLY the addressed node, not siblings sharing an attachmentId", async () => {
|
test("drawioUpdate: repoints ONLY the addressed node, not siblings sharing an attachmentId", async () => {
|
||||||
// A copied diagram: two drawio nodes share one attachmentId. Updating via the
|
// A copied diagram: two drawio nodes share one attachmentId. Updating via the
|
||||||
// "#0" handle must touch node #0 only, never the sibling copy.
|
// "#0" handle must touch node #0 only, never the sibling copy.
|
||||||
const shared = {
|
const shared = {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// (issue #228):
|
// (issue #228):
|
||||||
// - insertFootnote (#11): the required-argument guards reject BEFORE any write,
|
// - insertFootnote (#11): the required-argument guards reject BEFORE any write,
|
||||||
// and never touch the collab/mutate path.
|
// and never touch the collab/mutate path.
|
||||||
// - transformPage / docmost_transform (#13): the auto-canonicalize step
|
// - transformPage / docmostTransform (#13): the auto-canonicalize step
|
||||||
// (`result = canonicalizeFootnotes(raw)`) runs after every transform, so a
|
// (`result = canonicalizeFootnotes(raw)`) runs after every transform, so a
|
||||||
// transform that introduces an orphan footnote definition is silently tidied
|
// transform that introduces an orphan footnote definition is silently tidied
|
||||||
// away — observable as an EMPTY diff in a dryRun preview.
|
// away — observable as an EMPTY diff in a dryRun preview.
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
// These stand a local http.createServer in for Docmost and only exercise plain
|
// These stand a local http.createServer in for Docmost and only exercise plain
|
||||||
// HTTP routes (login / comments / pages.info), deliberately avoiding the live
|
// HTTP routes (login / comments / pages.info), deliberately avoiding the live
|
||||||
// Hocuspocus collab WebSocket: the insertFootnote guards short-circuit before it,
|
// Hocuspocus collab WebSocket: the insertFootnote guards short-circuit before it,
|
||||||
// and docmost_transform's dryRun preview never opens it. The collab mutate path
|
// and docmostTransform's dryRun preview never opens it. The collab mutate path
|
||||||
// itself — abort-via-throw on a missing anchor with NO persisted write, and the
|
// itself — abort-via-throw on a missing anchor with NO persisted write, and the
|
||||||
// reused-vs-new response shaping — is covered in
|
// reused-vs-new response shaping — is covered in
|
||||||
// test/mock/insert-footnote-wrapper.test.mjs (which overrides the mutatePage
|
// test/mock/insert-footnote-wrapper.test.mjs (which overrides the mutatePage
|
||||||
@@ -101,7 +101,7 @@ test("insertFootnote rejects an empty text before any write", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// #13 docmost_transform auto-canonicalization: a transform that adds an orphan
|
// #13 docmostTransform auto-canonicalization: a transform that adds an orphan
|
||||||
// footnote definition produces NO net change (the canonicalizer drops it), so a
|
// footnote definition produces NO net change (the canonicalizer drops it), so a
|
||||||
// dryRun preview reports an empty diff. Without the auto-canonicalize step the
|
// dryRun preview reports an empty diff. Without the auto-canonicalize step the
|
||||||
// orphan would survive and the diff would be non-empty.
|
// orphan would survive and the diff would be non-empty.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Footnote-canonicalization binding tests for the MCP FULL-document write tools
|
// Footnote-canonicalization binding tests for the MCP FULL-document write tools
|
||||||
// (issue #228, review #4): update_page_json and copy_page_content must persist a
|
// (issue #228, review #4): updatePageJson and copyPageContent must persist a
|
||||||
// footnote-canonical doc. These override the `replacePage` seam (symmetric to the
|
// footnote-canonical doc. These override the `replacePage` seam (symmetric to the
|
||||||
// `mutatePage` seam used by the insert-footnote-wrapper test) to capture the
|
// `mutatePage` seam used by the insert-footnote-wrapper test) to capture the
|
||||||
// persisted doc WITHOUT a live Hocuspocus collab socket. Symmetric to the
|
// persisted doc WITHOUT a live Hocuspocus collab socket. Symmetric to the
|
||||||
@@ -44,7 +44,7 @@ function makeClient(sourceDoc) {
|
|||||||
return { client, calls };
|
return { client, calls };
|
||||||
}
|
}
|
||||||
|
|
||||||
test("update_page_json canonicalizes the persisted full doc (out-of-order -> reference order)", async () => {
|
test("updatePageJson canonicalizes the persisted full doc (out-of-order -> reference order)", async () => {
|
||||||
const { client, calls } = makeClient();
|
const { client, calls } = makeClient();
|
||||||
const outOfOrder = {
|
const outOfOrder = {
|
||||||
type: "doc",
|
type: "doc",
|
||||||
@@ -60,7 +60,7 @@ test("update_page_json canonicalizes the persisted full doc (out-of-order -> ref
|
|||||||
assert.equal(findAll(calls.replaced[0].doc, "footnotesList").length, 1);
|
assert.equal(findAll(calls.replaced[0].doc, "footnotesList").length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("copy_page_content canonicalizes the persisted copy (orphan definition dropped)", async () => {
|
test("copyPageContent canonicalizes the persisted copy (orphan definition dropped)", async () => {
|
||||||
const sourceDoc = {
|
const sourceDoc = {
|
||||||
type: "doc",
|
type: "doc",
|
||||||
content: [
|
content: [
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// #413: getNode's markdown-default format, its JSON opt-in, the non-top-level
|
||||||
|
// AUTO fallback to JSON, and comment-anchor preservation (incl. resolved) on the
|
||||||
|
// markdown read. getNode only reads (getPageRaw), so a lightweight subclass that
|
||||||
|
// stubs auth + the page fetch is enough — no collab socket needed.
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { DocmostClient } from "../../build/client.js";
|
||||||
|
|
||||||
|
function makeClient(doc) {
|
||||||
|
class TestClient extends DocmostClient {
|
||||||
|
async ensureAuthenticated() {}
|
||||||
|
async getPageRaw(pageId) {
|
||||||
|
return { id: pageId, slugId: "s", title: "P", spaceId: "sp", content: doc };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new TestClient("http://127.0.0.1:1/api", "e@x.com", "pw");
|
||||||
|
}
|
||||||
|
|
||||||
|
const P = "p1";
|
||||||
|
|
||||||
|
test("getNode defaults to markdown for a paragraph", async () => {
|
||||||
|
const doc = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
attrs: { id: "b1" },
|
||||||
|
content: [{ type: "text", text: "hello world" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const res = await makeClient(doc).getNode(P, "b1");
|
||||||
|
assert.equal(res.format, "markdown");
|
||||||
|
assert.equal(typeof res.markdown, "string");
|
||||||
|
assert.match(res.markdown, /hello world/);
|
||||||
|
assert.equal(res.node, undefined, "markdown result carries no raw node");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getNode format:'json' returns the raw subtree verbatim", async () => {
|
||||||
|
const target = {
|
||||||
|
type: "paragraph",
|
||||||
|
attrs: { id: "b1" },
|
||||||
|
content: [{ type: "text", text: "hello" }],
|
||||||
|
};
|
||||||
|
const doc = { type: "doc", content: [target] };
|
||||||
|
const res = await makeClient(doc).getNode(P, "b1", "json");
|
||||||
|
assert.equal(res.format, "json");
|
||||||
|
assert.deepEqual(res.node, target);
|
||||||
|
assert.equal(res.markdown, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getNode AUTO-falls back to JSON for a non-top-level type (tableRow via #index)", async () => {
|
||||||
|
const doc = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "table",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tableRow",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tableCell",
|
||||||
|
attrs: { colspan: 1, rowspan: 1 },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
attrs: { id: "cp" },
|
||||||
|
content: [{ type: "text", text: "x" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
// "#0.0"-style refs are not supported; the whole table is "#0", a row is only
|
||||||
|
// reachable by drilling — but a tableRow IS a non-doc-child type. Address the
|
||||||
|
// table itself as "#0": a table CAN be a doc child, so markdown is fine there.
|
||||||
|
// To hit the fallback, address the row by walking: getNode resolves "#0" to the
|
||||||
|
// table (doc child -> markdown). Instead we verify the schema gate directly by
|
||||||
|
// asking for the table (markdown) and a row is exercised via the unit test on
|
||||||
|
// canBeDocChild; here confirm a table renders as markdown.
|
||||||
|
const tableRes = await makeClient(doc).getNode(P, "#0");
|
||||||
|
assert.equal(tableRes.format, "markdown", "a table is a doc child -> markdown");
|
||||||
|
|
||||||
|
// Now build a doc whose top-level block IS a tableRow (schematically invalid but
|
||||||
|
// exercises the getNode fallback branch): getNode("#0") resolves it and, because
|
||||||
|
// tableRow cannot be a doc child, must fall back to JSON.
|
||||||
|
const rowDoc = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tableRow",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tableCell",
|
||||||
|
attrs: { colspan: 1, rowspan: 1 },
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text: "y" }] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const rowRes = await makeClient(rowDoc).getNode(P, "#0");
|
||||||
|
assert.equal(rowRes.format, "json", "a tableRow cannot be a doc child -> JSON fallback");
|
||||||
|
assert.equal(rowRes.type, "tableRow");
|
||||||
|
assert.ok(rowRes.node, "the JSON fallback returns the raw subtree");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getNode(markdown) PRESERVES comment anchors — active and resolved", async () => {
|
||||||
|
// A paragraph with two comment marks: one active, one resolved. get_page strips
|
||||||
|
// resolved anchors; getNode must NOT (a read for editing/write-back).
|
||||||
|
const doc = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
attrs: { id: "b1" },
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "start " },
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "active",
|
||||||
|
marks: [{ type: "comment", attrs: { commentId: "cid-active" } }],
|
||||||
|
},
|
||||||
|
{ type: "text", text: " mid " },
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "resolved",
|
||||||
|
marks: [
|
||||||
|
{
|
||||||
|
type: "comment",
|
||||||
|
attrs: { commentId: "cid-resolved", resolved: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: "text", text: " end" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const res = await makeClient(doc).getNode(P, "b1");
|
||||||
|
assert.equal(res.format, "markdown");
|
||||||
|
assert.match(
|
||||||
|
res.markdown,
|
||||||
|
/data-comment-id="cid-active"/,
|
||||||
|
"the active comment anchor is preserved",
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
res.markdown,
|
||||||
|
/data-comment-id="cid-resolved"/,
|
||||||
|
"the RESOLVED comment anchor is ALSO preserved (unlike get_page)",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
// Mock-HTTP tests for DocmostClient.getPageContext — the #443 "where am I /
|
||||||
|
// what's around" read tool. A local http.createServer stands in for Docmost
|
||||||
|
// (same harness style as pagination-cursor.test.mjs) so everything is
|
||||||
|
// deterministic and offline.
|
||||||
|
//
|
||||||
|
// Contract pinned here:
|
||||||
|
// - Two requests: POST /pages/breadcrumbs (ancestor chain root->page, page
|
||||||
|
// INCLUDED as the LAST element) + listSidebarPages (direct children).
|
||||||
|
// - Split: last chain element -> `page`; the rest (root->parent) ->
|
||||||
|
// `breadcrumbs`. A ROOT page (chain length 1) -> breadcrumbs: [].
|
||||||
|
// - children: {pageId, title, hasChildren} in sidebar order.
|
||||||
|
// - INVARIANT: only the UUID `pageId` is exposed, never `slugId`.
|
||||||
|
// - A slugId input is resolved via /pages/info first (adds one request); a
|
||||||
|
// UUID input short-circuits (stays at two requests).
|
||||||
|
// - A bad/inaccessible pageId throws a CLEAR error, not an empty object.
|
||||||
|
import { test, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import http from "node:http";
|
||||||
|
import { DocmostClient } from "../../build/client.js";
|
||||||
|
|
||||||
|
function readBody(req) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let raw = "";
|
||||||
|
req.on("data", (chunk) => {
|
||||||
|
raw += chunk;
|
||||||
|
});
|
||||||
|
req.on("end", () => resolve(raw));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startServer(handler) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const server = http.createServer(handler);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const { port } = server.address();
|
||||||
|
resolve({ server, baseURL: `http://127.0.0.1:${port}/api` });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeServer(server) {
|
||||||
|
return new Promise((resolve) => server.close(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(res, status, obj, extraHeaders = {}) {
|
||||||
|
res.writeHead(status, { "Content-Type": "application/json", ...extraHeaders });
|
||||||
|
res.end(JSON.stringify(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
const openServers = [];
|
||||||
|
async function spawn(handler) {
|
||||||
|
const { server, baseURL } = await startServer(handler);
|
||||||
|
openServers.push(server);
|
||||||
|
return { server, baseURL };
|
||||||
|
}
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await Promise.all(openServers.map((s) => closeServer(s)));
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleLogin(req, res) {
|
||||||
|
if (req.url === "/api/auth/login") {
|
||||||
|
sendJson(res, 200, { success: true }, {
|
||||||
|
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two real UUIDs so resolvePageId short-circuits (no /pages/info round-trip).
|
||||||
|
const ROOT_UUID = "00000000-0000-4000-8000-000000000001";
|
||||||
|
const MID_UUID = "00000000-0000-4000-8000-000000000002";
|
||||||
|
const PAGE_UUID = "00000000-0000-4000-8000-000000000003";
|
||||||
|
const CHILD_A = "00000000-0000-4000-8000-00000000000a";
|
||||||
|
const CHILD_B = "00000000-0000-4000-8000-00000000000b";
|
||||||
|
|
||||||
|
// Build a breadcrumbs response as the server sends it: root->page order, page
|
||||||
|
// LAST, wrapped in the {data,success} envelope. slugId/icon/position are present
|
||||||
|
// on the wire (they must NOT leak into the tool output).
|
||||||
|
function breadcrumbsEnvelope(chain) {
|
||||||
|
return { success: true, data: chain };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 1) 3rd-level page: page = last chain element; breadcrumbs = the two ancestors
|
||||||
|
// root->parent; children mapped {pageId,title,hasChildren} in order; no leak;
|
||||||
|
// exactly two requests for a UUID input.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
test("getPageContext: 3rd-level page splits chain, maps children, no slugId leak, 2 requests", async () => {
|
||||||
|
let breadcrumbReqs = 0;
|
||||||
|
let sidebarReqs = 0;
|
||||||
|
let infoReqs = 0;
|
||||||
|
let breadcrumbBody = null;
|
||||||
|
|
||||||
|
const { baseURL } = await spawn(async (req, res) => {
|
||||||
|
const raw = await readBody(req);
|
||||||
|
if (handleLogin(req, res)) return;
|
||||||
|
if (req.url === "/api/pages/info") {
|
||||||
|
infoReqs++;
|
||||||
|
sendJson(res, 404, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === "/api/pages/breadcrumbs") {
|
||||||
|
breadcrumbReqs++;
|
||||||
|
breadcrumbBody = JSON.parse(raw || "{}");
|
||||||
|
// root -> parent -> page (page LAST). slugId/icon/position on the wire.
|
||||||
|
sendJson(
|
||||||
|
res,
|
||||||
|
200,
|
||||||
|
breadcrumbsEnvelope([
|
||||||
|
{ id: ROOT_UUID, slugId: "rootSlug", title: "Infrastructure", spaceId: "sp1", position: "a", icon: null, parentPageId: null, hasChildren: true },
|
||||||
|
{ id: MID_UUID, slugId: "midSlug", title: "Datacenter A", spaceId: "sp1", position: "a", icon: null, parentPageId: ROOT_UUID, hasChildren: true },
|
||||||
|
{ id: PAGE_UUID, slugId: "pageSlug", title: "Rack 12", spaceId: "sp1", position: "b", icon: null, parentPageId: MID_UUID, hasChildren: true },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === "/api/pages/sidebar-pages") {
|
||||||
|
sidebarReqs++;
|
||||||
|
const body = JSON.parse(raw || "{}");
|
||||||
|
assert.equal(body.pageId, PAGE_UUID, "children scoped to the page UUID");
|
||||||
|
assert.equal(body.spaceId, "sp1", "children scoped to the page's space");
|
||||||
|
sendJson(res, 200, {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{ id: CHILD_A, slugId: "aSlug", title: "Servers", parentPageId: PAGE_UUID, hasChildren: true, position: "a" },
|
||||||
|
{ id: CHILD_B, slugId: "bSlug", title: "Network", parentPageId: PAGE_UUID, hasChildren: false, position: "b" },
|
||||||
|
],
|
||||||
|
meta: { hasNextPage: false, nextCursor: null },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(res, 404, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
|
const result = await client.getPageContext(PAGE_UUID);
|
||||||
|
|
||||||
|
assert.equal(infoReqs, 0, "UUID input short-circuits resolvePageId (no /pages/info)");
|
||||||
|
assert.equal(breadcrumbReqs, 1, "exactly one breadcrumbs request");
|
||||||
|
assert.equal(sidebarReqs, 1, "exactly one sidebar request");
|
||||||
|
assert.deepEqual(breadcrumbBody, { pageId: PAGE_UUID }, "breadcrumbs posts the UUID");
|
||||||
|
|
||||||
|
// page = the LAST chain element.
|
||||||
|
assert.deepEqual(result.page, {
|
||||||
|
pageId: PAGE_UUID,
|
||||||
|
title: "Rack 12",
|
||||||
|
spaceId: "sp1",
|
||||||
|
});
|
||||||
|
// breadcrumbs = root->parent (the chain minus the page itself).
|
||||||
|
assert.deepEqual(result.breadcrumbs, [
|
||||||
|
{ pageId: ROOT_UUID, title: "Infrastructure" },
|
||||||
|
{ pageId: MID_UUID, title: "Datacenter A" },
|
||||||
|
]);
|
||||||
|
// children mapped in order, hasChildren coerced to boolean.
|
||||||
|
assert.deepEqual(result.children, [
|
||||||
|
{ pageId: CHILD_A, title: "Servers", hasChildren: true },
|
||||||
|
{ pageId: CHILD_B, title: "Network", hasChildren: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// No slugId anywhere in the output.
|
||||||
|
const dump = JSON.stringify(result);
|
||||||
|
assert.ok(!dump.includes("Slug"), "no slugId leaks into the output");
|
||||||
|
assert.ok(!/\bslugId\b/.test(dump), "no slugId key in the output");
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 2) ROOT page: chain has ONE element (the page itself) -> breadcrumbs: [].
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
test("getPageContext: a root page has breadcrumbs: []", async () => {
|
||||||
|
const { baseURL } = await spawn(async (req, res) => {
|
||||||
|
const raw = await readBody(req);
|
||||||
|
if (handleLogin(req, res)) return;
|
||||||
|
if (req.url === "/api/pages/breadcrumbs") {
|
||||||
|
// A root page: the CTE returns only the page itself.
|
||||||
|
sendJson(
|
||||||
|
res,
|
||||||
|
200,
|
||||||
|
breadcrumbsEnvelope([
|
||||||
|
{ id: ROOT_UUID, slugId: "rootSlug", title: "Infrastructure", spaceId: "sp1", parentPageId: null, hasChildren: false },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === "/api/pages/sidebar-pages") {
|
||||||
|
sendJson(res, 200, {
|
||||||
|
success: true,
|
||||||
|
data: { items: [], meta: { hasNextPage: false, nextCursor: null } },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(res, 404, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
|
const result = await client.getPageContext(ROOT_UUID);
|
||||||
|
|
||||||
|
assert.deepEqual(result.page, {
|
||||||
|
pageId: ROOT_UUID,
|
||||||
|
title: "Infrastructure",
|
||||||
|
spaceId: "sp1",
|
||||||
|
});
|
||||||
|
assert.deepEqual(result.breadcrumbs, [], "root page: no ancestors");
|
||||||
|
assert.deepEqual(result.children, [], "no children");
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 3) A slugId input is resolved via /pages/info first (one extra request), then
|
||||||
|
// breadcrumbs/sidebar use the resolved UUID.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
test("getPageContext: a slugId input is resolved via /pages/info", async () => {
|
||||||
|
let infoReqs = 0;
|
||||||
|
let infoBody = null;
|
||||||
|
let breadcrumbBody = null;
|
||||||
|
|
||||||
|
const { baseURL } = await spawn(async (req, res) => {
|
||||||
|
const raw = await readBody(req);
|
||||||
|
if (handleLogin(req, res)) return;
|
||||||
|
if (req.url === "/api/pages/info") {
|
||||||
|
infoReqs++;
|
||||||
|
infoBody = JSON.parse(raw || "{}");
|
||||||
|
// getPageRaw: slugId -> canonical UUID.
|
||||||
|
sendJson(res, 200, {
|
||||||
|
success: true,
|
||||||
|
data: { id: PAGE_UUID, slugId: "pageSlug", title: "Rack 12", spaceId: "sp1" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === "/api/pages/breadcrumbs") {
|
||||||
|
breadcrumbBody = JSON.parse(raw || "{}");
|
||||||
|
sendJson(
|
||||||
|
res,
|
||||||
|
200,
|
||||||
|
breadcrumbsEnvelope([
|
||||||
|
{ id: ROOT_UUID, slugId: "rootSlug", title: "Infrastructure", spaceId: "sp1", parentPageId: null },
|
||||||
|
{ id: PAGE_UUID, slugId: "pageSlug", title: "Rack 12", spaceId: "sp1", parentPageId: ROOT_UUID },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === "/api/pages/sidebar-pages") {
|
||||||
|
sendJson(res, 200, {
|
||||||
|
success: true,
|
||||||
|
data: { items: [], meta: { hasNextPage: false, nextCursor: null } },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(res, 404, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
|
const result = await client.getPageContext("pageSlug");
|
||||||
|
|
||||||
|
assert.equal(infoReqs, 1, "slugId resolved via one /pages/info");
|
||||||
|
assert.deepEqual(infoBody, { pageId: "pageSlug" }, "resolve posts the raw slugId");
|
||||||
|
assert.deepEqual(
|
||||||
|
breadcrumbBody,
|
||||||
|
{ pageId: PAGE_UUID },
|
||||||
|
"breadcrumbs posts the RESOLVED uuid, not the slugId",
|
||||||
|
);
|
||||||
|
assert.equal(result.page.pageId, PAGE_UUID, "page.pageId is the UUID");
|
||||||
|
assert.deepEqual(result.breadcrumbs, [
|
||||||
|
{ pageId: ROOT_UUID, title: "Infrastructure" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 4) >20 children: cursor pagination returns ALL of them, no dupes (regression
|
||||||
|
// on the #442 bug class — getPageContext must not re-introduce a cap).
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
test("getPageContext: a page with >20 children returns ALL of them (no cap, no dupes)", async () => {
|
||||||
|
// 45 children spread over three cursor pages.
|
||||||
|
const all = Array.from({ length: 45 }, (_, i) => ({
|
||||||
|
id: `child-${i}`,
|
||||||
|
slugId: `slug-${i}`,
|
||||||
|
title: `Child ${i}`,
|
||||||
|
parentPageId: PAGE_UUID,
|
||||||
|
hasChildren: i % 2 === 0,
|
||||||
|
}));
|
||||||
|
const PAGES = {
|
||||||
|
"": { items: all.slice(0, 20), nextCursor: "c1" },
|
||||||
|
c1: { items: all.slice(20, 40), nextCursor: "c2" },
|
||||||
|
c2: { items: all.slice(40), nextCursor: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { baseURL } = await spawn(async (req, res) => {
|
||||||
|
const raw = await readBody(req);
|
||||||
|
if (handleLogin(req, res)) return;
|
||||||
|
if (req.url === "/api/pages/breadcrumbs") {
|
||||||
|
sendJson(
|
||||||
|
res,
|
||||||
|
200,
|
||||||
|
breadcrumbsEnvelope([
|
||||||
|
{ id: PAGE_UUID, slugId: "pageSlug", title: "Big Parent", spaceId: "sp1", parentPageId: null },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === "/api/pages/sidebar-pages") {
|
||||||
|
const body = JSON.parse(raw || "{}");
|
||||||
|
const page = PAGES[body.cursor ?? ""] ?? { items: [], nextCursor: null };
|
||||||
|
sendJson(res, 200, {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: page.items,
|
||||||
|
meta: { hasNextPage: page.nextCursor != null, nextCursor: page.nextCursor },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(res, 404, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
|
const result = await client.getPageContext(PAGE_UUID);
|
||||||
|
|
||||||
|
assert.equal(result.children.length, 45, "all 45 children returned");
|
||||||
|
const ids = result.children.map((c) => c.pageId);
|
||||||
|
assert.equal(new Set(ids).size, 45, "no duplicate children");
|
||||||
|
assert.deepEqual(ids, all.map((c) => c.id), "children in server order across cursor pages");
|
||||||
|
assert.equal(result.children[0].hasChildren, true, "hasChildren preserved (child 0)");
|
||||||
|
assert.equal(result.children[1].hasChildren, false, "hasChildren preserved (child 1)");
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 5) A nonexistent / inaccessible pageId -> a CLEAR error, NOT an empty object.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
test("getPageContext: a bad/inaccessible pageId throws a clear error (not {})", async () => {
|
||||||
|
const { baseURL } = await spawn(async (req, res) => {
|
||||||
|
await readBody(req);
|
||||||
|
if (handleLogin(req, res)) return;
|
||||||
|
if (req.url === "/api/pages/breadcrumbs") {
|
||||||
|
// Server rejects an unknown/forbidden page.
|
||||||
|
sendJson(res, 404, { message: "Page not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(res, 404, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
|
await assert.rejects(
|
||||||
|
() => client.getPageContext(PAGE_UUID),
|
||||||
|
(err) => {
|
||||||
|
assert.ok(err instanceof Error, "throws an Error");
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
"a 404 from breadcrumbs propagates as a thrown error, not a hollow {}",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 6) An empty breadcrumbs chain (should never happen — the endpoint always
|
||||||
|
// includes the page itself) is treated as not-found, not a hollow {page:...}.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
test("getPageContext: an empty breadcrumbs chain throws (defensive)", async () => {
|
||||||
|
const { baseURL } = await spawn(async (req, res) => {
|
||||||
|
await readBody(req);
|
||||||
|
if (handleLogin(req, res)) return;
|
||||||
|
if (req.url === "/api/pages/breadcrumbs") {
|
||||||
|
sendJson(res, 200, breadcrumbsEnvelope([]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(res, 404, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
|
await assert.rejects(
|
||||||
|
() => client.getPageContext(PAGE_UUID),
|
||||||
|
/not found or inaccessible/,
|
||||||
|
"an empty chain is a clear error, not {}",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Mock regression for the FAIL-FAST invalid-node validation (#409).
|
// Mock regression for the FAIL-FAST invalid-node validation (#409).
|
||||||
//
|
//
|
||||||
// A structural editor (patch_node / insert_node / update_page_json) given a doc
|
// A structural editor (patchNode / insertNode / updatePageJson) given a doc
|
||||||
// whose NESTED child has an absent/unknown `type` (the exact shape the Yjs
|
// whose NESTED child has an absent/unknown `type` (the exact shape the Yjs
|
||||||
// encoder rejects with `Unknown node type: undefined`) must throw a RICH,
|
// encoder rejects with `Unknown node type: undefined`) must throw a RICH,
|
||||||
// path-anchored error BEFORE it ever opens a collab session or takes a page
|
// path-anchored error BEFORE it ever opens a collab session or takes a page
|
||||||
@@ -23,7 +23,7 @@ import { Hocuspocus } from "@hocuspocus/server";
|
|||||||
import { DocmostClient } from "../../build/client.js";
|
import { DocmostClient } from "../../build/client.js";
|
||||||
import { buildYDoc } from "../../build/lib/collaboration.js";
|
import { buildYDoc } from "../../build/lib/collaboration.js";
|
||||||
|
|
||||||
// A minimal valid seed doc with a real block id, so the happy-path patch_node
|
// A minimal valid seed doc with a real block id, so the happy-path patchNode
|
||||||
// finds its target.
|
// finds its target.
|
||||||
const SEED_ID = "seed-para-id";
|
const SEED_ID = "seed-para-id";
|
||||||
function seedDoc() {
|
function seedDoc() {
|
||||||
@@ -130,14 +130,14 @@ const nestedUnknownTypeNode = () => ({
|
|||||||
content: [{ type: "paragraf", content: [{ type: "text", text: "x" }] }],
|
content: [{ type: "paragraf", content: [{ type: "text", text: "x" }] }],
|
||||||
});
|
});
|
||||||
|
|
||||||
test("patch_node fails fast on a nested typeless node — no collab connection", async () => {
|
test("patchNode fails fast on a nested typeless node — no collab connection", async () => {
|
||||||
const { state, baseURL } = await spawnCollabStack();
|
const { state, baseURL } = await spawnCollabStack();
|
||||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => client.patchNode(PAGE, SEED_ID, nestedTypelessNode()),
|
() => client.patchNode(PAGE, SEED_ID, { node: nestedTypelessNode() }),
|
||||||
(err) => {
|
(err) => {
|
||||||
assert.match(err.message, /patch_node: invalid node/);
|
assert.match(err.message, /patchNode: invalid node/);
|
||||||
assert.match(err.message, /missing "type"/);
|
assert.match(err.message, /missing "type"/);
|
||||||
assert.match(err.message, /content\[0\]/); // path-anchored
|
assert.match(err.message, /content\[0\]/); // path-anchored
|
||||||
return true;
|
return true;
|
||||||
@@ -152,17 +152,21 @@ test("patch_node fails fast on a nested typeless node — no collab connection",
|
|||||||
assert.equal(state.changed, false, "the collab doc must never be written");
|
assert.equal(state.changed, false, "the collab doc must never be written");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("insert_node fails fast on a nested UNKNOWN type — no collab connection", async () => {
|
test("insertNode fails fast on a nested UNKNOWN type — no collab connection", async () => {
|
||||||
const { state, baseURL } = await spawnCollabStack();
|
const { state, baseURL } = await spawnCollabStack();
|
||||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() =>
|
() =>
|
||||||
client.insertNode(PAGE, nestedUnknownTypeNode(), {
|
client.insertNode(
|
||||||
position: "append",
|
PAGE,
|
||||||
}),
|
{ node: nestedUnknownTypeNode() },
|
||||||
|
{
|
||||||
|
position: "append",
|
||||||
|
},
|
||||||
|
),
|
||||||
(err) => {
|
(err) => {
|
||||||
assert.match(err.message, /insert_node: invalid node/);
|
assert.match(err.message, /insertNode: invalid node/);
|
||||||
assert.match(err.message, /unknown node type "paragraf"/);
|
assert.match(err.message, /unknown node type "paragraf"/);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -172,7 +176,7 @@ test("insert_node fails fast on a nested UNKNOWN type — no collab connection",
|
|||||||
assert.equal(state.changed, false);
|
assert.equal(state.changed, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("update_page_json fails fast on a nested typeless node — no collab connection", async () => {
|
test("updatePageJson fails fast on a nested typeless node — no collab connection", async () => {
|
||||||
const { state, baseURL } = await spawnCollabStack();
|
const { state, baseURL } = await spawnCollabStack();
|
||||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
|
|
||||||
@@ -184,7 +188,7 @@ test("update_page_json fails fast on a nested typeless node — no collab connec
|
|||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => client.updatePageJson(PAGE, badDoc),
|
() => client.updatePageJson(PAGE, badDoc),
|
||||||
(err) => {
|
(err) => {
|
||||||
// update_page_json runs validateDocStructure first (string-type check),
|
// updatePageJson runs validateDocStructure first (string-type check),
|
||||||
// which already rejects a typeless node — so the message may come from
|
// which already rejects a typeless node — so the message may come from
|
||||||
// either guard, but the write must not happen.
|
// either guard, but the write must not happen.
|
||||||
assert.match(err.message, /type/i);
|
assert.match(err.message, /type/i);
|
||||||
@@ -196,7 +200,7 @@ test("update_page_json fails fast on a nested typeless node — no collab connec
|
|||||||
assert.equal(state.changed, false);
|
assert.equal(state.changed, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("update_page_json fails fast on a nested UNKNOWN type name — rich #409 message", async () => {
|
test("updatePageJson fails fast on a nested UNKNOWN type name — rich #409 message", async () => {
|
||||||
const { state, baseURL } = await spawnCollabStack();
|
const { state, baseURL } = await spawnCollabStack();
|
||||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
|
|
||||||
@@ -210,7 +214,7 @@ test("update_page_json fails fast on a nested UNKNOWN type name — rich #409 me
|
|||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => client.updatePageJson(PAGE, badDoc),
|
() => client.updatePageJson(PAGE, badDoc),
|
||||||
(err) => {
|
(err) => {
|
||||||
assert.match(err.message, /update_page_json: invalid node/);
|
assert.match(err.message, /updatePageJson: invalid node/);
|
||||||
assert.match(err.message, /unknown node type "paragraf"/);
|
assert.match(err.message, /unknown node type "paragraf"/);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
@@ -220,13 +224,15 @@ test("update_page_json fails fast on a nested UNKNOWN type name — rich #409 me
|
|||||||
assert.equal(state.changed, false);
|
assert.equal(state.changed, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("patch_node with a well-formed node proceeds to the collab write", async () => {
|
test("patchNode with a well-formed node proceeds to the collab write", async () => {
|
||||||
const { state, baseURL } = await spawnCollabStack();
|
const { state, baseURL } = await spawnCollabStack();
|
||||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
|
|
||||||
const result = await client.patchNode(PAGE, SEED_ID, {
|
const result = await client.patchNode(PAGE, SEED_ID, {
|
||||||
type: "paragraph",
|
node: {
|
||||||
content: [{ type: "text", text: "replacement" }],
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "replacement" }],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.equal(result.success, true);
|
assert.equal(result.success, true);
|
||||||
|
|||||||
@@ -0,0 +1,538 @@
|
|||||||
|
// Mock collab tests for the #413 MARKDOWN path of patchNode / insertNode and the
|
||||||
|
// markdown-default getNode. These stand up a real Hocuspocus collab server seeded
|
||||||
|
// with a chosen document (mirroring ambiguous-node-id.test.mjs), let the client
|
||||||
|
// run its real transform against a live Y.Doc, and read the persisted result back
|
||||||
|
// to assert on the written document.
|
||||||
|
//
|
||||||
|
// Coverage (issue #413):
|
||||||
|
// - CANON CONVERGENCE: a block written via patchNode(markdown) is canonically
|
||||||
|
// equal to the SAME content run through a full markdown import (no "second
|
||||||
|
// canon" appears on the block-level path).
|
||||||
|
// - id-THREAD on a 1->N splice: the first block inherits the target id, the rest
|
||||||
|
// get fresh ids, and every NEIGHBOUR block is byte-identical before/after.
|
||||||
|
// - XOR validation (both / neither markdown+node -> error).
|
||||||
|
// - span/color-attr GUARD on the target block (a merged/colored cell refuses a
|
||||||
|
// markdown patch, nothing written).
|
||||||
|
// - `^[...]` footnote in the fragment -> a definition in the tail list + renumber.
|
||||||
|
// - insertNode(markdown) inserts N blocks in order at the anchor.
|
||||||
|
import { test, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import http from "node:http";
|
||||||
|
import { WebSocketServer } from "ws";
|
||||||
|
import { Hocuspocus } from "@hocuspocus/server";
|
||||||
|
import { DocmostClient } from "../../build/client.js";
|
||||||
|
import { buildYDoc } from "../../build/lib/collaboration.js";
|
||||||
|
import {
|
||||||
|
docsCanonicallyEqual,
|
||||||
|
markdownToProseMirror,
|
||||||
|
} from "@docmost/prosemirror-markdown";
|
||||||
|
|
||||||
|
const PAGE = "11111111-1111-4111-8111-111111111111";
|
||||||
|
|
||||||
|
// Deep JSON clone for byte-identity assertions.
|
||||||
|
const jclone = (v) => JSON.parse(JSON.stringify(v));
|
||||||
|
|
||||||
|
function findAll(node, type, acc = []) {
|
||||||
|
if (!node || typeof node !== "object") return acc;
|
||||||
|
if (node.type === type) acc.push(node);
|
||||||
|
if (Array.isArray(node.content))
|
||||||
|
for (const c of node.content) findAll(c, type, acc);
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stand up an HTTP+Hocuspocus stack seeded with `seedDoc`. `state.lastDoc` holds
|
||||||
|
// the most recently persisted document JSON (decoded from the live Y.Doc on every
|
||||||
|
// change) so a test can inspect exactly what was written.
|
||||||
|
async function spawnCollabStack(seedDoc) {
|
||||||
|
const state = { changed: false, lastDoc: null };
|
||||||
|
|
||||||
|
const hocuspocus = new Hocuspocus({
|
||||||
|
quiet: true,
|
||||||
|
async onLoadDocument() {
|
||||||
|
return buildYDoc(seedDoc);
|
||||||
|
},
|
||||||
|
async onChange(data) {
|
||||||
|
state.changed = true;
|
||||||
|
try {
|
||||||
|
const frag = data.document.getXmlFragment("default");
|
||||||
|
// Decode the live fragment back to JSON via the same helper the client
|
||||||
|
// reads with — but simpler: use the yjs->json path exposed by the doc.
|
||||||
|
state.lastDoc = fragmentToJson(frag);
|
||||||
|
} catch {
|
||||||
|
/* ignore decode errors in teardown races */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
let raw = "";
|
||||||
|
req.on("data", (c) => (raw += c));
|
||||||
|
req.on("end", () => {
|
||||||
|
if (req.url === "/api/auth/login") {
|
||||||
|
res.writeHead(200, {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
||||||
|
});
|
||||||
|
res.end(JSON.stringify({ success: true }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === "/api/auth/collab-token") {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ data: { token: "collab-jwt" } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(404, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ message: "not found" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.on("upgrade", (request, socket, head) => {
|
||||||
|
if (!request.url || !request.url.startsWith("/collab")) {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||||
|
hocuspocus.handleConnection(ws, request);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseURL = await new Promise((resolve) => {
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const { port } = server.address();
|
||||||
|
resolve(`http://127.0.0.1:${port}/api`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
openStacks.push({ server, hocuspocus });
|
||||||
|
return { state, baseURL };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal XmlFragment -> ProseMirror JSON decode, mirroring the shape Docmost
|
||||||
|
// stores. Reads element name as node type, attributes as attrs, and recurses into
|
||||||
|
// children; text nodes carry their string.
|
||||||
|
function fragmentToJson(frag) {
|
||||||
|
const decodeNode = (el) => {
|
||||||
|
if (el.constructor.name === "YXmlText") {
|
||||||
|
// A yjs text node: collect the string with its formatting deltas.
|
||||||
|
const delta = el.toDelta();
|
||||||
|
return delta.map((d) => {
|
||||||
|
const node = { type: "text", text: d.insert };
|
||||||
|
if (d.attributes && Object.keys(d.attributes).length) {
|
||||||
|
node.marks = Object.entries(d.attributes).map(([type, attrs]) =>
|
||||||
|
attrs && typeof attrs === "object" && Object.keys(attrs).length
|
||||||
|
? { type, attrs }
|
||||||
|
: { type },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const node = { type: el.nodeName };
|
||||||
|
const attrs = el.getAttributes();
|
||||||
|
if (attrs && Object.keys(attrs).length) node.attrs = attrs;
|
||||||
|
const children = [];
|
||||||
|
for (const child of el.toArray()) {
|
||||||
|
const decoded = decodeNode(child);
|
||||||
|
if (Array.isArray(decoded)) children.push(...decoded);
|
||||||
|
else children.push(decoded);
|
||||||
|
}
|
||||||
|
if (children.length) node.content = children;
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
const content = [];
|
||||||
|
for (const child of frag.toArray()) content.push(decodeNode(child));
|
||||||
|
return { type: "doc", content };
|
||||||
|
}
|
||||||
|
|
||||||
|
const openStacks = [];
|
||||||
|
after(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
openStacks.map(
|
||||||
|
({ server, hocuspocus }) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
server.close(() => {
|
||||||
|
Promise.resolve(hocuspocus.destroy?.()).finally(resolve);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A seed doc with two neighbour paragraphs around a target paragraph.
|
||||||
|
function seed3() {
|
||||||
|
return {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
attrs: { id: "before-id" },
|
||||||
|
content: [{ type: "text", text: "before" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
attrs: { id: "target-id" },
|
||||||
|
content: [{ type: "text", text: "old target" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
attrs: { id: "after-id" },
|
||||||
|
content: [{ type: "text", text: "after" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("patchNode(markdown): XOR — both markdown and node is rejected, nothing written", async () => {
|
||||||
|
const { state, baseURL } = await spawnCollabStack(seed3());
|
||||||
|
const client = new DocmostClient(baseURL, "e@x.com", "pw");
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
client.patchNode(PAGE, "target-id", {
|
||||||
|
markdown: "hello",
|
||||||
|
node: { type: "paragraph" },
|
||||||
|
}),
|
||||||
|
/exactly one of/i,
|
||||||
|
);
|
||||||
|
assert.equal(state.changed, false, "no write on an XOR violation");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("patchNode(markdown): XOR — neither markdown nor node is rejected", async () => {
|
||||||
|
const { baseURL } = await spawnCollabStack(seed3());
|
||||||
|
const client = new DocmostClient(baseURL, "e@x.com", "pw");
|
||||||
|
await assert.rejects(
|
||||||
|
() => client.patchNode(PAGE, "target-id", {}),
|
||||||
|
/exactly one of/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("patchNode(markdown): single block keeps the id; neighbours byte-identical", async () => {
|
||||||
|
const before = seed3();
|
||||||
|
const { state, baseURL } = await spawnCollabStack(before);
|
||||||
|
const client = new DocmostClient(baseURL, "e@x.com", "pw");
|
||||||
|
|
||||||
|
const res = await client.patchNode(PAGE, "target-id", {
|
||||||
|
markdown: "the **new** target",
|
||||||
|
});
|
||||||
|
assert.equal(res.success, true);
|
||||||
|
assert.equal(res.replaced, 1);
|
||||||
|
assert.equal(res.blocks, 1);
|
||||||
|
|
||||||
|
const doc = state.lastDoc;
|
||||||
|
const paras = doc.content;
|
||||||
|
// The rewritten block still carries the target id.
|
||||||
|
const target = paras.find((p) => p.attrs?.id === "target-id");
|
||||||
|
assert.ok(target, "rewritten block inherits target-id");
|
||||||
|
assert.equal(target.content.some((n) => n.text === "new"), true);
|
||||||
|
// Neighbours are byte-identical to the seed.
|
||||||
|
const beforeNode = paras.find((p) => p.attrs?.id === "before-id");
|
||||||
|
const afterNode = paras.find((p) => p.attrs?.id === "after-id");
|
||||||
|
assert.deepEqual(beforeNode, before.content[0]);
|
||||||
|
assert.deepEqual(afterNode, before.content[2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("patchNode(markdown): 1->N splice threads the id onto the first block; neighbours byte-identical", async () => {
|
||||||
|
const before = seed3();
|
||||||
|
const { state, baseURL } = await spawnCollabStack(before);
|
||||||
|
const client = new DocmostClient(baseURL, "e@x.com", "pw");
|
||||||
|
|
||||||
|
// Two paragraphs of markdown -> a 2-block fragment replacing one block.
|
||||||
|
const res = await client.patchNode(PAGE, "target-id", {
|
||||||
|
markdown: "first para\n\nsecond para",
|
||||||
|
});
|
||||||
|
assert.equal(res.blocks, 2);
|
||||||
|
|
||||||
|
const doc = state.lastDoc;
|
||||||
|
const idx = doc.content.findIndex((p) => p.attrs?.id === "target-id");
|
||||||
|
assert.ok(idx >= 0, "first spliced block inherits target-id");
|
||||||
|
const first = doc.content[idx];
|
||||||
|
const second = doc.content[idx + 1];
|
||||||
|
assert.equal(first.content.some((n) => n.text === "first para"), true);
|
||||||
|
assert.equal(second.content.some((n) => n.text === "second para"), true);
|
||||||
|
// The second block has a DIFFERENT (fresh) id.
|
||||||
|
assert.notEqual(second.attrs?.id, "target-id");
|
||||||
|
assert.ok(second.attrs?.id, "the extra block gets a fresh id");
|
||||||
|
// Neighbours untouched, byte-identical.
|
||||||
|
assert.deepEqual(
|
||||||
|
doc.content.find((p) => p.attrs?.id === "before-id"),
|
||||||
|
before.content[0],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
doc.content.find((p) => p.attrs?.id === "after-id"),
|
||||||
|
before.content[2],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("patchNode(markdown): CANON CONVERGENCE — block equals the same content full-imported", async () => {
|
||||||
|
const { state, baseURL } = await spawnCollabStack(seed3());
|
||||||
|
const client = new DocmostClient(baseURL, "e@x.com", "pw");
|
||||||
|
|
||||||
|
const md = "a paragraph with **bold**, _italic_ and `code`";
|
||||||
|
await client.patchNode(PAGE, "target-id", { markdown: md });
|
||||||
|
|
||||||
|
// The block as persisted.
|
||||||
|
const target = state.lastDoc.content.find((p) => p.attrs?.id === "target-id");
|
||||||
|
// The same markdown run through the full-page importer.
|
||||||
|
const full = await markdownToProseMirror(md);
|
||||||
|
const fullBlock = full.content[0];
|
||||||
|
|
||||||
|
assert.ok(
|
||||||
|
docsCanonicallyEqual(
|
||||||
|
{ type: "doc", content: [target] },
|
||||||
|
{ type: "doc", content: [fullBlock] },
|
||||||
|
),
|
||||||
|
"a patchNode(markdown) block must be canonically equal to a full import — no second canon",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("patchNode(markdown): a paragraph inside a merged (colspan) cell rewrites fine — the cell's span is preserved", async () => {
|
||||||
|
// A cell paragraph carries an id and IS id-targetable; rewriting ITS content
|
||||||
|
// from markdown replaces only the paragraph, so the cell's colspan is NOT lost
|
||||||
|
// (the span lives on the cell, which patchNode leaves in place). This is the
|
||||||
|
// correct behavior: no false guard, no loss. The guard's REJECTION logic (when
|
||||||
|
// the replaced block itself carries/contains an unrepresentable span) is proven
|
||||||
|
// by the findUnrepresentableTableAttrs unit test — that case is not reachable
|
||||||
|
// through the id-targeting API because tables/cells carry no addressable id.
|
||||||
|
const doc = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "table",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tableRow",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tableCell",
|
||||||
|
attrs: { colspan: 2, rowspan: 1 },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
attrs: { id: "cell-para" },
|
||||||
|
content: [{ type: "text", text: "merged" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const { state, baseURL } = await spawnCollabStack(doc);
|
||||||
|
const client = new DocmostClient(baseURL, "e@x.com", "pw");
|
||||||
|
|
||||||
|
const res = await client.patchNode(PAGE, "cell-para", { markdown: "rewritten" });
|
||||||
|
assert.equal(res.success, true);
|
||||||
|
// The cell's colspan survives (the span is on the cell, not the paragraph).
|
||||||
|
const cell = findAll(state.lastDoc, "tableCell")[0];
|
||||||
|
assert.equal(cell.attrs.colspan, 2, "the cell's colspan is preserved");
|
||||||
|
const para = findAll(cell, "paragraph")[0];
|
||||||
|
assert.equal(
|
||||||
|
(para.content || []).some((n) => n.text === "rewritten"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("patchNode(markdown): a `^[...]` footnote in the fragment lands in the tail list", async () => {
|
||||||
|
const { state, baseURL } = await spawnCollabStack(seed3());
|
||||||
|
const client = new DocmostClient(baseURL, "e@x.com", "pw");
|
||||||
|
|
||||||
|
await client.patchNode(PAGE, "target-id", {
|
||||||
|
markdown: "a claim^[the supporting note]",
|
||||||
|
});
|
||||||
|
|
||||||
|
const doc = state.lastDoc;
|
||||||
|
const lists = findAll(doc, "footnotesList");
|
||||||
|
assert.equal(lists.length, 1, "exactly one tail footnotesList");
|
||||||
|
const defs = findAll(doc, "footnoteDefinition");
|
||||||
|
assert.equal(defs.length, 1, "one definition for the fragment footnote");
|
||||||
|
const refs = findAll(doc, "footnoteReference");
|
||||||
|
assert.equal(refs.length, 1, "one reference in the body");
|
||||||
|
// Reference and definition share an id (renumbered canonically).
|
||||||
|
assert.equal(refs[0].attrs.id, defs[0].attrs.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("insertNode(markdown): inserts N blocks in order after the anchor", async () => {
|
||||||
|
const before = seed3();
|
||||||
|
const { state, baseURL } = await spawnCollabStack(before);
|
||||||
|
const client = new DocmostClient(baseURL, "e@x.com", "pw");
|
||||||
|
|
||||||
|
const res = await client.insertNode(
|
||||||
|
PAGE,
|
||||||
|
{ markdown: "new one\n\nnew two" },
|
||||||
|
{ position: "after", anchorNodeId: "before-id" },
|
||||||
|
);
|
||||||
|
assert.equal(res.success, true);
|
||||||
|
assert.equal(res.blocks, 2);
|
||||||
|
|
||||||
|
const texts = state.lastDoc.content.map((p) => (p.content || []).map((n) => n.text).join(""));
|
||||||
|
// Order: before, new one, new two, target, after.
|
||||||
|
assert.deepEqual(texts, ["before", "new one", "new two", "old target", "after"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("insertNode(markdown): XOR — both markdown and node is rejected", async () => {
|
||||||
|
const { baseURL } = await spawnCollabStack(seed3());
|
||||||
|
const client = new DocmostClient(baseURL, "e@x.com", "pw");
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
client.insertNode(
|
||||||
|
PAGE,
|
||||||
|
{ markdown: "x", node: { type: "paragraph" } },
|
||||||
|
{ position: "append" },
|
||||||
|
),
|
||||||
|
/exactly one of/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// A seed page whose ONLY footnote reference lives in the target paragraph p1,
|
||||||
|
// with a matching definition in a trailing footnotesList. Rewriting p1 with a
|
||||||
|
// footnote-free fragment removes the last referrer -> the definition is orphaned.
|
||||||
|
function seedOrphanFootnote() {
|
||||||
|
return {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
attrs: { id: "p1" },
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "a claim" },
|
||||||
|
{ type: "footnoteReference", attrs: { id: "fn-1", referenceNumber: 1 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "footnotesList",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "footnoteDefinition",
|
||||||
|
attrs: { id: "fn-1" },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
attrs: { id: "def-para" },
|
||||||
|
content: [{ type: "text", text: "the supporting note" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("patchNode(markdown): removing the LAST footnote referrer drops the now-orphan definition (canonical convergence)", async () => {
|
||||||
|
const { state, baseURL } = await spawnCollabStack(seedOrphanFootnote());
|
||||||
|
const client = new DocmostClient(baseURL, "e@x.com", "pw");
|
||||||
|
|
||||||
|
// The fragment has NO footnotes -> definitions=[]; the splice removes the only
|
||||||
|
// footnoteReference, leaving the tail definition orphaned. The canonicalization
|
||||||
|
// pass (which mergeFootnoteDefinitions must still run) has to drop it.
|
||||||
|
await client.patchNode(PAGE, "p1", { markdown: "just text" });
|
||||||
|
|
||||||
|
const doc = state.lastDoc;
|
||||||
|
assert.equal(
|
||||||
|
findAll(doc, "footnoteDefinition").length,
|
||||||
|
0,
|
||||||
|
"the orphaned definition is dropped",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
findAll(doc, "footnotesList").length,
|
||||||
|
0,
|
||||||
|
"the emptied footnotesList is removed",
|
||||||
|
);
|
||||||
|
assert.equal(findAll(doc, "footnoteReference").length, 0, "no references remain");
|
||||||
|
|
||||||
|
// Convergence: the persisted result equals the SAME content imported whole.
|
||||||
|
const full = await markdownToProseMirror("just text");
|
||||||
|
const target = doc.content.find((p) => p.attrs?.id === "p1");
|
||||||
|
assert.ok(
|
||||||
|
docsCanonicallyEqual(
|
||||||
|
{ type: "doc", content: [target] },
|
||||||
|
{ type: "doc", content: [full.content[0]] },
|
||||||
|
),
|
||||||
|
"the post-splice doc is canonically identical to a full re-import",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("patchNode(markdown): a pure-text patch on a footnote-FREE page leaves footnote topology untouched (fast path)", async () => {
|
||||||
|
const before = seed3();
|
||||||
|
const { state, baseURL } = await spawnCollabStack(before);
|
||||||
|
const client = new DocmostClient(baseURL, "e@x.com", "pw");
|
||||||
|
|
||||||
|
await client.patchNode(PAGE, "target-id", { markdown: "plain replacement" });
|
||||||
|
|
||||||
|
const doc = state.lastDoc;
|
||||||
|
assert.equal(findAll(doc, "footnotesList").length, 0, "no footnotesList appears");
|
||||||
|
assert.equal(findAll(doc, "footnoteDefinition").length, 0, "no definition appears");
|
||||||
|
assert.equal(findAll(doc, "footnoteReference").length, 0, "no reference appears");
|
||||||
|
// Neighbours byte-identical (the fast path does not clone/reshape the tree).
|
||||||
|
assert.deepEqual(
|
||||||
|
doc.content.find((p) => p.attrs?.id === "before-id"),
|
||||||
|
before.content[0],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
doc.content.find((p) => p.attrs?.id === "after-id"),
|
||||||
|
before.content[2],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("insertNode(markdown): a footnote-free insert on a page carrying a footnote still canonicalizes (definitions empty)", async () => {
|
||||||
|
// The page has an existing footnote (ref + tail def). Inserting a footnote-free
|
||||||
|
// fragment keeps the reference alive, so the definition stays — but the write
|
||||||
|
// path must still run canonicalization (definitions=[]), producing exactly one
|
||||||
|
// tail list with the reference/definition ids in sync.
|
||||||
|
const { state, baseURL } = await spawnCollabStack(seedOrphanFootnote());
|
||||||
|
const client = new DocmostClient(baseURL, "e@x.com", "pw");
|
||||||
|
|
||||||
|
const res = await client.insertNode(
|
||||||
|
PAGE,
|
||||||
|
{ markdown: "unrelated one\n\nunrelated two" },
|
||||||
|
{ position: "after", anchorNodeId: "p1" },
|
||||||
|
);
|
||||||
|
assert.equal(res.success, true);
|
||||||
|
|
||||||
|
const doc = state.lastDoc;
|
||||||
|
assert.equal(findAll(doc, "footnoteReference").length, 1, "the existing reference survives");
|
||||||
|
assert.equal(findAll(doc, "footnotesList").length, 1, "exactly one tail list");
|
||||||
|
const defs = findAll(doc, "footnoteDefinition");
|
||||||
|
assert.equal(defs.length, 1, "the definition is kept (still referenced)");
|
||||||
|
assert.equal(findAll(doc, "footnoteReference")[0].attrs.id, defs[0].attrs.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Collect every TOP-LEVEL block id in a doc (the invariant the splice dedup
|
||||||
|
// guarantees is page-wide top-level uniqueness).
|
||||||
|
function topLevelIds(doc) {
|
||||||
|
return doc.content
|
||||||
|
.map((b) => b?.attrs?.id)
|
||||||
|
.filter((id) => id != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("patchNode(markdown): a 1->N splice yields page-wide UNIQUE top-level block ids", async () => {
|
||||||
|
const before = seed3();
|
||||||
|
const { state, baseURL } = await spawnCollabStack(before);
|
||||||
|
const client = new DocmostClient(baseURL, "e@x.com", "pw");
|
||||||
|
|
||||||
|
await client.patchNode(PAGE, "target-id", {
|
||||||
|
markdown: "one\n\ntwo\n\nthree",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ids = topLevelIds(state.lastDoc);
|
||||||
|
assert.equal(new Set(ids).size, ids.length, "all top-level block ids are unique");
|
||||||
|
// The target id is still present (threaded onto the first block).
|
||||||
|
assert.ok(ids.includes("target-id"), "the first block still inherits target-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("insertNode(markdown): inserting multiple blocks yields page-wide UNIQUE top-level block ids", async () => {
|
||||||
|
const before = seed3();
|
||||||
|
const { state, baseURL } = await spawnCollabStack(before);
|
||||||
|
const client = new DocmostClient(baseURL, "e@x.com", "pw");
|
||||||
|
|
||||||
|
await client.insertNode(
|
||||||
|
PAGE,
|
||||||
|
{ markdown: "alpha\n\nbeta\n\ngamma" },
|
||||||
|
{ position: "after", anchorNodeId: "before-id" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const ids = topLevelIds(state.lastDoc);
|
||||||
|
assert.equal(new Set(ids).size, ids.length, "all top-level block ids are unique");
|
||||||
|
});
|
||||||
@@ -155,7 +155,7 @@ test("listSidebarPages terminates (no dups) when the server ignores the cursor",
|
|||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// 3a) enumerateSpacePages happy path: a SINGLE /pages/tree request.
|
// 3a) enumerateSpacePages happy path: a SINGLE /pages/tree request.
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
test("enumerateSpacePages (via list_pages tree) uses one /pages/tree request", async () => {
|
test("enumerateSpacePages (via listPages tree) uses one /pages/tree request", async () => {
|
||||||
let treeRequests = 0;
|
let treeRequests = 0;
|
||||||
let sidebarRequests = 0;
|
let sidebarRequests = 0;
|
||||||
let treeBody = null;
|
let treeBody = null;
|
||||||
@@ -184,7 +184,7 @@ test("enumerateSpacePages (via list_pages tree) uses one /pages/tree request", a
|
|||||||
});
|
});
|
||||||
|
|
||||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
// list_pages tree:true -> enumerateSpacePages(spaceId) -> buildPageTree.
|
// listPages tree:true -> enumerateSpacePages(spaceId) -> buildPageTree.
|
||||||
const tree = await client.listPages("space-1", 50, true);
|
const tree = await client.listPages("space-1", 50, true);
|
||||||
|
|
||||||
assert.equal(treeRequests, 1, "exactly one /pages/tree request for the space");
|
assert.equal(treeRequests, 1, "exactly one /pages/tree request for the space");
|
||||||
@@ -375,7 +375,7 @@ test("listComments terminates (no dups) when the server ignores the cursor", asy
|
|||||||
});
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// 4) check_new_comments subtree: the root is included in scope WITHOUT a
|
// 4) checkNewComments subtree: the root is included in scope WITHOUT a
|
||||||
// separate getPageRaw (/pages/info) request for the parent.
|
// separate getPageRaw (/pages/info) request for the parent.
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
test("checkNewComments subtree includes the root without a separate getPageRaw", async () => {
|
test("checkNewComments subtree includes the root without a separate getPageRaw", async () => {
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ test("tableInsertRow with a slugId opens the collab doc by the resolved UUID (#2
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the generic mutate (insert_footnote) with a slugId opens by the resolved UUID (#260)", async () => {
|
test("the generic mutate (insertFootnote) with a slugId opens by the resolved UUID (#260)", async () => {
|
||||||
const { state, baseURL } = await spawnCollabStack();
|
const { state, baseURL } = await spawnCollabStack();
|
||||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
|
|
||||||
@@ -254,7 +254,7 @@ test("the generic mutate (insert_footnote) with a slugId opens by the resolved U
|
|||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
state.docNames,
|
state.docNames,
|
||||||
[`page.${UUID}`],
|
[`page.${UUID}`],
|
||||||
"insert_footnote (via the mutatePage seam) must open the collab doc by UUID",
|
"insertFootnote (via the mutatePage seam) must open the collab doc by UUID",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Server round-trip test for the stash_page MCP tool result shape. The in-app
|
// Server round-trip test for the stashPage MCP tool result shape. The in-app
|
||||||
// path returns the full documented `{ uri, size, sha256, images }` object, but
|
// path returns the full documented `{ uri, size, sha256, images }` object, but
|
||||||
// the MCP transport must deliver the SAME shape: a resource_link (primary
|
// the MCP transport must deliver the SAME shape: a resource_link (primary
|
||||||
// payload) PLUS a `structuredContent` mirror carrying sha256 + image counts.
|
// payload) PLUS a `structuredContent` mirror carrying sha256 + image counts.
|
||||||
@@ -107,7 +107,7 @@ async function buildBaseURL() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test("stash_page MCP tool returns a resource_link AND a structuredContent mirror", async () => {
|
test("stashPage MCP tool returns a resource_link AND a structuredContent mirror", async () => {
|
||||||
const baseURL = await buildBaseURL();
|
const baseURL = await buildBaseURL();
|
||||||
const sandbox = makeSandbox();
|
const sandbox = makeSandbox();
|
||||||
const server = createDocmostMcpServer({
|
const server = createDocmostMcpServer({
|
||||||
@@ -124,7 +124,7 @@ test("stash_page MCP tool returns a resource_link AND a structuredContent mirror
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await client.callTool({
|
const res = await client.callTool({
|
||||||
name: "stash_page",
|
name: "stashPage",
|
||||||
arguments: { pageId: "page-1" },
|
arguments: { pageId: "page-1" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|||||||
|
|
||||||
import { createDocmostMcpServer } from "../../build/index.js";
|
import { createDocmostMcpServer } from "../../build/index.js";
|
||||||
|
|
||||||
// The tool we drive. get_workspace has NO input schema, so protocol-level input
|
// The tool we drive. getWorkspace has NO input schema, so protocol-level input
|
||||||
// validation cannot short-circuit before the handler runs — the wrapped handler
|
// validation cannot short-circuit before the handler runs — the wrapped handler
|
||||||
// is guaranteed to execute (and then fail on the unreachable backend, which is
|
// is guaranteed to execute (and then fail on the unreachable backend, which is
|
||||||
// exactly what we want: the wrapper times in a finally on throw too).
|
// exactly what we want: the wrapper times in a finally on throw too).
|
||||||
const TOOL_NAME = "get_workspace";
|
const TOOL_NAME = "getWorkspace";
|
||||||
|
|
||||||
test("the factory's registerTool monkeypatch times a live tool call and labels it with the registration name", async () => {
|
test("the factory's registerTool monkeypatch times a live tool call and labels it with the registration name", async () => {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ test("tautological comment tools are excluded and never probe", async () => {
|
|||||||
const { comments, probeCalls, tracker } = makeWorld();
|
const { comments, probeCalls, tracker } = makeWorld();
|
||||||
tracker.noteWorkingPage("p1");
|
tracker.noteWorkingPage("p1");
|
||||||
comments.push({ createdAt: 9_999_999 });
|
comments.push({ createdAt: 9_999_999 });
|
||||||
for (const name of ["listComments", "list_comments", "checkNewComments", "createComment"]) {
|
for (const name of ["listComments", "listComments", "checkNewComments", "createComment"]) {
|
||||||
assert.equal(await tracker.maybeSignal(name), null);
|
assert.equal(await tracker.maybeSignal(name), null);
|
||||||
}
|
}
|
||||||
assert.equal(probeCalls.length, 0);
|
assert.equal(probeCalls.length, 0);
|
||||||
@@ -188,7 +188,7 @@ function fakeTracker({ line }) {
|
|||||||
noteWorkingPage: (p) => events.push(["note", p]),
|
noteWorkingPage: (p) => events.push(["note", p]),
|
||||||
advanceWatermark: () => events.push(["advance"]),
|
advanceWatermark: () => events.push(["advance"]),
|
||||||
isExcludedTool: (n) =>
|
isExcludedTool: (n) =>
|
||||||
new Set(["listComments", "list_comments"]).has(n),
|
new Set(["listComments", "listComments"]).has(n),
|
||||||
maybeSignal: async () => line,
|
maybeSignal: async () => line,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -221,7 +221,7 @@ test("withCommentSignal: appends ONE extra text element when signalled", async (
|
|||||||
test("withCommentSignal: excluded tool advances the watermark and does not append", async () => {
|
test("withCommentSignal: excluded tool advances the watermark and does not append", async () => {
|
||||||
const tracker = fakeTracker({ line: "SHOULD-NOT-APPEAR" });
|
const tracker = fakeTracker({ line: "SHOULD-NOT-APPEAR" });
|
||||||
const original = { content: [{ type: "text", text: "comments" }] };
|
const original = { content: [{ type: "text", text: "comments" }] };
|
||||||
const wrapped = withCommentSignal("list_comments", async () => original, tracker);
|
const wrapped = withCommentSignal("listComments", async () => original, tracker);
|
||||||
const result = await wrapped({ pageId: "p1" });
|
const result = await wrapped({ pageId: "p1" });
|
||||||
assert.equal(result, original); // unchanged
|
assert.equal(result, original); // unchanged
|
||||||
assert.ok(tracker.events.some((e) => e[0] === "advance"));
|
assert.ok(tracker.events.some((e) => e[0] === "advance"));
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
// Issue #464 — prove the size guard SKIPS the recreateTransform pipeline over
|
|
||||||
// the cap, not merely that it returns "coarse". node:test's mock.module needs an
|
|
||||||
// experimental flag the suite does not pass, so instead of a module spy we use a
|
|
||||||
// deterministic BEHAVIORAL proxy that isolates the one variable — the guard:
|
|
||||||
//
|
|
||||||
// Same over-cap pair, run twice:
|
|
||||||
// (a) default caps -> guard trips -> recreateTransform skipped,
|
|
||||||
// (b) caps raised above the doc -> guard OFF -> recreateTransform DOES run.
|
|
||||||
//
|
|
||||||
// The only code path that differs between (a) and (b) is whether
|
|
||||||
// recreateTransform executes. recreateTransform on this pair is O(n²) and takes
|
|
||||||
// SECONDS; the guarded path is a linear coarse diff taking milliseconds. So a
|
|
||||||
// large (a)≪(b) time ratio can ONLY be explained by (a) skipping the transform.
|
|
||||||
// This asserts the skip without depending on mock.module.
|
|
||||||
import { test } from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
|
|
||||||
import { diffDocs } from "../../build/lib/diff.js";
|
|
||||||
|
|
||||||
const t = (text) => ({ type: "text", text });
|
|
||||||
const para = (text) => ({ type: "paragraph", content: text ? [t(text)] : [] });
|
|
||||||
const doc = (children) => ({ type: "doc", content: children });
|
|
||||||
function buildDoc(n, seed) {
|
|
||||||
return doc(
|
|
||||||
Array.from({ length: n }, (_, i) =>
|
|
||||||
para(Array.from({ length: 8 }, (_, w) => `${seed}${i}_${w}`).join(" ")),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function clearEnv() {
|
|
||||||
delete process.env.MCP_DIFF_MAX_NODES;
|
|
||||||
delete process.env.MCP_DIFF_MAX_BYTES;
|
|
||||||
}
|
|
||||||
function timed(fn) {
|
|
||||||
const s = performance.now();
|
|
||||||
const out = fn();
|
|
||||||
return { out, ms: performance.now() - s };
|
|
||||||
}
|
|
||||||
|
|
||||||
// A 300-para (~600-node) pair: comfortably over the 150-node default, yet small
|
|
||||||
// enough that the un-guarded recreateTransform still FINISHES (~1-3s) so the
|
|
||||||
// test can time the contrast without hanging.
|
|
||||||
const OLD = buildDoc(300, "a");
|
|
||||||
const NEW = buildDoc(300, "b");
|
|
||||||
|
|
||||||
test("guard skips recreateTransform over-cap (guarded run is far faster than un-guarded)", () => {
|
|
||||||
// (a) Guarded: default caps -> should short-circuit to coarse, near-instant.
|
|
||||||
clearEnv();
|
|
||||||
const guarded = timed(() => diffDocs(OLD, NEW));
|
|
||||||
assert.match(
|
|
||||||
guarded.out.markdown,
|
|
||||||
/coarse block-level diff/,
|
|
||||||
"guarded run must be coarse (guard tripped)",
|
|
||||||
);
|
|
||||||
|
|
||||||
// (b) Un-guarded: raise both caps above the doc so the precise path runs.
|
|
||||||
process.env.MCP_DIFF_MAX_NODES = "1000000";
|
|
||||||
process.env.MCP_DIFF_MAX_BYTES = "100000000";
|
|
||||||
let unguarded;
|
|
||||||
try {
|
|
||||||
unguarded = timed(() => diffDocs(OLD, NEW));
|
|
||||||
} finally {
|
|
||||||
clearEnv();
|
|
||||||
}
|
|
||||||
assert.doesNotMatch(
|
|
||||||
unguarded.out.markdown,
|
|
||||||
/coarse block-level diff/,
|
|
||||||
"with caps raised, the precise recreateTransform path runs",
|
|
||||||
);
|
|
||||||
|
|
||||||
// The precise run executed recreateTransform (O(n²)); the guarded run did not.
|
|
||||||
// Require a large speedup so the ONLY explanation is the skipped transform.
|
|
||||||
assert.ok(
|
|
||||||
guarded.ms * 5 < unguarded.ms,
|
|
||||||
`guarded (${guarded.ms.toFixed(1)}ms) must be >=5x faster than un-guarded ` +
|
|
||||||
`(${unguarded.ms.toFixed(1)}ms); a small gap would mean the transform still ran`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("guarded over-cap call stays within the ~200ms event-loop budget", () => {
|
|
||||||
clearEnv();
|
|
||||||
// Best-of-3 to shed GC/JIT noise; the guarded coarse path is a linear walk.
|
|
||||||
let best = Infinity;
|
|
||||||
for (let i = 0; i < 3; i++) best = Math.min(best, timed(() => diffDocs(OLD, NEW)).ms);
|
|
||||||
assert.ok(best < 200, `guarded over-cap diff must be <200ms, was ${best.toFixed(1)}ms`);
|
|
||||||
});
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
// Issue #464 — prod CPU-DoS pre-flight size guard for diffDocs.
|
|
||||||
//
|
|
||||||
// diffDocs synchronously calls recreateTransform (rfc6902) which is O(n·m) in
|
|
||||||
// node count and O(w²) in per-run word count; on a large/heavily-changed doc it
|
|
||||||
// pins the event loop for seconds-to-hours WITHOUT throwing. A pre-flight size
|
|
||||||
// guard routes any doc over MCP_DIFF_MAX_NODES / MCP_DIFF_MAX_BYTES straight to
|
|
||||||
// the coarse fallback (`fellBack:true`), so the sync block stays ~<200ms.
|
|
||||||
//
|
|
||||||
// These tests assert the BEHAVIOR of the guard (fast + coarse-mode + asymmetry +
|
|
||||||
// env knobs). A sibling test (diff-guard-skips-recreate.test.mjs) proves
|
|
||||||
// recreateTransform is skipped over the cap via a behavioral proxy (guarded run
|
|
||||||
// is orders of magnitude faster than the same pair with the caps raised).
|
|
||||||
import { test } from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
|
|
||||||
import { diffDocs } from "../../build/lib/diff.js";
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Builders
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
const t = (text) => ({ type: "text", text });
|
|
||||||
const para = (text) => ({ type: "paragraph", content: text ? [t(text)] : [] });
|
|
||||||
const doc = (children) => ({ type: "doc", content: children });
|
|
||||||
|
|
||||||
/** A doc of `n` paragraphs whose words are seeded from `seed` (fully changeable). */
|
|
||||||
function buildDoc(n, wordsPerPara, seed) {
|
|
||||||
const blocks = [];
|
|
||||||
for (let i = 0; i < n; i++) {
|
|
||||||
const words = [];
|
|
||||||
for (let w = 0; w < wordsPerPara; w++) words.push(`${seed}${i}_${w}`);
|
|
||||||
blocks.push(para(words.join(" ")));
|
|
||||||
}
|
|
||||||
return doc(blocks);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reset the env knobs to their unset default between tests. */
|
|
||||||
function clearEnv() {
|
|
||||||
delete process.env.MCP_DIFF_MAX_NODES;
|
|
||||||
delete process.env.MCP_DIFF_MAX_BYTES;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Over-threshold (by node count) -> FAST + coarse mode.
|
|
||||||
// A fully re-written 600-para doc is the worst case that drove the incident;
|
|
||||||
// with the guard it must return in well under the ~200ms budget and in coarse
|
|
||||||
// mode. Without the guard this single call takes multiple SECONDS.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
test("over-threshold doc falls back to coarse mode and returns fast", () => {
|
|
||||||
clearEnv();
|
|
||||||
// 600 paragraphs -> ~1200 nodes, far over the 150-node default.
|
|
||||||
const oldDoc = buildDoc(600, 8, "a");
|
|
||||||
const newDoc = buildDoc(600, 8, "b");
|
|
||||||
|
|
||||||
const start = performance.now();
|
|
||||||
const r = diffDocs(oldDoc, newDoc);
|
|
||||||
const elapsed = performance.now() - start;
|
|
||||||
|
|
||||||
// Coarse mode is signalled in the markdown note (fellBack path).
|
|
||||||
assert.match(
|
|
||||||
r.markdown,
|
|
||||||
/coarse block-level diff/,
|
|
||||||
"over-threshold pair must use the coarse fallback",
|
|
||||||
);
|
|
||||||
// Budget: the guard makes this near-instant. Generous 1s ceiling to avoid CI
|
|
||||||
// flake while still being ~10x under the multi-second un-guarded cost.
|
|
||||||
assert.ok(
|
|
||||||
elapsed < 1000,
|
|
||||||
`expected fast coarse fallback, took ${elapsed.toFixed(0)}ms`,
|
|
||||||
);
|
|
||||||
// Coarse diff still detects the wholesale change.
|
|
||||||
assert.ok(r.summary.inserted > 0 || r.summary.deleted > 0, "reports changes");
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Under-threshold (small) doc -> precise diff, NOT coarse mode. No regression.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
test("under-threshold doc uses the precise diff (no fallback note)", () => {
|
|
||||||
clearEnv();
|
|
||||||
const oldDoc = doc([para("Hello world")]);
|
|
||||||
const newDoc = doc([para("Hello brave world")]);
|
|
||||||
const r = diffDocs(oldDoc, newDoc);
|
|
||||||
|
|
||||||
assert.doesNotMatch(
|
|
||||||
r.markdown,
|
|
||||||
/coarse block-level diff/,
|
|
||||||
"a small doc must take the precise path",
|
|
||||||
);
|
|
||||||
// Precise word diff finds exactly the inserted word.
|
|
||||||
const ins = r.changes.find((c) => c.op === "insert");
|
|
||||||
assert.ok(ins && /brave/.test(ins.text), "precise diff isolates the inserted word");
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Asymmetry: a small NEW doc vs a huge OLD doc (and vice versa) still explodes
|
|
||||||
// rfc6902, so max(old,new) must trip the guard in BOTH directions.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
test("asymmetric pair (huge old, tiny new) falls back to coarse", () => {
|
|
||||||
clearEnv();
|
|
||||||
const hugeOld = buildDoc(600, 8, "a");
|
|
||||||
const tinyNew = doc([para("just one line")]);
|
|
||||||
const r = diffDocs(hugeOld, tinyNew);
|
|
||||||
assert.match(r.markdown, /coarse block-level diff/, "huge-old side must trip the guard");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("asymmetric pair (tiny old, huge new) falls back to coarse", () => {
|
|
||||||
clearEnv();
|
|
||||||
const tinyOld = doc([para("just one line")]);
|
|
||||||
const hugeNew = buildDoc(600, 8, "b");
|
|
||||||
const r = diffDocs(tinyOld, hugeNew);
|
|
||||||
assert.match(r.markdown, /coarse block-level diff/, "huge-new side must trip the guard");
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Byte axis: a FEW nodes but a very large serialized size (long text runs) is
|
|
||||||
// dangerous too (per-run word diff is O(words²)), so the byte cap must trip
|
|
||||||
// independently of the node count.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
test("node-light but byte-heavy doc falls back on the byte cap", () => {
|
|
||||||
clearEnv();
|
|
||||||
// 5 paragraphs (~11 nodes, well under the node cap) but each a very long run,
|
|
||||||
// pushing the serialized size far over the 12 KiB byte default.
|
|
||||||
const bigRun = (seed) =>
|
|
||||||
doc(
|
|
||||||
Array.from({ length: 5 }, (_, i) =>
|
|
||||||
para(Array.from({ length: 800 }, (_, w) => `${seed}${i}_${w}`).join(" ")),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const oldDoc = bigRun("a");
|
|
||||||
const newDoc = bigRun("b");
|
|
||||||
// Sanity: node count is under the default node cap, so ONLY the byte cap can
|
|
||||||
// be what trips the guard here.
|
|
||||||
const nodeCount = (d) => {
|
|
||||||
let n = 0;
|
|
||||||
const v = (x) => {
|
|
||||||
if (!x || typeof x !== "object") return;
|
|
||||||
n++;
|
|
||||||
if (Array.isArray(x.content)) for (const c of x.content) v(c);
|
|
||||||
};
|
|
||||||
v(d);
|
|
||||||
return n;
|
|
||||||
};
|
|
||||||
assert.ok(nodeCount(oldDoc) < 150, "node count is under the node cap");
|
|
||||||
assert.ok(JSON.stringify(oldDoc).length > 12 * 1024, "serialized size is over the byte cap");
|
|
||||||
|
|
||||||
const r = diffDocs(oldDoc, newDoc);
|
|
||||||
assert.match(r.markdown, /coarse block-level diff/, "byte cap must trip independently");
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Env override: a very low MCP_DIFF_MAX_NODES forces fallback on a tiny doc,
|
|
||||||
// proving the knob is read fresh and actually gates the diff.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
test("MCP_DIFF_MAX_NODES override forces fallback on a small doc", () => {
|
|
||||||
clearEnv();
|
|
||||||
const oldDoc = doc([para("Hello world")]);
|
|
||||||
const newDoc = doc([para("Hello brave world")]);
|
|
||||||
|
|
||||||
// Baseline: default caps -> precise diff.
|
|
||||||
assert.doesNotMatch(diffDocs(oldDoc, newDoc).markdown, /coarse block-level diff/);
|
|
||||||
|
|
||||||
// Knob set absurdly low -> even this 4-node doc trips the guard.
|
|
||||||
process.env.MCP_DIFF_MAX_NODES = "1";
|
|
||||||
try {
|
|
||||||
const r = diffDocs(oldDoc, newDoc);
|
|
||||||
assert.match(r.markdown, /coarse block-level diff/, "low node cap forces fallback");
|
|
||||||
} finally {
|
|
||||||
clearEnv();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("MCP_DIFF_MAX_BYTES override forces fallback on a small doc", () => {
|
|
||||||
clearEnv();
|
|
||||||
const oldDoc = doc([para("Hello world")]);
|
|
||||||
const newDoc = doc([para("Hello brave world")]);
|
|
||||||
|
|
||||||
process.env.MCP_DIFF_MAX_BYTES = "1";
|
|
||||||
try {
|
|
||||||
const r = diffDocs(oldDoc, newDoc);
|
|
||||||
assert.match(r.markdown, /coarse block-level diff/, "low byte cap forces fallback");
|
|
||||||
} finally {
|
|
||||||
clearEnv();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Garbage / unset env values fall back to the DEFAULT (the guard can never be
|
|
||||||
// accidentally disabled by a malformed knob). A small doc must still diff
|
|
||||||
// precisely under a garbage cap.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
test("garbage env values fall back to the default cap (guard not disabled)", () => {
|
|
||||||
clearEnv();
|
|
||||||
const oldDoc = doc([para("Hello world")]);
|
|
||||||
const newDoc = doc([para("Hello brave world")]);
|
|
||||||
|
|
||||||
for (const bad of ["not-a-number", "0", "-5", "", "NaN", "1e999"]) {
|
|
||||||
process.env.MCP_DIFF_MAX_NODES = bad;
|
|
||||||
process.env.MCP_DIFF_MAX_BYTES = bad;
|
|
||||||
// Under the DEFAULT caps this small doc is precise (garbage did not raise
|
|
||||||
// OR disable the cap). "1e999" -> parseInt yields 1 (finite) which is a
|
|
||||||
// valid low cap and would fall back; exclude that from the precise check.
|
|
||||||
const r = diffDocs(oldDoc, newDoc);
|
|
||||||
if (bad === "1e999") {
|
|
||||||
// parseInt("1e999",10) === 1 -> a legit low cap -> fallback. Guard active.
|
|
||||||
assert.match(r.markdown, /coarse block-level diff/);
|
|
||||||
} else {
|
|
||||||
assert.doesNotMatch(
|
|
||||||
r.markdown,
|
|
||||||
/coarse block-level diff/,
|
|
||||||
`garbage value ${JSON.stringify(bad)} must fall back to the default cap`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
clearEnv();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// A large doc that trips the guard must still return the correct INTEGRITY
|
|
||||||
// counts (computeIntegrity runs before the diff and is unaffected by fallback).
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
test("integrity counts are still correct on a guard-tripped (coarse) doc", () => {
|
|
||||||
clearEnv();
|
|
||||||
const image = { type: "image", attrs: { src: "/api/files/a.png" } };
|
|
||||||
const oldDoc = doc([image, ...buildDoc(600, 8, "a").content]);
|
|
||||||
const newDoc = doc([...buildDoc(600, 8, "b").content]); // image removed
|
|
||||||
|
|
||||||
const r = diffDocs(oldDoc, newDoc);
|
|
||||||
assert.match(r.markdown, /coarse block-level diff/, "large pair fell back");
|
|
||||||
assert.deepEqual(r.integrity.images, [1, 0], "integrity is computed regardless of fallback");
|
|
||||||
});
|
|
||||||
@@ -114,7 +114,7 @@ test("summarizeChange treats a key-order-only difference as no change", () => {
|
|||||||
// (v) CRITICAL: a structural change that touches no text/marks — adding an
|
// (v) CRITICAL: a structural change that touches no text/marks — adding an
|
||||||
// image node (images 0 -> 1) — must report changed:true and surface the
|
// image node (images 0 -> 1) — must report changed:true and surface the
|
||||||
// integrity delta in structure + summary, closing the verify blind spot for
|
// integrity delta in structure + summary, closing the verify blind spot for
|
||||||
// insert_image / delete_node on structural nodes.
|
// insertImage / deleteNode on structural nodes.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
test("summarizeChange surfaces an image-count change (0->1)", () => {
|
test("summarizeChange surfaces an image-count change (0->1)", () => {
|
||||||
const before = doc(para(t("caption")));
|
const before = doc(para(t("caption")));
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Unit tests for the drawio_guide progressive-disclosure reference (issue #424).
|
// Unit tests for the drawioGuide progressive-disclosure reference (issue #424).
|
||||||
// Acceptance #2: every section is returned and each is <= ~4KB so pulling one
|
// Acceptance #2: every section is returned and each is <= ~4KB so pulling one
|
||||||
// does not bloat the model's context.
|
// does not bloat the model's context.
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Unit tests for the drawio_shapes verified-stencil catalog (issue #424).
|
// Unit tests for the drawioShapes verified-stencil catalog (issue #424).
|
||||||
// Covers acceptance #1: a "lambda" query returns a valid mxgraph.aws4 icon with
|
// Covers acceptance #1: a "lambda" query returns a valid mxgraph.aws4 icon with
|
||||||
// the right service/resource pattern + sizes; a blocklisted stencil query
|
// the right service/resource pattern + sizes; a blocklisted stencil query
|
||||||
// returns its working replacement.
|
// returns its working replacement.
|
||||||
@@ -22,7 +22,7 @@ test("the bundled index loads and is the real ~10k-shape catalog", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('drawio_shapes("lambda") returns a valid mxgraph.aws4 service icon', () => {
|
test('drawioShapes("lambda") returns a valid mxgraph.aws4 service icon', () => {
|
||||||
const results = searchShapes("lambda", { limit: 5 });
|
const results = searchShapes("lambda", { limit: 5 });
|
||||||
assert.ok(results.length > 0);
|
assert.ok(results.length > 0);
|
||||||
// Acceptance #1: a valid aws4 service-level icon (resourceIcon + resIcon)
|
// Acceptance #1: a valid aws4 service-level icon (resourceIcon + resIcon)
|
||||||
|
|||||||
@@ -7,16 +7,16 @@ import assert from "node:assert/strict";
|
|||||||
import { SERVER_INSTRUCTIONS } from "../../build/index.js";
|
import { SERVER_INSTRUCTIONS } from "../../build/index.js";
|
||||||
import { SHARED_TOOL_SPECS } from "../../build/tool-specs.js";
|
import { SHARED_TOOL_SPECS } from "../../build/tool-specs.js";
|
||||||
|
|
||||||
test("drawio_shapes and drawio_guide are in the shared registry", () => {
|
test("drawioShapes and drawioGuide are in the shared registry", () => {
|
||||||
assert.equal(SHARED_TOOL_SPECS.drawioShapes.mcpName, "drawio_shapes");
|
assert.equal(SHARED_TOOL_SPECS.drawioShapes.mcpName, "drawioShapes");
|
||||||
assert.equal(SHARED_TOOL_SPECS.drawioGuide.mcpName, "drawio_guide");
|
assert.equal(SHARED_TOOL_SPECS.drawioGuide.mcpName, "drawioGuide");
|
||||||
// Deferred tier, matching the stage-1 drawio tools.
|
// Deferred tier, matching the stage-1 drawio tools.
|
||||||
assert.equal(SHARED_TOOL_SPECS.drawioShapes.tier, "deferred");
|
assert.equal(SHARED_TOOL_SPECS.drawioShapes.tier, "deferred");
|
||||||
assert.equal(SHARED_TOOL_SPECS.drawioGuide.tier, "deferred");
|
assert.equal(SHARED_TOOL_SPECS.drawioGuide.tier, "deferred");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the new tools are routed in SERVER_INSTRUCTIONS", () => {
|
test("the new tools are routed in SERVER_INSTRUCTIONS", () => {
|
||||||
for (const name of ["drawio_shapes", "drawio_guide"]) {
|
for (const name of ["drawioShapes", "drawioGuide"]) {
|
||||||
assert.match(SERVER_INSTRUCTIONS, new RegExp(`\\b${name}\\b`), `${name} missing from guide`);
|
assert.match(SERVER_INSTRUCTIONS, new RegExp(`\\b${name}\\b`), `${name} missing from guide`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -26,7 +26,7 @@ test("the hard-rules block is injected into create/update descriptions", () => {
|
|||||||
const d = SHARED_TOOL_SPECS[key].description;
|
const d = SHARED_TOOL_SPECS[key].description;
|
||||||
assert.match(d, /sentinels are MANDATORY/);
|
assert.match(d, /sentinels are MANDATORY/);
|
||||||
assert.match(d, /vertex="1" XOR edge="1"/);
|
assert.match(d, /vertex="1" XOR edge="1"/);
|
||||||
assert.match(d, /call drawio_shapes first/);
|
assert.match(d, /call drawioShapes first/);
|
||||||
assert.match(d, /adaptiveColors="auto"/);
|
assert.match(d, /adaptiveColors="auto"/);
|
||||||
assert.match(d, /
/);
|
assert.match(d, /
/);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ test("a non-axios error is passed through untouched", () => {
|
|||||||
const GOOD_UUID = "019f499a-9f8c-7d68-b7be-ce100d7c6c56";
|
const GOOD_UUID = "019f499a-9f8c-7d68-b7be-ce100d7c6c56";
|
||||||
|
|
||||||
test("assertFullUuid accepts a full canonical UUID (any version nibble)", () => {
|
test("assertFullUuid accepts a full canonical UUID (any version nibble)", () => {
|
||||||
assert.doesNotThrow(() => assertFullUuid("resolve_comment", "commentId", GOOD_UUID));
|
assert.doesNotThrow(() => assertFullUuid("resolveComment", "commentId", GOOD_UUID));
|
||||||
// A v4 id also passes (version/variant-agnostic).
|
// A v4 id also passes (version/variant-agnostic).
|
||||||
assert.doesNotThrow(() =>
|
assert.doesNotThrow(() =>
|
||||||
assertFullUuid("get_comment", "commentId", "3d5b7c1e-2f4a-4b6c-8d9e-0f1a2b3c4d5e"),
|
assertFullUuid("get_comment", "commentId", "3d5b7c1e-2f4a-4b6c-8d9e-0f1a2b3c4d5e"),
|
||||||
@@ -267,10 +267,10 @@ test("assertFullUuid accepts a full canonical UUID (any version nibble)", () =>
|
|||||||
|
|
||||||
test("assertFullUuid rejects a truncated prefix", () => {
|
test("assertFullUuid rejects a truncated prefix", () => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => assertFullUuid("resolve_comment", "commentId", "019f499a"),
|
() => assertFullUuid("resolveComment", "commentId", "019f499a"),
|
||||||
(e) =>
|
(e) =>
|
||||||
e.message.startsWith(
|
e.message.startsWith(
|
||||||
"resolve_comment: 'commentId' must be the FULL comment UUID",
|
"resolveComment: 'commentId' must be the FULL comment UUID",
|
||||||
) &&
|
) &&
|
||||||
e.message.includes("got '019f499a'") &&
|
e.message.includes("got '019f499a'") &&
|
||||||
e.message.includes("Copy the id verbatim"),
|
e.message.includes("Copy the id verbatim"),
|
||||||
@@ -279,11 +279,11 @@ test("assertFullUuid rejects a truncated prefix", () => {
|
|||||||
|
|
||||||
test("assertFullUuid rejects garbage and empty string", () => {
|
test("assertFullUuid rejects garbage and empty string", () => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => assertFullUuid("delete_comment", "commentId", "not-a-uuid"),
|
() => assertFullUuid("deleteComment", "commentId", "not-a-uuid"),
|
||||||
/must be the FULL comment UUID.*got 'not-a-uuid'/s,
|
/must be the FULL comment UUID.*got 'not-a-uuid'/s,
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => assertFullUuid("update_comment", "commentId", ""),
|
() => assertFullUuid("updateComment", "commentId", ""),
|
||||||
/must be the FULL comment UUID.*got ''/s,
|
/must be the FULL comment UUID.*got ''/s,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -436,14 +436,14 @@ test("all 5 comment-id call sites reject a bad id with ZERO network traffic", as
|
|||||||
const client = new DocmostClient(baseURL, "u@example.com", "pw");
|
const client = new DocmostClient(baseURL, "u@example.com", "pw");
|
||||||
const bad = "019f499a"; // truncated
|
const bad = "019f499a"; // truncated
|
||||||
|
|
||||||
await assert.rejects(() => client.resolveComment(bad, true), /resolve_comment: 'commentId'/);
|
await assert.rejects(() => client.resolveComment(bad, true), /resolveComment: 'commentId'/);
|
||||||
await assert.rejects(() => client.updateComment(bad, "hi"), /update_comment: 'commentId'/);
|
await assert.rejects(() => client.updateComment(bad, "hi"), /updateComment: 'commentId'/);
|
||||||
await assert.rejects(() => client.deleteComment(bad), /delete_comment: 'commentId'/);
|
await assert.rejects(() => client.deleteComment(bad), /deleteComment: 'commentId'/);
|
||||||
await assert.rejects(() => client.getComment(bad), /get_comment: 'commentId'/);
|
await assert.rejects(() => client.getComment(bad), /get_comment: 'commentId'/);
|
||||||
// createComment validates parentCommentId only when provided.
|
// createComment validates parentCommentId only when provided.
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => client.createComment("page-1", "body", "inline", "sel", bad),
|
() => client.createComment("page-1", "body", "inline", "sel", bad),
|
||||||
/create_comment: 'parentCommentId'/,
|
/createComment: 'parentCommentId'/,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(requests, 0, "no request (not even /auth/login) may be issued for a bad id");
|
assert.equal(requests, 0, "no request (not even /auth/login) may be issued for a bad id");
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
import { filterComment, filterPage } from "../../build/lib/filters.js";
|
import {
|
||||||
|
filterComment,
|
||||||
|
filterPage,
|
||||||
|
filterSearchResult,
|
||||||
|
} from "../../build/lib/filters.js";
|
||||||
|
|
||||||
test("filterComment includes resolvedAt/resolvedById as null when absent", () => {
|
test("filterComment includes resolvedAt/resolvedById as null when absent", () => {
|
||||||
const result = filterComment({
|
const result = filterComment({
|
||||||
@@ -171,3 +175,91 @@ test("filterPage includes both content and subpages together", () => {
|
|||||||
assert.equal(result.content, "body");
|
assert.equal(result.content, "body");
|
||||||
assert.deepEqual(result.subpages, [{ id: "s1", title: "Sub" }]);
|
assert.deepEqual(result.subpages, [{ id: "s1", title: "Sub" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- filterSearchResult (#443 agent-lookup contract) -------------------------
|
||||||
|
|
||||||
|
test("filterSearchResult maps the lookup shape to {pageId,title,path,snippet,score}", () => {
|
||||||
|
const result = filterSearchResult({
|
||||||
|
id: "0199aa-uuid",
|
||||||
|
slugId: "slug-secret",
|
||||||
|
title: "backup-srv.local",
|
||||||
|
parentPageId: "0199pp",
|
||||||
|
path: ["Infrastructure", "Datacenter A", "Servers"],
|
||||||
|
snippet: "…IP: 10.0.12.5. Debian 12…",
|
||||||
|
score: 0.92,
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
pageId: "0199aa-uuid",
|
||||||
|
title: "backup-srv.local",
|
||||||
|
path: ["Infrastructure", "Datacenter A", "Servers"],
|
||||||
|
snippet: "…IP: 10.0.12.5. Debian 12…",
|
||||||
|
score: 0.92,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filterSearchResult NEVER exposes slugId (pageId is the only identifier)", () => {
|
||||||
|
const result = filterSearchResult({
|
||||||
|
id: "uuid-1",
|
||||||
|
slugId: "slug-1",
|
||||||
|
title: "t",
|
||||||
|
path: [],
|
||||||
|
snippet: "s",
|
||||||
|
score: 0.1,
|
||||||
|
});
|
||||||
|
assert.equal("slugId" in result, false);
|
||||||
|
assert.equal("id" in result, false);
|
||||||
|
assert.equal(result.pageId, "uuid-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filterSearchResult root page yields path: []", () => {
|
||||||
|
const result = filterSearchResult({
|
||||||
|
id: "uuid-root",
|
||||||
|
title: "Root",
|
||||||
|
path: [],
|
||||||
|
snippet: "s",
|
||||||
|
score: 0.5,
|
||||||
|
});
|
||||||
|
assert.deepEqual(result.path, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filterSearchResult degrades a legacy FTS hit (no lookup fields)", () => {
|
||||||
|
// Stock upstream stripped the opt-in DTO fields → legacy shape with
|
||||||
|
// highlight + rank and no path/snippet/score.
|
||||||
|
const result = filterSearchResult({
|
||||||
|
id: "uuid-legacy",
|
||||||
|
slugId: "slug-legacy",
|
||||||
|
title: "Legacy",
|
||||||
|
parentPageId: null,
|
||||||
|
rank: 0.37,
|
||||||
|
highlight: "…matched <b>text</b>…",
|
||||||
|
space: { id: "sp1", name: "Space" },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.pageId, "uuid-legacy");
|
||||||
|
assert.equal(result.title, "Legacy");
|
||||||
|
// snippet falls back to highlight, score to rank, path to [].
|
||||||
|
assert.equal(result.snippet, "…matched <b>text</b>…");
|
||||||
|
assert.equal(result.score, 0.37);
|
||||||
|
assert.deepEqual(result.path, []);
|
||||||
|
assert.equal("slugId" in result, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filterSearchResult is null-safe on missing snippet/score/path", () => {
|
||||||
|
const result = filterSearchResult({ id: "u", title: "t" });
|
||||||
|
assert.equal(result.pageId, "u");
|
||||||
|
assert.equal(result.snippet, "");
|
||||||
|
assert.equal(result.score, 0);
|
||||||
|
assert.deepEqual(result.path, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("filterSearchResult ignores a non-array path", () => {
|
||||||
|
const result = filterSearchResult({
|
||||||
|
id: "u",
|
||||||
|
title: "t",
|
||||||
|
path: "not-an-array",
|
||||||
|
snippet: "s",
|
||||||
|
score: 1,
|
||||||
|
});
|
||||||
|
assert.deepEqual(result.path, []);
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
|
|
||||||
// Pins the footnoteWarnings PLUMBING contract (#169 review; reduced in #414): the
|
// Pins the footnoteWarnings PLUMBING contract (#169 review; reduced in #414): the
|
||||||
// field is present only when legacy reference-style `[^id]:` syntax is used and
|
// field is present only when legacy reference-style `[^id]:` syntax is used and
|
||||||
// omitted otherwise, AND `import_page_markdown` analyzes the BODY (after the
|
// omitted otherwise, AND `importPageMarkdown` analyzes the BODY (after the
|
||||||
// docmost:meta / docmost:comments blocks) — so a footnote-like token inside those
|
// docmost:meta / docmost:comments blocks) — so a footnote-like token inside those
|
||||||
// JSON blocks never warns, while a real definition in the body does.
|
// JSON blocks never warns, while a real definition in the body does.
|
||||||
// importPageMarkdown does exactly `footnoteWarningsField(parseDocmostMarkdown(full).body)`
|
// importPageMarkdown does exactly `footnoteWarningsField(parseDocmostMarkdown(full).body)`
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ test("formatting-only edit (strip-toggle) is refused, not applied", () => {
|
|||||||
assert.equal(failed.length, 1, "one refused edit");
|
assert.equal(failed.length, 1, "one refused edit");
|
||||||
assert.equal(failed[0].find, "~~x~~");
|
assert.equal(failed[0].find, "~~x~~");
|
||||||
assert.match(failed[0].reason, /cannot add or remove formatting marks/);
|
assert.match(failed[0].reason, /cannot add or remove formatting marks/);
|
||||||
assert.match(failed[0].reason, /patch_node/);
|
assert.match(failed[0].reason, /patchNode/);
|
||||||
// The document is untouched (the strike mark is preserved).
|
// The document is untouched (the strike mark is preserved).
|
||||||
assert.deepEqual(out, snapshot);
|
assert.deepEqual(out, snapshot);
|
||||||
});
|
});
|
||||||
@@ -143,7 +143,7 @@ test("typo fix wrapped in markdown still applies (not refused)", () => {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// (iv) #410 footnote token: a `replace` containing `^[...]` is refused into
|
// (iv) #410 footnote token: a `replace` containing `^[...]` is refused into
|
||||||
// failed[] (it would be written as a LITERAL string, never a real footnote).
|
// failed[] (it would be written as a LITERAL string, never a real footnote).
|
||||||
// Nothing is applied; the reason points at insert_footnote.
|
// Nothing is applied; the reason points at insertFootnote.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
test("replace containing a `^[...]` footnote token is refused, not applied", () => {
|
test("replace containing a `^[...]` footnote token is refused, not applied", () => {
|
||||||
const input = doc(paragraph(textNode("The claim stands.")));
|
const input = doc(paragraph(textNode("The claim stands.")));
|
||||||
@@ -156,7 +156,7 @@ test("replace containing a `^[...]` footnote token is refused, not applied", ()
|
|||||||
assert.equal(results.length, 0, "nothing applied");
|
assert.equal(results.length, 0, "nothing applied");
|
||||||
assert.equal(failed.length, 1, "one refused edit");
|
assert.equal(failed.length, 1, "one refused edit");
|
||||||
assert.equal(failed[0].find, "The claim stands.");
|
assert.equal(failed[0].find, "The claim stands.");
|
||||||
assert.match(failed[0].reason, /insert_footnote/);
|
assert.match(failed[0].reason, /insertFootnote/);
|
||||||
// The document is byte-for-byte untouched — no literal `^[` was written.
|
// The document is byte-for-byte untouched — no literal `^[` was written.
|
||||||
assert.deepEqual(out, snapshot);
|
assert.deepEqual(out, snapshot);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// #413: unit tests for the markdown-fragment helpers used by patchNode/insertNode.
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import {
|
||||||
|
importMarkdownFragment,
|
||||||
|
canBeDocChild,
|
||||||
|
findUnrepresentableTableAttrs,
|
||||||
|
} from "../../build/lib/markdown-fragment.js";
|
||||||
|
|
||||||
|
function findAll(node, type, acc = []) {
|
||||||
|
if (!node || typeof node !== "object") return acc;
|
||||||
|
if (node.type === type) acc.push(node);
|
||||||
|
if (Array.isArray(node.content))
|
||||||
|
for (const c of node.content) findAll(c, type, acc);
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("importMarkdownFragment: plain markdown -> blocks, no definitions", async () => {
|
||||||
|
const { blocks, definitions } = await importMarkdownFragment(
|
||||||
|
"first\n\nsecond",
|
||||||
|
);
|
||||||
|
assert.equal(blocks.length, 2);
|
||||||
|
assert.equal(definitions.length, 0);
|
||||||
|
assert.equal(blocks[0].type, "paragraph");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("importMarkdownFragment: `^[...]` footnote -> a definition + a remapped ref", async () => {
|
||||||
|
const { blocks, definitions } = await importMarkdownFragment(
|
||||||
|
"a claim^[the note]",
|
||||||
|
);
|
||||||
|
assert.equal(definitions.length, 1);
|
||||||
|
const refs = findAll({ type: "doc", content: blocks }, "footnoteReference");
|
||||||
|
assert.equal(refs.length, 1);
|
||||||
|
// The reference id must match the (remapped) definition id.
|
||||||
|
assert.equal(refs[0].attrs.id, definitions[0].attrs.id);
|
||||||
|
// The id is NOT the importer's sequential "fn-1" — it was remapped to a fresh
|
||||||
|
// uuid so it cannot collide with a page footnote of the same number.
|
||||||
|
assert.notEqual(refs[0].attrs.id, "fn-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("importMarkdownFragment: whitespace markdown imports to a single empty paragraph", async () => {
|
||||||
|
// The importer yields one empty paragraph for whitespace-only input (not zero
|
||||||
|
// blocks), so the fragment path returns that block. The client's XOR guard
|
||||||
|
// (markdown.trim() !== "") is what rejects an empty-string patch up front, so
|
||||||
|
// importMarkdownFragment never sees a truly empty string via patch/insert.
|
||||||
|
const { blocks, definitions } = await importMarkdownFragment(" \n ");
|
||||||
|
assert.equal(blocks.length, 1);
|
||||||
|
assert.equal(blocks[0].type, "paragraph");
|
||||||
|
assert.equal(definitions.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("canBeDocChild: paragraph/heading/table are doc children; tableRow/cell are not", () => {
|
||||||
|
assert.equal(canBeDocChild("paragraph"), true);
|
||||||
|
assert.equal(canBeDocChild("heading"), true);
|
||||||
|
assert.equal(canBeDocChild("table"), true);
|
||||||
|
assert.equal(canBeDocChild("tableRow"), false);
|
||||||
|
assert.equal(canBeDocChild("tableCell"), false);
|
||||||
|
assert.equal(canBeDocChild("tableHeader"), false);
|
||||||
|
assert.equal(canBeDocChild("text"), false);
|
||||||
|
assert.equal(canBeDocChild(undefined), false);
|
||||||
|
assert.equal(canBeDocChild("notARealType"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const cell = (attrs, text) => ({
|
||||||
|
type: "tableCell",
|
||||||
|
attrs,
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findUnrepresentableTableAttrs: null for a plain paragraph and a simple table", () => {
|
||||||
|
assert.equal(
|
||||||
|
findUnrepresentableTableAttrs({
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: "x" }],
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const simpleTable = {
|
||||||
|
type: "table",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tableRow",
|
||||||
|
content: [cell({ colspan: 1, rowspan: 1 }, "a")],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert.equal(findUnrepresentableTableAttrs(simpleTable), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findUnrepresentableTableAttrs: flags colspan/rowspan/colwidth/backgroundColor", () => {
|
||||||
|
const mk = (attrs) => ({
|
||||||
|
type: "table",
|
||||||
|
content: [{ type: "tableRow", content: [cell(attrs, "a")] }],
|
||||||
|
});
|
||||||
|
assert.match(findUnrepresentableTableAttrs(mk({ colspan: 2 })), /colspan/);
|
||||||
|
assert.match(findUnrepresentableTableAttrs(mk({ rowspan: 2 })), /rowspan/);
|
||||||
|
assert.match(
|
||||||
|
findUnrepresentableTableAttrs(mk({ colwidth: [120] })),
|
||||||
|
/colwidth/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
findUnrepresentableTableAttrs(mk({ backgroundColor: "#eee" })),
|
||||||
|
/backgroundColor/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findUnrepresentableTableAttrs: finds a span nested deep (table inside a callout)", () => {
|
||||||
|
const doc = {
|
||||||
|
type: "callout",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "table",
|
||||||
|
content: [
|
||||||
|
{ type: "tableRow", content: [cell({ colspan: 3 }, "wide")] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert.match(findUnrepresentableTableAttrs(doc), /colspan/);
|
||||||
|
});
|
||||||
@@ -486,34 +486,34 @@ test("insertNodeRelative truly-missing anchor still returns inserted:false", ()
|
|||||||
assert.equal(inserted, false);
|
assert.equal(inserted, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// assertUnambiguousMatch (#159, #185 review pt 2): the patch_node/delete_node
|
// assertUnambiguousMatch (#159, #185 review pt 2): the patchNode/deleteNode
|
||||||
// guard. Docmost duplicates block ids on copy/paste, so a write by id that
|
// guard. Docmost duplicates block ids on copy/paste, so a write by id that
|
||||||
// matches >1 node must be REFUSED (the caller already skipped the write for any
|
// matches >1 node must be REFUSED (the caller already skipped the write for any
|
||||||
// count !== 1; this reports the error). The duplicate COUNT itself is covered by
|
// count !== 1; this reports the error). The duplicate COUNT itself is covered by
|
||||||
// the replaceNodeById/deleteNodeById tests above (count===2 for a 2-dup doc).
|
// the replaceNodeById/deleteNodeById tests above (count===2 for a 2-dup doc).
|
||||||
test("assertUnambiguousMatch: count 0 throws 'no node found'", () => {
|
test("assertUnambiguousMatch: count 0 throws 'no node found'", () => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => assertUnambiguousMatch("patch_node", "replace", 0, "n1", "p1"),
|
() => assertUnambiguousMatch("patchNode", "replace", 0, "n1", "p1"),
|
||||||
/patch_node: no node with id "n1" found on page p1/,
|
/patchNode: no node with id "n1" found on page p1/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("assertUnambiguousMatch: count > 1 refuses with an 'ambiguous' error", () => {
|
test("assertUnambiguousMatch: count > 1 refuses with an 'ambiguous' error", () => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => assertUnambiguousMatch("patch_node", "replace", 2, "dup", "p1"),
|
() => assertUnambiguousMatch("patchNode", "replace", 2, "dup", "p1"),
|
||||||
/ambiguous.*Refusing to replace all of them; nothing was changed/,
|
/ambiguous.*Refusing to replace all of them; nothing was changed/,
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => assertUnambiguousMatch("delete_node", "delete", 3, "dup", "p1"),
|
() => assertUnambiguousMatch("deleteNode", "delete", 3, "dup", "p1"),
|
||||||
/ambiguous.*Refusing to delete all of them; nothing was changed/,
|
/ambiguous.*Refusing to delete all of them; nothing was changed/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("assertUnambiguousMatch: exactly one match does NOT throw", () => {
|
test("assertUnambiguousMatch: exactly one match does NOT throw", () => {
|
||||||
assert.doesNotThrow(() =>
|
assert.doesNotThrow(() =>
|
||||||
assertUnambiguousMatch("patch_node", "replace", 1, "n1", "p1"),
|
assertUnambiguousMatch("patchNode", "replace", 1, "n1", "p1"),
|
||||||
);
|
);
|
||||||
assert.doesNotThrow(() =>
|
assert.doesNotThrow(() =>
|
||||||
assertUnambiguousMatch("delete_node", "delete", 1, "n1", "p1"),
|
assertUnambiguousMatch("deleteNode", "delete", 1, "n1", "p1"),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ test("insertTableRow at index 0 inserts before the header and pads to 3 cells",
|
|||||||
test("insertTableRow throws when given more cells than columns", () => {
|
test("insertTableRow throws when given more cells than columns", () => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => insertTableRow(makeDoc(), "#1", ["a", "b", "c", "d"]),
|
() => insertTableRow(makeDoc(), "#1", ["a", "b", "c", "d"]),
|
||||||
/table_insert_row: got 4 cell\(s\) but the table has 3 column\(s\)/,
|
/tableInsertRow: got 4 cell\(s\) but the table has 3 column\(s\)/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -232,7 +232,7 @@ test("insertTableRow uses the max column count across all rows (ragged table)",
|
|||||||
// ...but 4 cells exceed the widest row and throw.
|
// ...but 4 cells exceed the widest row and throw.
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => insertTableRow(makeRaggedDoc(), "#0", ["a", "b", "c", "d"]),
|
() => insertTableRow(makeRaggedDoc(), "#0", ["a", "b", "c", "d"]),
|
||||||
/table_insert_row: got 4 cell\(s\) but the table has 3 column\(s\)/,
|
/tableInsertRow: got 4 cell\(s\) but the table has 3 column\(s\)/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -286,7 +286,7 @@ test("deleteTableRow removes the 3rd row -> rows:2", () => {
|
|||||||
test("deleteTableRow out-of-range index throws", () => {
|
test("deleteTableRow out-of-range index throws", () => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => deleteTableRow(makeDoc(), "#1", 9),
|
() => deleteTableRow(makeDoc(), "#1", 9),
|
||||||
/table_delete_row: row index 9 out of range \(table has 3 row\(s\)\)/,
|
/tableDeleteRow: row index 9 out of range \(table has 3 row\(s\)\)/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -329,10 +329,10 @@ test("updateTableCell sets cell [1,1] to 'Z' and preserves the paragraph id", ()
|
|||||||
test("updateTableCell out-of-range row/col throws", () => {
|
test("updateTableCell out-of-range row/col throws", () => {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => updateTableCell(makeDoc(), "#1", 9, 0, "x"),
|
() => updateTableCell(makeDoc(), "#1", 9, 0, "x"),
|
||||||
/table_update_cell: cell \[9,0\] out of range/,
|
/tableUpdateCell: cell \[9,0\] out of range/,
|
||||||
);
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() => updateTableCell(makeDoc(), "#1", 0, 9, "x"),
|
() => updateTableCell(makeDoc(), "#1", 0, 9, "x"),
|
||||||
/table_update_cell: cell \[0,9\] out of range/,
|
/tableUpdateCell: cell \[0,9\] out of range/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,13 +35,13 @@ function registeredToolNames() {
|
|||||||
const indexSrc = readFileSync(join(SRC, "index.ts"), "utf8");
|
const indexSrc = readFileSync(join(SRC, "index.ts"), "utf8");
|
||||||
const specsSrc = readFileSync(join(SRC, "tool-specs.ts"), "utf8");
|
const specsSrc = readFileSync(join(SRC, "tool-specs.ts"), "utf8");
|
||||||
const names = new Set();
|
const names = new Set();
|
||||||
for (const m of indexSrc.matchAll(/registerTool\(\s*"([a-z0-9_]+)"/g)) {
|
for (const m of indexSrc.matchAll(/registerTool\(\s*"([a-zA-Z0-9_]+)"/g)) {
|
||||||
names.add(m[1]);
|
names.add(m[1]);
|
||||||
}
|
}
|
||||||
// Each spec is one `{ ... }` block; scrape its mcpName but skip a block that
|
// Each spec is one `{ ... }` block; scrape its mcpName but skip a block that
|
||||||
// carries `inAppOnly: true` (not registered on the external MCP host).
|
// carries `inAppOnly: true` (not registered on the external MCP host).
|
||||||
for (const block of specsSrc.split(/\n\s{2}\w+:\s*\{/)) {
|
for (const block of specsSrc.split(/\n\s{2}\w+:\s*\{/)) {
|
||||||
const nameMatch = block.match(/mcpName:\s*['"]([a-z0-9_]+)['"]/);
|
const nameMatch = block.match(/mcpName:\s*['"]([a-zA-Z0-9_]+)['"]/);
|
||||||
if (!nameMatch) continue;
|
if (!nameMatch) continue;
|
||||||
if (/inAppOnly:\s*true/.test(block)) continue;
|
if (/inAppOnly:\s*true/.test(block)) continue;
|
||||||
names.add(nameMatch[1]);
|
names.add(nameMatch[1]);
|
||||||
@@ -82,27 +82,28 @@ test("the inventory has no phantom tool (every line is a real registered tool)",
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// #411: the external MCP surface gains update_page_markdown and LOSES
|
// #411: the external MCP surface gains updatePageMarkdown and LOSES
|
||||||
// import_page_markdown (now inAppOnly). The in-app agent still keeps
|
// importPageMarkdown (now inAppOnly). The in-app agent still keeps
|
||||||
// importPageMarkdown — asserted in the server-side contract spec.
|
// importPageMarkdown — asserted in the server-side contract spec. (#412 renamed
|
||||||
test("update_page_markdown is on the MCP surface; import_page_markdown is NOT", () => {
|
// both public MCP tool names to camelCase.)
|
||||||
|
test("updatePageMarkdown is on the MCP surface; importPageMarkdown is NOT", () => {
|
||||||
const inventory = new Set(buildToolInventoryLines().map((l) => l.name));
|
const inventory = new Set(buildToolInventoryLines().map((l) => l.name));
|
||||||
assert.ok(
|
assert.ok(
|
||||||
inventory.has("update_page_markdown"),
|
inventory.has("updatePageMarkdown"),
|
||||||
"update_page_markdown should be registered on the external MCP surface",
|
"updatePageMarkdown should be registered on the external MCP surface",
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!inventory.has("import_page_markdown"),
|
!inventory.has("importPageMarkdown"),
|
||||||
"import_page_markdown must be dropped from the external MCP surface (#411)",
|
"importPageMarkdown must be dropped from the external MCP surface (#411)",
|
||||||
);
|
);
|
||||||
// And the routing prose no longer points MCP clients at it.
|
// And the routing prose no longer points MCP clients at it.
|
||||||
assert.ok(
|
assert.ok(
|
||||||
!ROUTING_PROSE.includes("import_page_markdown"),
|
!ROUTING_PROSE.includes("importPageMarkdown"),
|
||||||
"ROUTING_PROSE still mentions the removed import_page_markdown",
|
"ROUTING_PROSE still mentions the removed importPageMarkdown",
|
||||||
);
|
);
|
||||||
assert.ok(
|
assert.ok(
|
||||||
ROUTING_PROSE.includes("update_page_markdown"),
|
ROUTING_PROSE.includes("updatePageMarkdown"),
|
||||||
"ROUTING_PROSE should mention update_page_markdown",
|
"ROUTING_PROSE should mention updatePageMarkdown",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,10 +22,13 @@ test("every spec exposes mcpName + inAppKey, and the key matches inAppKey", () =
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("mcpName uses snake_case and inAppKey uses camelCase", () => {
|
// Since issue #412 the external MCP name equals the in-app key: both are the
|
||||||
|
// same camelCase identifier (mcpName === inAppKey).
|
||||||
|
test("mcpName and inAppKey are the same camelCase identifier", () => {
|
||||||
for (const [key, spec] of Object.entries(SHARED_TOOL_SPECS)) {
|
for (const [key, spec] of Object.entries(SHARED_TOOL_SPECS)) {
|
||||||
assert.match(spec.mcpName, /^[a-z0-9]+(_[a-z0-9]+)*$/, `${key}: mcpName not snake_case`);
|
assert.match(spec.mcpName, /^[a-z][a-zA-Z0-9]*$/, `${key}: mcpName not camelCase`);
|
||||||
assert.match(spec.inAppKey, /^[a-z][a-zA-Z0-9]*$/, `${key}: inAppKey not camelCase`);
|
assert.match(spec.inAppKey, /^[a-z][a-zA-Z0-9]*$/, `${key}: inAppKey not camelCase`);
|
||||||
|
assert.equal(spec.mcpName, spec.inAppKey, `${key}: mcpName must equal inAppKey`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,7 +62,7 @@ test("buildShape (when present) returns a usable ZodRawShape with a real zod", (
|
|||||||
|
|
||||||
test("editPageText builder produces { pageId, edits } and drops the stale strip-and-retry claim", () => {
|
test("editPageText builder produces { pageId, edits } and drops the stale strip-and-retry claim", () => {
|
||||||
const spec = SHARED_TOOL_SPECS.editPageText;
|
const spec = SHARED_TOOL_SPECS.editPageText;
|
||||||
assert.equal(spec.mcpName, "edit_page_text");
|
assert.equal(spec.mcpName, "editPageText");
|
||||||
const shape = spec.buildShape(z);
|
const shape = spec.buildShape(z);
|
||||||
assert.deepEqual(Object.keys(shape).sort(), ["edits", "pageId"]);
|
assert.deepEqual(Object.keys(shape).sort(), ["edits", "pageId"]);
|
||||||
// A valid edits batch parses.
|
// A valid edits batch parses.
|
||||||
@@ -78,49 +81,68 @@ test("editPageText builder produces { pageId, edits } and drops the stale strip-
|
|||||||
assert.match(spec.description, /REFUSED into\s+failed\[\]/);
|
assert.match(spec.description, /REFUSED into\s+failed\[\]/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("getNode builder produces exactly { pageId, nodeId }", () => {
|
// #413: getNode gained an optional `format` (markdown default / json opt-in).
|
||||||
const shape = SHARED_TOOL_SPECS.getNode.buildShape(z);
|
test("getNode builder produces { pageId, nodeId, format? } with format optional", () => {
|
||||||
assert.deepEqual(Object.keys(shape).sort(), ["nodeId", "pageId"]);
|
const spec = SHARED_TOOL_SPECS.getNode;
|
||||||
|
const shape = spec.buildShape(z);
|
||||||
|
assert.deepEqual(Object.keys(shape).sort(), ["format", "nodeId", "pageId"]);
|
||||||
|
const schema = z.object(shape);
|
||||||
|
// format is optional (markdown default lives in the client).
|
||||||
|
assert.doesNotThrow(() => schema.parse({ pageId: "p1", nodeId: "n1" }));
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
schema.parse({ pageId: "p1", nodeId: "n1", format: "json" }),
|
||||||
|
);
|
||||||
|
assert.throws(() =>
|
||||||
|
schema.parse({ pageId: "p1", nodeId: "n1", format: "yaml" }),
|
||||||
|
);
|
||||||
|
// The description advertises the markdown default and the json opt-in.
|
||||||
|
assert.match(spec.description, /markdown/i);
|
||||||
|
assert.match(spec.description, /json/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("patchNode spec exists, merges BOTH descriptions, builds { pageId, nodeId, node }", () => {
|
// #413: patchNode takes XOR { markdown | node } (both schema-optional).
|
||||||
|
test("patchNode spec exists, describes markdown+node XOR, builds { pageId, nodeId, markdown?, node? }", () => {
|
||||||
const spec = SHARED_TOOL_SPECS.patchNode;
|
const spec = SHARED_TOOL_SPECS.patchNode;
|
||||||
assert.ok(spec, "patchNode spec missing");
|
assert.ok(spec, "patchNode spec missing");
|
||||||
assert.equal(spec.mcpName, "patch_node");
|
assert.equal(spec.mcpName, "patchNode");
|
||||||
assert.equal(spec.inAppKey, "patchNode");
|
assert.equal(spec.inAppKey, "patchNode");
|
||||||
|
|
||||||
// The canonical description must carry the key guidance from BOTH originals:
|
// The canonical description must carry the #413 guidance.
|
||||||
// - MCP-only: "WITHOUT resending the whole document" + the cheaper/safer note.
|
assert.match(spec.description, /WITHOUT/i);
|
||||||
// - in-app-only: "keeps the same node id" + the "Reversible ... page history"
|
assert.match(spec.description, /EXACTLY ONE of `markdown` or `node`/);
|
||||||
// framing the MCP copy lacked.
|
assert.match(spec.description, /RECOMMENDED/);
|
||||||
assert.match(spec.description, /WITHOUT resending the whole document/);
|
assert.match(spec.description, /keeps the same block id/i);
|
||||||
assert.match(spec.description, /Cheaper and safer/);
|
|
||||||
assert.match(spec.description, /keeps the same node id/i);
|
|
||||||
assert.match(spec.description, /Reversible/i);
|
assert.match(spec.description, /Reversible/i);
|
||||||
assert.match(spec.description, /page history/i);
|
assert.match(spec.description, /page history/i);
|
||||||
|
|
||||||
const shape = spec.buildShape(z);
|
const shape = spec.buildShape(z);
|
||||||
assert.deepEqual(Object.keys(shape).sort(), ["node", "nodeId", "pageId"]);
|
assert.deepEqual(
|
||||||
// A minimal valid input parses (node accepts an arbitrary object via z.any()).
|
Object.keys(shape).sort(),
|
||||||
const parsed = z.object(shape).parse({
|
["markdown", "node", "nodeId", "pageId"],
|
||||||
|
);
|
||||||
|
// markdown and node are BOTH optional in the schema (XOR enforced at runtime).
|
||||||
|
const schema = z.object(shape);
|
||||||
|
const parsedMd = schema.parse({ pageId: "p1", nodeId: "n1", markdown: "hi" });
|
||||||
|
assert.equal(parsedMd.markdown, "hi");
|
||||||
|
const parsedNode = schema.parse({
|
||||||
pageId: "p1",
|
pageId: "p1",
|
||||||
nodeId: "n1",
|
nodeId: "n1",
|
||||||
node: { type: "paragraph" },
|
node: { type: "paragraph" },
|
||||||
});
|
});
|
||||||
assert.equal(parsed.pageId, "p1");
|
assert.equal(parsedNode.pageId, "p1");
|
||||||
assert.equal(parsed.nodeId, "n1");
|
// Neither given parses at the schema level (the client throws the XOR error).
|
||||||
|
assert.doesNotThrow(() => schema.parse({ pageId: "p1", nodeId: "n1" }));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("insertNode spec exists, merges BOTH descriptions, builds the full anchor shape", () => {
|
// #413: insertNode also takes XOR { markdown | node } plus the anchor shape.
|
||||||
|
test("insertNode spec exists, describes markdown+node XOR, builds the full anchor+content shape", () => {
|
||||||
const spec = SHARED_TOOL_SPECS.insertNode;
|
const spec = SHARED_TOOL_SPECS.insertNode;
|
||||||
assert.ok(spec, "insertNode spec missing");
|
assert.ok(spec, "insertNode spec missing");
|
||||||
assert.equal(spec.mcpName, "insert_node");
|
assert.equal(spec.mcpName, "insertNode");
|
||||||
assert.equal(spec.inAppKey, "insertNode");
|
assert.equal(spec.inAppKey, "insertNode");
|
||||||
|
|
||||||
// Canonical description must keep BOTH sides' nuance:
|
|
||||||
// - in-app-only: "EXACTLY ONE of anchorNodeId or anchorText" + "Reversible".
|
|
||||||
// - MCP-only: the table-structure (tableRow/tableCell) insertion guidance.
|
|
||||||
assert.match(spec.description, /EXACTLY ONE of anchorNodeId or anchorText/);
|
assert.match(spec.description, /EXACTLY ONE of anchorNodeId or anchorText/);
|
||||||
|
assert.match(spec.description, /EXACTLY ONE of `markdown` or `node`/);
|
||||||
assert.match(spec.description, /tableRow/);
|
assert.match(spec.description, /tableRow/);
|
||||||
assert.match(spec.description, /append is top-level only/);
|
assert.match(spec.description, /append is top-level only/);
|
||||||
assert.match(spec.description, /Reversible via page history/);
|
assert.match(spec.description, /Reversible via page history/);
|
||||||
@@ -128,18 +150,85 @@ test("insertNode spec exists, merges BOTH descriptions, builds the full anchor s
|
|||||||
const shape = spec.buildShape(z);
|
const shape = spec.buildShape(z);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
Object.keys(shape).sort(),
|
Object.keys(shape).sort(),
|
||||||
["anchorNodeId", "anchorText", "node", "pageId", "position"],
|
["anchorNodeId", "anchorText", "markdown", "node", "pageId", "position"],
|
||||||
);
|
);
|
||||||
// before/after/append are the only accepted positions; anchors are optional.
|
// before/after/append are the only accepted positions; markdown/node/anchors optional.
|
||||||
const schema = z.object(shape);
|
const schema = z.object(shape);
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
schema.parse({ pageId: "p1", markdown: "hi", position: "append" }),
|
||||||
|
);
|
||||||
assert.doesNotThrow(() =>
|
assert.doesNotThrow(() =>
|
||||||
schema.parse({ pageId: "p1", node: { type: "paragraph" }, position: "append" }),
|
schema.parse({ pageId: "p1", node: { type: "paragraph" }, position: "append" }),
|
||||||
);
|
);
|
||||||
assert.throws(() =>
|
assert.throws(() =>
|
||||||
schema.parse({ pageId: "p1", node: {}, position: "sideways" }),
|
schema.parse({ pageId: "p1", markdown: "x", position: "sideways" }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #443: getTree — a space's page hierarchy (or a subtree) in one request.
|
||||||
|
test("getTree spec exists on both hosts, builds { spaceId, rootPageId?, maxDepth? }", () => {
|
||||||
|
const spec = SHARED_TOOL_SPECS.getTree;
|
||||||
|
assert.ok(spec, "getTree spec missing");
|
||||||
|
assert.equal(spec.mcpName, "getTree");
|
||||||
|
assert.equal(spec.inAppKey, "getTree");
|
||||||
|
// Shared spec: registered on BOTH hosts.
|
||||||
|
assert.notEqual(spec.inAppOnly, true);
|
||||||
|
assert.notEqual(spec.mcpOnly, true);
|
||||||
|
|
||||||
|
const shape = spec.buildShape(z);
|
||||||
|
assert.deepEqual(Object.keys(shape).sort(), ["maxDepth", "rootPageId", "spaceId"]);
|
||||||
|
const schema = z.object(shape);
|
||||||
|
// spaceId required; rootPageId + maxDepth optional.
|
||||||
|
assert.doesNotThrow(() => schema.parse({ spaceId: "sp1" }));
|
||||||
|
assert.throws(() => schema.parse({}));
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
schema.parse({ spaceId: "sp1", rootPageId: "p1", maxDepth: 2 }),
|
||||||
|
);
|
||||||
|
// maxDepth is an integer >= 1.
|
||||||
|
assert.throws(() => schema.parse({ spaceId: "sp1", maxDepth: 0 }));
|
||||||
|
assert.throws(() => schema.parse({ spaceId: "sp1", maxDepth: 1.5 }));
|
||||||
|
|
||||||
|
// The description advertises the output node shape, rootPageId, maxDepth, and
|
||||||
|
// steers away from the deprecated listPages tree:true.
|
||||||
|
assert.match(spec.description, /pageId/);
|
||||||
|
assert.match(spec.description, /rootPageId/);
|
||||||
|
assert.match(spec.description, /maxDepth/);
|
||||||
|
assert.match(spec.description, /hasChildren/);
|
||||||
|
assert.match(spec.description, /listPages tree:true/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #443: getPageContext — a page's breadcrumbs + direct children in one call.
|
||||||
|
test("getPageContext spec exists on both hosts, builds { pageId }", () => {
|
||||||
|
const spec = SHARED_TOOL_SPECS.getPageContext;
|
||||||
|
assert.ok(spec, "getPageContext spec missing");
|
||||||
|
assert.equal(spec.mcpName, "getPageContext");
|
||||||
|
assert.equal(spec.inAppKey, "getPageContext");
|
||||||
|
// Shared spec: registered on BOTH hosts.
|
||||||
|
assert.notEqual(spec.inAppOnly, true);
|
||||||
|
assert.notEqual(spec.mcpOnly, true);
|
||||||
|
|
||||||
|
const shape = spec.buildShape(z);
|
||||||
|
assert.deepEqual(Object.keys(shape).sort(), ["pageId"]);
|
||||||
|
const schema = z.object(shape);
|
||||||
|
// pageId required.
|
||||||
|
assert.doesNotThrow(() => schema.parse({ pageId: "p1" }));
|
||||||
|
assert.throws(() => schema.parse({}));
|
||||||
|
|
||||||
|
// The description advertises the output shape (page/breadcrumbs/children) and
|
||||||
|
// the root-page empty-breadcrumbs contract.
|
||||||
|
assert.match(spec.description, /breadcrumbs/);
|
||||||
|
assert.match(spec.description, /children/);
|
||||||
|
assert.match(spec.description, /hasChildren/);
|
||||||
|
assert.match(spec.description, /getTree/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #443: listPages tree:true is deprecated in favour of getTree.
|
||||||
|
test("listPages description deprecates tree:true and points at getTree", () => {
|
||||||
|
const spec = SHARED_TOOL_SPECS.listPages;
|
||||||
|
assert.match(spec.description, /DEPRECATED/i);
|
||||||
|
assert.match(spec.description, /getTree/);
|
||||||
|
});
|
||||||
|
|
||||||
test("no-arg specs (getWorkspace/listSpaces/listShares) omit buildShape", () => {
|
test("no-arg specs (getWorkspace/listSpaces/listShares) omit buildShape", () => {
|
||||||
for (const key of ["getWorkspace", "listSpaces", "listShares"]) {
|
for (const key of ["getWorkspace", "listSpaces", "listShares"]) {
|
||||||
assert.equal(SHARED_TOOL_SPECS[key].buildShape, undefined, `${key} should be no-arg`);
|
assert.equal(SHARED_TOOL_SPECS[key].buildShape, undefined, `${key} should be no-arg`);
|
||||||
@@ -150,7 +239,7 @@ test("no-arg specs (getWorkspace/listSpaces/listShares) omit buildShape", () =>
|
|||||||
test("updatePageMarkdown spec exists, pairs with updatePageJson, builds { pageId, content, title }", () => {
|
test("updatePageMarkdown spec exists, pairs with updatePageJson, builds { pageId, content, title }", () => {
|
||||||
const spec = SHARED_TOOL_SPECS.updatePageMarkdown;
|
const spec = SHARED_TOOL_SPECS.updatePageMarkdown;
|
||||||
assert.ok(spec, "updatePageMarkdown spec missing");
|
assert.ok(spec, "updatePageMarkdown spec missing");
|
||||||
assert.equal(spec.mcpName, "update_page_markdown");
|
assert.equal(spec.mcpName, "updatePageMarkdown");
|
||||||
assert.equal(spec.inAppKey, "updatePageMarkdown");
|
assert.equal(spec.inAppKey, "updatePageMarkdown");
|
||||||
// Registered on BOTH hosts (a shared spec, no inAppOnly/mcpOnly flag).
|
// Registered on BOTH hosts (a shared spec, no inAppOnly/mcpOnly flag).
|
||||||
assert.notEqual(spec.inAppOnly, true);
|
assert.notEqual(spec.inAppOnly, true);
|
||||||
@@ -168,13 +257,13 @@ test("updatePageMarkdown spec exists, pairs with updatePageJson, builds { pageId
|
|||||||
assert.match(spec.description, /\^\[/);
|
assert.match(spec.description, /\^\[/);
|
||||||
});
|
});
|
||||||
|
|
||||||
// #411: import_page_markdown is dropped from the EXTERNAL MCP surface but stays
|
// #411: importPageMarkdown is dropped from the EXTERNAL MCP surface but stays
|
||||||
// available to the in-app agent — encoded as inAppOnly on the shared spec.
|
// available to the in-app agent — encoded as inAppOnly on the shared spec.
|
||||||
test("importPageMarkdown spec is inAppOnly (removed from the external MCP surface, kept in-app)", () => {
|
test("importPageMarkdown spec is inAppOnly (removed from the external MCP surface, kept in-app)", () => {
|
||||||
const spec = SHARED_TOOL_SPECS.importPageMarkdown;
|
const spec = SHARED_TOOL_SPECS.importPageMarkdown;
|
||||||
assert.ok(spec, "importPageMarkdown spec missing");
|
assert.ok(spec, "importPageMarkdown spec missing");
|
||||||
assert.equal(spec.inAppOnly, true);
|
assert.equal(spec.inAppOnly, true);
|
||||||
// The spec + its client method are NOT deleted — only hidden from the MCP host.
|
// The spec + its client method are NOT deleted — only hidden from the MCP host.
|
||||||
assert.equal(spec.mcpName, "import_page_markdown");
|
assert.equal(spec.mcpName, "importPageMarkdown");
|
||||||
assert.equal(spec.inAppKey, "importPageMarkdown");
|
assert.equal(spec.inAppKey, "importPageMarkdown");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ test("times a tool and preserves the handler's return value", async () => {
|
|||||||
const onMetric = (name, value, labels) => calls.push({ name, value, labels });
|
const onMetric = (name, value, labels) => calls.push({ name, value, labels });
|
||||||
|
|
||||||
const handler = async (arg) => ({ ok: true, echo: arg });
|
const handler = async (arg) => ({ ok: true, echo: arg });
|
||||||
const wrapped = timeToolHandler("get_page", handler, onMetric);
|
const wrapped = timeToolHandler("getPage", handler, onMetric);
|
||||||
|
|
||||||
const result = await wrapped("hello");
|
const result = await wrapped("hello");
|
||||||
// Return value passes through untouched.
|
// Return value passes through untouched.
|
||||||
@@ -22,7 +22,7 @@ test("times a tool and preserves the handler's return value", async () => {
|
|||||||
// Exactly one sample, correct name/labels, numeric non-negative duration.
|
// Exactly one sample, correct name/labels, numeric non-negative duration.
|
||||||
assert.equal(calls.length, 1);
|
assert.equal(calls.length, 1);
|
||||||
assert.equal(calls[0].name, "mcp_tool_duration_seconds");
|
assert.equal(calls[0].name, "mcp_tool_duration_seconds");
|
||||||
assert.deepEqual(calls[0].labels, { tool: "get_page" });
|
assert.deepEqual(calls[0].labels, { tool: "getPage" });
|
||||||
assert.equal(typeof calls[0].value, "number");
|
assert.equal(typeof calls[0].value, "number");
|
||||||
assert.ok(calls[0].value >= 0, "duration must be non-negative seconds");
|
assert.ok(calls[0].value >= 0, "duration must be non-negative seconds");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -137,3 +137,164 @@ test("buildPageTree output shape is lean (drops position/parentPageId/hasChildre
|
|||||||
assert.equal("hasChildren" in node, false);
|
assert.equal("hasChildren" in node, false);
|
||||||
assert.equal("spaceId" in node, false);
|
assert.equal("spaceId" in node, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// #443 getTree output shape: { pageId, title, children?, hasChildren? }
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// A small representative space used across the getTree tests:
|
||||||
|
// r1 (Infrastructure)
|
||||||
|
// c1 (Datacenter A)
|
||||||
|
// g1 (Servers) [leaf]
|
||||||
|
// c2 (Datacenter B) [leaf]
|
||||||
|
// r2 (Notes) [leaf]
|
||||||
|
const SAMPLE = [
|
||||||
|
{ id: "r2", slugId: "s-r2", title: "Notes", position: "a1", icon: "📝", hasChildren: false },
|
||||||
|
{ id: "r1", slugId: "s-r1", title: "Infrastructure", position: "a0", icon: "🏢", hasChildren: true },
|
||||||
|
{ id: "c2", slugId: "s-c2", title: "Datacenter B", position: "b1", parentPageId: "r1", icon: "🅱️", hasChildren: false },
|
||||||
|
{ id: "c1", slugId: "s-c1", title: "Datacenter A", position: "b0", parentPageId: "r1", icon: "🅰️", hasChildren: true },
|
||||||
|
{ id: "g1", slugId: "s-g1", title: "Servers", position: "c0", parentPageId: "c1", icon: "🖥️", hasChildren: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
test("getTree shape: correct nesting + order-by-position, only {pageId,title,children?}, no leak", () => {
|
||||||
|
const tree = buildPageTree(SAMPLE, { shape: "getTree" });
|
||||||
|
|
||||||
|
// Roots sorted by position: r1 (a0) before r2 (a1).
|
||||||
|
assert.deepEqual(
|
||||||
|
tree.map((n) => n.pageId),
|
||||||
|
["r1", "r2"],
|
||||||
|
);
|
||||||
|
// r1's children sorted by position: c1 (b0) before c2 (b1).
|
||||||
|
assert.deepEqual(
|
||||||
|
tree[0].children.map((n) => n.pageId),
|
||||||
|
["c1", "c2"],
|
||||||
|
);
|
||||||
|
// Deep nesting: g1 under c1.
|
||||||
|
assert.deepEqual(
|
||||||
|
tree[0].children[0].children.map((n) => n.pageId),
|
||||||
|
["g1"],
|
||||||
|
);
|
||||||
|
|
||||||
|
// No slugId/icon/position/parentPageId/hasChildren leak on any node.
|
||||||
|
const walk = (nodes) => {
|
||||||
|
for (const n of nodes) {
|
||||||
|
assert.deepEqual(
|
||||||
|
Object.keys(n).sort(),
|
||||||
|
n.children ? ["children", "pageId", "title"] : ["pageId", "title"],
|
||||||
|
`unexpected keys on ${n.pageId}: ${Object.keys(n)}`,
|
||||||
|
);
|
||||||
|
assert.equal("slugId" in n, false);
|
||||||
|
assert.equal("icon" in n, false);
|
||||||
|
assert.equal("position" in n, false);
|
||||||
|
assert.equal("parentPageId" in n, false);
|
||||||
|
// Fully-expanded tree (no maxDepth): hasChildren never set.
|
||||||
|
assert.equal("hasChildren" in n, false);
|
||||||
|
if (n.children) walk(n.children);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
walk(tree);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getTree maxDepth:1 returns roots only, each with hasChildren from the flat item", () => {
|
||||||
|
const tree = buildPageTree(SAMPLE, { shape: "getTree", maxDepth: 1 });
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
tree.map((n) => n.pageId),
|
||||||
|
["r1", "r2"],
|
||||||
|
);
|
||||||
|
// No children arrays at depth 1 when maxDepth:1.
|
||||||
|
for (const n of tree) assert.equal("children" in n, false);
|
||||||
|
// r1 has children on the server -> hasChildren:true; r2 is a leaf -> omitted.
|
||||||
|
assert.equal(tree[0].hasChildren, true);
|
||||||
|
assert.equal("hasChildren" in tree[1], false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getTree maxDepth:2 cuts grandchildren; hasChildren only on the cut interior node", () => {
|
||||||
|
const tree = buildPageTree(SAMPLE, { shape: "getTree", maxDepth: 2 });
|
||||||
|
|
||||||
|
const r1 = tree[0];
|
||||||
|
// Depth-1 node r1 was EXPANDED (its children are present) -> no hasChildren.
|
||||||
|
assert.equal("hasChildren" in r1, false);
|
||||||
|
assert.equal(r1.children.length, 2);
|
||||||
|
|
||||||
|
const [c1, c2] = r1.children;
|
||||||
|
// c1 is at depth 2 (the cut) and has children on the server -> hasChildren:true,
|
||||||
|
// and its grandchild g1 is NOT present.
|
||||||
|
assert.equal(c1.pageId, "c1");
|
||||||
|
assert.equal("children" in c1, false);
|
||||||
|
assert.equal(c1.hasChildren, true);
|
||||||
|
// c2 is at depth 2 but is a leaf on the server -> hasChildren omitted.
|
||||||
|
assert.equal(c2.pageId, "c2");
|
||||||
|
assert.equal("children" in c2, false);
|
||||||
|
assert.equal("hasChildren" in c2, false);
|
||||||
|
|
||||||
|
// r2 is a depth-1 leaf -> no hasChildren, no children.
|
||||||
|
assert.equal("hasChildren" in tree[1], false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getTree hasChildren is set ONLY on depth-cut nodes (not leaves, not expanded interior nodes)", () => {
|
||||||
|
// Full tree (no cut): NO node anywhere carries hasChildren.
|
||||||
|
const full = buildPageTree(SAMPLE, { shape: "getTree" });
|
||||||
|
const anyHasChildren = (nodes) =>
|
||||||
|
nodes.some((n) => "hasChildren" in n || (n.children && anyHasChildren(n.children)));
|
||||||
|
assert.equal(anyHasChildren(full), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getTree orphan (parent filtered out) surfaces as a root, not dropped", () => {
|
||||||
|
const tree = buildPageTree(
|
||||||
|
[
|
||||||
|
{ id: "root", slugId: "s-root", title: "Root", position: "a0", hasChildren: true },
|
||||||
|
// parentPageId points at an id NOT in the flat list (parent filtered by perms).
|
||||||
|
{ id: "orphan", slugId: "s-orphan", title: "Orphan", position: "a1", parentPageId: "gone", hasChildren: false },
|
||||||
|
],
|
||||||
|
{ shape: "getTree" },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
tree.map((n) => n.pageId).sort(),
|
||||||
|
["orphan", "root"],
|
||||||
|
);
|
||||||
|
const orphan = tree.find((n) => n.pageId === "orphan");
|
||||||
|
assert.equal("children" in orphan, false);
|
||||||
|
assert.equal("hasChildren" in orphan, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getTree rootPageId path: a seeded single-root subtree keeps the getTree shape", () => {
|
||||||
|
// Simulate the server seeding the CTE with the subtree root c1: the flat list
|
||||||
|
// it returns contains c1 (now a root, parent absent) + its descendant g1.
|
||||||
|
const subtree = [
|
||||||
|
{ id: "c1", slugId: "s-c1", title: "Datacenter A", position: "b0", hasChildren: true },
|
||||||
|
{ id: "g1", slugId: "s-g1", title: "Servers", position: "c0", parentPageId: "c1", hasChildren: false },
|
||||||
|
];
|
||||||
|
const tree = buildPageTree(subtree, { shape: "getTree" });
|
||||||
|
|
||||||
|
assert.equal(tree.length, 1);
|
||||||
|
assert.equal(tree[0].pageId, "c1");
|
||||||
|
assert.deepEqual(
|
||||||
|
tree[0].children.map((n) => n.pageId),
|
||||||
|
["g1"],
|
||||||
|
);
|
||||||
|
assert.equal("slugId" in tree[0], false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getTree maxDepth<=0 / non-finite is treated as no cut (whole tree)", () => {
|
||||||
|
for (const bad of [0, -3, NaN, Infinity, undefined]) {
|
||||||
|
const tree = buildPageTree(SAMPLE, { shape: "getTree", maxDepth: bad });
|
||||||
|
// Grandchild g1 present -> no cut applied.
|
||||||
|
assert.deepEqual(
|
||||||
|
tree[0].children[0].children.map((n) => n.pageId),
|
||||||
|
["g1"],
|
||||||
|
`maxDepth=${bad} should not cut`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildPageTree() with no options is byte-identical to the historic lean call", () => {
|
||||||
|
// Guard the existing callers: buildPageTree(pages) must be unchanged by the
|
||||||
|
// additive options param.
|
||||||
|
const withoutOpts = buildPageTree(SAMPLE);
|
||||||
|
const withEmptyOpts = buildPageTree(SAMPLE, {});
|
||||||
|
assert.deepEqual(withoutOpts, withEmptyOpts);
|
||||||
|
// And it is the lean {id,slugId,title,children?} shape, not the getTree shape.
|
||||||
|
assert.deepEqual(Object.keys(withoutOpts[0]).sort(), ["children", "id", "slugId", "title"]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ const DocmostAttributes = Extension.create({
|
|||||||
* Docmost inline comment mark. Anchors a comment thread to a text range via
|
* Docmost inline comment mark. Anchors a comment thread to a text range via
|
||||||
* `commentId`. Without it, any document containing comment highlights fails to
|
* `commentId`. Without it, any document containing comment highlights fails to
|
||||||
* round-trip through the schema ("There is no mark type comment in this schema"),
|
* round-trip through the schema ("There is no mark type comment in this schema"),
|
||||||
* which breaks update_page_json and edit_page_text on every commented page.
|
* which breaks updatePageJson and editPageText on every commented page.
|
||||||
* Mirrors Docmost's @docmost/editor-ext comment mark (commentId / resolved).
|
* Mirrors Docmost's @docmost/editor-ext comment mark (commentId / resolved).
|
||||||
*/
|
*/
|
||||||
const Comment = Mark.create({
|
const Comment = Mark.create({
|
||||||
|
|||||||
@@ -53,11 +53,14 @@ export {
|
|||||||
buildOutline,
|
buildOutline,
|
||||||
getNodeByRef,
|
getNodeByRef,
|
||||||
replaceNodeById,
|
replaceNodeById,
|
||||||
|
replaceNodeByIdWithMany,
|
||||||
|
reassignCollidingBlockIds,
|
||||||
deleteNodeById,
|
deleteNodeById,
|
||||||
sanitizeForYjs,
|
sanitizeForYjs,
|
||||||
findUnstorableAttr,
|
findUnstorableAttr,
|
||||||
findInvalidNode,
|
findInvalidNode,
|
||||||
insertNodeRelative,
|
insertNodeRelative,
|
||||||
|
insertNodesRelative,
|
||||||
readTable,
|
readTable,
|
||||||
insertTableRow,
|
insertTableRow,
|
||||||
deleteTableRow,
|
deleteTableRow,
|
||||||
|
|||||||
@@ -217,6 +217,54 @@ export function replaceNodeById(
|
|||||||
return { doc: out, replaced };
|
return { doc: out, replaced };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splice a SINGLE node whose `attrs.id === nodeId` with an ORDERED ARRAY of new
|
||||||
|
* nodes (a "1 -> N" replacement), anywhere in the tree. Used by the markdown
|
||||||
|
* patch path, where importing a markdown fragment can yield several blocks that
|
||||||
|
* must replace one existing block in place ("rewrite a section" in one call).
|
||||||
|
*
|
||||||
|
* Unlike `replaceNodeById` (which substitutes EVERY match), this walks to the
|
||||||
|
* FIRST match only and splices `newNodes` in its position, so ordering and the
|
||||||
|
* neighbouring blocks are preserved byte-for-byte. It deliberately does NOT
|
||||||
|
* touch further duplicates: the caller (#159 semantics) must have already
|
||||||
|
* verified the id is unambiguous via a `replaceNodeById` dry pass, so a single
|
||||||
|
* splice here is safe and every other block is untouched.
|
||||||
|
*
|
||||||
|
* Each entry of `newNodes` is deep-cloned so they never share references with
|
||||||
|
* each other or with the caller\'s array. Operates on a clone of `doc`; returns
|
||||||
|
* `{ doc, replaced }` where `replaced` is 1 when a match was spliced, else 0.
|
||||||
|
*/
|
||||||
|
export function replaceNodeByIdWithMany(
|
||||||
|
doc: any,
|
||||||
|
nodeId: string,
|
||||||
|
newNodes: any[],
|
||||||
|
): { doc: any; replaced: number } {
|
||||||
|
const out = clone(doc);
|
||||||
|
const fresh = Array.isArray(newNodes) ? newNodes.map((n) => clone(n)) : [];
|
||||||
|
let replaced = 0;
|
||||||
|
|
||||||
|
// Walk to the FIRST match and splice the array in its place; stop afterwards.
|
||||||
|
const walkContent = (content: any[]): boolean => {
|
||||||
|
for (let i = 0; i < content.length; i++) {
|
||||||
|
const child = content[i];
|
||||||
|
if (matchesId(child, nodeId)) {
|
||||||
|
content.splice(i, 1, ...fresh);
|
||||||
|
replaced = 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (isObject(child) && Array.isArray(child.content)) {
|
||||||
|
if (walkContent(child.content)) return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
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`
|
* Remove EVERY node whose `attrs.id === nodeId` from its parent `content`
|
||||||
* array, anywhere in the tree (recursive, including callouts and tables).
|
* array, anywhere in the tree (recursive, including callouts and tables).
|
||||||
@@ -263,7 +311,7 @@ export function deleteNodeById(
|
|||||||
* changed. No-op for the unambiguous single-match case.
|
* changed. No-op for the unambiguous single-match case.
|
||||||
*/
|
*/
|
||||||
export function assertUnambiguousMatch(
|
export function assertUnambiguousMatch(
|
||||||
op: "patch_node" | "delete_node",
|
op: "patchNode" | "deleteNode",
|
||||||
verb: "replace" | "delete",
|
verb: "replace" | "delete",
|
||||||
count: number,
|
count: number,
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
@@ -631,7 +679,7 @@ export function insertNodeRelative(
|
|||||||
// top level — appending one would produce invalid nesting.
|
// top level — appending one would produce invalid nesting.
|
||||||
if (isStructural) {
|
if (isStructural) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`insert_node: cannot append a ${node.type} at the top level; use ` +
|
`insertNode: cannot append a ${node.type} at the top level; use ` +
|
||||||
`position before/after with an anchor inside the target table`,
|
`position before/after with an anchor inside the target table`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -666,7 +714,7 @@ export function insertNodeRelative(
|
|||||||
|
|
||||||
if (containerIdx === -1) {
|
if (containerIdx === -1) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`insert_node: cannot insert a ${node.type} here — the anchor is not ` +
|
`insertNode: cannot insert a ${node.type} here — the anchor is not ` +
|
||||||
`inside a ${containerType}. Anchor on a cell's text or a block id ` +
|
`inside a ${containerType}. Anchor on a cell's text or a block id ` +
|
||||||
`that lives inside the target table.`,
|
`that lives inside the target table.`,
|
||||||
);
|
);
|
||||||
@@ -725,6 +773,88 @@ export function insertNodeRelative(
|
|||||||
return { doc: out, inserted: false };
|
return { doc: out, inserted: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert an ORDERED ARRAY of nodes relative to an anchor, preserving their
|
||||||
|
* order. This is the multi-node twin of `insertNodeRelative`, used by the
|
||||||
|
* markdown insert path where importing a markdown fragment can yield several
|
||||||
|
* blocks that must land, in order, at one anchor.
|
||||||
|
*
|
||||||
|
* Semantics mirror `insertNodeRelative` exactly:
|
||||||
|
* - position "append": push every node onto the top-level `doc.content`.
|
||||||
|
* - position "before"/"after": splice every node into the anchor\'s parent
|
||||||
|
* `content` array immediately before / after it, keeping array order.
|
||||||
|
*
|
||||||
|
* The structural-table branch of `insertNodeRelative` is intentionally NOT
|
||||||
|
* duplicated here: a markdown fragment can never produce a bare tableRow/
|
||||||
|
* tableCell/tableHeader (those are not expressible in markdown), so the markdown
|
||||||
|
* insert path only ever hands whole top-level blocks. Structural inserts stay on
|
||||||
|
* the single-node JSON path. An empty `nodes` array is a no-op that still
|
||||||
|
* reports `inserted:false` (nothing to place).
|
||||||
|
*
|
||||||
|
* Operates on a clone of `doc`; returns `{ doc, inserted }`. `inserted` is false
|
||||||
|
* when the anchor could not be resolved (doc returned unchanged apart from the
|
||||||
|
* clone) or when `nodes` is empty.
|
||||||
|
*/
|
||||||
|
export function insertNodesRelative(
|
||||||
|
doc: any,
|
||||||
|
nodes: any[],
|
||||||
|
opts: InsertOptions,
|
||||||
|
): { doc: any; inserted: boolean } {
|
||||||
|
const out = clone(doc);
|
||||||
|
const fresh = Array.isArray(nodes) ? nodes.map((n) => clone(n)) : [];
|
||||||
|
|
||||||
|
if (!isObject(opts) || fresh.length === 0) {
|
||||||
|
return { doc: out, inserted: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// "append": push every node at the top level, in order.
|
||||||
|
if (opts.position === "append") {
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Resolve by id anywhere in the tree: splice the whole array into the parent.
|
||||||
|
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
|
// Table editing helpers
|
||||||
//
|
//
|
||||||
@@ -773,6 +903,27 @@ function makeFreshId(used: Set<string>): string {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-mint any top-level block id in `blocks` that already exists in `liveDoc`,
|
||||||
|
* so a 1 -> N splice cannot introduce a duplicate id. `skipIndex` (optional) is a
|
||||||
|
* block whose id is intentionally set (the patch path's first block inherits the
|
||||||
|
* target node's id) and must not be re-minted. Mutates `blocks` in place.
|
||||||
|
*/
|
||||||
|
export function reassignCollidingBlockIds(
|
||||||
|
liveDoc: any,
|
||||||
|
blocks: any[],
|
||||||
|
skipIndex?: number,
|
||||||
|
): void {
|
||||||
|
const used = new Set<string>();
|
||||||
|
collectIds(liveDoc, used);
|
||||||
|
blocks.forEach((b, i) => {
|
||||||
|
if (i === skipIndex || !isObject(b)) return;
|
||||||
|
if (!isObject(b.attrs)) b.attrs = {};
|
||||||
|
if (b.attrs.id != null && used.has(b.attrs.id)) b.attrs.id = makeFreshId(used);
|
||||||
|
if (b.attrs.id != null) used.add(b.attrs.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a table reference against an ALREADY-CLONED doc and return the LIVE
|
* 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
|
* table node (a reference inside `rootClone`, so the caller may mutate it) plus
|
||||||
@@ -854,7 +1005,7 @@ function makeCellParagraph(id: string, text: string): any {
|
|||||||
* width.
|
* width.
|
||||||
* - `cells`: `string[][]` of each cell's `blockPlainText`.
|
* - `cells`: `string[][]` of each cell's `blockPlainText`.
|
||||||
* - `cellIds`: `(string|null)[][]` of each cell's FIRST paragraph id (or null),
|
* - `cellIds`: `(string|null)[][]` of each cell's FIRST paragraph id (or null),
|
||||||
* so callers can `patch_node` a cell for rich-formatted edits.
|
* so callers can `patchNode` a cell for rich-formatted edits.
|
||||||
* - `path`: index path of the table within the doc.
|
* - `path`: index path of the table within the doc.
|
||||||
*/
|
*/
|
||||||
export function readTable(
|
export function readTable(
|
||||||
@@ -884,7 +1035,7 @@ export function readTable(
|
|||||||
const rowIds: (string | null)[] = [];
|
const rowIds: (string | null)[] = [];
|
||||||
for (const cellNode of cellNodes) {
|
for (const cellNode of cellNodes) {
|
||||||
rowText.push(blockPlainText(cellNode));
|
rowText.push(blockPlainText(cellNode));
|
||||||
// The cell's first paragraph carries the id used for patch_node.
|
// The cell's first paragraph carries the id used for patchNode.
|
||||||
const firstPara = Array.isArray(cellNode?.content)
|
const firstPara = Array.isArray(cellNode?.content)
|
||||||
? cellNode.content[0]
|
? cellNode.content[0]
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -940,7 +1091,7 @@ export function insertTableRow(
|
|||||||
|
|
||||||
if (Array.isArray(cells) && cells.length > colCount) {
|
if (Array.isArray(cells) && cells.length > colCount) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`table_insert_row: got ${cells.length} cell(s) but the table has ${colCount} column(s)`,
|
`tableInsertRow: got ${cells.length} cell(s) but the table has ${colCount} column(s)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1006,12 +1157,12 @@ export function deleteTableRow(
|
|||||||
|
|
||||||
if (!Number.isInteger(index) || index < 0 || index >= rows) {
|
if (!Number.isInteger(index) || index < 0 || index >= rows) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`table_delete_row: row index ${index} out of range (table has ${rows} row(s))`,
|
`tableDeleteRow: row index ${index} out of range (table has ${rows} row(s))`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (rows <= 1) {
|
if (rows <= 1) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"table_delete_row: refusing to delete the only row of the table",
|
"tableDeleteRow: refusing to delete the only row of the table",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1055,7 +1206,7 @@ export function updateTableCell(
|
|||||||
col < 0 ||
|
col < 0 ||
|
||||||
col >= cols
|
col >= cols
|
||||||
) {
|
) {
|
||||||
throw new Error(`table_update_cell: cell [${row},${col}] out of range`);
|
throw new Error(`tableUpdateCell: cell [${row},${col}] out of range`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cellNode = rowNode.content[col];
|
const cellNode = rowNode.content[col];
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// The model sometimes serializes a ProseMirror node arg as a JSON string
|
// 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
|
// instead of an object. Normalize: parse a string to an object (throwing on
|
||||||
// invalid JSON), pass an object through unchanged. Shared by patch_node /
|
// invalid JSON), pass an object through unchanged. Shared by patchNode /
|
||||||
// insert_node (and the analogous update_page_json content parsing).
|
// insertNode (and the analogous updatePageJson content parsing).
|
||||||
//
|
//
|
||||||
// This lives in the converter package (#414) so BOTH consumers import the ONE
|
// 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
|
// copy: `@docmost/mcp` (ESM) and the CommonJS server app. The server cannot
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
replaceNodeByIdWithMany,
|
||||||
|
insertNodesRelative,
|
||||||
|
} from "../src/lib/node-ops.js";
|
||||||
|
|
||||||
|
// #413: the array-splice helpers used by the markdown patch/insert paths.
|
||||||
|
const p = (id: string, t: string): any => ({
|
||||||
|
type: "paragraph",
|
||||||
|
attrs: { id },
|
||||||
|
content: [{ type: "text", text: t }],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("replaceNodeByIdWithMany", () => {
|
||||||
|
it("splices N nodes in place of the first match, keeping neighbours byte-identical", () => {
|
||||||
|
const before = { type: "doc", content: [p("a", "A"), p("b", "B"), p("c", "C")] };
|
||||||
|
const snap = JSON.parse(JSON.stringify(before));
|
||||||
|
const { doc, replaced } = replaceNodeByIdWithMany(before, "b", [
|
||||||
|
p("b1", "B1"),
|
||||||
|
p("b2", "B2"),
|
||||||
|
]);
|
||||||
|
expect(replaced).toBe(1);
|
||||||
|
expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["a", "b1", "b2", "c"]);
|
||||||
|
// Input never mutated.
|
||||||
|
expect(before).toEqual(snap);
|
||||||
|
// Neighbours byte-identical.
|
||||||
|
expect(doc.content[0]).toEqual(snap.content[0]);
|
||||||
|
expect(doc.content[3]).toEqual(snap.content[2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reaches a nested match (inside a callout) and splices there", () => {
|
||||||
|
const before = {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{ type: "callout", attrs: { id: "co" }, content: [p("x", "X")] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const { doc, replaced } = replaceNodeByIdWithMany(before, "x", [
|
||||||
|
p("x1", "X1"),
|
||||||
|
p("x2", "X2"),
|
||||||
|
]);
|
||||||
|
expect(replaced).toBe(1);
|
||||||
|
expect(doc.content[0].content.map((n: any) => n.attrs.id)).toEqual(["x1", "x2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only touches the FIRST duplicate (caller guards ambiguity)", () => {
|
||||||
|
const before = { type: "doc", content: [p("d", "1"), p("d", "2")] };
|
||||||
|
const { doc, replaced } = replaceNodeByIdWithMany(before, "d", [p("n", "N")]);
|
||||||
|
expect(replaced).toBe(1);
|
||||||
|
// First replaced; the second duplicate survives untouched.
|
||||||
|
expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["n", "d"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports replaced:0 for no match, doc unchanged", () => {
|
||||||
|
const before = { type: "doc", content: [p("a", "A")] };
|
||||||
|
const { doc, replaced } = replaceNodeByIdWithMany(before, "zzz", [p("n", "N")]);
|
||||||
|
expect(replaced).toBe(0);
|
||||||
|
expect(doc).toEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("insertNodesRelative", () => {
|
||||||
|
it("inserts an ordered array after an id anchor", () => {
|
||||||
|
const before = { type: "doc", content: [p("a", "A"), p("b", "B")] };
|
||||||
|
const { doc, inserted } = insertNodesRelative(
|
||||||
|
before,
|
||||||
|
[p("n1", "N1"), p("n2", "N2")],
|
||||||
|
{ position: "after", anchorNodeId: "a" },
|
||||||
|
);
|
||||||
|
expect(inserted).toBe(true);
|
||||||
|
expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["a", "n1", "n2", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts before an id anchor", () => {
|
||||||
|
const before = { type: "doc", content: [p("a", "A"), p("b", "B")] };
|
||||||
|
const { doc } = insertNodesRelative(before, [p("n", "N")], {
|
||||||
|
position: "before",
|
||||||
|
anchorNodeId: "b",
|
||||||
|
});
|
||||||
|
expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["a", "n", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends an ordered array at the top level", () => {
|
||||||
|
const before = { type: "doc", content: [p("a", "A")] };
|
||||||
|
const { doc } = insertNodesRelative(before, [p("n1", "N1"), p("n2", "N2")], {
|
||||||
|
position: "append",
|
||||||
|
});
|
||||||
|
expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["a", "n1", "n2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves an anchor by top-level text", () => {
|
||||||
|
const before = { type: "doc", content: [p("a", "hello there"), p("b", "B")] };
|
||||||
|
const { doc, inserted } = insertNodesRelative(before, [p("n", "N")], {
|
||||||
|
position: "after",
|
||||||
|
anchorText: "hello",
|
||||||
|
});
|
||||||
|
expect(inserted).toBe(true);
|
||||||
|
expect(doc.content.map((n: any) => n.attrs.id)).toEqual(["a", "n", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports inserted:false when the anchor is missing", () => {
|
||||||
|
const before = { type: "doc", content: [p("a", "A")] };
|
||||||
|
const { doc, inserted } = insertNodesRelative(before, [p("n", "N")], {
|
||||||
|
position: "after",
|
||||||
|
anchorNodeId: "missing",
|
||||||
|
});
|
||||||
|
expect(inserted).toBe(false);
|
||||||
|
expect(doc).toEqual(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op for an empty node array", () => {
|
||||||
|
const before = { type: "doc", content: [p("a", "A")] };
|
||||||
|
const { doc, inserted } = insertNodesRelative(before, [], {
|
||||||
|
position: "append",
|
||||||
|
});
|
||||||
|
expect(inserted).toBe(false);
|
||||||
|
expect(doc).toEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user