Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bfb4c8d8d0 | |||
| f794ac6d6c | |||
| e4e788f151 | |||
| 4be4a75fa3 | |||
| e6171a1810 | |||
| d269bd9efe |
@@ -99,6 +99,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
`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
|
||||
|
||||
- **Place several images side by side in a row.** A new "Inline (side by
|
||||
@@ -235,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
|
||||
search terms keep priority over remapped candidates, and short wrong-layout
|
||||
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
|
||||
|
||||
|
||||
@@ -355,23 +355,32 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
|
||||
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();
|
||||
await tools.patchNode.execute(
|
||||
{ pageId: 'p1', nodeId: 'n1', node: JSON.stringify(NODE_OBJ) } as never,
|
||||
{} as never,
|
||||
);
|
||||
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();
|
||||
await tools.patchNode.execute(
|
||||
{ pageId: 'p1', nodeId: 'n1', node: NODE_OBJ } 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 () => {
|
||||
@@ -385,7 +394,7 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
|
||||
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();
|
||||
await tools.insertNode.execute(
|
||||
{
|
||||
@@ -396,9 +405,15 @@ describe('AiChatToolsService node-arg JSON-string coercion', () => {
|
||||
{} as never,
|
||||
);
|
||||
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(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 () => {
|
||||
|
||||
@@ -59,10 +59,11 @@ function __assertClientCallContract(client: DocmostClientLike): void {
|
||||
void client.getWorkspace();
|
||||
void client.getSpaces();
|
||||
void client.listPages(s, n, true);
|
||||
void client.getTree(s, s, n);
|
||||
void client.listSidebarPages(s, s);
|
||||
void client.getOutline(s);
|
||||
void client.getPageJson(s);
|
||||
void client.getNode(s, s);
|
||||
void client.getNode(s, s, 'markdown');
|
||||
void client.searchInPage(s, s, {
|
||||
regex: true,
|
||||
caseSensitive: true,
|
||||
@@ -84,12 +85,16 @@ function __assertClientCallContract(client: DocmostClientLike): void {
|
||||
void client.movePage(s, s, s);
|
||||
void client.deletePage(s);
|
||||
void client.editPageText(s, edits);
|
||||
void client.patchNode(s, s, node);
|
||||
void client.insertNode(s, node, {
|
||||
position: 'append',
|
||||
anchorNodeId: s,
|
||||
anchorText: s,
|
||||
});
|
||||
void client.patchNode(s, s, { markdown: s, node });
|
||||
void client.insertNode(
|
||||
s,
|
||||
{ markdown: s, node },
|
||||
{
|
||||
position: 'append',
|
||||
anchorNodeId: s,
|
||||
anchorText: s,
|
||||
},
|
||||
);
|
||||
void client.deleteNode(s, s);
|
||||
void client.updatePageJson(s, node, s);
|
||||
void client.tableInsertRow(s, s, cells, n);
|
||||
|
||||
@@ -23,6 +23,7 @@ type DocmostClientMethod =
|
||||
| 'getWorkspace'
|
||||
| 'getSpaces'
|
||||
| 'listPages'
|
||||
| 'getTree'
|
||||
| 'listSidebarPages'
|
||||
| 'getOutline'
|
||||
| 'getPageJson'
|
||||
|
||||
@@ -12,3 +12,22 @@ export class SearchResponseDto {
|
||||
updatedAt: Date;
|
||||
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()
|
||||
@IsNumber()
|
||||
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 {
|
||||
|
||||
@@ -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') {
|
||||
return this.searchTypesense(searchDto, {
|
||||
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 { 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 { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import { sql } from 'kysely';
|
||||
@@ -34,6 +37,53 @@ export function buildTsQuery(raw: string): string {
|
||||
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()
|
||||
export class SearchService {
|
||||
constructor(
|
||||
@@ -50,12 +100,19 @@ export class SearchService {
|
||||
userId?: string;
|
||||
workspaceId: string;
|
||||
},
|
||||
): Promise<{ items: SearchResponseDto[] }> {
|
||||
): Promise<{ items: SearchResponseDto[] | SearchLookupResponseDto[] }> {
|
||||
const { query } = searchParams;
|
||||
|
||||
if (query.length < 1) {
|
||||
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);
|
||||
|
||||
let queryResults = this.db
|
||||
@@ -175,6 +232,348 @@ export class SearchService {
|
||||
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(
|
||||
suggestion: SearchSuggestionDTO,
|
||||
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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+18
-11
@@ -40,7 +40,7 @@ There are several Docmost MCPs. Here is a capability-by-capability comparison.
|
||||
| **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** |
|
||||
| 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) | ✅ | — | — | — | — |
|
||||
| **Compact page outline** (cheap block-id lookup) | ✅ | — | — | — | — |
|
||||
| **Fetch a single block** (by id or index) | ✅ | — | — | — | — |
|
||||
@@ -115,8 +115,10 @@ All 41 tools, grouped by what you'd reach for them.
|
||||
- **`listPages`** — Recent pages in a space, ordered by `updatedAt` desc (default 50,
|
||||
max 100). Use `search` for lookups in large spaces.
|
||||
- **`search`** — Full-text search across pages and content (bounded by `limit`, max 100).
|
||||
- **`getPage`** — A page's content as clean **Markdown** (convenient, but a *lossy*
|
||||
view — block ids and exact table/callout structure are approximated).
|
||||
- **`getPage`** — A page's content as clean **Markdown** (canonical for text; drops only
|
||||
block ids, resolved-comment anchors, and a fixed no-Markdown-representation attr set —
|
||||
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
|
||||
tools consume.
|
||||
@@ -186,10 +188,14 @@ All 41 tools, grouped by what you'd reach for them.
|
||||
|
||||
### Markdown round-trip
|
||||
|
||||
- **`exportPageMarkdown`** — Export a page to a single self-contained, **lossless
|
||||
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
|
||||
authoring Markdown, use `updatePageMarkdown`.
|
||||
- **`exportPageMarkdown`** — Export a page to a single self-contained
|
||||
**Docmost-flavoured Markdown** file: a meta header, the body with inline comment anchors
|
||||
and diagrams, and a trailing comments-thread block. The download → edit → import
|
||||
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:** `importPageMarkdown` (the round-trip parser for an
|
||||
> exported Docmost-Markdown file) is **no longer exposed on the external MCP surface**.
|
||||
@@ -293,15 +299,16 @@ so capable clients steer the model automatically.
|
||||
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
|
||||
triggers a single re-login.
|
||||
- **Lossless and lossy reads.** `getPageJson` returns the exact ProseMirror tree with
|
||||
block ids; `getPage` returns clean Markdown for convenience.
|
||||
- **Precise reads.** `getPageJson` returns the exact ProseMirror tree with block ids;
|
||||
`getPage` returns canonical Markdown that drops only a fixed, documented attr set.
|
||||
- **Full Docmost schema.** Markdown↔ProseMirror conversion supports callouts (including
|
||||
nested), task lists (bullet *and* numbered checklists), tables, math blocks, embeds,
|
||||
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
|
||||
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
|
||||
actually need, and large collections (spaces, pages, comments, history) are paginated.
|
||||
- **Hardened runtime.** Global handlers keep a stray socket error from tearing down the
|
||||
|
||||
+21
-11
@@ -43,7 +43,7 @@ Docmost-MCP не сочетают:
|
||||
| **Нужна enterprise-лицензия** | **Нет** | **Да** | Нет | Нет | Нет |
|
||||
| Аутентификация | email + пароль, **авто-переавторизация** | API-ключ | email + пароль | cookie `authToken` (копировать из DevTools) | API Docmost / **напрямую PostgreSQL** |
|
||||
| Чтение страницы как Markdown | ✅ | ✅ | ✅ | ✅ | ✅ (только чтение) |
|
||||
| **Lossless Markdown round-trip** (экспорт/импорт, сохраняет якоря комментариев) | ✅ | — | — | — | — |
|
||||
| **Markdown round-trip** (экспорт/импорт, сохраняет якоря комментариев) | ✅ | — | — | — | — |
|
||||
| Чтение **lossless ProseMirror JSON** (с id блоков) | ✅ | — | — | — | — |
|
||||
| **Компактная структура страницы** (дешёвый поиск id блока) | ✅ | — | — | — | — |
|
||||
| **Получение одного блока** (по id или индексу) | ✅ | — | — | — | — |
|
||||
@@ -119,8 +119,11 @@ Docmost-MCP не сочетают:
|
||||
50, максимум 100). Для поиска в больших пространствах используйте `search`.
|
||||
- **`search`** — Полнотекстовый поиск по страницам и контенту (ограничен `limit`, максимум
|
||||
100).
|
||||
- **`getPage`** — Контент страницы как чистый **Markdown** (удобно, но это
|
||||
*lossy*-представление — id блоков и точная структура таблиц/коллаутов аппроксимируются).
|
||||
- **`getPage`** — Контент страницы как чистый **Markdown** (канонично для текста; теряет
|
||||
лишь id блоков, якоря разрешённых комментариев и фиксированный набор атрибутов без
|
||||
markdown-представления — спаны/colwidth/фон ячеек таблиц, отступы (indent),
|
||||
`callout.icon`, `orderedList.type` и `internal`/`target`/`rel`/`class` у ссылок;
|
||||
используйте `getPageJson`, когда они нужны).
|
||||
- **`getPageJson`** — **Lossless ProseMirror/TipTap JSON** страницы, включая `attrs.id`
|
||||
каждого блока и `slugId`, используемый в URL. Именно его потребляют инструменты
|
||||
поблочного редактирования.
|
||||
@@ -191,10 +194,14 @@ Docmost-MCP не сочетают:
|
||||
|
||||
### Markdown: экспорт и импорт
|
||||
|
||||
- **`exportPageMarkdown`** — Экспортировать страницу в один самодостаточный, **lossless
|
||||
Markdown в диалекте Docmost**: мета-заголовок, тело с inline-якорями комментариев и
|
||||
диаграммами и завершающий блок тредов комментариев. Чтобы заменить тело страницы из
|
||||
обычного авторского Markdown, используйте `updatePageMarkdown`.
|
||||
- **`exportPageMarkdown`** — Экспортировать страницу в один самодостаточный
|
||||
**Markdown в диалекте Docmost**: мета-заголовок, тело с inline-якорями комментариев и
|
||||
диаграммами и завершающий блок тредов комментариев. Round-trip скачать → отредактировать →
|
||||
импортировать перегенерирует id блоков и **молча отбрасывает** набор атрибутов без
|
||||
markdown-представления (спаны/colwidth/фон ячеек таблиц, отступы (indent), `callout.icon`,
|
||||
`orderedList.type`, `internal`/`target`/`rel`/`class` у ссылок); держите их в ProseMirror
|
||||
JSON, если они должны выжить. Чтобы заменить тело страницы из обычного авторского Markdown,
|
||||
используйте `updatePageMarkdown`.
|
||||
|
||||
> **Удалено в этом релизе:** `importPageMarkdown` (парсер round-trip для
|
||||
> экспортированного Docmost-Markdown-файла) **больше не отдаётся на внешней MCP-поверхности**.
|
||||
@@ -302,16 +309,19 @@ Docmost-MCP не сочетают:
|
||||
автоматически на первом 401/403 (покрывая JSON, multipart-загрузку и путь токена
|
||||
коллаборации), с дедупликацией параллельных логинов, так что пачка вызовов вызывает один
|
||||
повторный логин.
|
||||
- **Lossless- и lossy-чтение.** `getPageJson` возвращает точное дерево ProseMirror с id
|
||||
блоков; `getPage` возвращает чистый Markdown для удобства.
|
||||
- **Точные чтения.** `getPageJson` возвращает точное дерево ProseMirror с id блоков;
|
||||
`getPage` возвращает канонический Markdown, теряющий лишь фиксированный, документированный
|
||||
набор атрибутов.
|
||||
- **Полная схема Docmost.** Конвертация Markdown↔ProseMirror поддерживает коллауты
|
||||
(включая вложенные), списки задач (маркированные *и* нумерованные чек-листы), таблицы,
|
||||
блоки формул, эмбеды, выделение, под/надстрочный текст и прочее, с защитными лимитами
|
||||
против патологического ввода.
|
||||
- **Структурные таблицы и lossless Markdown round-trip.** Таблицы можно редактировать как
|
||||
- **Структурные таблицы и Markdown round-trip.** Таблицы можно редактировать как
|
||||
матрицу (чтение, вставка/удаление строк, задание ячеек по `[row, col]`) без пересылки
|
||||
документа, а страницу — экспортировать и заново импортировать как самодостаточный
|
||||
Markdown-файл в диалекте Docmost, сохраняющий inline-якоря комментариев и диаграммы.
|
||||
Markdown-файл в диалекте Docmost, сохраняющий inline-якоря комментариев и диаграммы
|
||||
(id блоков перегенерируются, а фиксированный набор атрибутов без markdown-представления
|
||||
отбрасывается — см. `exportPageMarkdown`).
|
||||
- **Ответы, оптимизированные по токенам.** Ответы API урезаются до полей, действительно
|
||||
нужных агентам, а большие коллекции (пространства, страницы, комментарии, история)
|
||||
пагинируются.
|
||||
|
||||
+343
-39
@@ -32,9 +32,12 @@ import {
|
||||
} from "./lib/markdown-document.js";
|
||||
import {
|
||||
replaceNodeById,
|
||||
replaceNodeByIdWithMany,
|
||||
reassignCollidingBlockIds,
|
||||
deleteNodeById,
|
||||
assertUnambiguousMatch,
|
||||
insertNodeRelative,
|
||||
insertNodesRelative,
|
||||
blockPlainText,
|
||||
buildOutline,
|
||||
getNodeByRef,
|
||||
@@ -44,6 +47,11 @@ import {
|
||||
updateTableCell,
|
||||
findInvalidNode,
|
||||
} from "@docmost/prosemirror-markdown";
|
||||
import {
|
||||
importMarkdownFragment,
|
||||
canBeDocChild,
|
||||
findUnrepresentableTableAttrs,
|
||||
} from "./lib/markdown-fragment.js";
|
||||
import { searchInDoc, SearchOptions } from "./lib/page-search.js";
|
||||
import { withPageLock } from "./lib/page-lock.js";
|
||||
import {
|
||||
@@ -83,6 +91,7 @@ import {
|
||||
commentsToFootnotes,
|
||||
canonicalizeFootnotes,
|
||||
insertInlineFootnote,
|
||||
mergeFootnoteDefinitions,
|
||||
} from "./lib/transforms.js";
|
||||
import { normalizeAndMergeFootnotes } from "./lib/footnote-normalize-merge.js";
|
||||
import vm from "node:vm";
|
||||
@@ -802,13 +811,17 @@ export class DocmostClient {
|
||||
* large instances, so a single bounded page of results is returned (default
|
||||
* 50, max 100) via the `/pages/recent` feed.
|
||||
*
|
||||
* Tree (`tree` true): the space's FULL page hierarchy as a nested tree (each
|
||||
* node has a `children` array). This mode REQUIRES `spaceId` (a page tree is
|
||||
* Tree (`tree` true): DEPRECATED — prefer `getTree`, which shares this exact
|
||||
* code path (a single `/pages/tree` request via `enumerateSpacePages` +
|
||||
* `buildPageTree`) but returns the compact `{pageId, title, children?,
|
||||
* hasChildren?}` shape and supports `rootPageId`/`maxDepth`. This tree mode is
|
||||
* kept for backward compatibility; it REQUIRES `spaceId` (a page tree is
|
||||
* scoped to one space) and IGNORES `limit` — the whole hierarchy is returned.
|
||||
* It fetches the tree via `enumerateSpacePages`, which on the fork server
|
||||
* resolves to a single `/pages/tree` request returning the whole
|
||||
* permission-filtered flat page set (soft-deleted pages excluded
|
||||
* server-side).
|
||||
* server-side); the cursor-BFS in `enumerateSpacePages` is only a fallback for
|
||||
* stock upstream servers that lack `/pages/tree`.
|
||||
*/
|
||||
async listPages(spaceId?: string, limit: number = 50, tree: boolean = false) {
|
||||
await this.ensureAuthenticated();
|
||||
@@ -832,6 +845,39 @@ export class DocmostClient {
|
||||
return items.map((page: any) => filterPage(page));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a space's page hierarchy (or one subtree) as a nested tree in a SINGLE
|
||||
* request — the #443 `getTree` tool. Shares its whole code path with
|
||||
* `listPages(tree:true)`: `enumerateSpacePages` issues one `POST /pages/tree`
|
||||
* (with the cursor-BFS only as a fallback for stock upstream servers that lack
|
||||
* the endpoint), then `buildPageTree` nests the flat, permission-filtered,
|
||||
* position-ordered list. No second tree fetch, no per-node BFS.
|
||||
*
|
||||
* - `rootPageId` — restrict to that page's subtree; the server seeds the CTE
|
||||
* with the page itself, so the result is exactly ONE root (the page and its
|
||||
* descendants). Omit it for the whole space.
|
||||
* - `maxDepth` — trim the response to that many levels (roots = depth 1) to
|
||||
* save tokens; the server still returns everything in one request, the cut
|
||||
* is applied in `buildPageTree` AFTER the full tree is built. A node whose
|
||||
* children were cut carries `hasChildren: true` (source of truth = the flat
|
||||
* item's server `hasChildren`) so the caller can descend with a follow-up
|
||||
* `getTree(spaceId, rootPageId=that node)` call.
|
||||
*
|
||||
* Output nodes are `{pageId, title, children?, hasChildren?}` — only the UUID
|
||||
* `pageId` is exposed (never `slugId`/`icon`/`position`). Requires `spaceId`
|
||||
* (a page tree is scoped to one space).
|
||||
*/
|
||||
async getTree(spaceId: string, rootPageId?: string, maxDepth?: number) {
|
||||
await this.ensureAuthenticated();
|
||||
if (!spaceId) {
|
||||
throw new Error(
|
||||
"getTree: spaceId is required (a page tree is scoped to one space).",
|
||||
);
|
||||
}
|
||||
const { pages } = await this.enumerateSpacePages(spaceId, rootPageId);
|
||||
return buildPageTree(pages, { shape: "getTree", maxDepth });
|
||||
}
|
||||
|
||||
/**
|
||||
* List sidebar pages for a space. With no pageId the request returns the
|
||||
* space ROOT pages; with a pageId it returns the direct CHILDREN of that
|
||||
@@ -1298,12 +1344,31 @@ export class DocmostClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single node's full ProseMirror subtree (lossless) by reference:
|
||||
* a block id (headings/paragraphs/callouts/images), or `#<index>` to select
|
||||
* a top-level block by its outline index (the only way to reach tables/rows/
|
||||
* cells, which carry no id).
|
||||
* Fetch a single block for editing by reference: a block id (headings/
|
||||
* paragraphs/callouts/images), or `#<index>` to select a top-level block by its
|
||||
* outline index (the only way to reach tables/rows/cells, which carry no id).
|
||||
*
|
||||
* `format` (#413):
|
||||
* - `"markdown"` (DEFAULT): serialize the block via the canonical converter
|
||||
* (`{type:"doc",content:[node]}` -> `convertProseMirrorToMarkdown`) — a read
|
||||
* "for editing": pair it with `patchNode({markdown})` to rewrite the block.
|
||||
* Comment anchors (`<span data-comment-id>`, INCLUDING resolved ones) are
|
||||
* NOT stripped here (unlike getPage): losing them on write-back would
|
||||
* orphan the thread. Returns `{ ..., format:"markdown", markdown }`.
|
||||
* - `"json"`: return the raw ProseMirror subtree as-is (lossless; the previous
|
||||
* default). Returns `{ ..., format:"json", node }`.
|
||||
*
|
||||
* AUTO fallback: a type that cannot be a document top-level child
|
||||
* (tableRow/tableCell/tableHeader, addressed by `#<index>`) is NOT expressible
|
||||
* as a standalone markdown document, so a `"markdown"` request for such a node
|
||||
* transparently falls back to JSON with an explicit `format:"json"` field. The
|
||||
* check derives from the schema's `doc` contentMatch, so it tracks the schema.
|
||||
*/
|
||||
async getNode(pageId: string, nodeId: string) {
|
||||
async getNode(
|
||||
pageId: string,
|
||||
nodeId: string,
|
||||
format: "markdown" | "json" = "markdown",
|
||||
) {
|
||||
await this.ensureAuthenticated();
|
||||
const data = await this.getPageRaw(pageId);
|
||||
const hit = getNodeByRef(
|
||||
@@ -1315,12 +1380,35 @@ export class DocmostClient {
|
||||
`getNode: no node found for "${nodeId}" on page ${pageId} (use a block id from getOutline, or "#<index>" for a top-level block such as a table)`,
|
||||
);
|
||||
}
|
||||
|
||||
// JSON requested (or a non-top-level type that markdown cannot represent as a
|
||||
// standalone document): return the subtree verbatim.
|
||||
if (format === "json" || !canBeDocChild(hit.type)) {
|
||||
return {
|
||||
pageId,
|
||||
ref: nodeId,
|
||||
path: hit.path,
|
||||
type: hit.type,
|
||||
format: "json" as const,
|
||||
node: hit.node,
|
||||
};
|
||||
}
|
||||
|
||||
// Markdown: wrap the node as a one-block doc and run the canonical converter.
|
||||
// Comment anchors are DELIBERATELY preserved (converter default) so a
|
||||
// getNode(markdown) -> edit -> patchNode(markdown) round trip does not orphan
|
||||
// a comment thread; this differs from getPage, which strips them.
|
||||
const markdown = convertProseMirrorToMarkdown({
|
||||
type: "doc",
|
||||
content: [hit.node],
|
||||
});
|
||||
return {
|
||||
pageId,
|
||||
ref: nodeId,
|
||||
path: hit.path,
|
||||
type: hit.type,
|
||||
node: hit.node,
|
||||
format: "markdown" as const,
|
||||
markdown,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2307,17 +2395,61 @@ export class DocmostClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace EVERY node whose attrs.id === nodeId (recursively, including nodes
|
||||
* nested in callouts/tables) with the supplied node. Operates on the LIVE
|
||||
* collab document so comments and concurrent edits are preserved.
|
||||
* Replace the block whose attrs.id === nodeId. Operates on the LIVE collab
|
||||
* document so comments and concurrent edits are preserved.
|
||||
*
|
||||
* The replacement node's block id is preserved: if node.attrs is missing it
|
||||
* is created, and if node.attrs.id is missing it is set to nodeId so the
|
||||
* replacement keeps the same id it replaced. Throws if no node matches.
|
||||
* Exactly one of `input.markdown` / `input.node` (#413):
|
||||
* - `markdown` (RECOMMENDED): the block is rewritten from a canonical markdown
|
||||
* fragment. The fragment may import to N blocks (a "1 -> N" splice: rewrite a
|
||||
* whole section in one call). The FIRST resulting block INHERITS the target's
|
||||
* `attrs.id` (so an existing comment anchoring the block by id survives); the
|
||||
* rest get FRESH ids. `^[...]` footnotes in the fragment are first-class:
|
||||
* their definitions merge into the page's TAIL footnote list (content-key
|
||||
* dedup + canonicalize), same machinery insertFootnote uses. REJECTED when
|
||||
* the TARGET block carries a table-cell attribute markdown cannot represent
|
||||
* (colspan/rowspan/colwidth/background) — use the table tools or `node`.
|
||||
* - `node`: a raw ProseMirror node for precise attr/mark work. The replacement
|
||||
* keeps the target id (if `node.attrs.id` is missing it is set to nodeId).
|
||||
*
|
||||
* #159 ambiguous-id semantics are unchanged: 0 matches -> "no node"; >1 matches
|
||||
* -> "ambiguous, refused" (nothing written), on BOTH paths — the markdown path
|
||||
* runs a dry `replaceNodeById` count first, so a duplicated id never splices.
|
||||
*/
|
||||
async patchNode(pageId: string, nodeId: string, node: any) {
|
||||
async patchNode(
|
||||
pageId: string,
|
||||
nodeId: string,
|
||||
input: { markdown?: string; node?: any },
|
||||
) {
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
// XOR: exactly one of markdown / node. Both optional in the schema; the
|
||||
// runtime enforces the recommendation ("markdown for prose, node for fine
|
||||
// work") without letting an ambiguous both-or-neither call through.
|
||||
const hasMd =
|
||||
input != null &&
|
||||
typeof input.markdown === "string" &&
|
||||
input.markdown.trim() !== "";
|
||||
const hasNode = input != null && input.node != null;
|
||||
if (hasMd === hasNode) {
|
||||
throw new Error(
|
||||
"patchNode: provide exactly one of `markdown` (recommended, for prose) " +
|
||||
"or `node` (a raw ProseMirror node, for precise attr/mark work)",
|
||||
);
|
||||
}
|
||||
|
||||
if (hasMd) {
|
||||
return this.patchNodeMarkdown(pageId, nodeId, input.markdown as string);
|
||||
}
|
||||
return this.patchNodeJson(pageId, nodeId, input.node);
|
||||
}
|
||||
|
||||
/**
|
||||
* patchNode with a raw ProseMirror `node` (the pre-#413 behavior). Replaces
|
||||
* EVERY node whose attrs.id === nodeId; the swapped-in node keeps the target
|
||||
* id. #159 ambiguity refused. Split out so the markdown path can reuse the
|
||||
* shared collab/guard plumbing without a giant branch.
|
||||
*/
|
||||
private async patchNodeJson(pageId: string, nodeId: string, node: any) {
|
||||
if (!node || typeof node !== "object" || typeof node.type !== "string") {
|
||||
throw new Error(
|
||||
"patchNode: `node` must be an object with a string `type`",
|
||||
@@ -2382,22 +2514,143 @@ export class DocmostClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a node relative to an anchor (or append it at the top level).
|
||||
* patchNode with a MARKDOWN fragment (#413). Imports the fragment through the
|
||||
* canonical importer, then 1 -> N splices the resulting blocks in place of the
|
||||
* target block on the LIVE collab doc:
|
||||
* - the FIRST block inherits the target's id; the rest get FRESH ids (minted
|
||||
* by the importer/id-remap, so neighbour blocks are untouched);
|
||||
* - `^[...]` footnote definitions merge into the page's tail list;
|
||||
* - REJECTED when the target block carries a markdown-unrepresentable table
|
||||
* attr (colspan/rowspan/colwidth/background) — guarding against silent loss;
|
||||
* - #159 ambiguity is enforced by a dry `replaceNodeById` count BEFORE the
|
||||
* splice, so a duplicated id never writes.
|
||||
*/
|
||||
private async patchNodeMarkdown(
|
||||
pageId: string,
|
||||
nodeId: string,
|
||||
markdown: string,
|
||||
) {
|
||||
// Import the fragment up front (network-free, canonical) so a bad fragment
|
||||
// fails before any collab connection or page lock.
|
||||
const { blocks, definitions } = await importMarkdownFragment(markdown);
|
||||
|
||||
// The first imported block inherits the target id; the rest keep the fresh
|
||||
// ids the importer assigned. Build the thread now so it is stable across a
|
||||
// collab retry (the transform below is pure over its inputs).
|
||||
const threaded = blocks.map((b, i) => {
|
||||
if (i !== 0) return b;
|
||||
return {
|
||||
...b,
|
||||
attrs: {
|
||||
...(b && typeof b.attrs === "object" ? b.attrs : {}),
|
||||
id: nodeId,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Shape-validate every imported block up front (parity with the JSON path):
|
||||
// the importer only emits schema nodes, but the check is cheap insurance and
|
||||
// yields the same rich #409 diagnostics if the schema ever drifts.
|
||||
for (const b of threaded) {
|
||||
this.assertValidNodeShape("patchNode", b);
|
||||
}
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
|
||||
let replaced = 0;
|
||||
let guardAttrs: string | null = null;
|
||||
const mutation = await mutatePageContent(
|
||||
pageUuid,
|
||||
collabToken,
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
replaced = 0;
|
||||
guardAttrs = null;
|
||||
|
||||
// #159: count matches with the same recursive walk the JSON path uses;
|
||||
// only an UNAMBIGUOUS single match may write. A dry count keeps the
|
||||
// ambiguity semantics identical across both paths.
|
||||
const { replaced: count } = replaceNodeById(liveDoc, nodeId, {
|
||||
type: "paragraph",
|
||||
});
|
||||
replaced = count;
|
||||
if (count !== 1) return null;
|
||||
|
||||
// Guard against SILENT LOSS: if the target block carries a table-cell
|
||||
// attribute markdown cannot represent (colspan/rowspan/colwidth/
|
||||
// background), refuse the markdown rewrite so those attrs are not
|
||||
// dropped. Simple tables (no such attrs) rewrite fine.
|
||||
const hit = getNodeByRef(liveDoc, nodeId);
|
||||
guardAttrs = hit ? findUnrepresentableTableAttrs(hit.node) : null;
|
||||
if (guardAttrs != null) return null;
|
||||
|
||||
// Re-mint any minted block id that collides with an existing page id
|
||||
// (skip index 0: its id is intentionally the target nodeId, unique by
|
||||
// the #159 dry-count above), so the 1 -> N splice stays page-wide unique.
|
||||
reassignCollidingBlockIds(liveDoc, threaded, 0);
|
||||
|
||||
// 1 -> N splice, then merge any fragment footnote definitions into the
|
||||
// page's tail list and re-derive canonical footnote numbering.
|
||||
const { doc: spliced } = replaceNodeByIdWithMany(
|
||||
liveDoc,
|
||||
nodeId,
|
||||
threaded,
|
||||
);
|
||||
return mergeFootnoteDefinitions(spliced, definitions);
|
||||
},
|
||||
);
|
||||
|
||||
// Surface the guard rejection with an actionable message (nothing written).
|
||||
if (guardAttrs != null) {
|
||||
throw new Error(
|
||||
`patchNode: the target block has table-cell attributes markdown cannot ` +
|
||||
`represent (${guardAttrs}) — a markdown rewrite would drop them. Use ` +
|
||||
`the table tools (tableUpdateCell/tableInsertRow) or pass a raw ` +
|
||||
`ProseMirror \`node\` instead of \`markdown\`.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 0 -> "no node"; >1 -> "ambiguous, refused" (the transform skipped the write
|
||||
// for any count !== 1). Shared #159 guard, identical to the JSON path.
|
||||
assertUnambiguousMatch("patchNode", "replace", replaced, nodeId, pageId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
replaced,
|
||||
nodeId,
|
||||
blocks: threaded.length,
|
||||
verify: mutation.verify,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert content relative to an anchor (or append it at the top level).
|
||||
* Operates on the LIVE collab document so comments and concurrent edits are
|
||||
* preserved.
|
||||
*
|
||||
* Exactly one of `input.markdown` / `input.node` (#413):
|
||||
* - `markdown` (RECOMMENDED): a canonical markdown fragment. It may import to
|
||||
* SEVERAL blocks — they are inserted IN ORDER at the anchor. `^[...]`
|
||||
* footnote definitions merge into the page's tail list (same machinery as
|
||||
* insertFootnote). Every inserted block gets a fresh id.
|
||||
* - `node`: a raw ProseMirror node for precise attr/mark work, or to insert
|
||||
* table structure (a bare tableRow/tableCell/tableHeader — NOT expressible in
|
||||
* markdown, so those stay JSON-only).
|
||||
*
|
||||
* opts.position:
|
||||
* - "append": push the node at the end of the top-level content.
|
||||
* - "before"/"after": insert the node as a sibling of the anchor, just
|
||||
* before/after it. Exactly one of anchorNodeId / anchorText must be given;
|
||||
* anchorNodeId locates a node anywhere by attrs.id, anchorText matches the
|
||||
* first top-level block whose plain text includes it.
|
||||
* - "append": push the content at the end of the top-level content.
|
||||
* - "before"/"after": insert as a sibling of the anchor, just before/after it.
|
||||
* Exactly one of anchorNodeId / anchorText must be given; anchorNodeId
|
||||
* locates a node anywhere by attrs.id, anchorText matches the first top-level
|
||||
* block whose plain text includes it.
|
||||
*
|
||||
* Throws if the anchor cannot be found.
|
||||
*/
|
||||
async insertNode(
|
||||
pageId: string,
|
||||
node: any,
|
||||
input: { markdown?: string; node?: any },
|
||||
opts: {
|
||||
position: "before" | "after" | "append";
|
||||
anchorNodeId?: string;
|
||||
@@ -2406,11 +2659,19 @@ export class DocmostClient {
|
||||
) {
|
||||
await this.ensureAuthenticated();
|
||||
|
||||
if (!node || typeof node !== "object" || typeof node.type !== "string") {
|
||||
// XOR: exactly one of markdown / node (both optional in the schema).
|
||||
const hasMd =
|
||||
input != null &&
|
||||
typeof input.markdown === "string" &&
|
||||
input.markdown.trim() !== "";
|
||||
const hasNode = input != null && input.node != null;
|
||||
if (hasMd === hasNode) {
|
||||
throw new Error(
|
||||
"insertNode: `node` must be an object with a string `type`",
|
||||
"insertNode: provide exactly one of `markdown` (recommended, for prose) " +
|
||||
"or `node` (a raw ProseMirror node, for precise attr/mark work or table structure)",
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
!opts ||
|
||||
(opts.position !== "before" &&
|
||||
@@ -2434,10 +2695,32 @@ export class DocmostClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the ordered list of blocks to insert plus any footnote definitions
|
||||
// to merge. The markdown path imports canonically (so an inserted block is
|
||||
// byte-identical to the same content in a full-page import); the node path is
|
||||
// a single block with no footnote merge (raw JSON `^[...]` is not touched).
|
||||
let blocks: any[];
|
||||
let definitions: any[] = [];
|
||||
if (hasMd) {
|
||||
const frag = await importMarkdownFragment(input.markdown as string);
|
||||
blocks = frag.blocks;
|
||||
definitions = frag.definitions;
|
||||
} else {
|
||||
const node = input.node;
|
||||
if (!node || typeof node !== "object" || typeof node.type !== "string") {
|
||||
throw new Error(
|
||||
"insertNode: `node` must be an object with a string `type`",
|
||||
);
|
||||
}
|
||||
blocks = [node];
|
||||
}
|
||||
|
||||
// #409: fail fast on a malformed node SHAPE (a nested child with an
|
||||
// absent/unknown `type`) BEFORE opening a collab session or taking the page
|
||||
// lock — the root-only check above never sees nested children.
|
||||
this.assertValidNodeShape("insertNode", node);
|
||||
for (const b of blocks) {
|
||||
this.assertValidNodeShape("insertNode", b);
|
||||
}
|
||||
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||
@@ -2452,14 +2735,20 @@ export class DocmostClient {
|
||||
this.apiUrl,
|
||||
(liveDoc) => {
|
||||
inserted = false;
|
||||
const { doc: nd, inserted: ins } = insertNodeRelative(
|
||||
liveDoc,
|
||||
node,
|
||||
opts,
|
||||
);
|
||||
inserted = ins;
|
||||
// Re-mint any minted block id that collides with an existing page id
|
||||
// (all inserted blocks are fresh, no skip) so the splice stays unique.
|
||||
if (hasMd) reassignCollidingBlockIds(liveDoc, blocks);
|
||||
// Single-block node path keeps `insertNodeRelative` (it owns the
|
||||
// structural table-node splicing); the markdown path uses the array
|
||||
// splice so N blocks land in order at one anchor.
|
||||
const res = hasMd
|
||||
? insertNodesRelative(liveDoc, blocks, opts)
|
||||
: insertNodeRelative(liveDoc, blocks[0], opts);
|
||||
inserted = res.inserted;
|
||||
if (!inserted) return null; // anchor not found -> skip the write entirely
|
||||
return nd;
|
||||
// Merge any fragment footnote definitions into the page tail list and
|
||||
// re-derive canonical numbering (no-op when there are none).
|
||||
return mergeFootnoteDefinitions(res.doc, definitions);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2482,6 +2771,7 @@ export class DocmostClient {
|
||||
success: true,
|
||||
inserted: true,
|
||||
position: opts.position,
|
||||
blocks: blocks.length,
|
||||
verify: mutation.verify,
|
||||
};
|
||||
}
|
||||
@@ -2577,14 +2867,28 @@ export class DocmostClient {
|
||||
return { success: true, removedShareId: share.shareId, pageId };
|
||||
}
|
||||
|
||||
async search(query: string, spaceId?: string, limit?: number) {
|
||||
async search(
|
||||
query: string,
|
||||
spaceId?: string,
|
||||
limit?: number,
|
||||
opts: { parentPageId?: string; titleOnly?: boolean } = {},
|
||||
) {
|
||||
await this.ensureAuthenticated();
|
||||
const payload: Record<string, any> = { query, spaceId };
|
||||
// Clamp an optional caller-supplied limit into a sane 1..100 range before
|
||||
// forwarding it to the server; omit it entirely when not provided so the
|
||||
// server applies its own default.
|
||||
// Opt into the #443 agent-lookup mode: `substring: true` turns on the hybrid
|
||||
// substring + FTS branch that returns path + snippet + score. A stock
|
||||
// upstream server strips these unknown DTO fields (whitelist:true) and
|
||||
// silently degrades to plain FTS — see the tool-registration comment.
|
||||
const payload: Record<string, any> = {
|
||||
query,
|
||||
spaceId,
|
||||
substring: true,
|
||||
};
|
||||
if (opts.parentPageId) payload.parentPageId = opts.parentPageId;
|
||||
if (opts.titleOnly) payload.titleOnly = true;
|
||||
// Clamp an optional caller-supplied limit into the lookup range (1..50)
|
||||
// before forwarding; omit it when not provided so the server default applies.
|
||||
if (limit !== undefined) {
|
||||
payload.limit = Math.max(1, Math.min(100, limit));
|
||||
payload.limit = Math.max(1, Math.min(50, limit));
|
||||
}
|
||||
const response = await this.client.post("/search", payload);
|
||||
|
||||
|
||||
+44
-11
@@ -435,30 +435,63 @@ server.registerTool(
|
||||
// Tool: search
|
||||
// INTENTIONAL per-transport divergence (not shared): the in-app `searchPages`
|
||||
// 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
|
||||
// (limit up to 100). Different behaviour AND schema, so kept per-layer.
|
||||
// different schema; this transport is the #443 agent-lookup search — a hybrid
|
||||
// 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(
|
||||
"search",
|
||||
{
|
||||
description:
|
||||
"Full-text search for pages and content across the whole workspace. " +
|
||||
"Results are bounded by `limit` (1-100; when omitted the server applies " +
|
||||
"its own default).",
|
||||
"Find pages by a fragment of a technical string (hostnames, IPs, IDs " +
|
||||
"like `srv.local`, `10.0.12`, `WB-MGE-30D86B`) — one call returns each " +
|
||||
"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: {
|
||||
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
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.max(50)
|
||||
.optional()
|
||||
.describe("Max results to return (max 100)"),
|
||||
.describe("Max results to return (1-50, default 10)"),
|
||||
},
|
||||
},
|
||||
async ({ query, limit }) => {
|
||||
// The tool exposes no spaceId filter, so pass undefined for the client's
|
||||
// optional spaceId parameter and forward limit into its correct slot.
|
||||
const result = await docmostClient.search(query, undefined, limit);
|
||||
async ({ query, spaceId, parentPageId, titleOnly, limit }) => {
|
||||
const result = await docmostClient.search(query, spaceId, limit, {
|
||||
parentPageId,
|
||||
titleOnly,
|
||||
});
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
return {
|
||||
id: result.id,
|
||||
pageId: result.id,
|
||||
title: result.title,
|
||||
parentPageId: result.parentPageId,
|
||||
createdAt: result.createdAt,
|
||||
updatedAt: result.updatedAt,
|
||||
rank: result.rank,
|
||||
highlight: result.highlight,
|
||||
spaceId: result.space?.id,
|
||||
spaceName: result.space?.name,
|
||||
path: Array.isArray(result.path) ? result.path : [],
|
||||
snippet:
|
||||
typeof result.snippet === "string"
|
||||
? result.snippet
|
||||
: (result.highlight ?? ""),
|
||||
score:
|
||||
typeof result.score === "number"
|
||||
? result.score
|
||||
: typeof result.rank === "number"
|
||||
? result.rank
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -774,6 +774,61 @@ export function insertInlineFootnote(
|
||||
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
|
||||
* 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
|
||||
* by `enumerateSpacePages`) into a nested tree.
|
||||
*
|
||||
* Input: a flat array of nodes. Each node is expected to carry at least
|
||||
* { id, slugId, title, position, parentPageId } (extra fields are ignored).
|
||||
* { id, slugId, title, position, parentPageId } (extra fields are ignored),
|
||||
* plus a server `hasChildren` boolean used by the `getTree` shape below.
|
||||
*
|
||||
* Output: an array of ROOT nodes, each shaped as
|
||||
* Output (default / `shape: "lean"`): an array of ROOT nodes, each shaped as
|
||||
* { id, slugId, title, children? }
|
||||
* where `children` is the array of child nodes (same shape, recursively). The
|
||||
* `children` key is OMITTED entirely when a node has no children — consistent
|
||||
@@ -13,6 +32,14 @@
|
||||
* lean (nesting alone conveys the structure; parentPageId/position/hasChildren
|
||||
* are intentionally dropped from the output).
|
||||
*
|
||||
* Output (`shape: "getTree"`, the #443 tool shape): each node is
|
||||
* { pageId, title, children?, hasChildren? }
|
||||
* — the server `id` is exposed as `pageId` (never `slugId`/`icon`/`position`).
|
||||
* `children` is omitted for leaves and for nodes trimmed by `maxDepth`.
|
||||
* `hasChildren: true` is set ONLY on a node whose children exist on the server
|
||||
* (per the flat item's `hasChildren`) but were CUT by `maxDepth`; on leaves and
|
||||
* on fully-expanded interior nodes the field is omitted (see `maxDepth` below).
|
||||
*
|
||||
* Linking rule: a node is attached as a child of `parentPageId` only when that
|
||||
* parent id is actually present in the input. Otherwise — including a null /
|
||||
* undefined `parentPageId`, or a parent that was capped out of the bounded walk
|
||||
@@ -26,18 +53,42 @@
|
||||
* fractional-index ASCII keys (e.g. "a0", "a1"). Nodes with a missing/undefined
|
||||
* `position` sort last.
|
||||
*
|
||||
* maxDepth (getTree shape only): the tree is built in FULL first, then trimmed
|
||||
* on the way out. Root nodes are depth 1. `maxDepth: N` keeps nodes at depth
|
||||
* <= N and drops the `children` of any node AT depth N. A node whose children
|
||||
* were dropped this way gets `hasChildren: true` when it actually had children
|
||||
* in the flat input (source of truth = the server `hasChildren` flag), so the
|
||||
* caller knows it can descend further with a follow-up `rootPageId` call. An
|
||||
* absent/undefined `maxDepth` means no cut (whole tree). `maxDepth <= 0` is
|
||||
* treated as "no cut" (defensive; the tool schema clamps to >= 1).
|
||||
*
|
||||
* Pure: no I/O, no network, deterministic.
|
||||
*/
|
||||
export function buildPageTree(nodes: any[]): any[] {
|
||||
type OutputNode = {
|
||||
export function buildPageTree(
|
||||
nodes: any[],
|
||||
options: BuildPageTreeOptions = {},
|
||||
): any[] {
|
||||
const getTreeShape = options.shape === "getTree";
|
||||
// A finite, positive cut only; anything else means "no cut".
|
||||
const maxDepth =
|
||||
typeof options.maxDepth === "number" &&
|
||||
Number.isFinite(options.maxDepth) &&
|
||||
options.maxDepth > 0
|
||||
? Math.floor(options.maxDepth)
|
||||
: undefined;
|
||||
|
||||
type InternalNode = {
|
||||
id: string;
|
||||
// Retained internally for shaping; never all emitted at once.
|
||||
slugId: any;
|
||||
title: any;
|
||||
children?: OutputNode[];
|
||||
hasServerChildren: boolean;
|
||||
children?: InternalNode[];
|
||||
};
|
||||
|
||||
// Map id -> output node. Build the lean output shape up front.
|
||||
const byId = new Map<string, OutputNode>();
|
||||
// Map id -> internal node. Build up front; the output shape is projected at
|
||||
// the very end so the maxDepth cut can consult `hasServerChildren`.
|
||||
const byId = new Map<string, InternalNode>();
|
||||
// Preserve the original position string for sorting (kept off the output).
|
||||
const positionById = new Map<string, string | undefined>();
|
||||
|
||||
@@ -49,6 +100,7 @@ export function buildPageTree(nodes: any[]): any[] {
|
||||
id: node.id,
|
||||
slugId: node.slugId,
|
||||
title: node.title,
|
||||
hasServerChildren: node.hasChildren === true,
|
||||
});
|
||||
positionById.set(node.id, node.position);
|
||||
}
|
||||
@@ -90,5 +142,30 @@ export function buildPageTree(nodes: any[]): any[] {
|
||||
}
|
||||
|
||||
roots.sort(byPosition);
|
||||
return roots.map((id) => byId.get(id)!);
|
||||
const rootNodes = roots.map((id) => byId.get(id)!);
|
||||
|
||||
// Project the internal nodes into the requested OUTPUT shape, applying the
|
||||
// maxDepth cut for the getTree shape. `depth` is 1-based (roots = depth 1).
|
||||
const project = (node: InternalNode, depth: number): any => {
|
||||
if (getTreeShape) {
|
||||
const out: any = { pageId: node.id, title: node.title };
|
||||
const atCut = maxDepth !== undefined && depth >= maxDepth;
|
||||
if (!atCut && node.children && node.children.length > 0) {
|
||||
out.children = node.children.map((c) => project(c, depth + 1));
|
||||
} else if (atCut && node.hasServerChildren) {
|
||||
// Children exist on the server but were trimmed by maxDepth: signal it
|
||||
// so the caller can descend with a follow-up rootPageId call.
|
||||
out.hasChildren = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// Lean (historic) shape: cycle-safe, no depth cut, no hasChildren.
|
||||
const out: any = { id: node.id, slugId: node.slugId, title: node.title };
|
||||
if (node.children && node.children.length > 0) {
|
||||
out.children = node.children.map((c) => project(c, depth + 1));
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
return rootNodes.map((n) => project(n, 1));
|
||||
}
|
||||
|
||||
@@ -40,8 +40,8 @@ import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";
|
||||
*/
|
||||
export const ROUTING_PROSE =
|
||||
"Docmost editing guide — choose the tool by intent. The <tool_inventory> at the end lists every tool with a one-line purpose; the notes below are the routing hints for WHEN to reach for each.\n" +
|
||||
"READ: find a page -> search (workspace-wide full-text); list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block's subtree -> getNode (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) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, lossy; inline <span data-comment-id> tags are comment anchors — markup, not text) or getPageJson (lossless ProseMirror with block ids). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (returns a short-lived anonymous URL).\n" +
|
||||
"EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Change ONE block (paragraph/heading/callout/etc.) structurally -> patchNode (by attrs.id from getOutline). Add a block -> insertNode (before/after a block by attrs.id or by anchor text, or append). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#<index>\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> drawioCreate (create from mxGraph XML and insert), drawioGet (read a diagram as mxGraph XML + a hash), drawioUpdate (replace a diagram; pass the hash from drawioGet as baseHash for optimistic locking); before authoring a diagram, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
|
||||
"READ: find a page by a fragment of a technical string (hostname/IP/ID like srv.local, 10.0.12, WB-MGE-30D86B) -> search — hybrid substring + full-text, returns each hit's location (path: root->parent titles) and a snippet around the match, so you rarely need a follow-up getPage; scope with spaceId or parentPageId (a subtree), titleOnly to match titles only. A space's page HIERARCHY (or one subtree) -> getTree (one request, complete, `{pageId,title,children?}`; rootPageId for a subtree, maxDepth to trim depth — a trimmed node gets hasChildren:true); prefer it over listPages tree:true (deprecated). list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block, for editing -> getNode (by attrs.id, or \"#<index>\" for tables, which carry no id) — returns MARKDOWN by default (comment anchors kept for safe write-back); pass format:\"json\" for the raw ProseMirror subtree. Find every occurrence of a string/regex ON a page (and where each is) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, canonical for text; drops only block ids, resolved-comment anchors, and a fixed no-md-representation attr set: table spans/colwidth/bg, indent, callout.icon, orderedList.type, link internal/target/rel/class; inline <span data-comment-id> tags are comment anchors — markup, not text) or getPageJson (full ProseMirror with block ids, for those dropped attrs). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (returns a short-lived anonymous URL).\n" +
|
||||
"EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#<index>\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> drawioCreate (create from mxGraph XML and insert), drawioGet (read a diagram as mxGraph XML + a hash), drawioUpdate (replace a diagram; pass the hash from drawioGet as baseHash for optimistic locking); before authoring a diagram, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
|
||||
"PAGES: new -> createPage (Markdown). Rename (title only) -> renamePage. Move -> movePage. Delete -> deletePage (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copyPageContent. Sharing -> sharePage / unsharePage / listShares; sharePage makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
|
||||
"COMMENTS: createComment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> createComment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> listComments, updateComment, resolveComment (resolve/reopen, reversible — prefer over delete to close), deleteComment, checkNewComments.\n" +
|
||||
"HISTORY: review what changed -> diffPageVersions (a historyId vs current, or two versions). List saved versions -> listPageHistory. Undo a bad edit -> restorePageVersion (writes a past version back as current; itself revertible). Export a page to self-contained Docmost Markdown (with comment anchors) -> exportPageMarkdown.";
|
||||
@@ -82,6 +82,7 @@ const TOOL_FAMILY: Record<string, Family> = {
|
||||
// READ
|
||||
search: "READ",
|
||||
listPages: "READ",
|
||||
getTree: "READ",
|
||||
listSpaces: "READ",
|
||||
getOutline: "READ",
|
||||
getNode: "READ",
|
||||
@@ -152,7 +153,7 @@ export const INLINE_MCP_INVENTORY: ToolInventoryLine[] = [
|
||||
{
|
||||
name: "search",
|
||||
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: "docmostTransform",
|
||||
|
||||
+179
-58
@@ -63,6 +63,7 @@ export type DocmostClientLike = Pick<
|
||||
| 'getSpaces'
|
||||
| 'listShares'
|
||||
| 'listPages'
|
||||
| 'getTree'
|
||||
| 'getPage'
|
||||
| 'getPageJson'
|
||||
| 'getOutline'
|
||||
@@ -305,22 +306,41 @@ export const SHARED_TOOL_SPECS = {
|
||||
mcpName: 'getNode',
|
||||
inAppKey: 'getNode',
|
||||
description:
|
||||
"Fetch a single node's full ProseMirror subtree (lossless) without " +
|
||||
'pulling the whole document. `nodeId` is a block id from the page ' +
|
||||
"Fetch a single block for editing. `nodeId` is a block id from the page " +
|
||||
'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>` 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',
|
||||
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) => ({
|
||||
pageId: 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 }) =>
|
||||
client.getNode(pageId as string, nodeId as string),
|
||||
execute: (client, { pageId, nodeId, format }) =>
|
||||
client.getNode(
|
||||
pageId as string,
|
||||
nodeId as string,
|
||||
format as 'markdown' | 'json' | undefined,
|
||||
),
|
||||
},
|
||||
|
||||
|
||||
// --- in-page occurrence search (client-side, over ProseMirror plain text) ---
|
||||
|
||||
searchInPage: {
|
||||
@@ -415,24 +435,30 @@ export const SHARED_TOOL_SPECS = {
|
||||
mcpName: 'patchNode',
|
||||
inAppKey: 'patchNode',
|
||||
description:
|
||||
'Replace a single content block identified by its attrs.id with a new ' +
|
||||
'ProseMirror node, WITHOUT resending the whole document; the replacement ' +
|
||||
'keeps the same node id. Get the block id from the page outline (cheap) ' +
|
||||
'or the page-JSON view, then ' +
|
||||
'pass a ProseMirror node to put in its place. Example node: a paragraph ' +
|
||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
|
||||
'heading {"type":"heading","attrs":{"level":2},"content":' +
|
||||
'Replace a single content block identified by its attrs.id, WITHOUT ' +
|
||||
'resending the whole document; the replacement keeps the same block id. ' +
|
||||
'Get the block id from the page outline (cheap) or the page-JSON view. ' +
|
||||
'Provide EXACTLY ONE of `markdown` or `node`. ' +
|
||||
'`markdown` (RECOMMENDED for prose): a canonical markdown fragment — the ' +
|
||||
'usual round trip is getNode (markdown) → edit the markdown → patchNode ' +
|
||||
'(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":"x","marks":[{"type":"bold"}]}. The node may be a ' +
|
||||
'JSON object or a JSON string (both accepted). EVERY node, including ' +
|
||||
'nested children, must carry a string `type` from the Docmost schema; ' +
|
||||
'text leaves are {"type":"text","text":"..."} (a bare {"text":"..."} is ' +
|
||||
'rejected up front). Cheaper and safer than ' +
|
||||
'replacing the whole document for one-block structural edits. Reversible: ' +
|
||||
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. EVERY node, including ' +
|
||||
'nested children, must carry a string `type` from the Docmost schema; text ' +
|
||||
'leaves are {"type":"text","text":"..."} (a bare {"text":"..."} is rejected). ' +
|
||||
'The node may be a JSON object or a JSON string (both accepted). Reversible: ' +
|
||||
'the previous version is kept in page history.',
|
||||
tier: 'deferred',
|
||||
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) => ({
|
||||
pageId: z.string().min(1).describe('ID of the page containing the block'),
|
||||
nodeId: z
|
||||
@@ -442,35 +468,53 @@ export const SHARED_TOOL_SPECS = {
|
||||
'attrs.id of the block to replace (from the page outline or ' +
|
||||
'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
|
||||
.any()
|
||||
.optional()
|
||||
.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"}]}. ' +
|
||||
'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
|
||||
// sometimes serializes it as a string) before the client's typeof-object
|
||||
// guard rejects it — identical on both hosts.
|
||||
execute: (client, { pageId, nodeId, node }) =>
|
||||
client.patchNode(pageId as string, nodeId as string, parseNodeArg(node)),
|
||||
// guard rejects it — identical on both hosts. The XOR (markdown vs node) is
|
||||
// enforced at runtime in the client (both schema-optional).
|
||||
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: {
|
||||
mcpName: 'insertNode',
|
||||
inAppKey: 'insertNode',
|
||||
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 ' +
|
||||
'EXACTLY ONE of anchorNodeId or anchorText. Get anchor block ids from the ' +
|
||||
'page outline or the page-JSON view. Avoids resending the whole document. ' +
|
||||
'Can also insert ' +
|
||||
'table structure: to add a 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, so it cannot target a row). ' +
|
||||
'Provide EXACTLY ONE of `markdown` or `node`. ' +
|
||||
'`markdown` (RECOMMENDED): a canonical markdown fragment — may be SEVERAL ' +
|
||||
'blocks, inserted in order at the anchor; `^[...]` footnotes supported. ' +
|
||||
'`node` (for precise attr/mark work OR table structure): a raw ProseMirror ' +
|
||||
'node. Table structure is JSON-only (not expressible in markdown): to add a ' +
|
||||
'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 " +
|
||||
'(no markdown); markdown/emoji are tolerated as a fallback; prefer plain ' +
|
||||
'text or anchorNodeId. Note: append is top-level only and rejects ' +
|
||||
@@ -485,15 +529,23 @@ export const SHARED_TOOL_SPECS = {
|
||||
'JSON object or a JSON string (both accepted). Reversible via page history.',
|
||||
tier: 'deferred',
|
||||
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) => ({
|
||||
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
|
||||
.any()
|
||||
.optional()
|
||||
.describe(
|
||||
'ProseMirror node to insert, e.g. ' +
|
||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
|
||||
'JSON object or JSON string both accepted.',
|
||||
'For precise attr/mark work or table structure: a ProseMirror node, ' +
|
||||
'e.g. {"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
|
||||
'JSON object or JSON string both accepted. Exactly one of markdown / node.',
|
||||
),
|
||||
position: z
|
||||
.enum(['before', 'after', 'append'])
|
||||
@@ -511,14 +563,27 @@ export const SHARED_TOOL_SPECS = {
|
||||
'are tolerated as a fallback; prefer plain text or anchorNodeId.',
|
||||
),
|
||||
}),
|
||||
execute: (client, { pageId, node, position, anchorNodeId, anchorText }) =>
|
||||
client.insertNode(pageId as string, parseNodeArg(node), {
|
||||
position: position as 'before' | 'after' | 'append',
|
||||
anchorNodeId: anchorNodeId as string | undefined,
|
||||
anchorText: anchorText as string | undefined,
|
||||
}),
|
||||
// The XOR (markdown vs node) is enforced at runtime in the client (both
|
||||
// schema-optional). parseNodeArg only runs on the node path.
|
||||
execute: (
|
||||
client,
|
||||
{ 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 ---
|
||||
|
||||
// Unified from the per-layer inline definitions (#294). Both layers already
|
||||
@@ -813,11 +878,17 @@ export const SHARED_TOOL_SPECS = {
|
||||
inAppKey: 'getPage',
|
||||
description:
|
||||
'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 ' +
|
||||
'table/callout structure are approximated); for a lossless representation ' +
|
||||
'use the lossless page-JSON read tool. Inline <span data-comment-id> tags in the markdown ' +
|
||||
'are comment highlight anchors (also present for RESOLVED threads) — ' +
|
||||
'treat them as markup, not page text.',
|
||||
'its Markdown content. The converter is canonical (round-trips text and ' +
|
||||
'block structure), so this is sufficient for text edits; use the ' +
|
||||
'page-JSON read tool only when you need what Markdown cannot carry. The ' +
|
||||
'Markdown drops exactly: (1) block ids (not visible in Markdown); ' +
|
||||
'(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',
|
||||
catalogLine: 'getPage — fetch a page as Markdown by its id.',
|
||||
// Reconciled: MCP's stricter .min(1) kept; in-app's more-informative
|
||||
@@ -847,11 +918,13 @@ export const SHARED_TOOL_SPECS = {
|
||||
description:
|
||||
'List the most recent pages (ordered by updatedAt, descending), ' +
|
||||
'optionally scoped to a single space. Returns a bounded list (default ' +
|
||||
'50, max 100) — use search for lookups in large spaces. Pass tree:true ' +
|
||||
"(with spaceId) to instead get the space's full page hierarchy as a " +
|
||||
'nested tree.',
|
||||
'50, max 100) — use search for lookups in large spaces. tree:true (with ' +
|
||||
"spaceId) returns the space's full page hierarchy as a nested tree, but " +
|
||||
'is DEPRECATED — use getTree instead (leaner nodes, plus rootPageId / ' +
|
||||
'maxDepth).',
|
||||
tier: 'core',
|
||||
catalogLine: "listPages — list recent pages, or a space's full page tree.",
|
||||
catalogLine:
|
||||
"listPages — list recent pages (tree:true is deprecated; use getTree for the hierarchy).",
|
||||
buildShape: (z) => ({
|
||||
spaceId: z
|
||||
.string()
|
||||
@@ -884,6 +957,50 @@ 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,
|
||||
),
|
||||
},
|
||||
|
||||
createPage: {
|
||||
mcpName: 'createPage',
|
||||
inAppKey: 'createPage',
|
||||
@@ -1176,13 +1293,17 @@ export const SHARED_TOOL_SPECS = {
|
||||
inAppKey: 'exportPageMarkdown',
|
||||
// CANONICAL: the MCP copy (a strict superset of the terse in-app wording).
|
||||
description:
|
||||
'Export a page to a single self-contained, lossless Docmost-flavoured ' +
|
||||
'Markdown file (custom extensions): YAML-free meta header, body with ' +
|
||||
'inline comment anchors and diagrams, and a trailing comments-thread ' +
|
||||
'block. Designed for a download -> edit body -> page-Markdown import ' +
|
||||
'round-trip that preserves everything, including comment highlights. ' +
|
||||
'Comment THREADS are preserved in the file but are not re-pushed to the ' +
|
||||
'server on import.',
|
||||
'Export a page to a single self-contained Docmost-flavoured Markdown ' +
|
||||
'file (custom extensions): YAML-free meta header, body with inline ' +
|
||||
'comment anchors (resolved ones kept) and diagrams, and a trailing ' +
|
||||
'comments-thread block. Designed for a download -> edit body -> ' +
|
||||
'page-Markdown import round-trip; block ids regenerate and comment ' +
|
||||
'THREADS, though kept in the file, are not re-pushed to the server on ' +
|
||||
'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',
|
||||
catalogLine:
|
||||
'exportPageMarkdown — export a page to self-contained Markdown (body + comments).',
|
||||
|
||||
@@ -133,8 +133,10 @@ test("patchNode REFUSES an ambiguous (duplicate) id without writing to collab",
|
||||
await assert.rejects(
|
||||
() =>
|
||||
client.patchNode("11111111-1111-4111-8111-111111111111", DUP_ID, {
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "replacement" }],
|
||||
node: {
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "replacement" }],
|
||||
},
|
||||
}),
|
||||
/ambiguous/i,
|
||||
"patchNode must reject a duplicate-id target with an 'ambiguous' error",
|
||||
|
||||
@@ -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)",
|
||||
);
|
||||
});
|
||||
@@ -135,7 +135,7 @@ test("patchNode fails fast on a nested typeless node — no collab connection",
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
await assert.rejects(
|
||||
() => client.patchNode(PAGE, SEED_ID, nestedTypelessNode()),
|
||||
() => client.patchNode(PAGE, SEED_ID, { node: nestedTypelessNode() }),
|
||||
(err) => {
|
||||
assert.match(err.message, /patchNode: invalid node/);
|
||||
assert.match(err.message, /missing "type"/);
|
||||
@@ -158,9 +158,13 @@ test("insertNode fails fast on a nested UNKNOWN type — no collab connection",
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
client.insertNode(PAGE, nestedUnknownTypeNode(), {
|
||||
position: "append",
|
||||
}),
|
||||
client.insertNode(
|
||||
PAGE,
|
||||
{ node: nestedUnknownTypeNode() },
|
||||
{
|
||||
position: "append",
|
||||
},
|
||||
),
|
||||
(err) => {
|
||||
assert.match(err.message, /insertNode: invalid node/);
|
||||
assert.match(err.message, /unknown node type "paragraf"/);
|
||||
@@ -225,8 +229,10 @@ test("patchNode with a well-formed node proceeds to the collab write", async ()
|
||||
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||
|
||||
const result = await client.patchNode(PAGE, SEED_ID, {
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "replacement" }],
|
||||
node: {
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "replacement" }],
|
||||
},
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
@@ -1,7 +1,11 @@
|
||||
import { test } from "node:test";
|
||||
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", () => {
|
||||
const result = filterComment({
|
||||
@@ -171,3 +175,91 @@ test("filterPage includes both content and subpages together", () => {
|
||||
assert.equal(result.content, "body");
|
||||
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, []);
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -81,49 +81,68 @@ test("editPageText builder produces { pageId, edits } and drops the stale strip-
|
||||
assert.match(spec.description, /REFUSED into\s+failed\[\]/);
|
||||
});
|
||||
|
||||
test("getNode builder produces exactly { pageId, nodeId }", () => {
|
||||
const shape = SHARED_TOOL_SPECS.getNode.buildShape(z);
|
||||
assert.deepEqual(Object.keys(shape).sort(), ["nodeId", "pageId"]);
|
||||
// #413: getNode gained an optional `format` (markdown default / json opt-in).
|
||||
test("getNode builder produces { pageId, nodeId, format? } with format optional", () => {
|
||||
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;
|
||||
assert.ok(spec, "patchNode spec missing");
|
||||
assert.equal(spec.mcpName, "patchNode");
|
||||
assert.equal(spec.inAppKey, "patchNode");
|
||||
|
||||
// The canonical description must carry the key guidance from BOTH originals:
|
||||
// - MCP-only: "WITHOUT resending the whole document" + the cheaper/safer note.
|
||||
// - in-app-only: "keeps the same node id" + the "Reversible ... page history"
|
||||
// framing the MCP copy lacked.
|
||||
assert.match(spec.description, /WITHOUT resending the whole document/);
|
||||
assert.match(spec.description, /Cheaper and safer/);
|
||||
assert.match(spec.description, /keeps the same node id/i);
|
||||
// The canonical description must carry the #413 guidance.
|
||||
assert.match(spec.description, /WITHOUT/i);
|
||||
assert.match(spec.description, /EXACTLY ONE of `markdown` or `node`/);
|
||||
assert.match(spec.description, /RECOMMENDED/);
|
||||
assert.match(spec.description, /keeps the same block id/i);
|
||||
assert.match(spec.description, /Reversible/i);
|
||||
assert.match(spec.description, /page history/i);
|
||||
|
||||
const shape = spec.buildShape(z);
|
||||
assert.deepEqual(Object.keys(shape).sort(), ["node", "nodeId", "pageId"]);
|
||||
// A minimal valid input parses (node accepts an arbitrary object via z.any()).
|
||||
const parsed = z.object(shape).parse({
|
||||
assert.deepEqual(
|
||||
Object.keys(shape).sort(),
|
||||
["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",
|
||||
nodeId: "n1",
|
||||
node: { type: "paragraph" },
|
||||
});
|
||||
assert.equal(parsed.pageId, "p1");
|
||||
assert.equal(parsed.nodeId, "n1");
|
||||
assert.equal(parsedNode.pageId, "p1");
|
||||
// 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;
|
||||
assert.ok(spec, "insertNode spec missing");
|
||||
assert.equal(spec.mcpName, "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 `markdown` or `node`/);
|
||||
assert.match(spec.description, /tableRow/);
|
||||
assert.match(spec.description, /append is top-level only/);
|
||||
assert.match(spec.description, /Reversible via page history/);
|
||||
@@ -131,18 +150,60 @@ test("insertNode spec exists, merges BOTH descriptions, builds the full anchor s
|
||||
const shape = spec.buildShape(z);
|
||||
assert.deepEqual(
|
||||
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);
|
||||
assert.doesNotThrow(() =>
|
||||
schema.parse({ pageId: "p1", markdown: "hi", position: "append" }),
|
||||
);
|
||||
assert.doesNotThrow(() =>
|
||||
schema.parse({ pageId: "p1", node: { type: "paragraph" }, position: "append" }),
|
||||
);
|
||||
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: listPages tree:true is deprecated in favour of getTree.
|
||||
test("listPages description deprecates tree:true and points at getTree", () => {
|
||||
const spec = SHARED_TOOL_SPECS.listPages;
|
||||
assert.match(spec.description, /DEPRECATED/i);
|
||||
assert.match(spec.description, /getTree/);
|
||||
});
|
||||
|
||||
test("no-arg specs (getWorkspace/listSpaces/listShares) omit buildShape", () => {
|
||||
for (const key of ["getWorkspace", "listSpaces", "listShares"]) {
|
||||
assert.equal(SHARED_TOOL_SPECS[key].buildShape, undefined, `${key} should be no-arg`);
|
||||
|
||||
@@ -137,3 +137,164 @@ test("buildPageTree output shape is lean (drops position/parentPageId/hasChildre
|
||||
assert.equal("hasChildren" in node, false);
|
||||
assert.equal("spaceId" in node, false);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #443 getTree output shape: { pageId, title, children?, hasChildren? }
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// A small representative space used across the getTree tests:
|
||||
// r1 (Infrastructure)
|
||||
// c1 (Datacenter A)
|
||||
// g1 (Servers) [leaf]
|
||||
// c2 (Datacenter B) [leaf]
|
||||
// r2 (Notes) [leaf]
|
||||
const SAMPLE = [
|
||||
{ id: "r2", slugId: "s-r2", title: "Notes", position: "a1", icon: "📝", hasChildren: false },
|
||||
{ id: "r1", slugId: "s-r1", title: "Infrastructure", position: "a0", icon: "🏢", hasChildren: true },
|
||||
{ id: "c2", slugId: "s-c2", title: "Datacenter B", position: "b1", parentPageId: "r1", icon: "🅱️", hasChildren: false },
|
||||
{ id: "c1", slugId: "s-c1", title: "Datacenter A", position: "b0", parentPageId: "r1", icon: "🅰️", hasChildren: true },
|
||||
{ id: "g1", slugId: "s-g1", title: "Servers", position: "c0", parentPageId: "c1", icon: "🖥️", hasChildren: false },
|
||||
];
|
||||
|
||||
test("getTree shape: correct nesting + order-by-position, only {pageId,title,children?}, no leak", () => {
|
||||
const tree = buildPageTree(SAMPLE, { shape: "getTree" });
|
||||
|
||||
// Roots sorted by position: r1 (a0) before r2 (a1).
|
||||
assert.deepEqual(
|
||||
tree.map((n) => n.pageId),
|
||||
["r1", "r2"],
|
||||
);
|
||||
// r1's children sorted by position: c1 (b0) before c2 (b1).
|
||||
assert.deepEqual(
|
||||
tree[0].children.map((n) => n.pageId),
|
||||
["c1", "c2"],
|
||||
);
|
||||
// Deep nesting: g1 under c1.
|
||||
assert.deepEqual(
|
||||
tree[0].children[0].children.map((n) => n.pageId),
|
||||
["g1"],
|
||||
);
|
||||
|
||||
// No slugId/icon/position/parentPageId/hasChildren leak on any node.
|
||||
const walk = (nodes) => {
|
||||
for (const n of nodes) {
|
||||
assert.deepEqual(
|
||||
Object.keys(n).sort(),
|
||||
n.children ? ["children", "pageId", "title"] : ["pageId", "title"],
|
||||
`unexpected keys on ${n.pageId}: ${Object.keys(n)}`,
|
||||
);
|
||||
assert.equal("slugId" in n, false);
|
||||
assert.equal("icon" in n, false);
|
||||
assert.equal("position" in n, false);
|
||||
assert.equal("parentPageId" in n, false);
|
||||
// Fully-expanded tree (no maxDepth): hasChildren never set.
|
||||
assert.equal("hasChildren" in n, false);
|
||||
if (n.children) walk(n.children);
|
||||
}
|
||||
};
|
||||
walk(tree);
|
||||
});
|
||||
|
||||
test("getTree maxDepth:1 returns roots only, each with hasChildren from the flat item", () => {
|
||||
const tree = buildPageTree(SAMPLE, { shape: "getTree", maxDepth: 1 });
|
||||
|
||||
assert.deepEqual(
|
||||
tree.map((n) => n.pageId),
|
||||
["r1", "r2"],
|
||||
);
|
||||
// No children arrays at depth 1 when maxDepth:1.
|
||||
for (const n of tree) assert.equal("children" in n, false);
|
||||
// r1 has children on the server -> hasChildren:true; r2 is a leaf -> omitted.
|
||||
assert.equal(tree[0].hasChildren, true);
|
||||
assert.equal("hasChildren" in tree[1], false);
|
||||
});
|
||||
|
||||
test("getTree maxDepth:2 cuts grandchildren; hasChildren only on the cut interior node", () => {
|
||||
const tree = buildPageTree(SAMPLE, { shape: "getTree", maxDepth: 2 });
|
||||
|
||||
const r1 = tree[0];
|
||||
// Depth-1 node r1 was EXPANDED (its children are present) -> no hasChildren.
|
||||
assert.equal("hasChildren" in r1, false);
|
||||
assert.equal(r1.children.length, 2);
|
||||
|
||||
const [c1, c2] = r1.children;
|
||||
// c1 is at depth 2 (the cut) and has children on the server -> hasChildren:true,
|
||||
// and its grandchild g1 is NOT present.
|
||||
assert.equal(c1.pageId, "c1");
|
||||
assert.equal("children" in c1, false);
|
||||
assert.equal(c1.hasChildren, true);
|
||||
// c2 is at depth 2 but is a leaf on the server -> hasChildren omitted.
|
||||
assert.equal(c2.pageId, "c2");
|
||||
assert.equal("children" in c2, false);
|
||||
assert.equal("hasChildren" in c2, false);
|
||||
|
||||
// r2 is a depth-1 leaf -> no hasChildren, no children.
|
||||
assert.equal("hasChildren" in tree[1], false);
|
||||
});
|
||||
|
||||
test("getTree hasChildren is set ONLY on depth-cut nodes (not leaves, not expanded interior nodes)", () => {
|
||||
// Full tree (no cut): NO node anywhere carries hasChildren.
|
||||
const full = buildPageTree(SAMPLE, { shape: "getTree" });
|
||||
const anyHasChildren = (nodes) =>
|
||||
nodes.some((n) => "hasChildren" in n || (n.children && anyHasChildren(n.children)));
|
||||
assert.equal(anyHasChildren(full), false);
|
||||
});
|
||||
|
||||
test("getTree orphan (parent filtered out) surfaces as a root, not dropped", () => {
|
||||
const tree = buildPageTree(
|
||||
[
|
||||
{ id: "root", slugId: "s-root", title: "Root", position: "a0", hasChildren: true },
|
||||
// parentPageId points at an id NOT in the flat list (parent filtered by perms).
|
||||
{ id: "orphan", slugId: "s-orphan", title: "Orphan", position: "a1", parentPageId: "gone", hasChildren: false },
|
||||
],
|
||||
{ shape: "getTree" },
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
tree.map((n) => n.pageId).sort(),
|
||||
["orphan", "root"],
|
||||
);
|
||||
const orphan = tree.find((n) => n.pageId === "orphan");
|
||||
assert.equal("children" in orphan, false);
|
||||
assert.equal("hasChildren" in orphan, false);
|
||||
});
|
||||
|
||||
test("getTree rootPageId path: a seeded single-root subtree keeps the getTree shape", () => {
|
||||
// Simulate the server seeding the CTE with the subtree root c1: the flat list
|
||||
// it returns contains c1 (now a root, parent absent) + its descendant g1.
|
||||
const subtree = [
|
||||
{ id: "c1", slugId: "s-c1", title: "Datacenter A", position: "b0", hasChildren: true },
|
||||
{ id: "g1", slugId: "s-g1", title: "Servers", position: "c0", parentPageId: "c1", hasChildren: false },
|
||||
];
|
||||
const tree = buildPageTree(subtree, { shape: "getTree" });
|
||||
|
||||
assert.equal(tree.length, 1);
|
||||
assert.equal(tree[0].pageId, "c1");
|
||||
assert.deepEqual(
|
||||
tree[0].children.map((n) => n.pageId),
|
||||
["g1"],
|
||||
);
|
||||
assert.equal("slugId" in tree[0], false);
|
||||
});
|
||||
|
||||
test("getTree maxDepth<=0 / non-finite is treated as no cut (whole tree)", () => {
|
||||
for (const bad of [0, -3, NaN, Infinity, undefined]) {
|
||||
const tree = buildPageTree(SAMPLE, { shape: "getTree", maxDepth: bad });
|
||||
// Grandchild g1 present -> no cut applied.
|
||||
assert.deepEqual(
|
||||
tree[0].children[0].children.map((n) => n.pageId),
|
||||
["g1"],
|
||||
`maxDepth=${bad} should not cut`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("buildPageTree() with no options is byte-identical to the historic lean call", () => {
|
||||
// Guard the existing callers: buildPageTree(pages) must be unchanged by the
|
||||
// additive options param.
|
||||
const withoutOpts = buildPageTree(SAMPLE);
|
||||
const withEmptyOpts = buildPageTree(SAMPLE, {});
|
||||
assert.deepEqual(withoutOpts, withEmptyOpts);
|
||||
// And it is the lean {id,slugId,title,children?} shape, not the getTree shape.
|
||||
assert.deepEqual(Object.keys(withoutOpts[0]).sort(), ["children", "id", "slugId", "title"]);
|
||||
});
|
||||
|
||||
@@ -53,11 +53,14 @@ export {
|
||||
buildOutline,
|
||||
getNodeByRef,
|
||||
replaceNodeById,
|
||||
replaceNodeByIdWithMany,
|
||||
reassignCollidingBlockIds,
|
||||
deleteNodeById,
|
||||
sanitizeForYjs,
|
||||
findUnstorableAttr,
|
||||
findInvalidNode,
|
||||
insertNodeRelative,
|
||||
insertNodesRelative,
|
||||
readTable,
|
||||
insertTableRow,
|
||||
deleteTableRow,
|
||||
|
||||
@@ -217,6 +217,54 @@ export function replaceNodeById(
|
||||
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`
|
||||
* array, anywhere in the tree (recursive, including callouts and tables).
|
||||
@@ -725,6 +773,88 @@ export function insertNodeRelative(
|
||||
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
|
||||
//
|
||||
@@ -773,6 +903,27 @@ function makeFreshId(used: Set<string>): string {
|
||||
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
|
||||
* table node (a reference inside `rootClone`, so the caller may mutate it) plus
|
||||
|
||||
@@ -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