Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe5bd159c4 | |||
| f12b685698 | |||
| f6fc914c95 | |||
| 1d89cc2058 | |||
| 70a9e2a9cb | |||
| 5d8083f8ff | |||
| 3e945305c8 | |||
| 6d7dba970c | |||
| 512bcba5f3 | |||
| 5c1ab9c7b5 | |||
| 14d7b21df0 | |||
| 363f20ab75 | |||
| 3411bda2d1 | |||
| e670f7498a | |||
| 9a8671c3af | |||
| bec2156e96 | |||
| acc705de19 | |||
| a49872444e | |||
| ffc38ea2ca | |||
| 2a951df096 | |||
| 5dc7a2703f | |||
| bfb4c8d8d0 | |||
| f794ac6d6c | |||
| e4e788f151 | |||
| 4be4a75fa3 | |||
| 2f23cf4b65 | |||
| d287c15db4 |
@@ -124,6 +124,40 @@ MCP_DOCMOST_PASSWORD=
|
|||||||
# MCP_TOKEN=
|
# MCP_TOKEN=
|
||||||
# MCP_SESSION_IDLE_MS=1800000
|
# MCP_SESSION_IDLE_MS=1800000
|
||||||
#
|
#
|
||||||
|
# --- MCP collaboration write path: concurrency + rights-staleness (#449) ------
|
||||||
|
# MCP content writes (update_page, insert/replace nodes, comments-in-body, etc.)
|
||||||
|
# go over the collaboration websocket and are serialized PER PAGE by an
|
||||||
|
# in-process mutex (a module-level Map, one promise-chain per page UUID). This
|
||||||
|
# guarantees no two MCP writes on the SAME page overlap and clobber each other.
|
||||||
|
#
|
||||||
|
# DEPLOY REQUIREMENT — SINGLE INSTANCE or STICKY SESSIONS. The mutex is
|
||||||
|
# process-local. Behind a multi-replica load balancer WITHOUT sticky sessions,
|
||||||
|
# two replicas can each "hold" the lock for the same page at the same time and
|
||||||
|
# serialization is silently lost (concurrent full-document writes race on the
|
||||||
|
# live Yjs fragment). Run the MCP/app as a SINGLE instance, OR pin a page's
|
||||||
|
# traffic to one replica (sticky sessions / consistent hashing on page id). The
|
||||||
|
# same constraint applies to the RAM-only stash_page blob store above. There is
|
||||||
|
# deliberately no cross-process (e.g. Postgres advisory) lock yet — this is a
|
||||||
|
# CONSCIOUS documented constraint, not an oversight (#449).
|
||||||
|
#
|
||||||
|
# To reduce connect-storms the write path caches ONE live collab session per
|
||||||
|
# (wsUrl, page, token). Tunables (all optional; defaults are safe):
|
||||||
|
# MCP_COLLAB_SESSION_IDLE_MS=60000 # idle TTL, reset per op; 0 disables cache
|
||||||
|
# MCP_COLLAB_SESSION_MAX_ENTRIES=32 # LRU cap on cached sessions
|
||||||
|
# MCP_COLLAB_TOKEN_TTL_MS=300000 # per-client collab-token cache (5 min)
|
||||||
|
#
|
||||||
|
# RIGHTS-STALENESS TRADE-OFF. A cached collab session writes under the token
|
||||||
|
# captured at CONNECT time, and the collab-token cache reuses a token for its TTL.
|
||||||
|
# So if a user's access to a page is REVOKED, MCP writes on an already-open
|
||||||
|
# session may keep succeeding until the session ages out. MCP_COLLAB_SESSION_MAX_AGE_MS
|
||||||
|
# is the HARD lifetime (checked at each acquire) that BOUNDS this window: after it,
|
||||||
|
# the session is torn down and the next write re-auths with a fresh token, picking
|
||||||
|
# up the revocation. Default 10 min. LOWER it to shorten the revocation lag at the
|
||||||
|
# cost of more reconnects; RAISE it to reduce reconnects at the cost of a longer
|
||||||
|
# stale-rights window. There is intentionally no push-based cache invalidation on
|
||||||
|
# a rights change — this bounded window is the accepted trade-off (#449).
|
||||||
|
# MCP_COLLAB_SESSION_MAX_AGE_MS=600000
|
||||||
|
#
|
||||||
# BLOB SANDBOX (stash_page). An in-RAM, process-local store that hands large page
|
# BLOB SANDBOX (stash_page). An in-RAM, process-local store that hands large page
|
||||||
# content + images to an external consumer WITHOUT bloating the model context or
|
# content + images to an external consumer WITHOUT bloating the model context or
|
||||||
# requiring Docmost auth. The stash_page tool serializes a page, mirrors its
|
# requiring Docmost auth. The stash_page tool serializes a page, mirrors its
|
||||||
|
|||||||
@@ -157,6 +157,12 @@ jobs:
|
|||||||
- name: Build prosemirror-markdown
|
- name: Build prosemirror-markdown
|
||||||
run: pnpm --filter @docmost/prosemirror-markdown build
|
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||||
|
|
||||||
|
# docmost-client.loader.ts type-imports from @docmost/mcp (issue #446); its
|
||||||
|
# build/ is gitignored and `test:e2e` type-checks, so build it here or tsc
|
||||||
|
# fails with TS2307 (mirrors the e2e-mcp / mcp-server-parity jobs).
|
||||||
|
- name: Build mcp
|
||||||
|
run: pnpm --filter @docmost/mcp build
|
||||||
|
|
||||||
- name: Run migrations
|
- name: Run migrations
|
||||||
run: pnpm --filter ./apps/server migration:latest
|
run: pnpm --filter ./apps/server migration:latest
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,139 @@ repository. It has two layers: **how to run a task end-to-end** (the
|
|||||||
sections below), and **how the codebase is built** (the technical sections
|
sections below), and **how the codebase is built** (the technical sections
|
||||||
further down, formerly in `CLAUDE.md`).
|
further down, formerly in `CLAUDE.md`).
|
||||||
|
|
||||||
|
## ARCHITECTURAL INVARIANTS — NON-NEGOTIABLE
|
||||||
|
|
||||||
|
THE TEN RULES BELOW ARE HARD CONSTRAINTS. Each one was paid for with a real
|
||||||
|
production incident or a multi-PR bug chain in THIS repository (cited inline).
|
||||||
|
They override convenience, deadlines and "it's just a small feature". A PR that
|
||||||
|
violates any of them MUST be rejected in review regardless of how good the rest
|
||||||
|
of it is. If a task genuinely seems to require breaking one — STOP and raise it
|
||||||
|
with the owner; do not code around it.
|
||||||
|
|
||||||
|
### 1. EVERY BUFFER, CACHE, HISTORY AND PAYLOAD HAS AN EXPLICIT SIZE BUDGET
|
||||||
|
|
||||||
|
Nothing accumulates unboundedly. A row/item cap is NOT a byte cap. Anything
|
||||||
|
replayed to a model, buffered in memory, persisted per step, or refetched by a
|
||||||
|
poll must state its budget in bytes/tokens and enforce it. Rewriting a growing
|
||||||
|
structure in full on every increment is FORBIDDEN — append or diff instead;
|
||||||
|
O(n²) write/serialize patterns do not pass review.
|
||||||
|
(Paid for by: full-row rewrite on every agent step — hundreds of MB of Postgres
|
||||||
|
writes per 50-step run, with every tool output serialized twice; unbounded
|
||||||
|
history replay killing long chats on the provider context window; 32 MB replay
|
||||||
|
buffers per active run.)
|
||||||
|
|
||||||
|
### 2. EVERYTHING LONG-RUNNING TERMINATES BY CONSTRUCTION
|
||||||
|
|
||||||
|
Every run / row / session / lease / subscriber / queue entry must define AT
|
||||||
|
DESIGN TIME: its owner; every terminal state; who writes the terminal state on
|
||||||
|
EVERY path (success, error, abort, disconnect in each phase, process restart);
|
||||||
|
retries for the terminal write; and a periodic sweeper that does not depend on
|
||||||
|
a reboot. A best-effort terminal write with no retry and no sweep is FORBIDDEN.
|
||||||
|
(Paid for by: assistant rows stuck 'streaming' forever; runs stuck 'running'
|
||||||
|
409-locking their chat until a restart — the #183/#184 follow-up chain.)
|
||||||
|
|
||||||
|
### 3. EVERY AWAIT IS CANCELLABLE AND DEADLINED; NEVER BLOCK THE EVENT LOOP
|
||||||
|
|
||||||
|
Every async step inside a request or agent turn honors the turn's AbortSignal
|
||||||
|
AND a wall-clock deadline — including in-app tools, lock queues and pagination
|
||||||
|
loops, not just external calls. Synchronous CPU work beyond ~50 ms goes to a
|
||||||
|
worker_thread. Promise.race DOES NOT cancel synchronous work — using it as a
|
||||||
|
"timeout" for sync computation is forbidden (the timer only fires after the
|
||||||
|
event loop is free again, i.e. after the damage is done).
|
||||||
|
(Paid for by: in-app tools ignoring abortSignal and writing pages AFTER Stop;
|
||||||
|
the synchronous ELK layout freezing every SSE stream in the process; the
|
||||||
|
step-0 MCP handshake hang — #397.)
|
||||||
|
|
||||||
|
### 4. ONE SOURCE OF TRUTH; EVERYTHING ELSE IS A REBUILDABLE CACHE
|
||||||
|
|
||||||
|
Postgres is the authoritative state. Every in-memory structure (registries,
|
||||||
|
caches, client stores) must be reconstructible from the DB and treated as
|
||||||
|
lossy. The client renders SERVER-DECLARED state — "a run is active" is a server
|
||||||
|
fact delivered as data, never inferred from side signals (204 vs 2xx, the
|
||||||
|
flavor of a disconnect). A new feature must name the owner of each piece of
|
||||||
|
state before implementation starts.
|
||||||
|
(Paid for by: the strip/restore resume machinery, silently frozen UIs and
|
||||||
|
ghost sends after unmount — the #381→#432→#456 chain.)
|
||||||
|
|
||||||
|
### 5. STATE MACHINES ARE EXPLICIT — ONE-SHOT FLAGS ARE FORBIDDEN
|
||||||
|
|
||||||
|
A complex lifecycle (chat thread, resume/reconnect, run) lives in a named-state
|
||||||
|
automaton (reducer / enum) where every state has an owner and a rendered
|
||||||
|
representation — including the failure states. Adding a boolean ref that one
|
||||||
|
callback arms and another reads-and-clears is FORBIDDEN in the AI-chat client.
|
||||||
|
New behavior = a new named state + explicit transitions, and the interruption
|
||||||
|
matrix (disconnect in each phase × restart × stop × supersede) is enumerated at
|
||||||
|
design time, not discovered one incident at a time.
|
||||||
|
(Paid for by: 26 one-shot useRef flags in chat-thread.tsx and the drip of
|
||||||
|
"one more missing transition" across #381→#386/#389→#432→#456.)
|
||||||
|
|
||||||
|
### 6. NO NEW MODE FORKS; A FLAG IS FOR ROLLOUT, THEN IT DIES
|
||||||
|
|
||||||
|
A behavior flag that forks a code path must ship with a written sunset
|
||||||
|
condition; stacking a new flag onto the existing matrix without deleting or
|
||||||
|
scheduling an old one is forbidden. While a temporary fork exists, BOTH sides
|
||||||
|
must share identical lifecycle handling (abort semantics, error listeners,
|
||||||
|
concurrency gates) — asymmetric forks are outlawed.
|
||||||
|
(Paid for by: legacy vs autonomous divergence — the one-active-run gate and
|
||||||
|
the socket 'error' listener each existing on only ONE side; 2^4 flag
|
||||||
|
combinations each with different abort semantics.)
|
||||||
|
|
||||||
|
### 7. NO HAND-SYNCED MIRRORS — CODEGEN OR A CI PARITY TEST, NOTHING LESS
|
||||||
|
|
||||||
|
Two copies of the same knowledge (schema, tool registry, glyph map, probe
|
||||||
|
body, hash/normalize algorithm, label list) require either generation from a
|
||||||
|
single source or a CI test that FAILS on drift. A "mirror this change over
|
||||||
|
there" comment is NOT a guard and does not pass review.
|
||||||
|
(Paid for by: #293 — three drifting converter copies losing data; #447 —
|
||||||
|
REGISTRY_STAMP covering only one of the mirrored files; ~10 still-unguarded
|
||||||
|
mirrors across the MCP layer.)
|
||||||
|
|
||||||
|
### 8. CACHES, HEADERS, BUFFERS AND FSM TRANSITIONS GET AN INTEGRATION TEST OF THE OBSERVABLE PROPERTY
|
||||||
|
|
||||||
|
A unit test of a pure helper DOES NOT COUNT for these. Test the real header on
|
||||||
|
the real HTTP response, the real cache hit under real token sources, the real
|
||||||
|
transition under a really-killed socket. If the observable property cannot be
|
||||||
|
tested, the design is wrong — fix the design, not the test.
|
||||||
|
(Paid for by: #431→#439 — a cache keyed on a fresh-per-call JWT, so it NEVER
|
||||||
|
hit and became prod incident #435 while its unit tests stayed green; and by
|
||||||
|
the #352→#455 immutable-cache header silently overwritten by a framework
|
||||||
|
default AFTER the unit-tested code ran.)
|
||||||
|
|
||||||
|
### 9. CLIENT INPUT IS HOSTILE UNTIL VALIDATED — ALSO BEFORE PERSISTENCE
|
||||||
|
|
||||||
|
Anything from the browser (message parts, ids, titles, selections, flags) is
|
||||||
|
validated/sanitized BEFORE it is persisted into a row that will later be
|
||||||
|
replayed into a prompt, a converter or another subsystem. A poisoned row must
|
||||||
|
never be able to permanently brick a chat or a page on every subsequent read.
|
||||||
|
(Paid for by: unvalidated UIMessage parts persisted verbatim — one bad row
|
||||||
|
500s the chat on every later turn; #159 client-spoofed page titles; #388
|
||||||
|
selection re-sanitized server-side for the same reason.)
|
||||||
|
|
||||||
|
### 10. FAILURES ARE LOUD AND SPECIFIC; SILENT DEGRADATION IS FORBIDDEN
|
||||||
|
|
||||||
|
Extends the error convention below: a fire-and-forget write is allowed ONLY
|
||||||
|
with a metric or a greppable ERROR log; a degraded mode (dead cached MCP
|
||||||
|
client, stopped poll, exhausted retries, evicted buffer) must be VISIBLE to
|
||||||
|
the user or the operator. A feature that can quietly stop working — a frozen
|
||||||
|
"streaming…" UI, a poll that silently gives up, a cache serving corpses — does
|
||||||
|
not pass review.
|
||||||
|
(Paid for by: the degraded poll's silent 10-minute death leaving a forever-
|
||||||
|
"streaming" answer; dead MCP clients served from cache while every external
|
||||||
|
tool call failed; #435 being caught in minutes ONLY because metrics — #403 —
|
||||||
|
existed.)
|
||||||
|
|
||||||
|
## Default skill for feature design
|
||||||
|
|
||||||
|
For any feature-design request — the user hands over a raw feature idea, asks
|
||||||
|
to design or think through a feature, or to draft an issue («спроектируй»,
|
||||||
|
«продумай фичу», «составь ишью», "design X", "write an issue for X") — invoke
|
||||||
|
the `orchestrator-feature-designer` skill (Skill tool) BEFORE any other work.
|
||||||
|
It is the default operating mode for design work in this repository: research
|
||||||
|
→ design checklist (R1–R10) → forks resolved with the human → adversarial
|
||||||
|
self-attack → filed PR-sized issues. Do not design features or write issues
|
||||||
|
ad-hoc while this skill is available. This does not apply to non-design work
|
||||||
|
(bug fixes, reviews, retrospectives, refactors already specified by an issue).
|
||||||
|
|
||||||
## Task lifecycle
|
## Task lifecycle
|
||||||
|
|
||||||
### 1. Start: sync with develop
|
### 1. Start: sync with develop
|
||||||
@@ -201,7 +334,7 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
|
|||||||
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend |
|
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend |
|
||||||
| `packages/editor-ext` | `@docmost/editor-ext` | Tiptap/ProseMirror | Shared Tiptap node/mark extensions, imported by both the client and the server |
|
| `packages/editor-ext` | `@docmost/editor-ext` | Tiptap/ProseMirror | Shared Tiptap node/mark extensions, imported by both the client and the server |
|
||||||
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Consumes the shared converter/schema from `@docmost/prosemirror-markdown` (#293) — it no longer carries its own vendored converter/schema copy |
|
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Consumes the shared converter/schema from `@docmost/prosemirror-markdown` (#293) — it no longer carries its own vendored converter/schema copy |
|
||||||
| `packages/prosemirror-markdown` | `@docmost/prosemirror-markdown` | Tiptap, marked, jsdom | The single, canonical ProseMirror↔Markdown converter + Docmost schema mirror (#293). Consumed by `mcp`, `git-sync`, AND `apps/server` (server-side markdown import/export, #345); there is exactly ONE copy of the converter now |
|
| `packages/prosemirror-markdown` | `@docmost/prosemirror-markdown` | Tiptap, marked; jsdom (Node only) | The single, canonical ProseMirror↔Markdown converter + Docmost schema mirror (#293). Consumed by `mcp`, `git-sync`, `apps/server` (server-side markdown import/export, #345), AND `apps/client` (markdown paste/copy + AI-chat render, via the `browser` entry — native `DOMParser`, no jsdom in the client bundle, #347); there is exactly ONE copy of the converter now |
|
||||||
|
|
||||||
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
||||||
|
|
||||||
@@ -327,7 +460,7 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
|
|||||||
### Client structure
|
### Client structure
|
||||||
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
||||||
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
|
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
|
||||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, and `apps/server` (#345) — do NOT reintroduce a per-package copy. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`.
|
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, `apps/server` (#345), and `apps/client` (#347) — do NOT reintroduce a per-package copy. The client uses the package's `browser` entry (`@docmost/prosemirror-markdown/browser`): markdown paste (`markdown-clipboard.ts`), copy-as-markdown, and AI-chat rendering now all go through the canonical converter, so the hand-written `marked`/`turndown` markdown layer that used to live in `editor-ext` was deleted (#347). The browser entry runs the HTML→DOM stage on the native `DOMParser`, so jsdom stays out of the client bundle. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`.
|
||||||
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
||||||
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
||||||
|
|
||||||
|
|||||||
@@ -251,9 +251,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
by physical key position and matched against the commands; genuine Cyrillic
|
by physical key position and matched against the commands; genuine Cyrillic
|
||||||
search terms keep priority over remapped candidates, and short wrong-layout
|
search terms keep priority over remapped candidates, and short wrong-layout
|
||||||
prefixes match by command title. (#283, #285, #287)
|
prefixes match by command title. (#283, #285, #287)
|
||||||
|
- **Opt-in substring "lookup" search mode for agents.** `/api/search` gains an
|
||||||
|
additive, opt-in mode (guarded by a new `substring` flag) that matches literal
|
||||||
|
substrings of page titles and body text — so technical tokens the full-text
|
||||||
|
tokenizer mangles (`backup-srv.local`, `10.0.12.5`, `WB-MGE-30D86B`) are found
|
||||||
|
even when the FTS query is empty. It returns a location `path`, a windowed
|
||||||
|
`snippet` and a per-response relevance `score`, supports `titleOnly` and a
|
||||||
|
`parentPageId` subtree scope, and applies the page-level permission filter
|
||||||
|
before the limit. The web UI never sets `substring`, so its full-text search
|
||||||
|
behaviour is byte-for-byte unchanged. The leading-wildcard `LIKE` predicates
|
||||||
|
are backed by GIN trigram indexes on `LOWER(f_unaccent(title))` and
|
||||||
|
`LOWER(f_unaccent(text_content))` so lookups use a bitmap index scan instead of
|
||||||
|
a sequential scan. (#443)
|
||||||
|
- **MCP `search` tool returns richer, agent-oriented results.** The external MCP
|
||||||
|
`search` response shape changes for the agent surface: each hit now carries
|
||||||
|
`pageId` (renamed from `id`), plus `path`, `snippet` and `score`; the
|
||||||
|
UI-oriented `spaceId`, `rank` and `highlight` fields are dropped. (#443)
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- **Client markdown paste/copy and AI-chat rendering now go through the canonical
|
||||||
|
converter.** Pasting markdown into the editor, "Copy as markdown", the AI title
|
||||||
|
generator, and the AI-chat markdown renderer all now use
|
||||||
|
`@docmost/prosemirror-markdown` (via its new `browser` entry — native
|
||||||
|
`DOMParser`, no jsdom in the client bundle) instead of the hand-written
|
||||||
|
`marked`/`turndown` markdown layer in `editor-ext`, which was **deleted**. As a
|
||||||
|
result, pasting canonical markdown (`^[…]` footnotes, `<!--img …-->`,
|
||||||
|
`> [!type]` callouts, `$…$` math, `==…==` highlight, standalone `<!--subpages-->`
|
||||||
|
comments) now produces the SAME nodes the server import produces for the same
|
||||||
|
text. Chat/reasoning markdown now renders through the editor schema (list items
|
||||||
|
are wrapped in `<p>`; CSS keeps them tight). (#347)
|
||||||
|
|
||||||
- **Enabling a public share no longer auto-shares the whole sub-tree.** Turning
|
- **Enabling a public share no longer auto-shares the whole sub-tree.** Turning
|
||||||
a page "Shared to web" now defaults to the page alone; descendant pages become
|
a page "Shared to web" now defaults to the page alone; descendant pages become
|
||||||
public only when you explicitly turn on the dedicated "Include sub-pages"
|
public only when you explicitly turn on the dedicated "Include sub-pages"
|
||||||
|
|||||||
+15
@@ -45,6 +45,11 @@ COPY --from=builder /app/packages/editor-ext/dist /app/packages/editor-ext/dist
|
|||||||
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
COPY --from=builder /app/packages/editor-ext/package.json /app/packages/editor-ext/package.json
|
||||||
COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build
|
COPY --from=builder /app/packages/mcp/build /app/packages/mcp/build
|
||||||
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
|
COPY --from=builder /app/packages/mcp/package.json /app/packages/mcp/package.json
|
||||||
|
# The mcp package reads its data files (drawio-presets.json, drawio-shape-index.json.gz)
|
||||||
|
# at runtime via `new URL("../../data/…", import.meta.url)` relative to build/lib/*.js,
|
||||||
|
# i.e. from packages/mcp/data/. tsc emits only build/, so ship data/ explicitly or
|
||||||
|
# drawioFromGraph and the shape catalog die with ENOENT on packages/mcp/data/*.
|
||||||
|
COPY --from=builder /app/packages/mcp/data /app/packages/mcp/data
|
||||||
# mcp now depends on @docmost/prosemirror-markdown (workspace:*) and eager-imports
|
# mcp now depends on @docmost/prosemirror-markdown (workspace:*) and eager-imports
|
||||||
# it at runtime (the in-app ai-chat DocmostClient loads build/index.js -> lib/
|
# it at runtime (the in-app ai-chat DocmostClient loads build/index.js -> lib/
|
||||||
# markdown-converter.js). Ship the built package + its manifest, or the prod
|
# markdown-converter.js). Ship the built package + its manifest, or the prod
|
||||||
@@ -81,4 +86,14 @@ VOLUME ["/app/data/storage"]
|
|||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# DEPLOY REQUIREMENT — SINGLE INSTANCE or STICKY SESSIONS (#449).
|
||||||
|
# MCP content writes are serialized per page by an IN-PROCESS mutex, and the
|
||||||
|
# stash_page blob store + cached collab sessions are RAM-only and process-local.
|
||||||
|
# Running MULTIPLE replicas of this image behind a load balancer WITHOUT sticky
|
||||||
|
# sessions silently breaks per-page write serialization (two replicas can lock
|
||||||
|
# the same page at once) and makes stash_page blobs unreachable across replicas.
|
||||||
|
# Run a SINGLE instance, or pin each page's traffic to one replica (sticky
|
||||||
|
# sessions / consistent hashing on page id). There is deliberately no
|
||||||
|
# cross-process lock yet — a conscious constraint. See .env.example (the "MCP
|
||||||
|
# collaboration write path" block) and packages/mcp/README.md for details.
|
||||||
CMD ["pnpm", "start"]
|
CMD ["pnpm", "start"]
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"@atlaskit/pragmatic-drag-and-drop-live-region": "1.3.4",
|
"@atlaskit/pragmatic-drag-and-drop-live-region": "1.3.4",
|
||||||
"@casl/react": "5.0.1",
|
"@casl/react": "5.0.1",
|
||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
|
"@docmost/prosemirror-markdown": "workspace:*",
|
||||||
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
|
"@excalidraw/excalidraw": "0.18.0-3a5ef40",
|
||||||
"@mantine/core": "8.3.18",
|
"@mantine/core": "8.3.18",
|
||||||
"@mantine/dates": "8.3.18",
|
"@mantine/dates": "8.3.18",
|
||||||
|
|||||||
@@ -55,6 +55,15 @@
|
|||||||
padding-inline-start: 1.4em;
|
padding-inline-start: 1.4em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The canonical converter renders list items through the editor schema, which
|
||||||
|
wraps each item's content in a <p> (listItem content is `paragraph+`). Drop
|
||||||
|
that paragraph's block margin so list items render TIGHT (no extra vertical
|
||||||
|
gap), matching the previous marked output — same rule already applied to
|
||||||
|
table cells above (issue #347). */
|
||||||
|
.markdown li p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* GFM tables in assistant markdown. The chat lives in a NARROW side panel, so a
|
/* GFM tables in assistant markdown. The chat lives in a NARROW side panel, so a
|
||||||
wide LLM table must scroll horizontally instead of collapsing its columns:
|
wide LLM table must scroll horizontally instead of collapsing its columns:
|
||||||
`.markdown` sets `word-break: break-word`, which (with the default table
|
`.markdown` sets `word-break: break-word`, which (with the default table
|
||||||
@@ -172,6 +181,14 @@
|
|||||||
margin: 0 0 4px;
|
margin: 0 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Same as `.markdown li p` above: the canonical converter wraps every list
|
||||||
|
item's content in a <p>, so without this each reasoning-panel list item would
|
||||||
|
pick up `.reasoningText p`'s 4px bottom margin and render too loose. Drop it
|
||||||
|
so Reasoning-panel lists stay tight, mirroring the pre-#347 marked output. */
|
||||||
|
.reasoningText li p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.inputWrapper {
|
.inputWrapper {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding-top: var(--mantine-spacing-xs);
|
padding-top: var(--mantine-spacing-xs);
|
||||||
|
|||||||
@@ -33,29 +33,44 @@ describe("collapseBlankLines", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("collapseBlankLines + renderChatMarkdown (tight reasoning rendering)", () => {
|
describe("collapseBlankLines + renderChatMarkdown (canonical converter)", () => {
|
||||||
it("renders a blank-line-separated list as a TIGHT list (no <li><p>)", () => {
|
// Chat markdown now renders through @docmost/prosemirror-markdown (issue #347):
|
||||||
|
// the SAME converter the editor/import use. Its list items are schema-shaped —
|
||||||
|
// each <li>'s content is wrapped in a <p> (listItem content is `paragraph+`) —
|
||||||
|
// so the HTML always carries `<li><p>…</p></li>` regardless of blank-line
|
||||||
|
// looseness in the source (the converter has no tight/loose distinction). The
|
||||||
|
// visual tightness that `collapseBlankLines` used to buy is now provided by
|
||||||
|
// CSS (`.markdown li p { margin: 0 }`), not the HTML shape.
|
||||||
|
it("renders a blank-line-separated bullet list as a real <ul> list", () => {
|
||||||
const loose =
|
const loose =
|
||||||
"Intro paragraph.\n\n- item one\n\n- item two\n\n- item three";
|
"Intro paragraph.\n\n- item one\n\n- item two\n\n- item three";
|
||||||
const html = renderChatMarkdown(collapseBlankLines(loose), {});
|
const html = renderChatMarkdown(collapseBlankLines(loose), {});
|
||||||
// Tight list: each <li> holds the text directly, not wrapped in a <p>.
|
// Clean, un-namespaced HTML (DOMSerializer, not XMLSerializer) — no xmlns.
|
||||||
expect(html).toContain("<li>item one</li>");
|
|
||||||
expect(html).not.toContain("<li><p>");
|
|
||||||
// The list still parses as a list after the paragraph (not a paragraph+<br>).
|
|
||||||
expect(html).toContain("<ul>");
|
expect(html).toContain("<ul>");
|
||||||
|
expect(html).not.toMatch(/<ul[^>]*xmlns/);
|
||||||
|
// The item text is present (inside the schema's <li><p> wrapper).
|
||||||
|
expect(html).toContain("item one");
|
||||||
|
// The intro paragraph renders as its own paragraph before the list.
|
||||||
expect(html).toContain("<p>Intro paragraph.</p>");
|
expect(html).toContain("<p>Intro paragraph.</p>");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders an ordered list (1. 2.) as tight after collapsing", () => {
|
it("renders an ordered list (1. 2.) as a real <ol> list", () => {
|
||||||
const loose = "Intro.\n\n1. first\n\n2. second";
|
const loose = "Intro.\n\n1. first\n\n2. second";
|
||||||
const html = renderChatMarkdown(collapseBlankLines(loose), {});
|
const html = renderChatMarkdown(collapseBlankLines(loose), {});
|
||||||
expect(html).toContain("<ol>");
|
expect(html).toContain("<ol>");
|
||||||
expect(html).toContain("<li>first</li>");
|
expect(html).not.toMatch(/<ol[^>]*xmlns/);
|
||||||
expect(html).not.toContain("<li><p>");
|
expect(html).toContain("first");
|
||||||
|
expect(html).toContain("second");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the loose source WOULD render <li><p> without collapsing (control)", () => {
|
it("wraps list-item content in <p> (schema shape; tightness is CSS)", () => {
|
||||||
|
// The canonical converter always wraps a list item's content in a paragraph,
|
||||||
|
// whether or not the source had blank lines between items.
|
||||||
const loose = "- a\n\n- b";
|
const loose = "- a\n\n- b";
|
||||||
expect(renderChatMarkdown(loose, {})).toContain("<li><p>");
|
expect(renderChatMarkdown(loose, {})).toContain("<li><p>");
|
||||||
|
// And a "tight" source produces the identical wrapping (no distinction).
|
||||||
|
expect(renderChatMarkdown(collapseBlankLines(loose), {})).toContain(
|
||||||
|
"<li><p>",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,37 @@
|
|||||||
import { markdownToHtml } from "@docmost/editor-ext";
|
import {
|
||||||
|
markdownToProseMirrorSync,
|
||||||
|
docmostExtensions,
|
||||||
|
} from "@docmost/prosemirror-markdown/browser";
|
||||||
|
import { getSchema } from "@tiptap/core";
|
||||||
|
import { Node as PMNode, DOMSerializer } from "@tiptap/pm/model";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
|
|
||||||
|
// The Docmost editor schema, built once. Chat markdown is rendered through the
|
||||||
|
// SAME schema the editor/import use (issue #347), so chat output matches how the
|
||||||
|
// page would render the same markdown.
|
||||||
|
const chatSchema = getSchema(docmostExtensions);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Markdown -> HTML for chat display, via the canonical converter. We serialize
|
||||||
|
* the ProseMirror doc with `DOMSerializer` into a real element and read its
|
||||||
|
* `innerHTML` (rather than `@tiptap/html`'s `generateHTML`, whose browser path
|
||||||
|
* uses `XMLSerializer` and stamps a `xmlns` on every block) so the markup is
|
||||||
|
* clean HTML. `li > p` wrapping is inherent to the schema (listItem content is
|
||||||
|
* `paragraph+`); the chat CSS zeroes those paragraph margins so lists still
|
||||||
|
* render tight.
|
||||||
|
*/
|
||||||
|
function markdownToChatHtml(markdown: string): string {
|
||||||
|
const doc = markdownToProseMirrorSync(markdown);
|
||||||
|
const node = PMNode.fromJSON(chatSchema, doc);
|
||||||
|
const div = document.createElement("div");
|
||||||
|
DOMSerializer.fromSchema(chatSchema).serializeFragment(
|
||||||
|
node.content,
|
||||||
|
{ document },
|
||||||
|
div,
|
||||||
|
);
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RenderChatMarkdownOptions {
|
export interface RenderChatMarkdownOptions {
|
||||||
/**
|
/**
|
||||||
* Neutralize INTERNAL links so they render as inert text (no `href`/`target`).
|
* Neutralize INTERNAL links so they render as inert text (no `href`/`target`).
|
||||||
@@ -63,22 +94,32 @@ function neutralizeInternalLinksHook(node: Element): void {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Render AI markdown to sanitized HTML for read-only display. We reuse the
|
* Render AI markdown to sanitized HTML for read-only display. We reuse the
|
||||||
* app's `markdownToHtml` (the same `marked` pipeline used for paste/import) so
|
* canonical converter (issue #347): markdown -> ProseMirror JSON (the SAME
|
||||||
* chat output matches the editor's markdown flavor, then sanitize with
|
* `markdownToProseMirrorSync` the editor paste/import path uses, so chat output
|
||||||
* DOMPurify — LLM output is untrusted, so it must never reach the DOM unsanitized.
|
* matches the editor's markdown flavor) -> HTML via `markdownToChatHtml`
|
||||||
|
* (DOMSerializer), then sanitize with DOMPurify — LLM output is untrusted, so it
|
||||||
|
* must never reach the DOM unsanitized.
|
||||||
*
|
*
|
||||||
* `markdownToHtml` can return `string | Promise<string>` (it has async marked
|
* Stays SYNCHRONOUS: both callers render inside React (a memo and a useMemo),
|
||||||
* extensions registered). In practice plain chat markdown resolves
|
* so the whole pipeline must resolve without awaiting. The converter's sync
|
||||||
* synchronously, but we guard the Promise case by returning a safe empty string
|
* entry makes that possible; on any conversion error we return "" so the caller
|
||||||
* for that branch (the caller renders the raw text fallback instead).
|
* falls back to raw text (the same fallback the old Promise-guard produced).
|
||||||
*/
|
*/
|
||||||
export function renderChatMarkdown(
|
export function renderChatMarkdown(
|
||||||
markdown: string,
|
markdown: string,
|
||||||
options: RenderChatMarkdownOptions = {},
|
options: RenderChatMarkdownOptions = {},
|
||||||
): string {
|
): string {
|
||||||
if (!markdown) return "";
|
if (!markdown) return "";
|
||||||
const html = markdownToHtml(markdown);
|
let html: string;
|
||||||
if (typeof html !== "string") return "";
|
try {
|
||||||
|
// markdown -> canonical PM JSON -> HTML (native DOMParser in the browser;
|
||||||
|
// jsdom is never bundled — see @docmost/prosemirror-markdown/browser).
|
||||||
|
html = markdownToChatHtml(markdown);
|
||||||
|
} catch {
|
||||||
|
// Malformed/unsupported markdown must not crash the chat render; fall back
|
||||||
|
// to raw text (empty return -> caller shows the plain-text branch).
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
if (!options.neutralizeInternalLinks) {
|
if (!options.neutralizeInternalLinks) {
|
||||||
// Internal chat: unchanged behavior, no hook registered.
|
// Internal chat: unchanged behavior, no hook registered.
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
import { Document } from "@tiptap/extension-document";
|
||||||
|
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||||
|
import { Text } from "@tiptap/extension-text";
|
||||||
|
import { Bold } from "@tiptap/extension-bold";
|
||||||
|
import { Italic } from "@tiptap/extension-italic";
|
||||||
|
import { MarkdownClipboard } from "./markdown-clipboard";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration coverage for the async `handlePaste` seam (issue #347). The paste
|
||||||
|
* conversion moved to `@docmost/prosemirror-markdown`'s browser entry, whose
|
||||||
|
* `markdownToProseMirror` is async — so `handlePaste` captures the range, claims
|
||||||
|
* the event (returns true), and dispatches the insert on the next microtask.
|
||||||
|
* These tests drive that path end to end on a minimal schema (a plain-markdown
|
||||||
|
* paste whose converted nodes fit paragraph/text/bold/italic), asserting the
|
||||||
|
* text lands with the right marks and that the raw markdown syntax is consumed
|
||||||
|
* (recognized as markdown, not inserted literally).
|
||||||
|
*/
|
||||||
|
|
||||||
|
function makeEditor() {
|
||||||
|
const element = document.createElement("div");
|
||||||
|
document.body.appendChild(element);
|
||||||
|
return new Editor({
|
||||||
|
element,
|
||||||
|
extensions: [
|
||||||
|
Document,
|
||||||
|
Paragraph,
|
||||||
|
Text,
|
||||||
|
Bold,
|
||||||
|
Italic,
|
||||||
|
MarkdownClipboard.configure({ transformPastedText: true }),
|
||||||
|
],
|
||||||
|
content: { type: "doc", content: [{ type: "paragraph" }] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locate the markdownClipboard plugin and invoke its handlePaste directly with a
|
||||||
|
// synthetic clipboard event (jsdom has no real paste pipeline). The plugin's
|
||||||
|
// handlePaste closes over the extension `this`, so calling it off the plugin
|
||||||
|
// props preserves `this.editor`/`this.options`.
|
||||||
|
function paste(editor: Editor, text: string): boolean {
|
||||||
|
const view = editor.view;
|
||||||
|
const plugin = view.state.plugins.find(
|
||||||
|
(p: any) => p.props && p.spec?.key,
|
||||||
|
) as any;
|
||||||
|
const event = {
|
||||||
|
clipboardData: {
|
||||||
|
getData: (type: string) => (type === "text/plain" ? text : ""),
|
||||||
|
},
|
||||||
|
} as unknown as ClipboardEvent;
|
||||||
|
// Find the specific handlePaste that belongs to the markdown clipboard plugin.
|
||||||
|
const md = view.state.plugins.find(
|
||||||
|
(p: any) => typeof p.props?.handlePaste === "function",
|
||||||
|
) as any;
|
||||||
|
return md.props.handlePaste(view, event, view.state.selection.content());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush the microtask queue so the async .then() dispatch runs.
|
||||||
|
const flush = () => new Promise((r) => setTimeout(r, 0));
|
||||||
|
|
||||||
|
describe("MarkdownClipboard handlePaste (async md -> PM)", () => {
|
||||||
|
it("converts a plain-markdown paste with bold/italic into marked text", async () => {
|
||||||
|
const editor = makeEditor();
|
||||||
|
const claimed = paste(editor, "hello **bold** and *italic*");
|
||||||
|
// The paste is claimed synchronously (async insert follows).
|
||||||
|
expect(claimed).toBe(true);
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const json = editor.getJSON();
|
||||||
|
const text = JSON.stringify(json);
|
||||||
|
// The raw markdown asterisks are consumed (recognized), not inserted literally.
|
||||||
|
expect(editor.getText()).not.toContain("**");
|
||||||
|
expect(editor.getText()).toContain("bold");
|
||||||
|
expect(editor.getText()).toContain("italic");
|
||||||
|
// The bold/italic marks materialized.
|
||||||
|
expect(text).toContain('"bold"');
|
||||||
|
expect(text).toContain('"italic"');
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognizes a bullet list paste as list structure (not literal '-')", async () => {
|
||||||
|
// A bullet list is not representable in this minimal schema, so the converter
|
||||||
|
// output would fail PMNode.fromJSON and the catch inserts raw text. Use a
|
||||||
|
// paste whose nodes DO fit the schema to assert the happy path instead: two
|
||||||
|
// paragraphs separated by a blank line.
|
||||||
|
const editor = makeEditor();
|
||||||
|
paste(editor, "first para\n\nsecond para");
|
||||||
|
await flush();
|
||||||
|
const json = editor.getJSON() as any;
|
||||||
|
const paras = (json.content || []).filter(
|
||||||
|
(n: any) => n.type === "paragraph",
|
||||||
|
);
|
||||||
|
// Two paragraphs materialized from the blank-line-separated markdown.
|
||||||
|
expect(paras.length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(editor.getText()).toContain("first para");
|
||||||
|
expect(editor.getText()).toContain("second para");
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to raw text when conversion yields nodes the schema lacks", async () => {
|
||||||
|
// `# heading` converts to a `heading` node absent from this minimal schema,
|
||||||
|
// so PMNode.fromJSON throws and the catch re-inserts the raw text — the user
|
||||||
|
// never loses their clipboard content.
|
||||||
|
const editor = makeEditor();
|
||||||
|
paste(editor, "# a heading line");
|
||||||
|
await flush();
|
||||||
|
// Content is preserved (either as heading text or literal), never dropped.
|
||||||
|
expect(editor.getText()).toContain("a heading line");
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The async seam captures the target range synchronously, then replaces on the
|
||||||
|
// next microtask. If the document changed under it between capture and resolve
|
||||||
|
// (impossible in prod — same microtask — but pinned here), BOTH the success
|
||||||
|
// (replaceRange) and the fail-open (insertText) branches must fall back to the
|
||||||
|
// LIVE selection rather than a stale absolute range, so neither clobbers content
|
||||||
|
// nor throws a RangeError. We force the mid-flight change by dispatching a
|
||||||
|
// doc-mutating transaction AFTER the synchronous claim but BEFORE flushing the
|
||||||
|
// microtask that runs the `.then`/`.catch`.
|
||||||
|
describe("MarkdownClipboard handlePaste — doc-changed-mid-flight guard", () => {
|
||||||
|
// Replace the whole doc with one paragraph of `text` (synchronous dispatch).
|
||||||
|
// An empty string yields an empty paragraph (a text node may not be empty).
|
||||||
|
function seedContent(editor: Editor, text: string) {
|
||||||
|
editor.commands.setContent({
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
text
|
||||||
|
? { type: "paragraph", content: [{ type: "text", text }] }
|
||||||
|
: { type: "paragraph" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("success branch: mid-flight doc change routes the paste to the LIVE selection, never the stale range (clobber-proving)", async () => {
|
||||||
|
// The paste captures a NON-EMPTY range {1,5} (over "AAAA"). Then, before the
|
||||||
|
// async resolve, the doc GROWS ("MARKER" inserted at the start) and the cursor
|
||||||
|
// is parked at the doc END. The captured {1,5} is now stale and points INTO
|
||||||
|
// "MARKER". A WORKING guard replaces at the live (end) selection → MARKER is
|
||||||
|
// untouched. A BROKEN guard replaces the stale {1,5} → it erases the first
|
||||||
|
// characters of MARKER (this is what a zero-width `from==to` range could never
|
||||||
|
// reveal, which is why the earlier version was vacuous).
|
||||||
|
const editor = makeEditor();
|
||||||
|
seedContent(editor, "AAAABBBB");
|
||||||
|
editor.commands.setTextSelection({ from: 1, to: 5 }); // captured range = {1,5}
|
||||||
|
const claimed = paste(editor, "hello **bold**");
|
||||||
|
expect(claimed).toBe(true);
|
||||||
|
|
||||||
|
// Mid-flight: grow the doc and move the cursor to a KNOWN-safe end position.
|
||||||
|
editor.view.dispatch(editor.view.state.tr.insertText("MARKER", 1));
|
||||||
|
const end = editor.state.doc.content.size;
|
||||||
|
editor.commands.setTextSelection({ from: end, to: end });
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const text = editor.getText();
|
||||||
|
// MARKER intact only if the guard used the live selection, not the stale range.
|
||||||
|
expect(text).toContain("MARKER");
|
||||||
|
expect(text).toContain("bold");
|
||||||
|
expect(text).not.toContain("**");
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fail-open branch: a mid-flight doc SHRINK makes the stale `to` out of bounds — the guard must avoid a RangeError (throw-proving)", async () => {
|
||||||
|
// The paste captures a range {1,9} over an 8-char paragraph, then the
|
||||||
|
// conversion FAILS (`# heading` -> a heading node the minimal schema lacks,
|
||||||
|
// so PMNode.fromJSON throws -> the fail-open catch runs). Before the reject,
|
||||||
|
// the doc is SHRUNK to an empty paragraph, so the captured `to` (9) is now far
|
||||||
|
// past the doc's end. A WORKING guard inserts the raw text at the live (valid)
|
||||||
|
// selection → "raw heading" lands. A BROKEN guard does insertText(md, 1, 9) on
|
||||||
|
// a size-2 doc → RangeError, so the dispatch never runs and "raw heading" is
|
||||||
|
// absent (the assertion reddens). A zero-width/growing-doc setup could never
|
||||||
|
// push `to` out of bounds, which is why the earlier version was vacuous.
|
||||||
|
const editor = makeEditor();
|
||||||
|
seedContent(editor, "AAAABBBB");
|
||||||
|
editor.commands.setTextSelection({ from: 1, to: 9 }); // captured range = {1,9}
|
||||||
|
paste(editor, "# raw heading");
|
||||||
|
|
||||||
|
// Mid-flight: shrink the doc so the captured `to` = 9 is now out of bounds.
|
||||||
|
seedContent(editor, "");
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const text = editor.getText();
|
||||||
|
// Raw text lands (via the live selection) only if the guard avoided the
|
||||||
|
// stale, now-out-of-bounds range.
|
||||||
|
expect(text).toContain("raw heading");
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("two pastes in flight: neither payload is lost (no data loss)", async () => {
|
||||||
|
// Prod-unreachable (two paste events are separate macrotasks, and each
|
||||||
|
// conversion resolves on a microtask before the next), but pinned here: when
|
||||||
|
// both resolve back-to-back, the second sees the changed doc and inserts at
|
||||||
|
// the live selection the first left — so the two payloads may INTERLEAVE, but
|
||||||
|
// neither is dropped. We assert no data loss, not contiguity.
|
||||||
|
const editor = makeEditor();
|
||||||
|
paste(editor, "alphaword");
|
||||||
|
paste(editor, "betaword");
|
||||||
|
await flush();
|
||||||
|
const text = editor.getText();
|
||||||
|
// Neither payload fully dropped (interleaving may split one of them).
|
||||||
|
expect(text).toContain("alpha");
|
||||||
|
expect(text).toContain("beta");
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
// Markdown conversion now goes through the canonical package's BROWSER entry
|
||||||
|
// (issue #347): the same converter the server import/export uses, resolved via
|
||||||
|
// the `browser` exports condition so it runs on the native `DOMParser` (the
|
||||||
|
// client jsdom vitest env provides one) with jsdom never bundled.
|
||||||
|
import {
|
||||||
|
convertProseMirrorToMarkdown,
|
||||||
|
markdownToProseMirrorSync,
|
||||||
|
} from "@docmost/prosemirror-markdown/browser";
|
||||||
import {
|
import {
|
||||||
normalizeTableColumnWidths,
|
normalizeTableColumnWidths,
|
||||||
classifyClipboardSelection,
|
classifyClipboardSelection,
|
||||||
@@ -175,10 +182,13 @@ describe("classifyClipboardSelection", () => {
|
|||||||
|
|
||||||
// Output-level tests for the table clipboard regression: copying a table must
|
// Output-level tests for the table clipboard regression: copying a table must
|
||||||
// yield a real GFM pipe table, NOT one-value-per-line concatenated cells.
|
// yield a real GFM pipe table, NOT one-value-per-line concatenated cells.
|
||||||
// These exercise the actual markdown produced by htmlToMarkdown (the same
|
// These exercise the actual markdown produced by convertProseMirrorToMarkdown —
|
||||||
// serializer step the clipboardTextSerializer runs), so they pin the OUTPUT
|
// the same serializer step the clipboardTextSerializer now runs (issue #347) —
|
||||||
// shape that the classifier-flag tests above do not cover.
|
// so they pin the OUTPUT shape that the classifier-flag tests above do not cover.
|
||||||
describe("table clipboard markdown output (htmlToMarkdown)", () => {
|
// Input is ProseMirror JSON (what the copied slice serializes to), matching the
|
||||||
|
// clipboardTextSerializer's new call: it wraps the slice content in a synthetic
|
||||||
|
// `doc` (and the bare-rows case in a `table`) and calls the converter.
|
||||||
|
describe("table clipboard markdown output (convertProseMirrorToMarkdown)", () => {
|
||||||
// Trim each line and drop blanks so structural assertions are whitespace-robust.
|
// Trim each line and drop blanks so structural assertions are whitespace-robust.
|
||||||
function lines(md: string): string[] {
|
function lines(md: string): string[] {
|
||||||
return md
|
return md
|
||||||
@@ -188,10 +198,10 @@ describe("table clipboard markdown output (htmlToMarkdown)", () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A GFM separator row like "| --- | --- |" (any number of columns), tolerant
|
// A GFM separator row like "| --- | --- |" (any number of columns), tolerant
|
||||||
// of the padding turndown emits.
|
// of the padding the serializer emits.
|
||||||
function isSeparatorRow(line: string): boolean {
|
function isSeparatorRow(line: string): boolean {
|
||||||
const compact = line.replace(/\s+/g, "");
|
const compact = line.replace(/\s+/g, "");
|
||||||
return /^\|(?:-{3,}\|)+$/.test(compact);
|
return /^\|(?::?-{2,}:?\|)+$/.test(compact);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Split a pipe-delimited row into trimmed cell values.
|
// Split a pipe-delimited row into trimmed cell values.
|
||||||
@@ -203,42 +213,33 @@ describe("table clipboard markdown output (htmlToMarkdown)", () => {
|
|||||||
.map((c) => c.trim());
|
.map((c) => c.trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
it("serializes a header-less partial cell selection (bare rows) as a valid GFM pipe table", () => {
|
const cell = (t: string) => ({
|
||||||
// Mirror the serializer's `wrapBareRows` branch exactly: bare <tr> nodes are
|
type: "tableCell",
|
||||||
// wrapped in <table><tbody> and htmlToMarkdown(div.innerHTML) is called.
|
content: [{ type: "paragraph", content: [{ type: "text", text: t }] }],
|
||||||
// See markdown-clipboard.ts clipboardTextSerializer:
|
});
|
||||||
// const table = document.createElement("table");
|
const headerCell = (t: string) => ({
|
||||||
// const tbody = document.createElement("tbody");
|
type: "tableHeader",
|
||||||
// tbody.appendChild(fragment); table.appendChild(tbody);
|
content: [{ type: "paragraph", content: [{ type: "text", text: t }] }],
|
||||||
// div.appendChild(table);
|
});
|
||||||
// return htmlToMarkdown(div.innerHTML);
|
const row = (nodes: any[]) => ({ type: "tableRow", content: nodes });
|
||||||
const div = document.createElement("div");
|
|
||||||
const table = document.createElement("table");
|
|
||||||
const tbody = document.createElement("tbody");
|
|
||||||
for (const [c1, c2] of [
|
|
||||||
["a", "b"],
|
|
||||||
["c", "d"],
|
|
||||||
]) {
|
|
||||||
const tr = document.createElement("tr");
|
|
||||||
const td1 = document.createElement("td");
|
|
||||||
td1.textContent = c1;
|
|
||||||
const td2 = document.createElement("td");
|
|
||||||
td2.textContent = c2;
|
|
||||||
tr.appendChild(td1);
|
|
||||||
tr.appendChild(td2);
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
}
|
|
||||||
table.appendChild(tbody);
|
|
||||||
div.appendChild(table);
|
|
||||||
|
|
||||||
const md = htmlToMarkdown(div.innerHTML);
|
it("serializes a header-less partial cell selection (bare rows) as a valid GFM pipe table", () => {
|
||||||
|
// Mirror the serializer's `wrapBareRows` branch: bare tableRow nodes are
|
||||||
|
// wrapped in a synthetic `table` and convertProseMirrorToMarkdown is called
|
||||||
|
// (see markdown-clipboard.ts clipboardTextSerializer).
|
||||||
|
const rows = [
|
||||||
|
row([cell("a"), cell("b")]),
|
||||||
|
row([cell("c"), cell("d")]),
|
||||||
|
];
|
||||||
|
const md = convertProseMirrorToMarkdown({
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "table", content: rows }],
|
||||||
|
});
|
||||||
const ls = lines(md);
|
const ls = lines(md);
|
||||||
|
|
||||||
// Valid GFM: a header/data separator row is present (an empty header is
|
// Valid GFM: a header/data separator row is present.
|
||||||
// synthesized by the GFM turndown plugin for a header-less table — fine).
|
|
||||||
expect(ls.some(isSeparatorRow)).toBe(true);
|
expect(ls.some(isSeparatorRow)).toBe(true);
|
||||||
// NOT the old broken "one value per line" shape: every line is pipe-delimited
|
// NOT the old broken "one value per line" shape: every line is pipe-delimited.
|
||||||
// and no line is a bare cell value on its own.
|
|
||||||
expect(ls.every((l) => l.includes("|"))).toBe(true);
|
expect(ls.every((l) => l.includes("|"))).toBe(true);
|
||||||
expect(md).not.toMatch(/^\s*(a|b|c|d)\s*$/m);
|
expect(md).not.toMatch(/^\s*(a|b|c|d)\s*$/m);
|
||||||
// The cell values land in real pipe-delimited data rows.
|
// The cell values land in real pipe-delimited data rows.
|
||||||
@@ -248,39 +249,21 @@ describe("table clipboard markdown output (htmlToMarkdown)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("serializes a whole table with a header row as a proper GFM table (headline regression)", () => {
|
it("serializes a whole table with a header row as a proper GFM table (headline regression)", () => {
|
||||||
// Mirror the serializer's non-wrap branch: the full <table> node is appended
|
// Mirror the serializer's non-wrap branch: the full `table` node is the
|
||||||
// directly (div.appendChild(fragment)) and htmlToMarkdown(div.innerHTML) runs.
|
// slice content and convertProseMirrorToMarkdown runs on it.
|
||||||
const div = document.createElement("div");
|
const md = convertProseMirrorToMarkdown({
|
||||||
const table = document.createElement("table");
|
type: "doc",
|
||||||
|
content: [
|
||||||
const thead = document.createElement("thead");
|
{
|
||||||
const headerRow = document.createElement("tr");
|
type: "table",
|
||||||
for (const h of ["Name", "Age"]) {
|
content: [
|
||||||
const th = document.createElement("th");
|
row([headerCell("Name"), headerCell("Age")]),
|
||||||
th.textContent = h;
|
row([cell("Alice"), cell("30")]),
|
||||||
headerRow.appendChild(th);
|
row([cell("Bob"), cell("25")]),
|
||||||
}
|
],
|
||||||
thead.appendChild(headerRow);
|
},
|
||||||
table.appendChild(thead);
|
],
|
||||||
|
});
|
||||||
const tbody = document.createElement("tbody");
|
|
||||||
for (const [name, age] of [
|
|
||||||
["Alice", "30"],
|
|
||||||
["Bob", "25"],
|
|
||||||
]) {
|
|
||||||
const tr = document.createElement("tr");
|
|
||||||
const td1 = document.createElement("td");
|
|
||||||
td1.textContent = name;
|
|
||||||
const td2 = document.createElement("td");
|
|
||||||
td2.textContent = age;
|
|
||||||
tr.appendChild(td1);
|
|
||||||
tr.appendChild(td2);
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
}
|
|
||||||
table.appendChild(tbody);
|
|
||||||
div.appendChild(table);
|
|
||||||
|
|
||||||
const md = htmlToMarkdown(div.innerHTML);
|
|
||||||
const ls = lines(md);
|
const ls = lines(md);
|
||||||
|
|
||||||
// Proper GFM structure: separator row + all rows pipe-delimited.
|
// Proper GFM structure: separator row + all rows pipe-delimited.
|
||||||
@@ -296,3 +279,146 @@ describe("table clipboard markdown output (htmlToMarkdown)", () => {
|
|||||||
expect(md).not.toMatch(/^\s*(Name|Age|Alice|Bob|30|25)\s*$/m);
|
expect(md).not.toMatch(/^\s*(Name|Age|Alice|Bob|30|25)\s*$/m);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #347 acceptance: pasting CANONICAL markdown yields the SAME nodes the server
|
||||||
|
// import produces for the same text. The paste path calls markdownToProseMirror
|
||||||
|
// (the package browser entry) — the identical converter the server import uses —
|
||||||
|
// so asserting the converter (via the browser entry, on the native DOMParser)
|
||||||
|
// recognizes each canon form pins the paste-parity guarantee. These forms were
|
||||||
|
// NOT recognized by the old editor-ext marked layer the paste used before.
|
||||||
|
describe("canonical markdown paste recognition (browser entry parity)", () => {
|
||||||
|
// Collect every node type present in a doc (recursively).
|
||||||
|
const collectTypes = (n: any, set = new Set<string>()): Set<string> => {
|
||||||
|
if (!n || typeof n !== "object") return set;
|
||||||
|
if (n.type) set.add(n.type);
|
||||||
|
if (Array.isArray(n.content)) n.content.forEach((c) => collectTypes(c, set));
|
||||||
|
return set;
|
||||||
|
};
|
||||||
|
const findNode = (n: any, type: string): any => {
|
||||||
|
if (!n || typeof n !== "object") return undefined;
|
||||||
|
if (n.type === type) return n;
|
||||||
|
if (Array.isArray(n.content)) {
|
||||||
|
for (const c of n.content) {
|
||||||
|
const hit = findNode(c, type);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
const allText = (n: any): string => {
|
||||||
|
if (!n || typeof n !== "object") return "";
|
||||||
|
if (typeof n.text === "string") return n.text;
|
||||||
|
if (Array.isArray(n.content)) return n.content.map(allText).join("");
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
it("^[…] inline footnote -> footnoteReference + footnotesList", () => {
|
||||||
|
const doc = markdownToProseMirrorSync("Body^[a note here].");
|
||||||
|
const types = collectTypes(doc);
|
||||||
|
expect(types.has("footnoteReference")).toBe(true);
|
||||||
|
expect(types.has("footnotesList")).toBe(true);
|
||||||
|
expect(types.has("footnoteDefinition")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('<!--img {…}--> attached image comment -> image with align', () => {
|
||||||
|
const doc = markdownToProseMirrorSync(
|
||||||
|
' <!--img {"align":"left"}-->',
|
||||||
|
);
|
||||||
|
const img = findNode(doc, "image");
|
||||||
|
expect(img).toBeTruthy();
|
||||||
|
expect(img.attrs?.align).toBe("left");
|
||||||
|
expect(img.attrs?.src).toBe("/files/x.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("> [!type] Obsidian callout -> callout node with type", () => {
|
||||||
|
const doc = markdownToProseMirrorSync("> [!warning]\n> be careful");
|
||||||
|
const callout = findNode(doc, "callout");
|
||||||
|
expect(callout).toBeTruthy();
|
||||||
|
expect(callout.attrs?.type).toBe("warning");
|
||||||
|
expect(allText(callout)).toContain("be careful");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("$…$ inline math -> mathInline node", () => {
|
||||||
|
const doc = markdownToProseMirrorSync("Euler: $e^{i\\pi}+1=0$ done");
|
||||||
|
const math = findNode(doc, "mathInline");
|
||||||
|
expect(math).toBeTruthy();
|
||||||
|
expect(math.attrs?.text).toContain("e^{i\\pi}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("==…== highlight -> highlight mark", () => {
|
||||||
|
const doc = markdownToProseMirrorSync("A ==marked== word");
|
||||||
|
const marked = findNode(doc, "text");
|
||||||
|
// The highlighted run carries a `highlight` mark somewhere in the doc.
|
||||||
|
const hasHighlight = (n: any): boolean => {
|
||||||
|
if (!n || typeof n !== "object") return false;
|
||||||
|
if (
|
||||||
|
n.type === "text" &&
|
||||||
|
(n.marks || []).some((m: any) => m.type === "highlight")
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
return Array.isArray(n.content) ? n.content.some(hasHighlight) : false;
|
||||||
|
};
|
||||||
|
expect(marked).toBeTruthy();
|
||||||
|
expect(hasHighlight(doc)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("<!--subpages--> standalone comment -> subpages node", () => {
|
||||||
|
const doc = markdownToProseMirrorSync("intro\n\n<!--subpages-->\n\nafter");
|
||||||
|
expect(collectTypes(doc).has("subpages")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// #347 negatives: plain text carrying markdown-LIKE punctuation must NOT be
|
||||||
|
// silently converted/mangled (currency, bare `==`, a `[^1]` reference form).
|
||||||
|
describe("plain-text paste negatives (no phantom conversion)", () => {
|
||||||
|
const findNode = (n: any, type: string): any => {
|
||||||
|
if (!n || typeof n !== "object") return undefined;
|
||||||
|
if (n.type === type) return n;
|
||||||
|
if (Array.isArray(n.content)) {
|
||||||
|
for (const c of n.content) {
|
||||||
|
const hit = findNode(c, type);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
const collectTypes = (n: any, set = new Set<string>()): Set<string> => {
|
||||||
|
if (!n || typeof n !== "object") return set;
|
||||||
|
if (n.type) set.add(n.type);
|
||||||
|
if (Array.isArray(n.content)) n.content.forEach((c) => collectTypes(c, set));
|
||||||
|
return set;
|
||||||
|
};
|
||||||
|
const allText = (n: any): string => {
|
||||||
|
if (!n || typeof n !== "object") return "";
|
||||||
|
if (typeof n.text === "string") return n.text;
|
||||||
|
if (Array.isArray(n.content)) return n.content.map(allText).join("");
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
it("currency `$5 and $10` is NOT turned into math", () => {
|
||||||
|
const doc = markdownToProseMirrorSync("It costs $5 and $10 total");
|
||||||
|
expect(findNode(doc, "mathInline")).toBeFalsy();
|
||||||
|
expect(allText(doc)).toContain("$5 and $10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a lone `==` is NOT turned into a highlight", () => {
|
||||||
|
const doc = markdownToProseMirrorSync("compare a == b in code");
|
||||||
|
const hasHighlight = (n: any): boolean => {
|
||||||
|
if (!n || typeof n !== "object") return false;
|
||||||
|
if (
|
||||||
|
n.type === "text" &&
|
||||||
|
(n.marks || []).some((m: any) => m.type === "highlight")
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
return Array.isArray(n.content) ? n.content.some(hasHighlight) : false;
|
||||||
|
};
|
||||||
|
expect(hasHighlight(doc)).toBe(false);
|
||||||
|
expect(allText(doc)).toContain("== b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a `[^1]` reference form (no `^[`) is NOT turned into a footnote", () => {
|
||||||
|
const doc = markdownToProseMirrorSync("see note [^1] for details");
|
||||||
|
expect(collectTypes(doc).has("footnoteReference")).toBe(false);
|
||||||
|
expect(allText(doc)).toContain("[^1]");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
// adapted from: https://github.com/aguingand/tiptap-markdown/blob/main/src/extensions/tiptap/clipboard.js - MIT
|
// adapted from: https://github.com/aguingand/tiptap-markdown/blob/main/src/extensions/tiptap/clipboard.js - MIT
|
||||||
import { Extension } from "@tiptap/core";
|
import { Extension } from "@tiptap/core";
|
||||||
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
|
||||||
import { DOMParser, DOMSerializer, Fragment, Slice } from "@tiptap/pm/model";
|
import { DOMParser, DOMSerializer, Fragment, Slice, Node as PMNode } from "@tiptap/pm/model";
|
||||||
import { find } from "linkifyjs";
|
import { find } from "linkifyjs";
|
||||||
import {
|
import {
|
||||||
markdownToHtml,
|
|
||||||
htmlToMarkdown,
|
|
||||||
canonicalizeFootnotes,
|
canonicalizeFootnotes,
|
||||||
FOOTNOTES_LIST_NAME,
|
FOOTNOTES_LIST_NAME,
|
||||||
FOOTNOTE_REFERENCE_NAME,
|
FOOTNOTE_REFERENCE_NAME,
|
||||||
} from "@docmost/editor-ext";
|
} from "@docmost/editor-ext";
|
||||||
|
// Markdown <-> ProseMirror conversion now lives ONLY in the canonical
|
||||||
|
// `@docmost/prosemirror-markdown` package (issue #347). The BROWSER entry uses
|
||||||
|
// the native `DOMParser` for its HTML->DOM stage (jsdom stays out of the client
|
||||||
|
// bundle) while producing the SAME nodes the server import does — so a paste of
|
||||||
|
// canonical markdown (`^[…]`, `<!--img …-->`, `> [!type]`, `$…$`, `==…==`,
|
||||||
|
// standalone comments) is recognized identically to import.
|
||||||
|
import {
|
||||||
|
markdownToProseMirror,
|
||||||
|
convertProseMirrorToMarkdown,
|
||||||
|
} from "@docmost/prosemirror-markdown/browser";
|
||||||
import type { Schema } from "@tiptap/pm/model";
|
import type { Schema } from "@tiptap/pm/model";
|
||||||
|
|
||||||
export const MarkdownClipboard = Extension.create({
|
export const MarkdownClipboard = Extension.create({
|
||||||
@@ -39,25 +47,24 @@ export const MarkdownClipboard = Extension.create({
|
|||||||
classifyClipboardSelection(topLevelNodes);
|
classifyClipboardSelection(topLevelNodes);
|
||||||
if (!asMarkdown) return null;
|
if (!asMarkdown) return null;
|
||||||
|
|
||||||
const div = document.createElement("div");
|
// Convert the copied selection to Markdown through the canonical
|
||||||
const serializer = DOMSerializer.fromSchema(this.editor.schema);
|
// package (issue #347), the SAME serializer the server export uses,
|
||||||
const fragment = serializer.serializeFragment(slice.content);
|
// so a copied table/list matches the on-disk markdown form. The
|
||||||
|
// converter takes a ProseMirror `doc` JSON, so wrap the slice's
|
||||||
|
// top-level content in a synthetic doc.
|
||||||
|
const content = slice.content.toJSON() as any[];
|
||||||
if (wrapBareRows) {
|
if (wrapBareRows) {
|
||||||
// A partial table cell-selection serializes to bare <tr> nodes
|
// A partial table cell-selection serializes to bare `tableRow`
|
||||||
// (prosemirror-tables returns the whole `table` node only when the
|
// nodes (prosemirror-tables yields the whole `table` node only for
|
||||||
// entire table is selected). Bare <tr> would be foster-parented
|
// a full-table selection). The converter's table case expects a
|
||||||
// away by the HTML parser inside htmlToMarkdown, so wrap them in
|
// `table` wrapper, so wrap the bare rows in one — mirroring the old
|
||||||
// <table><tbody> first for the GFM turndown rule to detect them.
|
// <table><tbody> wrap that the HTML->markdown step needed.
|
||||||
const table = document.createElement("table");
|
return convertProseMirrorToMarkdown({
|
||||||
const tbody = document.createElement("tbody");
|
type: "doc",
|
||||||
tbody.appendChild(fragment);
|
content: [{ type: "table", content }],
|
||||||
table.appendChild(tbody);
|
});
|
||||||
div.appendChild(table);
|
|
||||||
} else {
|
|
||||||
div.appendChild(fragment);
|
|
||||||
}
|
}
|
||||||
return htmlToMarkdown(div.innerHTML);
|
return convertProseMirrorToMarkdown({ type: "doc", content });
|
||||||
},
|
},
|
||||||
handlePaste: (view, event, slice) => {
|
handlePaste: (view, event, slice) => {
|
||||||
if (!event.clipboardData) {
|
if (!event.clipboardData) {
|
||||||
@@ -95,37 +102,115 @@ export const MarkdownClipboard = Extension.create({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { tr } = view.state;
|
const schema = this.editor.schema;
|
||||||
const { from, to } = view.state.selection;
|
// Capture the target range NOW. markdownToProseMirror RETURNS A
|
||||||
|
// PROMISE (kept async only for the Node consumers' contract; the
|
||||||
|
// conversion pipeline itself is synchronous), so the actual replace
|
||||||
|
// happens on the next microtask. No user input can interleave a
|
||||||
|
// microtask, so the state is unchanged when we dispatch — but we
|
||||||
|
// still re-read the live state before replacing and, if the doc did
|
||||||
|
// change under us, fall back to the live selection rather than the
|
||||||
|
// captured (now-stale) range.
|
||||||
|
const from = view.state.selection.from;
|
||||||
|
const to = view.state.selection.to;
|
||||||
|
const startDoc = view.state.doc;
|
||||||
|
const md = text.replace(/\n+$/, "");
|
||||||
|
|
||||||
const parsed = markdownToHtml(text.replace(/\n+$/, ""));
|
void markdownToProseMirror(md)
|
||||||
const body = elementFromString(parsed);
|
.then((doc) => {
|
||||||
normalizeTableColumnWidths(body);
|
if (view.isDestroyed) return;
|
||||||
|
// Canonical PM-JSON -> HTML via the LIVE editor schema, then
|
||||||
|
// reuse the UNCHANGED downstream seam (normalizeTableColumnWidths
|
||||||
|
// + parseSlice + canonicalizePastedFootnotes). The JSON->HTML->
|
||||||
|
// JSON hop is lossless (same schema both directions); it lets the
|
||||||
|
// existing paste-insertion logic stay byte-identical — only the
|
||||||
|
// SOURCE of the markdown conversion changed (issue #347 guardrail:
|
||||||
|
// no converter logic in the client, only a call into the package).
|
||||||
|
const node = PMNode.fromJSON(schema, doc);
|
||||||
|
const div = document.createElement("div");
|
||||||
|
DOMSerializer.fromSchema(schema).serializeFragment(
|
||||||
|
node.content,
|
||||||
|
{ document },
|
||||||
|
div,
|
||||||
|
);
|
||||||
|
|
||||||
const parsedSlice = DOMParser.fromSchema(
|
const body = elementFromString(div.innerHTML);
|
||||||
this.editor.schema,
|
normalizeTableColumnWidths(body);
|
||||||
).parseSlice(body, {
|
|
||||||
preserveWhitespace: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// A markdown paste builds its ProseMirror fragment directly (DOM ->
|
const parsedSlice = DOMParser.fromSchema(schema).parseSlice(
|
||||||
// parseSlice), bypassing the editor's footnoteSyncPlugin, which never
|
body,
|
||||||
// reorders an existing list. So a pasted markdown block whose footnote
|
{ preserveWhitespace: true },
|
||||||
// definitions are out of order (or contains orphan defs) would be
|
);
|
||||||
// stored out of order. Canonicalize the self-contained pasted block so
|
|
||||||
// its footnotes come out reference-ordered, deduped and orphan-free
|
|
||||||
// (issue #228). See canonicalizePastedFootnotes for why this is scoped
|
|
||||||
// to whole-block pastes that carry their own footnotesList.
|
|
||||||
const contentNodes = canonicalizePastedFootnotes(
|
|
||||||
parsedSlice,
|
|
||||||
this.editor.schema,
|
|
||||||
);
|
|
||||||
|
|
||||||
tr.replaceRange(from, to, contentNodes);
|
// A markdown paste builds its ProseMirror fragment directly (DOM
|
||||||
const insertEnd = tr.mapping.map(from, 1);
|
// -> parseSlice), bypassing the editor's footnoteSyncPlugin, which
|
||||||
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(from, insertEnd - 2)), -1));
|
// never reorders an existing list. So a pasted markdown block whose
|
||||||
tr.setMeta('paste', true)
|
// footnote definitions are out of order (or contains orphan defs)
|
||||||
view.dispatch(tr);
|
// would be stored out of order. Canonicalize the self-contained
|
||||||
|
// pasted block so its footnotes come out reference-ordered, deduped
|
||||||
|
// and orphan-free (issue #228). See canonicalizePastedFootnotes for
|
||||||
|
// why this is scoped to whole-block pastes that carry their own
|
||||||
|
// footnotesList.
|
||||||
|
const contentNodes = canonicalizePastedFootnotes(
|
||||||
|
parsedSlice,
|
||||||
|
schema,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Target the captured range (normally still valid — same
|
||||||
|
// microtask). If the doc changed under us since capture, the
|
||||||
|
// captured absolute from/to are stale, so fall back to the live
|
||||||
|
// selection rather than StepMap-mapping the old range.
|
||||||
|
const tr = view.state.tr;
|
||||||
|
let mappedFrom = from;
|
||||||
|
let mappedTo = to;
|
||||||
|
if (view.state.doc !== startDoc) {
|
||||||
|
// Defensive: if the doc changed under us, fall back to the
|
||||||
|
// current selection rather than a stale absolute range.
|
||||||
|
mappedFrom = view.state.selection.from;
|
||||||
|
mappedTo = view.state.selection.to;
|
||||||
|
}
|
||||||
|
tr.replaceRange(mappedFrom, mappedTo, contentNodes);
|
||||||
|
const insertEnd = tr.mapping.map(mappedFrom, 1);
|
||||||
|
tr.setSelection(
|
||||||
|
TextSelection.near(
|
||||||
|
tr.doc.resolve(Math.max(mappedFrom, insertEnd - 2)),
|
||||||
|
-1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
tr.setMeta("paste", true);
|
||||||
|
view.dispatch(tr);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
// Fail-open: a conversion error must not swallow the paste
|
||||||
|
// silently in a way that loses the text. We already claimed the
|
||||||
|
// event (returned true), so re-insert the raw text as a plain
|
||||||
|
// paragraph so the user never loses their clipboard content.
|
||||||
|
// Log it: this catch covers BOTH the converter and the success
|
||||||
|
// `.then` body (e.g. PMNode.fromJSON throwing on a schema drift
|
||||||
|
// between the canonical package and the live editor schema), so a
|
||||||
|
// silent degrade to raw text would otherwise be an invisible,
|
||||||
|
// non-reproducible regression ("my table pasted as text").
|
||||||
|
console.error(
|
||||||
|
"markdown paste conversion failed, inserting raw text",
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
if (view.isDestroyed) return;
|
||||||
|
const tr = view.state.tr;
|
||||||
|
// Same guard the success path uses: if the doc changed under us
|
||||||
|
// since the range was captured (normally never — same microtask),
|
||||||
|
// the captured absolute from/to are stale and would throw a
|
||||||
|
// RangeError here (an unhandled rejection on a hot paste path).
|
||||||
|
// Fall back to the live selection instead of a stale range.
|
||||||
|
if (view.state.doc !== startDoc) {
|
||||||
|
const sel = view.state.selection;
|
||||||
|
tr.insertText(md, sel.from, sel.to);
|
||||||
|
} else {
|
||||||
|
tr.insertText(md, from, to);
|
||||||
|
}
|
||||||
|
tr.setMeta("paste", true);
|
||||||
|
view.dispatch(tr);
|
||||||
|
});
|
||||||
|
// Claim the paste: we insert asynchronously above.
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
// Strip trailing whitespace-only paragraphs from pasted content.
|
// Strip trailing whitespace-only paragraphs from pasted content.
|
||||||
|
|||||||
@@ -33,10 +33,11 @@ vi.mock("@/lib/local-emitter.ts", () => ({
|
|||||||
default: { emit: (...args: unknown[]) => localEmitMock(...args) },
|
default: { emit: (...args: unknown[]) => localEmitMock(...args) },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// htmlToMarkdown just echoes the editor HTML so each test controls the markdown
|
// convertProseMirrorToMarkdown echoes a marker carried on the fake editor's
|
||||||
// purely via the fake page editor's getHTML().
|
// getJSON() doc, so each test controls the markdown purely via the fake page
|
||||||
vi.mock("@docmost/editor-ext", () => ({
|
// editor (issue #347: the hook now serializes editor JSON through the package).
|
||||||
htmlToMarkdown: (html: string) => html,
|
vi.mock("@docmost/prosemirror-markdown/browser", () => ({
|
||||||
|
convertProseMirrorToMarkdown: (doc: { __md?: string }) => doc?.__md ?? "",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const notificationsShowMock = vi.fn();
|
const notificationsShowMock = vi.fn();
|
||||||
@@ -53,10 +54,12 @@ import { useGeneratePageTitle } from "./use-generate-page-title.ts";
|
|||||||
|
|
||||||
// --- Test helpers -------------------------------------------------------------
|
// --- Test helpers -------------------------------------------------------------
|
||||||
|
|
||||||
function makePageEditor(pageId: string, html = "<p>content</p>"): Editor {
|
function makePageEditor(pageId: string, md = "content"): Editor {
|
||||||
return {
|
return {
|
||||||
isDestroyed: false,
|
isDestroyed: false,
|
||||||
getHTML: () => html,
|
// The mocked convertProseMirrorToMarkdown reads `__md` back off this doc,
|
||||||
|
// so `md` is exactly the markdown the hook will send to the title service.
|
||||||
|
getJSON: () => ({ type: "doc", __md: md }),
|
||||||
storage: { pageId },
|
storage: { pageId },
|
||||||
} as unknown as Editor;
|
} as unknown as Editor;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useMutation } from "@tanstack/react-query";
|
|||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
import { convertProseMirrorToMarkdown } from "@docmost/prosemirror-markdown/browser";
|
||||||
import {
|
import {
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
titleEditorAtom,
|
titleEditorAtom,
|
||||||
@@ -49,7 +49,9 @@ export function useGeneratePageTitle(pageId: string) {
|
|||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!pageEditor || pageEditor.isDestroyed) return;
|
if (!pageEditor || pageEditor.isDestroyed) return;
|
||||||
|
|
||||||
const markdown = htmlToMarkdown(pageEditor.getHTML()).trim();
|
// Serialize the live editor content to markdown through the canonical
|
||||||
|
// converter (issue #347), matching the on-disk/export markdown form.
|
||||||
|
const markdown = convertProseMirrorToMarkdown(pageEditor.getJSON()).trim();
|
||||||
if (!markdown) {
|
if (!markdown) {
|
||||||
notifications.show({ message: t("The note is empty"), color: "yellow" });
|
notifications.show({ message: t("The note is empty"), color: "yellow" });
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts
|
|||||||
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
|
import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import ExportModal from "@/components/common/export-modal";
|
import ExportModal from "@/components/common/export-modal";
|
||||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
import { convertProseMirrorToMarkdown } from "@docmost/prosemirror-markdown/browser";
|
||||||
import {
|
import {
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
@@ -199,8 +199,9 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
|
|
||||||
const handleCopyAsMarkdown = () => {
|
const handleCopyAsMarkdown = () => {
|
||||||
if (!pageEditor) return;
|
if (!pageEditor) return;
|
||||||
const html = pageEditor.getHTML();
|
// Copy the page as canonical markdown through the shared converter (issue
|
||||||
const markdown = htmlToMarkdown(html);
|
// #347), so "Copy as markdown" matches the server export byte-for-byte.
|
||||||
|
const markdown = convertProseMirrorToMarkdown(pageEditor.getJSON());
|
||||||
const title = page?.title ? `# ${page.title}\n\n` : "";
|
const title = page?.title ? `# ${page.title}\n\n` : "";
|
||||||
clipboard.copy(`${title}${markdown}`);
|
clipboard.copy(`${title}${markdown}`);
|
||||||
notifications.show({ message: t("Copied") });
|
notifications.show({ message: t("Copied") });
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { markdownToHtml, encodeHtmlEmbedSource } from '@docmost/editor-ext';
|
import { markdownToProseMirror } from '@docmost/prosemirror-markdown';
|
||||||
|
import { encodeHtmlEmbedSource } from '@docmost/editor-ext';
|
||||||
import { htmlToJson } from '../../../collaboration/collaboration.util';
|
import { htmlToJson } from '../../../collaboration/collaboration.util';
|
||||||
import { hasHtmlEmbedNode, stripHtmlEmbedNodes } from './html-embed.util';
|
import { hasHtmlEmbedNode, stripHtmlEmbedNodes } from './html-embed.util';
|
||||||
|
|
||||||
@@ -10,13 +11,12 @@ import { hasHtmlEmbedNode, stripHtmlEmbedNodes } from './html-embed.util';
|
|||||||
*
|
*
|
||||||
* The block renders inside a sandboxed iframe, so this is not an XSS surface;
|
* The block renders inside a sandboxed iframe, so this is not an XSS surface;
|
||||||
* this exercises the REAL server import conversion path that ImportService uses
|
* this exercises the REAL server import conversion path that ImportService uses
|
||||||
* (`markdownToHtml` then `htmlToJson`; `processHTML` adds only a cheerio
|
* (`markdownToProseMirror`, the canonical converter — issue #345/#347) and
|
||||||
* link/iframe normalize pass which does not touch htmlEmbed divs) and asserts
|
* asserts that such a node is DETECTED and STRIPPABLE — so the share read path's
|
||||||
* that such a node is DETECTED and STRIPPABLE — so the share read path's
|
|
||||||
* master-toggle strip can remove it when the workspace toggle is OFF.
|
* master-toggle strip can remove it when the workspace toggle is OFF.
|
||||||
*/
|
*/
|
||||||
describe('htmlEmbed smuggled via the raw serialized div in imported markdown/HTML', () => {
|
describe('htmlEmbed smuggled via the raw serialized div in imported markdown/HTML', () => {
|
||||||
it('round-trips through markdownToHtml -> htmlToJson and is DETECTED (base64 data-source)', async () => {
|
it('round-trips through markdownToProseMirror and is DETECTED (base64 data-source)', async () => {
|
||||||
const source = '<script>steal()</script>';
|
const source = '<script>steal()</script>';
|
||||||
const encoded = encodeHtmlEmbedSource(source);
|
const encoded = encodeHtmlEmbedSource(source);
|
||||||
const md = [
|
const md = [
|
||||||
@@ -27,12 +27,9 @@ describe('htmlEmbed smuggled via the raw serialized div in imported markdown/HTM
|
|||||||
'World',
|
'World',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
const html = await markdownToHtml(md);
|
// The canonical importer parses the raw block-level div into a real
|
||||||
// marked preserves the raw block-level div verbatim.
|
// htmlEmbed node carrying the decoded source.
|
||||||
expect(html).toContain('data-type="htmlEmbed"');
|
const json = await markdownToProseMirror(md);
|
||||||
|
|
||||||
const json = htmlToJson(html);
|
|
||||||
// The div parses into a real htmlEmbed node carrying the decoded source.
|
|
||||||
expect(hasHtmlEmbedNode(json)).toBe(true);
|
expect(hasHtmlEmbedNode(json)).toBe(true);
|
||||||
|
|
||||||
// Because it is detected, the share master-toggle strip can remove it.
|
// Because it is detected, the share master-toggle strip can remove it.
|
||||||
@@ -59,8 +56,7 @@ describe('htmlEmbed smuggled via the raw serialized div in imported markdown/HTM
|
|||||||
// therefore stripping) does not depend on the source being well-formed, so
|
// therefore stripping) does not depend on the source being well-formed, so
|
||||||
// the bypass cannot be hidden by sending a malformed data-source.
|
// the bypass cannot be hidden by sending a malformed data-source.
|
||||||
const md = `<div data-type="htmlEmbed" data-source="<script>x</script>"></div>`;
|
const md = `<div data-type="htmlEmbed" data-source="<script>x</script>"></div>`;
|
||||||
const html = await markdownToHtml(md);
|
const json = await markdownToProseMirror(md);
|
||||||
const json = htmlToJson(html);
|
|
||||||
expect(hasHtmlEmbedNode(json)).toBe(true);
|
expect(hasHtmlEmbedNode(json)).toBe(true);
|
||||||
expect(hasHtmlEmbedNode(stripHtmlEmbedNodes(json))).toBe(false);
|
expect(hasHtmlEmbedNode(stripHtmlEmbedNodes(json))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ function __assertClientCallContract(client: DocmostClientLike): void {
|
|||||||
void client.getWorkspace();
|
void client.getWorkspace();
|
||||||
void client.getSpaces();
|
void client.getSpaces();
|
||||||
void client.listPages(s, n, true);
|
void client.listPages(s, n, true);
|
||||||
|
void client.getTree(s, s, n);
|
||||||
|
void client.getPageContext(s);
|
||||||
void client.listSidebarPages(s, s);
|
void client.listSidebarPages(s, s);
|
||||||
void client.getOutline(s);
|
void client.getOutline(s);
|
||||||
void client.getPageJson(s);
|
void client.getPageJson(s);
|
||||||
@@ -121,6 +123,23 @@ function __assertClientCallContract(client: DocmostClientLike): void {
|
|||||||
void client.drawioGet(s, s, 'xml');
|
void client.drawioGet(s, s, 'xml');
|
||||||
void client.drawioCreate(s, { position: 'append', anchorNodeId: s }, s, s, 'elk');
|
void client.drawioCreate(s, { position: 'append', anchorNodeId: s }, s, s, 'elk');
|
||||||
void client.drawioUpdate(s, s, s, s, 'elk');
|
void client.drawioUpdate(s, s, s, s, 'elk');
|
||||||
|
// --- draw.io high-level semantic tools (#425 stage 3) ---
|
||||||
|
void client.drawioEditCells(s, s, [{ op: 'delete', cellId: s }], s);
|
||||||
|
void client.drawioFromGraph(
|
||||||
|
s,
|
||||||
|
{ position: 'append', anchorNodeId: s },
|
||||||
|
{ nodes: [{ id: s, label: s }] },
|
||||||
|
'LR',
|
||||||
|
s,
|
||||||
|
'full',
|
||||||
|
s,
|
||||||
|
);
|
||||||
|
void client.drawioFromMermaid(
|
||||||
|
s,
|
||||||
|
{ position: 'append', anchorNodeId: s },
|
||||||
|
s,
|
||||||
|
s,
|
||||||
|
);
|
||||||
// --- write (comment) ---
|
// --- write (comment) ---
|
||||||
void client.createComment(s, s, 'inline', s, s, s);
|
void client.createComment(s, s, 'inline', s, s, s);
|
||||||
void client.resolveComment(s, true);
|
void client.resolveComment(s, true);
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ type DocmostClientMethod =
|
|||||||
| 'getWorkspace'
|
| 'getWorkspace'
|
||||||
| 'getSpaces'
|
| 'getSpaces'
|
||||||
| 'listPages'
|
| 'listPages'
|
||||||
|
| 'getTree'
|
||||||
|
| 'getPageContext'
|
||||||
| 'listSidebarPages'
|
| 'listSidebarPages'
|
||||||
| 'getOutline'
|
| 'getOutline'
|
||||||
| 'getPageJson'
|
| 'getPageJson'
|
||||||
@@ -69,6 +71,10 @@ type DocmostClientMethod =
|
|||||||
| 'drawioGet'
|
| 'drawioGet'
|
||||||
| 'drawioCreate'
|
| 'drawioCreate'
|
||||||
| 'drawioUpdate'
|
| 'drawioUpdate'
|
||||||
|
// --- draw.io high-level semantic tools (#425 stage 3) ---
|
||||||
|
| 'drawioEditCells'
|
||||||
|
| 'drawioFromGraph'
|
||||||
|
| 'drawioFromMermaid'
|
||||||
// --- write (comment) ---
|
// --- write (comment) ---
|
||||||
| 'createComment'
|
| 'createComment'
|
||||||
| 'resolveComment';
|
| 'resolveComment';
|
||||||
|
|||||||
@@ -27,10 +27,12 @@ import type { DocmostClientLike } from './docmost-client.loader';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
describe('tool tier metadata (#332)', () => {
|
describe('tool tier metadata (#332)', () => {
|
||||||
it('core set is the documented 13 + searchInPage + insertFootnote (15)', () => {
|
it('core set is the documented 13 + searchInPage + insertFootnote + getTree + getPageContext (17, #443)', () => {
|
||||||
expect(CORE_TOOL_KEYS).toHaveLength(15);
|
expect(CORE_TOOL_KEYS).toHaveLength(17);
|
||||||
expect(CORE_TOOL_SET.has('searchInPage')).toBe(true); // #330, promoted to core
|
expect(CORE_TOOL_SET.has('searchInPage')).toBe(true); // #330, promoted to core
|
||||||
expect(CORE_TOOL_SET.has('insertFootnote')).toBe(true); // #410, promoted to core
|
expect(CORE_TOOL_SET.has('insertFootnote')).toBe(true); // #410, promoted to core
|
||||||
|
expect(CORE_TOOL_SET.has('getTree')).toBe(true); // #443, promoted to core
|
||||||
|
expect(CORE_TOOL_SET.has('getPageContext')).toBe(true); // #443, promoted to core
|
||||||
// loadTools is a meta-tool, not a normal core key.
|
// loadTools is a meta-tool, not a normal core key.
|
||||||
expect(CORE_TOOL_SET.has(LOAD_TOOLS_NAME)).toBe(false);
|
expect(CORE_TOOL_SET.has(LOAD_TOOLS_NAME)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,12 +39,14 @@ export interface ToolCatalogEntry {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* CORE (always-active) in-app tool keys — 13 frequent/tiny tools + `searchInPage`
|
* CORE (always-active) in-app tool keys — 13 frequent/tiny tools + `searchInPage`
|
||||||
* (#330) + `insertFootnote` (#410). `searchInPage` is core because it is frequent
|
* (#330) + `insertFootnote` (#410) + `getTree`/`getPageContext` (#443).
|
||||||
* for the editorial roles this feature targets; `insertFootnote` is core so the
|
* `searchInPage` is core because it is frequent for the editorial roles this
|
||||||
* footnote tool is NOT hidden while its natural sibling `editPageText` is always
|
* feature targets; `insertFootnote` is core so the footnote tool is NOT hidden
|
||||||
* active (that asymmetry is exactly what pushed the agent to write literal
|
* while its natural sibling `editPageText` is always active (that asymmetry is
|
||||||
* `^[...]`). `loadTools` is active too but is not a normal tool key (it is added
|
* exactly what pushed the agent to write literal `^[...]`). `getTree` and
|
||||||
* to activeTools separately).
|
* `getPageContext` are the single-call navigation/lookup tools — core so the
|
||||||
|
* agent never has to loadTools just to orient itself. `loadTools` is active too
|
||||||
|
* but is not a normal tool key (it is added to activeTools separately).
|
||||||
*/
|
*/
|
||||||
export const CORE_TOOL_KEYS = [
|
export const CORE_TOOL_KEYS = [
|
||||||
'searchPages',
|
'searchPages',
|
||||||
@@ -66,6 +68,11 @@ export const CORE_TOOL_KEYS = [
|
|||||||
// #410 insertFootnote — core so pinpoint citations to already-written text
|
// #410 insertFootnote — core so pinpoint citations to already-written text
|
||||||
// don't degrade into literal `^[...]`; kept symmetric with editPageText.
|
// don't degrade into literal `^[...]`; kept symmetric with editPageText.
|
||||||
'insertFootnote',
|
'insertFootnote',
|
||||||
|
// #443 getTree + getPageContext — cheap single-call navigation/lookup tools
|
||||||
|
// (the core listPages even points to getTree); core so the agent never has
|
||||||
|
// to loadTools just to orient itself.
|
||||||
|
'getTree',
|
||||||
|
'getPageContext',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** O(1) membership test for the core tier. */
|
/** O(1) membership test for the core tier. */
|
||||||
|
|||||||
@@ -12,3 +12,22 @@ export class SearchResponseDto {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
space: Partial<Space>;
|
space: Partial<Space>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Response shape for the opt-in agent-lookup mode (#443, `substring: true`).
|
||||||
|
// Additive to the FTS response: carries the location (`path`), a windowed
|
||||||
|
// `snippet` around the first match and a per-response sort `score`. The MCP
|
||||||
|
// layer maps `id → pageId`; `slugId` is never exposed.
|
||||||
|
export class SearchLookupResponseDto {
|
||||||
|
id: string;
|
||||||
|
slugId: string;
|
||||||
|
title: string;
|
||||||
|
parentPageId: string | null;
|
||||||
|
// Ancestor titles from the space root down to the direct parent; [] for a
|
||||||
|
// root page.
|
||||||
|
path: string[];
|
||||||
|
// ~300–500 chars around the first match (or a leading text window / extended
|
||||||
|
// ts_headline fallback).
|
||||||
|
snippet: string;
|
||||||
|
// 0..1 float, meaningful ONLY for sorting within one response.
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,31 @@ export class SearchDTO {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
|
||||||
|
// --- Opt-in agent-lookup mode (#443). ------------------------------------
|
||||||
|
// These fields are ADDITIVE and default-off: a web client that sends none of
|
||||||
|
// them gets byte-identical FTS behaviour and result shape. They are only read
|
||||||
|
// by the substring/path/snippet code path in SearchService.searchPage.
|
||||||
|
//
|
||||||
|
// NOTE (standalone stdio vs stock upstream): stock upstream validates this DTO
|
||||||
|
// with `whitelist: true`, so an older server silently strips these unknown
|
||||||
|
// fields and the request degrades gracefully to the plain FTS behaviour.
|
||||||
|
|
||||||
|
// Enables the hybrid substring branch (title + text_content LIKE) merged with
|
||||||
|
// the existing FTS branch, plus tiered ranking, path and windowed snippet.
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
substring?: boolean;
|
||||||
|
|
||||||
|
// Restrict the search to a page and all of its descendants (inclusive).
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
parentPageId?: string;
|
||||||
|
|
||||||
|
// Match titles only; do not scan text_content.
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
titleOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SearchShareDTO extends SearchDTO {
|
export class SearchShareDTO extends SearchDTO {
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ export class SearchController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// #443 graceful degradation: on EE/Typesense instances the request routes to
|
||||||
|
// the Typesense backend, which does NOT implement the opt-in agent-lookup
|
||||||
|
// mode. The `substring`/`parentPageId`/`titleOnly` fields are silently ignored
|
||||||
|
// and the response carries no `path`/`snippet`/`score` and no substring/tier
|
||||||
|
// ranking — it degrades to plain Typesense FTS. The native lookup mode below
|
||||||
|
// is Postgres-search-driver only.
|
||||||
if (this.environmentService.getSearchDriver() === 'typesense') {
|
if (this.environmentService.getSearchDriver() === 'typesense') {
|
||||||
return this.searchTypesense(searchDto, {
|
return this.searchTypesense(searchDto, {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import {
|
||||||
|
computeLookupScore,
|
||||||
|
escapeLikePattern,
|
||||||
|
SearchLookupTier,
|
||||||
|
} from './search.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure-function coverage for the #443 agent-lookup helpers:
|
||||||
|
* - escapeLikePattern: LIKE-metacharacter escaping so `%`/`_`/`\` are literals
|
||||||
|
* (the acceptance-table requirement that a query of `%` or `_` does NOT match
|
||||||
|
* everything);
|
||||||
|
* - computeLookupScore: the tiered 0..1 ranking score, where a stronger tier
|
||||||
|
* always outranks a weaker one regardless of the in-tier secondary signal.
|
||||||
|
*
|
||||||
|
* The DB-touching branch (substring UNION FTS, path CTE, snippet window) is
|
||||||
|
* covered by the integration spec against the real schema.
|
||||||
|
*/
|
||||||
|
describe('escapeLikePattern', () => {
|
||||||
|
it('escapes the LIKE metacharacters % _ and \\', () => {
|
||||||
|
expect(escapeLikePattern('%')).toBe('\\%');
|
||||||
|
expect(escapeLikePattern('_')).toBe('\\_');
|
||||||
|
expect(escapeLikePattern('\\')).toBe('\\\\');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes the backslash FIRST so it does not double-escape %/_', () => {
|
||||||
|
// Input `\%` must become `\\` + `\%` = `\\\%`, not `\\%`.
|
||||||
|
expect(escapeLikePattern('\\%')).toBe('\\\\\\%');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves ordinary technical chars (. - / digits) untouched', () => {
|
||||||
|
expect(escapeLikePattern('backup-srv.local')).toBe('backup-srv.local');
|
||||||
|
expect(escapeLikePattern('10.0.12')).toBe('10.0.12');
|
||||||
|
expect(escapeLikePattern('WB-MGE-30D86B')).toBe('WB-MGE-30D86B');
|
||||||
|
expect(escapeLikePattern('a/b')).toBe('a/b');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes only the metacharacters in a mixed string', () => {
|
||||||
|
expect(escapeLikePattern('50%_off.zip')).toBe('50\\%\\_off.zip');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is null/undefined-safe', () => {
|
||||||
|
expect(escapeLikePattern(undefined as any)).toBe('');
|
||||||
|
expect(escapeLikePattern(null as any)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('computeLookupScore', () => {
|
||||||
|
it('keeps every score within (0, 1]', () => {
|
||||||
|
for (const tier of [
|
||||||
|
SearchLookupTier.TITLE_EXACT,
|
||||||
|
SearchLookupTier.TITLE_SUBSTRING,
|
||||||
|
SearchLookupTier.TEXT,
|
||||||
|
]) {
|
||||||
|
for (const secondary of [0, 0.001, 1, 100, 1e6]) {
|
||||||
|
const s = computeLookupScore({ tier, secondary });
|
||||||
|
expect(s).toBeGreaterThan(0);
|
||||||
|
expect(s).toBeLessThanOrEqual(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a stronger tier ALWAYS outranks a weaker tier, whatever the secondary', () => {
|
||||||
|
// Weak tier with a huge secondary must still lose to a strong tier with a
|
||||||
|
// tiny secondary — tiers dominate.
|
||||||
|
const strongLowSecondary = computeLookupScore({
|
||||||
|
tier: SearchLookupTier.TITLE_EXACT,
|
||||||
|
secondary: 0,
|
||||||
|
});
|
||||||
|
const weakHighSecondary = computeLookupScore({
|
||||||
|
tier: SearchLookupTier.TEXT,
|
||||||
|
secondary: 1e9,
|
||||||
|
});
|
||||||
|
expect(strongLowSecondary).toBeGreaterThan(weakHighSecondary);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('within a tier a larger secondary sorts higher', () => {
|
||||||
|
const lo = computeLookupScore({
|
||||||
|
tier: SearchLookupTier.TEXT,
|
||||||
|
secondary: 0.1,
|
||||||
|
});
|
||||||
|
const hi = computeLookupScore({
|
||||||
|
tier: SearchLookupTier.TEXT,
|
||||||
|
secondary: 5,
|
||||||
|
});
|
||||||
|
expect(hi).toBeGreaterThan(lo);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a negative/absent secondary as 0', () => {
|
||||||
|
const zero = computeLookupScore({ tier: SearchLookupTier.TEXT, secondary: 0 });
|
||||||
|
expect(computeLookupScore({ tier: SearchLookupTier.TEXT })).toBe(zero);
|
||||||
|
expect(
|
||||||
|
computeLookupScore({ tier: SearchLookupTier.TEXT, secondary: -5 }),
|
||||||
|
).toBe(zero);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { SearchDTO, SearchSuggestionDTO } from './dto/search.dto';
|
import { SearchDTO, SearchSuggestionDTO } from './dto/search.dto';
|
||||||
import { SearchResponseDto } from './dto/search-response.dto';
|
import {
|
||||||
|
SearchLookupResponseDto,
|
||||||
|
SearchResponseDto,
|
||||||
|
} from './dto/search-response.dto';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
import { sql } from 'kysely';
|
import { sql } from 'kysely';
|
||||||
@@ -34,6 +37,53 @@ export function buildTsQuery(raw: string): string {
|
|||||||
return tsquery(cleaned + '*');
|
return tsquery(cleaned + '*');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Escape the LIKE metacharacters (`%`, `_`, `\`) in a raw user query so every
|
||||||
|
// character — including `.`, `-`, `_`, `%`, `/` — is matched LITERALLY by a
|
||||||
|
// `col LIKE '%' || q || '%'` predicate. Without this, a query of `%` or `_`
|
||||||
|
// would match every row (see the #443 acceptance table). The backslash is the
|
||||||
|
// escape char (Postgres LIKE default), so it must be escaped first.
|
||||||
|
export function escapeLikePattern(raw: string): string {
|
||||||
|
return (raw ?? '')
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/%/g, '\\%')
|
||||||
|
.replace(/_/g, '\\_');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ranking tiers for the agent-lookup mode (#443), highest first. A hit's tier
|
||||||
|
// is the strongest way it matched; ties inside a tier break on a secondary
|
||||||
|
// signal (FTS rank, or first-match position). The numeric `score` returned to
|
||||||
|
// the caller is derived from (tier, secondary) and is meaningful ONLY for
|
||||||
|
// ordering within a single response.
|
||||||
|
export enum SearchLookupTier {
|
||||||
|
// Title equals the query, case-insensitively.
|
||||||
|
TITLE_EXACT = 3,
|
||||||
|
// Query is a substring of the title.
|
||||||
|
TITLE_SUBSTRING = 2,
|
||||||
|
// Query matched in the text (substring or FTS).
|
||||||
|
TEXT = 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RankableHit {
|
||||||
|
tier: SearchLookupTier;
|
||||||
|
// Secondary in-tier signal, higher = better (e.g. ts_rank, or a
|
||||||
|
// position-derived closeness score). Defaults to 0.
|
||||||
|
secondary?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map (tier, secondary) → a 0..1 float used ONLY to sort one response.
|
||||||
|
//
|
||||||
|
// Formula: score = (tier + squash(secondary)) / (maxTier + 1), where
|
||||||
|
// squash(x) = x / (1 + x) maps any non-negative secondary into [0, 1)
|
||||||
|
// so a stronger tier ALWAYS outranks a weaker one regardless of the secondary
|
||||||
|
// value, and within a tier a larger secondary sorts higher. maxTier is the top
|
||||||
|
// enum value (TITLE_EXACT = 3), so the divisor keeps the result in (0, 1].
|
||||||
|
export function computeLookupScore(hit: RankableHit): number {
|
||||||
|
const maxTier = SearchLookupTier.TITLE_EXACT;
|
||||||
|
const secondary = Math.max(0, hit.secondary ?? 0);
|
||||||
|
const squashed = secondary / (1 + secondary);
|
||||||
|
return (hit.tier + squashed) / (maxTier + 1);
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SearchService {
|
export class SearchService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -50,12 +100,19 @@ export class SearchService {
|
|||||||
userId?: string;
|
userId?: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
},
|
},
|
||||||
): Promise<{ items: SearchResponseDto[] }> {
|
): Promise<{ items: SearchResponseDto[] | SearchLookupResponseDto[] }> {
|
||||||
const { query } = searchParams;
|
const { query } = searchParams;
|
||||||
|
|
||||||
if (query.length < 1) {
|
if (query.length < 1) {
|
||||||
return { items: [] };
|
return { items: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Opt-in agent-lookup mode (#443). Guarded by the `substring` flag so the
|
||||||
|
// web-UI (which never sets it) keeps byte-identical FTS behaviour below.
|
||||||
|
if (searchParams.substring) {
|
||||||
|
return this.searchPageLookup(searchParams, opts);
|
||||||
|
}
|
||||||
|
|
||||||
const searchQuery = buildTsQuery(query);
|
const searchQuery = buildTsQuery(query);
|
||||||
|
|
||||||
let queryResults = this.db
|
let queryResults = this.db
|
||||||
@@ -175,6 +232,348 @@ export class SearchService {
|
|||||||
return { items: searchResults };
|
return { items: searchResults };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent-lookup search (#443, opt-in via `SearchDTO.substring`).
|
||||||
|
*
|
||||||
|
* ADDITIVE to the FTS path: runs a substring branch (title + optionally
|
||||||
|
* text_content, LIKE with metacharacters escaped) MERGED with the existing
|
||||||
|
* FTS branch, so technical tokens that the `english` tokenizer mangles
|
||||||
|
* (`backup-srv.local`, `10.0.12.5`, `WB-MGE-30D86B`) are still found — even
|
||||||
|
* when `buildTsQuery()` returns '' for a dotted/numeric query. Results carry a
|
||||||
|
* location (`path`), a windowed `snippet` and a per-response `score`.
|
||||||
|
*
|
||||||
|
* The whole method is only reached when `substring: true`; the web-UI never
|
||||||
|
* sets it, so its behaviour is unchanged.
|
||||||
|
*/
|
||||||
|
private async searchPageLookup(
|
||||||
|
searchParams: SearchDTO,
|
||||||
|
opts: { userId?: string; workspaceId: string },
|
||||||
|
): Promise<{ items: SearchLookupResponseDto[] }> {
|
||||||
|
const rawQuery = searchParams.query.trim();
|
||||||
|
if (!rawQuery) {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = Math.min(Math.max(searchParams.limit || 10, 1), 50);
|
||||||
|
|
||||||
|
// Normalize the query the same way as the FTS / suggest path: f_unaccent +
|
||||||
|
// lower, done in SQL. `q` is the escaped LIKE pattern body (literal chars).
|
||||||
|
const likeBody = escapeLikePattern(rawQuery);
|
||||||
|
// Compare against `LOWER(f_unaccent(col))`; unaccent+lower the needle too.
|
||||||
|
const needle = sql<string>`LOWER(f_unaccent(${rawQuery}))`;
|
||||||
|
const likePattern = sql<string>`LOWER(f_unaccent(${'%' + likeBody + '%'}))`;
|
||||||
|
const tsQuery = buildTsQuery(rawQuery);
|
||||||
|
const hasTsQuery = tsQuery.length > 0;
|
||||||
|
|
||||||
|
// --- Resolve the space scope. ---------------------------------------------
|
||||||
|
// Mirrors searchPage: explicit spaceId, else the authenticated user's member
|
||||||
|
// spaces. The share path is not exposed to this opt-in mode.
|
||||||
|
let spaceIds: string[] = [];
|
||||||
|
if (searchParams.spaceId) {
|
||||||
|
spaceIds = [searchParams.spaceId];
|
||||||
|
} else if (opts.userId) {
|
||||||
|
spaceIds = await this.spaceMemberRepo.getUserSpaceIds(opts.userId);
|
||||||
|
} else {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
if (spaceIds.length === 0) {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Optional parentPageId subtree scope (inclusive). ---------------------
|
||||||
|
// Reuse the same recursive-descendants pattern used for share-scope.
|
||||||
|
let descendantIds: string[] | null = null;
|
||||||
|
if (searchParams.parentPageId) {
|
||||||
|
const descendants = await this.pageRepo.getPageAndDescendants(
|
||||||
|
searchParams.parentPageId,
|
||||||
|
{ includeContent: false },
|
||||||
|
);
|
||||||
|
descendantIds = descendants.map((p: any) => p.id);
|
||||||
|
if (descendantIds.length === 0) {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Candidate query: substring (title + text) UNION FTS. -----------------
|
||||||
|
// We compute everything the ranker needs in SQL and pull only small columns
|
||||||
|
// (never the whole text_content) into Node:
|
||||||
|
// - titleExact / titleSub: tier signals
|
||||||
|
// - textMatchPos: 1-based position of the first text match (0 = none)
|
||||||
|
// - ftsRank: ts_rank for the FTS secondary signal (0 when no tsquery)
|
||||||
|
// - snippet: windowed ~500 chars around the first text match, or a leading
|
||||||
|
// text window (title-only hit), or an extended ts_headline fallback.
|
||||||
|
const N_BEFORE = 60; // chars of context before the first match
|
||||||
|
const SNIPPET_LEN = 500;
|
||||||
|
|
||||||
|
let candidates = this.db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select([
|
||||||
|
'pages.id as id',
|
||||||
|
'pages.slugId as slugId',
|
||||||
|
'pages.title as title',
|
||||||
|
'pages.parentPageId as parentPageId',
|
||||||
|
// Tier signals.
|
||||||
|
sql<boolean>`LOWER(f_unaccent(coalesce(pages.title, ''))) = ${needle}`.as(
|
||||||
|
'titleExact',
|
||||||
|
),
|
||||||
|
sql<boolean>`LOWER(f_unaccent(coalesce(pages.title, ''))) LIKE ${likePattern} ESCAPE '\\'`.as(
|
||||||
|
'titleSub',
|
||||||
|
),
|
||||||
|
// 1-based position of the first text match (0 = no text match).
|
||||||
|
sql<number>`strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle})`.as(
|
||||||
|
'textMatchPos',
|
||||||
|
),
|
||||||
|
// FTS secondary signal (0 when the tsquery is empty).
|
||||||
|
hasTsQuery
|
||||||
|
? sql<number>`ts_rank(pages.tsv, to_tsquery('english', f_unaccent(${tsQuery})))`.as(
|
||||||
|
'ftsRank',
|
||||||
|
)
|
||||||
|
: sql<number>`0`.as('ftsRank'),
|
||||||
|
// Windowed snippet, computed entirely in SQL. Priority:
|
||||||
|
// 1. window around the first text match;
|
||||||
|
// 2. otherwise (titleOnly: no snippet; else) a leading window of the
|
||||||
|
// page text (title-only hit);
|
||||||
|
// 3. otherwise an extended ts_headline for pure-FTS hits.
|
||||||
|
//
|
||||||
|
// #443 snippet-position fix: the match position (`strpos`) is computed in
|
||||||
|
// the LOWER(f_unaccent(...)) space, but f_unaccent is NOT length-
|
||||||
|
// preserving (ß→ss, æ→ae, …→..., ½→ 1/2, full-width forms), so slicing
|
||||||
|
// the ORIGINAL text at that position was misaligned — a single expanding
|
||||||
|
// char before the match shifted the window (or ran it past end → empty).
|
||||||
|
// We now slice from the SAME LOWER(f_unaccent(...)) string so position
|
||||||
|
// and slice share one coordinate space. DELIBERATE trade-off: the snippet
|
||||||
|
// loses original case/diacritics — acceptable for an agent-facing snippet
|
||||||
|
// (position accuracy over original-glyph fidelity). The ts_headline branch
|
||||||
|
// matches over the ORIGINAL text itself, so it is unaffected and kept as-is.
|
||||||
|
searchParams.titleOnly
|
||||||
|
? sql<string>`''`.as('snippet')
|
||||||
|
: sql<string>`
|
||||||
|
coalesce(
|
||||||
|
case
|
||||||
|
when strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle}) > 0
|
||||||
|
then substring(
|
||||||
|
LOWER(f_unaccent(coalesce(pages.text_content, '')))
|
||||||
|
from greatest(1, strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle}) - ${N_BEFORE})
|
||||||
|
for ${SNIPPET_LEN}
|
||||||
|
)
|
||||||
|
when coalesce(pages.text_content, '') <> ''
|
||||||
|
then substring(LOWER(f_unaccent(pages.text_content)) from 1 for 300)
|
||||||
|
${
|
||||||
|
hasTsQuery
|
||||||
|
? sql`else ts_headline('english', coalesce(pages.text_content, ''), to_tsquery('english', f_unaccent(${tsQuery})), 'MinWords=25, MaxWords=40, MaxFragments=3')`
|
||||||
|
: sql``
|
||||||
|
}
|
||||||
|
end,
|
||||||
|
''
|
||||||
|
)
|
||||||
|
`.as('snippet'),
|
||||||
|
])
|
||||||
|
.where('pages.deletedAt', 'is', null)
|
||||||
|
.where('pages.spaceId', 'in', spaceIds);
|
||||||
|
|
||||||
|
if (descendantIds) {
|
||||||
|
candidates = candidates.where('pages.id', 'in', descendantIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match predicate: title substring OR (unless titleOnly) text substring OR
|
||||||
|
// (unless titleOnly) FTS. The substring branch runs even when the tsquery is
|
||||||
|
// empty — that is the dotted/numeric-token case the FTS path misses.
|
||||||
|
//
|
||||||
|
// #443 dead-index fix: these two LIKE predicates MUST match the GIN trgm
|
||||||
|
// index expressions EXACTLY for Postgres to use them. The indexes are on the
|
||||||
|
// coalesce-FREE expressions `LOWER(f_unaccent(title))` (#348's
|
||||||
|
// idx_pages_title_trgm) and `LOWER(f_unaccent(text_content))` (this PR's
|
||||||
|
// idx_pages_text_content_trgm). A `coalesce(col,'')` wrapper here would make
|
||||||
|
// the query expression differ from the index expression and force a Seq Scan
|
||||||
|
// on pages for every lookup. Dropping coalesce is SEMANTICALLY EQUIVALENT:
|
||||||
|
// `NULL LIKE '%q%'` is NULL (falsy), so a NULL title/text simply doesn't
|
||||||
|
// match — exactly as an empty string wouldn't match `%q%`.
|
||||||
|
candidates = candidates.where((eb) => {
|
||||||
|
const ors = [
|
||||||
|
eb(
|
||||||
|
sql`LOWER(f_unaccent(pages.title))`,
|
||||||
|
'like',
|
||||||
|
sql`${likePattern} ESCAPE '\\'`,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
if (!searchParams.titleOnly) {
|
||||||
|
ors.push(
|
||||||
|
eb(
|
||||||
|
sql`LOWER(f_unaccent(pages.text_content))`,
|
||||||
|
'like',
|
||||||
|
sql`${likePattern} ESCAPE '\\'`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (hasTsQuery) {
|
||||||
|
ors.push(
|
||||||
|
sql<boolean>`pages.tsv @@ to_tsquery('english', f_unaccent(${tsQuery}))` as any,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return eb.or(ors);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pull a generous candidate set (before permission filtering + limit).
|
||||||
|
// Cap it so a pathological match set cannot blow up memory; 200 >> limit
|
||||||
|
// (max 50) leaves ample headroom for the post-permission truncation.
|
||||||
|
//
|
||||||
|
// #443 cap-ordering fix: the 200-cap MUST be deterministic and relevance-
|
||||||
|
// biased. Without an ORDER BY, Postgres returns an ARBITRARY 200 rows, so on
|
||||||
|
// a broad match set (common word / short substring) a strong TITLE_EXACT hit
|
||||||
|
// could be among the dropped rows while 200 low-tier TEXT hits fill the cap.
|
||||||
|
// We order by the SAME SQL tier proxies the Node ranker uses — title-exact,
|
||||||
|
// then title-substring, then fts-rank (nulls last), then earliest text-match
|
||||||
|
// position — so the cap keeps the strongest candidates. The Node-side final
|
||||||
|
// tier sort + slice(0, limit) below still runs and stays authoritative; this
|
||||||
|
// ORDER BY only decides WHICH candidates survive the 200-cap.
|
||||||
|
// NB: a BARE integer literal in ORDER BY is read by Postgres as an ordinal
|
||||||
|
// column position (`ORDER BY 0` → "position 0 is not in select list"), so the
|
||||||
|
// no-tsquery fallback is `0::float`, not `0`.
|
||||||
|
const ftsRankExpr = hasTsQuery
|
||||||
|
? sql`ts_rank(pages.tsv, to_tsquery('english', f_unaccent(${tsQuery})))`
|
||||||
|
: sql`0::float`;
|
||||||
|
const candidatesCapped = candidates
|
||||||
|
// Raw-SQL ORDER BY expressions: pass the full `<expr> <dir>` as ONE arg
|
||||||
|
// (the two-arg form treats a raw-SQL second arg as an ORDER BY position).
|
||||||
|
.orderBy(
|
||||||
|
sql`(LOWER(f_unaccent(coalesce(pages.title, ''))) = ${needle}) desc`,
|
||||||
|
)
|
||||||
|
.orderBy(
|
||||||
|
sql`(LOWER(f_unaccent(coalesce(pages.title, ''))) LIKE ${likePattern} ESCAPE '\\') desc`,
|
||||||
|
)
|
||||||
|
.orderBy(sql`${ftsRankExpr} desc nulls last`)
|
||||||
|
// Earlier text match first; strpos returns 0 for "no match", which would
|
||||||
|
// sort BEFORE a real (>=1) position under plain ASC, so push 0 to the end.
|
||||||
|
.orderBy(
|
||||||
|
sql`case when strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle}) = 0 then 2147483647 else strpos(LOWER(f_unaccent(coalesce(pages.text_content, ''))), ${needle}) end asc`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let rows: any[] = await candidatesCapped.limit(200).execute();
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Permissions BEFORE limit. --------------------------------------------
|
||||||
|
// Apply the existing page-level post-filter to the MERGED set, then rank and
|
||||||
|
// only THEN truncate to `limit` — never lose the permission filter.
|
||||||
|
if (opts.userId) {
|
||||||
|
const accessibleIds =
|
||||||
|
await this.pagePermissionRepo.filterAccessiblePageIds({
|
||||||
|
pageIds: rows.map((r) => r.id),
|
||||||
|
userId: opts.userId,
|
||||||
|
spaceId: searchParams.spaceId,
|
||||||
|
workspaceId: opts.workspaceId,
|
||||||
|
});
|
||||||
|
const accessibleSet = new Set(accessibleIds);
|
||||||
|
rows = rows.filter((r) => accessibleSet.has(r.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tiered ranking + dedup. ----------------------------------------------
|
||||||
|
// Rows are already unique by id (single pages scan), so no cross-branch
|
||||||
|
// dedup is needed here; the tier captures the strongest match reason.
|
||||||
|
const ranked = rows.map((r) => {
|
||||||
|
let tier: SearchLookupTier;
|
||||||
|
let secondary: number;
|
||||||
|
if (r.titleExact) {
|
||||||
|
tier = SearchLookupTier.TITLE_EXACT;
|
||||||
|
secondary = Number(r.ftsRank) || 0;
|
||||||
|
} else if (r.titleSub) {
|
||||||
|
tier = SearchLookupTier.TITLE_SUBSTRING;
|
||||||
|
secondary = Number(r.ftsRank) || 0;
|
||||||
|
} else {
|
||||||
|
tier = SearchLookupTier.TEXT;
|
||||||
|
// Prefer earlier text matches; map position → closeness in (0, 1].
|
||||||
|
const pos = Number(r.textMatchPos) || 0;
|
||||||
|
secondary =
|
||||||
|
pos > 0 ? 1 / (1 + (pos - 1) / 100) : Number(r.ftsRank) || 0;
|
||||||
|
}
|
||||||
|
return { row: r, tier, score: computeLookupScore({ tier, secondary }) };
|
||||||
|
});
|
||||||
|
|
||||||
|
ranked.sort((a, b) => b.score - a.score);
|
||||||
|
const top = ranked.slice(0, limit);
|
||||||
|
|
||||||
|
// --- Batch ancestor path (ONE recursive CTE, not N+1). --------------------
|
||||||
|
const pathById = await this.buildAncestorPaths(top.map((t) => t.row.id));
|
||||||
|
|
||||||
|
const items: SearchLookupResponseDto[] = top.map((t) => ({
|
||||||
|
id: t.row.id,
|
||||||
|
slugId: t.row.slugId,
|
||||||
|
title: t.row.title,
|
||||||
|
parentPageId: t.row.parentPageId ?? null,
|
||||||
|
path: pathById.get(t.row.id) ?? [],
|
||||||
|
snippet: (t.row.snippet ?? '')
|
||||||
|
.replace(/\r\n|\r|\n/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim(),
|
||||||
|
score: t.score,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { items };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch ancestor-titles helper (#443): ONE recursive CTE seeded with ALL hit
|
||||||
|
* ids, walking UP parentPageId. Returns a map hitId → ancestor titles ordered
|
||||||
|
* root → direct parent (the hit's own title is excluded). Root pages map to
|
||||||
|
* an empty array. Avoids the N+1 of a per-page breadcrumb call.
|
||||||
|
*/
|
||||||
|
private async buildAncestorPaths(
|
||||||
|
hitIds: string[],
|
||||||
|
): Promise<Map<string, string[]>> {
|
||||||
|
const result = new Map<string, string[]>();
|
||||||
|
if (hitIds.length === 0) return result;
|
||||||
|
|
||||||
|
// ancestry(hit_id, page_id, title, parent_page_id, depth): seed one row per
|
||||||
|
// hit at depth 0 (the hit itself), then walk to parents (increasing depth).
|
||||||
|
const rows = await this.db
|
||||||
|
.withRecursive('ancestry', (db) =>
|
||||||
|
db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select([
|
||||||
|
'pages.id as hitId',
|
||||||
|
'pages.id as pageId',
|
||||||
|
'pages.title as title',
|
||||||
|
'pages.parentPageId as parentPageId',
|
||||||
|
sql<number>`0`.as('depth'),
|
||||||
|
])
|
||||||
|
.where('pages.id', 'in', hitIds)
|
||||||
|
.unionAll((exp) =>
|
||||||
|
exp
|
||||||
|
.selectFrom('pages as p')
|
||||||
|
.innerJoin('ancestry as a', 'p.id', 'a.parentPageId')
|
||||||
|
.select([
|
||||||
|
'a.hitId as hitId',
|
||||||
|
'p.id as pageId',
|
||||||
|
'p.title as title',
|
||||||
|
'p.parentPageId as parentPageId',
|
||||||
|
sql<number>`a.depth + 1`.as('depth'),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.selectFrom('ancestry')
|
||||||
|
.select(['hitId', 'title', 'depth'])
|
||||||
|
// depth 0 is the hit itself — excluded from the path.
|
||||||
|
.where('depth', '>', 0)
|
||||||
|
.orderBy('hitId')
|
||||||
|
// Larger depth = closer to the space root. Ordering DESC gives
|
||||||
|
// root → parent once collected.
|
||||||
|
.orderBy('depth', 'desc')
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
for (const r of rows as any[]) {
|
||||||
|
const list = result.get(r.hitId) ?? [];
|
||||||
|
list.push(r.title);
|
||||||
|
result.set(r.hitId, list);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async searchSuggestions(
|
async searchSuggestions(
|
||||||
suggestion: SearchSuggestionDTO,
|
suggestion: SearchSuggestionDTO,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { type Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #443 — trigram indexes for the opt-in agent-lookup search mode.
|
||||||
|
*
|
||||||
|
* The lookup mode adds a substring branch that runs leading-wildcard
|
||||||
|
* `LOWER(f_unaccent(col)) LIKE '%q%'` predicates on pages.title and
|
||||||
|
* pages.text_content. A leading wildcard cannot use a b-tree index, so without a
|
||||||
|
* GIN trigram index each such predicate is a sequential scan.
|
||||||
|
*
|
||||||
|
* - TITLE: the lookup-mode title predicate is `LOWER(f_unaccent(title)) LIKE
|
||||||
|
* '%q%'` (coalesce-free, so it can use a functional index), which is IDENTICAL
|
||||||
|
* to the one added for /search/suggest (#348). #348's perf-indexes migration
|
||||||
|
* already created `idx_pages_title_trgm` on `(LOWER(f_unaccent(title)))
|
||||||
|
* gin_trgm_ops`, so the title predicate is already covered — we do NOT
|
||||||
|
* re-create that index here (it would be redundant).
|
||||||
|
*
|
||||||
|
* - TEXT_CONTENT: NEW. The substring branch scans text_content when the query
|
||||||
|
* is not titleOnly. text_content is the large column, so a GIN trigram index
|
||||||
|
* on it is the meaningful acceleration for the lookup mode. The lookup search
|
||||||
|
* is ALWAYS space-scoped (spaceId or the user's member spaces), so on small
|
||||||
|
* instances a per-space sequential scan is tolerable — but the index turns the
|
||||||
|
* `%q%` text predicate into a Bitmap Index Scan and removes the only
|
||||||
|
* unbounded-per-space cost of the feature. We add it. The trade-off is disk +
|
||||||
|
* write amplification on page edits (GIN trigram indexes are larger and slower
|
||||||
|
* to update than b-trees); on the small instances this fork targets that cost
|
||||||
|
* is acceptable and the read win on agent lookups is the priority.
|
||||||
|
*
|
||||||
|
* DEPLOY-TIME LOCK WARNING: plain (non-CONCURRENT) CREATE INDEX — Kysely runs
|
||||||
|
* each migration in a transaction, so CONCURRENTLY is impossible. The build takes
|
||||||
|
* a SHARE lock that BLOCKS writes on `pages` for its duration. The text_content
|
||||||
|
* GIN build is the slow one and can take minutes on a large tenant. For large
|
||||||
|
* installations, run this in a maintenance window or build the index out-of-band
|
||||||
|
* with CREATE INDEX CONCURRENTLY before deploying (then `IF NOT EXISTS` no-ops
|
||||||
|
* here). Small/typical tenants are unaffected.
|
||||||
|
*/
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
// The title predicate is served by #348's idx_pages_title_trgm — see header.
|
||||||
|
// Only the text_content index is introduced here.
|
||||||
|
|
||||||
|
// text_content trigram index. Its expression is coalesce-free —
|
||||||
|
// `LOWER(f_unaccent(text_content))` — to EXACTLY match the coalesce-free
|
||||||
|
// lookup-mode text substring predicate in search.service.ts, so Postgres can
|
||||||
|
// use it (a `coalesce(...)` mismatch would silently fall back to a Seq Scan).
|
||||||
|
await sql`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pages_text_content_trgm
|
||||||
|
ON pages USING gin ((LOWER(f_unaccent(text_content))) gin_trgm_ops)
|
||||||
|
`.execute(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
// Only drop the index this migration introduced. idx_pages_title_trgm is owned
|
||||||
|
// by the #348 perf-indexes migration, so leave it for that migration's down().
|
||||||
|
await sql`DROP INDEX IF EXISTS idx_pages_text_content_trgm`.execute(db);
|
||||||
|
}
|
||||||
@@ -238,8 +238,9 @@ function convertReferenceFootnotes(markdown: string): string {
|
|||||||
*
|
*
|
||||||
* LINE-ANCHORED (the same shape the canonical parser uses in
|
* LINE-ANCHORED (the same shape the canonical parser uses in
|
||||||
* prosemirror-markdown/page-file.ts): the block opens only on `---\n` at the
|
* prosemirror-markdown/page-file.ts): the block opens only on `---\n` at the
|
||||||
* very start and closes only on a `\n---` line. The retired `markdownToHtml`
|
* very start and closes only on a `\n---` line. The retired editor-ext
|
||||||
* strip closed on the FIRST `---` ANYWHERE (an unanchored close), so a value
|
* `markdownToHtml` front-matter strip (removed in #347) closed on the FIRST
|
||||||
|
* `---` ANYWHERE (an unanchored close), so a value
|
||||||
* containing a triple-dash (e.g. `title: Q1 --- Q2`) truncated the front-matter
|
* containing a triple-dash (e.g. `title: Q1 --- Q2`) truncated the front-matter
|
||||||
* and leaked the rest into the body. An optional leading BOM is tolerated.
|
* and leaked the rest into the body. An optional leading BOM is tolerated.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import {
|
|||||||
isMetricsEnabled,
|
isMetricsEnabled,
|
||||||
observeMcpTool,
|
observeMcpTool,
|
||||||
incConnectTimeout,
|
incConnectTimeout,
|
||||||
|
incGetPageCacheHit,
|
||||||
|
incGetPageCacheMiss,
|
||||||
} from '../metrics/metrics.registry';
|
} from '../metrics/metrics.registry';
|
||||||
|
|
||||||
// Minimal shape of the embedded MCP HTTP handler exported by @docmost/mcp/http.
|
// Minimal shape of the embedded MCP HTTP handler exported by @docmost/mcp/http.
|
||||||
@@ -357,6 +359,10 @@ export class McpService implements OnModuleDestroy {
|
|||||||
observeMcpTool(labels?.tool ?? 'other', value);
|
observeMcpTool(labels?.tool ?? 'other', value);
|
||||||
} else if (name === 'collab_connect_timeouts_total') {
|
} else if (name === 'collab_connect_timeouts_total') {
|
||||||
incConnectTimeout();
|
incConnectTimeout();
|
||||||
|
} else if (name === 'mcp_getpage_cache_hits_total') {
|
||||||
|
incGetPageCacheHit();
|
||||||
|
} else if (name === 'mcp_getpage_cache_misses_total') {
|
||||||
|
incGetPageCacheMiss();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ export const METRIC_COLLAB_CONNECT_TIMEOUTS_TOTAL =
|
|||||||
export const METRIC_COLLAB_AUTH_DURATION = 'collab_auth_duration_seconds';
|
export const METRIC_COLLAB_AUTH_DURATION = 'collab_auth_duration_seconds';
|
||||||
export const METRIC_MCP_TOOL_DURATION = 'mcp_tool_duration_seconds';
|
export const METRIC_MCP_TOOL_DURATION = 'mcp_tool_duration_seconds';
|
||||||
|
|
||||||
|
// #479 — getPage PM→Markdown conversion cache hit/miss counters. Emitted by the
|
||||||
|
// MCP package via its dependency-neutral onMetric sink and routed onto these two
|
||||||
|
// prom counters by the mcp.service onMetric callback; a >50% hit-rate is the
|
||||||
|
// success signal for the getPage perf work. Same "do not rename" contract.
|
||||||
|
export const METRIC_MCP_GETPAGE_CACHE_HITS_TOTAL =
|
||||||
|
'mcp_getpage_cache_hits_total';
|
||||||
|
export const METRIC_MCP_GETPAGE_CACHE_MISSES_TOTAL =
|
||||||
|
'mcp_getpage_cache_misses_total';
|
||||||
|
|
||||||
// Histogram buckets (seconds). Chosen to give useful p50/p95/p99 resolution
|
// Histogram buckets (seconds). Chosen to give useful p50/p95/p99 resolution
|
||||||
// for typical web/DB latencies without exploding series cardinality.
|
// for typical web/DB latencies without exploding series cardinality.
|
||||||
export const HTTP_BUCKETS = [
|
export const HTTP_BUCKETS = [
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
METRIC_DB_QUERY_DURATION,
|
METRIC_DB_QUERY_DURATION,
|
||||||
METRIC_HTTP_REQUEST_DURATION,
|
METRIC_HTTP_REQUEST_DURATION,
|
||||||
METRIC_MCP_TOOL_DURATION,
|
METRIC_MCP_TOOL_DURATION,
|
||||||
|
METRIC_MCP_GETPAGE_CACHE_HITS_TOTAL,
|
||||||
|
METRIC_MCP_GETPAGE_CACHE_MISSES_TOTAL,
|
||||||
sizeBucket,
|
sizeBucket,
|
||||||
} from './metrics.constants';
|
} from './metrics.constants';
|
||||||
|
|
||||||
@@ -61,6 +63,9 @@ let connectTimeoutsCounter: Counter | null = null;
|
|||||||
let collabConnectHist: Histogram | null = null;
|
let collabConnectHist: Histogram | null = null;
|
||||||
let collabAuthHist: Histogram | null = null;
|
let collabAuthHist: Histogram | null = null;
|
||||||
let mcpToolHist: Histogram<'tool'> | null = null;
|
let mcpToolHist: Histogram<'tool'> | null = null;
|
||||||
|
// #479 — getPage conversion-cache hit/miss counters.
|
||||||
|
let getPageCacheHitsCounter: Counter | null = null;
|
||||||
|
let getPageCacheMissesCounter: Counter | null = null;
|
||||||
|
|
||||||
// #402 — read-on-scrape source for collab_docs_open. The gauge is NEVER
|
// #402 — read-on-scrape source for collab_docs_open. The gauge is NEVER
|
||||||
// inc/dec'd (that drifts under crashes/handoffs); instead its collect() callback
|
// inc/dec'd (that drifts under crashes/handoffs); instead its collect() callback
|
||||||
@@ -175,6 +180,18 @@ function init(): void {
|
|||||||
buckets: MCP_TOOL_BUCKETS,
|
buckets: MCP_TOOL_BUCKETS,
|
||||||
registers: [registry],
|
registers: [registry],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
getPageCacheHitsCounter = new Counter({
|
||||||
|
name: METRIC_MCP_GETPAGE_CACHE_HITS_TOTAL,
|
||||||
|
help: 'Total getPage PM→Markdown conversions served from the cache (skipped)',
|
||||||
|
registers: [registry],
|
||||||
|
});
|
||||||
|
|
||||||
|
getPageCacheMissesCounter = new Counter({
|
||||||
|
name: METRIC_MCP_GETPAGE_CACHE_MISSES_TOTAL,
|
||||||
|
help: 'Total getPage PM→Markdown conversions computed (cache misses)',
|
||||||
|
registers: [registry],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runs once when this module is first imported. Safe to call again (idempotent).
|
// Runs once when this module is first imported. Safe to call again (idempotent).
|
||||||
@@ -247,6 +264,14 @@ export function observeCollabAuth(seconds: number): void {
|
|||||||
collabAuthHist?.observe(seconds);
|
collabAuthHist?.observe(seconds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function incGetPageCacheHit(): void {
|
||||||
|
getPageCacheHitsCounter?.inc();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function incGetPageCacheMiss(): void {
|
||||||
|
getPageCacheMissesCounter?.inc();
|
||||||
|
}
|
||||||
|
|
||||||
export function observeMcpTool(tool: string, seconds: number): void {
|
export function observeMcpTool(tool: string, seconds: number): void {
|
||||||
// `tool` MUST be a bounded, registration-derived MCP tool name (the caller
|
// `tool` MUST be a bounded, registration-derived MCP tool name (the caller
|
||||||
// guarantees it comes from the registered-tool set) — never free-form input —
|
// guarantees it comes from the registered-tool set) — never free-form input —
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
incConnectTimeout,
|
incConnectTimeout,
|
||||||
incDocLoad,
|
incDocLoad,
|
||||||
incDocUnload,
|
incDocUnload,
|
||||||
|
incGetPageCacheHit,
|
||||||
|
incGetPageCacheMiss,
|
||||||
isMetricsEnabled,
|
isMetricsEnabled,
|
||||||
observeCollabAuth,
|
observeCollabAuth,
|
||||||
observeCollabConnect,
|
observeCollabConnect,
|
||||||
@@ -197,6 +199,8 @@ describe('metrics helpers are safe no-ops when METRICS_PORT is unset', () => {
|
|||||||
incDocLoad();
|
incDocLoad();
|
||||||
incDocUnload();
|
incDocUnload();
|
||||||
incConnectTimeout();
|
incConnectTimeout();
|
||||||
|
incGetPageCacheHit();
|
||||||
|
incGetPageCacheMiss();
|
||||||
// Registering a source must not create the gauge or invoke the fn.
|
// Registering a source must not create the gauge or invoke the fn.
|
||||||
registerDocsOpenSource(() => {
|
registerDocsOpenSource(() => {
|
||||||
throw new Error('docsOpenSource must NOT be called when disabled');
|
throw new Error('docsOpenSource must NOT be called when disabled');
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ describe('AiChatService run-stream attach [integration]', () => {
|
|||||||
{
|
{
|
||||||
isAiChatDeferredToolsEnabled: () => false,
|
isAiChatDeferredToolsEnabled: () => false,
|
||||||
isAiChatResumableStreamEnabled: () => true,
|
isAiChatResumableStreamEnabled: () => true,
|
||||||
|
isAiChatFinalStepLockdownEnabled: () => false,
|
||||||
} as any,
|
} as any,
|
||||||
registry,
|
registry,
|
||||||
);
|
);
|
||||||
@@ -499,6 +500,7 @@ describe('AiChatService run-stream attach [integration]', () => {
|
|||||||
{
|
{
|
||||||
isAiChatDeferredToolsEnabled: () => false,
|
isAiChatDeferredToolsEnabled: () => false,
|
||||||
isAiChatResumableStreamEnabled: () => true,
|
isAiChatResumableStreamEnabled: () => true,
|
||||||
|
isAiChatFinalStepLockdownEnabled: () => false,
|
||||||
} as any,
|
} as any,
|
||||||
registry,
|
registry,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ describe('AiChatService.stream [integration]', () => {
|
|||||||
{} as any, // pageAccess (idem)
|
{} as any, // pageAccess (idem)
|
||||||
// environment (#332): keep deferred tool loading OFF for this lifecycle
|
// environment (#332): keep deferred tool loading OFF for this lifecycle
|
||||||
// harness so the toolset/behavior is exactly as before.
|
// harness so the toolset/behavior is exactly as before.
|
||||||
{ isAiChatDeferredToolsEnabled: () => false } as any,
|
{ isAiChatDeferredToolsEnabled: () => false, isAiChatFinalStepLockdownEnabled: () => false } as any,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -378,7 +378,7 @@ describe('AiChatService.stream [integration]', () => {
|
|||||||
{} as any,
|
{} as any,
|
||||||
{} as any,
|
{} as any,
|
||||||
// #332: deferred tool loading ON — the property under test.
|
// #332: deferred tool loading ON — the property under test.
|
||||||
{ isAiChatDeferredToolsEnabled: () => true } as any,
|
{ isAiChatDeferredToolsEnabled: () => true, isAiChatFinalStepLockdownEnabled: () => false } as any,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { Kysely, sql } from 'kysely';
|
||||||
|
import {
|
||||||
|
getTestDb,
|
||||||
|
destroyTestDb,
|
||||||
|
createWorkspace,
|
||||||
|
createSpace,
|
||||||
|
} from './db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #443 dead-index guard — EXPLAIN on the REAL DB.
|
||||||
|
*
|
||||||
|
* The lookup mode's substring predicates run a leading-wildcard
|
||||||
|
* `LOWER(f_unaccent(col)) LIKE '%q%'`. Those are only fast when Postgres uses
|
||||||
|
* the GIN trigram indexes:
|
||||||
|
* - idx_pages_title_trgm on (LOWER(f_unaccent(title))) [#348]
|
||||||
|
* - idx_pages_text_content_trgm on (LOWER(f_unaccent(text_content))) [#443]
|
||||||
|
*
|
||||||
|
* Postgres uses a functional index ONLY when the query expression matches the
|
||||||
|
* index expression EXACTLY. The original lookup query wrapped the columns in
|
||||||
|
* `coalesce(col,'')`, which differs from the coalesce-FREE index expression and
|
||||||
|
* silently forced a Seq Scan on pages for EVERY lookup (the MCP client always
|
||||||
|
* sends substring:true). This test locks that in.
|
||||||
|
*
|
||||||
|
* Discriminator: `SET enable_seqscan = off` asks the planner "CAN this predicate
|
||||||
|
* use the index at all?" — which is exactly what the coalesce bug breaks. With
|
||||||
|
* seqscan disabled:
|
||||||
|
* - the coalesce-FREE (fixed) predicate plans a Bitmap Index Scan on the trgm
|
||||||
|
* index (no Seq Scan on pages);
|
||||||
|
* - the coalesce-WRAPPED (buggy) predicate cannot use the index and falls back
|
||||||
|
* to a Seq Scan on pages even though seqscan is disabled.
|
||||||
|
* We assert both to prove the fix and to keep the regression from silently
|
||||||
|
* returning.
|
||||||
|
*/
|
||||||
|
describe('SearchService agent-lookup EXPLAIN — trgm index is live [integration]', () => {
|
||||||
|
let db: Kysely<any>;
|
||||||
|
let workspaceId: string;
|
||||||
|
let spaceId: string;
|
||||||
|
|
||||||
|
async function insertPage(title: string, textContent: string): Promise<void> {
|
||||||
|
const id = randomUUID();
|
||||||
|
await db
|
||||||
|
.insertInto('pages')
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
slugId: `slug-${id.slice(0, 12)}`,
|
||||||
|
title,
|
||||||
|
textContent,
|
||||||
|
spaceId,
|
||||||
|
workspaceId,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run EXPLAIN (no ANALYZE — we only inspect the chosen plan) and return the
|
||||||
|
// concatenated plan text.
|
||||||
|
async function explain(query: string): Promise<string> {
|
||||||
|
const rows = await sql<{ 'QUERY PLAN': string }>`EXPLAIN ${sql.raw(query)}`.execute(
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
return (rows.rows as any[]).map((r) => r['QUERY PLAN']).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = getTestDb();
|
||||||
|
workspaceId = (await createWorkspace(db)).id;
|
||||||
|
spaceId = (await createSpace(db, workspaceId)).id;
|
||||||
|
|
||||||
|
// Seed enough rows that a trigram index is a plausible plan. The content is
|
||||||
|
// varied so the '%needle%' pattern is selective.
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
await insertPage(
|
||||||
|
`seed-title-${i}`,
|
||||||
|
`seed body content number ${i} lorem ipsum dolor sit amet ${i}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await insertPage('backup-srv.local', 'the needle-token-xyz lives here');
|
||||||
|
|
||||||
|
// Keep the trgm indexes' stats fresh so the planner costs them correctly.
|
||||||
|
await sql`ANALYZE pages`.execute(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await destroyTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Force the planner to answer "can the index be used?" rather than "is it
|
||||||
|
// cheaper than a seq scan on this size?". Restored after each test.
|
||||||
|
beforeEach(async () => {
|
||||||
|
await sql`SET enable_seqscan = off`.execute(db);
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
await sql`RESET enable_seqscan`.execute(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title predicate (coalesce-FREE, as fixed) uses idx_pages_title_trgm, not a Seq Scan', async () => {
|
||||||
|
const plan = await explain(
|
||||||
|
`SELECT id FROM pages WHERE LOWER(f_unaccent(title)) LIKE '%srv.local%'`,
|
||||||
|
);
|
||||||
|
expect(plan).toContain('idx_pages_title_trgm');
|
||||||
|
expect(plan).not.toMatch(/Seq Scan on pages/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('text_content predicate (coalesce-FREE, as fixed) uses idx_pages_text_content_trgm, not a Seq Scan', async () => {
|
||||||
|
const plan = await explain(
|
||||||
|
`SELECT id FROM pages WHERE LOWER(f_unaccent(text_content)) LIKE '%needle-token%'`,
|
||||||
|
);
|
||||||
|
expect(plan).toContain('idx_pages_text_content_trgm');
|
||||||
|
expect(plan).not.toMatch(/Seq Scan on pages/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Negative control: the OLD coalesce-wrapped predicate must NOT be able to use
|
||||||
|
// the index — even with seqscan disabled it can only Seq Scan pages. If this
|
||||||
|
// ever stops seq-scanning, the coalesce/index expressions have re-aligned and
|
||||||
|
// the guard above is no longer meaningful.
|
||||||
|
it('coalesce-WRAPPED text predicate (the bug) cannot use the index — falls to Seq Scan', async () => {
|
||||||
|
const plan = await explain(
|
||||||
|
`SELECT id FROM pages WHERE LOWER(f_unaccent(coalesce(text_content,''))) LIKE '%needle-token%'`,
|
||||||
|
);
|
||||||
|
expect(plan).not.toContain('idx_pages_text_content_trgm');
|
||||||
|
expect(plan).toMatch(/Seq Scan on pages/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { Kysely } from 'kysely';
|
||||||
|
import { SearchService } from 'src/core/search/search.service';
|
||||||
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
|
import {
|
||||||
|
getTestDb,
|
||||||
|
destroyTestDb,
|
||||||
|
createWorkspace,
|
||||||
|
createSpace,
|
||||||
|
} from './db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #443 — agent-lookup search mode, acceptance on the REAL DB schema.
|
||||||
|
*
|
||||||
|
* Exercises SearchService.searchPage(..., { substring: true }) against a
|
||||||
|
* migrated Postgres: substring matching of technical tokens the FTS tokenizer
|
||||||
|
* mangles (backup-srv.local, 10.0.12.5, WB-MGE-30D86B, "Теги: Docker"), the
|
||||||
|
* populated path + snippet, parentPageId subtree scoping, titleOnly, the empty
|
||||||
|
* result, LIKE-metacharacter escaping (`%`/`_` must NOT match everything), the
|
||||||
|
* permission post-filter applied BEFORE the limit, and the web-UI path staying
|
||||||
|
* on the legacy FTS shape when `substring` is absent.
|
||||||
|
*
|
||||||
|
* The tsv column is populated by the pages_tsvector_trigger on insert, so the
|
||||||
|
* FTS branch is exercised too.
|
||||||
|
*/
|
||||||
|
describe('SearchService agent-lookup mode [integration]', () => {
|
||||||
|
let db: Kysely<any>;
|
||||||
|
let service: SearchService;
|
||||||
|
let workspaceId: string;
|
||||||
|
let spaceId: string;
|
||||||
|
|
||||||
|
// Direct page insert (the shared createPage seeder omits text_content /
|
||||||
|
// parent_page_id, both of which this mode depends on). Returns the id.
|
||||||
|
async function insertPage(args: {
|
||||||
|
title: string;
|
||||||
|
textContent?: string;
|
||||||
|
parentPageId?: string | null;
|
||||||
|
spaceId?: string;
|
||||||
|
}): Promise<string> {
|
||||||
|
const id = randomUUID();
|
||||||
|
await db
|
||||||
|
.insertInto('pages')
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
slugId: `slug-${id.slice(0, 12)}`,
|
||||||
|
title: args.title,
|
||||||
|
textContent: args.textContent ?? null,
|
||||||
|
parentPageId: args.parentPageId ?? null,
|
||||||
|
spaceId: args.spaceId ?? spaceId,
|
||||||
|
workspaceId,
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a SearchService wired to the real DB + a real PageRepo (only its
|
||||||
|
// recursive-descendants method is used by this mode, and it needs only `db`),
|
||||||
|
// with lightweight stubs for the space-membership and permission repos so a
|
||||||
|
// test can drive scope + the permission post-filter explicitly.
|
||||||
|
function buildService(opts?: {
|
||||||
|
userSpaceIds?: string[];
|
||||||
|
// ids to KEEP after the permission post-filter; undefined = keep all.
|
||||||
|
accessibleIds?: string[];
|
||||||
|
}): SearchService {
|
||||||
|
const pageRepo = new PageRepo(db as any, null as any, null as any);
|
||||||
|
const spaceMemberRepo = {
|
||||||
|
getUserSpaceIds: async () => opts?.userSpaceIds ?? [spaceId],
|
||||||
|
};
|
||||||
|
const pagePermissionRepo = {
|
||||||
|
filterAccessiblePageIds: async ({ pageIds }: { pageIds: string[] }) =>
|
||||||
|
opts?.accessibleIds
|
||||||
|
? pageIds.filter((id) => opts.accessibleIds!.includes(id))
|
||||||
|
: pageIds,
|
||||||
|
};
|
||||||
|
return new SearchService(
|
||||||
|
db as any,
|
||||||
|
pageRepo as any,
|
||||||
|
{} as any, // shareRepo — unused by the lookup path
|
||||||
|
spaceMemberRepo as any,
|
||||||
|
pagePermissionRepo as any,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = getTestDb();
|
||||||
|
workspaceId = (await createWorkspace(db)).id;
|
||||||
|
spaceId = (await createSpace(db, workspaceId)).id;
|
||||||
|
service = buildService();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await destroyTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds `backup-srv.local` by the fragment `srv.local`', async () => {
|
||||||
|
const pageId = await insertPage({
|
||||||
|
title: 'backup-srv.local',
|
||||||
|
textContent: 'A backup server node.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items } = (await service.searchPage(
|
||||||
|
{ query: 'srv.local', spaceId, substring: true } as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
expect(items.map((i: any) => i.id)).toContain(pageId);
|
||||||
|
const hit = items.find((i: any) => i.id === pageId);
|
||||||
|
expect(hit.title).toBe('backup-srv.local');
|
||||||
|
// slugId must never be part of the server response shape.
|
||||||
|
expect('slugId' in hit).toBe(true); // server carries it; MCP strips it
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds a page whose TEXT contains `10.0.12.5` by the fragment `10.0.12` (empty-tsquery case)', async () => {
|
||||||
|
const pageId = await insertPage({
|
||||||
|
title: 'Server inventory',
|
||||||
|
textContent: 'The backup box lives at IP: 10.0.12.5. Debian 12, backups.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items } = (await service.searchPage(
|
||||||
|
{ query: '10.0.12', spaceId, substring: true } as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
const hit = items.find((i: any) => i.id === pageId);
|
||||||
|
expect(hit).toBeDefined();
|
||||||
|
// The windowed snippet must include the matched text.
|
||||||
|
expect(hit.snippet).toContain('10.0.12.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds `WB-MGE-30D86B` (alphanumeric token with dashes) by title', async () => {
|
||||||
|
const pageId = await insertPage({
|
||||||
|
title: 'WB-MGE-30D86B',
|
||||||
|
textContent: 'Device page.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items } = (await service.searchPage(
|
||||||
|
{ query: 'WB-MGE-30D86B', spaceId, substring: true } as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
const hit = items.find((i: any) => i.id === pageId);
|
||||||
|
expect(hit).toBeDefined();
|
||||||
|
// Exact title match → top tier (TITLE_EXACT=3) → score in [0.75, 1].
|
||||||
|
expect(hit.score).toBeGreaterThanOrEqual(0.75);
|
||||||
|
// And it is the top-ranked hit of its own result set.
|
||||||
|
expect(items[0].id).toBe(pageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds every page whose text literally contains `Теги: Docker`', async () => {
|
||||||
|
const a = await insertPage({
|
||||||
|
title: 'Container host A',
|
||||||
|
textContent: 'Some notes.\nТеги: Docker, compose\nmore.',
|
||||||
|
});
|
||||||
|
const b = await insertPage({
|
||||||
|
title: 'Container host B',
|
||||||
|
textContent: 'Prelude.\nТеги: Docker\nepilogue.',
|
||||||
|
});
|
||||||
|
const noise = await insertPage({
|
||||||
|
title: 'Unrelated',
|
||||||
|
textContent: 'Теги: Kubernetes',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items } = (await service.searchPage(
|
||||||
|
{ query: 'Теги: Docker', spaceId, substring: true, limit: 50 } as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
const ids = items.map((i: any) => i.id);
|
||||||
|
expect(ids).toContain(a);
|
||||||
|
expect(ids).toContain(b);
|
||||||
|
expect(ids).not.toContain(noise);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('populates a non-empty `path` for a nested hit and `[]` for a root hit', async () => {
|
||||||
|
const root = await insertPage({ title: 'Infrastructure' });
|
||||||
|
const mid = await insertPage({ title: 'Datacenter A', parentPageId: root });
|
||||||
|
const leaf = await insertPage({
|
||||||
|
title: 'unique-nested-host',
|
||||||
|
parentPageId: mid,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items } = (await service.searchPage(
|
||||||
|
{ query: 'unique-nested-host', spaceId, substring: true } as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
const hit = items.find((i: any) => i.id === leaf);
|
||||||
|
expect(hit.path).toEqual(['Infrastructure', 'Datacenter A']);
|
||||||
|
|
||||||
|
const rootHits = (await service.searchPage(
|
||||||
|
{ query: 'Infrastructure', spaceId, substring: true } as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
const rootHit = rootHits.items.find((i: any) => i.id === root);
|
||||||
|
expect(rootHit.path).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scopes to a subtree with parentPageId (cutting off sibling branches)', async () => {
|
||||||
|
const branchA = await insertPage({ title: 'BranchA-root' });
|
||||||
|
const inA = await insertPage({
|
||||||
|
title: 'scoped-target-xyz',
|
||||||
|
parentPageId: branchA,
|
||||||
|
});
|
||||||
|
const branchB = await insertPage({ title: 'BranchB-root' });
|
||||||
|
const inB = await insertPage({
|
||||||
|
title: 'scoped-target-xyz',
|
||||||
|
parentPageId: branchB,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items } = (await service.searchPage(
|
||||||
|
{
|
||||||
|
query: 'scoped-target-xyz',
|
||||||
|
spaceId,
|
||||||
|
substring: true,
|
||||||
|
parentPageId: branchA,
|
||||||
|
} as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
const ids = items.map((i: any) => i.id);
|
||||||
|
expect(ids).toContain(inA);
|
||||||
|
expect(ids).not.toContain(inB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes the parent page itself in the parentPageId subtree', async () => {
|
||||||
|
const parent = await insertPage({ title: 'self-included-parent' });
|
||||||
|
await insertPage({ title: 'child-of-self', parentPageId: parent });
|
||||||
|
|
||||||
|
const { items } = (await service.searchPage(
|
||||||
|
{
|
||||||
|
query: 'self-included-parent',
|
||||||
|
spaceId,
|
||||||
|
substring: true,
|
||||||
|
parentPageId: parent,
|
||||||
|
} as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
expect(items.map((i: any) => i.id)).toContain(parent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('titleOnly does NOT match on text_content', async () => {
|
||||||
|
const pageId = await insertPage({
|
||||||
|
title: 'Plain title',
|
||||||
|
textContent: 'body mentions the-secret-token here',
|
||||||
|
});
|
||||||
|
|
||||||
|
const withText = (await service.searchPage(
|
||||||
|
{ query: 'the-secret-token', spaceId, substring: true } as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
expect(withText.items.map((i: any) => i.id)).toContain(pageId);
|
||||||
|
|
||||||
|
const titleOnly = (await service.searchPage(
|
||||||
|
{
|
||||||
|
query: 'the-secret-token',
|
||||||
|
spaceId,
|
||||||
|
substring: true,
|
||||||
|
titleOnly: true,
|
||||||
|
} as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
expect(titleOnly.items.map((i: any) => i.id)).not.toContain(pageId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #443 Fix #1 regression: f_unaccent is NOT length-preserving, so an
|
||||||
|
// expanding char (ß→ss, …→...) BEFORE the match shifted the strpos position
|
||||||
|
// relative to the ORIGINAL text and the snippet slice ran past end → empty.
|
||||||
|
// The position and the slice now share the LOWER(f_unaccent(...)) space, so
|
||||||
|
// the window is aligned and always contains the matched (unaccented) token.
|
||||||
|
it('returns a populated snippet when an unaccent-EXPANDING char precedes the match', async () => {
|
||||||
|
// 300 × `ß` (each f_unaccent-expands to `ss`) before the needle. Under the
|
||||||
|
// old code strpos returned a position ~593 in the expanded space but the
|
||||||
|
// slice ran over the ORIGINAL (~360 char) text → empty snippet, match lost.
|
||||||
|
const prefix = 'ß'.repeat(300);
|
||||||
|
const pageId = await insertPage({
|
||||||
|
title: 'Expanding-unaccent page',
|
||||||
|
textContent: `${prefix} needle-token-xyz trailing.`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items } = (await service.searchPage(
|
||||||
|
{ query: 'needle-token-xyz', spaceId, substring: true } as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
const hit = items.find((i: any) => i.id === pageId);
|
||||||
|
expect(hit).toBeDefined();
|
||||||
|
// Snippet must be non-empty AND contain the matched token (unaccented form).
|
||||||
|
expect(hit.snippet.length).toBeGreaterThan(0);
|
||||||
|
expect(hit.snippet).toContain('needle-token-xyz');
|
||||||
|
});
|
||||||
|
|
||||||
|
// #443 Fix #2 regression: >200 matching pages for a broad substring, with
|
||||||
|
// exactly ONE exact-title hit. Without an ORDER BY on the 200-cap the exact
|
||||||
|
// hit could be among the arbitrarily-dropped rows; the ORDER BY keeps the
|
||||||
|
// strongest candidates so it must survive the cap and rank at the top.
|
||||||
|
it('keeps an exact-title hit through the 200-cap on a >200-row match set', async () => {
|
||||||
|
const isoSpace = (await createSpace(db, workspaceId)).id;
|
||||||
|
const svc = buildService({ userSpaceIds: [isoSpace] });
|
||||||
|
|
||||||
|
// 250 low-tier TEXT hits: the shared substring `capword` appears only in the
|
||||||
|
// body, never the title, so each is a TEXT-tier match (weakest tier).
|
||||||
|
for (let i = 0; i < 250; i++) {
|
||||||
|
await insertPage({
|
||||||
|
title: `filler-page-${i}`,
|
||||||
|
textContent: `body contains capword here #${i}`,
|
||||||
|
spaceId: isoSpace,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Exactly one EXACT-title hit for the same query token.
|
||||||
|
const exact = await insertPage({
|
||||||
|
title: 'capword',
|
||||||
|
textContent: 'unrelated body text',
|
||||||
|
spaceId: isoSpace,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items } = (await svc.searchPage(
|
||||||
|
{ query: 'capword', spaceId: isoSpace, substring: true, limit: 10 } as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
const ids = items.map((i: any) => i.id);
|
||||||
|
// The exact-title hit must survive the 200-cap and appear in the top `limit`.
|
||||||
|
expect(ids).toContain(exact);
|
||||||
|
// And, being TITLE_EXACT, it must be the single strongest hit.
|
||||||
|
expect(items[0].id).toBe(exact);
|
||||||
|
});
|
||||||
|
|
||||||
|
// #443 Fix #3: titleOnly matches only the title, so it must not leak the page
|
||||||
|
// body as the snippet (the old "first 300 chars of text_content" fallback).
|
||||||
|
it('titleOnly does NOT return a text-body snippet', async () => {
|
||||||
|
const pageId = await insertPage({
|
||||||
|
title: 'titleonly-snippet-page',
|
||||||
|
textContent: 'SECRET-BODY-CONTENT-NOT-IN-TITLE that must not leak.',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items } = (await service.searchPage(
|
||||||
|
{
|
||||||
|
query: 'titleonly-snippet-page',
|
||||||
|
spaceId,
|
||||||
|
substring: true,
|
||||||
|
titleOnly: true,
|
||||||
|
} as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
const hit = items.find((i: any) => i.id === pageId);
|
||||||
|
expect(hit).toBeDefined();
|
||||||
|
// The body text must not appear in the snippet; titleOnly → empty snippet.
|
||||||
|
expect(hit.snippet).not.toContain('SECRET-BODY-CONTENT-NOT-IN-TITLE');
|
||||||
|
expect(hit.snippet).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] (not an error) for a query that matches nothing', async () => {
|
||||||
|
const { items } = (await service.searchPage(
|
||||||
|
{
|
||||||
|
query: 'zzz-no-such-string-anywhere-42',
|
||||||
|
spaceId,
|
||||||
|
substring: true,
|
||||||
|
} as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
expect(items).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a `%` query does NOT match everything (LIKE metacharacter escaped)', async () => {
|
||||||
|
// Fresh space so we can assert on total counts without cross-test noise.
|
||||||
|
const isoSpace = (await createSpace(db, workspaceId)).id;
|
||||||
|
const svc = buildService({ userSpaceIds: [isoSpace] });
|
||||||
|
await insertPage({ title: 'alpha', spaceId: isoSpace });
|
||||||
|
await insertPage({ title: 'beta', spaceId: isoSpace });
|
||||||
|
const literal = await insertPage({
|
||||||
|
title: '100%-coverage',
|
||||||
|
spaceId: isoSpace,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items } = (await svc.searchPage(
|
||||||
|
{ query: '%', spaceId: isoSpace, substring: true, limit: 50 } as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
const ids = items.map((i: any) => i.id);
|
||||||
|
// `%` is a literal → matches only the page that actually contains '%'.
|
||||||
|
expect(ids).toContain(literal);
|
||||||
|
expect(ids).not.toContain(
|
||||||
|
items.find((i: any) => i.title === 'alpha')?.id,
|
||||||
|
);
|
||||||
|
expect(items.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an `_` query does NOT match everything (LIKE metacharacter escaped)', async () => {
|
||||||
|
const isoSpace = (await createSpace(db, workspaceId)).id;
|
||||||
|
const svc = buildService({ userSpaceIds: [isoSpace] });
|
||||||
|
await insertPage({ title: 'gamma', spaceId: isoSpace });
|
||||||
|
const literal = await insertPage({
|
||||||
|
title: 'snake_case_name',
|
||||||
|
spaceId: isoSpace,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items } = (await svc.searchPage(
|
||||||
|
{ query: '_', spaceId: isoSpace, substring: true, limit: 50 } as any,
|
||||||
|
{ workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
const ids = items.map((i: any) => i.id);
|
||||||
|
expect(ids).toContain(literal);
|
||||||
|
expect(items.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the permission post-filter to the MERGED set BEFORE the limit', async () => {
|
||||||
|
const isoSpace = (await createSpace(db, workspaceId)).id;
|
||||||
|
const keep = await insertPage({
|
||||||
|
title: 'perm-visible-target',
|
||||||
|
spaceId: isoSpace,
|
||||||
|
});
|
||||||
|
const hidden = await insertPage({
|
||||||
|
title: 'perm-hidden-target',
|
||||||
|
spaceId: isoSpace,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Authenticated (userId set) so the permission filter runs; only `keep` is
|
||||||
|
// accessible. limit 1 must NOT be able to select `hidden`.
|
||||||
|
const svc = buildService({
|
||||||
|
userSpaceIds: [isoSpace],
|
||||||
|
accessibleIds: [keep],
|
||||||
|
});
|
||||||
|
const { items } = (await svc.searchPage(
|
||||||
|
{
|
||||||
|
query: 'perm-',
|
||||||
|
spaceId: isoSpace,
|
||||||
|
substring: true,
|
||||||
|
limit: 1,
|
||||||
|
} as any,
|
||||||
|
{ userId: 'user-1', workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
const ids = items.map((i: any) => i.id);
|
||||||
|
expect(ids).toContain(keep);
|
||||||
|
expect(ids).not.toContain(hidden);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('web-UI path (no `substring` flag) keeps the legacy FTS response shape', async () => {
|
||||||
|
await insertPage({
|
||||||
|
title: 'legacy shape page',
|
||||||
|
textContent: 'searchable legacyword content',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { items } = (await service.searchPage(
|
||||||
|
{ query: 'legacyword', spaceId } as any,
|
||||||
|
{ userId: 'user-1', workspaceId },
|
||||||
|
)) as any;
|
||||||
|
|
||||||
|
// Legacy hits carry rank + highlight + space, and NO path/snippet/score.
|
||||||
|
const hit = items[0];
|
||||||
|
expect(hit).toBeDefined();
|
||||||
|
expect('rank' in hit).toBe(true);
|
||||||
|
expect('highlight' in hit).toBe(true);
|
||||||
|
expect('path' in hit).toBe(false);
|
||||||
|
expect('snippet' in hit).toBe(false);
|
||||||
|
expect('score' in hit).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,9 +11,6 @@
|
|||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "./src/index.ts",
|
"module": "./src/index.ts",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"dependencies": {
|
|
||||||
"marked": "17.0.5"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitest/coverage-v8": "4.1.6",
|
"@vitest/coverage-v8": "4.1.6",
|
||||||
"vitest": "4.1.6"
|
"vitest": "4.1.6"
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export * from "./lib/excalidraw";
|
|||||||
export * from "./lib/embed";
|
export * from "./lib/embed";
|
||||||
export * from "./lib/html-embed/html-embed";
|
export * from "./lib/html-embed/html-embed";
|
||||||
export * from "./lib/mention";
|
export * from "./lib/mention";
|
||||||
export * from "./lib/markdown";
|
|
||||||
export * from "./lib/search-and-replace";
|
export * from "./lib/search-and-replace";
|
||||||
export * from "./lib/embed-provider";
|
export * from "./lib/embed-provider";
|
||||||
export * from "./lib/subpages";
|
export * from "./lib/subpages";
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import {
|
|||||||
* ProseMirror JSON directly (never running the editor's plugins), so the
|
* ProseMirror JSON directly (never running the editor's plugins), so the
|
||||||
* canonical footnote topology was never enforced on those writes. The consumers
|
* canonical footnote topology was never enforced on those writes. The consumers
|
||||||
* of this editor-ext copy are: the server markdown/HTML import
|
* of this editor-ext copy are: the server markdown/HTML import
|
||||||
* (`markdownToHtml -> htmlToJson` in import.service / file-import-task.service),
|
* (`markdownToProseMirror` from @docmost/prosemirror-markdown in import.service /
|
||||||
|
* file-import-task.service),
|
||||||
* `PageService` create/update (`parseProsemirrorContent` for the JSON/markdown/
|
* `PageService` create/update (`parseProsemirrorContent` for the JSON/markdown/
|
||||||
* HTML REST write paths), and the client markdown PASTE path
|
* HTML REST write paths), and the client markdown PASTE path
|
||||||
* (`markdown-clipboard.ts`). (The MCP package mirrors this canonicalizer in
|
* (`markdown-clipboard.ts`). (The MCP package mirrors this canonicalizer in
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { htmlToMarkdown } from "../markdown/utils/turndown.utils";
|
|
||||||
import { markdownToHtml } from "../markdown/utils/marked.utils";
|
|
||||||
import { extractFootnoteDefinitions } from "../markdown/utils/footnote.marked";
|
|
||||||
|
|
||||||
// HTML the editor-ext nodes render (sup[data-footnote-ref], section/div).
|
|
||||||
const HTML =
|
|
||||||
`<p>Water<sup data-footnote-ref data-id="fn1"></sup> and clay<sup data-footnote-ref data-id="fn2"></sup>.</p>` +
|
|
||||||
`<section data-footnotes>` +
|
|
||||||
`<div data-footnote-def data-id="fn1"><p>First note.</p></div>` +
|
|
||||||
`<div data-footnote-def data-id="fn2"><p>Second note.</p></div>` +
|
|
||||||
`</section>`;
|
|
||||||
|
|
||||||
describe("footnote markdown round-trip", () => {
|
|
||||||
it("HTML -> Markdown produces pandoc footnote syntax", () => {
|
|
||||||
const md = htmlToMarkdown(HTML);
|
|
||||||
expect(md).toContain("[^fn1]");
|
|
||||||
expect(md).toContain("[^fn2]");
|
|
||||||
expect(md).toContain("[^fn1]: First note.");
|
|
||||||
expect(md).toContain("[^fn2]: Second note.");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Markdown -> HTML rebuilds the footnote nodes' HTML", async () => {
|
|
||||||
const md = htmlToMarkdown(HTML);
|
|
||||||
const html = await markdownToHtml(md);
|
|
||||||
expect(html).toContain('data-footnote-ref data-id="fn1"');
|
|
||||||
expect(html).toContain('data-footnote-ref data-id="fn2"');
|
|
||||||
expect(html).toContain("data-footnotes");
|
|
||||||
expect(html).toContain('data-footnote-def data-id="fn1"');
|
|
||||||
expect(html).toContain("First note.");
|
|
||||||
expect(html).toContain("Second note.");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves a [^id]: line shown inside a fenced code block (not a definition)", async () => {
|
|
||||||
// A document that DOCUMENTS footnote syntax inside a code fence. The
|
|
||||||
// `[^demo]: ...` line is example text, not a real definition, and must
|
|
||||||
// survive the Markdown -> HTML conversion verbatim.
|
|
||||||
const md = [
|
|
||||||
"Here is how footnotes look:",
|
|
||||||
"",
|
|
||||||
"```markdown",
|
|
||||||
"Some text[^demo]",
|
|
||||||
"",
|
|
||||||
"[^demo]: this is the definition",
|
|
||||||
"```",
|
|
||||||
"",
|
|
||||||
"End of doc.",
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
const html = await markdownToHtml(md);
|
|
||||||
// The example definition line is kept inside the rendered code block.
|
|
||||||
expect(html).toContain("[^demo]: this is the definition");
|
|
||||||
// It did NOT get pulled out into a real footnotes section.
|
|
||||||
expect(html).not.toContain("data-footnotes");
|
|
||||||
expect(html).not.toContain("data-footnote-def");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("extractFootnoteDefinitions keeps the FIRST duplicate definition and reuses markers", () => {
|
|
||||||
// Two definitions share id `d`, and the body has two `[^d]` markers. Under
|
|
||||||
// the import model (#166) duplicate definition ids are FIRST-WINS: only the
|
|
||||||
// first definition is kept; markers are NEVER rewritten, so the two `[^d]`
|
|
||||||
// references reuse the single footnote.
|
|
||||||
const md = [
|
|
||||||
"See here[^d] and there[^d].",
|
|
||||||
"",
|
|
||||||
"[^d]: first",
|
|
||||||
"[^d]: second",
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
const { body, section } = extractFootnoteDefinitions(md);
|
|
||||||
|
|
||||||
const defIds = Array.from(
|
|
||||||
section.matchAll(/data-footnote-def data-id="([^"]+)"/g),
|
|
||||||
).map((m) => m[1]);
|
|
||||||
expect(defIds).toEqual(["d"]); // first-wins: one definition
|
|
||||||
expect(section).toContain("first");
|
|
||||||
expect(section).not.toContain("second"); // duplicate dropped
|
|
||||||
|
|
||||||
// Both markers stay `[^d]` (reuse) — no `d__2` minting.
|
|
||||||
const refIds = Array.from(body.matchAll(/\[\^([^\]\s]+)\]/g)).map(
|
|
||||||
(m) => m[1],
|
|
||||||
);
|
|
||||||
expect(refIds).toEqual(["d", "d"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("extractFootnoteDefinitions is DETERMINISTIC and stable (same input -> same output)", () => {
|
|
||||||
// The output must be a pure function of the input markdown so importing the
|
|
||||||
// same source twice (or via the editor and the MCP mirror) is identical.
|
|
||||||
const md = [
|
|
||||||
"See[^d] one[^d] two[^d].",
|
|
||||||
"",
|
|
||||||
"[^d]: first",
|
|
||||||
"[^d]: second",
|
|
||||||
"[^d]: third",
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
const run = () => {
|
|
||||||
const { body, section } = extractFootnoteDefinitions(md);
|
|
||||||
const defIds = Array.from(
|
|
||||||
section.matchAll(/data-footnote-def data-id="([^"]+)"/g),
|
|
||||||
).map((m) => m[1]);
|
|
||||||
const refIds = Array.from(body.matchAll(/\[\^([^\]\s]+)\]/g)).map(
|
|
||||||
(m) => m[1],
|
|
||||||
);
|
|
||||||
return { defIds, refIds };
|
|
||||||
};
|
|
||||||
|
|
||||||
const a = run();
|
|
||||||
const b = run();
|
|
||||||
expect(a).toEqual(b);
|
|
||||||
// First-wins: one kept definition `d`; all three reuse markers stay `d`.
|
|
||||||
expect(a.defIds).toEqual(["d"]);
|
|
||||||
expect(a.refIds).toEqual(["d", "d", "d"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("markdownToHtml with a reused id renders ONE shared footnote def", async () => {
|
|
||||||
const md = [
|
|
||||||
"See here[^d] and there[^d].",
|
|
||||||
"",
|
|
||||||
"[^d]: first",
|
|
||||||
"[^d]: second",
|
|
||||||
].join("\n");
|
|
||||||
const html = await markdownToHtml(md);
|
|
||||||
const defIds = Array.from(
|
|
||||||
html.matchAll(/data-footnote-def data-id="([^"]+)"/g),
|
|
||||||
).map((m) => m[1]);
|
|
||||||
expect(defIds).toEqual(["d"]); // one shared definition
|
|
||||||
expect(html).toContain("first");
|
|
||||||
expect(html).not.toContain("second");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -103,8 +103,9 @@ interface CollisionPlan {
|
|||||||
* `X__2`, `X__3`, collision-bumped) so it survives as a distinct footnote — which,
|
* `X__2`, `X__3`, collision-bumped) so it survives as a distinct footnote — which,
|
||||||
* having no matching reference, then falls under the normal orphan policy. It is
|
* having no matching reference, then falls under the normal orphan policy. It is
|
||||||
* only ever dropped for lacking a reference, never for colliding. The IMPORT
|
* only ever dropped for lacking a reference, never for colliding. The IMPORT
|
||||||
* paths (footnote.marked.ts / MCP extractFootnotes) instead apply first-wins +
|
* paths (@docmost/prosemirror-markdown / MCP extractFootnotes) instead apply
|
||||||
* drop + warn for duplicate definitions; that divergence is intentional — import
|
* first-wins + drop + warn for duplicate definitions; that divergence is
|
||||||
|
* intentional — import
|
||||||
* is an agent-authored artifact we sanitize, the editor is live user data we must
|
* is an agent-authored artifact we sanitize, the editor is live user data we must
|
||||||
* not lose.
|
* not lose.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import { deriveFootnoteId } from "./footnote-util";
|
|||||||
*
|
*
|
||||||
* `deriveFootnoteId` lives ONLY in editor-ext now — it is used by
|
* `deriveFootnoteId` lives ONLY in editor-ext now — it is used by
|
||||||
* `resolveCollisions` (re-id of a duplicate definition) and `footnotePastePlugin`
|
* `resolveCollisions` (re-id of a duplicate definition) and `footnotePastePlugin`
|
||||||
* (re-id of a pasted colliding definition). The MCP/marked import paths no longer
|
* (re-id of a pasted colliding definition). The MCP / @docmost/prosemirror-markdown
|
||||||
* derive ids (duplicate definitions there are first-wins-dropped, #166), so there
|
* import paths no longer derive ids (duplicate definitions there are
|
||||||
|
* first-wins-dropped, #166), so there
|
||||||
* is no cross-package copy and no parity test to keep in sync. This table pins the
|
* is no cross-package copy and no parity test to keep in sync. This table pins the
|
||||||
* deterministic scheme so a future change to it is a conscious one.
|
* deterministic scheme so a future change to it is a conscious one.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -63,8 +63,9 @@ export function generateFootnoteId(): string {
|
|||||||
* its own seen-set before requesting the next derived id.
|
* its own seen-set before requesting the next derived id.
|
||||||
*
|
*
|
||||||
* Used only inside editor-ext now (resolveCollisions for a re-id'd duplicate
|
* Used only inside editor-ext now (resolveCollisions for a re-id'd duplicate
|
||||||
* DEFINITION, and footnotePastePlugin). The MCP/marked import paths no longer
|
* DEFINITION, and footnotePastePlugin). The MCP / @docmost/prosemirror-markdown
|
||||||
* derive ids — duplicate definitions there are first-wins-dropped (#166) — so
|
* import paths no longer derive ids — duplicate definitions there are
|
||||||
|
* first-wins-dropped (#166) — so
|
||||||
* there is no cross-package copy to keep in sync. The golden table in
|
* there is no cross-package copy to keep in sync. The golden table in
|
||||||
* footnote-util.derive-id.test.ts pins the scheme.
|
* footnote-util.derive-id.test.ts pins the scheme.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { generateJSON } from "@tiptap/html";
|
|
||||||
import { Document } from "@tiptap/extension-document";
|
|
||||||
import { Paragraph } from "@tiptap/extension-paragraph";
|
|
||||||
import { Text } from "@tiptap/extension-text";
|
|
||||||
import { htmlToMarkdown } from "../markdown/utils/turndown.utils";
|
|
||||||
import { markdownToHtml } from "../markdown/utils/marked.utils";
|
|
||||||
import { TiptapImage } from "./image";
|
|
||||||
|
|
||||||
// Minimal schema for parsing markdownToHtml output back to JSON (mirrors
|
|
||||||
// image.spec.ts), so we can assert the recovered caption EXACTLY.
|
|
||||||
const parseExtensions = [Document, Paragraph, Text, TiptapImage];
|
|
||||||
|
|
||||||
// Lossless markdown round-trip for image captions (issue #221). An image WITH a
|
|
||||||
// caption can't be expressed as ``, so it is emitted as a raw <img>
|
|
||||||
// (carrying data-caption) wrapped in a block <div>, the same trick the <video>
|
|
||||||
// rule uses. marked passes the raw HTML through, so markdownToHtml keeps the
|
|
||||||
// data-caption, and the image extension's parseHTML restores the attribute.
|
|
||||||
describe("image caption markdown round-trip", () => {
|
|
||||||
it("HTML -> Markdown emits a raw <img data-caption> for captioned images", () => {
|
|
||||||
const html = `<p><img src="/files/a.png" alt="cat" data-caption="A grey cat"></p>`;
|
|
||||||
const md = htmlToMarkdown(html);
|
|
||||||
expect(md).toContain("data-caption=\"A grey cat\"");
|
|
||||||
expect(md).toContain('src="/files/a.png"');
|
|
||||||
expect(md).toContain('alt="cat"');
|
|
||||||
// It must NOT degrade to the lossy ![]() form.
|
|
||||||
expect(md).not.toContain("![cat]");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Markdown -> HTML restores data-caption on the <img>", async () => {
|
|
||||||
const html = `<p><img src="/files/a.png" alt="cat" data-caption="A grey cat"></p>`;
|
|
||||||
const md = htmlToMarkdown(html);
|
|
||||||
const back = await markdownToHtml(md);
|
|
||||||
expect(back).toContain('data-caption="A grey cat"');
|
|
||||||
expect(back).toContain('src="/files/a.png"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("special characters in the caption survive the round-trip (escaped)", async () => {
|
|
||||||
// The source caption is the decoded string `Tom & "Jerry"` (both an `&` and
|
|
||||||
// a `"`). escapeHtmlAttr must encode `&` -> `&` and `"` -> `"`.
|
|
||||||
const html = `<p><img src="/files/a.png" data-caption='Tom & "Jerry"'></p>`;
|
|
||||||
const md = htmlToMarkdown(html);
|
|
||||||
|
|
||||||
// (a) The intermediate Markdown must carry the EXACT escaped attribute. This
|
|
||||||
// fails if escapeHtmlAttr stopped escaping `"` (attribute break-out:
|
|
||||||
// data-caption="Tom & "Jerry"") or double-encoded `&` (`&amp;`).
|
|
||||||
expect(md).toContain('data-caption="Tom & "Jerry""');
|
|
||||||
|
|
||||||
const back = await markdownToHtml(md);
|
|
||||||
expect(back).toContain("data-caption=");
|
|
||||||
expect(back).toContain("Jerry");
|
|
||||||
expect(back).toContain("Tom");
|
|
||||||
|
|
||||||
// (b) Re-parse the rendered HTML through the image extension's parseHTML and
|
|
||||||
// assert the recovered caption is EXACTLY the original (no corruption, loss,
|
|
||||||
// or double-encoding).
|
|
||||||
const json = generateJSON(back, parseExtensions);
|
|
||||||
expect(json.content?.[0]?.attrs?.caption).toBe('Tom & "Jerry"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("caption-less images stay a clean  with no raw HTML", () => {
|
|
||||||
const html = `<p><img src="/files/a.png" alt="cat"></p>`;
|
|
||||||
const md = htmlToMarkdown(html);
|
|
||||||
expect(md).toContain("");
|
|
||||||
expect(md).not.toContain("data-caption");
|
|
||||||
expect(md).not.toContain("<img");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { htmlEmbedExtension } from "./utils/html-embed.marked";
|
|
||||||
import { markdownToHtml } from "./index";
|
|
||||||
import { encodeHtmlEmbedSource } from "../html-embed/html-embed";
|
|
||||||
|
|
||||||
// CONTRACT tests for the marked block tokenizer that rebuilds an htmlEmbed node
|
|
||||||
// from the `<!--html-embed:BASE64-->` marker (html-embed.marked.ts), plus the
|
|
||||||
// observable round-trip through markdownToHtml.
|
|
||||||
//
|
|
||||||
// These pin the REAL tokenizer behaviour the import path depends on:
|
|
||||||
// - the tokenizer rule is anchored (^) and only accepts the base64 alphabet
|
|
||||||
// [A-Za-z0-9+/=], so a marker with non-base64 chars is NOT tokenized and
|
|
||||||
// survives as a literal HTML comment (not silently turned into something the
|
|
||||||
// server's strip no longer recognizes);
|
|
||||||
// - start() reports the correct index of the next marker so marked invokes the
|
|
||||||
// tokenizer at the right offset when a marker sits mid-document / after text;
|
|
||||||
// - a marker with surrounding text on the SAME line is split out into its own
|
|
||||||
// embed div while the surrounding text becomes ordinary paragraphs.
|
|
||||||
//
|
|
||||||
// The contract is asserted against the actual exported extension and pipeline —
|
|
||||||
// no behaviour is invented; the expectations were read off the real tokenizer.
|
|
||||||
|
|
||||||
const SAMPLE = "<b>x</b>";
|
|
||||||
const ENC = encodeHtmlEmbedSource(SAMPLE);
|
|
||||||
|
|
||||||
describe("htmlEmbed marked tokenizer — start()", () => {
|
|
||||||
it("returns the index of a marker that sits mid-document", () => {
|
|
||||||
const src = `hello world <!--html-embed:${ENC}-->`;
|
|
||||||
expect(htmlEmbedExtension.start(src)).toBe(src.indexOf("<!--html-embed:"));
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 0 when the marker is at the very start", () => {
|
|
||||||
expect(htmlEmbedExtension.start(`<!--html-embed:${ENC}-->`)).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns -1 when there is no marker", () => {
|
|
||||||
expect(htmlEmbedExtension.start("no marker here")).toBe(-1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("htmlEmbed marked tokenizer — tokenizer()", () => {
|
|
||||||
it("tokenizes a marker at the start of the input, capturing the base64 payload", () => {
|
|
||||||
const token = htmlEmbedExtension.tokenizer(`<!--html-embed:${ENC}-->`);
|
|
||||||
expect(token).toBeTruthy();
|
|
||||||
expect(token!.type).toBe("htmlEmbed");
|
|
||||||
expect(token!.raw).toBe(`<!--html-embed:${ENC}-->`);
|
|
||||||
expect(token!.encoded).toBe(ENC);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("tokenizes an EMPTY marker (the [A-Za-z0-9+/=]* class allows zero chars)", () => {
|
|
||||||
const token = htmlEmbedExtension.tokenizer("<!--html-embed:-->");
|
|
||||||
expect(token).toBeTruthy();
|
|
||||||
expect(token!.encoded).toBe("");
|
|
||||||
expect(token!.raw).toBe("<!--html-embed:-->");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT tokenize when text precedes the marker (rule is anchored ^)", () => {
|
|
||||||
// marked relies on start() to advance to the marker; the tokenizer itself
|
|
||||||
// only matches at offset 0, so a non-anchored call returns undefined.
|
|
||||||
expect(
|
|
||||||
htmlEmbedExtension.tokenizer(`hello <!--html-embed:${ENC}-->`),
|
|
||||||
).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT tokenize a marker containing a non-base64 char ('$')", () => {
|
|
||||||
expect(
|
|
||||||
htmlEmbedExtension.tokenizer("<!--html-embed:ab$cd-->"),
|
|
||||||
).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT tokenize a marker containing a space", () => {
|
|
||||||
expect(
|
|
||||||
htmlEmbedExtension.tokenizer("<!--html-embed:ab cd-->"),
|
|
||||||
).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renderer emits the embed div the node's parseHTML recognizes", () => {
|
|
||||||
const token = htmlEmbedExtension.tokenizer(`<!--html-embed:${ENC}-->`)!;
|
|
||||||
const html = htmlEmbedExtension.renderer(token as any);
|
|
||||||
expect(html).toBe(
|
|
||||||
`<div data-type="htmlEmbed" data-source="${ENC}"></div>`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("htmlEmbed marked tokenizer — markdownToHtml round-trip", () => {
|
|
||||||
it("splits a marker out of surrounding same-line text into its own embed div", async () => {
|
|
||||||
const html = await markdownToHtml(`before <!--html-embed:${ENC}--> after`);
|
|
||||||
// The marker became the embed div...
|
|
||||||
expect(html).toContain(
|
|
||||||
`<div data-type="htmlEmbed" data-source="${ENC}"></div>`,
|
|
||||||
);
|
|
||||||
// ...and the surrounding text survived as ordinary paragraph content.
|
|
||||||
expect(html).toContain("before");
|
|
||||||
expect(html).toContain("after");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves a marker with non-base64 chars as a literal comment (NOT an embed div)", async () => {
|
|
||||||
const html = await markdownToHtml("<!--html-embed:ab$cd-->");
|
|
||||||
// It is NOT tokenized into an embed div the server would strip...
|
|
||||||
expect(html).not.toContain('data-type="htmlEmbed"');
|
|
||||||
// ...it passes through unchanged as a literal HTML comment.
|
|
||||||
expect(html).toContain("<!--html-embed:ab$cd-->");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from "./utils/marked.utils";
|
|
||||||
export * from "./utils/turndown.utils";
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { markdownToHtml, htmlToMarkdown } from "./index";
|
|
||||||
import {
|
|
||||||
encodeHtmlEmbedSource,
|
|
||||||
decodeHtmlEmbedSource,
|
|
||||||
} from "../html-embed/html-embed";
|
|
||||||
|
|
||||||
// SECURITY (Variant C admin gate, import attack surface).
|
|
||||||
//
|
|
||||||
// The markdown import path is the only write path where an htmlEmbed reaches
|
|
||||||
// the server purely from file bytes (no editor / collab socket). The marked
|
|
||||||
// tokenizer in `html-embed.marked.ts` and the turndown rule in
|
|
||||||
// `turndown.utils.ts` are what materialize the `<!--html-embed:BASE64-->`
|
|
||||||
// marker into the `<div data-type="htmlEmbed" data-source="BASE64">` element
|
|
||||||
// that the server then parses into an htmlEmbed node and the admin gate strips.
|
|
||||||
//
|
|
||||||
// If either the tokenizer regex or the turndown rule shape drifts, the marker
|
|
||||||
// would either (a) stop becoming an htmlEmbed node (silently dropping admin
|
|
||||||
// content) or (b) become some OTHER tag the server's `hasHtmlEmbedNode` no
|
|
||||||
// longer recognizes (a strip bypass). These tests pin the marker <-> embed-div
|
|
||||||
// contract that the server-side strip relies on. editor-ext had ZERO tests
|
|
||||||
// before this file; this adds the runner + the round-trip coverage.
|
|
||||||
|
|
||||||
// The server parses the embed div by matching `data-type="htmlEmbed"` and
|
|
||||||
// decoding `data-source`; mirror that here so the assertion is exactly what the
|
|
||||||
// real `htmlToJson` -> htmlEmbed node parse depends on (the node's parseHTML in
|
|
||||||
// html-embed.ts uses the same selector + decodeHtmlEmbedSource).
|
|
||||||
const EMBED_DIV_RE = /<div[^>]*\bdata-type="htmlEmbed"[^>]*>/;
|
|
||||||
function extractEmbedSource(html: string): string | undefined {
|
|
||||||
const div = EMBED_DIV_RE.exec(html);
|
|
||||||
if (!div) return undefined;
|
|
||||||
const enc = /data-source="([^"]*)"/.exec(div[0]);
|
|
||||||
if (!enc) return undefined;
|
|
||||||
return decodeHtmlEmbedSource(enc[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replicates the server's `hasHtmlEmbedNode` decision against the embed *div*
|
|
||||||
// (the HTML form the server immediately converts to JSON). If this matches, the
|
|
||||||
// server's JSON-level `hasHtmlEmbedNode` will too, because htmlToJson maps this
|
|
||||||
// exact div to an htmlEmbed node.
|
|
||||||
function htmlHasHtmlEmbed(html: string): boolean {
|
|
||||||
return EMBED_DIV_RE.test(html);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("markdown <!--html-embed--> import round-trip", () => {
|
|
||||||
const source = "<script>x</script>";
|
|
||||||
|
|
||||||
it("markdownToHtml turns the marker into an htmlEmbed div carrying the source", async () => {
|
|
||||||
const md = "<!--html-embed:" + encodeHtmlEmbedSource(source) + "-->";
|
|
||||||
const html = await markdownToHtml(md);
|
|
||||||
|
|
||||||
// The marker became the embed div the server recognizes as an htmlEmbed
|
|
||||||
// node (so the server's hasHtmlEmbedNode would match it after htmlToJson).
|
|
||||||
expect(htmlHasHtmlEmbed(html)).toBe(true);
|
|
||||||
// The decoded source is the original script, intact.
|
|
||||||
expect(extractEmbedSource(html)).toBe(source);
|
|
||||||
// The raw script is NOT inlined into the HTML — it stays base64 in the
|
|
||||||
// attribute (the marker itself must not be a direct injection vector).
|
|
||||||
expect(html).not.toContain("<script>x</script>");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves UTF-8 / special chars in the embedded source", async () => {
|
|
||||||
const utf8 = '<script>console.log("héllo → 世界")</script>';
|
|
||||||
const md = "<!--html-embed:" + encodeHtmlEmbedSource(utf8) + "-->";
|
|
||||||
const html = await markdownToHtml(md);
|
|
||||||
expect(htmlHasHtmlEmbed(html)).toBe(true);
|
|
||||||
expect(extractEmbedSource(html)).toBe(utf8);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("an empty marker still produces an htmlEmbed div (empty source)", async () => {
|
|
||||||
const html = await markdownToHtml("<!--html-embed:-->");
|
|
||||||
expect(htmlHasHtmlEmbed(html)).toBe(true);
|
|
||||||
expect(extractEmbedSource(html)).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("round-trips htmlToMarkdown -> markdownToHtml preserving the embed marker", async () => {
|
|
||||||
const encoded = encodeHtmlEmbedSource(source);
|
|
||||||
// NOTE: turndown drops a *blank* (childless) element before any custom rule
|
|
||||||
// runs, and the htmlEmbed div is normally childless. The export pipeline
|
|
||||||
// therefore must give the rule a non-blank div to fire on; we add an inert
|
|
||||||
// text child here to exercise the real turndown htmlEmbed rule. (A blank
|
|
||||||
// embed div serializing to "" is asserted separately below as a documented
|
|
||||||
// edge so this contract drift is visible.)
|
|
||||||
const startHtml = `<div data-type="htmlEmbed" data-source="${encoded}">x</div>`;
|
|
||||||
|
|
||||||
// Export to markdown: the turndown rule emits the <!--html-embed:..-->
|
|
||||||
// marker (lossless, inert in plain markdown viewers).
|
|
||||||
const md = htmlToMarkdown(startHtml);
|
|
||||||
expect(md).toContain("<!--html-embed:" + encoded + "-->");
|
|
||||||
|
|
||||||
// Re-import: the marker round-trips back into an embed div with the same
|
|
||||||
// decoded source — this is the marker <-> embed-div contract the server's
|
|
||||||
// import strip depends on.
|
|
||||||
const html = await markdownToHtml(md);
|
|
||||||
expect(htmlHasHtmlEmbed(html)).toBe(true);
|
|
||||||
expect(extractEmbedSource(html)).toBe(source);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("documents that a BLANK embed div serializes to empty markdown (turndown drops childless blocks)", () => {
|
|
||||||
const encoded = encodeHtmlEmbedSource(source);
|
|
||||||
const blank = `<div data-type="htmlEmbed" data-source="${encoded}"></div>`;
|
|
||||||
// This pins current behavior so a future change to the turndown rule (e.g.
|
|
||||||
// making it fire on blank nodes) is caught rather than silently shipping.
|
|
||||||
expect(htmlToMarkdown(blank)).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("the base64 codec itself round-trips (no '<' leaks into the attribute)", () => {
|
|
||||||
const encoded = encodeHtmlEmbedSource(source);
|
|
||||||
expect(encoded).not.toContain("<");
|
|
||||||
expect(decodeHtmlEmbedSource(encoded)).toBe(source);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
/**
|
|
||||||
* Flexible `basename` implementation for node and the browser
|
|
||||||
* @see https://stackoverflow.com/a/59907288/2228771
|
|
||||||
*/
|
|
||||||
export function getBasename(path: string) {
|
|
||||||
// make sure the basename is not empty, if string ends with separator
|
|
||||||
let end = path.length - 1;
|
|
||||||
while (path[end] === '/' || path[end] === '\\') {
|
|
||||||
--end;
|
|
||||||
}
|
|
||||||
|
|
||||||
// support mixing of Win + Unix path separators
|
|
||||||
const i1 = path.lastIndexOf('/', end);
|
|
||||||
const i2 = path.lastIndexOf('\\', end);
|
|
||||||
|
|
||||||
let start: number;
|
|
||||||
if (i1 === -1) {
|
|
||||||
if (i2 === -1) {
|
|
||||||
// no separator in the whole thing
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
start = i2;
|
|
||||||
} else if (i2 === -1) {
|
|
||||||
start = i1;
|
|
||||||
} else {
|
|
||||||
start = Math.max(i1, i2);
|
|
||||||
}
|
|
||||||
return path.substring(start + 1, end + 1);
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared pieces for the two callout tokenizers — `callout.marked.ts` (the
|
|
||||||
* `:::type` fenced form) and `github-callout.marked.ts` (the `> [!type]` GitHub
|
|
||||||
* alert form). Both emit the SAME callout node, so the banner type dictionary
|
|
||||||
* and the HTML renderer live here once instead of drifting apart in two files.
|
|
||||||
* The tokenizers themselves stay separate (different syntaxes / source matching).
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** The four callout banner types the editor schema supports. */
|
|
||||||
export const CALLOUT_TYPES = ['info', 'success', 'warning', 'danger'] as const;
|
|
||||||
|
|
||||||
export type CalloutType = (typeof CALLOUT_TYPES)[number];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Coerce an arbitrary type name onto a supported banner type, defaulting to
|
|
||||||
* `info` for anything unrecognized (the shared fallback both tokenizers use).
|
|
||||||
*/
|
|
||||||
export function normalizeCalloutType(type: string): CalloutType {
|
|
||||||
return (CALLOUT_TYPES as readonly string[]).includes(type)
|
|
||||||
? (type as CalloutType)
|
|
||||||
: 'info';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render a callout node to the editor's HTML shape. `body` is the already
|
|
||||||
* markdown-parsed inner content (marked may hand back a string synchronously).
|
|
||||||
*/
|
|
||||||
export function renderCalloutHtml(
|
|
||||||
type: string,
|
|
||||||
body: string | Promise<string>,
|
|
||||||
): string {
|
|
||||||
return `<div data-type="callout" data-callout-type="${type}">${body}</div>`;
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { Token, marked } from 'marked';
|
|
||||||
import { normalizeCalloutType, renderCalloutHtml } from './callout-common.marked';
|
|
||||||
|
|
||||||
interface CalloutToken {
|
|
||||||
type: 'callout';
|
|
||||||
calloutType: string;
|
|
||||||
text: string;
|
|
||||||
raw: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const calloutExtension = {
|
|
||||||
name: 'callout',
|
|
||||||
level: 'block',
|
|
||||||
start(src: string) {
|
|
||||||
return src.match(/:::/)?.index ?? -1;
|
|
||||||
},
|
|
||||||
tokenizer(src: string): CalloutToken | undefined {
|
|
||||||
const rule = /^:::([a-zA-Z0-9]+)\s+([\s\S]+?):::/;
|
|
||||||
const match = rule.exec(src);
|
|
||||||
|
|
||||||
if (match) {
|
|
||||||
return {
|
|
||||||
type: 'callout',
|
|
||||||
calloutType: normalizeCalloutType(match[1]),
|
|
||||||
raw: match[0],
|
|
||||||
text: match[2].trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
renderer(token: Token) {
|
|
||||||
const calloutToken = token as CalloutToken;
|
|
||||||
return renderCalloutHtml(
|
|
||||||
calloutToken.calloutType,
|
|
||||||
marked.parse(calloutToken.text),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { extractFootnoteDefinitions } from "./footnote.marked";
|
|
||||||
|
|
||||||
/** Pull the ordered list of `data-footnote-def` ids out of the rendered section. */
|
|
||||||
function defIds(section: string): string[] {
|
|
||||||
return [...section.matchAll(/data-footnote-def data-id="([^"]+)"/g)].map(
|
|
||||||
(m) => m[1],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pull the ordered list of `[^id]` markers that remain in the body. */
|
|
||||||
function bodyMarkers(body: string): string[] {
|
|
||||||
return [...body.matchAll(/\[\^([^\]\s]+)\]/g)].map((m) => m[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("extractFootnoteDefinitions: duplicate definition ids (first-wins)", () => {
|
|
||||||
// Body has ONE `[^d]` reference but THREE `[^d]:` definitions. Under the
|
|
||||||
// import model (#166) a duplicate definition id is FIRST-WINS: only the first
|
|
||||||
// definition is kept; the rest are DROPPED (and surfaced by analyzeFootnotes,
|
|
||||||
// not silently re-id'd into orphan footnotes as before). Reference markers are
|
|
||||||
// never rewritten, so repeated references would reuse the single footnote.
|
|
||||||
const md = ["See[^d].", "", "[^d]: a", "[^d]: b", "[^d]: c"].join("\n");
|
|
||||||
|
|
||||||
it("keeps only the FIRST definition for the id (first-wins)", () => {
|
|
||||||
const { section } = extractFootnoteDefinitions(md);
|
|
||||||
const ids = defIds(section);
|
|
||||||
expect(ids).toEqual(["d"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the first definition's text and drops the duplicates", () => {
|
|
||||||
const { section } = extractFootnoteDefinitions(md);
|
|
||||||
expect(section).toContain('data-footnote-def data-id="d"><p>a</p>');
|
|
||||||
// No derived `d__2` / `d__3` ids are emitted anymore.
|
|
||||||
expect(section).not.toContain("d__2");
|
|
||||||
expect(section).not.toContain("d__3");
|
|
||||||
// The dropped duplicate texts are not in the section.
|
|
||||||
expect(section).not.toContain("<p>b</p>");
|
|
||||||
expect(section).not.toContain("<p>c</p>");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves the SINGLE body marker as [^d] (markers are never rewritten)", () => {
|
|
||||||
const { body } = extractFootnoteDefinitions(md);
|
|
||||||
expect(bodyMarkers(body)).toEqual(["d"]);
|
|
||||||
expect(body).toContain("See[^d].");
|
|
||||||
// The definition lines themselves were pulled OUT of the body.
|
|
||||||
expect(body).not.toContain("[^d]: a");
|
|
||||||
expect(body).not.toContain("[^d]: b");
|
|
||||||
expect(body).not.toContain("[^d]: c");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not crash and produces a well-formed footnotes section", () => {
|
|
||||||
const { section } = extractFootnoteDefinitions(md);
|
|
||||||
expect(section.startsWith("<section data-footnotes>")).toBe(true);
|
|
||||||
expect(section.endsWith("</section>")).toBe(true);
|
|
||||||
// Exactly one definition div (first-wins).
|
|
||||||
expect([...section.matchAll(/<div data-footnote-def/g)]).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("extractFootnoteDefinitions: reuse (repeated references, one definition)", () => {
|
|
||||||
// Pandoc semantics: many `[^a]` references + one `[^a]:` definition = one
|
|
||||||
// footnote, shared. Markers are left intact so the editor numbers them as one.
|
|
||||||
const md = ["A[^a] B[^a] C[^a].", "", "[^a]: shared note"].join("\n");
|
|
||||||
|
|
||||||
it("emits exactly one definition and leaves every reference marker as [^a]", () => {
|
|
||||||
const { section, body } = extractFootnoteDefinitions(md);
|
|
||||||
expect(defIds(section)).toEqual(["a"]);
|
|
||||||
expect(section).toContain('data-footnote-def data-id="a"><p>shared note</p>');
|
|
||||||
// All three reference markers stay `a` (no `a__2`/`a__3` minting).
|
|
||||||
expect(bodyMarkers(body)).toEqual(["a", "a", "a"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import { marked } from "marked";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pandoc/GFM footnote support for the marked (Markdown -> HTML) pipeline.
|
|
||||||
*
|
|
||||||
* Two pieces:
|
|
||||||
* - an INLINE tokenizer for `[^id]` references -> <sup data-footnote-ref
|
|
||||||
* data-id="id"> (matches the editor-ext FootnoteReference renderHTML);
|
|
||||||
* - a document hook (`preprocess`/`walkTokens` is awkward for collecting +
|
|
||||||
* removing definitions, so we use a regex preprocessing step instead) that
|
|
||||||
* pulls every `[^id]: text` definition line out of the body and appends a
|
|
||||||
* single <section data-footnotes> with one <div data-footnote-def> per
|
|
||||||
* definition, so the round-trip rebuilds footnotesList + footnoteDefinition.
|
|
||||||
*
|
|
||||||
* Every FIRST definition line is emitted — duplicate ids are first-wins (the
|
|
||||||
* rest are dropped, and surfaced via analyzeFootnotes), and reference markers are
|
|
||||||
* left untouched so repeated `[^a]` references reuse the one footnote (#166).
|
|
||||||
* Orphan definitions (no matching reference) are still emitted here; the editor's
|
|
||||||
* sync plugin reconciles the final reference/definition set (drops orphans,
|
|
||||||
* synthesizes a single empty definition for a reference that lacks one).
|
|
||||||
*/
|
|
||||||
|
|
||||||
const DEFINITION_RE = /^\[\^([^\]\s]+)\]:[ \t]*(.*)$/;
|
|
||||||
const REFERENCE_RE = /\[\^([^\]\s]+)\]/;
|
|
||||||
|
|
||||||
interface FootnoteRefToken {
|
|
||||||
type: "footnoteRef";
|
|
||||||
raw: string;
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const footnoteReferenceExtension = {
|
|
||||||
name: "footnoteRef",
|
|
||||||
level: "inline" as const,
|
|
||||||
start(src: string) {
|
|
||||||
return src.match(/\[\^/)?.index ?? -1;
|
|
||||||
},
|
|
||||||
tokenizer(src: string): FootnoteRefToken | undefined {
|
|
||||||
const match = REFERENCE_RE.exec(src);
|
|
||||||
// Only match at the very start of the remaining inline source.
|
|
||||||
if (match && match.index === 0) {
|
|
||||||
return {
|
|
||||||
type: "footnoteRef",
|
|
||||||
raw: match[0],
|
|
||||||
id: match[1],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
renderer(token: FootnoteRefToken) {
|
|
||||||
return `<sup data-footnote-ref data-id="${escapeAttr(token.id)}"></sup>`;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function escapeAttr(value: string): string {
|
|
||||||
return String(value).replace(/&/g, "&").replace(/"/g, """);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract `[^id]: text` definition lines from the markdown body, returning the
|
|
||||||
* cleaned body plus a rendered <section data-footnotes> (empty string when no
|
|
||||||
* definitions). Call this BEFORE marked.parse and append the section to the
|
|
||||||
* resulting HTML.
|
|
||||||
*/
|
|
||||||
export function extractFootnoteDefinitions(markdown: string): {
|
|
||||||
body: string;
|
|
||||||
section: string;
|
|
||||||
} {
|
|
||||||
const lines = markdown.split("\n");
|
|
||||||
const bodyLines: string[] = [];
|
|
||||||
const definitions: Array<{ id: string; text: string }> = [];
|
|
||||||
|
|
||||||
// Track fenced-code state so a `[^id]: ...` line that merely SHOWS footnote
|
|
||||||
// syntax inside a ``` / ~~~ code block is left in the body verbatim and not
|
|
||||||
// mistaken for a real definition.
|
|
||||||
let fence: string | null = null;
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const fenceMatch = /^(\s*)(`{3,}|~{3,})/.exec(line);
|
|
||||||
if (fenceMatch) {
|
|
||||||
const marker = fenceMatch[2][0];
|
|
||||||
if (fence === null) {
|
|
||||||
fence = marker; // opening fence
|
|
||||||
} else if (marker === fence) {
|
|
||||||
fence = null; // closing fence (matching delimiter type)
|
|
||||||
}
|
|
||||||
bodyLines.push(line);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const m = fence === null ? DEFINITION_RE.exec(line) : null;
|
|
||||||
if (m) {
|
|
||||||
definitions.push({ id: m[1], text: m[2] });
|
|
||||||
} else {
|
|
||||||
bodyLines.push(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (definitions.length === 0) {
|
|
||||||
return { body: markdown, section: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Duplicate definition ids (e.g. `[^d]: first` / `[^d]: second`): FIRST WINS,
|
|
||||||
// the rest are DROPPED. Reference markers are left UNTOUCHED so repeated `[^a]`
|
|
||||||
// references reuse the single footnote (Pandoc semantics, #166). This differs
|
|
||||||
// from the live editor's never-lose policy (resolveCollisions re-ids a
|
|
||||||
// duplicate definition into an orphan) on purpose: an import is an
|
|
||||||
// agent-authored artifact we sanitize, and the dropped duplicate is surfaced
|
|
||||||
// to the caller via analyzeFootnotes' `duplicateDefinitions` warning instead.
|
|
||||||
const firstById = new Map<string, string>(); // id -> first definition text
|
|
||||||
for (const def of definitions) {
|
|
||||||
if (!firstById.has(def.id)) firstById.set(def.id, def.text);
|
|
||||||
}
|
|
||||||
|
|
||||||
const defsHtml = [...firstById.entries()]
|
|
||||||
.map(([id, text]) => {
|
|
||||||
// Render the definition text as inline markdown so emphasis/links inside
|
|
||||||
// a footnote survive the round-trip; wrap in a paragraph (the node's
|
|
||||||
// content is paragraph+).
|
|
||||||
const inner = marked.parseInline(text || "");
|
|
||||||
return `<div data-footnote-def data-id="${escapeAttr(
|
|
||||||
id,
|
|
||||||
)}"><p>${inner}</p></div>`;
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
return {
|
|
||||||
body: bodyLines.join("\n"),
|
|
||||||
section: `<section data-footnotes>${defsHtml}</section>`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { markdownToHtml } from "./marked.utils";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Regression for issue #192: pasting a GitHub-style `> [!type]` alert produced a
|
|
||||||
* literal `<blockquote>` containing `[!info]` instead of a callout node, because
|
|
||||||
* only the `:::type` form was tokenized. The editor paste path runs the same
|
|
||||||
* `markdownToHtml`, so these assertions pin the conversion at the source.
|
|
||||||
*/
|
|
||||||
function html(md: string): string {
|
|
||||||
const out = markdownToHtml(md);
|
|
||||||
if (typeof out !== "string") throw new Error("expected sync string output");
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("markdownToHtml: GitHub `> [!type]` callouts", () => {
|
|
||||||
it("converts `> [!info]` to a callout node, not a literal blockquote", () => {
|
|
||||||
const out = html("> [!info]\n> Callout body text here");
|
|
||||||
expect(out).toContain('data-type="callout"');
|
|
||||||
expect(out).toContain('data-callout-type="info"');
|
|
||||||
expect(out).toContain("Callout body text here");
|
|
||||||
expect(out).not.toContain("[!info]");
|
|
||||||
expect(out).not.toContain("<blockquote");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps GitHub alert aliases onto the supported banner types", () => {
|
|
||||||
expect(html("> [!NOTE]\n> x")).toContain('data-callout-type="info"');
|
|
||||||
expect(html("> [!TIP]\n> x")).toContain('data-callout-type="success"');
|
|
||||||
expect(html("> [!WARNING]\n> x")).toContain('data-callout-type="warning"');
|
|
||||||
expect(html("> [!CAUTION]\n> x")).toContain('data-callout-type="danger"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("accepts the editor's own type names directly", () => {
|
|
||||||
expect(html("> [!success]\n> x")).toContain('data-callout-type="success"');
|
|
||||||
expect(html("> [!danger]\n> x")).toContain('data-callout-type="danger"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to info for an unknown type", () => {
|
|
||||||
expect(html("> [!bogus]\n> x")).toContain('data-callout-type="info"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves multi-line callout bodies", () => {
|
|
||||||
const out = html("> [!warning]\n> line one\n> line two");
|
|
||||||
expect(out).toContain('data-callout-type="warning"');
|
|
||||||
expect(out).toContain("line one");
|
|
||||||
expect(out).toContain("line two");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("still converts the `:::type` form", () => {
|
|
||||||
const out = html(":::info\nbody\n:::");
|
|
||||||
expect(out).toContain('data-type="callout"');
|
|
||||||
expect(out).toContain('data-callout-type="info"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { Token, marked } from 'marked';
|
|
||||||
import { renderCalloutHtml } from './callout-common.marked';
|
|
||||||
|
|
||||||
interface GithubCalloutToken {
|
|
||||||
type: 'githubCallout';
|
|
||||||
calloutType: string;
|
|
||||||
text: string;
|
|
||||||
raw: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map GitHub "alert" blockquote markers (`> [!NOTE]`, `> [!WARNING]`, …) onto
|
|
||||||
* the four callout banner types the editor schema supports. The editor's own
|
|
||||||
* type names (`info`/`success`/`warning`/`danger`) are also accepted directly,
|
|
||||||
* because users paste both forms. Anything unrecognized falls back to `info`,
|
|
||||||
* matching the `:::type` callout tokenizer.
|
|
||||||
*/
|
|
||||||
const GITHUB_ALERT_TYPE_MAP: Record<string, string> = {
|
|
||||||
note: 'info',
|
|
||||||
tip: 'success',
|
|
||||||
important: 'info',
|
|
||||||
warning: 'warning',
|
|
||||||
caution: 'danger',
|
|
||||||
info: 'info',
|
|
||||||
success: 'success',
|
|
||||||
danger: 'danger',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tokenizer for GitHub-flavored alert callouts written as a blockquote whose
|
|
||||||
* first line is `[!type]`:
|
|
||||||
*
|
|
||||||
* > [!info]
|
|
||||||
* > body line one
|
|
||||||
* > body line two
|
|
||||||
*
|
|
||||||
* Without this, the default blockquote tokenizer wins and the marker renders as
|
|
||||||
* a literal `[!info]` inside a `<blockquote>`. The editor's paste path runs the
|
|
||||||
* same `markdownToHtml`, so registering this here also fixes pasting the syntax
|
|
||||||
* into the editor (issue #192), not just markdown import.
|
|
||||||
*/
|
|
||||||
export const githubCalloutExtension = {
|
|
||||||
name: 'githubCallout',
|
|
||||||
level: 'block' as const,
|
|
||||||
start(src: string) {
|
|
||||||
return src.match(/^ {0,3}>[ \t]*\[!/m)?.index ?? -1;
|
|
||||||
},
|
|
||||||
tokenizer(src: string): GithubCalloutToken | undefined {
|
|
||||||
const rule =
|
|
||||||
/^ {0,3}>[ \t]*\[!([a-zA-Z]+)\][^\n]*(?:\n {0,3}>[^\n]*)*(?:\n|$)/;
|
|
||||||
const match = rule.exec(src);
|
|
||||||
if (!match) return undefined;
|
|
||||||
|
|
||||||
const rawType = match[1].toLowerCase();
|
|
||||||
const calloutType = GITHUB_ALERT_TYPE_MAP[rawType] ?? 'info';
|
|
||||||
|
|
||||||
const text = match[0]
|
|
||||||
.replace(/\n+$/, '')
|
|
||||||
.split('\n')
|
|
||||||
// Strip the blockquote marker (`>` + optional space) from every line.
|
|
||||||
.map((line) => line.replace(/^ {0,3}>[ \t]?/, ''))
|
|
||||||
// Drop the `[!type]` marker that opens the first line.
|
|
||||||
.map((line, i) => (i === 0 ? line.replace(/^\[![a-zA-Z]+\][ \t]*/, '') : line))
|
|
||||||
.join('\n')
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
return {
|
|
||||||
type: 'githubCallout',
|
|
||||||
calloutType,
|
|
||||||
raw: match[0],
|
|
||||||
text,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
renderer(token: Token) {
|
|
||||||
const calloutToken = token as GithubCalloutToken;
|
|
||||||
return renderCalloutHtml(
|
|
||||||
calloutToken.calloutType,
|
|
||||||
marked.parse(calloutToken.text),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { Token } from "marked";
|
|
||||||
|
|
||||||
interface HtmlEmbedToken {
|
|
||||||
type: "htmlEmbed";
|
|
||||||
raw: string;
|
|
||||||
encoded: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Marked extension that rebuilds an `htmlEmbed` node from the HTML comment
|
|
||||||
* marker produced by the turndown rule (`<!--html-embed:<base64>-->`).
|
|
||||||
*
|
|
||||||
* It emits the same marker div the node's `parseHTML` recognizes, so the
|
|
||||||
* pipeline MD -> HTML -> ProseMirror JSON restores the node (and its
|
|
||||||
* base64 `data-source`) exactly. We do NOT expand the raw markup here; the
|
|
||||||
* source stays base64-encoded in the attribute and is only executed by the
|
|
||||||
* client NodeView.
|
|
||||||
*/
|
|
||||||
export const htmlEmbedExtension = {
|
|
||||||
name: "htmlEmbed",
|
|
||||||
level: "block" as const,
|
|
||||||
start(src: string) {
|
|
||||||
return src.indexOf("<!--html-embed:");
|
|
||||||
},
|
|
||||||
tokenizer(src: string): HtmlEmbedToken | undefined {
|
|
||||||
const rule = /^<!--html-embed:([A-Za-z0-9+/=]*)-->/;
|
|
||||||
const match = rule.exec(src);
|
|
||||||
|
|
||||||
if (match) {
|
|
||||||
return {
|
|
||||||
type: "htmlEmbed",
|
|
||||||
raw: match[0],
|
|
||||||
encoded: match[1] ?? "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
renderer(token: Token) {
|
|
||||||
const htmlEmbedToken = token as HtmlEmbedToken;
|
|
||||||
return `<div data-type="htmlEmbed" data-source="${htmlEmbedToken.encoded}"></div>`;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { marked } from "marked";
|
|
||||||
import { calloutExtension } from "./callout.marked";
|
|
||||||
import { githubCalloutExtension } from "./github-callout.marked";
|
|
||||||
import { mathBlockExtension } from "./math-block.marked";
|
|
||||||
import { mathInlineExtension } from "./math-inline.marked";
|
|
||||||
import {
|
|
||||||
footnoteReferenceExtension,
|
|
||||||
extractFootnoteDefinitions,
|
|
||||||
} from "./footnote.marked";
|
|
||||||
import { htmlEmbedExtension } from "./html-embed.marked";
|
|
||||||
|
|
||||||
marked.use({
|
|
||||||
renderer: {
|
|
||||||
list({ ordered, start, items }) {
|
|
||||||
let body = "";
|
|
||||||
for (const item of items) {
|
|
||||||
body += this.listitem(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ordered) {
|
|
||||||
const startAttr = start !== 1 ? ` start="${start}"` : "";
|
|
||||||
return `<ol${startAttr}>\n${body}</ol>\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isTaskList = items.some((item) => item.task);
|
|
||||||
const dataType = isTaskList ? ' data-type="taskList"' : "";
|
|
||||||
return `<ul${dataType}>\n${body}</ul>\n`;
|
|
||||||
},
|
|
||||||
listitem({ tokens, task: isTask, checked: isChecked }) {
|
|
||||||
const text = this.parser.parse(tokens);
|
|
||||||
if (!isTask) {
|
|
||||||
return `<li>${text}</li>\n`;
|
|
||||||
}
|
|
||||||
const checkedAttr = isChecked
|
|
||||||
? 'data-checked="true"'
|
|
||||||
: 'data-checked="false"';
|
|
||||||
return `<li data-type="taskItem" ${checkedAttr}>${text}</li>\n`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
marked.use({
|
|
||||||
extensions: [
|
|
||||||
calloutExtension,
|
|
||||||
githubCalloutExtension,
|
|
||||||
mathBlockExtension,
|
|
||||||
mathInlineExtension,
|
|
||||||
footnoteReferenceExtension,
|
|
||||||
htmlEmbedExtension,
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
marked.setOptions({ breaks: true });
|
|
||||||
|
|
||||||
export function markdownToHtml(
|
|
||||||
markdownInput: string,
|
|
||||||
): string | Promise<string> {
|
|
||||||
const YAML_FONT_MATTER_REGEX = /^\s*---[\s\S]*?---\s*/;
|
|
||||||
|
|
||||||
const markdown = markdownInput
|
|
||||||
.replace(YAML_FONT_MATTER_REGEX, "")
|
|
||||||
.trimStart();
|
|
||||||
|
|
||||||
// Pull `[^id]: ...` definition lines out of the body, render the body, then
|
|
||||||
// append a single <section data-footnotes> so the round-trip rebuilds the
|
|
||||||
// footnotesList + footnoteDefinition nodes.
|
|
||||||
const { body, section } = extractFootnoteDefinitions(markdown);
|
|
||||||
|
|
||||||
const parsed = marked.parse(body);
|
|
||||||
if (!section) return parsed;
|
|
||||||
|
|
||||||
if (typeof parsed === "string") {
|
|
||||||
return parsed + section;
|
|
||||||
}
|
|
||||||
return parsed.then((html) => html + section);
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { Token, marked } from 'marked';
|
|
||||||
|
|
||||||
interface MathBlockToken {
|
|
||||||
type: 'mathBlock';
|
|
||||||
text: string;
|
|
||||||
raw: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const mathBlockExtension = {
|
|
||||||
name: 'mathBlock',
|
|
||||||
level: 'block',
|
|
||||||
start(src: string) {
|
|
||||||
return src.match(/\$\$/)?.index ?? -1;
|
|
||||||
},
|
|
||||||
tokenizer(src: string): MathBlockToken | undefined {
|
|
||||||
const rule = /^\$\$(?!(\$))([\s\S]+?)\$\$/;
|
|
||||||
const match = rule.exec(src);
|
|
||||||
|
|
||||||
if (match) {
|
|
||||||
return {
|
|
||||||
type: 'mathBlock',
|
|
||||||
raw: match[0],
|
|
||||||
text: match[2]?.trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
renderer(token: Token) {
|
|
||||||
const mathBlockToken = token as MathBlockToken;
|
|
||||||
// parse to prevent escaping slashes
|
|
||||||
const latex = marked
|
|
||||||
.parse(mathBlockToken.text)
|
|
||||||
.toString()
|
|
||||||
.replace(/<(\/)?p>/g, '');
|
|
||||||
|
|
||||||
return `<div data-type="${mathBlockToken.type}" data-katex="true">${latex}</div>`;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { markdownToHtml } from "./marked.utils";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Data-integrity regression (issue #204, Phase 2): plain prose that mentions
|
|
||||||
* prices like `$5 and $6` must NOT be misread as inline math. The inline-math
|
|
||||||
* tokenizer mutates a global `marked` singleton at import time
|
|
||||||
* (`marked.utils.ts`), so math behaviour can only be exercised safely through
|
|
||||||
* the public `markdownToHtml`; importing the tokenizer in isolation would give
|
|
||||||
* a different, non-representative result. These assertions therefore drive the
|
|
||||||
* real conversion path.
|
|
||||||
*/
|
|
||||||
function html(md: string): string {
|
|
||||||
const out = markdownToHtml(md);
|
|
||||||
if (typeof out !== "string") throw new Error("expected sync string output");
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MATH_MARKERS = ['data-type="mathInline"', 'data-katex="true"'];
|
|
||||||
|
|
||||||
function hasInlineMath(out: string): boolean {
|
|
||||||
return MATH_MARKERS.some((m) => out.includes(m));
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("markdownToHtml: inline-math false positives", () => {
|
|
||||||
it("does not treat prices `$5 and $6` as inline math", () => {
|
|
||||||
const out = html("It costs $5 and $6 today.");
|
|
||||||
expect(hasInlineMath(out)).toBe(false);
|
|
||||||
// The text survives verbatim (no katex span swallowing it).
|
|
||||||
expect(out).toContain("$5 and $6");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not treat a single trailing price `$5` as inline math", () => {
|
|
||||||
const out = html("Lunch was $5.");
|
|
||||||
expect(hasInlineMath(out)).toBe(false);
|
|
||||||
expect(out).toContain("$5");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not treat `$5, $6, $7` (multiple prices) as inline math", () => {
|
|
||||||
const out = html("Choose $5, $6, $7 plans.");
|
|
||||||
expect(hasInlineMath(out)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("STILL converts a genuine inline-math expression `$x + y$`", () => {
|
|
||||||
// Guard the positive path so the false-positive guard above can't be
|
|
||||||
// satisfied by simply disabling math entirely.
|
|
||||||
const out = html("The sum $x + y$ is shown.");
|
|
||||||
expect(hasInlineMath(out)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { Token, marked } from 'marked';
|
|
||||||
|
|
||||||
interface MathInlineToken {
|
|
||||||
type: 'mathInline';
|
|
||||||
text: string;
|
|
||||||
raw: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const inlineMathRegex = /^\$(?!\s)(.+?)(?<!\s)\$(?!\d)/;
|
|
||||||
|
|
||||||
export const mathInlineExtension = {
|
|
||||||
name: 'mathInline',
|
|
||||||
level: 'inline',
|
|
||||||
start(src: string) {
|
|
||||||
let index: number;
|
|
||||||
let indexSrc = src;
|
|
||||||
|
|
||||||
while (indexSrc) {
|
|
||||||
index = indexSrc.indexOf('$');
|
|
||||||
if (index === -1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const f = index === 0 || indexSrc.charAt(index - 1) === ' ';
|
|
||||||
if (f) {
|
|
||||||
const possibleKatex = indexSrc.substring(index);
|
|
||||||
if (possibleKatex.match(inlineMathRegex)) {
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
indexSrc = indexSrc.substring(index + 1).replace(/^\$+/, '');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
tokenizer(src: string): MathInlineToken | undefined {
|
|
||||||
const match = inlineMathRegex.exec(src);
|
|
||||||
|
|
||||||
if (match) {
|
|
||||||
return {
|
|
||||||
type: 'mathInline',
|
|
||||||
raw: match[0],
|
|
||||||
text: match[1]?.trim(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
renderer(token: Token) {
|
|
||||||
const mathInlineToken = token as MathInlineToken;
|
|
||||||
// parse to prevent escaping slashes
|
|
||||||
const latex = marked
|
|
||||||
.parse(mathInlineToken.text)
|
|
||||||
.toString()
|
|
||||||
.replace(/<(\/)?p>/g, '');
|
|
||||||
|
|
||||||
return `<span data-type="${mathInlineToken.type}" data-katex="true">${latex}</span>`;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { getSchema } from "@tiptap/core";
|
|
||||||
import { generateHTML, generateJSON } from "@tiptap/html";
|
|
||||||
import { Document } from "@tiptap/extension-document";
|
|
||||||
import { Paragraph } from "@tiptap/extension-paragraph";
|
|
||||||
import { Text } from "@tiptap/extension-text";
|
|
||||||
import { Bold } from "@tiptap/extension-bold";
|
|
||||||
import { htmlToMarkdown } from "./turndown.utils";
|
|
||||||
import { markdownToHtml } from "./marked.utils";
|
|
||||||
import { Spoiler } from "../../spoiler/spoiler";
|
|
||||||
|
|
||||||
// The spoiler mark has no native Markdown syntax, so it is preserved losslessly
|
|
||||||
// as raw inline HTML (`<span data-spoiler="true">…</span>`), the same approach
|
|
||||||
// htmlEmbed uses. This test drives the full editor round-trip:
|
|
||||||
// JSON -> HTML -> Markdown -> HTML -> JSON
|
|
||||||
// and asserts the `spoiler` mark survives end to end. We use the same
|
|
||||||
// getSchema + @tiptap/html generateHTML/generateJSON utilities the other
|
|
||||||
// editor-ext schema tests use.
|
|
||||||
|
|
||||||
const extensions = [Document, Paragraph, Text, Bold, Spoiler];
|
|
||||||
|
|
||||||
function html(md: string): string {
|
|
||||||
const out = markdownToHtml(md);
|
|
||||||
if (typeof out !== "string") throw new Error("expected sync string output");
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count text nodes carrying a `spoiler` mark anywhere in a ProseMirror JSON doc.
|
|
||||||
function countSpoilerMarks(doc: any): number {
|
|
||||||
let count = 0;
|
|
||||||
const walk = (node: any) => {
|
|
||||||
if (!node || typeof node !== "object") return;
|
|
||||||
if (Array.isArray(node.marks)) {
|
|
||||||
for (const mark of node.marks) {
|
|
||||||
if (mark?.type === "spoiler") count++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Array.isArray(node.content)) node.content.forEach(walk);
|
|
||||||
};
|
|
||||||
walk(doc);
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Spoiler mark schema", () => {
|
|
||||||
it("registers the spoiler mark in the schema", () => {
|
|
||||||
const schema = getSchema(extensions);
|
|
||||||
expect(schema.marks.spoiler).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("recovers the spoiler mark from span[data-spoiler] (HTML -> JSON)", () => {
|
|
||||||
const json = generateJSON(
|
|
||||||
'<p>before <span data-spoiler="true">hidden</span> after</p>',
|
|
||||||
extensions,
|
|
||||||
);
|
|
||||||
expect(countSpoilerMarks(json)).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("emits data-spoiler + class on render (JSON -> HTML)", () => {
|
|
||||||
const doc = {
|
|
||||||
type: "doc",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "paragraph",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "hidden",
|
|
||||||
marks: [{ type: "spoiler" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const out = generateHTML(doc, extensions);
|
|
||||||
expect(out).toContain('data-spoiler="true"');
|
|
||||||
expect(out).toContain('class="spoiler"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Spoiler Markdown round-trip is lossless", () => {
|
|
||||||
const docWith = (textNode: any) => ({
|
|
||||||
type: "doc",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "paragraph",
|
|
||||||
content: [{ type: "text", text: "before " }, textNode, { type: "text", text: " after" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves the spoiler mark through JSON -> MD -> HTML -> JSON", () => {
|
|
||||||
const startDoc = docWith({
|
|
||||||
type: "text",
|
|
||||||
text: "hidden",
|
|
||||||
marks: [{ type: "spoiler" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
// JSON -> HTML
|
|
||||||
const html1 = generateHTML(startDoc, extensions);
|
|
||||||
expect(html1).toContain('data-spoiler="true"');
|
|
||||||
|
|
||||||
// HTML -> Markdown (raw inline HTML, lossless)
|
|
||||||
const md = htmlToMarkdown(html1);
|
|
||||||
expect(md).toContain('<span data-spoiler="true">hidden</span>');
|
|
||||||
|
|
||||||
// MD -> HTML -> JSON (mark restored via parseHTML)
|
|
||||||
const endJson = generateJSON(html(md), extensions);
|
|
||||||
expect(countSpoilerMarks(endJson)).toBe(1);
|
|
||||||
// The visible text survives.
|
|
||||||
expect(JSON.stringify(endJson)).toContain("hidden");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the spoiler intact when it intersects a bold mark", () => {
|
|
||||||
const startDoc = docWith({
|
|
||||||
type: "text",
|
|
||||||
text: "secret",
|
|
||||||
marks: [{ type: "bold" }, { type: "spoiler" }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const md = htmlToMarkdown(generateHTML(startDoc, extensions));
|
|
||||||
expect(md).toContain("data-spoiler=\"true\"");
|
|
||||||
|
|
||||||
const endJson = generateJSON(html(md), extensions);
|
|
||||||
expect(countSpoilerMarks(endJson)).toBe(1);
|
|
||||||
// Bold survives alongside the spoiler.
|
|
||||||
expect(JSON.stringify(endJson)).toContain('"bold"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
// Map @joplin/turndown types to @types/turndown
|
|
||||||
declare module "@joplin/turndown" {
|
|
||||||
import TurndownService from "turndown";
|
|
||||||
export = TurndownService;
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module "@joplin/turndown-plugin-gfm" {
|
|
||||||
import TurndownService from "turndown";
|
|
||||||
export const tables: TurndownService.Plugin;
|
|
||||||
export const strikethrough: TurndownService.Plugin;
|
|
||||||
export const highlightedCodeBlock: TurndownService.Plugin;
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { htmlToMarkdown } from "./turndown.utils";
|
|
||||||
import { markdownToHtml } from "./marked.utils";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* #206 mdrt-2 — Markdown export must never SILENTLY drop a block. (FIXED)
|
|
||||||
*
|
|
||||||
* `htmlToMarkdown` (turndown) historically only registered rules for a fixed
|
|
||||||
* set of custom nodes (callout, taskItem, details, math, iframe, htmlEmbed,
|
|
||||||
* image, video, footnote). Any other custom node — `transclusionReference`,
|
|
||||||
* `pageBreak`, `mention`, `status` — fell through to turndown's default
|
|
||||||
* handling: an empty wrapper is "blank" and removed, so the block disappeared
|
|
||||||
* from the exported Markdown with no trace, and `mention`/`status` collapsed to
|
|
||||||
* bare text, losing their identity (data-id / data-color). The invariant
|
|
||||||
* "never silently lose a block" was broken.
|
|
||||||
*
|
|
||||||
* The fix adds lossless turndown rules that re-emit each of these nodes as raw
|
|
||||||
* HTML carrying every `data-*` attribute. Plain-Markdown viewers ignore the
|
|
||||||
* inert tag; the import path round-trips it (`markdownToHtml` passes the raw
|
|
||||||
* HTML through and each node's `parseHTML` rebuilds the ProseMirror node). These
|
|
||||||
* tests assert the surviving contract (the block is preserved AND its identity
|
|
||||||
* round-trips back through import).
|
|
||||||
*/
|
|
||||||
describe("htmlToMarkdown — custom nodes are preserved losslessly (#206 mdrt-2)", () => {
|
|
||||||
const wrap = (inner: string) => `<p>before</p>${inner}<p>after</p>`;
|
|
||||||
|
|
||||||
it("preserves a pageBreak block on Markdown export", () => {
|
|
||||||
const md = htmlToMarkdown(
|
|
||||||
wrap('<div data-type="pageBreak" class="page-break"></div>'),
|
|
||||||
);
|
|
||||||
expect(md).toContain("before");
|
|
||||||
expect(md).toContain("after");
|
|
||||||
// The break survives as an inert raw-HTML tag, not silently dropped.
|
|
||||||
expect(md).toMatch(/data-type="pageBreak"/);
|
|
||||||
expect(md).toMatch(/page-?break/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves a transclusionReference's identity on Markdown export", () => {
|
|
||||||
const md = htmlToMarkdown(
|
|
||||||
wrap('<div data-type="transclusionReference" data-id="abc"></div>'),
|
|
||||||
);
|
|
||||||
expect(md).toContain("before");
|
|
||||||
expect(md).toContain("after");
|
|
||||||
// The data-id (the only thing that gives the reference identity) survives.
|
|
||||||
expect(md).toContain("abc");
|
|
||||||
expect(md).toMatch(/data-type="transclusionReference"/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves a mention's data-id (stable identity) on Markdown export", () => {
|
|
||||||
const md = htmlToMarkdown(
|
|
||||||
'<p>hi <span data-type="mention" data-id="u1" data-label="Bob">@Bob</span> there</p>',
|
|
||||||
);
|
|
||||||
// The mention keeps its stable identity (data-id), not just the text.
|
|
||||||
expect(md).toContain("u1");
|
|
||||||
expect(md).toContain("Bob");
|
|
||||||
expect(md).toMatch(/data-type="mention"/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves a status chip's color on Markdown export", () => {
|
|
||||||
const md = htmlToMarkdown(
|
|
||||||
'<p>s <span data-type="status" data-color="green">Done</span></p>',
|
|
||||||
);
|
|
||||||
// The chip's color (its identity) survives, not just the visible text.
|
|
||||||
expect(md).toContain("green");
|
|
||||||
expect(md).toContain("Done");
|
|
||||||
expect(md).toMatch(/data-type="status"/);
|
|
||||||
});
|
|
||||||
|
|
||||||
// The export form is only lossless if the import path can rebuild it. These
|
|
||||||
// assert the full MD -> HTML round-trip restores the node + its attributes,
|
|
||||||
// which is the marker <-> node contract each `parseHTML` relies on.
|
|
||||||
describe("import round-trip (markdownToHtml restores the node)", () => {
|
|
||||||
it("round-trips a pageBreak through export + import", async () => {
|
|
||||||
const md = htmlToMarkdown(
|
|
||||||
wrap('<div data-type="pageBreak" class="page-break"></div>'),
|
|
||||||
);
|
|
||||||
const html = await markdownToHtml(md);
|
|
||||||
expect(html).toMatch(/<div[^>]*data-type="pageBreak"[^>]*>/);
|
|
||||||
expect(html).toContain("before");
|
|
||||||
expect(html).toContain("after");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("round-trips a transclusionReference (keeps data-id)", async () => {
|
|
||||||
const md = htmlToMarkdown(
|
|
||||||
wrap('<div data-type="transclusionReference" data-id="abc"></div>'),
|
|
||||||
);
|
|
||||||
const html = await markdownToHtml(md);
|
|
||||||
expect(html).toMatch(/<div[^>]*data-type="transclusionReference"[^>]*>/);
|
|
||||||
expect(html).toContain("abc");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("round-trips a mention (keeps data-id + data-label)", async () => {
|
|
||||||
const md = htmlToMarkdown(
|
|
||||||
'<p>hi <span data-type="mention" data-id="u1" data-label="Bob">@Bob</span> there</p>',
|
|
||||||
);
|
|
||||||
const html = await markdownToHtml(md);
|
|
||||||
expect(html).toMatch(/<span[^>]*data-type="mention"[^>]*>/);
|
|
||||||
expect(html).toContain("u1");
|
|
||||||
expect(html).toContain("Bob");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("round-trips a status chip (keeps data-color)", async () => {
|
|
||||||
const md = htmlToMarkdown(
|
|
||||||
'<p>s <span data-type="status" data-color="green">Done</span></p>',
|
|
||||||
);
|
|
||||||
const html = await markdownToHtml(md);
|
|
||||||
expect(html).toMatch(/<span[^>]*data-type="status"[^>]*>/);
|
|
||||||
expect(html).toContain("green");
|
|
||||||
});
|
|
||||||
|
|
||||||
// HTML special chars in an attribute value or in a node's text must be
|
|
||||||
// ESCAPED when re-emitted as raw HTML, otherwise the exported tag is
|
|
||||||
// malformed and `markdownToHtml`'s parser cannot restore the original value
|
|
||||||
// (the same silent data loss this PR fixes). Dropping `<`/`>` escaping is the
|
|
||||||
// dangerous regression: a stray `<` or `>` corrupts the tag (or injects new
|
|
||||||
// markup), so the test data carries ALL of `&`, `"`, `<`, `>` in BOTH the
|
|
||||||
// data-label attribute and the visible text. That fully exercises
|
|
||||||
// escapeHtmlAttr's `&,",<,>` branches and escapeHtmlText's `&,<,>` branches
|
|
||||||
// (escapeHtmlText leaves `"` literal); the alphanumeric-only cases above hit
|
|
||||||
// none of them.
|
|
||||||
it("escapes HTML special chars (& \" < >) in attrs + text and round-trips them", async () => {
|
|
||||||
const md = htmlToMarkdown(
|
|
||||||
`<p>hi <span data-type="mention" data-id="u1" data-label="A & <B> "C"">@A & <B> "C"</span> there</p>`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// (a) The exported Markdown carries a WELL-FORMED, correctly-escaped tag:
|
|
||||||
// the attribute escapes `&`, `<`, `>` AND `"`; the text escapes `&`, `<`,
|
|
||||||
// `>` (a `"` inside text content is legal, so it stays literal).
|
|
||||||
expect(md).toContain('data-label="A & <B> "C""');
|
|
||||||
expect(md).toContain('>@A & <B> "C"</span>');
|
|
||||||
// And explicitly NOT the raw, tag-corrupting forms: a literal `<B>` (would
|
|
||||||
// mean `<`/`>` escaping was dropped in either the attr or the text)...
|
|
||||||
expect(md).not.toContain("<B>");
|
|
||||||
// ...nor the malformed attribute that an unescaped `"` would produce.
|
|
||||||
expect(md).not.toContain('data-label="A & <B> "C""');
|
|
||||||
|
|
||||||
// (b) Import restores the ORIGINAL (unescaped) values, attribute and text.
|
|
||||||
const html = await markdownToHtml(md);
|
|
||||||
const dom = new DOMParser().parseFromString(html as string, "text/html");
|
|
||||||
const span = dom.querySelector('span[data-type="mention"]');
|
|
||||||
expect(span).not.toBeNull();
|
|
||||||
expect(span!.getAttribute("data-id")).toBe("u1");
|
|
||||||
expect(span!.getAttribute("data-label")).toBe('A & <B> "C"');
|
|
||||||
expect(span!.textContent).toBe('@A & <B> "C"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,488 +0,0 @@
|
|||||||
import * as _TurndownService from '@joplin/turndown';
|
|
||||||
import * as TurndownPluginGfm from '@joplin/turndown-plugin-gfm';
|
|
||||||
import { getBasename } from './basename';
|
|
||||||
|
|
||||||
// CJS/ESM interop: .default exists in Vite, not in NestJS
|
|
||||||
const TurndownService = (_TurndownService as any).default || _TurndownService;
|
|
||||||
|
|
||||||
function sanitizeMdLinkText(value: string): string {
|
|
||||||
return value
|
|
||||||
.replace(/\\/g, '\\\\')
|
|
||||||
.replace(/([\[\]!])/g, '\\$1')
|
|
||||||
.replace(/[\r\n]+/g, ' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tags turndown treats as void (self-closing). Footnote references render as an
|
|
||||||
// empty <sup data-footnote-ref> whose meaning lives entirely in its data-id;
|
|
||||||
// without marking it void, turndown's blank-node removal drops it before our
|
|
||||||
// rule runs, losing the `[^id]` marker. Mirrors turndown's built-in list.
|
|
||||||
const TURNDOWN_VOID_ELEMENTS = [
|
|
||||||
'AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT',
|
|
||||||
'KEYGEN', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR',
|
|
||||||
];
|
|
||||||
|
|
||||||
function isVoidNode(node: any): boolean {
|
|
||||||
const name = node?.nodeName?.toUpperCase?.();
|
|
||||||
if (!name) return false;
|
|
||||||
if (name === 'SUP' && node.hasAttribute?.('data-footnote-ref')) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return TURNDOWN_VOID_ELEMENTS.indexOf(name) !== -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An empty <sup data-footnote-ref> is "blank" to turndown, which removes blank
|
|
||||||
* inline nodes (RootNode/Node use a module-level isVoid the options cannot
|
|
||||||
* override). To survive, inject the id as text content so the node is non-blank;
|
|
||||||
* the footnoteReference rule then reads data-id and emits `[^id]`.
|
|
||||||
*/
|
|
||||||
function fillEmptyFootnoteRefs(html: string): string {
|
|
||||||
return html.replace(
|
|
||||||
/<sup\b([^>]*\bdata-footnote-ref\b[^>]*)>\s*<\/sup>/gi,
|
|
||||||
(_m, attrs) => `<sup${attrs}></sup>`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `pageBreak` and `transclusionReference` are childless atom <div>s. Like an
|
|
||||||
* empty footnote ref (see above), turndown treats a childless block as "blank"
|
|
||||||
* and replaces it with the blankRule BEFORE any custom rule can fire — so the
|
|
||||||
* node disappears from the export with no trace (#206 mdrt-2). Inject a
|
|
||||||
* zero-width space so the node is non-blank and our lossless rule runs; the
|
|
||||||
* rule rebuilds the tag from the element's attributes, so the injected char
|
|
||||||
* never reaches the output.
|
|
||||||
*/
|
|
||||||
function fillEmptyAtomBlocks(html: string): string {
|
|
||||||
return html.replace(
|
|
||||||
/<div\b([^>]*\bdata-type="(?:pageBreak|transclusionReference)"[^>]*)>\s*<\/div>/gi,
|
|
||||||
(_m, attrs) => `<div${attrs}></div>`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** HTML-escape an attribute value so a re-emitted raw-HTML tag is well-formed. */
|
|
||||||
function escapeHtmlAttr(value: string): string {
|
|
||||||
return value
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** HTML-escape text placed inside a re-emitted raw-HTML element. */
|
|
||||||
function escapeHtmlText(value: string): string {
|
|
||||||
return value
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serialize ALL of an element's attributes back to a raw-HTML attribute string
|
|
||||||
* (leading space included). Generic on purpose: a custom node's identity lives
|
|
||||||
* entirely in its `data-*` attributes (data-id, data-color, data-source-page-id,
|
|
||||||
* data-transclusion-id, …), and serializing every attribute keeps the export
|
|
||||||
* lossless regardless of which attributes a given node carries.
|
|
||||||
*/
|
|
||||||
function serializeAttrs(node: any): string {
|
|
||||||
const attrs = node?.attributes;
|
|
||||||
if (!attrs) return '';
|
|
||||||
return Array.from(attrs as ArrayLike<{ name: string; value: string }>)
|
|
||||||
.map((attr) => ` ${attr.name}="${escapeHtmlAttr(attr.value ?? '')}"`)
|
|
||||||
.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function htmlToMarkdown(html: string): string {
|
|
||||||
const turndownService = new TurndownService({
|
|
||||||
headingStyle: 'atx',
|
|
||||||
codeBlockStyle: 'fenced',
|
|
||||||
hr: '---',
|
|
||||||
bulletListMarker: '-',
|
|
||||||
isVoid: isVoidNode,
|
|
||||||
});
|
|
||||||
|
|
||||||
turndownService.use([
|
|
||||||
TurndownPluginGfm.tables,
|
|
||||||
TurndownPluginGfm.strikethrough,
|
|
||||||
TurndownPluginGfm.highlightedCodeBlock,
|
|
||||||
taskList,
|
|
||||||
callout,
|
|
||||||
preserveDetail,
|
|
||||||
listParagraph,
|
|
||||||
orderedListItem,
|
|
||||||
mathInline,
|
|
||||||
mathBlock,
|
|
||||||
iframeEmbed,
|
|
||||||
htmlEmbed,
|
|
||||||
spoiler,
|
|
||||||
image,
|
|
||||||
video,
|
|
||||||
footnoteReference,
|
|
||||||
footnotesList,
|
|
||||||
pageBreak,
|
|
||||||
transclusionReference,
|
|
||||||
mention,
|
|
||||||
status,
|
|
||||||
]);
|
|
||||||
return turndownService
|
|
||||||
.turndown(fillEmptyAtomBlocks(fillEmptyFootnoteRefs(html)))
|
|
||||||
.replaceAll('<br>', ' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lossless export rules for custom nodes that have NO native Markdown syntax
|
|
||||||
* (#206 mdrt-2). Markdown cannot represent a page break, a transclusion
|
|
||||||
* reference, a mention's stable id, or a status chip's color — so rather than
|
|
||||||
* letting turndown silently drop them, each rule re-emits the node as raw HTML
|
|
||||||
* carrying every `data-*` attribute. Plain-Markdown viewers ignore the inert
|
|
||||||
* tag, and the import path round-trips it: `markdownToHtml` passes raw HTML
|
|
||||||
* through and each node's `parseHTML` (`div[data-type="…"]`, `span[…]`) rebuilds
|
|
||||||
* the ProseMirror node with its attributes intact.
|
|
||||||
*/
|
|
||||||
function pageBreak(turndownService: _TurndownService) {
|
|
||||||
turndownService.addRule('pageBreak', {
|
|
||||||
filter: function (node: HTMLInputElement) {
|
|
||||||
return (
|
|
||||||
node.nodeName === 'DIV' &&
|
|
||||||
node.getAttribute('data-type') === 'pageBreak'
|
|
||||||
);
|
|
||||||
},
|
|
||||||
replacement: function (_content: string, node: HTMLInputElement) {
|
|
||||||
return `\n\n<div${serializeAttrs(node)}></div>\n\n`;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function transclusionReference(turndownService: _TurndownService) {
|
|
||||||
turndownService.addRule('transclusionReference', {
|
|
||||||
filter: function (node: HTMLInputElement) {
|
|
||||||
return (
|
|
||||||
node.nodeName === 'DIV' &&
|
|
||||||
node.getAttribute('data-type') === 'transclusionReference'
|
|
||||||
);
|
|
||||||
},
|
|
||||||
replacement: function (_content: string, node: HTMLInputElement) {
|
|
||||||
return `\n\n<div${serializeAttrs(node)}></div>\n\n`;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function mention(turndownService: _TurndownService) {
|
|
||||||
turndownService.addRule('mention', {
|
|
||||||
filter: function (node: HTMLInputElement) {
|
|
||||||
return (
|
|
||||||
node.nodeName === 'SPAN' &&
|
|
||||||
node.getAttribute('data-type') === 'mention'
|
|
||||||
);
|
|
||||||
},
|
|
||||||
replacement: function (_content: string, node: HTMLInputElement) {
|
|
||||||
const text = escapeHtmlText(node.textContent || '');
|
|
||||||
return `<span${serializeAttrs(node)}>${text}</span>`;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function status(turndownService: _TurndownService) {
|
|
||||||
turndownService.addRule('status', {
|
|
||||||
filter: function (node: HTMLInputElement) {
|
|
||||||
return (
|
|
||||||
node.nodeName === 'SPAN' && node.getAttribute('data-type') === 'status'
|
|
||||||
);
|
|
||||||
},
|
|
||||||
replacement: function (_content: string, node: HTMLInputElement) {
|
|
||||||
const text = escapeHtmlText(node.textContent || '');
|
|
||||||
return `<span${serializeAttrs(node)}>${text}</span>`;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serialize the `htmlEmbed` node to Markdown.
|
|
||||||
*
|
|
||||||
* Markdown has no native representation for an arbitrary-HTML block, so we
|
|
||||||
* preserve the node losslessly as an HTML comment carrying the base64-encoded
|
|
||||||
* source (the same `data-source` payload the node stores). `markdownToHtml`
|
|
||||||
* recognizes the same marker and rebuilds the node, so the round-trip
|
|
||||||
* MD -> HTML -> JSON keeps the source intact. The comment also keeps the raw
|
|
||||||
* markup inert in the exported `.md` file (it does not render in plain Markdown
|
|
||||||
* viewers).
|
|
||||||
*/
|
|
||||||
function htmlEmbed(turndownService: _TurndownService) {
|
|
||||||
turndownService.addRule('htmlEmbed', {
|
|
||||||
filter: function (node: HTMLInputElement) {
|
|
||||||
return (
|
|
||||||
node.nodeName === 'DIV' &&
|
|
||||||
node.getAttribute('data-type') === 'htmlEmbed'
|
|
||||||
);
|
|
||||||
},
|
|
||||||
replacement: function (_content: string, node: HTMLInputElement) {
|
|
||||||
const encoded = node.getAttribute('data-source') || '';
|
|
||||||
return `\n\n<!--html-embed:${encoded}-->\n\n`;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serialize the `spoiler` inline mark to lossless raw inline HTML.
|
|
||||||
*
|
|
||||||
* Markdown has no native spoiler syntax, so we emit the same `<span
|
|
||||||
* data-spoiler="true">…</span>` the mark renders. `marked` passes inline raw HTML
|
|
||||||
* through untouched, and `generateJSON` restores the mark via its parseHTML, so
|
|
||||||
* the round-trip MD -> HTML -> JSON keeps the spoiler intact. The UI-only
|
|
||||||
* `is-revealed` state is never serialized.
|
|
||||||
*/
|
|
||||||
function spoiler(turndownService: _TurndownService) {
|
|
||||||
turndownService.addRule('spoiler', {
|
|
||||||
filter: function (node: HTMLInputElement) {
|
|
||||||
return (
|
|
||||||
node.nodeName === 'SPAN' &&
|
|
||||||
node.getAttribute('data-spoiler') === 'true'
|
|
||||||
);
|
|
||||||
},
|
|
||||||
replacement: function (content: string) {
|
|
||||||
return `<span data-spoiler="true">${content}</span>`;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function listParagraph(turndownService: _TurndownService) {
|
|
||||||
turndownService.addRule('paragraph', {
|
|
||||||
filter: ['p'],
|
|
||||||
replacement: (content: string, node: HTMLInputElement) => {
|
|
||||||
if (node.parentElement?.nodeName === 'LI') {
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
return `\n\n${content}\n\n`;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function orderedListItem(turndownService: _TurndownService) {
|
|
||||||
turndownService.addRule('orderedListItem', {
|
|
||||||
filter: function (node: HTMLInputElement) {
|
|
||||||
return node.nodeName === 'LI' && node.getAttribute('data-type') !== 'taskItem';
|
|
||||||
},
|
|
||||||
replacement: (content: string, node: HTMLInputElement, options: any) => {
|
|
||||||
const parent = node.parentNode as HTMLElement;
|
|
||||||
if (parent.nodeName !== 'OL' && parent.nodeName !== 'UL') {
|
|
||||||
return content;
|
|
||||||
}
|
|
||||||
|
|
||||||
content = content
|
|
||||||
.replace(/^\n+/, '')
|
|
||||||
.replace(/\n+$/, '\n')
|
|
||||||
.replace(/\n/gm, '\n ');
|
|
||||||
|
|
||||||
let prefix: string;
|
|
||||||
if (parent.nodeName === 'OL') {
|
|
||||||
const start = parseInt(parent.getAttribute('start') || '1', 10);
|
|
||||||
const index = Array.prototype.indexOf.call(parent.children, node);
|
|
||||||
prefix = `${start + index}. `;
|
|
||||||
} else {
|
|
||||||
prefix = `${options.bulletListMarker} `;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
prefix +
|
|
||||||
content +
|
|
||||||
(node.nextSibling && !/\n$/.test(content) ? '\n' : '')
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function callout(turndownService: _TurndownService) {
|
|
||||||
turndownService.addRule('callout', {
|
|
||||||
filter: function (node: HTMLInputElement) {
|
|
||||||
return (
|
|
||||||
node.nodeName === 'DIV' && node.getAttribute('data-type') === 'callout'
|
|
||||||
);
|
|
||||||
},
|
|
||||||
replacement: function (content: string, node: HTMLInputElement) {
|
|
||||||
const calloutType = node.getAttribute('data-callout-type');
|
|
||||||
return `\n\n:::${calloutType}\n${content.trim()}\n:::\n\n`;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function taskList(turndownService: _TurndownService) {
|
|
||||||
turndownService.addRule('taskListItem', {
|
|
||||||
filter: function (node: HTMLInputElement) {
|
|
||||||
return (
|
|
||||||
node.getAttribute('data-type') === 'taskItem' &&
|
|
||||||
node.parentNode.nodeName === 'UL'
|
|
||||||
);
|
|
||||||
},
|
|
||||||
replacement: function (_content: string, node: HTMLInputElement) {
|
|
||||||
const isChecked = node.getAttribute('data-checked') === 'true';
|
|
||||||
const div = node.querySelector('div');
|
|
||||||
const text = div ? div.textContent.trim() : node.textContent.trim();
|
|
||||||
|
|
||||||
const prefix = `- ${isChecked ? '[x]' : '[ ]'} `;
|
|
||||||
|
|
||||||
return (
|
|
||||||
prefix +
|
|
||||||
text +
|
|
||||||
(node.nextSibling && !/\n$/.test(text) ? '\n' : '')
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function preserveDetail(turndownService: _TurndownService) {
|
|
||||||
turndownService.addRule('preserveDetail', {
|
|
||||||
filter: function (node: HTMLInputElement) {
|
|
||||||
return node.nodeName === 'DETAILS';
|
|
||||||
},
|
|
||||||
replacement: function (_content: string, node: HTMLInputElement) {
|
|
||||||
const summary = node.querySelector(':scope > summary');
|
|
||||||
let detailSummary = '';
|
|
||||||
|
|
||||||
if (summary) {
|
|
||||||
detailSummary = `<summary>${turndownService.turndown(summary.innerHTML)}</summary>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const detailsContent = Array.from(node.childNodes)
|
|
||||||
.filter((child) => child.nodeName !== 'SUMMARY')
|
|
||||||
.map((child) =>
|
|
||||||
child.nodeType === 1
|
|
||||||
? turndownService.turndown((child as HTMLElement).outerHTML)
|
|
||||||
: child.textContent,
|
|
||||||
)
|
|
||||||
.join('');
|
|
||||||
|
|
||||||
return `\n<details>\n${detailSummary}\n\n${detailsContent}\n\n</details>\n`;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function mathInline(turndownService: _TurndownService) {
|
|
||||||
turndownService.addRule('mathInline', {
|
|
||||||
filter: function (node: HTMLInputElement) {
|
|
||||||
return (
|
|
||||||
node.nodeName === 'SPAN' &&
|
|
||||||
node.getAttribute('data-type') === 'mathInline'
|
|
||||||
);
|
|
||||||
},
|
|
||||||
replacement: function (content: string) {
|
|
||||||
return `$${content}$`;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function mathBlock(turndownService: _TurndownService) {
|
|
||||||
turndownService.addRule('mathBlock', {
|
|
||||||
filter: function (node: HTMLInputElement) {
|
|
||||||
return (
|
|
||||||
node.nodeName === 'DIV' &&
|
|
||||||
node.getAttribute('data-type') === 'mathBlock'
|
|
||||||
);
|
|
||||||
},
|
|
||||||
replacement: function (content: string) {
|
|
||||||
return `\n$$\n${content}\n$$\n`;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function iframeEmbed(turndownService: _TurndownService) {
|
|
||||||
turndownService.addRule('iframeEmbed', {
|
|
||||||
filter: function (node: HTMLInputElement) {
|
|
||||||
return node.nodeName === 'IFRAME';
|
|
||||||
},
|
|
||||||
replacement: function (_content: string, node: HTMLInputElement) {
|
|
||||||
const src = node.getAttribute('src');
|
|
||||||
return '[' + src + '](' + src + ')';
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function image(turndownService: _TurndownService) {
|
|
||||||
turndownService.addRule('image', {
|
|
||||||
filter: 'img',
|
|
||||||
replacement: function (_content: string, node: HTMLInputElement) {
|
|
||||||
const src = node.getAttribute('src') || '';
|
|
||||||
if (!src) return '';
|
|
||||||
const caption = node.getAttribute('data-caption') || '';
|
|
||||||
if (caption) {
|
|
||||||
// ![]() can't carry a caption, so emit a raw <img> wrapped in a block
|
|
||||||
// <div>. marked passes it through and the image extension's parseHTML
|
|
||||||
// restores the caption from data-caption.
|
|
||||||
const parts = [`src="${escapeHtmlAttr(src)}"`];
|
|
||||||
const alt = node.getAttribute('alt') || '';
|
|
||||||
if (alt) parts.push(`alt="${escapeHtmlAttr(alt)}"`);
|
|
||||||
parts.push(`data-caption="${escapeHtmlAttr(caption)}"`);
|
|
||||||
return `<div><img ${parts.join(' ')}></div>`;
|
|
||||||
}
|
|
||||||
const alt = sanitizeMdLinkText(node.getAttribute('alt') || '');
|
|
||||||
const title = node.getAttribute('title') || '';
|
|
||||||
const titlePart = title ? ' "' + title.replace(/"/g, '\\"') + '"' : '';
|
|
||||||
return '';
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Footnote reference (inline atom) -> pandoc/GFM marker `[^id]`.
|
|
||||||
* The visible number is derived (not stored), so the id is the stable anchor.
|
|
||||||
*/
|
|
||||||
function footnoteReference(turndownService: _TurndownService) {
|
|
||||||
turndownService.addRule('footnoteReference', {
|
|
||||||
filter: function (node: HTMLInputElement) {
|
|
||||||
return (
|
|
||||||
node.nodeName === 'SUP' && node.hasAttribute('data-footnote-ref')
|
|
||||||
);
|
|
||||||
},
|
|
||||||
replacement: function (_content: string, node: HTMLInputElement) {
|
|
||||||
const id = node.getAttribute('data-id') || '';
|
|
||||||
return id ? `[^${id}]` : '';
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Footnotes container -> the list of `[^id]: text` definitions at the end of
|
|
||||||
* the document (one per line). Each footnoteDefinition inside emits its own
|
|
||||||
* `[^id]: ...` line; turndown joins them with the surrounding block spacing.
|
|
||||||
*/
|
|
||||||
function footnotesList(turndownService: _TurndownService) {
|
|
||||||
turndownService.addRule('footnoteDefinition', {
|
|
||||||
filter: function (node: HTMLInputElement) {
|
|
||||||
return (
|
|
||||||
node.nodeName === 'DIV' && node.hasAttribute('data-footnote-def')
|
|
||||||
);
|
|
||||||
},
|
|
||||||
replacement: function (content: string, node: HTMLInputElement) {
|
|
||||||
const id = node.getAttribute('data-id') || '';
|
|
||||||
// Collapse internal newlines so the definition stays a single MD line;
|
|
||||||
// continuation lines are a v2 refinement.
|
|
||||||
const text = content.replace(/\s*\n+\s*/g, ' ').trim();
|
|
||||||
return id ? `\n[^${id}]: ${text}\n` : '';
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
turndownService.addRule('footnotesList', {
|
|
||||||
filter: function (node: HTMLInputElement) {
|
|
||||||
return (
|
|
||||||
node.nodeName === 'SECTION' && node.hasAttribute('data-footnotes')
|
|
||||||
);
|
|
||||||
},
|
|
||||||
replacement: function (content: string) {
|
|
||||||
return `\n\n${content.trim()}\n`;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function video(turndownService: _TurndownService) {
|
|
||||||
turndownService.addRule('video', {
|
|
||||||
filter: function (node: HTMLInputElement) {
|
|
||||||
return node.tagName === 'VIDEO';
|
|
||||||
},
|
|
||||||
replacement: function (_content: string, node: HTMLInputElement) {
|
|
||||||
const src = node.getAttribute('src') || '';
|
|
||||||
const ariaLabel = node.getAttribute('aria-label');
|
|
||||||
const name = sanitizeMdLinkText(
|
|
||||||
ariaLabel || getBasename(src) || src,
|
|
||||||
);
|
|
||||||
return '[' + name + '](' + src + ')';
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -14,10 +14,15 @@ export default defineConfig({
|
|||||||
provider: "v8",
|
provider: "v8",
|
||||||
reporter: ["text-summary", "text"],
|
reporter: ["text-summary", "text"],
|
||||||
all: false,
|
all: false,
|
||||||
|
// functions lowered 60 -> 57 after issue #347 removed the editor-ext
|
||||||
|
// markdown layer (src/lib/markdown) and its image/footnote round-trip
|
||||||
|
// specs: that markdown behavior now lives in — and is tested by —
|
||||||
|
// @docmost/prosemirror-markdown, so the editor-ext baseline shifts down.
|
||||||
|
// Still a real gate (a few points below the post-removal measured level).
|
||||||
thresholds: {
|
thresholds: {
|
||||||
statements: 54,
|
statements: 54,
|
||||||
branches: 44,
|
branches: 44,
|
||||||
functions: 60,
|
functions: 57,
|
||||||
lines: 54,
|
lines: 54,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+40
-13
@@ -40,7 +40,7 @@ There are several Docmost MCPs. Here is a capability-by-capability comparison.
|
|||||||
| **Enterprise license required** | **No** | **Yes** | No | No | No |
|
| **Enterprise license required** | **No** | **Yes** | No | No | No |
|
||||||
| Authentication | email + password, **auto re-auth** | API key | email + password | cookie `authToken` (copy from DevTools) | Docmost API / **direct PostgreSQL** |
|
| Authentication | email + password, **auto re-auth** | API key | email + password | cookie `authToken` (copy from DevTools) | Docmost API / **direct PostgreSQL** |
|
||||||
| Read page as Markdown | ✅ | ✅ | ✅ | ✅ | ✅ (read-only) |
|
| Read page as Markdown | ✅ | ✅ | ✅ | ✅ | ✅ (read-only) |
|
||||||
| **Lossless Markdown round-trip** (export / import, keeps comment anchors) | ✅ | — | — | — | — |
|
| **Markdown round-trip** (export / import, keeps comment anchors) | ✅ | — | — | — | — |
|
||||||
| Read **lossless ProseMirror JSON** (with block ids) | ✅ | — | — | — | — |
|
| Read **lossless ProseMirror JSON** (with block ids) | ✅ | — | — | — | — |
|
||||||
| **Compact page outline** (cheap block-id lookup) | ✅ | — | — | — | — |
|
| **Compact page outline** (cheap block-id lookup) | ✅ | — | — | — | — |
|
||||||
| **Fetch a single block** (by id or index) | ✅ | — | — | — | — |
|
| **Fetch a single block** (by id or index) | ✅ | — | — | — | — |
|
||||||
@@ -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,
|
- **`listPages`** — Recent pages in a space, ordered by `updatedAt` desc (default 50,
|
||||||
max 100). Use `search` for lookups in large spaces.
|
max 100). Use `search` for lookups in large spaces.
|
||||||
- **`search`** — Full-text search across pages and content (bounded by `limit`, max 100).
|
- **`search`** — Full-text search across pages and content (bounded by `limit`, max 100).
|
||||||
- **`getPage`** — A page's content as clean **Markdown** (convenient, but a *lossy*
|
- **`getPage`** — A page's content as clean **Markdown** (canonical for text; drops only
|
||||||
view — block ids and exact table/callout structure are approximated).
|
block ids, resolved-comment anchors, and a fixed no-Markdown-representation attr set —
|
||||||
|
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
|
- **`getPageJson`** — A page's **lossless ProseMirror/TipTap JSON**, including every
|
||||||
block's `attrs.id` and the `slugId` used in URLs. This is what the per-block editing
|
block's `attrs.id` and the `slugId` used in URLs. This is what the per-block editing
|
||||||
tools consume.
|
tools consume.
|
||||||
@@ -186,10 +188,14 @@ All 41 tools, grouped by what you'd reach for them.
|
|||||||
|
|
||||||
### Markdown round-trip
|
### Markdown round-trip
|
||||||
|
|
||||||
- **`exportPageMarkdown`** — Export a page to a single self-contained, **lossless
|
- **`exportPageMarkdown`** — Export a page to a single self-contained
|
||||||
Docmost-flavoured Markdown** file: a meta header, the body with inline comment anchors
|
**Docmost-flavoured Markdown** file: a meta header, the body with inline comment anchors
|
||||||
and diagrams, and a trailing comments-thread block. To replace a page's body from plain
|
and diagrams, and a trailing comments-thread block. The download → edit → import
|
||||||
authoring Markdown, use `updatePageMarkdown`.
|
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
|
> **Removed in this release:** `importPageMarkdown` (the round-trip parser for an
|
||||||
> exported Docmost-Markdown file) is **no longer exposed on the external MCP surface**.
|
> exported Docmost-Markdown file) is **no longer exposed on the external MCP surface**.
|
||||||
@@ -287,21 +293,42 @@ so capable clients steer the model automatically.
|
|||||||
the debounced REST snapshot), then **reads → transforms → writes synchronously** in one
|
the debounced REST snapshot), then **reads → transforms → writes synchronously** in one
|
||||||
tick so no remote update can interleave, and **waits for persistence acknowledgement**
|
tick so no remote update can interleave, and **waits for persistence acknowledgement**
|
||||||
before returning.
|
before returning.
|
||||||
- **Per-page write serialization.** A per-`pageId` async mutex ensures two MCP writes to
|
- **Per-page write serialization.** A per-`pageId` async mutex (keyed by the resolved
|
||||||
the same page never overlap; different pages never block each other.
|
page **UUID**, never a slugId) ensures two MCP writes to the same page never overlap;
|
||||||
|
different pages never block each other. The lock helper fails fast if it is ever handed
|
||||||
|
a non-UUID key, so a write path that forgot to resolve the id can never silently lock
|
||||||
|
under a split key.
|
||||||
|
|
||||||
|
**Deploy requirement — single instance or sticky sessions.** This mutex is an
|
||||||
|
in-process `Map`, and the cached collab sessions and the `stash_page` blob store are
|
||||||
|
RAM-only and process-local. Behind a **multi-replica** load balancer **without sticky
|
||||||
|
sessions**, two replicas can each "hold" the lock for the same page at once and per-page
|
||||||
|
serialization is silently lost. Run the MCP/app as a **single instance**, or pin each
|
||||||
|
page's traffic to one replica (sticky sessions / consistent hashing on the page id).
|
||||||
|
There is deliberately no cross-process (e.g. Postgres advisory) lock yet — a conscious
|
||||||
|
documented constraint. See the `Dockerfile` comment and the `MCP collaboration write
|
||||||
|
path` block in `.env.example`.
|
||||||
|
|
||||||
|
**Rights-staleness window.** A cached collab session writes under the token captured at
|
||||||
|
connect time (and the collab-token cache reuses a token for its TTL), so a **revoked**
|
||||||
|
page access can lag by up to `MCP_COLLAB_SESSION_MAX_AGE_MS` (the hard session lifetime,
|
||||||
|
default 10 min) before the next re-auth picks it up. Lower it to shorten the lag at the
|
||||||
|
cost of more reconnects. This bounded window is an accepted trade-off; there is no
|
||||||
|
push-based cache invalidation on a rights change.
|
||||||
- **Transparent re-authentication.** Login uses email/password; expired tokens are
|
- **Transparent re-authentication.** Login uses email/password; expired tokens are
|
||||||
refreshed automatically on the first 401/403 (covering JSON, multipart upload, and the
|
refreshed automatically on the first 401/403 (covering JSON, multipart upload, and the
|
||||||
collaboration-token path), with in-flight login de-duplication so a burst of calls
|
collaboration-token path), with in-flight login de-duplication so a burst of calls
|
||||||
triggers a single re-login.
|
triggers a single re-login.
|
||||||
- **Lossless and lossy reads.** `getPageJson` returns the exact ProseMirror tree with
|
- **Precise reads.** `getPageJson` returns the exact ProseMirror tree with block ids;
|
||||||
block ids; `getPage` returns clean Markdown for convenience.
|
`getPage` returns canonical Markdown that drops only a fixed, documented attr set.
|
||||||
- **Full Docmost schema.** Markdown↔ProseMirror conversion supports callouts (including
|
- **Full Docmost schema.** Markdown↔ProseMirror conversion supports callouts (including
|
||||||
nested), task lists (bullet *and* numbered checklists), tables, math blocks, embeds,
|
nested), task lists (bullet *and* numbered checklists), tables, math blocks, embeds,
|
||||||
highlights, sub/superscript and more, with defensive caps against pathological input.
|
highlights, sub/superscript and more, with defensive caps against pathological input.
|
||||||
- **Structured tables & lossless Markdown round-trip.** Tables can be edited as a matrix
|
- **Structured tables & Markdown round-trip.** Tables can be edited as a matrix
|
||||||
(read, insert/delete rows, set cells by `[row,col]`) without resending the document, and
|
(read, insert/delete rows, set cells by `[row,col]`) without resending the document, and
|
||||||
a page can be exported to and re-imported from a self-contained Docmost-flavoured
|
a page can be exported to and re-imported from a self-contained Docmost-flavoured
|
||||||
Markdown file that preserves inline comment anchors and diagrams.
|
Markdown file that preserves inline comment anchors and diagrams (block ids regenerate
|
||||||
|
and a fixed no-Markdown-representation attr set is dropped — see `exportPageMarkdown`).
|
||||||
- **Token-optimized responses.** API responses are filtered down to the fields agents
|
- **Token-optimized responses.** API responses are filtered down to the fields agents
|
||||||
actually need, and large collections (spaces, pages, comments, history) are paginated.
|
actually need, and large collections (spaces, pages, comments, history) are paginated.
|
||||||
- **Hardened runtime.** Global handlers keep a stray socket error from tearing down the
|
- **Hardened runtime.** Global handlers keep a stray socket error from tearing down the
|
||||||
|
|||||||
+42
-14
@@ -43,7 +43,7 @@ Docmost-MCP не сочетают:
|
|||||||
| **Нужна enterprise-лицензия** | **Нет** | **Да** | Нет | Нет | Нет |
|
| **Нужна enterprise-лицензия** | **Нет** | **Да** | Нет | Нет | Нет |
|
||||||
| Аутентификация | email + пароль, **авто-переавторизация** | API-ключ | email + пароль | cookie `authToken` (копировать из DevTools) | API Docmost / **напрямую PostgreSQL** |
|
| Аутентификация | email + пароль, **авто-переавторизация** | API-ключ | email + пароль | cookie `authToken` (копировать из DevTools) | API Docmost / **напрямую PostgreSQL** |
|
||||||
| Чтение страницы как Markdown | ✅ | ✅ | ✅ | ✅ | ✅ (только чтение) |
|
| Чтение страницы как Markdown | ✅ | ✅ | ✅ | ✅ | ✅ (только чтение) |
|
||||||
| **Lossless Markdown round-trip** (экспорт/импорт, сохраняет якоря комментариев) | ✅ | — | — | — | — |
|
| **Markdown round-trip** (экспорт/импорт, сохраняет якоря комментариев) | ✅ | — | — | — | — |
|
||||||
| Чтение **lossless ProseMirror JSON** (с id блоков) | ✅ | — | — | — | — |
|
| Чтение **lossless ProseMirror JSON** (с id блоков) | ✅ | — | — | — | — |
|
||||||
| **Компактная структура страницы** (дешёвый поиск id блока) | ✅ | — | — | — | — |
|
| **Компактная структура страницы** (дешёвый поиск id блока) | ✅ | — | — | — | — |
|
||||||
| **Получение одного блока** (по id или индексу) | ✅ | — | — | — | — |
|
| **Получение одного блока** (по id или индексу) | ✅ | — | — | — | — |
|
||||||
@@ -119,8 +119,11 @@ Docmost-MCP не сочетают:
|
|||||||
50, максимум 100). Для поиска в больших пространствах используйте `search`.
|
50, максимум 100). Для поиска в больших пространствах используйте `search`.
|
||||||
- **`search`** — Полнотекстовый поиск по страницам и контенту (ограничен `limit`, максимум
|
- **`search`** — Полнотекстовый поиск по страницам и контенту (ограничен `limit`, максимум
|
||||||
100).
|
100).
|
||||||
- **`getPage`** — Контент страницы как чистый **Markdown** (удобно, но это
|
- **`getPage`** — Контент страницы как чистый **Markdown** (канонично для текста; теряет
|
||||||
*lossy*-представление — id блоков и точная структура таблиц/коллаутов аппроксимируются).
|
лишь id блоков, якоря разрешённых комментариев и фиксированный набор атрибутов без
|
||||||
|
markdown-представления — спаны/colwidth/фон ячеек таблиц, отступы (indent),
|
||||||
|
`callout.icon`, `orderedList.type` и `internal`/`target`/`rel`/`class` у ссылок;
|
||||||
|
используйте `getPageJson`, когда они нужны).
|
||||||
- **`getPageJson`** — **Lossless ProseMirror/TipTap JSON** страницы, включая `attrs.id`
|
- **`getPageJson`** — **Lossless ProseMirror/TipTap JSON** страницы, включая `attrs.id`
|
||||||
каждого блока и `slugId`, используемый в URL. Именно его потребляют инструменты
|
каждого блока и `slugId`, используемый в URL. Именно его потребляют инструменты
|
||||||
поблочного редактирования.
|
поблочного редактирования.
|
||||||
@@ -191,10 +194,14 @@ Docmost-MCP не сочетают:
|
|||||||
|
|
||||||
### Markdown: экспорт и импорт
|
### Markdown: экспорт и импорт
|
||||||
|
|
||||||
- **`exportPageMarkdown`** — Экспортировать страницу в один самодостаточный, **lossless
|
- **`exportPageMarkdown`** — Экспортировать страницу в один самодостаточный
|
||||||
Markdown в диалекте Docmost**: мета-заголовок, тело с inline-якорями комментариев и
|
**Markdown в диалекте Docmost**: мета-заголовок, тело с inline-якорями комментариев и
|
||||||
диаграммами и завершающий блок тредов комментариев. Чтобы заменить тело страницы из
|
диаграммами и завершающий блок тредов комментариев. Round-trip скачать → отредактировать →
|
||||||
обычного авторского Markdown, используйте `updatePageMarkdown`.
|
импортировать перегенерирует id блоков и **молча отбрасывает** набор атрибутов без
|
||||||
|
markdown-представления (спаны/colwidth/фон ячеек таблиц, отступы (indent), `callout.icon`,
|
||||||
|
`orderedList.type`, `internal`/`target`/`rel`/`class` у ссылок); держите их в ProseMirror
|
||||||
|
JSON, если они должны выжить. Чтобы заменить тело страницы из обычного авторского Markdown,
|
||||||
|
используйте `updatePageMarkdown`.
|
||||||
|
|
||||||
> **Удалено в этом релизе:** `importPageMarkdown` (парсер round-trip для
|
> **Удалено в этом релизе:** `importPageMarkdown` (парсер round-trip для
|
||||||
> экспортированного Docmost-Markdown-файла) **больше не отдаётся на внешней MCP-поверхности**.
|
> экспортированного Docmost-Markdown-файла) **больше не отдаётся на внешней MCP-поверхности**.
|
||||||
@@ -295,23 +302,44 @@ Docmost-MCP не сочетают:
|
|||||||
правки, которых ещё нет в дебаунс-снапшоте REST), затем **читает → трансформирует →
|
правки, которых ещё нет в дебаунс-снапшоте REST), затем **читает → трансформирует →
|
||||||
пишет синхронно** в одном тике, чтобы никакое удалённое обновление не вклинилось, и
|
пишет синхронно** в одном тике, чтобы никакое удалённое обновление не вклинилось, и
|
||||||
**ждёт подтверждения сохранения** до возврата.
|
**ждёт подтверждения сохранения** до возврата.
|
||||||
- **Сериализация записи по странице.** Асинхронный мьютекс по `pageId` гарантирует, что
|
- **Сериализация записи по странице.** Асинхронный мьютекс по разрешённому **UUID**
|
||||||
две записи MCP в одну страницу никогда не пересекаются; разные страницы друг друга не
|
страницы (никогда не по slugId) гарантирует, что две записи MCP в одну страницу никогда
|
||||||
блокируют.
|
не пересекаются; разные страницы друг друга не блокируют. Хелпер блокировки падает сразу
|
||||||
|
(fail-fast), если ему передали не-UUID ключ, — путь записи, забывший разрезолвить id, не
|
||||||
|
сможет молча взять лок под расщеплённым ключом.
|
||||||
|
|
||||||
|
**Требование к деплою — один инстанс или sticky-сессии.** Этот мьютекс — процесс-локальный
|
||||||
|
`Map`, а кэш collab-сессий и хранилище `stashPage` живут только в RAM одного процесса. За
|
||||||
|
**мультиреплика**-балансировщиком **без sticky-сессий** две реплики могут одновременно
|
||||||
|
«держать» лок одной страницы, и сериализация по странице молча теряется. Запускайте
|
||||||
|
MCP/приложение **одним инстансом** либо прибивайте трафик страницы к одной реплике
|
||||||
|
(sticky-сессии / consistent hashing по id страницы). Кросс-процессной блокировки (например,
|
||||||
|
Postgres advisory-lock) намеренно пока нет — осознанное задокументированное ограничение.
|
||||||
|
См. комментарий в `Dockerfile` и блок `MCP collaboration write path` в `.env.example`.
|
||||||
|
|
||||||
|
**Окно устаревших прав.** Кэшированная collab-сессия пишет под токеном, захваченным в
|
||||||
|
момент connect (а кэш collab-токена переиспользует токен в пределах своего TTL), поэтому
|
||||||
|
**отозванный** доступ к странице может лагать до `MCP_COLLAB_SESSION_MAX_AGE_MS` (жёсткий
|
||||||
|
срок жизни сессии, по умолчанию 10 мин), пока следующая переавторизация его не подхватит.
|
||||||
|
Уменьшите значение, чтобы сократить лаг ценой большего числа переподключений. Это
|
||||||
|
ограниченное окно — принятый trade-off; push-инвалидации кэша при смене прав нет.
|
||||||
- **Прозрачная переавторизация.** Логин по email/паролю; истёкшие токены обновляются
|
- **Прозрачная переавторизация.** Логин по email/паролю; истёкшие токены обновляются
|
||||||
автоматически на первом 401/403 (покрывая JSON, multipart-загрузку и путь токена
|
автоматически на первом 401/403 (покрывая JSON, multipart-загрузку и путь токена
|
||||||
коллаборации), с дедупликацией параллельных логинов, так что пачка вызовов вызывает один
|
коллаборации), с дедупликацией параллельных логинов, так что пачка вызовов вызывает один
|
||||||
повторный логин.
|
повторный логин.
|
||||||
- **Lossless- и lossy-чтение.** `getPageJson` возвращает точное дерево ProseMirror с id
|
- **Точные чтения.** `getPageJson` возвращает точное дерево ProseMirror с id блоков;
|
||||||
блоков; `getPage` возвращает чистый Markdown для удобства.
|
`getPage` возвращает канонический Markdown, теряющий лишь фиксированный, документированный
|
||||||
|
набор атрибутов.
|
||||||
- **Полная схема Docmost.** Конвертация Markdown↔ProseMirror поддерживает коллауты
|
- **Полная схема Docmost.** Конвертация Markdown↔ProseMirror поддерживает коллауты
|
||||||
(включая вложенные), списки задач (маркированные *и* нумерованные чек-листы), таблицы,
|
(включая вложенные), списки задач (маркированные *и* нумерованные чек-листы), таблицы,
|
||||||
блоки формул, эмбеды, выделение, под/надстрочный текст и прочее, с защитными лимитами
|
блоки формул, эмбеды, выделение, под/надстрочный текст и прочее, с защитными лимитами
|
||||||
против патологического ввода.
|
против патологического ввода.
|
||||||
- **Структурные таблицы и lossless Markdown round-trip.** Таблицы можно редактировать как
|
- **Структурные таблицы и Markdown round-trip.** Таблицы можно редактировать как
|
||||||
матрицу (чтение, вставка/удаление строк, задание ячеек по `[row, col]`) без пересылки
|
матрицу (чтение, вставка/удаление строк, задание ячеек по `[row, col]`) без пересылки
|
||||||
документа, а страницу — экспортировать и заново импортировать как самодостаточный
|
документа, а страницу — экспортировать и заново импортировать как самодостаточный
|
||||||
Markdown-файл в диалекте Docmost, сохраняющий inline-якоря комментариев и диаграммы.
|
Markdown-файл в диалекте Docmost, сохраняющий inline-якоря комментариев и диаграммы
|
||||||
|
(id блоков перегенерируются, а фиксированный набор атрибутов без markdown-представления
|
||||||
|
отбрасывается — см. `exportPageMarkdown`).
|
||||||
- **Ответы, оптимизированные по токенам.** Ответы API урезаются до полей, действительно
|
- **Ответы, оптимизированные по токенам.** Ответы API урезаются до полей, действительно
|
||||||
нужных агентам, а большие коллекции (пространства, страницы, комментарии, история)
|
нужных агентам, а большие коллекции (пространства, страницы, комментарии, история)
|
||||||
пагинируются.
|
пагинируются.
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"$comment": "Semantic palettes for drawioFromGraph (issue #425). DATA, not code: node `kind` -> fill/stroke slot, edge `kind` -> line-style props, per preset. The `default` node palette is the issue's base table; `dark` keeps the same hues on a dark canvas with lighter strokes/font; `colorblind-safe` maps every slot onto the Okabe-Ito qualitative palette (8 colours proven distinguishable for all common colour-vision deficiencies) so no two adjacent kinds collide. `fontColor`/`fillColor`/`strokeColor` are exact draw.io values. `edgeDefault` is the fallback line style; `group` is the (always-transparent) container stroke per preset.",
|
||||||
|
"presets": {
|
||||||
|
"default": {
|
||||||
|
"canvasDark": false,
|
||||||
|
"nodes": {
|
||||||
|
"service": { "fillColor": "#dae8fc", "strokeColor": "#6c8ebf", "fontColor": "#000000" },
|
||||||
|
"db": { "fillColor": "#d5e8d4", "strokeColor": "#82b366", "fontColor": "#000000" },
|
||||||
|
"queue": { "fillColor": "#fff2cc", "strokeColor": "#d6b656", "fontColor": "#000000" },
|
||||||
|
"gateway": { "fillColor": "#ffe6cc", "strokeColor": "#d79b00", "fontColor": "#000000" },
|
||||||
|
"error": { "fillColor": "#f8cecc", "strokeColor": "#b85450", "fontColor": "#000000" },
|
||||||
|
"external": { "fillColor": "#f5f5f5", "strokeColor": "#666666", "fontColor": "#333333" },
|
||||||
|
"security": { "fillColor": "#e1d5e7", "strokeColor": "#9673a6", "fontColor": "#000000" }
|
||||||
|
},
|
||||||
|
"edges": {
|
||||||
|
"sync": { "props": "" },
|
||||||
|
"async": { "props": "dashed=1;" },
|
||||||
|
"error": { "props": "dashed=1;strokeColor=#DD344C;" }
|
||||||
|
},
|
||||||
|
"edgeDefault": { "strokeColor": "#333333", "fontColor": "#333333" },
|
||||||
|
"group": { "strokeColor": "#666666", "fontColor": "#333333" }
|
||||||
|
},
|
||||||
|
"dark": {
|
||||||
|
"canvasDark": true,
|
||||||
|
"nodes": {
|
||||||
|
"service": { "fillColor": "#1a2a44", "strokeColor": "#7ea6e0", "fontColor": "#dae8fc" },
|
||||||
|
"db": { "fillColor": "#1f331e", "strokeColor": "#97d077", "fontColor": "#d5e8d4" },
|
||||||
|
"queue": { "fillColor": "#3a3218", "strokeColor": "#e5c15a", "fontColor": "#fff2cc" },
|
||||||
|
"gateway": { "fillColor": "#3a2812", "strokeColor": "#ffb570", "fontColor": "#ffe6cc" },
|
||||||
|
"error": { "fillColor": "#3a1c1b", "strokeColor": "#e08e8b", "fontColor": "#f8cecc" },
|
||||||
|
"external": { "fillColor": "#2b2b2b", "strokeColor": "#999999", "fontColor": "#e0e0e0" },
|
||||||
|
"security": { "fillColor": "#2c2338", "strokeColor": "#b39ddb", "fontColor": "#e1d5e7" }
|
||||||
|
},
|
||||||
|
"edges": {
|
||||||
|
"sync": { "props": "" },
|
||||||
|
"async": { "props": "dashed=1;" },
|
||||||
|
"error": { "props": "dashed=1;strokeColor=#ff6b6b;" }
|
||||||
|
},
|
||||||
|
"edgeDefault": { "strokeColor": "#cccccc", "fontColor": "#e0e0e0" },
|
||||||
|
"group": { "strokeColor": "#aaaaaa", "fontColor": "#e0e0e0" }
|
||||||
|
},
|
||||||
|
"colorblind-safe": {
|
||||||
|
"canvasDark": false,
|
||||||
|
"okabeIto": ["#000000", "#E69F00", "#56B4E9", "#009E73", "#F0E442", "#0072B2", "#D55E00", "#CC79A7"],
|
||||||
|
"nodes": {
|
||||||
|
"service": { "fillColor": "#D6E9F5", "strokeColor": "#0072B2", "fontColor": "#000000" },
|
||||||
|
"db": { "fillColor": "#D6EFE4", "strokeColor": "#009E73", "fontColor": "#000000" },
|
||||||
|
"queue": { "fillColor": "#FCF8CC", "strokeColor": "#F0E442", "fontColor": "#000000" },
|
||||||
|
"gateway": { "fillColor": "#FBEBD0", "strokeColor": "#E69F00", "fontColor": "#000000" },
|
||||||
|
"error": { "fillColor": "#F7DDCC", "strokeColor": "#D55E00", "fontColor": "#000000" },
|
||||||
|
"external": { "fillColor": "#EDEDED", "strokeColor": "#000000", "fontColor": "#000000" },
|
||||||
|
"security": { "fillColor": "#F3DEEB", "strokeColor": "#CC79A7", "fontColor": "#000000" }
|
||||||
|
},
|
||||||
|
"edges": {
|
||||||
|
"sync": { "props": "" },
|
||||||
|
"async": { "props": "dashed=1;" },
|
||||||
|
"error": { "props": "dashed=1;strokeColor=#D55E00;" }
|
||||||
|
},
|
||||||
|
"edgeDefault": { "strokeColor": "#000000", "fontColor": "#000000" },
|
||||||
|
"group": { "strokeColor": "#000000", "fontColor": "#000000" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
-4815
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,704 @@
|
|||||||
|
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
|
||||||
|
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
|
||||||
|
// changed to a mixin factory. See client/context.ts for the shared base.
|
||||||
|
import type { GConstructor, DocmostClientContext } from "./context.js";
|
||||||
|
import { assertFullUuid } from "./errors.js";
|
||||||
|
import {
|
||||||
|
filterWorkspace,
|
||||||
|
filterSpace,
|
||||||
|
filterPage,
|
||||||
|
filterComment,
|
||||||
|
filterSearchResult,
|
||||||
|
} from "../lib/filters.js";
|
||||||
|
import { convertProseMirrorToMarkdown } from "../lib/markdown-converter.js";
|
||||||
|
import {
|
||||||
|
updatePageContentRealtime,
|
||||||
|
replacePageContent,
|
||||||
|
markdownToProseMirror,
|
||||||
|
markdownToProseMirrorCanonical,
|
||||||
|
mutatePageContent,
|
||||||
|
assertYjsEncodable,
|
||||||
|
MutationResult,
|
||||||
|
} from "../lib/collaboration.js";
|
||||||
|
import {
|
||||||
|
replaceNodeById,
|
||||||
|
replaceNodeByIdWithMany,
|
||||||
|
reassignCollidingBlockIds,
|
||||||
|
deleteNodeById,
|
||||||
|
assertUnambiguousMatch,
|
||||||
|
insertNodeRelative,
|
||||||
|
insertNodesRelative,
|
||||||
|
blockPlainText,
|
||||||
|
buildOutline,
|
||||||
|
getNodeByRef,
|
||||||
|
readTable,
|
||||||
|
insertTableRow,
|
||||||
|
deleteTableRow,
|
||||||
|
updateTableCell,
|
||||||
|
findInvalidNode,
|
||||||
|
} from "@docmost/prosemirror-markdown";
|
||||||
|
import {
|
||||||
|
applyAnchorInDoc,
|
||||||
|
countAnchorMatches,
|
||||||
|
getAnchoredText,
|
||||||
|
resolveAnchorSelection,
|
||||||
|
normalizeForMatch,
|
||||||
|
} from "../lib/comment-anchor.js";
|
||||||
|
import { closestBlockHint } from "../lib/text-normalize.js";
|
||||||
|
import {
|
||||||
|
blockText,
|
||||||
|
walk,
|
||||||
|
getList,
|
||||||
|
insertMarkerAfter,
|
||||||
|
setCalloutRange,
|
||||||
|
noteItem,
|
||||||
|
mdToInlineNodes,
|
||||||
|
commentsToFootnotes,
|
||||||
|
canonicalizeFootnotes,
|
||||||
|
insertInlineFootnote,
|
||||||
|
mergeFootnoteDefinitions,
|
||||||
|
} from "../lib/transforms.js";
|
||||||
|
|
||||||
|
// Public method surface of CommentsMixin (issue #450) — a NAMED type so the factory
|
||||||
|
// return type is expressible in the emitted .d.ts (the anonymous mixin class
|
||||||
|
// carries the base's protected shared state, which would otherwise trip TS4094).
|
||||||
|
// Derived from the class below; `implements ICommentsMixin` fails to compile on drift.
|
||||||
|
export interface ICommentsMixin {
|
||||||
|
listComments(pageId: string, includeResolved?: boolean): any;
|
||||||
|
getComment(commentId: string): any;
|
||||||
|
createComment(pageId: string, content: string, type?: "page" | "inline", selection?: string, parentCommentId?: string, suggestedText?: string): any;
|
||||||
|
updateComment(commentId: string, content: string): any;
|
||||||
|
deleteComment(commentId: string): any;
|
||||||
|
resolveComment(commentId: string, resolved: boolean): any;
|
||||||
|
checkNewComments(spaceId: string, since: string, parentPageId?: string): any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommentsMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & ICommentsMixin> & TBase {
|
||||||
|
abstract class CommentsMixin extends Base implements ICommentsMixin {
|
||||||
|
// --- Comment methods (ported from upstream PR #3 by Max Nikitin) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a comment's `content` into a ProseMirror doc object before
|
||||||
|
* markdown conversion. createComment/updateComment send content as a
|
||||||
|
* JSON.stringify(...) STRING, and the server stores it as-is, so on read it
|
||||||
|
* comes back as a string. convertProseMirrorToMarkdown returns "" for a
|
||||||
|
* string, so parse it first (guarded — fall back to the raw value on any
|
||||||
|
* parse failure so a non-JSON legacy value is still handled gracefully).
|
||||||
|
*/
|
||||||
|
protected parseCommentContent(content: any): any {
|
||||||
|
if (typeof content !== "string") return content;
|
||||||
|
try {
|
||||||
|
return JSON.parse(content);
|
||||||
|
} catch {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List comments on a page (cursor-paginated), content as markdown.
|
||||||
|
*
|
||||||
|
* DEFAULT (`includeResolved = false`) hides RESOLVED THREADS WHOLESALE so the
|
||||||
|
* agent sees only active discussions: a top-level comment with `resolvedAt`
|
||||||
|
* set AND every reply under it (a reply of a closed thread is part of the
|
||||||
|
* closed thread) are dropped from `items`. `resolvedThreadsHidden` reports how
|
||||||
|
* many resolved top-level threads were hidden so the agent can re-query with
|
||||||
|
* `includeResolved: true` to see everything. Active threads always stay.
|
||||||
|
*
|
||||||
|
* Returns `{ items, resolvedThreadsHidden }` (NOT a bare array) — callers that
|
||||||
|
* need the full feed (lossless export, transformPage, checkNewComments) pass
|
||||||
|
* `includeResolved: true` and read `.items`.
|
||||||
|
*/
|
||||||
|
async listComments(pageId: string, includeResolved = false) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
let allComments: any[] = [];
|
||||||
|
let cursor: string | null = null;
|
||||||
|
|
||||||
|
// Hard ceiling + immovable-cursor guard (mirrors paginateAll): if /comments
|
||||||
|
// ever stops advancing the cursor (the exact #442 drift scenario) this loop
|
||||||
|
// would otherwise spin forever accumulating duplicates.
|
||||||
|
const MAX_PAGES = 50;
|
||||||
|
let truncated = false;
|
||||||
|
|
||||||
|
for (let page = 0; page < MAX_PAGES; page++) {
|
||||||
|
const payload: Record<string, any> = { pageId, limit: 100 };
|
||||||
|
if (cursor) payload.cursor = cursor;
|
||||||
|
|
||||||
|
const response = await this.client.post("/comments", payload);
|
||||||
|
const data = response.data.data || response.data;
|
||||||
|
const items = data.items || [];
|
||||||
|
allComments = allComments.concat(items);
|
||||||
|
|
||||||
|
// Advance strictly via the server-issued cursor. A missing nextCursor or a
|
||||||
|
// cursor identical to the one we just sent means the end (or a server that
|
||||||
|
// ignores our pagination param) — stop instead of re-fetching page one.
|
||||||
|
const next: string | null = data.meta?.nextCursor || null;
|
||||||
|
if (!next || next === cursor) break;
|
||||||
|
cursor = next;
|
||||||
|
|
||||||
|
// Reaching the ceiling with a still-advancing cursor means truncation.
|
||||||
|
if (page === MAX_PAGES - 1) truncated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (truncated) {
|
||||||
|
console.warn(
|
||||||
|
`listComments: comments for "${pageId}" truncated at the ${MAX_PAGES}-page cap; more pages exist on the server`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped = allComments.map((comment: any) => {
|
||||||
|
const markdown = comment.content
|
||||||
|
? convertProseMirrorToMarkdown(
|
||||||
|
this.parseCommentContent(comment.content),
|
||||||
|
)
|
||||||
|
: "";
|
||||||
|
return filterComment(comment, markdown);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (includeResolved) {
|
||||||
|
return { items: mapped, resolvedThreadsHidden: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ids of RESOLVED top-level threads (a top-level comment has no
|
||||||
|
// parentCommentId). A whole thread is hidden when its root is resolved.
|
||||||
|
const resolvedRootIds = new Set(
|
||||||
|
mapped
|
||||||
|
.filter((c) => !c.parentCommentId && c.resolvedAt != null)
|
||||||
|
.map((c) => c.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = mapped.filter((c) => {
|
||||||
|
// Hide the resolved root itself and every reply anchored to it. A reply's
|
||||||
|
// own resolvedAt is irrelevant — its membership follows the parent thread.
|
||||||
|
// ASSUMPTION: Docmost's comment model is FLAT — a reply's parentCommentId
|
||||||
|
// always points at the thread ROOT (no reply-of-reply nesting), so a single
|
||||||
|
// level of parent lookup covers a whole thread. If nested replies are ever
|
||||||
|
// introduced, a deep reply of a resolved thread would need a root-walk here.
|
||||||
|
if (!c.parentCommentId) return !resolvedRootIds.has(c.id);
|
||||||
|
return !resolvedRootIds.has(c.parentCommentId);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { items, resolvedThreadsHidden: resolvedRootIds.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async getComment(commentId: string) {
|
||||||
|
// Fail fast (#436): reject a truncated id before any network call.
|
||||||
|
assertFullUuid("get_comment", "commentId", commentId);
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
const response = await this.client.post("/comments/info", { commentId });
|
||||||
|
const comment = response.data.data || response.data;
|
||||||
|
const markdown = comment.content
|
||||||
|
? convertProseMirrorToMarkdown(this.parseCommentContent(comment.content))
|
||||||
|
: "";
|
||||||
|
return {
|
||||||
|
data: filterComment(comment, markdown),
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain text of each TOP-LEVEL block of `doc`, for anchor-failure hints. */
|
||||||
|
protected topLevelBlockTexts(doc: any): string[] {
|
||||||
|
const content = doc && Array.isArray(doc.content) ? doc.content : [];
|
||||||
|
return content
|
||||||
|
.map((b: any) => blockPlainText(b))
|
||||||
|
.filter((t: string) => t.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when per-block anchoring failed but the (normalized) selection DOES
|
||||||
|
* appear in the blocks' joined plain text — i.e. it straddles a block
|
||||||
|
* boundary. Blocks are joined with a newline (collapsed to one space by
|
||||||
|
* normalizeForMatch) so a selection whose parts are separated by a paragraph
|
||||||
|
* break still matches. Callers only reach here after single-block anchoring
|
||||||
|
* (incl. the markdown-strip fallback) has already failed.
|
||||||
|
*/
|
||||||
|
protected selectionSpansMultipleBlocks(
|
||||||
|
blockTexts: string[],
|
||||||
|
selection: string,
|
||||||
|
): boolean {
|
||||||
|
const normSel = normalizeForMatch(selection).norm.trim();
|
||||||
|
if (normSel.length === 0) return false;
|
||||||
|
const joined = normalizeForMatch(blockTexts.join("\n")).norm;
|
||||||
|
return joined.indexOf(normSel) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the actionable error for a createComment anchor MISS, porting
|
||||||
|
* editPageText's self-correction affordances: an explicit "spans multiple
|
||||||
|
* blocks" message when the selection straddles a block boundary, otherwise a
|
||||||
|
* "closest block text" hint quoting the block that holds the selection's
|
||||||
|
* longest token. `live` switches the wording between the pre-check (reading the
|
||||||
|
* persisted page) and the post-create live-anchor failure (which rolls back).
|
||||||
|
*/
|
||||||
|
protected anchorNotFoundError(
|
||||||
|
doc: any,
|
||||||
|
selection: string,
|
||||||
|
live: boolean,
|
||||||
|
): Error {
|
||||||
|
const blockTexts = this.topLevelBlockTexts(doc);
|
||||||
|
const rolled = live ? " The comment was rolled back." : "";
|
||||||
|
if (this.selectionSpansMultipleBlocks(blockTexts, selection)) {
|
||||||
|
return new Error(
|
||||||
|
"createComment: the selection spans multiple blocks; anchor on a " +
|
||||||
|
"contiguous fragment within a SINGLE paragraph/block (<=250 chars)." +
|
||||||
|
rolled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const where = live ? "in the live document" : "in the page";
|
||||||
|
return new Error(
|
||||||
|
`createComment: could not find the selection text ${where} to anchor ` +
|
||||||
|
"the comment. Provide the EXACT contiguous text from a single " +
|
||||||
|
"paragraph/block (<=250 chars)." +
|
||||||
|
closestBlockHint(blockTexts, selection) +
|
||||||
|
rolled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an inline comment anchored to its `selection` text, or a reply.
|
||||||
|
*
|
||||||
|
* Top-level comments (no `parentCommentId`) are ALWAYS inline and MUST carry a
|
||||||
|
* `selection`: the `type` argument is kept for interface compatibility but the
|
||||||
|
* effective type is coerced to "inline". The selection has to anchor in the
|
||||||
|
* document; if it cannot, the comment is rolled back and an error is thrown so
|
||||||
|
* the caller is forced to supply a proper inline selection rather than leaving
|
||||||
|
* an orphan, unanchored comment behind. Replies (parentCommentId set) inherit
|
||||||
|
* their parent's anchor: they take NO selection and are not anchored.
|
||||||
|
*/
|
||||||
|
async createComment(
|
||||||
|
pageId: string,
|
||||||
|
content: string,
|
||||||
|
type: "page" | "inline" = "page",
|
||||||
|
selection?: string,
|
||||||
|
parentCommentId?: string,
|
||||||
|
suggestedText?: string,
|
||||||
|
) {
|
||||||
|
// Fail fast (#436): a provided parent id must be a full UUID before any
|
||||||
|
// network call. Validate only when truthy — a falsy parentCommentId means
|
||||||
|
// "top-level comment" (mirrors the isReply computation below), not a reply.
|
||||||
|
if (parentCommentId) {
|
||||||
|
assertFullUuid("createComment", "parentCommentId", parentCommentId);
|
||||||
|
}
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
|
||||||
|
const isReply = !!parentCommentId;
|
||||||
|
const hasSuggestion =
|
||||||
|
suggestedText !== undefined && suggestedText !== null;
|
||||||
|
// Defense in depth mirroring the server DTO/service: a suggested edit rewrites
|
||||||
|
// the exact anchored text, so it is only meaningful on a top-level inline
|
||||||
|
// comment that carries a selection.
|
||||||
|
if (hasSuggestion) {
|
||||||
|
if (isReply) {
|
||||||
|
throw new Error(
|
||||||
|
"createComment: a suggested edit (suggestedText) cannot be attached to a reply; it applies only to a top-level inline comment.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!selection || !selection.trim()) {
|
||||||
|
throw new Error(
|
||||||
|
"createComment: a suggested edit (suggestedText) requires a 'selection' to anchor and rewrite.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Only top-level comments are inline-anchored, so they are stored as
|
||||||
|
// "inline". Replies carry no inline selection, so they keep the historical
|
||||||
|
// general ("page") type — both backward-compatible and semantically correct.
|
||||||
|
// The `type` argument is kept for interface compatibility; createComment
|
||||||
|
// normalizes the effective type internally, so callers may pass "inline".
|
||||||
|
const effectiveType: "page" | "inline" = isReply ? "page" : "inline";
|
||||||
|
if (!isReply && (!selection || !selection.trim())) {
|
||||||
|
throw new Error(
|
||||||
|
"createComment: an inline 'selection' (exact text to anchor on) is required for a top-level comment",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For a SUGGESTION, the value we store as the comment's `selection` must be
|
||||||
|
// the RAW document substring the mark lands on (typographic quotes/dashes,
|
||||||
|
// nbsp, collapsed whitespace), NOT the agent's ASCII input. The anchor is
|
||||||
|
// placed via normalization, so when the doc was auto-converted to
|
||||||
|
// typographic the raw substring differs from the agent input; apply-time
|
||||||
|
// compares the stored selection to the marked doc text STRICTLY, so storing
|
||||||
|
// the raw substring is what makes "Apply" succeed instead of a spurious 409.
|
||||||
|
// Captured in the pre-check below (which already reads the page) and used as
|
||||||
|
// payload.selection. Ordinary comments keep sending the raw agent selection.
|
||||||
|
let anchoredSelection: string | null = null;
|
||||||
|
// Set when the anchor matched only after stripping markdown from the
|
||||||
|
// selection (the strip fallback); surfaced as a soft warning like
|
||||||
|
// editPageText does, so a stale-markdown selection is flagged.
|
||||||
|
let anchorNormalized = false;
|
||||||
|
|
||||||
|
// For a top-level comment, fail BEFORE creating anything when the selection
|
||||||
|
// is not present in the persisted document — this avoids leaving an orphan
|
||||||
|
// comment + notification behind. A read failure (network) is non-fatal: the
|
||||||
|
// live anchor step below still enforces the anchoring invariant.
|
||||||
|
if (!isReply && selection) {
|
||||||
|
try {
|
||||||
|
const page = await this.getPageJson(pageId);
|
||||||
|
if (hasSuggestion) {
|
||||||
|
// A suggestion's anchor MUST be unambiguous: applying it rewrites the
|
||||||
|
// exact anchored text, and ordinary anchoring silently takes the first
|
||||||
|
// occurrence, so 0 matches -> not found and >=2 -> ambiguous, both
|
||||||
|
// rejected BEFORE creating the comment.
|
||||||
|
const matches = countAnchorMatches(page.content, selection);
|
||||||
|
if (matches === 0) {
|
||||||
|
throw this.anchorNotFoundError(page.content, selection, false);
|
||||||
|
}
|
||||||
|
if (matches >= 2) {
|
||||||
|
throw new Error(
|
||||||
|
`createComment: the suggestion's selection is ambiguous — it occurs ${matches} times in the page. ` +
|
||||||
|
"A suggested edit must anchor to a UNIQUE location; expand the selection with surrounding context " +
|
||||||
|
"(still <=250 chars) so it appears exactly once.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Exactly one match: capture the RAW anchored substring to store as the
|
||||||
|
// comment selection (so apply-time equality holds). If this returns
|
||||||
|
// null despite countAnchorMatches===1 (shouldn't happen), fall back to
|
||||||
|
// the raw agent selection below rather than crash.
|
||||||
|
anchoredSelection = getAnchoredText(page.content, selection);
|
||||||
|
anchorNormalized = resolveAnchorSelection(
|
||||||
|
page.content,
|
||||||
|
selection,
|
||||||
|
).normalized;
|
||||||
|
} else {
|
||||||
|
const resolved = resolveAnchorSelection(page.content, selection);
|
||||||
|
if (!resolved.found) {
|
||||||
|
throw this.anchorNotFoundError(page.content, selection, false);
|
||||||
|
}
|
||||||
|
anchorNormalized = resolved.normalized;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Rethrow our own "not found"/"ambiguous"/"spans multiple blocks" errors;
|
||||||
|
// swallow read/network errors so the live anchor step can still try (and
|
||||||
|
// enforce) anchoring.
|
||||||
|
if (
|
||||||
|
e instanceof Error &&
|
||||||
|
(e.message.startsWith("createComment: could not find the selection") ||
|
||||||
|
e.message.startsWith(
|
||||||
|
"createComment: the selection spans multiple blocks",
|
||||||
|
) ||
|
||||||
|
e.message.startsWith(
|
||||||
|
"createComment: the suggestion's selection is ambiguous",
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
if (process.env.DEBUG) {
|
||||||
|
console.error(
|
||||||
|
"Pre-check getPageJson failed; deferring to live anchor step:",
|
||||||
|
e,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert through the full Docmost schema. Deliberately the NON-canonicalizing
|
||||||
|
// variant: a comment body may carry a footnote definition with no matching
|
||||||
|
// reference, and canonicalization would drop it (data loss). See
|
||||||
|
// markdownToProseMirror vs markdownToProseMirrorCanonical.
|
||||||
|
const jsonContent = await markdownToProseMirror(content);
|
||||||
|
const payload: Record<string, any> = {
|
||||||
|
pageId,
|
||||||
|
content: JSON.stringify(jsonContent),
|
||||||
|
type: effectiveType,
|
||||||
|
};
|
||||||
|
// For a suggestion, store the RAW anchored substring (anchoredSelection) so
|
||||||
|
// the stored selection === the text under the mark === apply-time
|
||||||
|
// expectedText. Ordinary comments (and the null fallback) keep the raw
|
||||||
|
// agent selection — their selection is only display/anchor and never used
|
||||||
|
// by apply, so their behavior is unchanged.
|
||||||
|
if (!isReply && selection)
|
||||||
|
payload.selection = anchoredSelection ?? selection;
|
||||||
|
if (parentCommentId) payload.parentCommentId = parentCommentId;
|
||||||
|
// Only a top-level inline comment (with a selection) may carry a suggestion.
|
||||||
|
if (!isReply && selection && hasSuggestion) {
|
||||||
|
payload.suggestedText = suggestedText;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.client.post("/comments/create", payload);
|
||||||
|
const comment = response.data.data || response.data;
|
||||||
|
const markdown = comment.content
|
||||||
|
? convertProseMirrorToMarkdown(this.parseCommentContent(comment.content))
|
||||||
|
: content;
|
||||||
|
const result: any = {
|
||||||
|
data: filterComment(comment, markdown),
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Replies inherit the parent's anchor: no selection, no anchoring.
|
||||||
|
if (isReply) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anchor the comment in the document. The /comments/create API records the
|
||||||
|
// comment + its `selection` text, but it does NOT insert the comment MARK
|
||||||
|
// into the page content, so without this the inline comment has no
|
||||||
|
// highlight/anchor and is not clickable. If anchoring fails the comment is
|
||||||
|
// rolled back (deleted) and an error is thrown — never an orphan comment.
|
||||||
|
const newCommentId: string = comment.id;
|
||||||
|
// Guard: a create response without an id would mean writing a comment mark
|
||||||
|
// with commentId: undefined and a later delete of a falsy id. We have no id
|
||||||
|
// to roll back here (nothing was created with an id), so just fail loudly.
|
||||||
|
if (!newCommentId) {
|
||||||
|
throw new Error(
|
||||||
|
"createComment: the server returned no comment id, so the comment could not be anchored",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let anchored = false;
|
||||||
|
// Set inside the transform when a suggestion's live anchor is ambiguous
|
||||||
|
// (>=2 occurrences), so the rollback path can surface the right error.
|
||||||
|
let ambiguousInLiveDoc = false;
|
||||||
|
// Captured inside the transform on a not-found abort, so the rollback path
|
||||||
|
// can surface the closest-block / spans-multiple-blocks hint built from the
|
||||||
|
// LIVE document (the pre-check page is not in scope there).
|
||||||
|
let liveNotFoundError: Error | null = null;
|
||||||
|
try {
|
||||||
|
const collabToken = await this.getCollabTokenWithReauth();
|
||||||
|
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||||
|
// /comments/create REST call above keeps the agent-supplied id.
|
||||||
|
const pageUuid = await this.resolvePageId(pageId);
|
||||||
|
// Route through the mutatePage seam (not the free function) so this
|
||||||
|
// wrapper's uniqueness gate + rollback can be unit-tested without a live
|
||||||
|
// Hocuspocus collab socket.
|
||||||
|
const mutation = await this.mutatePage(
|
||||||
|
pageUuid,
|
||||||
|
collabToken,
|
||||||
|
this.apiUrl,
|
||||||
|
(liveDoc) => {
|
||||||
|
const doc =
|
||||||
|
liveDoc && liveDoc.type === "doc"
|
||||||
|
? liveDoc
|
||||||
|
: { type: "doc", content: [] };
|
||||||
|
if (hasSuggestion) {
|
||||||
|
// Authoritative uniqueness check against the LIVE document: a
|
||||||
|
// suggestion must anchor to EXACTLY ONE occurrence, otherwise
|
||||||
|
// "Apply" would rewrite the wrong/ambiguous text. If the live doc
|
||||||
|
// no longer has exactly one occurrence (it changed since the
|
||||||
|
// pre-check), abort so the just-created comment is rolled back
|
||||||
|
// rather than mis-anchored to the first occurrence.
|
||||||
|
const liveCount = countAnchorMatches(doc, selection as string);
|
||||||
|
if (liveCount !== 1) {
|
||||||
|
ambiguousInLiveDoc = liveCount >= 2;
|
||||||
|
if (liveCount === 0) {
|
||||||
|
liveNotFoundError = this.anchorNotFoundError(
|
||||||
|
doc,
|
||||||
|
selection as string,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (applyAnchorInDoc(doc, selection as string, newCommentId)) {
|
||||||
|
anchored = true;
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
// Selection text not found in the LIVE document: abort the write. The
|
||||||
|
// rollback + throw below turns this into a hard error.
|
||||||
|
liveNotFoundError = this.anchorNotFoundError(
|
||||||
|
doc,
|
||||||
|
selection as string,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
result.verify = mutation.verify;
|
||||||
|
} catch (e) {
|
||||||
|
// The comment record already exists; roll it back so we never leave an
|
||||||
|
// orphan, then rethrow the original anchoring error.
|
||||||
|
await this.safeDeleteComment(newCommentId);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!anchored) {
|
||||||
|
// Mutation aborted because the selection was not found (or, for a
|
||||||
|
// suggestion, was ambiguous) in the live document. Roll back the comment
|
||||||
|
// and surface a hard error.
|
||||||
|
await this.safeDeleteComment(newCommentId);
|
||||||
|
if (ambiguousInLiveDoc) {
|
||||||
|
throw new Error(
|
||||||
|
"createComment: the suggestion's selection is ambiguous in the live document (multiple occurrences); the comment was rolled back. Expand the selection with surrounding context so it is unique.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw (
|
||||||
|
liveNotFoundError ??
|
||||||
|
new Error(
|
||||||
|
"createComment: failed to anchor the comment (selection not found in the live document); the comment was rolled back",
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft warning (like editPageText): the selection only matched after
|
||||||
|
// stripping markdown, so the caller likely quoted a styled fragment.
|
||||||
|
if (anchorNormalized) {
|
||||||
|
result.warning =
|
||||||
|
"The selection matched only after stripping markdown syntax; the comment " +
|
||||||
|
"was anchored on the document's plain text. Copy the selection verbatim " +
|
||||||
|
"from getPage / searchInPage output to avoid this.";
|
||||||
|
}
|
||||||
|
|
||||||
|
result.anchored = true;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort rollback of a just-created comment. Swallows any delete failure
|
||||||
|
* (logging under DEBUG) so a failed cleanup never masks the original error.
|
||||||
|
*/
|
||||||
|
protected async safeDeleteComment(commentId: string): Promise<void> {
|
||||||
|
// Defense in depth: never call the delete API with a falsy id — there is
|
||||||
|
// nothing to roll back, and deleteComment(undefined) would hit a bad route.
|
||||||
|
if (!commentId) return;
|
||||||
|
try {
|
||||||
|
await this.deleteComment(commentId);
|
||||||
|
} catch (delErr) {
|
||||||
|
if (process.env.DEBUG) {
|
||||||
|
console.error(
|
||||||
|
"Failed to roll back comment after anchoring error:",
|
||||||
|
delErr,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async updateComment(commentId: string, content: string) {
|
||||||
|
// Fail fast (#436): reject a truncated id before any network call.
|
||||||
|
assertFullUuid("updateComment", "commentId", commentId);
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
// NON-canonicalizing on purpose (comment body — see createComment).
|
||||||
|
const jsonContent = await markdownToProseMirror(content);
|
||||||
|
await this.client.post("/comments/update", {
|
||||||
|
commentId,
|
||||||
|
content: JSON.stringify(jsonContent),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
commentId,
|
||||||
|
message: "Comment updated successfully.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async deleteComment(commentId: string) {
|
||||||
|
// Fail fast (#436): reject a truncated id before any network call.
|
||||||
|
assertFullUuid("deleteComment", "commentId", commentId);
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
return this.client
|
||||||
|
.post("/comments/delete", { commentId })
|
||||||
|
.then((res) => res.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve or reopen a top-level comment thread (reversible — `resolved`
|
||||||
|
* toggles the state). Only top-level comments can be resolved; the server
|
||||||
|
* rejects resolving a reply. Hits POST /comments/resolve.
|
||||||
|
*/
|
||||||
|
async resolveComment(commentId: string, resolved: boolean) {
|
||||||
|
// Fail fast (#436): reject a truncated id before any network call.
|
||||||
|
assertFullUuid("resolveComment", "commentId", commentId);
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
const response = await this.client.post("/comments/resolve", {
|
||||||
|
commentId,
|
||||||
|
resolved,
|
||||||
|
});
|
||||||
|
const comment = response.data?.data ?? response.data;
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
commentId,
|
||||||
|
resolved,
|
||||||
|
comment,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for new comments across pages in a space (optionally scoped to a
|
||||||
|
* subtree): pages updated after `since` are scanned and their comments
|
||||||
|
* filtered by createdAt > since.
|
||||||
|
*/
|
||||||
|
async checkNewComments(
|
||||||
|
spaceId: string,
|
||||||
|
since: string,
|
||||||
|
parentPageId?: string,
|
||||||
|
) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
|
||||||
|
const sinceDate = new Date(since);
|
||||||
|
|
||||||
|
// Reject an unparseable `since`: comparing against an Invalid Date silently
|
||||||
|
// yields zero new comments (every `>` against NaN is false), which would
|
||||||
|
// mask a malformed input as "nothing new" instead of erroring.
|
||||||
|
if (Number.isNaN(sinceDate.getTime())) {
|
||||||
|
throw new Error(
|
||||||
|
`checkNewComments: invalid "since" date "${since}"; expected an ISO-8601 timestamp`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Enumerate the FULL set of pages in scope via the page tree (a complete
|
||||||
|
// page index), NOT the bounded "/pages/recent" feed which caps at ~5000
|
||||||
|
// recent items and silently misses comments on older pages.
|
||||||
|
//
|
||||||
|
// Subtree scope: when parentPageId is given, the scope is that page ITSELF
|
||||||
|
// plus every descendant. Otherwise the scope is the whole space (all roots
|
||||||
|
// and their descendants).
|
||||||
|
//
|
||||||
|
// NOTE: do NOT pre-filter by page.updatedAt — creating a comment does not
|
||||||
|
// bump it (verified on a live server), so such a filter silently misses
|
||||||
|
// comments on pages that were not otherwise edited. The complete tree walk
|
||||||
|
// already restricts the scope correctly, so no recent-feed allow-list is
|
||||||
|
// needed any more.
|
||||||
|
//
|
||||||
|
// The subtree scope (parentPageId given) already INCLUDES the root node
|
||||||
|
// itself: /pages/tree seeds getPageAndDescendants with id = parentPageId, so
|
||||||
|
// no separate getPageRaw fetch for the parent is needed.
|
||||||
|
const { pages: pagesInScope, truncated } = await this.enumerateSpacePages(
|
||||||
|
spaceId,
|
||||||
|
parentPageId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Fetch comments for each page, keep ones created after since
|
||||||
|
const results: any[] = [];
|
||||||
|
for (const page of pagesInScope) {
|
||||||
|
try {
|
||||||
|
// Full feed (incl. resolved): a "new comments since" scan reports all
|
||||||
|
// recent activity; the active-only filter is scoped to listComments.
|
||||||
|
const comments = (await this.listComments(page.id, true)).items;
|
||||||
|
const newComments = comments.filter(
|
||||||
|
(c: any) => new Date(c.createdAt) > sinceDate,
|
||||||
|
);
|
||||||
|
if (newComments.length > 0) {
|
||||||
|
results.push({
|
||||||
|
pageId: page.id,
|
||||||
|
pageTitle: page.title,
|
||||||
|
comments: newComments,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
// Skip pages with errors (e.g. deleted between calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalNewComments = results.reduce(
|
||||||
|
(sum, r) => sum + r.comments.length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// `truncated` is reported by enumerateSpacePages: it is true ONLY when the
|
||||||
|
// stdio fallback BFS hit its node cap. The primary /pages/tree path is
|
||||||
|
// uncapped, so a space with legitimately many pages is not falsely flagged.
|
||||||
|
return {
|
||||||
|
since,
|
||||||
|
scope: parentPageId ? `subtree of ${parentPageId}` : `space ${spaceId}`,
|
||||||
|
checkedPages: pagesInScope.length,
|
||||||
|
pagesWithNewComments: results.length,
|
||||||
|
totalNewComments,
|
||||||
|
truncated,
|
||||||
|
comments: results,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Image upload / embedding ---
|
||||||
|
|
||||||
|
/** Map a Content-Type string to a supported MIME type, or null if unsupported. */
|
||||||
|
}
|
||||||
|
return CommentsMixin;
|
||||||
|
}
|
||||||
@@ -0,0 +1,698 @@
|
|||||||
|
// Shared client context + core seams (issue #450). The abstract base of the
|
||||||
|
// DocmostClient mixin chain: it owns ALL shared instance state (the axios
|
||||||
|
// client, apiUrl, auth tokens, the resolvePageId cache, the collab-token cache,
|
||||||
|
// the sandbox/metrics sinks) and the core HTTP/auth/pagination/write seams every
|
||||||
|
// domain module builds on. Domain modules are mixins layered on top; the final
|
||||||
|
// DocmostClient (client.ts) assembles them. Extracted VERBATIM from the original
|
||||||
|
// monolith — only field/seam visibility was widened from `private` to
|
||||||
|
// `protected` so sibling mixins can reach the shared state through `this`, and
|
||||||
|
// the cross-module methods that live in other mixins are declared `abstract`
|
||||||
|
// here so `this.<method>` type-checks. No behaviour changed.
|
||||||
|
import axios, { AxiosInstance } from "axios";
|
||||||
|
import FormData from "form-data";
|
||||||
|
import {
|
||||||
|
updatePageContentRealtime,
|
||||||
|
replacePageContent,
|
||||||
|
markdownToProseMirror,
|
||||||
|
markdownToProseMirrorCanonical,
|
||||||
|
mutatePageContent,
|
||||||
|
assertYjsEncodable,
|
||||||
|
MutationResult,
|
||||||
|
} from "../lib/collaboration.js";
|
||||||
|
import { acquireCollabSession } from "../lib/collab-session.js";
|
||||||
|
import { withPageLock, isUuid } from "../lib/page-lock.js";
|
||||||
|
import { getCollabToken, performLogin } from "../lib/auth-utils.js";
|
||||||
|
import { formatDocmostAxiosError } from "./errors.js";
|
||||||
|
import { GetPageConversionCache } from "./getpage-cache.js";
|
||||||
|
|
||||||
|
// A generic mixin base constructor (issue #450). Each domain mixin is a factory
|
||||||
|
// `<T extends GConstructor<DocmostClientContext>>(Base: T) => class extends Base`
|
||||||
|
// so the mixins compose into one prototype chain sharing this context.
|
||||||
|
export type GConstructor<T = {}> = abstract new (...args: any[]) => T;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for a DocmostClient / MCP server instance. A discriminated
|
||||||
|
* union: either service-account credentials (email/password — the client calls
|
||||||
|
* performLogin, powering the external /mcp HTTP endpoint and the stdio CLI) OR
|
||||||
|
* a token getter (getToken — the client uses the returned BARE access JWT as
|
||||||
|
* the Bearer and never calls performLogin; used for the internal per-user path).
|
||||||
|
*
|
||||||
|
* Both branches may ALSO carry an optional `getCollabToken` provider. When set,
|
||||||
|
* content mutations (which go over the collaboration websocket) use the token it
|
||||||
|
* returns INSTEAD of calling `POST /auth/collab-token`. The internal per-user
|
||||||
|
* agent path uses this to hand the client a provenance collab token (signed
|
||||||
|
* `actor:'agent'`+`aiChatId`), so agent content edits are attributed without a
|
||||||
|
* spoofable client-side field. When absent the client keeps the original
|
||||||
|
* `/auth/collab-token` path (service-account/stdio unchanged).
|
||||||
|
*
|
||||||
|
* Housed here (not in index.ts) so client.ts has no type dependency on index.ts;
|
||||||
|
* index.ts re-exports it for the package's public surface.
|
||||||
|
*/
|
||||||
|
// Sink the stash tool writes blobs into. The host app binds this to its in-RAM
|
||||||
|
// SandboxStore and composes the public `uri` (the package never sees the store
|
||||||
|
// or any env). `put` returns the anonymous read URL plus integrity metadata.
|
||||||
|
export type SandboxPut = (
|
||||||
|
buf: Buffer,
|
||||||
|
mime: string,
|
||||||
|
) => { uri: string; sha256: string; size: number };
|
||||||
|
|
||||||
|
export type DocmostMcpConfig = { apiUrl: string } & (
|
||||||
|
| { email: string; password: string }
|
||||||
|
| { getToken: () => Promise<string> } // returns a BARE JWT; the client adds "Bearer "
|
||||||
|
) & {
|
||||||
|
// Optional collab-token provider (returns a ready collab JWT). Common to
|
||||||
|
// both branches; see the type doc above.
|
||||||
|
getCollabToken?: () => Promise<string>;
|
||||||
|
// Optional blob sandbox sink. Present only where the stash tool is wired;
|
||||||
|
// when absent, stashPage throws a clear "not configured" error. The
|
||||||
|
// optional `has`/`evict` probes let stashPage keep its mirror counts honest
|
||||||
|
// under the store's FIFO eviction (see stashPage); older sinks omit them.
|
||||||
|
sandbox?: {
|
||||||
|
put: SandboxPut;
|
||||||
|
has?: (uri: string) => boolean;
|
||||||
|
evict?: (uri: string) => void;
|
||||||
|
};
|
||||||
|
// Dependency-neutral metrics sink. When present, the client emits generic
|
||||||
|
// (name, value, labels) samples; the HOST maps those names onto its own
|
||||||
|
// metrics registry (the package never depends on prom-client or the server).
|
||||||
|
// Absent in standalone/stdio mode → the client is a complete no-op here.
|
||||||
|
onMetric?: (
|
||||||
|
name: string,
|
||||||
|
value: number,
|
||||||
|
labels?: Record<string, string>,
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collab-token cache TTL in milliseconds (issue #435). Read fresh from the
|
||||||
|
* environment on every mint — like collab-session.ts readConfig — so tests and a
|
||||||
|
* live rollback can change it without reloading the module.
|
||||||
|
*
|
||||||
|
* Why a cache at all: the live CollabSession registry (#400/#431) keys sessions
|
||||||
|
* on (wsUrl, pageId, collabToken) for identity isolation (invariant 4). But BOTH
|
||||||
|
* collab-token sources mint a FRESH token per mutation — the in-app provider
|
||||||
|
* re-signs a JWT whose iat/exp (seconds) changes every second, and the external
|
||||||
|
* MCP POSTs /auth/collab-token each call — so the token in the key changed on
|
||||||
|
* every op and the session was almost never reused (connect-storms, 25s
|
||||||
|
* timeouts, zombie sessions). Caching the token per-client keeps the key stable
|
||||||
|
* across a burst of mutations so ONE session is reused.
|
||||||
|
*
|
||||||
|
* Default 5 min: well under the 24h collab-token lifetime AND <= the collab
|
||||||
|
* session max-age (10 min, MCP_COLLAB_SESSION_MAX_AGE_MS), so the
|
||||||
|
* permission-staleness window is not widened beyond what #431 already accepted.
|
||||||
|
* The rollback knob is an EXPLICIT 0 (or a negative number): that DISABLES the
|
||||||
|
* cache — an exact fetch-per-call legacy path, mirroring how idleMs<=0 disables
|
||||||
|
* the session cache. Unset OR unparseable (e.g. a typo like "5min", "abc") falls
|
||||||
|
* back to the 5-min default with the cache ON — parseInt yields NaN, which is
|
||||||
|
* treated as "not configured", not as "disabled". So to turn the cache off you
|
||||||
|
* must set the value to exactly 0, not to garbage.
|
||||||
|
*/
|
||||||
|
function readCollabTokenTtlMs(): number {
|
||||||
|
const raw = parseInt(process.env.MCP_COLLAB_TOKEN_TTL_MS ?? "", 10);
|
||||||
|
return Number.isFinite(raw) ? Math.max(0, raw) : 5 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export abstract class DocmostClientContext {
|
||||||
|
protected client: AxiosInstance;
|
||||||
|
protected token: string | null = null;
|
||||||
|
protected apiUrl: string;
|
||||||
|
// email/password are only set on the service-account (credentials) variant;
|
||||||
|
// null on the getToken variant (where there are no credentials to log in with).
|
||||||
|
protected email: string | null = null;
|
||||||
|
protected password: string | null = null;
|
||||||
|
// Per-user token provider. When set, login() calls it to obtain a BARE access
|
||||||
|
// JWT instead of performLogin, and the 401/403 re-auth path re-calls it.
|
||||||
|
protected getTokenFn: (() => Promise<string>) | null = null;
|
||||||
|
// Optional collab-token provider. When set, getCollabTokenWithReauth() returns
|
||||||
|
// its token instead of calling POST /auth/collab-token; on a 401/403 it is
|
||||||
|
// re-invoked once. Used by the internal agent to carry signed provenance.
|
||||||
|
protected getCollabTokenFn: (() => Promise<string>) | null = null;
|
||||||
|
// Optional blob-sandbox sink for the stash tool. Null when not configured.
|
||||||
|
protected sandboxPut: SandboxPut | null = null;
|
||||||
|
// Optional probes paired with the sink. `has` lets stashPage detect a blob
|
||||||
|
// FIFO-evicted by a LATER put in the same stash; `evict` lets it free this
|
||||||
|
// op's image blobs if the final doc put throws. Null when the sink omits them.
|
||||||
|
protected sandboxHas: ((uri: string) => boolean) | null = null;
|
||||||
|
protected sandboxEvict: ((uri: string) => void) | null = null;
|
||||||
|
// Optional dependency-neutral metrics sink (see DocmostMcpConfig.onMetric).
|
||||||
|
// Null on the legacy positional form and whenever the host omits it → no-op.
|
||||||
|
protected onMetricFn:
|
||||||
|
| ((name: string, value: number, labels?: Record<string, string>) => void)
|
||||||
|
| null = null;
|
||||||
|
// In-flight login dedup: when the token expires, the 401 interceptor,
|
||||||
|
// ensureAuthenticated, getCollabTokenWithReauth and the two multipart retries
|
||||||
|
// can all call login() at once. Memoizing a single promise collapses that
|
||||||
|
// thundering herd into ONE /auth/login request that everyone awaits.
|
||||||
|
protected loginPromise: Promise<void> | null = null;
|
||||||
|
// Canonical-UUID cache for resolvePageId: maps an agent-supplied slugId to the
|
||||||
|
// page's canonical UUID, so repeated collab edits on the same page do not
|
||||||
|
// re-fetch /pages/info. A UUID input short-circuits before this cache (see
|
||||||
|
// resolvePageId), so only slugId->uuid entries are stored/read here.
|
||||||
|
protected pageIdCache = new Map<string, string>();
|
||||||
|
|
||||||
|
// Collab-token cache (issue #435): the last minted collab token plus the
|
||||||
|
// wall-clock time it was minted, so a burst of content mutations reuses ONE
|
||||||
|
// token and therefore ONE live CollabSession (whose registry key includes the
|
||||||
|
// token — #400 invariant 4). Per-instance: a DocmostClient is built per
|
||||||
|
// user/per chat request, so a cached token can never leak across identities.
|
||||||
|
// Reset whenever the client's identity changes (login() / this.token cleared);
|
||||||
|
// bypassed on a forced refresh (the 401/403 reauth path). null = no token yet.
|
||||||
|
protected collabTokenCache: { token: string; mintedAt: number } | null = null;
|
||||||
|
|
||||||
|
// Content-addressed conversion cache for getPage (issue #479). Keyed on
|
||||||
|
// (canonical pageId, updatedAt, optionsHash) -> the converted Markdown, so a
|
||||||
|
// re-read of an UNCHANGED page skips the expensive convertProseMirrorToMarkdown
|
||||||
|
// tree walk. Per-instance (a DocmostClient is built per user / per chat), so a
|
||||||
|
// cached conversion can never leak across identities. See getpage-cache.ts.
|
||||||
|
protected getPageCache = new GetPageConversionCache();
|
||||||
|
|
||||||
|
// Two construction forms:
|
||||||
|
// - new DocmostClient(config) // discriminated union (current)
|
||||||
|
// - new DocmostClient(baseURL, email, password) // legacy positional creds
|
||||||
|
// The positional form is retained so existing callers/tests keep working; it
|
||||||
|
// is exactly equivalent to the credentials branch of the object form.
|
||||||
|
constructor(config: DocmostMcpConfig);
|
||||||
|
constructor(baseURL: string, email: string, password: string);
|
||||||
|
constructor(
|
||||||
|
configOrBaseURL: DocmostMcpConfig | string,
|
||||||
|
email?: string,
|
||||||
|
password?: string,
|
||||||
|
) {
|
||||||
|
// Normalize the legacy positional form into the object union.
|
||||||
|
const config: DocmostMcpConfig =
|
||||||
|
typeof configOrBaseURL === "string"
|
||||||
|
? { apiUrl: configOrBaseURL, email: email!, password: password! }
|
||||||
|
: configOrBaseURL;
|
||||||
|
|
||||||
|
this.apiUrl = config.apiUrl;
|
||||||
|
if ("getToken" in config) {
|
||||||
|
// Token variant: carry the user's JWT via getToken; no credentials, so
|
||||||
|
// login() must never call performLogin (there is nothing to log in with).
|
||||||
|
this.getTokenFn = config.getToken;
|
||||||
|
} else {
|
||||||
|
// Service-account variant: behaves exactly as before (performLogin).
|
||||||
|
this.email = config.email;
|
||||||
|
this.password = config.password;
|
||||||
|
}
|
||||||
|
// Optional, available to both variants. When present, content mutations get
|
||||||
|
// their collab token from here instead of POST /auth/collab-token.
|
||||||
|
if (config.getCollabToken) {
|
||||||
|
this.getCollabTokenFn = config.getCollabToken;
|
||||||
|
}
|
||||||
|
if (config.sandbox) {
|
||||||
|
this.sandboxPut = config.sandbox.put;
|
||||||
|
this.sandboxHas = config.sandbox.has ?? null;
|
||||||
|
this.sandboxEvict = config.sandbox.evict ?? null;
|
||||||
|
}
|
||||||
|
// Legacy positional form carries no onMetric → null (complete no-op).
|
||||||
|
this.onMetricFn = config.onMetric ?? null;
|
||||||
|
this.client = axios.create({
|
||||||
|
baseURL: this.apiUrl,
|
||||||
|
// Default request timeout so a hung connection cannot wedge a per-page
|
||||||
|
// lock or block the server indefinitely. Multipart uploads override this
|
||||||
|
// with a longer per-request timeout.
|
||||||
|
timeout: 30000,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-authenticate transparently on a 401/403 once: the JWT authToken can
|
||||||
|
// expire while the server is long-running, after which every cached-token
|
||||||
|
// request would otherwise fail until a manual restart. On such a response,
|
||||||
|
// clear the stale token, perform a fresh login, and replay the original
|
||||||
|
// request exactly once (guarded by config._retry to avoid infinite loops;
|
||||||
|
// the login request itself is never retried).
|
||||||
|
this.client.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const config = error.config;
|
||||||
|
const status = error.response?.status;
|
||||||
|
const isAuthError = status === 401 || status === 403;
|
||||||
|
const isLoginRequest =
|
||||||
|
typeof config?.url === "string" && config.url.includes("/auth/login");
|
||||||
|
|
||||||
|
if (config && isAuthError && !config._retry && !isLoginRequest) {
|
||||||
|
config._retry = true;
|
||||||
|
// Drop the stale token + Authorization header before re-login. Also
|
||||||
|
// clear the collab-token cache (#435): a new identity/login must not
|
||||||
|
// keep serving a collab token minted under the old one.
|
||||||
|
this.token = null;
|
||||||
|
this.collabTokenCache = null;
|
||||||
|
delete this.client.defaults.headers.common["Authorization"];
|
||||||
|
try {
|
||||||
|
await this.login();
|
||||||
|
} catch (loginError) {
|
||||||
|
// Re-login failed: surface the original error to the caller.
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
// Re-issue the original request with the freshly minted Bearer token.
|
||||||
|
// Read it from the default header that login() just set, not from
|
||||||
|
// this.token, to avoid a theoretical "Bearer null" if this.token was
|
||||||
|
// cleared between login() resolving and this point.
|
||||||
|
config.headers = config.headers || {};
|
||||||
|
config.headers["Authorization"] =
|
||||||
|
this.client.defaults.headers.common["Authorization"];
|
||||||
|
return this.client.request(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Diagnostics interceptor (issue #437). Registered AFTER the re-login
|
||||||
|
// interceptor so a successful re-login retry (which resolves to a real
|
||||||
|
// response) is never seen here as an error; only a genuine failure reaches
|
||||||
|
// this rejection handler. It reformats error.message IN PLACE (see
|
||||||
|
// formatDocmostAxiosError — kept as a mutation, not a custom Error class, so
|
||||||
|
// the surrounding axios.isAxiosError / error.response?.status / config._retry
|
||||||
|
// checks keep working) and re-rejects the SAME error. The _docmostFormatted
|
||||||
|
// flag makes a re-processed retry-failure a no-op.
|
||||||
|
this.client.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
formatDocmostAxiosError(error);
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Cross-module seams (issue #450) -----------------------------------
|
||||||
|
// A method in one domain mixin sometimes calls a PROTECTED method owned by
|
||||||
|
// another mixin (e.g. nodes-write -> validateDocUrls in doc-validate). Those
|
||||||
|
// callees are `protected`, so they cannot be surfaced through the public
|
||||||
|
// per-mixin interfaces. Declaring them here on the shared base lets `this.<m>`
|
||||||
|
// type-check across modules. Each is a stub that is ALWAYS overridden by the
|
||||||
|
// owning mixin (layered above this base in the chain), so the body never runs;
|
||||||
|
// it throws only to make an impossible mis-wiring loud instead of silent.
|
||||||
|
// (The PUBLIC cross-module callees — getPage, getPageJson, listComments,
|
||||||
|
// deleteComment, listPageHistory — arrive via the mixins' public interfaces,
|
||||||
|
// so they are not restated here.)
|
||||||
|
protected enumerateSpacePages(
|
||||||
|
_spaceId: string,
|
||||||
|
_rootPageId?: string,
|
||||||
|
): Promise<{ pages: any[]; truncated: boolean }> {
|
||||||
|
throw new Error("enumerateSpacePages not wired (missing ReadMixin)");
|
||||||
|
}
|
||||||
|
protected validateDocUrls(_node: any, _depth?: number): void {
|
||||||
|
throw new Error("validateDocUrls not wired (missing DocValidateMixin)");
|
||||||
|
}
|
||||||
|
protected validateDocStructure(_node: any, _depth?: number): void {
|
||||||
|
throw new Error("validateDocStructure not wired (missing DocValidateMixin)");
|
||||||
|
}
|
||||||
|
protected assertValidNodeShape(_op: string, _node: any): void {
|
||||||
|
throw new Error("assertValidNodeShape not wired (missing DocValidateMixin)");
|
||||||
|
}
|
||||||
|
protected fetchInternalFile(
|
||||||
|
_src: string,
|
||||||
|
): Promise<{ buffer: Buffer; mime: string }> {
|
||||||
|
throw new Error("fetchInternalFile not wired (missing StashMixin)");
|
||||||
|
}
|
||||||
|
protected uploadAttachmentBuffer(
|
||||||
|
_pageId: string,
|
||||||
|
_buffer: Buffer,
|
||||||
|
_fileName: string,
|
||||||
|
_mime: string,
|
||||||
|
): Promise<{ id: string; fileName: string; fileSize: number }> {
|
||||||
|
throw new Error("uploadAttachmentBuffer not wired (missing MediaMixin)");
|
||||||
|
}
|
||||||
|
protected fetchAttachmentText(_src: string): Promise<string> {
|
||||||
|
throw new Error("fetchAttachmentText not wired (missing MediaMixin)");
|
||||||
|
}
|
||||||
|
// PUBLIC cross-module callees. Declared here too (as always-overridden stubs)
|
||||||
|
// so a mixin calling e.g. `this.getPageJson` type-checks against the base —
|
||||||
|
// the mixin's own public interface only covers its own methods. The real
|
||||||
|
// implementations live in ReadMixin / CommentsMixin / PagesMixin and shadow
|
||||||
|
// these on the prototype chain.
|
||||||
|
getPage(_pageId: string): Promise<any> {
|
||||||
|
throw new Error("getPage not wired (missing ReadMixin)");
|
||||||
|
}
|
||||||
|
getPageJson(_pageId: string): Promise<any> {
|
||||||
|
throw new Error("getPageJson not wired (missing ReadMixin)");
|
||||||
|
}
|
||||||
|
listComments(_pageId: string, _includeResolved?: boolean): Promise<any> {
|
||||||
|
throw new Error("listComments not wired (missing CommentsMixin)");
|
||||||
|
}
|
||||||
|
deleteComment(_commentId: string): Promise<any> {
|
||||||
|
throw new Error("deleteComment not wired (missing CommentsMixin)");
|
||||||
|
}
|
||||||
|
listPageHistory(_pageId: string, _cursor?: string): Promise<any> {
|
||||||
|
throw new Error("listPageHistory not wired (missing PagesMixin)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Application base URL (API URL without the /api suffix). */
|
||||||
|
get appUrl(): string {
|
||||||
|
return this.apiUrl.replace(/\/api\/?$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async login() {
|
||||||
|
// Reuse an in-flight login if one is already running so concurrent callers
|
||||||
|
// share a single token fetch instead of each issuing their own.
|
||||||
|
if (!this.loginPromise) {
|
||||||
|
// Token variant: re-fetch a BARE JWT via getToken() (there are no
|
||||||
|
// credentials to log in with — on a 401/403 the interceptor below calls
|
||||||
|
// login() again, which re-invokes getToken()). Credentials variant:
|
||||||
|
// performLogin against /auth/login exactly as before.
|
||||||
|
const fetchToken = this.getTokenFn
|
||||||
|
? this.getTokenFn()
|
||||||
|
: performLogin(this.apiUrl, this.email!, this.password!);
|
||||||
|
this.loginPromise = fetchToken
|
||||||
|
.then((token) => {
|
||||||
|
// Guard against an empty/invalid token (e.g. a getToken provider that
|
||||||
|
// resolves to "" or null): without this an empty token would set a
|
||||||
|
// literal "Authorization: Bearer null"/"Bearer " header and every
|
||||||
|
// request would 401 with a confusing error. Fail loudly instead.
|
||||||
|
if (typeof token !== "string" || token.length === 0) {
|
||||||
|
throw new Error("getToken returned an empty token");
|
||||||
|
}
|
||||||
|
this.token = token;
|
||||||
|
// Identity (re)established: drop any collab token minted under a
|
||||||
|
// previous identity so the #435 cache can never outlive it.
|
||||||
|
this.collabTokenCache = null;
|
||||||
|
this.client.defaults.headers.common["Authorization"] =
|
||||||
|
`Bearer ${token}`;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.loginPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.loginPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async ensureAuthenticated() {
|
||||||
|
if (!this.token) {
|
||||||
|
await this.login();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a collaboration token, transparently re-authenticating once on a
|
||||||
|
* 401/403. getCollabToken() uses bare axios internally, so it is NOT covered
|
||||||
|
* by this.client's response interceptor; this helper replicates that
|
||||||
|
* behaviour for collab-token requests: ensure a token, try once, and on an
|
||||||
|
* expired-token auth error perform a fresh login and retry exactly once.
|
||||||
|
*
|
||||||
|
* Collab-token cache (issue #435): both sources — the getCollabToken provider
|
||||||
|
* (in-app agent) AND the REST /auth/collab-token endpoint (external MCP) — mint
|
||||||
|
* a FRESH token per call, whose string therefore changes every op. Since the
|
||||||
|
* live CollabSession registry keys on the token string (#400/#431 invariant 4),
|
||||||
|
* that churned the key and defeated session reuse. So we cache the last minted
|
||||||
|
* token per-client for readCollabTokenTtlMs() and hand it back for a burst of
|
||||||
|
* mutations, keeping the session key stable. `forceRefresh` bypasses the cache
|
||||||
|
* (the 401/403 reauth retry uses it, so the retry cannot be handed the same
|
||||||
|
* stale token that just failed — otherwise reauth would be a no-op). TTL 0
|
||||||
|
* disables the cache: exact fetch-per-call legacy behaviour.
|
||||||
|
*/
|
||||||
|
protected async getCollabTokenWithReauth(
|
||||||
|
forceRefresh = false,
|
||||||
|
): Promise<string> {
|
||||||
|
const ttl = readCollabTokenTtlMs();
|
||||||
|
// Serve the cached collab token while it is still fresh (identity isolation
|
||||||
|
// is preserved: the cache is a per-instance field on a client built per
|
||||||
|
// user/per chat request, and it is cleared on every identity change).
|
||||||
|
if (
|
||||||
|
!forceRefresh &&
|
||||||
|
ttl > 0 &&
|
||||||
|
this.collabTokenCache &&
|
||||||
|
Date.now() - this.collabTokenCache.mintedAt < ttl
|
||||||
|
) {
|
||||||
|
return this.collabTokenCache.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collab-token PROVIDER path: when a getCollabToken provider was supplied
|
||||||
|
// (the internal agent's provenance collab token), use it instead of the
|
||||||
|
// REST /auth/collab-token endpoint. Re-invoke it once on a 401/403 (e.g. the
|
||||||
|
// signed token expired between content mutations in a long agent turn).
|
||||||
|
if (this.getCollabTokenFn) {
|
||||||
|
try {
|
||||||
|
const token = await this.getCollabTokenFn();
|
||||||
|
if (typeof token !== "string" || token.length === 0) {
|
||||||
|
throw new Error("getCollabToken returned an empty token");
|
||||||
|
}
|
||||||
|
return this.rememberCollabToken(token, ttl);
|
||||||
|
} catch (e) {
|
||||||
|
// On an auth error retry EXACTLY once, forcing a refresh so the retry
|
||||||
|
// re-invokes the provider (bypassing the cache) for a genuinely fresh
|
||||||
|
// token. `!forceRefresh` bounds it to a single retry (no loop).
|
||||||
|
if (this.isCollabAuthError(e) && !forceRefresh) {
|
||||||
|
return this.getCollabTokenWithReauth(true);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
try {
|
||||||
|
const token = await getCollabToken(this.apiUrl, this.token!);
|
||||||
|
return this.rememberCollabToken(token, ttl);
|
||||||
|
} catch (e) {
|
||||||
|
// getCollabToken wraps the AxiosError in a plain Error but attaches the
|
||||||
|
// HTTP status as `.status`, so isCollabAuthError detects an auth failure
|
||||||
|
// via either the raw AxiosError shape OR the attached status.
|
||||||
|
if (this.isCollabAuthError(e) && !forceRefresh) {
|
||||||
|
// Fresh login (which clears this.token AND the collab-token cache), then
|
||||||
|
// retry exactly once with the cache bypassed via forceRefresh.
|
||||||
|
await this.login();
|
||||||
|
return this.getCollabTokenWithReauth(true);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a freshly minted collab token in the per-client cache (issue #435) and
|
||||||
|
* return it unchanged. No-op write when the cache is disabled (ttl<=0) or the
|
||||||
|
* token is empty, so a disabled cache is exact fetch-per-call legacy behaviour
|
||||||
|
* and a bad token is never cached.
|
||||||
|
*/
|
||||||
|
protected rememberCollabToken(token: string, ttl: number): string {
|
||||||
|
if (ttl > 0 && typeof token === "string" && token.length > 0) {
|
||||||
|
this.collabTokenCache = { token, mintedAt: Date.now() };
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when an error carries a 401/403 — either as a raw AxiosError
|
||||||
|
* (`error.response.status`) or as the plain-Error `.status` that
|
||||||
|
* lib/auth-utils.getCollabToken attaches after wrapping the AxiosError.
|
||||||
|
*/
|
||||||
|
protected isCollabAuthError(e: unknown): boolean {
|
||||||
|
const axiosStatus = axios.isAxiosError(e) ? e.response?.status : undefined;
|
||||||
|
const attachedStatus = (e as any)?.status;
|
||||||
|
return (
|
||||||
|
axiosStatus === 401 ||
|
||||||
|
axiosStatus === 403 ||
|
||||||
|
attachedStatus === 401 ||
|
||||||
|
attachedStatus === 403
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to the collaboration websocket, read the live doc, apply
|
||||||
|
* `transform`, write the result, and wait for the server to persist it —
|
||||||
|
* WITHOUT acquiring the per-page lock.
|
||||||
|
*
|
||||||
|
* This mirrors collaboration.mutatePageContent EXCEPT that it does not call
|
||||||
|
* withPageLock. It exists solely so replaceImage can hold ONE withPageLock
|
||||||
|
* across its scan -> upload -> write sequence: the per-page mutex is NOT
|
||||||
|
* reentrant, so calling the normal (self-locking) mutatePageContent inside an
|
||||||
|
* outer withPageLock for the same pageId would deadlock. The caller MUST hold
|
||||||
|
* the page lock for the whole operation; this helper assumes that invariant.
|
||||||
|
*
|
||||||
|
* `transform` receives the live ProseMirror doc and returns the NEW full doc
|
||||||
|
* to write, or `null` to abort with no write. Errors thrown by `transform`
|
||||||
|
* propagate to the caller.
|
||||||
|
*
|
||||||
|
* Resolves a `MutationResult { doc, verify }` mirroring mutatePageContent, so
|
||||||
|
* every content mutator (including replaceImage) can return a verifiable
|
||||||
|
* change report. The report is computed AFTER the atomic read->write and
|
||||||
|
* never throws.
|
||||||
|
*/
|
||||||
|
protected async mutateLiveContentUnlocked(
|
||||||
|
pageId: string,
|
||||||
|
collabToken: string,
|
||||||
|
transform: (liveDoc: any) => any | null,
|
||||||
|
): Promise<MutationResult> {
|
||||||
|
// Reuse a live CollabSession for the page (issue #400) instead of opening a
|
||||||
|
// fresh provider per op. acquireCollabSession does NOT take the per-page
|
||||||
|
// lock — the caller (replaceImage) already holds ONE withPageLock across its
|
||||||
|
// scan -> upload -> write sequence, and the mutex is not reentrant, so
|
||||||
|
// taking it here would deadlock. The synchronous read->write section and the
|
||||||
|
// unsyncedChanges/connectionLost ack logic live in CollabSession.mutate,
|
||||||
|
// preserved verbatim from the old inline machine (incl. the #152 structural
|
||||||
|
// diff that keeps a live editor's cursor anchored).
|
||||||
|
const session = await acquireCollabSession(pageId, collabToken, this.apiUrl, {
|
||||||
|
// Only the actual 25s collab connect timeout emits this — the connect-vs-
|
||||||
|
// unload signal; the other failure paths must NOT emit it.
|
||||||
|
onConnectTimeout: () =>
|
||||||
|
this.onMetricFn?.("collab_connect_timeouts_total", 1),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await session.mutate(transform);
|
||||||
|
} catch (e) {
|
||||||
|
// Drop the session on any failure so the next call reconnects fresh.
|
||||||
|
session.destroy("mutate failed");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic pagination handler for Docmost API endpoints
|
||||||
|
*/
|
||||||
|
async paginateAll<T = any>(
|
||||||
|
endpoint: string,
|
||||||
|
basePayload: Record<string, any> = {},
|
||||||
|
limit: number = 100,
|
||||||
|
): Promise<T[]> {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
|
||||||
|
const clampedLimit = Math.max(1, Math.min(100, limit));
|
||||||
|
|
||||||
|
// Hard ceiling on the number of pages to fetch: guards against a server
|
||||||
|
// that returns a perpetually-true hasNextPage (which would otherwise loop
|
||||||
|
// forever and accumulate duplicates).
|
||||||
|
const MAX_PAGES = 50;
|
||||||
|
|
||||||
|
let cursor: string | undefined;
|
||||||
|
let allItems: T[] = [];
|
||||||
|
let truncated = false;
|
||||||
|
|
||||||
|
for (let page = 0; page < MAX_PAGES; page++) {
|
||||||
|
const payload: Record<string, any> = {
|
||||||
|
...basePayload,
|
||||||
|
limit: clampedLimit,
|
||||||
|
};
|
||||||
|
if (cursor) payload.cursor = cursor;
|
||||||
|
|
||||||
|
const response = await this.client.post(endpoint, payload);
|
||||||
|
|
||||||
|
const data = response.data;
|
||||||
|
const items = data.data?.items || data.items || [];
|
||||||
|
const meta = data.data?.meta || data.meta;
|
||||||
|
|
||||||
|
allItems = allItems.concat(items);
|
||||||
|
|
||||||
|
// Advance strictly via the server-issued cursor. A missing nextCursor (or
|
||||||
|
// hasNextPage false) means we reached the end. A cursor identical to the
|
||||||
|
// one we just sent means the server did not understand our pagination
|
||||||
|
// param — stop instead of re-fetching page one forever and duplicating.
|
||||||
|
const next = meta?.hasNextPage ? meta?.nextCursor : null;
|
||||||
|
if (!next || next === cursor) {
|
||||||
|
// If the server still reports more pages but stopped issuing a usable
|
||||||
|
// cursor at the ceiling, flag the result as truncated below.
|
||||||
|
if (page === MAX_PAGES - 1 && meta?.hasNextPage) truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cursor = next;
|
||||||
|
|
||||||
|
// Reaching the ceiling with more pages still available means the result
|
||||||
|
// set is truncated.
|
||||||
|
if (page === MAX_PAGES - 1) truncated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the loop stopped because it hit the MAX_PAGES ceiling while the server
|
||||||
|
// still reported more results, the result set is truncated — warn so the
|
||||||
|
// caller is not silently handed an incomplete list.
|
||||||
|
if (truncated) {
|
||||||
|
console.warn(
|
||||||
|
`paginateAll: results from "${endpoint}" truncated at the ${MAX_PAGES}-page cap; more pages exist on the server`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Raw page info including the ProseMirror JSON content and slugId. */
|
||||||
|
async getPageRaw(pageId: string) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
const response = await this.client.post("/pages/info", { pageId });
|
||||||
|
return response.data?.data ?? response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve an agent-supplied pageId to the page's CANONICAL UUID (`page.id`),
|
||||||
|
* so every collaboration document the MCP opens is named `page.<uuid>` — the
|
||||||
|
* SAME name the web editor always uses (`page.${page.id}`).
|
||||||
|
*
|
||||||
|
* The agent commonly passes a 10-char public slugId (from URLs/listings) as
|
||||||
|
* the pageId. The web editor opens the collab doc by UUID, but the MCP used to
|
||||||
|
* pass that slugId straight into the collab doc name (`page.<slugId>`). For one
|
||||||
|
* DB row that produced TWO independent Yjs documents whose debounced stores
|
||||||
|
* clobbered each other — the agent's edit was silently lost (#260).
|
||||||
|
*
|
||||||
|
* A UUID input short-circuits with no network round-trip. A slugId is resolved
|
||||||
|
* once via getPageRaw and cached (both slugId->uuid and uuid->uuid), so
|
||||||
|
* repeated edits on the same page add no extra request.
|
||||||
|
*/
|
||||||
|
protected async resolvePageId(pageId: string): Promise<string> {
|
||||||
|
if (isUuid(pageId)) return pageId;
|
||||||
|
const cached = this.pageIdCache.get(pageId);
|
||||||
|
if (cached) return cached;
|
||||||
|
const data = await this.getPageRaw(pageId);
|
||||||
|
const uuid = data?.id;
|
||||||
|
if (typeof uuid !== "string" || !uuid) {
|
||||||
|
throw new Error(
|
||||||
|
`Could not resolve a canonical page id for "${pageId}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.pageIdCache.set(pageId, uuid);
|
||||||
|
return uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page-locked write seam over collaboration.mutatePageContent. Production just
|
||||||
|
* delegates; it exists as an overridable method so the insertFootnote wrapper
|
||||||
|
* (transform abort-on-not-found + response shaping) can be unit-tested without
|
||||||
|
* standing up a live Hocuspocus collab socket.
|
||||||
|
*
|
||||||
|
* SELF-RESOLVES the pageId to the canonical UUID (issue #449, "resolve-then-
|
||||||
|
* lock"): every write must lock and key its CollabSession by the UUID, never a
|
||||||
|
* raw slugId (#260). resolvePageId is cached/idempotent, so a caller that
|
||||||
|
* already resolved pays no extra round-trip; centralizing it here means a
|
||||||
|
* caller that reaches this seam with a raw slugId still locks correctly instead
|
||||||
|
* of silently splitting the mutex key. withPageLock also asserts the key is a
|
||||||
|
* UUID as a hard backstop.
|
||||||
|
*/
|
||||||
|
protected async mutatePage(
|
||||||
|
pageId: string,
|
||||||
|
collabToken: string,
|
||||||
|
apiUrl: string,
|
||||||
|
transform: (doc: any) => any,
|
||||||
|
): Promise<{ doc?: any; verify?: any }> {
|
||||||
|
const pageUuid = await this.resolvePageId(pageId);
|
||||||
|
return mutatePageContent(pageUuid, collabToken, apiUrl, transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-document write seam over collaboration.replacePageContent. Production
|
||||||
|
* just delegates; it exists as an overridable method so the full-doc write
|
||||||
|
* tools (updatePageJson, copyPageContent) can have their footnote-
|
||||||
|
* canonicalization binding unit-tested without a live Hocuspocus collab socket.
|
||||||
|
*
|
||||||
|
* SELF-RESOLVES the pageId to the canonical UUID (issue #449, "resolve-then-
|
||||||
|
* lock") for the same reason as mutatePage above — the lock/CollabSession key
|
||||||
|
* is guaranteed canonical here, not left to the caller's discipline.
|
||||||
|
*/
|
||||||
|
protected async replacePage(
|
||||||
|
pageId: string,
|
||||||
|
doc: any,
|
||||||
|
collabToken: string,
|
||||||
|
apiUrl: string,
|
||||||
|
): Promise<{ doc?: any; verify?: any }> {
|
||||||
|
const pageUuid = await this.resolvePageId(pageId);
|
||||||
|
return replacePageContent(pageUuid, doc, collabToken, apiUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export a page to a single self-contained Docmost-flavoured markdown file:
|
||||||
|
* meta block + body (with inline comment anchors + diagrams) + comment
|
||||||
|
* threads. Lossless round-trip target; see importPageMarkdown for the inverse.
|
||||||
|
*/
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
|
||||||
|
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
|
||||||
|
// changed to a mixin factory. See client/context.ts for the shared base.
|
||||||
|
import type { GConstructor, DocmostClientContext } from "./context.js";
|
||||||
|
import {
|
||||||
|
updatePageContentRealtime,
|
||||||
|
replacePageContent,
|
||||||
|
markdownToProseMirror,
|
||||||
|
markdownToProseMirrorCanonical,
|
||||||
|
mutatePageContent,
|
||||||
|
assertYjsEncodable,
|
||||||
|
MutationResult,
|
||||||
|
} from "../lib/collaboration.js";
|
||||||
|
import {
|
||||||
|
replaceNodeById,
|
||||||
|
replaceNodeByIdWithMany,
|
||||||
|
reassignCollidingBlockIds,
|
||||||
|
deleteNodeById,
|
||||||
|
assertUnambiguousMatch,
|
||||||
|
insertNodeRelative,
|
||||||
|
insertNodesRelative,
|
||||||
|
blockPlainText,
|
||||||
|
buildOutline,
|
||||||
|
getNodeByRef,
|
||||||
|
readTable,
|
||||||
|
insertTableRow,
|
||||||
|
deleteTableRow,
|
||||||
|
updateTableCell,
|
||||||
|
findInvalidNode,
|
||||||
|
} from "@docmost/prosemirror-markdown";
|
||||||
|
import {
|
||||||
|
blockText,
|
||||||
|
walk,
|
||||||
|
getList,
|
||||||
|
insertMarkerAfter,
|
||||||
|
setCalloutRange,
|
||||||
|
noteItem,
|
||||||
|
mdToInlineNodes,
|
||||||
|
commentsToFootnotes,
|
||||||
|
canonicalizeFootnotes,
|
||||||
|
insertInlineFootnote,
|
||||||
|
mergeFootnoteDefinitions,
|
||||||
|
} from "../lib/transforms.js";
|
||||||
|
|
||||||
|
// Public method surface of DocValidateMixin (issue #450) — a NAMED type so the factory
|
||||||
|
// return type is expressible in the emitted .d.ts (the anonymous mixin class
|
||||||
|
// carries the base's protected shared state, which would otherwise trip TS4094).
|
||||||
|
// Derived from the class below; `implements IDocValidateMixin` fails to compile on drift.
|
||||||
|
export interface IDocValidateMixin {
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocValidateMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & IDocValidateMixin> & TBase {
|
||||||
|
abstract class DocValidateMixin extends Base implements IDocValidateMixin {
|
||||||
|
/**
|
||||||
|
* Validate a URL string against a scheme allowlist for a given context.
|
||||||
|
*
|
||||||
|
* The markdown link path enforces safe schemes via TipTap, but the raw
|
||||||
|
* JSON path (updatePageJson) bypasses that — so this is the sanitization
|
||||||
|
* choke point for ProseMirror JSON written directly by the caller.
|
||||||
|
*
|
||||||
|
* - "link": reject javascript:, vbscript:, data: (any scheme that can
|
||||||
|
* execute or smuggle script when the href is clicked).
|
||||||
|
* - "src": allow only http(s):, mailto:, /api/files paths, or a
|
||||||
|
* scheme-less relative/absolute path; reject
|
||||||
|
* javascript:/vbscript:/data:/file:.
|
||||||
|
*/
|
||||||
|
protected isSafeUrl(url: unknown, context: "link" | "src"): boolean {
|
||||||
|
if (typeof url !== "string") return false;
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (trimmed === "") return true; // empty href/src is harmless
|
||||||
|
|
||||||
|
// Extract a leading "scheme:" if present. A scheme must start with a
|
||||||
|
// letter and contain only letters/digits/+/-/. before the colon. Strip
|
||||||
|
// whitespace and ASCII control chars first so a tab/newline embedded in
|
||||||
|
// the scheme cannot smuggle a dangerous scheme past the check.
|
||||||
|
const cleaned = trimmed.replace(/[\s\x00-\x1f]+/g, "");
|
||||||
|
const schemeMatch = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(cleaned);
|
||||||
|
const scheme = schemeMatch ? schemeMatch[1].toLowerCase() : null;
|
||||||
|
|
||||||
|
const dangerous = new Set(["javascript", "vbscript", "data", "file"]);
|
||||||
|
|
||||||
|
if (context === "link") {
|
||||||
|
if (scheme === null) return true; // relative/anchor link is fine
|
||||||
|
// For links, data: is also blocked (can carry script payloads).
|
||||||
|
return !new Set(["javascript", "vbscript", "data"]).has(scheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// context === "src"
|
||||||
|
if (scheme === null) return true; // relative/absolute path (incl. /api/files)
|
||||||
|
if (dangerous.has(scheme)) return false;
|
||||||
|
return scheme === "http" || scheme === "https" || scheme === "mailto";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively walk a ProseMirror doc and reject any unsafe URL on a link
|
||||||
|
* mark href or on a media node's src/url. Media nodes covered: image,
|
||||||
|
* attachment, video, plus embed (rendered as an iframe), youtube, drawio
|
||||||
|
* and excalidraw — all of which carry a user-controlled URL that Docmost
|
||||||
|
* renders. Throws a clear error on the first violation. A max-depth guard
|
||||||
|
* turns an over-deep document into a clean error instead of a RangeError
|
||||||
|
* stack overflow.
|
||||||
|
*/
|
||||||
|
protected validateDocUrls(node: any, depth: number = 0): void {
|
||||||
|
const MAX_DEPTH = 200;
|
||||||
|
if (depth > MAX_DEPTH) {
|
||||||
|
throw new Error(
|
||||||
|
`document nesting exceeds the maximum depth of ${MAX_DEPTH}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!node || typeof node !== "object") return;
|
||||||
|
|
||||||
|
// Link marks on text nodes: validate the href.
|
||||||
|
if (Array.isArray(node.marks)) {
|
||||||
|
for (const mark of node.marks) {
|
||||||
|
if (mark && mark.type === "link" && mark.attrs) {
|
||||||
|
if (!this.isSafeUrl(mark.attrs.href, "link")) {
|
||||||
|
throw new Error(`unsafe link href rejected: "${mark.attrs.href}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media nodes: validate src/url against the stricter src allowlist.
|
||||||
|
// embed renders as an iframe (highest risk); youtube/drawio/excalidraw
|
||||||
|
// likewise carry a user-controlled URL Docmost renders, so they get the
|
||||||
|
// same scheme check as image/attachment/video.
|
||||||
|
if (
|
||||||
|
node.type === "image" ||
|
||||||
|
node.type === "attachment" ||
|
||||||
|
node.type === "video" ||
|
||||||
|
node.type === "embed" ||
|
||||||
|
node.type === "youtube" ||
|
||||||
|
node.type === "drawio" ||
|
||||||
|
node.type === "excalidraw" ||
|
||||||
|
node.type === "audio" ||
|
||||||
|
node.type === "pdf"
|
||||||
|
) {
|
||||||
|
const attrs = node.attrs || {};
|
||||||
|
for (const key of ["src", "url"]) {
|
||||||
|
if (attrs[key] != null && !this.isSafeUrl(attrs[key], "src")) {
|
||||||
|
throw new Error(
|
||||||
|
`unsafe ${node.type} ${key} rejected: "${attrs[key]}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(node.content)) {
|
||||||
|
for (const child of node.content) {
|
||||||
|
this.validateDocUrls(child, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively validate the STRUCTURE of a ProseMirror node (reuses the
|
||||||
|
* recursion shape of validateDocUrls). Every node must be an object with a
|
||||||
|
* string `type`; when present, `content` must be an array, `marks` must be
|
||||||
|
* an array of objects each with a string `type`, and a text node's `text`
|
||||||
|
* must be a string. Throws a clear "invalid ProseMirror document" error on
|
||||||
|
* the first violation. A max-depth guard turns an over-deep document into a
|
||||||
|
* clean error instead of a RangeError stack overflow.
|
||||||
|
*/
|
||||||
|
protected validateDocStructure(node: any, depth: number = 0): void {
|
||||||
|
const MAX_DEPTH = 200;
|
||||||
|
if (depth > MAX_DEPTH) {
|
||||||
|
throw new Error(
|
||||||
|
`invalid ProseMirror document: nesting exceeds the maximum depth of ${MAX_DEPTH}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!node || typeof node !== "object" || typeof node.type !== "string") {
|
||||||
|
throw new Error(
|
||||||
|
"invalid ProseMirror document: every node must be an object with a string `type`",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
"text" in node &&
|
||||||
|
node.type === "text" &&
|
||||||
|
typeof node.text !== "string"
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"invalid ProseMirror document: a text node must have a string `text`",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (node.marks !== undefined) {
|
||||||
|
if (!Array.isArray(node.marks)) {
|
||||||
|
throw new Error(
|
||||||
|
"invalid ProseMirror document: `marks` must be an array",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const mark of node.marks) {
|
||||||
|
if (
|
||||||
|
!mark ||
|
||||||
|
typeof mark !== "object" ||
|
||||||
|
typeof mark.type !== "string"
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"invalid ProseMirror document: every mark must be an object with a string `type`",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.content !== undefined) {
|
||||||
|
if (!Array.isArray(node.content)) {
|
||||||
|
throw new Error(
|
||||||
|
"invalid ProseMirror document: `content` must be an array when present",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const child of node.content) {
|
||||||
|
this.validateDocStructure(child, depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pre-write SHAPE gate (#409). Walk the WHOLE node tree with the shared
|
||||||
|
* `findInvalidNode` and throw a rich, path-anchored error the instant a nested
|
||||||
|
* node has an absent/unknown `type` (or an unknown mark) — the exact shape that
|
||||||
|
* otherwise surfaces DEEP in the Yjs encode as the cryptic
|
||||||
|
* `Unknown node type: undefined`, but only AFTER a collab session was opened
|
||||||
|
* and a page lock taken. Calling this BEFORE `getCollabTokenWithReauth` /
|
||||||
|
* `mutatePageContent` fails fast: no collab connection, no lock, deterministic
|
||||||
|
* message. `op` names the tool for the message prefix (e.g. "patchNode").
|
||||||
|
*
|
||||||
|
* `findInvalidNode` derives its "known type" set from the very same
|
||||||
|
* `docmostExtensions` the encode path uses, so a node this gate accepts is one
|
||||||
|
* the encoder will accept too.
|
||||||
|
*/
|
||||||
|
protected assertValidNodeShape(op: string, node: any): void {
|
||||||
|
const bad = findInvalidNode(node);
|
||||||
|
if (bad) {
|
||||||
|
throw new Error(`${op}: invalid node — ${bad.summary}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace page content with a raw ProseMirror JSON document (lossless) and/or
|
||||||
|
* update its title. Both `doc` and `title` are optional, but at least one must
|
||||||
|
* be supplied:
|
||||||
|
* - `doc` provided -> validate + full-overwrite the body (and update the
|
||||||
|
* title too when `title` is also given).
|
||||||
|
* - `doc` omitted, `title` given -> title-only update; the body is NOT
|
||||||
|
* touched/resent (no collab write happens).
|
||||||
|
* - neither given -> throws (nothing to update).
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
return DocValidateMixin;
|
||||||
|
}
|
||||||
@@ -0,0 +1,707 @@
|
|||||||
|
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
|
||||||
|
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
|
||||||
|
// changed to a mixin factory. See client/context.ts for the shared base.
|
||||||
|
import type { GConstructor, DocmostClientContext } from "./context.js";
|
||||||
|
import { parseCells as parseDrawioCells } from "../lib/drawio-xml.js";
|
||||||
|
import {
|
||||||
|
replaceNodeById,
|
||||||
|
replaceNodeByIdWithMany,
|
||||||
|
reassignCollidingBlockIds,
|
||||||
|
deleteNodeById,
|
||||||
|
assertUnambiguousMatch,
|
||||||
|
insertNodeRelative,
|
||||||
|
insertNodesRelative,
|
||||||
|
blockPlainText,
|
||||||
|
buildOutline,
|
||||||
|
getNodeByRef,
|
||||||
|
readTable,
|
||||||
|
insertTableRow,
|
||||||
|
deleteTableRow,
|
||||||
|
updateTableCell,
|
||||||
|
findInvalidNode,
|
||||||
|
} from "@docmost/prosemirror-markdown";
|
||||||
|
import {
|
||||||
|
prepareModel,
|
||||||
|
decodeDrawioSvg,
|
||||||
|
buildDrawioSvg,
|
||||||
|
mxHash,
|
||||||
|
normalizeXml,
|
||||||
|
countUserCells,
|
||||||
|
} from "../lib/drawio-xml.js";
|
||||||
|
import { renderDiagramShapes } from "../lib/drawio-preview.js";
|
||||||
|
import { applyElkLayout } from "../lib/drawio-layout.js";
|
||||||
|
import {
|
||||||
|
buildFromGraph,
|
||||||
|
type Graph,
|
||||||
|
type LayoutMode as GraphLayoutMode,
|
||||||
|
} from "../lib/drawio-graph.js";
|
||||||
|
import { applyCellOps, type CellOp } from "../lib/drawio-cell-ops.js";
|
||||||
|
import { mermaidToGraph } from "../lib/drawio-mermaid.js";
|
||||||
|
import {
|
||||||
|
blockText,
|
||||||
|
walk,
|
||||||
|
getList,
|
||||||
|
insertMarkerAfter,
|
||||||
|
setCalloutRange,
|
||||||
|
noteItem,
|
||||||
|
mdToInlineNodes,
|
||||||
|
commentsToFootnotes,
|
||||||
|
canonicalizeFootnotes,
|
||||||
|
insertInlineFootnote,
|
||||||
|
mergeFootnoteDefinitions,
|
||||||
|
} from "../lib/transforms.js";
|
||||||
|
|
||||||
|
// Public method surface of DrawioMixin (issue #450) — a NAMED type so the factory
|
||||||
|
// return type is expressible in the emitted .d.ts (the anonymous mixin class
|
||||||
|
// carries the base's protected shared state, which would otherwise trip TS4094).
|
||||||
|
// Derived from the class below; `implements IDrawioMixin` fails to compile on drift.
|
||||||
|
export interface IDrawioMixin {
|
||||||
|
drawioGet(pageId: string, node: string, format?: "xml" | "svg"): Promise<{ pageId: string; nodeId: string; format: "xml" | "svg"; content: string; meta: { attachmentId: string | null; title: string | null; width: number | null; height: number | null; cellCount: number; hash: string; }; }>;
|
||||||
|
drawioCreate(pageId: string, where: { position: "before" | "after" | "append"; anchorNodeId?: string; anchorText?: string; }, xml: string, title?: string, layout?: "elk"): Promise<{ success: boolean; nodeId: string; attachmentId: string; warnings: string[]; verify?: any; }>;
|
||||||
|
drawioUpdate(pageId: string, node: string, xml: string, baseHash: string, layout?: "elk"): Promise<{ success: boolean; nodeId: string; attachmentId: string; warnings: string[]; verify?: any; }>;
|
||||||
|
drawioEditCells(pageId: string, node: string, operations: CellOp[], baseHash: string): Promise<{ success: boolean; nodeId: string; attachmentId: string; warnings: string[]; verify?: any; }>;
|
||||||
|
drawioFromGraph(pageId: string, where: { position: "before" | "after" | "append"; anchorNodeId?: string; anchorText?: string; }, graph: Graph, direction?: "LR" | "RL" | "TB" | "BT", preset?: string, layout?: GraphLayoutMode, node?: string): Promise<{ success: boolean; nodeId: string; attachmentId: string; warnings: string[]; iconsResolved: number; iconsMissing: string[]; verify?: any; }>;
|
||||||
|
drawioFromMermaid(pageId: string, where: { position: "before" | "after" | "append"; anchorNodeId?: string; anchorText?: string; }, mermaid: string, preset?: string): Promise<{ success: boolean; nodeId: string; attachmentId: string; warnings: string[]; iconsResolved: number; iconsMissing: string[]; verify?: any; }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DrawioMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & IDrawioMixin> & TBase {
|
||||||
|
abstract class DrawioMixin extends Base implements IDrawioMixin {
|
||||||
|
/**
|
||||||
|
* Resolve a drawio node on a page by `attrs.id` or `#<index>` and return the
|
||||||
|
* node plus its ref. Throws a clear error if the ref does not resolve to a
|
||||||
|
* drawio node.
|
||||||
|
*/
|
||||||
|
protected async resolveDrawioNode(
|
||||||
|
pageId: string,
|
||||||
|
node: string,
|
||||||
|
): Promise<{ node: any; ref: string }> {
|
||||||
|
const data = await this.getPageRaw(pageId);
|
||||||
|
const hit = getNodeByRef(
|
||||||
|
data.content ?? { type: "doc", content: [] },
|
||||||
|
node,
|
||||||
|
);
|
||||||
|
if (!hit) {
|
||||||
|
throw new Error(
|
||||||
|
`drawio: no node found for "${node}" on page ${pageId} (use the drawio node's attrs.id or "#<index>" from getOutline)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (hit.type !== "drawio") {
|
||||||
|
throw new Error(
|
||||||
|
`drawio: node "${node}" on page ${pageId} is a ${hit.type}, not a drawio diagram`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { node: hit.node, ref: node };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a drawio diagram as mxGraph XML (default) or as the raw `.drawio.svg`.
|
||||||
|
* Runs the decode chain (base64/entity content= → drawio file → nested XML or
|
||||||
|
* pako-inflated compressed <diagram>). The returned `hash` is the
|
||||||
|
* optimistic-lock key for drawioUpdate.
|
||||||
|
*/
|
||||||
|
async drawioGet(
|
||||||
|
pageId: string,
|
||||||
|
node: string,
|
||||||
|
format: "xml" | "svg" = "xml",
|
||||||
|
): Promise<{
|
||||||
|
pageId: string;
|
||||||
|
nodeId: string;
|
||||||
|
format: "xml" | "svg";
|
||||||
|
content: string;
|
||||||
|
meta: {
|
||||||
|
attachmentId: string | null;
|
||||||
|
title: string | null;
|
||||||
|
width: number | null;
|
||||||
|
height: number | null;
|
||||||
|
cellCount: number;
|
||||||
|
hash: string;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
const { node: drawio } = await this.resolveDrawioNode(pageId, node);
|
||||||
|
const attrs = drawio.attrs || {};
|
||||||
|
const src = attrs.src;
|
||||||
|
if (!src) {
|
||||||
|
throw new Error(
|
||||||
|
`drawio: node "${node}" on page ${pageId} has no src to read`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const svg = await this.fetchAttachmentText(src);
|
||||||
|
const modelXml = decodeDrawioSvg(svg);
|
||||||
|
const meta = {
|
||||||
|
attachmentId: attrs.attachmentId ?? null,
|
||||||
|
title: attrs.title ?? null,
|
||||||
|
width: attrs.width != null ? Number(attrs.width) : null,
|
||||||
|
height: attrs.height != null ? Number(attrs.height) : null,
|
||||||
|
cellCount: countUserCells(modelXml),
|
||||||
|
hash: mxHash(modelXml),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
pageId,
|
||||||
|
nodeId: attrs.id ?? node,
|
||||||
|
format,
|
||||||
|
content: format === "svg" ? svg : normalizeXml(modelXml),
|
||||||
|
meta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a drawio diagram from mxGraph XML: lint → schematic SVG preview
|
||||||
|
* (pure TS) → build the `.drawio.svg` (createDrawioSvg contract) → create the
|
||||||
|
* attachment → insert a `drawio` node before/after an anchor or appended.
|
||||||
|
* `xml` is a bare `<mxGraphModel>` or a list of `<mxCell>` (the server wraps
|
||||||
|
* it and adds the id=0/id=1 sentinels).
|
||||||
|
*/
|
||||||
|
async drawioCreate(
|
||||||
|
pageId: string,
|
||||||
|
where: {
|
||||||
|
position: "before" | "after" | "append";
|
||||||
|
anchorNodeId?: string;
|
||||||
|
anchorText?: string;
|
||||||
|
},
|
||||||
|
xml: string,
|
||||||
|
title?: string,
|
||||||
|
layout?: "elk",
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
nodeId: string;
|
||||||
|
attachmentId: string;
|
||||||
|
warnings: string[];
|
||||||
|
verify?: any;
|
||||||
|
}> {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
if (
|
||||||
|
!where ||
|
||||||
|
(where.position !== "before" &&
|
||||||
|
where.position !== "after" &&
|
||||||
|
where.position !== "append")
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'drawioCreate: `where.position` must be one of "before", "after", "append"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (where.position === "before" || where.position === "after") {
|
||||||
|
const hasId =
|
||||||
|
typeof where.anchorNodeId === "string" && where.anchorNodeId.length > 0;
|
||||||
|
const hasText =
|
||||||
|
typeof where.anchorText === "string" && where.anchorText.length > 0;
|
||||||
|
if (hasId === hasText) {
|
||||||
|
throw new Error(
|
||||||
|
`drawioCreate: position "${where.position}" requires exactly one of anchorNodeId or anchorText`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional server-side ELK auto-layout: the model declares structure with
|
||||||
|
// rough coords, ELK computes the pixels (best-effort — returns the input
|
||||||
|
// unchanged on any layout failure).
|
||||||
|
const laidOutXml = layout === "elk" ? await applyElkLayout(xml) : xml;
|
||||||
|
// Pre-write pipeline (throws a structured DrawioLintError on any violation).
|
||||||
|
const prepared = prepareModel(laidOutXml);
|
||||||
|
const inner = renderDiagramShapes(prepared.cells, prepared.bbox);
|
||||||
|
const diagramTitle = title || "Page-1";
|
||||||
|
const svg = buildDrawioSvg(prepared.modelXml, inner, prepared.bbox, diagramTitle);
|
||||||
|
|
||||||
|
const att = await this.uploadAttachmentBuffer(
|
||||||
|
pageId,
|
||||||
|
Buffer.from(svg, "utf-8"),
|
||||||
|
"diagram.drawio.svg",
|
||||||
|
"image/svg+xml",
|
||||||
|
);
|
||||||
|
|
||||||
|
// NOTE: no `id` attribute is set here. The vendored `drawio` node schema
|
||||||
|
// (diagramAttributes) declares no `id`, so any block id would be silently
|
||||||
|
// dropped by PMNode.fromJSON on save and the returned handle would fail to
|
||||||
|
// resolve. The addressable handle is the node's "#<index>" (like image/table
|
||||||
|
// nodes), computed after the insert below.
|
||||||
|
const drawioNode: any = {
|
||||||
|
type: "drawio",
|
||||||
|
attrs: {
|
||||||
|
src: `/api/files/${att.id}/${att.fileName}`,
|
||||||
|
attachmentId: att.id,
|
||||||
|
width: prepared.bbox.width,
|
||||||
|
height: prepared.bbox.height,
|
||||||
|
align: "center",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (title) drawioNode.attrs.title = title;
|
||||||
|
// Reuse the existing URL trust boundary (rejects unsafe src schemes).
|
||||||
|
this.validateDocUrls(drawioNode);
|
||||||
|
|
||||||
|
const collabToken = await this.getCollabTokenWithReauth();
|
||||||
|
const pageUuid = await this.resolvePageId(pageId);
|
||||||
|
|
||||||
|
let inserted = false;
|
||||||
|
let insertedIndex = -1;
|
||||||
|
const mutation = await this.mutatePage(
|
||||||
|
pageUuid,
|
||||||
|
collabToken,
|
||||||
|
this.apiUrl,
|
||||||
|
(liveDoc) => {
|
||||||
|
inserted = false;
|
||||||
|
insertedIndex = -1;
|
||||||
|
const { doc: nd, inserted: ins } = insertNodeRelative(
|
||||||
|
liveDoc,
|
||||||
|
drawioNode,
|
||||||
|
where,
|
||||||
|
);
|
||||||
|
inserted = ins;
|
||||||
|
if (!inserted) return null; // anchor not found -> skip the write
|
||||||
|
// Locate the freshly-inserted node to derive its "#<index>" handle. The
|
||||||
|
// just-uploaded attachmentId is unique, so it identifies our node.
|
||||||
|
if (Array.isArray(nd.content)) {
|
||||||
|
insertedIndex = nd.content.findIndex(
|
||||||
|
(b: any) =>
|
||||||
|
b &&
|
||||||
|
b.type === "drawio" &&
|
||||||
|
b.attrs &&
|
||||||
|
b.attrs.attachmentId === att.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return nd;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!inserted) {
|
||||||
|
const anchorDesc = where.anchorNodeId
|
||||||
|
? `anchorNodeId "${where.anchorNodeId}"`
|
||||||
|
: `anchorText "${where.anchorText}"`;
|
||||||
|
throw new Error(
|
||||||
|
`drawioCreate: anchor not found (${anchorDesc}) on page ${pageId}. The diagram attachment ${att.id} is now an unreferenced orphan.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (insertedIndex < 0) {
|
||||||
|
// The node was inserted nested (e.g. inside a callout/table cell via an
|
||||||
|
// anchor), where "#<index>" — which addresses only top-level blocks —
|
||||||
|
// cannot reference it. drawio nodes carry no persisted id, so there is no
|
||||||
|
// stable handle for a nested diagram.
|
||||||
|
throw new Error(
|
||||||
|
`drawioCreate: the diagram was inserted on page ${pageId} but not as a ` +
|
||||||
|
`top-level block, so it has no addressable "#<index>" handle. Anchor ` +
|
||||||
|
`on a top-level block (or append) so the diagram can be re-read.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The returned handle is POSITIONAL ("#<index>"): valid for the immediate
|
||||||
|
// create -> get/update flow, but re-resolve via getOutline if the document
|
||||||
|
// structure changes (blocks added/removed before it shift the index).
|
||||||
|
const nodeId = `#${insertedIndex}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
nodeId,
|
||||||
|
attachmentId: att.id,
|
||||||
|
warnings: prepared.warnings,
|
||||||
|
verify: mutation.verify,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-replacement update of a drawio diagram. `baseHash` is MANDATORY: it is
|
||||||
|
* compared against the hash of the diagram's CURRENT XML (from drawioGet);
|
||||||
|
* any mismatch means a human or another agent edited the diagram after the
|
||||||
|
* read, so the write is refused with a conflict error. On success the new
|
||||||
|
* `.drawio.svg` is uploaded as a FRESH attachment (in-place byte overwrite is
|
||||||
|
* avoided — some Docmost versions corrupt an attachment on overwrite, exactly
|
||||||
|
* as replaceImage documents) and the node is repointed with new dimensions.
|
||||||
|
*/
|
||||||
|
async drawioUpdate(
|
||||||
|
pageId: string,
|
||||||
|
node: string,
|
||||||
|
xml: string,
|
||||||
|
baseHash: string,
|
||||||
|
layout?: "elk",
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
nodeId: string;
|
||||||
|
attachmentId: string;
|
||||||
|
warnings: string[];
|
||||||
|
verify?: any;
|
||||||
|
}> {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
if (typeof baseHash !== "string" || baseHash.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
"drawioUpdate: baseHash is mandatory — read the diagram with drawioGet first and pass back its meta.hash",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the node and read the CURRENT diagram to enforce the optimistic
|
||||||
|
// lock before doing any write or upload.
|
||||||
|
const { node: drawio, ref } = await this.resolveDrawioNode(pageId, node);
|
||||||
|
const oldAttrs = drawio.attrs || {};
|
||||||
|
const oldSrc = oldAttrs.src;
|
||||||
|
// The returned handle is the caller-supplied reference. drawio nodes carry
|
||||||
|
// no persisted id, so `ref` (an "#<index>" or a rare legacy attrs.id) is the
|
||||||
|
// honest identifier to hand back.
|
||||||
|
const nodeId = oldAttrs.id ?? ref;
|
||||||
|
if (!oldSrc) {
|
||||||
|
throw new Error(
|
||||||
|
`drawioUpdate: node "${node}" on page ${pageId} has no src to compare against`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const currentSvg = await this.fetchAttachmentText(oldSrc);
|
||||||
|
const currentHash = mxHash(decodeDrawioSvg(currentSvg));
|
||||||
|
if (currentHash !== baseHash) {
|
||||||
|
throw new Error(
|
||||||
|
`drawioUpdate: conflict — the diagram changed since it was read ` +
|
||||||
|
`(baseHash ${baseHash} != current ${currentHash}). Re-read it with drawioGet and retry.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional server-side ELK auto-layout (best-effort; see drawioCreate).
|
||||||
|
const laidOutXml = layout === "elk" ? await applyElkLayout(xml) : xml;
|
||||||
|
// Pipeline for the new content (throws a structured DrawioLintError).
|
||||||
|
const prepared = prepareModel(laidOutXml);
|
||||||
|
const inner = renderDiagramShapes(prepared.cells, prepared.bbox);
|
||||||
|
const diagramTitle = oldAttrs.title || "Page-1";
|
||||||
|
const svg = buildDrawioSvg(prepared.modelXml, inner, prepared.bbox, diagramTitle);
|
||||||
|
|
||||||
|
const att = await this.uploadAttachmentBuffer(
|
||||||
|
pageId,
|
||||||
|
Buffer.from(svg, "utf-8"),
|
||||||
|
"diagram.drawio.svg",
|
||||||
|
"image/svg+xml",
|
||||||
|
);
|
||||||
|
const newSrc = `/api/files/${att.id}/${att.fileName}`;
|
||||||
|
|
||||||
|
const collabToken = await this.getCollabTokenWithReauth();
|
||||||
|
const pageUuid = await this.resolvePageId(pageId);
|
||||||
|
|
||||||
|
let repointed = 0;
|
||||||
|
const repoint = (n: any) => {
|
||||||
|
n.attrs = {
|
||||||
|
...n.attrs,
|
||||||
|
src: newSrc,
|
||||||
|
attachmentId: att.id,
|
||||||
|
width: prepared.bbox.width,
|
||||||
|
height: prepared.bbox.height,
|
||||||
|
};
|
||||||
|
repointed++;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mutation = await this.mutatePage(
|
||||||
|
pageUuid,
|
||||||
|
collabToken,
|
||||||
|
this.apiUrl,
|
||||||
|
(liveDoc) => {
|
||||||
|
repointed = 0;
|
||||||
|
const doc =
|
||||||
|
liveDoc && liveDoc.type === "doc"
|
||||||
|
? liveDoc
|
||||||
|
: { type: "doc", content: [] };
|
||||||
|
if (!Array.isArray(doc.content)) doc.content = [];
|
||||||
|
// Repoint ONLY the resolved node — never every node that happens to
|
||||||
|
// share this attachmentId (a copied diagram is two nodes with one
|
||||||
|
// attachmentId; keying on it would clobber both). Re-resolve the same
|
||||||
|
// handle against the live doc and walk to its exact position.
|
||||||
|
const hit = getNodeByRef(doc, ref);
|
||||||
|
if (!hit || hit.type !== "drawio") return null; // vanished/changed -> skip
|
||||||
|
let target: any = doc;
|
||||||
|
for (const idx of hit.path) {
|
||||||
|
if (!target || !Array.isArray(target.content)) {
|
||||||
|
target = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
target = target.content[idx];
|
||||||
|
}
|
||||||
|
if (!target || target.type !== "drawio") return null;
|
||||||
|
repoint(target);
|
||||||
|
if (repointed === 0) return null; // node vanished concurrently -> skip
|
||||||
|
return doc;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (repointed === 0) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
nodeId,
|
||||||
|
attachmentId: att.id,
|
||||||
|
warnings: [
|
||||||
|
...prepared.warnings,
|
||||||
|
"target drawio node was removed concurrently; uploaded attachment is unreferenced",
|
||||||
|
],
|
||||||
|
verify: mutation.verify,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
nodeId,
|
||||||
|
attachmentId: att.id,
|
||||||
|
warnings: prepared.warnings,
|
||||||
|
verify: mutation.verify,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- draw.io high-level semantic tools (issue #425) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID-based targeted edits of an existing drawio diagram (add / update / delete
|
||||||
|
* cells) instead of resending the whole XML. Reads the CURRENT diagram, checks
|
||||||
|
* the optimistic lock (`baseHash` is MANDATORY, exactly as drawioUpdate), applies
|
||||||
|
* the operations to the parsed model (a `delete` CASCADES to container children
|
||||||
|
* and to every edge whose source/target is deleted), then runs the SAME #423
|
||||||
|
* pipeline as drawioUpdate (lint + quality warnings -> preview -> attachment ->
|
||||||
|
* repoint the node). Ids are stable so diffs stay meaningful across edits.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- draw.io high-level semantic tools (issue #425) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID-based targeted edits of an existing drawio diagram (add / update / delete
|
||||||
|
* cells) instead of resending the whole XML. Reads the CURRENT diagram, checks
|
||||||
|
* the optimistic lock (`baseHash` is MANDATORY, exactly as drawioUpdate), applies
|
||||||
|
* the operations to the parsed model (a `delete` CASCADES to container children
|
||||||
|
* and to every edge whose source/target is deleted), then runs the SAME #423
|
||||||
|
* pipeline as drawioUpdate (lint + quality warnings -> preview -> attachment ->
|
||||||
|
* repoint the node). Ids are stable so diffs stay meaningful across edits.
|
||||||
|
*/
|
||||||
|
async drawioEditCells(
|
||||||
|
pageId: string,
|
||||||
|
node: string,
|
||||||
|
operations: CellOp[],
|
||||||
|
baseHash: string,
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
nodeId: string;
|
||||||
|
attachmentId: string;
|
||||||
|
warnings: string[];
|
||||||
|
verify?: any;
|
||||||
|
}> {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
if (typeof baseHash !== "string" || baseHash.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
"drawioEditCells: baseHash is mandatory — read the diagram with drawioGet first and pass back its meta.hash",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(operations) || operations.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
"drawioEditCells: operations must be a non-empty array of { op, ... }",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { node: drawio, ref } = await this.resolveDrawioNode(pageId, node);
|
||||||
|
const oldAttrs = drawio.attrs || {};
|
||||||
|
const oldSrc = oldAttrs.src;
|
||||||
|
const nodeId = oldAttrs.id ?? ref;
|
||||||
|
if (!oldSrc) {
|
||||||
|
throw new Error(
|
||||||
|
`drawioEditCells: node "${node}" on page ${pageId} has no src to edit`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const currentSvg = await this.fetchAttachmentText(oldSrc);
|
||||||
|
const currentModel = decodeDrawioSvg(currentSvg);
|
||||||
|
const currentHash = mxHash(currentModel);
|
||||||
|
if (currentHash !== baseHash) {
|
||||||
|
throw new Error(
|
||||||
|
`drawioEditCells: conflict — the diagram changed since it was read ` +
|
||||||
|
`(baseHash ${baseHash} != current ${currentHash}). Re-read it with drawioGet and retry.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the operations to the parsed model, then run the standard pipeline.
|
||||||
|
const editedModel = applyCellOps(currentModel, operations);
|
||||||
|
const prepared = prepareModel(editedModel);
|
||||||
|
const inner = renderDiagramShapes(prepared.cells, prepared.bbox);
|
||||||
|
const diagramTitle = oldAttrs.title || "Page-1";
|
||||||
|
const svg = buildDrawioSvg(prepared.modelXml, inner, prepared.bbox, diagramTitle);
|
||||||
|
|
||||||
|
const att = await this.uploadAttachmentBuffer(
|
||||||
|
pageId,
|
||||||
|
Buffer.from(svg, "utf-8"),
|
||||||
|
"diagram.drawio.svg",
|
||||||
|
"image/svg+xml",
|
||||||
|
);
|
||||||
|
const newSrc = `/api/files/${att.id}/${att.fileName}`;
|
||||||
|
|
||||||
|
const collabToken = await this.getCollabTokenWithReauth();
|
||||||
|
const pageUuid = await this.resolvePageId(pageId);
|
||||||
|
|
||||||
|
let repointed = 0;
|
||||||
|
const mutation = await this.mutatePage(
|
||||||
|
pageUuid,
|
||||||
|
collabToken,
|
||||||
|
this.apiUrl,
|
||||||
|
(liveDoc) => {
|
||||||
|
repointed = 0;
|
||||||
|
const doc =
|
||||||
|
liveDoc && liveDoc.type === "doc" ? liveDoc : { type: "doc", content: [] };
|
||||||
|
if (!Array.isArray(doc.content)) doc.content = [];
|
||||||
|
const hit = getNodeByRef(doc, ref);
|
||||||
|
if (!hit || hit.type !== "drawio") return null;
|
||||||
|
let target: any = doc;
|
||||||
|
for (const idx of hit.path) {
|
||||||
|
if (!target || !Array.isArray(target.content)) {
|
||||||
|
target = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
target = target.content[idx];
|
||||||
|
}
|
||||||
|
if (!target || target.type !== "drawio") return null;
|
||||||
|
target.attrs = {
|
||||||
|
...target.attrs,
|
||||||
|
src: newSrc,
|
||||||
|
attachmentId: att.id,
|
||||||
|
width: prepared.bbox.width,
|
||||||
|
height: prepared.bbox.height,
|
||||||
|
};
|
||||||
|
repointed++;
|
||||||
|
return doc;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (repointed === 0) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
nodeId,
|
||||||
|
attachmentId: att.id,
|
||||||
|
warnings: [
|
||||||
|
...prepared.warnings,
|
||||||
|
"target drawio node was removed concurrently; uploaded attachment is unreferenced",
|
||||||
|
],
|
||||||
|
verify: mutation.verify,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
nodeId,
|
||||||
|
attachmentId: att.id,
|
||||||
|
warnings: prepared.warnings,
|
||||||
|
verify: mutation.verify,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The main high-level tool: build a diagram from a SEMANTIC graph (nodes with
|
||||||
|
* a `kind`/`icon`, groups, edges) — the model never supplies coordinates or
|
||||||
|
* style strings. The server resolves icons via the shape catalog (#424),
|
||||||
|
* assigns palette colors from the preset, runs ELK layered layout (honouring
|
||||||
|
* `direction` and the `layer`/`sameLayerAs`/`pinned` hints and compound groups),
|
||||||
|
* and assembles linter-clean XML, then inserts it through the SAME create
|
||||||
|
* pipeline as drawioCreate. `layout:"incremental"` is only meaningful when a
|
||||||
|
* target `node` is given (it preserves that diagram's existing coordinates and
|
||||||
|
* places only new cells); on a fresh insert it behaves like "full".
|
||||||
|
*/
|
||||||
|
async drawioFromGraph(
|
||||||
|
pageId: string,
|
||||||
|
where: {
|
||||||
|
position: "before" | "after" | "append";
|
||||||
|
anchorNodeId?: string;
|
||||||
|
anchorText?: string;
|
||||||
|
},
|
||||||
|
graph: Graph,
|
||||||
|
direction?: "LR" | "RL" | "TB" | "BT",
|
||||||
|
preset?: string,
|
||||||
|
layout?: GraphLayoutMode,
|
||||||
|
node?: string,
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
nodeId: string;
|
||||||
|
attachmentId: string;
|
||||||
|
warnings: string[];
|
||||||
|
iconsResolved: number;
|
||||||
|
iconsMissing: string[];
|
||||||
|
verify?: any;
|
||||||
|
}> {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
// Direction/preset supplied as separate params override the graph fields so
|
||||||
|
// both the flat tool schema and an inline graph can set them.
|
||||||
|
const merged: Graph = {
|
||||||
|
...graph,
|
||||||
|
direction: direction ?? graph.direction,
|
||||||
|
preset: preset ?? graph.preset,
|
||||||
|
};
|
||||||
|
const mode: GraphLayoutMode = layout ?? "full";
|
||||||
|
|
||||||
|
// Incremental into an EXISTING node: read its coords so ELK preserves them,
|
||||||
|
// and keep the full existing model so incremental MERGES (never drops) any
|
||||||
|
// cell the new graph doesn't re-list.
|
||||||
|
let existingCoords: Map<string, { x: number; y: number }> | undefined;
|
||||||
|
let existingModelXml: string | undefined;
|
||||||
|
let editExisting = false;
|
||||||
|
let baseHash: string | undefined;
|
||||||
|
if (node && (mode === "incremental" || mode === "none")) {
|
||||||
|
const { node: drawio } = await this.resolveDrawioNode(pageId, node);
|
||||||
|
const src = (drawio.attrs || {}).src;
|
||||||
|
if (src) {
|
||||||
|
const svg = await this.fetchAttachmentText(src);
|
||||||
|
const model = decodeDrawioSvg(svg);
|
||||||
|
baseHash = mxHash(model);
|
||||||
|
existingModelXml = model;
|
||||||
|
existingCoords = new Map();
|
||||||
|
for (const c of parseDrawioCells(model)) {
|
||||||
|
if (c.vertex && c.geometry.x != null && c.geometry.y != null) {
|
||||||
|
existingCoords.set(c.id, { x: c.geometry.x, y: c.geometry.y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
editExisting = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const built = await buildFromGraph(
|
||||||
|
merged,
|
||||||
|
mode,
|
||||||
|
existingCoords,
|
||||||
|
existingModelXml,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (editExisting && node && baseHash) {
|
||||||
|
// Re-target the existing diagram: replace it with the assembled model.
|
||||||
|
const res = await this.drawioUpdate(pageId, node, built.modelXml, baseHash);
|
||||||
|
return {
|
||||||
|
...res,
|
||||||
|
iconsResolved: built.iconsResolved,
|
||||||
|
iconsMissing: built.iconsMissing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await this.drawioCreate(pageId, where, built.modelXml);
|
||||||
|
return {
|
||||||
|
...res,
|
||||||
|
iconsResolved: built.iconsResolved,
|
||||||
|
iconsMissing: built.iconsMissing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Mermaid `flowchart` to a redactable draw.io diagram via a PURE
|
||||||
|
* parser (no Electron / draw.io CLI): mermaid text -> graph-JSON -> the
|
||||||
|
* drawioFromGraph pipeline. Only `flowchart`/`graph` is supported (the most
|
||||||
|
* common wiki case); other diagram types throw a clear error so the model can
|
||||||
|
* fall back to drawioFromGraph.
|
||||||
|
*/
|
||||||
|
async drawioFromMermaid(
|
||||||
|
pageId: string,
|
||||||
|
where: {
|
||||||
|
position: "before" | "after" | "append";
|
||||||
|
anchorNodeId?: string;
|
||||||
|
anchorText?: string;
|
||||||
|
},
|
||||||
|
mermaid: string,
|
||||||
|
preset?: string,
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
nodeId: string;
|
||||||
|
attachmentId: string;
|
||||||
|
warnings: string[];
|
||||||
|
iconsResolved: number;
|
||||||
|
iconsMissing: string[];
|
||||||
|
verify?: any;
|
||||||
|
}> {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
const graph = mermaidToGraph(mermaid);
|
||||||
|
if (preset) graph.preset = preset;
|
||||||
|
return this.drawioFromGraph(pageId, where, graph, graph.direction, graph.preset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Page history / diff / transform ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List the saved versions (history snapshots) of a page, newest first.
|
||||||
|
* Docmost auto-snapshots on every save. Returns one cursor-paginated page of
|
||||||
|
* results: `{ items, nextCursor }`. The history record's id field is `id`.
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
return DrawioMixin;
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// Central REST error diagnostics (issues #437 + #450). SINGLE place that maps an
|
||||||
|
// axios error to the model-facing message. Extracted verbatim from client.ts;
|
||||||
|
// the constructor's response interceptor (see client/context.ts) routes every
|
||||||
|
// REST call through formatDocmostAxiosError so the whole surface is uniform.
|
||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
// --- Issue #437: central error diagnostics -------------------------------
|
||||||
|
// The agent only ever sees the thrown exception's `error.message`, so a failed
|
||||||
|
// tool must return an ACTIONABLE message (method, path, status, and the
|
||||||
|
// server's own validation text) instead of the opaque "Request failed with
|
||||||
|
// status code 400". These helpers + the response interceptor in the
|
||||||
|
// constructor are the single authoritative place that text is composed.
|
||||||
|
|
||||||
|
// Overall cap on the composed diagnostic message so the model context stays
|
||||||
|
// compact and a (whitelisted) server string can never blow up the text.
|
||||||
|
const ERROR_MESSAGE_CAP = 300;
|
||||||
|
// Only attempt to JSON.parse an arraybuffer body under this size: a larger
|
||||||
|
// binary body is never a JSON error envelope, so parsing it just wastes memory
|
||||||
|
// (fetchInternalFile uses responseType:"arraybuffer", so a failed file fetch
|
||||||
|
// carries the JSON error envelope as raw bytes here).
|
||||||
|
const ERROR_BUFFER_PARSE_CAP = 4096;
|
||||||
|
|
||||||
|
// Canonical 36-char UUID (8-4-4-4-12 hex). Deliberately version/variant-
|
||||||
|
// AGNOSTIC: the ids are UUIDv7 (e.g. 019f499a-9f8c-7d68-...), so only the
|
||||||
|
// canonical shape/length is enforced, not the version/variant nibble.
|
||||||
|
const FULL_UUID_RE =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throw an actionable error BEFORE any network call when `value` is not a full
|
||||||
|
* canonical UUID. Absorbs #436: a truncated/short comment id used to reach the
|
||||||
|
* server and bounce back as an opaque 400/404 the agent could not self-correct;
|
||||||
|
* failing fast here names the exact fix.
|
||||||
|
*/
|
||||||
|
export function assertFullUuid(
|
||||||
|
tool: string,
|
||||||
|
param: string,
|
||||||
|
value: string,
|
||||||
|
): void {
|
||||||
|
if (typeof value !== "string" || !FULL_UUID_RE.test(value)) {
|
||||||
|
throw new Error(
|
||||||
|
`${tool}: '${param}' must be the FULL comment UUID (36 chars, e.g. ` +
|
||||||
|
`019f499a-9f8c-7d68-b7be-ce100d7c6c56), got '${value}'. Copy the id ` +
|
||||||
|
`verbatim from listComments / createComment output.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep ONLY the pathname of a request (no host, no query string, no fragment)
|
||||||
|
// so the message never leaks a host or query params. Resolves a relative
|
||||||
|
// config.url against config.baseURL, then discards everything but the path.
|
||||||
|
function requestPath(config: any): string {
|
||||||
|
const rawUrl = typeof config?.url === "string" ? config.url : "";
|
||||||
|
const base =
|
||||||
|
typeof config?.baseURL === "string" ? config.baseURL : undefined;
|
||||||
|
try {
|
||||||
|
// A dummy base makes an absolute config.url parse too; its host is dropped.
|
||||||
|
return new URL(rawUrl, base ?? "http://localhost").pathname;
|
||||||
|
} catch {
|
||||||
|
// Malformed url: still strip any query/fragment manually.
|
||||||
|
return rawUrl.split(/[?#]/)[0] || rawUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose the server-facing message from `error.response.data`, using ONLY the
|
||||||
|
* whitelisted `message`/`error` fields or the HTTP statusText. SECURITY: the
|
||||||
|
* raw response body, headers (Authorization!) and config are NEVER read here —
|
||||||
|
* a string/HTML body (e.g. a proxy's 502 page) is deliberately dropped in
|
||||||
|
* favour of the statusText.
|
||||||
|
*/
|
||||||
|
function extractServerMessage(data: any, statusText: string): string {
|
||||||
|
// class-validator envelope: { message: string | string[], error?: string }.
|
||||||
|
if (
|
||||||
|
data &&
|
||||||
|
typeof data === "object" &&
|
||||||
|
!Buffer.isBuffer(data) &&
|
||||||
|
!(data instanceof ArrayBuffer)
|
||||||
|
) {
|
||||||
|
const msg = (data as any).message;
|
||||||
|
if (Array.isArray(msg)) {
|
||||||
|
const joined = msg.filter((m) => typeof m === "string").join("; ");
|
||||||
|
if (joined) return joined;
|
||||||
|
} else if (typeof msg === "string" && msg) {
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
const err = (data as any).error;
|
||||||
|
if (typeof err === "string" && err) return err;
|
||||||
|
return statusText;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffer / ArrayBuffer body: attempt a size-capped, guarded JSON.parse so a
|
||||||
|
// failed arraybuffer fetch still surfaces the server's validation text.
|
||||||
|
if (Buffer.isBuffer(data) || data instanceof ArrayBuffer) {
|
||||||
|
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||||
|
if (buf.length > 0 && buf.length <= ERROR_BUFFER_PARSE_CAP) {
|
||||||
|
try {
|
||||||
|
return extractServerMessage(JSON.parse(buf.toString("utf8")), statusText);
|
||||||
|
} catch {
|
||||||
|
return statusText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return statusText;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A raw string / HTML body is never surfaced (may echo server internals).
|
||||||
|
return statusText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reformat an AxiosError's `.message` IN PLACE into an actionable diagnostic:
|
||||||
|
* `<METHOD> <path> failed (<status> <statusText>): <serverMessage>`
|
||||||
|
* or, when the request never got a response:
|
||||||
|
* `<METHOD> <path> failed: <code> (no response from server)`.
|
||||||
|
*
|
||||||
|
* Mutates the SAME error object (never a custom subclass) so the live
|
||||||
|
* axios.isAxiosError / error.response?.status / config._retry checks around the
|
||||||
|
* client keep working, and sets `_docmostFormatted` as a double-processing
|
||||||
|
* guard. A no-op on a non-axios or already-formatted error.
|
||||||
|
*/
|
||||||
|
export function formatDocmostAxiosError(error: any): void {
|
||||||
|
if (!error || error._docmostFormatted) return;
|
||||||
|
if (!axios.isAxiosError(error)) return;
|
||||||
|
|
||||||
|
const config: any = error.config ?? {};
|
||||||
|
const method =
|
||||||
|
typeof config.method === "string" ? config.method.toUpperCase() : "";
|
||||||
|
const methodPath = `${method} ${requestPath(config)}`.trim();
|
||||||
|
const response = error.response;
|
||||||
|
|
||||||
|
let message: string;
|
||||||
|
if (response) {
|
||||||
|
const statusText =
|
||||||
|
typeof response.statusText === "string" ? response.statusText : "";
|
||||||
|
const serverMessage = extractServerMessage(response.data, statusText);
|
||||||
|
message = `${methodPath} failed (${response.status} ${statusText}): ${serverMessage}`;
|
||||||
|
// Full body only to stderr under DEBUG (parity with downloadImage).
|
||||||
|
if (process.env.DEBUG) {
|
||||||
|
console.error(
|
||||||
|
"Docmost request failed; response body:",
|
||||||
|
JSON.stringify(response.data),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No response at all (ECONNREFUSED / ETIMEDOUT / ECONNRESET / DNS / timeout).
|
||||||
|
// Use ONLY error.code, never the raw error.message: axios network messages
|
||||||
|
// embed host:port ("connect ECONNREFUSED 127.0.0.1:3000", "getaddrinfo
|
||||||
|
// ENOTFOUND host") and #437's invariant is that the host never reaches the
|
||||||
|
// model-visible message. code is set for essentially every real no-response
|
||||||
|
// error (ECONNREFUSED/ETIMEDOUT/ECONNRESET/ENOTFOUND/ECONNABORTED); the full
|
||||||
|
// native message still goes to stderr under DEBUG.
|
||||||
|
const reason = error.code ?? "network error";
|
||||||
|
message = `${methodPath} failed: ${reason} (no response from server)`;
|
||||||
|
if (process.env.DEBUG) {
|
||||||
|
console.error("Docmost request failed; no response:", error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.length > ERROR_MESSAGE_CAP) {
|
||||||
|
message = message.slice(0, ERROR_MESSAGE_CAP - 1) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
error.message = message;
|
||||||
|
(error as any)._docmostFormatted = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// Content-addressed LRU cache for the PM->Markdown conversion in getPage
|
||||||
|
// (issue #479). getPage is the dominant agent op (812 calls / 2h, p95 840ms);
|
||||||
|
// the bulk of its cost is convertProseMirrorToMarkdown — a full ProseMirror-tree
|
||||||
|
// walk over the page content (hundreds of KB of JSON on large pages) run on
|
||||||
|
// EVERY read. Since agents re-read far more than they write (812 reads vs 28
|
||||||
|
// writes in the sample), most conversions re-produce the SAME markdown from
|
||||||
|
// UNCHANGED content. This cache skips the recomputation on a hit.
|
||||||
|
//
|
||||||
|
// KEY = (pageId, updatedAt, optionsHash):
|
||||||
|
// - pageId: the page's CANONICAL UUID (resultData.id), not the agent-supplied
|
||||||
|
// slugId — so a slugId read and a UUID read of the same page share one entry.
|
||||||
|
// - updatedAt: comes from the SAME /pages/info response as `content`, so the
|
||||||
|
// two are mutually consistent; a changed page yields a new updatedAt -> a new
|
||||||
|
// key -> automatic, precise invalidation (no stale markdown is ever served).
|
||||||
|
// - optionsHash: a stable hash of the conversion options. getPage passes
|
||||||
|
// `{dropResolvedCommentAnchors:true}` while exportPageMarkdown passes the
|
||||||
|
// defaults (#328) — DIFFERENT output for the same content, so the options
|
||||||
|
// MUST be part of the key or a hit would serve the wrong variant.
|
||||||
|
//
|
||||||
|
// BOUNDS: evict the LEAST-recently-used entry when EITHER the entry count OR the
|
||||||
|
// total stored bytes would exceed its cap. Large pages are hundreds of KB, so a
|
||||||
|
// byte cap (not just a count cap) is what actually bounds memory. A Map iterates
|
||||||
|
// in insertion order, so the first key is the LRU entry; a hit re-inserts its key
|
||||||
|
// to move it to the most-recently-used end.
|
||||||
|
//
|
||||||
|
// This module is dependency-neutral (no axios/client/prom-client): a plain class
|
||||||
|
// the shared client context owns one instance of, so the cache persists across
|
||||||
|
// getPage calls on a single DocmostClient instance (built per user / per chat).
|
||||||
|
|
||||||
|
/** A stable, order-insensitive hash of the conversion options object. */
|
||||||
|
export function hashConvertOptions(options: unknown): string {
|
||||||
|
// JSON.stringify with SORTED keys makes the hash independent of key order, so
|
||||||
|
// {a:1,b:2} and {b:2,a:1} collapse to one entry. undefined/null options -> a
|
||||||
|
// fixed empty-object key, matching a caller that passes no options at all.
|
||||||
|
if (options === undefined || options === null) return "{}";
|
||||||
|
return stableStringify(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableStringify(value: any): string {
|
||||||
|
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||||
|
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
||||||
|
const keys = Object.keys(value).sort();
|
||||||
|
return `{${keys
|
||||||
|
.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`)
|
||||||
|
.join(",")}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
markdown: string;
|
||||||
|
bytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetPageCacheOptions {
|
||||||
|
/** Max number of entries before LRU eviction. Default 50. */
|
||||||
|
maxEntries?: number;
|
||||||
|
/** Max total stored bytes before LRU eviction. Default 10 MB. */
|
||||||
|
maxBytes?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetPageConversionCache {
|
||||||
|
private readonly maxEntries: number;
|
||||||
|
private readonly maxBytes: number;
|
||||||
|
// Insertion-ordered: the FIRST key is the least-recently-used entry.
|
||||||
|
private readonly map = new Map<string, CacheEntry>();
|
||||||
|
private totalBytes = 0;
|
||||||
|
|
||||||
|
constructor(opts: GetPageCacheOptions = {}) {
|
||||||
|
// A non-positive/NaN cap is treated as "use the default", never as an
|
||||||
|
// unbounded (or always-empty) cache — a silently unbounded cache would leak
|
||||||
|
// memory, and an always-empty one would defeat the whole optimization.
|
||||||
|
this.maxEntries =
|
||||||
|
Number.isFinite(opts.maxEntries) && (opts.maxEntries as number) > 0
|
||||||
|
? Math.floor(opts.maxEntries as number)
|
||||||
|
: 50;
|
||||||
|
this.maxBytes =
|
||||||
|
Number.isFinite(opts.maxBytes) && (opts.maxBytes as number) > 0
|
||||||
|
? Math.floor(opts.maxBytes as number)
|
||||||
|
: 10 * 1024 * 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compose the content-addressed key from its three parts. */
|
||||||
|
static key(pageId: string, updatedAt: string, optionsHash: string): string {
|
||||||
|
// A space separates the parts so no combination of values can collide by
|
||||||
|
// concatenation: a canonical UUID and an ISO updatedAt never contain a
|
||||||
|
// space, so the boundaries between the three parts are unambiguous.
|
||||||
|
return `${pageId} ${updatedAt} ${optionsHash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the cached markdown for `key`, or undefined on a miss. A hit moves
|
||||||
|
* the entry to the most-recently-used end (delete + re-set) so the LRU order
|
||||||
|
* reflects real access, not just insertion.
|
||||||
|
*/
|
||||||
|
get(key: string): string | undefined {
|
||||||
|
const entry = this.map.get(key);
|
||||||
|
if (entry === undefined) return undefined;
|
||||||
|
this.map.delete(key);
|
||||||
|
this.map.set(key, entry);
|
||||||
|
return entry.markdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store `markdown` under `key`, then evict LRU entries until BOTH caps hold.
|
||||||
|
* Re-storing an existing key refreshes its value and recency (its old bytes
|
||||||
|
* are subtracted first, so totalBytes stays exact).
|
||||||
|
*/
|
||||||
|
set(key: string, markdown: string): void {
|
||||||
|
// Byte size of the stored string (UTF-8). A single entry larger than the
|
||||||
|
// whole byte cap is still stored (so getPage always gets a hit next time),
|
||||||
|
// then the eviction loop below simply cannot shrink below it — accepted:
|
||||||
|
// one oversized page is bounded by the page itself, not a cache leak.
|
||||||
|
const bytes = Buffer.byteLength(markdown, "utf8");
|
||||||
|
const existing = this.map.get(key);
|
||||||
|
if (existing !== undefined) {
|
||||||
|
this.totalBytes -= existing.bytes;
|
||||||
|
this.map.delete(key);
|
||||||
|
}
|
||||||
|
this.map.set(key, { markdown, bytes });
|
||||||
|
this.totalBytes += bytes;
|
||||||
|
this.evict();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Evict the LRU entry until both the count and byte caps are satisfied. */
|
||||||
|
private evict(): void {
|
||||||
|
while (
|
||||||
|
this.map.size > this.maxEntries ||
|
||||||
|
(this.totalBytes > this.maxBytes && this.map.size > 1)
|
||||||
|
) {
|
||||||
|
// The first key in insertion order is the least-recently-used.
|
||||||
|
const oldest = this.map.keys().next().value as string | undefined;
|
||||||
|
if (oldest === undefined) break;
|
||||||
|
const entry = this.map.get(oldest);
|
||||||
|
this.map.delete(oldest);
|
||||||
|
if (entry) this.totalBytes -= entry.bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current entry count (test/introspection). */
|
||||||
|
get size(): number {
|
||||||
|
return this.map.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current total stored bytes (test/introspection). */
|
||||||
|
get bytes(): number {
|
||||||
|
return this.totalBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,730 @@
|
|||||||
|
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
|
||||||
|
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
|
||||||
|
// changed to a mixin factory. See client/context.ts for the shared base.
|
||||||
|
import type { GConstructor, DocmostClientContext } from "./context.js";
|
||||||
|
import FormData from "form-data";
|
||||||
|
import axios, { AxiosInstance } from "axios";
|
||||||
|
import { basename, extname } from "path";
|
||||||
|
import {
|
||||||
|
updatePageContentRealtime,
|
||||||
|
replacePageContent,
|
||||||
|
markdownToProseMirror,
|
||||||
|
markdownToProseMirrorCanonical,
|
||||||
|
mutatePageContent,
|
||||||
|
assertYjsEncodable,
|
||||||
|
MutationResult,
|
||||||
|
} from "../lib/collaboration.js";
|
||||||
|
import { withPageLock, isUuid } from "../lib/page-lock.js";
|
||||||
|
import { diffDocs, summarizeChange } from "../lib/diff.js";
|
||||||
|
import {
|
||||||
|
blockText,
|
||||||
|
walk,
|
||||||
|
getList,
|
||||||
|
insertMarkerAfter,
|
||||||
|
setCalloutRange,
|
||||||
|
noteItem,
|
||||||
|
mdToInlineNodes,
|
||||||
|
commentsToFootnotes,
|
||||||
|
canonicalizeFootnotes,
|
||||||
|
insertInlineFootnote,
|
||||||
|
mergeFootnoteDefinitions,
|
||||||
|
} from "../lib/transforms.js";
|
||||||
|
|
||||||
|
// Supported image types, kept as two lookup tables so both a local file
|
||||||
|
// extension and a remote Content-Type can be mapped to the same canonical set.
|
||||||
|
const EXT_TO_MIME: Record<string, string> = {
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
};
|
||||||
|
const MIME_TO_EXT: Record<string, string> = {
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/gif": ".gif",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
"image/svg+xml": ".svg",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Public method surface of MediaMixin (issue #450) — a NAMED type so the factory
|
||||||
|
// return type is expressible in the emitted .d.ts (the anonymous mixin class
|
||||||
|
// carries the base's protected shared state, which would otherwise trip TS4094).
|
||||||
|
// Derived from the class below; `implements IMediaMixin` fails to compile on drift.
|
||||||
|
export interface IMediaMixin {
|
||||||
|
uploadImage(pageId: string, url: string): any;
|
||||||
|
insertImage(pageId: string, url: string, opts?: { align?: "left" | "center" | "right"; alt?: string; replaceText?: string; afterText?: string; }): any;
|
||||||
|
replaceImage(pageId: string, oldAttachmentId: string, url: string, opts?: { align?: "left" | "center" | "right"; alt?: string }): any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediaMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & IMediaMixin> & TBase {
|
||||||
|
abstract class MediaMixin extends Base implements IMediaMixin {
|
||||||
|
// --- Image upload / embedding ---
|
||||||
|
|
||||||
|
/** Map a Content-Type string to a supported MIME type, or null if unsupported. */
|
||||||
|
protected supportedImageMime(ct: string): string | null {
|
||||||
|
return MIME_TO_EXT[ct] ? ct : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a remote image from a caller-supplied URL and resolve its bytes,
|
||||||
|
* MIME and a filename.
|
||||||
|
*
|
||||||
|
* SSRF / RESOURCE TRUST BOUNDARY: the URL comes from the MCP caller and is
|
||||||
|
* fetched BY THE SERVER, so it must be guarded before and after the request.
|
||||||
|
* The guards mirror the local-file trust boundary in uploadImage:
|
||||||
|
* - scheme allowlist (http/https only) — rejects file:, data:, ftp:, etc.,
|
||||||
|
* so the caller cannot use this path to read local files or other schemes;
|
||||||
|
* - a size cap enforced both via axios maxContentLength/maxBodyLength AND a
|
||||||
|
* post-download buffer.length re-check (defends against a missing/lying
|
||||||
|
* Content-Length), so a huge response cannot exhaust memory;
|
||||||
|
* - a 30s timeout. The timeout matters because replaceImage holds the
|
||||||
|
* per-page lock across this upload, so a hung download would wedge the
|
||||||
|
* lock for that page.
|
||||||
|
* We deliberately do NOT block private IP ranges: the MCP caller is already
|
||||||
|
* trusted to read arbitrary host files via the filePath path, so the marginal
|
||||||
|
* trust granted by fetching internal URLs is comparable, and blocking would
|
||||||
|
* break legitimate internal-image use.
|
||||||
|
*/
|
||||||
|
protected async fetchRemoteImage(
|
||||||
|
url: string,
|
||||||
|
maxBytes: number,
|
||||||
|
): Promise<{ buffer: Buffer; mime: string; fileName: string }> {
|
||||||
|
// Scheme allowlist first — cheapest guard, and rejects non-http(s) schemes
|
||||||
|
// (file:, data:, ftp:, ...) before any network request is made.
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(url);
|
||||||
|
} catch (e: any) {
|
||||||
|
throw new Error(`Invalid image URL "${url}": ${e.message}`);
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||||
|
throw new Error(
|
||||||
|
`unsupported image URL scheme "${parsed.protocol}"; only http and https are allowed`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await axios.get(url, {
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
timeout: 30000,
|
||||||
|
maxContentLength: maxBytes,
|
||||||
|
maxBodyLength: maxBytes,
|
||||||
|
headers: { Accept: "image/*" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Keep the thrown message free of the raw response body (it may echo
|
||||||
|
// server internals); surface only status/statusText. The full body is
|
||||||
|
// logged under DEBUG for diagnostics.
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
if (process.env.DEBUG) {
|
||||||
|
console.error(
|
||||||
|
"Image download failed; response body:",
|
||||||
|
JSON.stringify(error.response?.data),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Image download failed for "${url}": ${error.response?.status ?? ""} ${error.response?.statusText ?? error.message}`.trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// axios returns an ArrayBuffer for responseType: "arraybuffer".
|
||||||
|
const buffer = Buffer.from(response.data);
|
||||||
|
// Re-check the size: maxContentLength relies on Content-Length, which may be
|
||||||
|
// absent or lie, so guard against the actual byte count too.
|
||||||
|
if (buffer.length === 0) {
|
||||||
|
throw new Error(`Empty image response from "${url}"`);
|
||||||
|
}
|
||||||
|
if (buffer.length > maxBytes) {
|
||||||
|
throw new Error(
|
||||||
|
`Image too large: ${buffer.length} bytes exceeds the ${maxBytes}-byte cap`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve MIME: prefer the response Content-Type (strip any "; charset=..."
|
||||||
|
// parameter, lowercase, trim) mapped through the supported set; if the
|
||||||
|
// header is generic/missing/unsupported, fall back to the URL path
|
||||||
|
// extension via the existing extension->MIME logic.
|
||||||
|
const rawCt = response.headers?.["content-type"];
|
||||||
|
let mime: string | null = null;
|
||||||
|
if (typeof rawCt === "string" && rawCt.length > 0) {
|
||||||
|
const ct = rawCt.split(";")[0].trim().toLowerCase();
|
||||||
|
mime = this.supportedImageMime(ct);
|
||||||
|
}
|
||||||
|
if (!mime) {
|
||||||
|
// Fall back to the URL path extension. Use the pathname so the query
|
||||||
|
// string never contaminates the extension lookup.
|
||||||
|
const ext = extname(parsed.pathname).toLowerCase();
|
||||||
|
mime = EXT_TO_MIME[ext] ?? null;
|
||||||
|
}
|
||||||
|
if (!mime) {
|
||||||
|
throw new Error(
|
||||||
|
`cannot determine supported image type for "${url}"; supported: png, jpg, jpeg, gif, webp, svg`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a filename from the URL path basename (ignore the query string),
|
||||||
|
// defaulting to "image" when empty, and ensure it ends with the canonical
|
||||||
|
// extension for the resolved MIME (append it when missing/mismatched).
|
||||||
|
const canonicalExt = MIME_TO_EXT[mime];
|
||||||
|
let fileName = basename(parsed.pathname) || "image";
|
||||||
|
if (extname(fileName).toLowerCase() !== canonicalExt) {
|
||||||
|
fileName += canonicalExt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { buffer, mime, fileName };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a Docmost ProseMirror image node from an uploaded attachment. */
|
||||||
|
protected buildImageNode(
|
||||||
|
att: { id: string; fileName: string; fileSize?: number },
|
||||||
|
align?: "left" | "center" | "right",
|
||||||
|
alt?: string,
|
||||||
|
): any {
|
||||||
|
// Clean file URL, matching Docmost's native behaviour. No cache-busting
|
||||||
|
// query: the server serves the bare URL correctly, and replacement creates
|
||||||
|
// a new attachment id (a new URL) which busts caches naturally.
|
||||||
|
const src = `/api/files/${att.id}/${att.fileName}`;
|
||||||
|
const node: any = {
|
||||||
|
type: "image",
|
||||||
|
attrs: {
|
||||||
|
src,
|
||||||
|
attachmentId: att.id,
|
||||||
|
// Default to null when the server omits fileSize so the attr is never
|
||||||
|
// undefined (undefined would be dropped on serialization / break the
|
||||||
|
// ProseMirror image schema which expects size present).
|
||||||
|
size: att.fileSize ?? null,
|
||||||
|
align: align || "center",
|
||||||
|
width: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (alt) node.attrs.alt = alt;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download a remote image from an http(s) URL and upload it as an attachment
|
||||||
|
* of a page, returning the attachment metadata plus a ready-to-insert
|
||||||
|
* ProseMirror image node. Local file paths are intentionally not supported:
|
||||||
|
* the MCP caller is a remote AI with no access to this server's filesystem.
|
||||||
|
*/
|
||||||
|
async uploadImage(pageId: string, url: string) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
|
||||||
|
const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MiB
|
||||||
|
|
||||||
|
// Fetch + validate the remote image (scheme allowlist, size cap, timeout).
|
||||||
|
// See fetchRemoteImage for the SSRF / resource trust boundary.
|
||||||
|
const fetched = await this.fetchRemoteImage(url, MAX_IMAGE_BYTES);
|
||||||
|
const fileBuffer = fetched.buffer;
|
||||||
|
const mime = fetched.mime;
|
||||||
|
const fileName = fetched.fileName;
|
||||||
|
|
||||||
|
// Build a FRESH FormData for every send attempt. A FormData body is a
|
||||||
|
// single-use stream that is CONSUMED on the first send, so it cannot be
|
||||||
|
// replayed by this.client's response interceptor (replaying a consumed
|
||||||
|
// stream fails with 'socket hang up'). Multipart re-auth is therefore done
|
||||||
|
// here with bare axios and an explicit one-shot 401/403 retry that rebuilds
|
||||||
|
// the body. Field order matters: text fields must precede the file part so
|
||||||
|
// the server reads them; the server always generates a fresh attachment id.
|
||||||
|
const buildForm = () => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("pageId", pageId);
|
||||||
|
form.append("file", fileBuffer, {
|
||||||
|
filename: fileName,
|
||||||
|
contentType: mime,
|
||||||
|
});
|
||||||
|
return form;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Local name distinct from the `url` parameter (the source image URL): this
|
||||||
|
// is the /files/upload endpoint we POST the multipart body to.
|
||||||
|
const uploadUrl = `${this.apiUrl}/files/upload`;
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
// Call buildForm() ONCE per attempt and reuse the instance for both
|
||||||
|
// getHeaders() and the body so the Content-Type boundary matches the body.
|
||||||
|
const form = buildForm();
|
||||||
|
// Read the Authorization header from this.client's defaults (set by
|
||||||
|
// login(), only ever deleted — never set to null) instead of building
|
||||||
|
// `Bearer ${this.token}`: a concurrent JSON 401 can null this.token
|
||||||
|
// mid-flight, which would otherwise produce a literal "Bearer null".
|
||||||
|
// ensureAuthenticated() above guarantees login() ran, so the default
|
||||||
|
// header exists here. A 60s timeout keeps a hung upload from wedging the
|
||||||
|
// per-page lock (replaceImage holds withPageLock across this call).
|
||||||
|
response = await axios.post(uploadUrl, form, {
|
||||||
|
headers: {
|
||||||
|
...form.getHeaders(),
|
||||||
|
Authorization: this.client.defaults.headers.common["Authorization"],
|
||||||
|
},
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// On an expired-token auth error, re-login and retry exactly once with a
|
||||||
|
// freshly-rebuilt FormData (the previous one was already consumed).
|
||||||
|
if (
|
||||||
|
axios.isAxiosError(error) &&
|
||||||
|
(error.response?.status === 401 || error.response?.status === 403)
|
||||||
|
) {
|
||||||
|
await this.login();
|
||||||
|
const form2 = buildForm();
|
||||||
|
response = await axios.post(uploadUrl, form2, {
|
||||||
|
headers: {
|
||||||
|
...form2.getHeaders(),
|
||||||
|
Authorization: this.client.defaults.headers.common["Authorization"],
|
||||||
|
},
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
} else if (axios.isAxiosError(error)) {
|
||||||
|
// Keep the thrown message free of the raw response body (it may echo
|
||||||
|
// request data or server internals); surface only status/statusText.
|
||||||
|
// The full body is logged under DEBUG for diagnostics.
|
||||||
|
if (process.env.DEBUG) {
|
||||||
|
console.error(
|
||||||
|
"Image upload failed; response body:",
|
||||||
|
JSON.stringify(error.response?.data),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Image upload failed: ${error.response?.status} ${error.response?.statusText}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The attachment may arrive bare or wrapped in a { data } envelope.
|
||||||
|
const att = response.data?.data ?? response.data;
|
||||||
|
if (!att?.id || !att?.fileName) {
|
||||||
|
throw new Error(
|
||||||
|
"Unexpected /files/upload response: " + JSON.stringify(response.data),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some Docmost versions omit fileSize from the upload response. Fall back
|
||||||
|
// to the fetched byte length (the bytes we just uploaded) so callers never
|
||||||
|
// get an undefined size.
|
||||||
|
const resolvedSize = att.fileSize ?? fileBuffer.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
attachmentId: att.id,
|
||||||
|
fileName: att.fileName,
|
||||||
|
fileSize: resolvedSize,
|
||||||
|
src: `/api/files/${att.id}/${att.fileName}`,
|
||||||
|
imageNode: this.buildImageNode({ ...att, fileSize: resolvedSize }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload an image from a web (http/https) URL and insert it into a page in
|
||||||
|
* one step.
|
||||||
|
* By default the image is appended at the end. With replaceText, the first
|
||||||
|
* top-level block whose text contains the string is replaced; with afterText,
|
||||||
|
* the image is inserted right after the first matching block. All other
|
||||||
|
* block ids are preserved (only one top-level block is added or swapped).
|
||||||
|
*/
|
||||||
|
async insertImage(
|
||||||
|
pageId: string,
|
||||||
|
url: string,
|
||||||
|
opts: {
|
||||||
|
align?: "left" | "center" | "right";
|
||||||
|
alt?: string;
|
||||||
|
replaceText?: string;
|
||||||
|
afterText?: string;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const up = await this.uploadImage(pageId, url);
|
||||||
|
// Reuse the node from uploadImage (clean /api/files/<id>/<file> src), then
|
||||||
|
// apply align/alt onto a shallow attrs copy.
|
||||||
|
const node: any = { ...up.imageNode, attrs: { ...up.imageNode.attrs } };
|
||||||
|
if (opts.align) node.attrs.align = opts.align;
|
||||||
|
if (opts.alt) node.attrs.alt = opts.alt;
|
||||||
|
|
||||||
|
const collabToken = await this.getCollabTokenWithReauth();
|
||||||
|
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||||
|
// uploadImage /files/upload call above keeps the agent-supplied id.
|
||||||
|
const pageUuid = await this.resolvePageId(pageId);
|
||||||
|
|
||||||
|
// Recursively collect the plain text of a top-level block.
|
||||||
|
const blockText = (n: any): string => {
|
||||||
|
let out = "";
|
||||||
|
if (n.type === "text") out += n.text || "";
|
||||||
|
for (const child of n.content || []) out += blockText(child);
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Insert into the LIVE synced document, not the debounced REST snapshot, so
|
||||||
|
// concurrent edits/comments/images are preserved and parallel insertImage
|
||||||
|
// calls (serialized by the per-page lock) each see the previous insertion.
|
||||||
|
let placement: "replaced" | "after" | "appended" | undefined;
|
||||||
|
const mutation = await mutatePageContent(
|
||||||
|
pageUuid,
|
||||||
|
collabToken,
|
||||||
|
this.apiUrl,
|
||||||
|
(liveDoc) => {
|
||||||
|
const doc =
|
||||||
|
liveDoc && liveDoc.type === "doc"
|
||||||
|
? liveDoc
|
||||||
|
: { type: "doc", content: [] };
|
||||||
|
if (!Array.isArray(doc.content)) doc.content = [];
|
||||||
|
|
||||||
|
if (opts.replaceText) {
|
||||||
|
// Ambiguity guard (mirrors editPageText): count matching top-level
|
||||||
|
// blocks first, so a non-unique fragment cannot silently replace the
|
||||||
|
// wrong block (e.g. text that also appears inside a callout/table).
|
||||||
|
const matches = doc.content.filter((b: any) =>
|
||||||
|
blockText(b).includes(opts.replaceText!),
|
||||||
|
);
|
||||||
|
if (matches.length === 0) {
|
||||||
|
throw new Error(`replaceText not found: "${opts.replaceText}"`);
|
||||||
|
}
|
||||||
|
if (matches.length > 1) {
|
||||||
|
throw new Error(
|
||||||
|
`replaceText "${opts.replaceText}" matches ${matches.length} blocks; use a longer unique fragment`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const idx = doc.content.findIndex((b: any) =>
|
||||||
|
blockText(b).includes(opts.replaceText!),
|
||||||
|
);
|
||||||
|
// Data-loss guard: replaceText swaps the WHOLE top-level block, so if
|
||||||
|
// the fragment only appears nested inside a container (table, callout,
|
||||||
|
// list, blockquote) the entire structure would be destroyed. Refuse
|
||||||
|
// when the matched block is a container rather than a leaf
|
||||||
|
// paragraph/heading and point the caller at a safer tool.
|
||||||
|
const CONTAINER_TYPES = new Set([
|
||||||
|
"table",
|
||||||
|
"callout",
|
||||||
|
"bulletList",
|
||||||
|
"orderedList",
|
||||||
|
"taskList",
|
||||||
|
"blockquote",
|
||||||
|
]);
|
||||||
|
const matchedBlock = doc.content[idx];
|
||||||
|
if (matchedBlock && CONTAINER_TYPES.has(matchedBlock.type)) {
|
||||||
|
throw new Error(
|
||||||
|
`replaceText matched a ${matchedBlock.type} container block; replacing it would destroy the whole structure. ` +
|
||||||
|
`Use afterText to insert near it, or updatePageJson for surgical edits.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
doc.content.splice(idx, 1, node);
|
||||||
|
placement = "replaced";
|
||||||
|
} else if (opts.afterText) {
|
||||||
|
// Ambiguity guard (mirrors editPageText): refuse a non-unique fragment.
|
||||||
|
const matches = doc.content.filter((b: any) =>
|
||||||
|
blockText(b).includes(opts.afterText!),
|
||||||
|
);
|
||||||
|
if (matches.length === 0) {
|
||||||
|
throw new Error(`afterText not found: "${opts.afterText}"`);
|
||||||
|
}
|
||||||
|
if (matches.length > 1) {
|
||||||
|
throw new Error(
|
||||||
|
`afterText "${opts.afterText}" matches ${matches.length} blocks; use a longer unique fragment`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const idx = doc.content.findIndex((b: any) =>
|
||||||
|
blockText(b).includes(opts.afterText!),
|
||||||
|
);
|
||||||
|
doc.content.splice(idx + 1, 0, node);
|
||||||
|
placement = "after";
|
||||||
|
} else {
|
||||||
|
doc.content.push(node);
|
||||||
|
placement = "appended";
|
||||||
|
}
|
||||||
|
|
||||||
|
return doc;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
pageId,
|
||||||
|
attachmentId: up.attachmentId,
|
||||||
|
src: up.src,
|
||||||
|
placement,
|
||||||
|
verify: mutation.verify,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace an existing image in a page with a new image fetched from a web
|
||||||
|
* (http/https) URL. Uploads the new file as a brand-new attachment, which
|
||||||
|
* yields a fresh clean URL that both renders correctly and busts browser
|
||||||
|
* caches (the URL changed). Finds every image node
|
||||||
|
* whose attrs.attachmentId === oldAttachmentId (recursively, incl. nodes nested
|
||||||
|
* in callouts/tables) and repoints its src/attachmentId/size, preserving
|
||||||
|
* comments, alignment and alt. Operates on the live collab document so comments
|
||||||
|
* and concurrent edits are preserved. Throws if no matching image is found.
|
||||||
|
*
|
||||||
|
* The OLD attachment is left in place as an unreferenced orphan: Docmost
|
||||||
|
* exposes NO HTTP API to delete a single content attachment (verified against
|
||||||
|
* the attachment controller/service and by probing the live API — deletion
|
||||||
|
* happens only by cascade when the page, space or user is removed). This is the
|
||||||
|
* same outcome as Docmost's own editor when an image is removed/replaced.
|
||||||
|
* In-place byte overwrite is deliberately NOT used because some Docmost
|
||||||
|
* versions corrupt the attachment (HTTP 500) when its bytes are overwritten.
|
||||||
|
*/
|
||||||
|
async replaceImage(
|
||||||
|
pageId: string,
|
||||||
|
oldAttachmentId: string,
|
||||||
|
url: string,
|
||||||
|
opts: { align?: "left" | "center" | "right"; alt?: string } = {},
|
||||||
|
) {
|
||||||
|
const collabToken = await this.getCollabTokenWithReauth();
|
||||||
|
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||||
|
// page lock must ALSO key on the UUID so this operation serializes against
|
||||||
|
// other writes to the same page (mutatePageContent now locks by the resolved
|
||||||
|
// UUID too); locking by the raw slugId here would desync the mutex key and
|
||||||
|
// reopen the TOCTOU/orphan-attachment window the lock closes. uploadImage
|
||||||
|
// keeps the agent-supplied id (it hits REST, not the collab doc).
|
||||||
|
const pageUuid = await this.resolvePageId(pageId);
|
||||||
|
|
||||||
|
// Hold ONE per-page lock for the WHOLE operation (scan -> upload -> write).
|
||||||
|
// Previously the scan and the write were two separate mutatePageContent
|
||||||
|
// calls, each acquiring + releasing the lock, with the upload happening in
|
||||||
|
// the UNLOCKED gap between them. A concurrent op could interleave there: it
|
||||||
|
// could remove the target image so the write pass matches nothing, leaving
|
||||||
|
// the freshly-uploaded attachment as an un-deletable orphan (Docmost has no
|
||||||
|
// API to delete a single content attachment). Acquiring the lock once and
|
||||||
|
// using the non-locking collab helper inside (the per-page mutex is NOT
|
||||||
|
// reentrant, so the self-locking mutatePageContent would deadlock here)
|
||||||
|
// closes that TOCTOU window. uploadImage hits /files/upload over plain HTTP
|
||||||
|
// and does not touch the page lock, so it is safe to call while held.
|
||||||
|
return withPageLock(pageUuid, async () => {
|
||||||
|
// STEP 1: read-only live check. Scan the live document for any image node
|
||||||
|
// matching oldAttachmentId BEFORE uploading anything, so a wrong/stale id
|
||||||
|
// throws without ever creating an orphan attachment.
|
||||||
|
let matchFound = false;
|
||||||
|
const scan = (nodes: any[]) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (!node) continue;
|
||||||
|
if (
|
||||||
|
node.type === "image" &&
|
||||||
|
node.attrs &&
|
||||||
|
node.attrs.attachmentId === oldAttachmentId
|
||||||
|
) {
|
||||||
|
matchFound = true;
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.content)) scan(node.content);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.mutateLiveContentUnlocked(pageUuid, collabToken, (liveDoc) => {
|
||||||
|
matchFound = false; // reset per-transform (collab may retry the read).
|
||||||
|
const doc =
|
||||||
|
liveDoc && liveDoc.type === "doc"
|
||||||
|
? liveDoc
|
||||||
|
: { type: "doc", content: [] };
|
||||||
|
if (Array.isArray(doc.content)) scan(doc.content);
|
||||||
|
return null; // read-only: never write on the check pass.
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!matchFound) {
|
||||||
|
throw new Error(
|
||||||
|
`replaceImage: no image with attachmentId "${oldAttachmentId}" found on page ${pageId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// STEP 2: a match exists — upload the new file as a FRESH attachment (new
|
||||||
|
// id, new clean URL) and repoint every matching node in a second pass.
|
||||||
|
// Still inside the SAME lock, so no other op can have changed the page
|
||||||
|
// since the scan.
|
||||||
|
const up = await this.uploadImage(pageId, url);
|
||||||
|
|
||||||
|
let replaced = 0;
|
||||||
|
|
||||||
|
// Swap the source of one image node, preserving align/alt/title/geometry.
|
||||||
|
const repoint = (node: any) => {
|
||||||
|
node.attrs = {
|
||||||
|
...node.attrs,
|
||||||
|
src: up.src,
|
||||||
|
attachmentId: up.attachmentId,
|
||||||
|
// Default to null when fileSize is unknown so the attr is never
|
||||||
|
// undefined.
|
||||||
|
size: up.fileSize ?? null,
|
||||||
|
};
|
||||||
|
if (opts.align) node.attrs.align = opts.align;
|
||||||
|
if (opts.alt !== undefined) node.attrs.alt = opts.alt;
|
||||||
|
replaced++;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recursively repoint every image node (incl. ones nested in callouts/tables).
|
||||||
|
const walk = (nodes: any[]) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (!node) continue;
|
||||||
|
if (
|
||||||
|
node.type === "image" &&
|
||||||
|
node.attrs &&
|
||||||
|
node.attrs.attachmentId === oldAttachmentId
|
||||||
|
) {
|
||||||
|
repoint(node);
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.content)) walk(node.content);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const mutation = await this.mutateLiveContentUnlocked(
|
||||||
|
pageUuid,
|
||||||
|
collabToken,
|
||||||
|
(liveDoc) => {
|
||||||
|
// Reset per-transform so collab retries recompute cleanly (no double-count).
|
||||||
|
replaced = 0;
|
||||||
|
const doc =
|
||||||
|
liveDoc && liveDoc.type === "doc"
|
||||||
|
? liveDoc
|
||||||
|
: { type: "doc", content: [] };
|
||||||
|
if (!Array.isArray(doc.content)) doc.content = [];
|
||||||
|
walk(doc.content);
|
||||||
|
if (replaced === 0) return null; // no match -> skip the write entirely
|
||||||
|
return doc;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// KNOWN LIMITATION: a same-count image SRC swap (image count unchanged, no
|
||||||
|
// text/mark change) may still report verify.changed === false, because the
|
||||||
|
// text+marks+integrity-count model in summarizeChange does not inspect
|
||||||
|
// image `src`/attachmentId attributes. That is acceptable here — the
|
||||||
|
// replace is confirmed by `replaced` below, and verify is supplementary.
|
||||||
|
|
||||||
|
if (replaced === 0) {
|
||||||
|
// The pass-1 SCAN found the target (matchFound was true) and we already
|
||||||
|
// uploaded the new attachment, but pass-2 matched nothing — a concurrent
|
||||||
|
// editor must have removed the node between the two passes. Do NOT throw
|
||||||
|
// here (that would leak the just-uploaded attachment AND report failure);
|
||||||
|
// instead report success with the upload flagged as an unreferenced
|
||||||
|
// orphan so the caller knows. (The early throw above still covers the
|
||||||
|
// case where pass-1 finds nothing, before any upload happens.)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
replaced: 0,
|
||||||
|
pageId,
|
||||||
|
oldAttachmentId,
|
||||||
|
newAttachmentId: up.attachmentId,
|
||||||
|
src: up.src,
|
||||||
|
orphanedAttachmentId: up.attachmentId,
|
||||||
|
warning:
|
||||||
|
"target image was removed concurrently; uploaded attachment is unreferenced",
|
||||||
|
verify: mutation.verify,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
pageId,
|
||||||
|
replaced,
|
||||||
|
oldAttachmentId,
|
||||||
|
newAttachmentId: up.attachmentId,
|
||||||
|
src: up.src,
|
||||||
|
verify: mutation.verify,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- draw.io diagrams (issue #423) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a ready-made byte buffer as a page attachment via the same
|
||||||
|
* multipart /files/upload endpoint uploadImage uses. Split out as its own
|
||||||
|
* (overridable) seam so drawioCreate/update can upload the generated
|
||||||
|
* `.drawio.svg` without going through the URL-fetch path, and so tests can
|
||||||
|
* stub the network. Mirrors uploadImage's fresh-FormData + one-shot 401/403
|
||||||
|
* re-auth handling (a FormData body is single-use, so it must be rebuilt per
|
||||||
|
* attempt).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- draw.io diagrams (issue #423) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a ready-made byte buffer as a page attachment via the same
|
||||||
|
* multipart /files/upload endpoint uploadImage uses. Split out as its own
|
||||||
|
* (overridable) seam so drawioCreate/update can upload the generated
|
||||||
|
* `.drawio.svg` without going through the URL-fetch path, and so tests can
|
||||||
|
* stub the network. Mirrors uploadImage's fresh-FormData + one-shot 401/403
|
||||||
|
* re-auth handling (a FormData body is single-use, so it must be rebuilt per
|
||||||
|
* attempt).
|
||||||
|
*/
|
||||||
|
protected async uploadAttachmentBuffer(
|
||||||
|
pageId: string,
|
||||||
|
buffer: Buffer,
|
||||||
|
fileName: string,
|
||||||
|
mime: string,
|
||||||
|
): Promise<{ id: string; fileName: string; fileSize: number }> {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
const buildForm = () => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("pageId", pageId);
|
||||||
|
form.append("file", buffer, { filename: fileName, contentType: mime });
|
||||||
|
return form;
|
||||||
|
};
|
||||||
|
const uploadUrl = `${this.apiUrl}/files/upload`;
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
const form = buildForm();
|
||||||
|
response = await axios.post(uploadUrl, form, {
|
||||||
|
headers: {
|
||||||
|
...form.getHeaders(),
|
||||||
|
Authorization: this.client.defaults.headers.common["Authorization"],
|
||||||
|
},
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
axios.isAxiosError(error) &&
|
||||||
|
(error.response?.status === 401 || error.response?.status === 403)
|
||||||
|
) {
|
||||||
|
await this.login();
|
||||||
|
const form2 = buildForm();
|
||||||
|
response = await axios.post(uploadUrl, form2, {
|
||||||
|
headers: {
|
||||||
|
...form2.getHeaders(),
|
||||||
|
Authorization: this.client.defaults.headers.common["Authorization"],
|
||||||
|
},
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
} else if (axios.isAxiosError(error)) {
|
||||||
|
if (process.env.DEBUG) {
|
||||||
|
console.error(
|
||||||
|
"Attachment upload failed; response body:",
|
||||||
|
JSON.stringify(error.response?.data),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Attachment upload failed: ${error.response?.status} ${error.response?.statusText}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const att = response.data?.data ?? response.data;
|
||||||
|
if (!att?.id || !att?.fileName) {
|
||||||
|
throw new Error(
|
||||||
|
"Unexpected /files/upload response: " + JSON.stringify(response.data),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: att.id,
|
||||||
|
fileName: att.fileName,
|
||||||
|
fileSize: att.fileSize ?? buffer.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a stored `.drawio.svg` attachment as text. Overridable seam over
|
||||||
|
* fetchInternalFile (the authed loopback fetch, which also rejects any
|
||||||
|
* traversal/SSRF src) so drawioGet/update can read the current diagram and
|
||||||
|
* tests can stub the bytes.
|
||||||
|
*/
|
||||||
|
protected async fetchAttachmentText(src: string): Promise<string> {
|
||||||
|
const { buffer } = await this.fetchInternalFile(src);
|
||||||
|
return buffer.toString("utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a drawio node on a page by `attrs.id` or `#<index>` and return the
|
||||||
|
* node plus its ref. Throws a clear error if the ref does not resolve to a
|
||||||
|
* drawio node.
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
return MediaMixin;
|
||||||
|
}
|
||||||
@@ -0,0 +1,700 @@
|
|||||||
|
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
|
||||||
|
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
|
||||||
|
// changed to a mixin factory. See client/context.ts for the shared base.
|
||||||
|
import type { GConstructor, DocmostClientContext } from "./context.js";
|
||||||
|
import {
|
||||||
|
updatePageContentRealtime,
|
||||||
|
replacePageContent,
|
||||||
|
markdownToProseMirror,
|
||||||
|
markdownToProseMirrorCanonical,
|
||||||
|
mutatePageContent,
|
||||||
|
assertYjsEncodable,
|
||||||
|
MutationResult,
|
||||||
|
} from "../lib/collaboration.js";
|
||||||
|
import {
|
||||||
|
replaceNodeById,
|
||||||
|
replaceNodeByIdWithMany,
|
||||||
|
reassignCollidingBlockIds,
|
||||||
|
deleteNodeById,
|
||||||
|
assertUnambiguousMatch,
|
||||||
|
insertNodeRelative,
|
||||||
|
insertNodesRelative,
|
||||||
|
blockPlainText,
|
||||||
|
buildOutline,
|
||||||
|
getNodeByRef,
|
||||||
|
readTable,
|
||||||
|
insertTableRow,
|
||||||
|
deleteTableRow,
|
||||||
|
updateTableCell,
|
||||||
|
findInvalidNode,
|
||||||
|
} from "@docmost/prosemirror-markdown";
|
||||||
|
import {
|
||||||
|
importMarkdownFragment,
|
||||||
|
canBeDocChild,
|
||||||
|
findUnrepresentableTableAttrs,
|
||||||
|
} from "../lib/markdown-fragment.js";
|
||||||
|
import {
|
||||||
|
applyTextEdits,
|
||||||
|
TextEdit,
|
||||||
|
TextEditResult,
|
||||||
|
TextEditFailure,
|
||||||
|
} from "../lib/json-edit.js";
|
||||||
|
import {
|
||||||
|
blockText,
|
||||||
|
walk,
|
||||||
|
getList,
|
||||||
|
insertMarkerAfter,
|
||||||
|
setCalloutRange,
|
||||||
|
noteItem,
|
||||||
|
mdToInlineNodes,
|
||||||
|
commentsToFootnotes,
|
||||||
|
canonicalizeFootnotes,
|
||||||
|
insertInlineFootnote,
|
||||||
|
mergeFootnoteDefinitions,
|
||||||
|
} from "../lib/transforms.js";
|
||||||
|
import { normalizeAndMergeFootnotes } from "../lib/footnote-normalize-merge.js";
|
||||||
|
|
||||||
|
// Public method surface of NodesWriteMixin (issue #450) — a NAMED type so the factory
|
||||||
|
// return type is expressible in the emitted .d.ts (the anonymous mixin class
|
||||||
|
// carries the base's protected shared state, which would otherwise trip TS4094).
|
||||||
|
// Derived from the class below; `implements INodesWriteMixin` fails to compile on drift.
|
||||||
|
export interface INodesWriteMixin {
|
||||||
|
updatePageJson(pageId: string, doc?: any, title?: string): any;
|
||||||
|
editPageText(pageId: string, edits: TextEdit[]): any;
|
||||||
|
patchNode(pageId: string, nodeId: string, input: { markdown?: string; node?: any }): any;
|
||||||
|
insertNode(pageId: string, input: { markdown?: string; node?: any }, opts: { position: "before" | "after" | "append"; anchorNodeId?: string; anchorText?: string; }): any;
|
||||||
|
deleteNode(pageId: string, nodeId: string): any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodesWriteMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & INodesWriteMixin> & TBase {
|
||||||
|
abstract class NodesWriteMixin extends Base implements INodesWriteMixin {
|
||||||
|
/**
|
||||||
|
* Replace page content with a raw ProseMirror JSON document (lossless) and/or
|
||||||
|
* update its title. Both `doc` and `title` are optional, but at least one must
|
||||||
|
* be supplied:
|
||||||
|
* - `doc` provided -> validate + full-overwrite the body (and update the
|
||||||
|
* title too when `title` is also given).
|
||||||
|
* - `doc` omitted, `title` given -> title-only update; the body is NOT
|
||||||
|
* touched/resent (no collab write happens).
|
||||||
|
* - neither given -> throws (nothing to update).
|
||||||
|
*/
|
||||||
|
async updatePageJson(pageId: string, doc?: any, title?: string) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
|
||||||
|
// Title-only / no-op handling: when no document is supplied, do NOT write
|
||||||
|
// the body. Update the title if one was given; otherwise there is nothing
|
||||||
|
// to do, so fail loudly rather than silently no-op.
|
||||||
|
if (doc == null) {
|
||||||
|
if (!title) {
|
||||||
|
throw new Error(
|
||||||
|
"updatePageJson: nothing to update (provide content and/or title)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.client.post("/pages/update", { pageId, title });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
modified: true,
|
||||||
|
message: "Page title updated (content left unchanged).",
|
||||||
|
pageId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the document shape before a full overwrite: a malformed doc
|
||||||
|
// would otherwise silently corrupt the page (full-overwrite is the
|
||||||
|
// documented behaviour; no optimistic-concurrency is applied here).
|
||||||
|
if (
|
||||||
|
typeof doc !== "object" ||
|
||||||
|
doc.type !== "doc" ||
|
||||||
|
!Array.isArray(doc.content)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'content must be a ProseMirror document ({"type":"doc","content":[...]}) ' +
|
||||||
|
"where content is an array of nodes each having a string `type`",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurse the WHOLE document so a malformed nested node (e.g. a node with a
|
||||||
|
// non-string type, a non-array content/marks, or a text node missing its
|
||||||
|
// string text) is rejected up front rather than silently corrupting the
|
||||||
|
// page on overwrite.
|
||||||
|
this.validateDocStructure(doc);
|
||||||
|
|
||||||
|
// #409: beyond the string-`type` check above, reject a nested node whose
|
||||||
|
// `type` is a string but NOT a known Docmost schema node (a typo/unknown
|
||||||
|
// block) — the same `Unknown node type` the encoder throws — with a rich,
|
||||||
|
// path-anchored message, still BEFORE any collab connection.
|
||||||
|
this.assertValidNodeShape("updatePageJson", doc);
|
||||||
|
|
||||||
|
// Sanitize URLs before writing. This closes the JSON-path bypass: unlike
|
||||||
|
// the markdown link path (which TipTap sanitizes), raw JSON could otherwise
|
||||||
|
// inject javascript:/data: link hrefs or media srcs straight into the doc.
|
||||||
|
this.validateDocUrls(doc);
|
||||||
|
|
||||||
|
// Canonicalize footnotes (idempotent): an agent-authored JSON doc cannot
|
||||||
|
// leave footnotes out of order, orphaned, or in multiple lists — the bottom
|
||||||
|
// list + numbering are always derived from reference order. No-op when the
|
||||||
|
// footnotes are already canonical.
|
||||||
|
// #419: normalize + merge glyph-forked definitions before canonicalizing.
|
||||||
|
doc = normalizeAndMergeFootnotes(doc);
|
||||||
|
doc = canonicalizeFootnotes(doc);
|
||||||
|
|
||||||
|
// Write the BODY first, then the title (#159 split-brain): a failed body
|
||||||
|
// write (e.g. persist timeout) must not leave a new title over the old body.
|
||||||
|
const collabToken = await this.getCollabTokenWithReauth();
|
||||||
|
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||||
|
const pageUuid = await this.resolvePageId(pageId);
|
||||||
|
const mutation = await this.replacePage(
|
||||||
|
pageUuid,
|
||||||
|
doc,
|
||||||
|
collabToken,
|
||||||
|
this.apiUrl,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Body persisted successfully — now it is safe to set the title.
|
||||||
|
if (title) {
|
||||||
|
await this.client.post("/pages/update", { pageId, title });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
modified: true,
|
||||||
|
message: "Page content replaced from ProseMirror JSON.",
|
||||||
|
pageId,
|
||||||
|
verify: mutation.verify,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AUTHOR-INLINE footnote insertion. The agent supplies only WHERE
|
||||||
|
* (`anchorText`, a snippet of body text to attach the marker after) and WHAT
|
||||||
|
* (`text`, the footnote content as markdown). Numbering and the bottom
|
||||||
|
* `footnotesList` are derived deterministically server-side
|
||||||
|
* (`insertInlineFootnote` -> `canonicalizeFootnotes`): the agent never sees,
|
||||||
|
* assigns, or edits a footnote number or the list, so it CANNOT desync.
|
||||||
|
*
|
||||||
|
* Content DEDUP: when an existing definition has the same content, its id is
|
||||||
|
* reused (one number, one definition, several references). The write is atomic
|
||||||
|
* via `mutatePageContent` (single-writer, page-locked); if the anchor text is
|
||||||
|
* not found the transform aborts with a clear error and no write happens.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surgical text edits: find/replace inside text nodes of the live
|
||||||
|
* document. Preserves all block ids, marks, callouts and tables.
|
||||||
|
*/
|
||||||
|
async editPageText(pageId: string, edits: TextEdit[]) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
|
||||||
|
const collabToken = await this.getCollabTokenWithReauth();
|
||||||
|
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||||
|
const pageUuid = await this.resolvePageId(pageId);
|
||||||
|
|
||||||
|
// Apply the edits against the LIVE synced document, not the debounced REST
|
||||||
|
// snapshot, so concurrent human edits/comments are preserved. applyTextEdits
|
||||||
|
// records per-edit match problems in `failed` instead of throwing, and
|
||||||
|
// applies whatever it can; we abort the write only when nothing applied.
|
||||||
|
let results: TextEditResult[] | undefined;
|
||||||
|
let failed: TextEditFailure[] | undefined;
|
||||||
|
// Whether we actually wrote new content. Set inside the transform: a
|
||||||
|
// degenerate edit (e.g. find === replace, or a batch that nets to no change)
|
||||||
|
// can "apply" yet leave the document byte-for-byte identical, in which case
|
||||||
|
// we must NOT write (no spurious history version) and must not claim a write
|
||||||
|
// happened.
|
||||||
|
let wrote = false;
|
||||||
|
const mutation = await mutatePageContent(
|
||||||
|
pageUuid,
|
||||||
|
collabToken,
|
||||||
|
this.apiUrl,
|
||||||
|
(liveDoc) => {
|
||||||
|
wrote = false;
|
||||||
|
const r = applyTextEdits(liveDoc, edits);
|
||||||
|
results = r.results;
|
||||||
|
failed = r.failed;
|
||||||
|
// Nothing applied -> abort the write (mutatePageContent treats a null
|
||||||
|
// return from the transform as "write nothing").
|
||||||
|
if (r.results.length === 0) return null;
|
||||||
|
// Edits "applied" but produced an identical document: skip the write so
|
||||||
|
// no new history version is created. Stable structural comparison via
|
||||||
|
// JSON.stringify (both docs come from the same deep-copied source, so
|
||||||
|
// key order is stable).
|
||||||
|
if (JSON.stringify(r.doc) === JSON.stringify(liveDoc)) return null;
|
||||||
|
wrote = true;
|
||||||
|
return r.doc;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((results?.length ?? 0) === 0 && (failed?.length ?? 0) > 0) {
|
||||||
|
// No edit applied: surface an aggregated, actionable error so the caller
|
||||||
|
// does not mistake a no-op for a partial success.
|
||||||
|
throw new Error(
|
||||||
|
"editPageText: no edits were applied (nothing written). " +
|
||||||
|
failed!.map((f) => `"${f.find}": ${f.reason}`).join("; "),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edits matched but produced no content change (identical document): report
|
||||||
|
// a successful no-op — NOT a failure — and do not falsely claim a write.
|
||||||
|
if (!wrote) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
pageId,
|
||||||
|
applied: results,
|
||||||
|
failed,
|
||||||
|
message: "No changes written (edits produced identical content).",
|
||||||
|
verify: mutation.verify,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: any = {
|
||||||
|
success: true,
|
||||||
|
pageId,
|
||||||
|
applied: results,
|
||||||
|
failed,
|
||||||
|
message:
|
||||||
|
(failed?.length ?? 0)
|
||||||
|
? `Applied ${results?.length ?? 0} edit(s); ${failed!.length} failed (see failed[]). Node ids and formatting preserved.`
|
||||||
|
: "Text edits applied (node ids and formatting preserved).",
|
||||||
|
verify: mutation.verify,
|
||||||
|
};
|
||||||
|
|
||||||
|
// If any applied edit matched only after stripping markdown (the
|
||||||
|
// normalized fallback), warn that editPageText preserved existing marks
|
||||||
|
// and did NOT change formatting — so a caller who intended a formatting
|
||||||
|
// change is pointed at patchNode.
|
||||||
|
if (results?.some((r) => r.normalized === true)) {
|
||||||
|
result.warning =
|
||||||
|
"Some edits matched only after stripping markdown from your find string; " +
|
||||||
|
"editPageText preserved existing marks (it did not change bold/strike/etc.). " +
|
||||||
|
"If you intended a formatting change, use patchNode.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace the block whose attrs.id === nodeId. Operates on the LIVE collab
|
||||||
|
* document so comments and concurrent edits are preserved.
|
||||||
|
*
|
||||||
|
* 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,
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
protected 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`",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Preserve the block id WITHOUT mutating the caller's object: build a local
|
||||||
|
// copy whose attrs.id === nodeId (so the swapped-in node keeps the id of the
|
||||||
|
// node it replaces).
|
||||||
|
const target = {
|
||||||
|
...node,
|
||||||
|
attrs: {
|
||||||
|
...(node.attrs && typeof node.attrs === "object" ? node.attrs : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (target.attrs.id == null) {
|
||||||
|
target.attrs.id = nodeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// #409: fail fast on a malformed node SHAPE (a nested child with an
|
||||||
|
// absent/unknown `type`, e.g. a text leaf written as `{"text":"foo"}` with
|
||||||
|
// no `"type":"text"`) BEFORE opening a collab session or taking the page
|
||||||
|
// lock — the root-only `typeof node.type === "string"` check above never
|
||||||
|
// sees nested children, and the encoder's `Unknown node type: undefined`
|
||||||
|
// would otherwise only surface after the connection.
|
||||||
|
this.assertValidNodeShape("patchNode", target);
|
||||||
|
|
||||||
|
const collabToken = await this.getCollabTokenWithReauth();
|
||||||
|
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||||
|
const pageUuid = await this.resolvePageId(pageId);
|
||||||
|
|
||||||
|
// Track the replacement count in an outer var, reset per-transform, so a
|
||||||
|
// collab retry recomputes it cleanly (mirrors replaceImage's pattern).
|
||||||
|
let replaced = 0;
|
||||||
|
const mutation = await mutatePageContent(
|
||||||
|
pageUuid,
|
||||||
|
collabToken,
|
||||||
|
this.apiUrl,
|
||||||
|
(liveDoc) => {
|
||||||
|
replaced = 0;
|
||||||
|
const { doc: nd, replaced: r } = replaceNodeById(
|
||||||
|
liveDoc,
|
||||||
|
nodeId,
|
||||||
|
target,
|
||||||
|
);
|
||||||
|
replaced = r;
|
||||||
|
// 0 matches -> skip the write. >1 matches -> the id is AMBIGUOUS: Docmost
|
||||||
|
// duplicates block ids on copy/paste (and copyPageContent writes them
|
||||||
|
// verbatim), so replacing "the node with id X" would silently clobber
|
||||||
|
// EVERY duplicate (#159). Refuse: skip the write and throw below so the
|
||||||
|
// model re-targets with a more specific anchor instead of corrupting the
|
||||||
|
// page. Only an unambiguous single match is written.
|
||||||
|
if (replaced !== 1) return null;
|
||||||
|
return nd;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 0 -> "no node"; >1 -> "ambiguous, refused" (the transform already skipped
|
||||||
|
// the write for any count !== 1). Single shared guard (#159, #185 review).
|
||||||
|
assertUnambiguousMatch("patchNode", "replace", replaced, nodeId, pageId);
|
||||||
|
|
||||||
|
return { success: true, replaced, nodeId, verify: mutation.verify };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
protected 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 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,
|
||||||
|
input: { markdown?: string; node?: any },
|
||||||
|
opts: {
|
||||||
|
position: "before" | "after" | "append";
|
||||||
|
anchorNodeId?: string;
|
||||||
|
anchorText?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
|
||||||
|
// 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: 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" &&
|
||||||
|
opts.position !== "after" &&
|
||||||
|
opts.position !== "append")
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'insertNode: `position` must be one of "before", "after", "append"',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (opts.position === "before" || opts.position === "after") {
|
||||||
|
// before/after require EXACTLY ONE anchor (an id or a text fragment).
|
||||||
|
const hasId =
|
||||||
|
typeof opts.anchorNodeId === "string" && opts.anchorNodeId.length > 0;
|
||||||
|
const hasText =
|
||||||
|
typeof opts.anchorText === "string" && opts.anchorText.length > 0;
|
||||||
|
if (hasId === hasText) {
|
||||||
|
throw new Error(
|
||||||
|
`insertNode: position "${opts.position}" requires exactly one of anchorNodeId or anchorText`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
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).
|
||||||
|
const pageUuid = await this.resolvePageId(pageId);
|
||||||
|
|
||||||
|
// Track insertion in an outer var, reset per-transform, so a collab retry
|
||||||
|
// recomputes it cleanly (mirrors replaceImage's pattern).
|
||||||
|
let inserted = false;
|
||||||
|
const mutation = await mutatePageContent(
|
||||||
|
pageUuid,
|
||||||
|
collabToken,
|
||||||
|
this.apiUrl,
|
||||||
|
(liveDoc) => {
|
||||||
|
inserted = false;
|
||||||
|
// 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
|
||||||
|
// 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!inserted) {
|
||||||
|
const anchorDesc = opts.anchorNodeId
|
||||||
|
? `anchorNodeId "${opts.anchorNodeId}"`
|
||||||
|
: `anchorText "${opts.anchorText}"`;
|
||||||
|
// anchorText is matched against the block's literal RENDERED plain text;
|
||||||
|
// markdown/emoji are tolerated only as a strip-and-retry fallback, so a
|
||||||
|
// miss usually means the text differs from what's on the page.
|
||||||
|
const hint = opts.anchorText
|
||||||
|
? " anchorText must be the block's literal rendered plain text (no markdown wrappers or emoji); anchorNodeId from getPageJson is more reliable."
|
||||||
|
: "";
|
||||||
|
throw new Error(
|
||||||
|
`insertNode: anchor not found (${anchorDesc}) on page ${pageId}.${hint}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
inserted: true,
|
||||||
|
position: opts.position,
|
||||||
|
blocks: blocks.length,
|
||||||
|
verify: mutation.verify,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove EVERY node whose attrs.id === nodeId (recursively, including nodes
|
||||||
|
* nested in callouts/tables) from its parent content array. Operates on the
|
||||||
|
* LIVE collab document so comments and concurrent edits are preserved.
|
||||||
|
* Throws if no node matches.
|
||||||
|
*/
|
||||||
|
async deleteNode(pageId: string, nodeId: string) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
|
||||||
|
const collabToken = await this.getCollabTokenWithReauth();
|
||||||
|
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||||
|
const pageUuid = await this.resolvePageId(pageId);
|
||||||
|
|
||||||
|
// Track the deletion count in an outer var, reset per-transform, so a
|
||||||
|
// collab retry recomputes it cleanly (mirrors replaceImage's pattern).
|
||||||
|
let deleted = 0;
|
||||||
|
const mutation = await mutatePageContent(
|
||||||
|
pageUuid,
|
||||||
|
collabToken,
|
||||||
|
this.apiUrl,
|
||||||
|
(liveDoc) => {
|
||||||
|
deleted = 0;
|
||||||
|
const { doc: nd, deleted: d } = deleteNodeById(liveDoc, nodeId);
|
||||||
|
deleted = d;
|
||||||
|
// 0 matches -> skip the write. >1 matches -> the id is AMBIGUOUS (block
|
||||||
|
// ids are duplicated on copy/paste, #159): deleting "the node with id X"
|
||||||
|
// would silently remove EVERY duplicate. Refuse: skip the write and throw
|
||||||
|
// below so the model re-targets. Only an unambiguous single match is
|
||||||
|
// deleted.
|
||||||
|
if (deleted !== 1) return null;
|
||||||
|
return nd;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 0 -> "no node"; >1 -> "ambiguous, refused" (the transform already skipped
|
||||||
|
// the write for any count !== 1). Single shared guard (#159, #185 review).
|
||||||
|
assertUnambiguousMatch("deleteNode", "delete", deleted, nodeId, pageId);
|
||||||
|
|
||||||
|
return { success: true, deleted, nodeId, verify: mutation.verify };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the public share URL for a page. */
|
||||||
|
}
|
||||||
|
return NodesWriteMixin;
|
||||||
|
}
|
||||||
@@ -0,0 +1,655 @@
|
|||||||
|
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
|
||||||
|
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
|
||||||
|
// changed to a mixin factory. See client/context.ts for the shared base.
|
||||||
|
import type { GConstructor, DocmostClientContext } from "./context.js";
|
||||||
|
import FormData from "form-data";
|
||||||
|
import axios, { AxiosInstance } from "axios";
|
||||||
|
import { convertProseMirrorToMarkdown } from "../lib/markdown-converter.js";
|
||||||
|
import {
|
||||||
|
updatePageContentRealtime,
|
||||||
|
replacePageContent,
|
||||||
|
markdownToProseMirror,
|
||||||
|
markdownToProseMirrorCanonical,
|
||||||
|
mutatePageContent,
|
||||||
|
assertYjsEncodable,
|
||||||
|
MutationResult,
|
||||||
|
} from "../lib/collaboration.js";
|
||||||
|
import { footnoteWarningsField } from "../lib/footnote-analyze.js";
|
||||||
|
import {
|
||||||
|
serializeDocmostMarkdown,
|
||||||
|
parseDocmostMarkdown,
|
||||||
|
} from "../lib/markdown-document.js";
|
||||||
|
import { diffDocs, summarizeChange } from "../lib/diff.js";
|
||||||
|
import {
|
||||||
|
blockText,
|
||||||
|
walk,
|
||||||
|
getList,
|
||||||
|
insertMarkerAfter,
|
||||||
|
setCalloutRange,
|
||||||
|
noteItem,
|
||||||
|
mdToInlineNodes,
|
||||||
|
commentsToFootnotes,
|
||||||
|
canonicalizeFootnotes,
|
||||||
|
insertInlineFootnote,
|
||||||
|
mergeFootnoteDefinitions,
|
||||||
|
} from "../lib/transforms.js";
|
||||||
|
import { normalizeAndMergeFootnotes } from "../lib/footnote-normalize-merge.js";
|
||||||
|
import vm from "node:vm";
|
||||||
|
|
||||||
|
// Public method surface of PagesMixin (issue #450) — a NAMED type so the factory
|
||||||
|
// return type is expressible in the emitted .d.ts (the anonymous mixin class
|
||||||
|
// carries the base's protected shared state, which would otherwise trip TS4094).
|
||||||
|
// Derived from the class below; `implements IPagesMixin` fails to compile on drift.
|
||||||
|
export interface IPagesMixin {
|
||||||
|
createPage(title: string, content: string, spaceId: string, parentPageId?: string): any;
|
||||||
|
updatePage(pageId: string, content: string, title?: string): any;
|
||||||
|
renamePage(pageId: string, title: string): any;
|
||||||
|
movePage(pageId: string, parentPageId: string | null, position?: string): any;
|
||||||
|
deletePage(pageId: string): any;
|
||||||
|
sharePage(pageId: string, searchIndexing?: boolean): any;
|
||||||
|
listShares(): any;
|
||||||
|
unsharePage(pageId: string): any;
|
||||||
|
exportPageMarkdown(pageId: string): Promise<string>;
|
||||||
|
importPageMarkdown(pageId: string, fullMarkdown: string): Promise<any>;
|
||||||
|
copyPageContent(sourcePageId: string, targetPageId: string): any;
|
||||||
|
listPageHistory(pageId: string, cursor?: string): any;
|
||||||
|
getPageHistory(historyId: string): any;
|
||||||
|
restorePageVersion(historyId: string): any;
|
||||||
|
diffPageVersions(pageId: string, from?: string, to?: string): any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PagesMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & IPagesMixin> & TBase {
|
||||||
|
abstract class PagesMixin extends Base implements IPagesMixin {
|
||||||
|
/**
|
||||||
|
* Create a new page with title and content.
|
||||||
|
* Uses the /pages/import workaround (the only endpoint accepting content),
|
||||||
|
* then moves the page and restores the exact title: the import endpoint
|
||||||
|
* derives the title from the FILENAME and replaces spaces with
|
||||||
|
* underscores, so we explicitly re-set it via /pages/update afterwards.
|
||||||
|
*/
|
||||||
|
async createPage(
|
||||||
|
title: string,
|
||||||
|
content: string,
|
||||||
|
spaceId: string,
|
||||||
|
parentPageId?: string,
|
||||||
|
) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
|
||||||
|
if (parentPageId) {
|
||||||
|
try {
|
||||||
|
await this.getPage(parentPageId);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`Parent page with ID ${parentPageId} not found.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Create content via Import (using multipart/form-data).
|
||||||
|
// Build a FRESH FormData per send attempt: a FormData body is a single-use
|
||||||
|
// stream consumed on the first send, so it cannot be replayed by
|
||||||
|
// this.client's response interceptor (replay fails with 'socket hang up').
|
||||||
|
// Multipart re-auth is therefore done here with bare axios and an explicit
|
||||||
|
// one-shot 401/403 retry that rebuilds the body.
|
||||||
|
const fileContent = Buffer.from(content, "utf-8");
|
||||||
|
const buildForm = () => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("spaceId", spaceId);
|
||||||
|
form.append("file", fileContent, {
|
||||||
|
filename: `${title || "import"}.md`,
|
||||||
|
contentType: "text/markdown",
|
||||||
|
});
|
||||||
|
return form;
|
||||||
|
};
|
||||||
|
|
||||||
|
const importUrl = `${this.apiUrl}/pages/import`;
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
// Call buildForm() ONCE per attempt and reuse the instance for both
|
||||||
|
// getHeaders() and the body so the Content-Type boundary matches the body.
|
||||||
|
const form = buildForm();
|
||||||
|
// Read the Authorization header from this.client's defaults (set by
|
||||||
|
// login(), only ever deleted — never set to null) instead of building
|
||||||
|
// `Bearer ${this.token}`: a concurrent JSON 401 can null this.token
|
||||||
|
// mid-flight, which would otherwise produce a literal "Bearer null".
|
||||||
|
// ensureAuthenticated() above guarantees login() ran, so the default
|
||||||
|
// header exists here.
|
||||||
|
response = await axios.post(importUrl, form, {
|
||||||
|
headers: {
|
||||||
|
...form.getHeaders(),
|
||||||
|
Authorization: this.client.defaults.headers.common["Authorization"],
|
||||||
|
},
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// On an expired-token auth error, re-login and retry exactly once with a
|
||||||
|
// freshly-rebuilt FormData (the previous one was already consumed).
|
||||||
|
if (
|
||||||
|
axios.isAxiosError(error) &&
|
||||||
|
(error.response?.status === 401 || error.response?.status === 403)
|
||||||
|
) {
|
||||||
|
await this.login();
|
||||||
|
const form2 = buildForm();
|
||||||
|
response = await axios.post(importUrl, form2, {
|
||||||
|
headers: {
|
||||||
|
...form2.getHeaders(),
|
||||||
|
Authorization: this.client.defaults.headers.common["Authorization"],
|
||||||
|
},
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const newPageId = (response.data?.data ?? response.data).id;
|
||||||
|
|
||||||
|
// 2. Move to parent if needed
|
||||||
|
if (parentPageId) {
|
||||||
|
await this.movePage(newPageId, parentPageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Restore the exact title (import mangles spaces into underscores)
|
||||||
|
if (title) {
|
||||||
|
await this.client.post("/pages/update", { pageId: newPageId, title });
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = await this.getPage(newPageId);
|
||||||
|
// Surface non-fatal footnote problems (dangling refs, empty/duplicate
|
||||||
|
// definitions, markers in tables) so the agent can fix its markup (#166).
|
||||||
|
return { ...page, ...footnoteWarningsField(content) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a page's content from markdown and optionally its title.
|
||||||
|
* NOTE: full re-import — block ids regenerate. For surgical changes
|
||||||
|
* use editPageText / updatePageJson instead.
|
||||||
|
*/
|
||||||
|
async updatePage(pageId: string, content: string, title?: string) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
// Open the collab doc by the canonical UUID, never the slugId (#260). The
|
||||||
|
// REST /pages/update title write below keeps the agent-supplied id (the
|
||||||
|
// server resolves a slugId there).
|
||||||
|
const pageUuid = await this.resolvePageId(pageId);
|
||||||
|
|
||||||
|
// Write the BODY first, then the title (#159 split-brain). If the collab
|
||||||
|
// body write fails (e.g. a persist timeout), the title must be left
|
||||||
|
// UNTOUCHED so the page never ends up with a new title over its old body.
|
||||||
|
// A title write failing AFTER a successful body is rarer (REST is fast) and
|
||||||
|
// leaves correct content under a stale title — the lesser inconsistency.
|
||||||
|
let collabToken = "";
|
||||||
|
let mutation;
|
||||||
|
try {
|
||||||
|
collabToken = await this.getCollabTokenWithReauth();
|
||||||
|
mutation = await updatePageContentRealtime(
|
||||||
|
pageUuid,
|
||||||
|
content,
|
||||||
|
collabToken,
|
||||||
|
this.apiUrl,
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
// Verbose diagnostics (incl. anything that could expose a token prefix)
|
||||||
|
// are gated behind DEBUG; the thrown Error below carries no token data.
|
||||||
|
if (process.env.DEBUG) {
|
||||||
|
console.error(
|
||||||
|
"Failed to update page content via realtime collaboration:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
const tokenPreview = collabToken
|
||||||
|
? collabToken.substring(0, 15) + "..."
|
||||||
|
: "null";
|
||||||
|
console.error(`Collab token preview: ${tokenPreview}`);
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to update page content: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body persisted successfully — now it is safe to set the title.
|
||||||
|
if (title) {
|
||||||
|
await this.client.post("/pages/update", { pageId, title });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
modified: true,
|
||||||
|
message: "Page updated successfully.",
|
||||||
|
pageId: pageId,
|
||||||
|
verify: mutation.verify,
|
||||||
|
// Non-fatal footnote diagnostics (#166); omitted when there are none.
|
||||||
|
...footnoteWarningsField(content),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a URL string against a scheme allowlist for a given context.
|
||||||
|
*
|
||||||
|
* The markdown link path enforces safe schemes via TipTap, but the raw
|
||||||
|
* JSON path (updatePageJson) bypasses that — so this is the sanitization
|
||||||
|
* choke point for ProseMirror JSON written directly by the caller.
|
||||||
|
*
|
||||||
|
* - "link": reject javascript:, vbscript:, data: (any scheme that can
|
||||||
|
* execute or smuggle script when the href is clicked).
|
||||||
|
* - "src": allow only http(s):, mailto:, /api/files paths, or a
|
||||||
|
* scheme-less relative/absolute path; reject
|
||||||
|
* javascript:/vbscript:/data:/file:.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename a page (change its title only) without touching or resending its
|
||||||
|
* content. The slug is derived from the page record, not the body, so it is
|
||||||
|
* left intact too.
|
||||||
|
*/
|
||||||
|
async renamePage(pageId: string, title: string) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
await this.client.post("/pages/update", { pageId, title });
|
||||||
|
return { success: true, pageId, title };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy the WHOLE content of one page onto another, entirely server-side: the
|
||||||
|
* source's ProseMirror document is read and written verbatim onto the target
|
||||||
|
* via the live collab path, so the document never passes through the model.
|
||||||
|
*
|
||||||
|
* Only the target's BODY is replaced — its title and slug live on the page
|
||||||
|
* record (not in the content), so they are untouched. The source page is not
|
||||||
|
* modified at all.
|
||||||
|
*/
|
||||||
|
|
||||||
|
async movePage(
|
||||||
|
pageId: string,
|
||||||
|
parentPageId: string | null,
|
||||||
|
position?: string,
|
||||||
|
) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
// Docmost requires position >= 5 chars.
|
||||||
|
const validPosition = position || "a00000";
|
||||||
|
|
||||||
|
return this.client
|
||||||
|
.post("/pages/move", {
|
||||||
|
pageId,
|
||||||
|
parentPageId,
|
||||||
|
position: validPosition,
|
||||||
|
})
|
||||||
|
.then((res) => res.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async deletePage(pageId: string) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
return this.client
|
||||||
|
.post("/pages/delete", { pageId })
|
||||||
|
.then((res) => res.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Comment methods (ported from upstream PR #3 by Max Nikitin) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a comment's `content` into a ProseMirror doc object before
|
||||||
|
* markdown conversion. createComment/updateComment send content as a
|
||||||
|
* JSON.stringify(...) STRING, and the server stores it as-is, so on read it
|
||||||
|
* comes back as a string. convertProseMirrorToMarkdown returns "" for a
|
||||||
|
* string, so parse it first (guarded — fall back to the raw value on any
|
||||||
|
* parse failure so a non-JSON legacy value is still handled gracefully).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Share a page publicly (idempotent) and return the public URL. */
|
||||||
|
async sharePage(pageId: string, searchIndexing: boolean = true) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
const response = await this.client.post("/shares/create", {
|
||||||
|
pageId,
|
||||||
|
includeSubPages: false,
|
||||||
|
searchIndexing,
|
||||||
|
});
|
||||||
|
const share = response.data?.data ?? response.data;
|
||||||
|
const slugId = share.page?.slugId || (await this.getPageRaw(pageId)).slugId;
|
||||||
|
return {
|
||||||
|
shareId: share.id,
|
||||||
|
key: share.key,
|
||||||
|
pageId: share.pageId,
|
||||||
|
publicUrl: this.shareUrl(share.key, slugId),
|
||||||
|
searchIndexing: share.searchIndexing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List all public shares in the workspace with their URLs. */
|
||||||
|
|
||||||
|
/** Build the public share URL for a page. */
|
||||||
|
protected shareUrl(shareKey: string, slugId: string): string {
|
||||||
|
return `${this.appUrl}/share/${shareKey}/p/${slugId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Share a page publicly (idempotent) and return the public URL. */
|
||||||
|
|
||||||
|
/** List all public shares in the workspace with their URLs. */
|
||||||
|
async listShares() {
|
||||||
|
const shares = await this.paginateAll("/shares", {});
|
||||||
|
return shares.map((s: any) => ({
|
||||||
|
shareId: s.id,
|
||||||
|
key: s.key,
|
||||||
|
pageId: s.pageId,
|
||||||
|
pageTitle: s.page?.title,
|
||||||
|
publicUrl: s.page?.slugId ? this.shareUrl(s.key, s.page.slugId) : null,
|
||||||
|
searchIndexing: s.searchIndexing,
|
||||||
|
createdAt: s.createdAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove the public share of a page. */
|
||||||
|
async unsharePage(pageId: string) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
const shares = await this.listShares();
|
||||||
|
const share = shares.find((s: any) => s.pageId === pageId);
|
||||||
|
if (!share) {
|
||||||
|
throw new Error(`Page ${pageId} is not shared.`);
|
||||||
|
}
|
||||||
|
await this.client.post("/shares/delete", { shareId: share.shareId });
|
||||||
|
return { success: true, removedShareId: share.shareId, pageId };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export a page to a single self-contained Docmost-flavoured markdown file:
|
||||||
|
* meta block + body (with inline comment anchors + diagrams) + comment
|
||||||
|
* threads. Lossless round-trip target; see importPageMarkdown for the inverse.
|
||||||
|
*/
|
||||||
|
async exportPageMarkdown(pageId: string): Promise<string> {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
const page = await this.getPageRaw(pageId);
|
||||||
|
const body = page.content ? convertProseMirrorToMarkdown(page.content) : "";
|
||||||
|
let comments: any[] = [];
|
||||||
|
try {
|
||||||
|
// Lossless export: include RESOLVED threads so the export -> import
|
||||||
|
// round-trip preserves every comment. This is exactly why the active-only
|
||||||
|
// filter is an opt-in (default false) on listComments.
|
||||||
|
comments = (await this.listComments(pageId, true)).items;
|
||||||
|
} catch (e) {
|
||||||
|
// A comments fetch failure must not lose the body; export with [] and let
|
||||||
|
// the caller see the (empty) comments block. Log under DEBUG only.
|
||||||
|
if (process.env.DEBUG) console.error("export: listComments failed", e);
|
||||||
|
}
|
||||||
|
const meta = {
|
||||||
|
version: 1,
|
||||||
|
pageId: page.id,
|
||||||
|
slugId: page.slugId,
|
||||||
|
title: page.title,
|
||||||
|
spaceId: page.spaceId,
|
||||||
|
parentPageId: page.parentPageId ?? null,
|
||||||
|
};
|
||||||
|
return serializeDocmostMarkdown(meta, body, comments);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a self-contained Docmost markdown file back into a page. Parses out
|
||||||
|
* the meta + comments metadata blocks, converts the body to ProseMirror
|
||||||
|
* (restoring comment marks + diagrams from their inline HTML), and replaces
|
||||||
|
* the page content. Comment THREAD records are NOT written to the server in
|
||||||
|
* this version — they are preserved in the file and the inline marks are
|
||||||
|
* re-applied so the highlights survive; managing comment records stays with
|
||||||
|
* the comment tools/UI.
|
||||||
|
*/
|
||||||
|
async importPageMarkdown(pageId: string, fullMarkdown: string): Promise<any> {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
const { meta, body, comments } = parseDocmostMarkdown(fullMarkdown);
|
||||||
|
// PAGE import: canonicalize footnotes (see markdownToProseMirrorCanonical).
|
||||||
|
const doc = await markdownToProseMirrorCanonical(body);
|
||||||
|
const collabToken = await this.getCollabTokenWithReauth();
|
||||||
|
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||||
|
const pageUuid = await this.resolvePageId(pageId);
|
||||||
|
const mutation = await replacePageContent(
|
||||||
|
pageUuid,
|
||||||
|
doc,
|
||||||
|
collabToken,
|
||||||
|
this.apiUrl,
|
||||||
|
);
|
||||||
|
// Collect distinct comment ids that actually became comment marks in the doc.
|
||||||
|
const collectCommentIds = (node: any, acc: Set<string>): Set<string> => {
|
||||||
|
if (!node || typeof node !== "object") return acc;
|
||||||
|
if (Array.isArray(node.marks)) {
|
||||||
|
for (const mk of node.marks) {
|
||||||
|
if (mk && mk.type === "comment" && mk.attrs?.commentId) {
|
||||||
|
acc.add(mk.attrs.commentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.content)) {
|
||||||
|
for (const child of node.content) collectCommentIds(child, acc);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
};
|
||||||
|
// Count reflects the comment marks present in the written document, so an id
|
||||||
|
// that only appears as inert text (e.g. inside a fenced code block) is not
|
||||||
|
// counted because it never becomes a comment mark.
|
||||||
|
const anchoredIds = collectCommentIds(doc, new Set<string>());
|
||||||
|
const result: any = {
|
||||||
|
success: true,
|
||||||
|
pageId,
|
||||||
|
anchoredCommentCount: anchoredIds.size,
|
||||||
|
commentsInFile: Array.isArray(comments) ? comments.length : 0,
|
||||||
|
verify: mutation.verify,
|
||||||
|
};
|
||||||
|
// Warn (non-fatal) if the file was exported from a DIFFERENT page.
|
||||||
|
if (meta?.pageId && meta.pageId !== pageId) {
|
||||||
|
result.warning = `File was exported from page ${meta.pageId} but is being imported into ${pageId}.`;
|
||||||
|
}
|
||||||
|
// Non-fatal footnote diagnostics (#166), analyzed on the BODY (the part after
|
||||||
|
// the docmost:meta / docmost:comments blocks) — so a `[^x]`-like token inside
|
||||||
|
// those JSON blocks never produces a false warning, while real markers in the
|
||||||
|
// body do. `body` comes from parseDocmostMarkdown(fullMarkdown) above.
|
||||||
|
Object.assign(result, footnoteWarningsField(body));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename a page (change its title only) without touching or resending its
|
||||||
|
* content. The slug is derived from the page record, not the body, so it is
|
||||||
|
* left intact too.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy the WHOLE content of one page onto another, entirely server-side: the
|
||||||
|
* source's ProseMirror document is read and written verbatim onto the target
|
||||||
|
* via the live collab path, so the document never passes through the model.
|
||||||
|
*
|
||||||
|
* Only the target's BODY is replaced — its title and slug live on the page
|
||||||
|
* record (not in the content), so they are untouched. The source page is not
|
||||||
|
* modified at all.
|
||||||
|
*/
|
||||||
|
async copyPageContent(sourcePageId: string, targetPageId: string) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
|
||||||
|
// A self-copy would be a no-op overwrite; reject it explicitly so a caller
|
||||||
|
// mistake surfaces as a clear error rather than a silent round-trip.
|
||||||
|
if (sourcePageId === targetPageId) {
|
||||||
|
throw new Error(
|
||||||
|
"copyPageContent: sourcePageId and targetPageId are the same page (no-op copy)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = await this.getPageRaw(sourcePageId);
|
||||||
|
const content = source?.content;
|
||||||
|
if (
|
||||||
|
!content ||
|
||||||
|
typeof content !== "object" ||
|
||||||
|
content.type !== "doc" ||
|
||||||
|
!Array.isArray(content.content)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`copyPageContent: source page ${sourcePageId} has no usable ProseMirror content to copy`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defense-in-depth: run the same URL-scheme sanitizer the JSON write path
|
||||||
|
// uses, so copying never lands a javascript:/data: href/src on the target
|
||||||
|
// (parity with updatePageJson; harmless for already-stored source content).
|
||||||
|
this.validateDocUrls(content);
|
||||||
|
|
||||||
|
// Defense-in-depth (#228): this is a FULL-document write, so canonicalize
|
||||||
|
// footnotes before copying — a no-op on already-canonical source content, but
|
||||||
|
// it guarantees a copy can never propagate a non-canonical footnote topology
|
||||||
|
// to the target (parity with the other full-doc write paths).
|
||||||
|
// #419: normalize + merge glyph-forked definitions before canonicalizing.
|
||||||
|
const canonical = canonicalizeFootnotes(normalizeAndMergeFootnotes(content));
|
||||||
|
|
||||||
|
const collabToken = await this.getCollabTokenWithReauth();
|
||||||
|
// Open the TARGET collab doc by its canonical UUID, never the slugId (#260).
|
||||||
|
const targetUuid = await this.resolvePageId(targetPageId);
|
||||||
|
const mutation = await this.replacePage(
|
||||||
|
targetUuid,
|
||||||
|
canonical,
|
||||||
|
collabToken,
|
||||||
|
this.apiUrl,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
sourcePageId,
|
||||||
|
targetPageId,
|
||||||
|
copiedNodes: canonical.content.length,
|
||||||
|
verify: mutation.verify,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surgical text edits: find/replace inside text nodes of the live
|
||||||
|
* document. Preserves all block ids, marks, callouts and tables.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- Page history / diff / transform ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List the saved versions (history snapshots) of a page, newest first.
|
||||||
|
* Docmost auto-snapshots on every save. Returns one cursor-paginated page of
|
||||||
|
* results: `{ items, nextCursor }`. The history record's id field is `id`.
|
||||||
|
*/
|
||||||
|
async listPageHistory(pageId: string, cursor?: string) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
const payload: Record<string, any> = { pageId };
|
||||||
|
if (cursor) payload.cursor = cursor;
|
||||||
|
const response = await this.client.post("/pages/history", payload);
|
||||||
|
const data = response.data?.data ?? response.data;
|
||||||
|
return {
|
||||||
|
items: data?.items ?? [],
|
||||||
|
nextCursor: data?.meta?.nextCursor ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single page-history version including its lossless ProseMirror
|
||||||
|
* `content`. The version also carries pageId/title/createdAt.
|
||||||
|
*/
|
||||||
|
async getPageHistory(historyId: string) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
const response = await this.client.post("/pages/history/info", {
|
||||||
|
historyId,
|
||||||
|
});
|
||||||
|
return response.data?.data ?? response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Restore" a version: Docmost has NO restore endpoint, so we take the
|
||||||
|
* version's `content` and write it as the page's current content via the live
|
||||||
|
* collab path (which itself creates a new history snapshot). Returns the
|
||||||
|
* affected pageId and the source historyId.
|
||||||
|
*/
|
||||||
|
async restorePageVersion(historyId: string) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
const version = await this.getPageHistory(historyId);
|
||||||
|
if (
|
||||||
|
!version ||
|
||||||
|
!version.pageId ||
|
||||||
|
!version.content ||
|
||||||
|
typeof version.content !== "object"
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`restorePageVersion: history ${historyId} has no usable content`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Defense-in-depth: sanitize URLs in the restored content (parity with the
|
||||||
|
// JSON write path) before writing it back.
|
||||||
|
this.validateDocUrls(version.content);
|
||||||
|
const collabToken = await this.getCollabTokenWithReauth();
|
||||||
|
// version.pageId is the page entity id (already a UUID); resolvePageId
|
||||||
|
// short-circuits a UUID with no round-trip, so this is defensive only (#260).
|
||||||
|
const pageUuid = await this.resolvePageId(version.pageId);
|
||||||
|
const mutation = await mutatePageContent(
|
||||||
|
pageUuid,
|
||||||
|
collabToken,
|
||||||
|
this.apiUrl,
|
||||||
|
() => version.content,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
pageId: version.pageId,
|
||||||
|
restoredFrom: historyId,
|
||||||
|
verify: mutation.verify,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diff two versions of a page and return a Docmost-equivalent change set.
|
||||||
|
* `from`/`to` each resolve to a ProseMirror doc:
|
||||||
|
* - null / undefined / "current" -> the page's CURRENT content;
|
||||||
|
* - any other string -> that historyId's content.
|
||||||
|
* Returns the diff plus the resolved version metadata for each side.
|
||||||
|
*/
|
||||||
|
async diffPageVersions(pageId: string, from?: string, to?: string) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
|
||||||
|
const isCurrent = (v?: string) => v == null || v === "" || v === "current";
|
||||||
|
|
||||||
|
const resolveSide = async (
|
||||||
|
v?: string,
|
||||||
|
): Promise<{ doc: any; meta: any }> => {
|
||||||
|
if (isCurrent(v)) {
|
||||||
|
const raw = await this.getPageRaw(pageId);
|
||||||
|
return {
|
||||||
|
doc: raw.content || { type: "doc", content: [] },
|
||||||
|
meta: {
|
||||||
|
kind: "current",
|
||||||
|
pageId,
|
||||||
|
title: raw.title,
|
||||||
|
updatedAt: raw.updatedAt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const version = await this.getPageHistory(v as string);
|
||||||
|
return {
|
||||||
|
doc: version.content || { type: "doc", content: [] },
|
||||||
|
meta: {
|
||||||
|
kind: "history",
|
||||||
|
historyId: version.id,
|
||||||
|
pageId: version.pageId,
|
||||||
|
title: version.title,
|
||||||
|
createdAt: version.createdAt,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const fromSide = await resolveSide(from);
|
||||||
|
const toSide = await resolveSide(to);
|
||||||
|
const diff = diffDocs(fromSide.doc, toSide.doc);
|
||||||
|
return { from: fromSide.meta, to: toSide.meta, diff };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit a page by running an arbitrary user-supplied JS transform against the
|
||||||
|
* live document, with a diff preview + page-history safety net.
|
||||||
|
*
|
||||||
|
* The transform string is evaluated as `(doc, ctx) => doc` inside a node:vm
|
||||||
|
* sandbox: it gets ONLY `{ doc, ctx, structuredClone, console }` as globals,
|
||||||
|
* a 5s timeout, and NO access to require/process/fs/network. It must return a
|
||||||
|
* `{ type: "doc" }` node, which is validated structurally before any write.
|
||||||
|
*
|
||||||
|
* `ctx` exposes:
|
||||||
|
* - comments: the page's comments (fetched before the live read);
|
||||||
|
* - log: an array the transform can push diagnostics to (via console.log);
|
||||||
|
* - consume(id): mark a comment id as consumed (for deleteComments);
|
||||||
|
* - helpers: the transforms.ts primitives + commentsToFootnotes.
|
||||||
|
*
|
||||||
|
* Footnote convention used by the helpers: footnote markers are plain "[N]"
|
||||||
|
* text in the body, and the notes are an orderedList under a heading whose
|
||||||
|
* text is "Примечания переводчика".
|
||||||
|
*
|
||||||
|
* dryRun (default true): read the page's current content, run the transform,
|
||||||
|
* and return `{ pushed:false, diff, log }` WITHOUT opening the collab socket.
|
||||||
|
* Otherwise the transform runs atomically inside mutatePageContent, optionally
|
||||||
|
* deletes consumed comments, and returns the new historyId + diff + log.
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
return PagesMixin;
|
||||||
|
}
|
||||||
@@ -0,0 +1,713 @@
|
|||||||
|
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
|
||||||
|
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
|
||||||
|
// changed to a mixin factory. See client/context.ts for the shared base.
|
||||||
|
import type { GConstructor, DocmostClientContext } from "./context.js";
|
||||||
|
import axios, { AxiosInstance } from "axios";
|
||||||
|
import {
|
||||||
|
filterWorkspace,
|
||||||
|
filterSpace,
|
||||||
|
filterPage,
|
||||||
|
filterComment,
|
||||||
|
filterSearchResult,
|
||||||
|
} from "../lib/filters.js";
|
||||||
|
import {
|
||||||
|
convertProseMirrorToMarkdown,
|
||||||
|
type ConvertProseMirrorToMarkdownOptions,
|
||||||
|
} from "../lib/markdown-converter.js";
|
||||||
|
import {
|
||||||
|
GetPageConversionCache,
|
||||||
|
hashConvertOptions,
|
||||||
|
} from "./getpage-cache.js";
|
||||||
|
import {
|
||||||
|
collectInternalFileNodes,
|
||||||
|
normalizeFileUrl,
|
||||||
|
resolveInternalFilePath,
|
||||||
|
} from "../lib/internal-file-urls.js";
|
||||||
|
import { buildPageTree } from "../lib/tree.js";
|
||||||
|
import {
|
||||||
|
replaceNodeById,
|
||||||
|
replaceNodeByIdWithMany,
|
||||||
|
reassignCollidingBlockIds,
|
||||||
|
deleteNodeById,
|
||||||
|
assertUnambiguousMatch,
|
||||||
|
insertNodeRelative,
|
||||||
|
insertNodesRelative,
|
||||||
|
blockPlainText,
|
||||||
|
buildOutline,
|
||||||
|
getNodeByRef,
|
||||||
|
readTable,
|
||||||
|
insertTableRow,
|
||||||
|
deleteTableRow,
|
||||||
|
updateTableCell,
|
||||||
|
findInvalidNode,
|
||||||
|
} from "@docmost/prosemirror-markdown";
|
||||||
|
import {
|
||||||
|
importMarkdownFragment,
|
||||||
|
canBeDocChild,
|
||||||
|
findUnrepresentableTableAttrs,
|
||||||
|
} from "../lib/markdown-fragment.js";
|
||||||
|
import { searchInDoc, SearchOptions } from "../lib/page-search.js";
|
||||||
|
import {
|
||||||
|
blockText,
|
||||||
|
walk,
|
||||||
|
getList,
|
||||||
|
insertMarkerAfter,
|
||||||
|
setCalloutRange,
|
||||||
|
noteItem,
|
||||||
|
mdToInlineNodes,
|
||||||
|
commentsToFootnotes,
|
||||||
|
canonicalizeFootnotes,
|
||||||
|
insertInlineFootnote,
|
||||||
|
mergeFootnoteDefinitions,
|
||||||
|
} from "../lib/transforms.js";
|
||||||
|
|
||||||
|
// Public method surface of ReadMixin (issue #450) — a NAMED type so the factory
|
||||||
|
// return type is expressible in the emitted .d.ts (the anonymous mixin class
|
||||||
|
// carries the base's protected shared state, which would otherwise trip TS4094).
|
||||||
|
// Derived from the class below; `implements IReadMixin` fails to compile on drift.
|
||||||
|
export interface IReadMixin {
|
||||||
|
getWorkspace(): any;
|
||||||
|
getSpaces(): any;
|
||||||
|
listPages(spaceId?: string, limit?: number, tree?: boolean): any;
|
||||||
|
getTree(spaceId: string, rootPageId?: string, maxDepth?: number): any;
|
||||||
|
getPageContext(pageId: string): any;
|
||||||
|
listSidebarPages(spaceId: string, pageId?: string): any;
|
||||||
|
getPage(pageId: string): any;
|
||||||
|
getPageJson(pageId: string): any;
|
||||||
|
getOutline(pageId: string): any;
|
||||||
|
getNode(pageId: string, nodeId: string, format?: "markdown" | "json"): any;
|
||||||
|
searchInPage(pageId: string, query: string, opts?: SearchOptions): any;
|
||||||
|
getTable(pageId: string, tableRef: string): any;
|
||||||
|
search(query: string, spaceId?: string, limit?: number, opts?: { parentPageId?: string; titleOnly?: boolean }): any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReadMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & IReadMixin> & TBase {
|
||||||
|
abstract class ReadMixin extends Base implements IReadMixin {
|
||||||
|
async getWorkspace() {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
const response = await this.client.post("/workspace/info", {});
|
||||||
|
return {
|
||||||
|
data: filterWorkspace(response.data?.data ?? response.data),
|
||||||
|
success: response.data.success,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async getSpaces() {
|
||||||
|
const spaces = await this.paginateAll("/spaces", {});
|
||||||
|
return spaces.map((space) => filterSpace(space));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List pages in one of two modes.
|
||||||
|
*
|
||||||
|
* Default (`tree` false): most recent pages by updatedAt (descending),
|
||||||
|
* bounded. Fetching the whole space can exceed MCP response/time limits on
|
||||||
|
* large instances, so a single bounded page of results is returned (default
|
||||||
|
* 50, max 100) via the `/pages/recent` feed.
|
||||||
|
*
|
||||||
|
* 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); 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();
|
||||||
|
|
||||||
|
if (tree) {
|
||||||
|
if (!spaceId) {
|
||||||
|
throw new Error(
|
||||||
|
"listPages: tree mode requires a spaceId (a page tree is scoped to one space). Pass spaceId, or omit tree to get the recent-pages list.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { pages } = await this.enumerateSpacePages(spaceId);
|
||||||
|
return buildPageTree(pages);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampedLimit = Math.max(1, Math.min(100, limit));
|
||||||
|
const payload: Record<string, any> = { limit: clampedLimit, page: 1 };
|
||||||
|
if (spaceId) payload.spaceId = spaceId;
|
||||||
|
const response = await this.client.post("/pages/recent", payload);
|
||||||
|
const data = response.data;
|
||||||
|
const items = data.data?.items || data.items || [];
|
||||||
|
return items.map((page: any) => filterPage(page));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a space's page hierarchy (or one subtree) as a nested tree in a SINGLE
|
||||||
|
* request — the #443 `getTree` tool. Shares its whole code path with
|
||||||
|
* `listPages(tree:true)`: `enumerateSpacePages` issues one `POST /pages/tree`
|
||||||
|
* (with the cursor-BFS only as a fallback for stock upstream servers that lack
|
||||||
|
* the endpoint), then `buildPageTree` nests the flat, permission-filtered,
|
||||||
|
* position-ordered list. No second tree fetch, no per-node BFS.
|
||||||
|
*
|
||||||
|
* - `rootPageId` — restrict to that page's subtree; the server seeds the CTE
|
||||||
|
* with the page itself, so the result is exactly ONE root (the page and its
|
||||||
|
* descendants). Omit it for the whole space.
|
||||||
|
* - `maxDepth` — trim the response to that many levels (roots = depth 1) to
|
||||||
|
* save tokens; the server still returns everything in one request, the cut
|
||||||
|
* is applied in `buildPageTree` AFTER the full tree is built. A node whose
|
||||||
|
* children were cut carries `hasChildren: true` (source of truth = the flat
|
||||||
|
* item's server `hasChildren`) so the caller can descend with a follow-up
|
||||||
|
* `getTree(spaceId, rootPageId=that node)` call.
|
||||||
|
*
|
||||||
|
* Output nodes are `{pageId, title, children?, hasChildren?}` — only the UUID
|
||||||
|
* `pageId` is exposed (never `slugId`/`icon`/`position`). Requires `spaceId`
|
||||||
|
* (a page tree is scoped to one space).
|
||||||
|
*/
|
||||||
|
async getTree(spaceId: string, rootPageId?: string, maxDepth?: number) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
if (!spaceId) {
|
||||||
|
throw new Error(
|
||||||
|
"getTree: spaceId is required (a page tree is scoped to one space).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { pages } = await this.enumerateSpacePages(spaceId, rootPageId);
|
||||||
|
return buildPageTree(pages, { shape: "getTree", maxDepth });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Where am I / what's around" for a single page — the #443 `getPageContext`
|
||||||
|
* tool. Metadata only (no page content), using exactly TWO server requests:
|
||||||
|
*
|
||||||
|
* 1. `POST /pages/breadcrumbs` — a recursive CTE that walks UP from the page.
|
||||||
|
* The server returns the chain root->page order (it `.reverse()`s the
|
||||||
|
* child-first walk before responding), INCLUDING the page itself as the
|
||||||
|
* LAST element. So the last element is the page and everything before it
|
||||||
|
* is the ancestor chain root->parent. This carries the page's own title
|
||||||
|
* and spaceId, so no extra page-info fetch is needed for a UUID input.
|
||||||
|
* 2. `listSidebarPages(spaceId, pageId)` — the page's DIRECT children,
|
||||||
|
* cursor-paginated (a page with >20 children returns ALL of them, no
|
||||||
|
* dupes) and in sidebar `position` order, each carrying `hasChildren`.
|
||||||
|
*
|
||||||
|
* The input may be a slugId (agents copy them from URLs); it is run through
|
||||||
|
* `resolvePageId` first, exactly like the other page tools. A UUID input adds
|
||||||
|
* no request there (short-circuit), keeping the total at two; a slugId input
|
||||||
|
* adds one unavoidable resolve round-trip.
|
||||||
|
*
|
||||||
|
* INVARIANT: only the UUID `pageId` is exposed anywhere — server `id` is
|
||||||
|
* mapped to `pageId` and `slugId` is never leaked. A nonexistent/inaccessible
|
||||||
|
* pageId makes the server 404/403, which propagates as a clear tool error
|
||||||
|
* (never a hollow empty object).
|
||||||
|
*/
|
||||||
|
async getPageContext(pageId: string) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
|
||||||
|
// Resolve a possibly-slugId input to the canonical UUID (no round-trip for a
|
||||||
|
// UUID). Errors here (bad/inaccessible id) propagate as a clear tool error.
|
||||||
|
const pageUuid = await this.resolvePageId(pageId);
|
||||||
|
|
||||||
|
// Request 1: the ancestor chain, root->page, page included as the LAST item.
|
||||||
|
const response = await this.client.post("/pages/breadcrumbs", {
|
||||||
|
pageId: pageUuid,
|
||||||
|
});
|
||||||
|
const chain: any[] = (response.data?.data ?? response.data) ?? [];
|
||||||
|
if (!Array.isArray(chain) || chain.length === 0) {
|
||||||
|
// The endpoint always includes the page itself, so an empty chain means
|
||||||
|
// the page is gone/inaccessible — surface a clear error, not {}.
|
||||||
|
throw new Error(`getPageContext: page "${pageId}" not found or inaccessible`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split: the last element is the page, the rest (root->parent) are the
|
||||||
|
// breadcrumbs. A root page has no ancestors -> breadcrumbs is [].
|
||||||
|
const self = chain[chain.length - 1];
|
||||||
|
const ancestors = chain.slice(0, -1);
|
||||||
|
|
||||||
|
const page = {
|
||||||
|
pageId: self.id,
|
||||||
|
title: self.title,
|
||||||
|
spaceId: self.spaceId,
|
||||||
|
};
|
||||||
|
const breadcrumbs = ancestors.map((n: any) => ({
|
||||||
|
pageId: n.id,
|
||||||
|
title: n.title,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Request 2: direct children in sidebar order, each with hasChildren.
|
||||||
|
const childItems = await this.listSidebarPages(self.spaceId, pageUuid);
|
||||||
|
const children = childItems.map((c: any) => ({
|
||||||
|
pageId: c.id,
|
||||||
|
title: c.title,
|
||||||
|
hasChildren: Boolean(c.hasChildren),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { page, breadcrumbs, children };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List sidebar pages for a space. With no pageId the request returns the
|
||||||
|
* space ROOT pages; with a pageId it returns the direct CHILDREN of that
|
||||||
|
* page. pageId is therefore optional and is only included in the POST body
|
||||||
|
* when provided (an empty/undefined pageId would otherwise change the
|
||||||
|
* semantics on the server).
|
||||||
|
*/
|
||||||
|
async listSidebarPages(spaceId: string, pageId?: string) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
|
||||||
|
// Paginate via the server-issued cursor. The server switched from OFFSET
|
||||||
|
// (`page`) to CURSOR (`cursor`/`nextCursor`) pagination, and the global
|
||||||
|
// ValidationPipe(whitelist:true) SILENTLY STRIPS the obsolete `page` field
|
||||||
|
// — so the old offset loop got the SAME first page every time (with
|
||||||
|
// hasNextPage stuck true) and dropped every child beyond the first page.
|
||||||
|
const MAX_PAGES = 50;
|
||||||
|
let cursor: string | undefined;
|
||||||
|
let allItems: any[] = [];
|
||||||
|
let truncated = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < MAX_PAGES; i++) {
|
||||||
|
// limit: 100 is the server-side Max; cuts request count 5x vs the default 20.
|
||||||
|
const payload: Record<string, any> = { spaceId, limit: 100 };
|
||||||
|
// Only send pageId when scoping to a page's children; omit it for roots.
|
||||||
|
if (pageId) payload.pageId = pageId;
|
||||||
|
if (cursor) payload.cursor = cursor;
|
||||||
|
|
||||||
|
const data = (await this.client.post("/pages/sidebar-pages", payload)).data
|
||||||
|
?.data;
|
||||||
|
allItems = allItems.concat(data?.items ?? []);
|
||||||
|
|
||||||
|
// Advance strictly via the server-issued cursor; a missing/repeated cursor
|
||||||
|
// means the protocol drifted again — stop instead of looping on page one.
|
||||||
|
const next = data?.meta?.hasNextPage ? data?.meta?.nextCursor : null;
|
||||||
|
if (!next || next === cursor) break;
|
||||||
|
cursor = next;
|
||||||
|
|
||||||
|
// Reaching the ceiling with more pages still available means the child
|
||||||
|
// list is truncated (mirrors paginateAll).
|
||||||
|
if (i === MAX_PAGES - 1) truncated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warn on real truncation (ceiling hit while the server still had pages) so
|
||||||
|
// the caller is not silently handed an incomplete child list.
|
||||||
|
if (truncated) {
|
||||||
|
console.warn(
|
||||||
|
`listSidebarPages: children of "${pageId ?? spaceId}" truncated at the ${MAX_PAGES}-page cap; more pages exist on the server`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumerate EVERY page in a space (or in a subtree, when rootPageId is given).
|
||||||
|
*
|
||||||
|
* Primary path (fork server): a SINGLE `POST /pages/tree` returns the whole
|
||||||
|
* space (or a subtree) as a flat, permission-filtered list in one request, in
|
||||||
|
* the exact node shape buildPageTree consumes. This replaces the old
|
||||||
|
* per-node BFS, which issued N sidebar requests and — after the server moved
|
||||||
|
* to cursor pagination — silently lost every child past the first sidebar
|
||||||
|
* page (the obsolete `page` param was stripped by ValidationPipe).
|
||||||
|
*
|
||||||
|
* The subtree variant (rootPageId given) INCLUDES the root node itself
|
||||||
|
* (getPageAndDescendants seeds with id = rootPageId), unlike the old BFS
|
||||||
|
* which started from the root's children.
|
||||||
|
*
|
||||||
|
* Fallback path (stdio mode may target STOCK upstream Docmost, which lacks
|
||||||
|
* `/pages/tree`): on a 404/405 it falls back to the cursor-based BFS below,
|
||||||
|
* walking direct children via the fixed cursor listSidebarPages. Safeguards:
|
||||||
|
* a `visited` Set of page ids prevents re-processing a node (cycles /
|
||||||
|
* duplicate references), and a hard node cap bounds pathological trees so the
|
||||||
|
* walk always terminates.
|
||||||
|
*
|
||||||
|
* Returns `{ pages, truncated }`. `truncated` is true ONLY when the fallback
|
||||||
|
* BFS stopped at its MAX_NODES cap — the primary /pages/tree path is uncapped
|
||||||
|
* and always returns the complete set, so it never reports truncation.
|
||||||
|
*/
|
||||||
|
protected async enumerateSpacePages(
|
||||||
|
spaceId: string,
|
||||||
|
rootPageId?: string,
|
||||||
|
): Promise<{ pages: any[]; truncated: boolean }> {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
|
||||||
|
// Single request replaces the whole BFS: /pages/tree returns the full
|
||||||
|
// permission-filtered flat page set of a space (or a subtree) at once. This
|
||||||
|
// path is uncapped, so it is never truncated.
|
||||||
|
const payload = rootPageId ? { pageId: rootPageId } : { spaceId };
|
||||||
|
try {
|
||||||
|
const response = await this.client.post("/pages/tree", payload);
|
||||||
|
const pages = (response.data?.data ?? response.data)?.items ?? [];
|
||||||
|
return { pages, truncated: false };
|
||||||
|
} catch (e: any) {
|
||||||
|
// Only fall back when the endpoint is absent (stock upstream Docmost);
|
||||||
|
// any other error is a genuine failure and must propagate.
|
||||||
|
if (
|
||||||
|
!axios.isAxiosError(e) ||
|
||||||
|
(e.response?.status !== 404 && e.response?.status !== 405)
|
||||||
|
) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: cursor-based breadth-first walk via listSidebarPages.
|
||||||
|
const MAX_NODES = 10000;
|
||||||
|
const result: any[] = [];
|
||||||
|
const visited = new Set<string>();
|
||||||
|
|
||||||
|
// Seed with the root node itself when scoping to a subtree, so its own
|
||||||
|
// comments aren't dropped: the primary /pages/tree seeds
|
||||||
|
// getPageAndDescendants with id = rootPageId (root included), but
|
||||||
|
// listSidebarPages(spaceId, rootPageId) returns only the root's CHILDREN.
|
||||||
|
// The `visited` set below prevents a double-add if the root also appears
|
||||||
|
// among the children. getPageRaw returns a page whose id/title/spaceId are
|
||||||
|
// exactly what buildPageTree and checkNewComments consume.
|
||||||
|
if (rootPageId) {
|
||||||
|
try {
|
||||||
|
const root = await this.getPageRaw(rootPageId);
|
||||||
|
if (root?.id) {
|
||||||
|
result.push(root);
|
||||||
|
visited.add(root.id);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-fatal: if the root can't be read, fall through to children-only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed the queue with the starting level (subtree children or roots).
|
||||||
|
const queue: any[] = await this.listSidebarPages(spaceId, rootPageId);
|
||||||
|
|
||||||
|
while (queue.length > 0 && result.length < MAX_NODES) {
|
||||||
|
const node = queue.shift();
|
||||||
|
if (!node || typeof node !== "object" || !node.id) continue;
|
||||||
|
|
||||||
|
// Skip already-seen ids to guard against cycles / duplicate references.
|
||||||
|
if (visited.has(node.id)) continue;
|
||||||
|
visited.add(node.id);
|
||||||
|
|
||||||
|
result.push(node);
|
||||||
|
|
||||||
|
if (node.hasChildren) {
|
||||||
|
try {
|
||||||
|
const children = await this.listSidebarPages(spaceId, node.id);
|
||||||
|
for (const child of children) queue.push(child);
|
||||||
|
} catch (e: any) {
|
||||||
|
// A failure fetching one node's children must not abort the whole
|
||||||
|
// walk: skip this branch and keep enumerating the rest.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncated only when the cap was hit with the queue still non-empty (real
|
||||||
|
// truncation, not a natural end at exactly MAX_NODES).
|
||||||
|
return {
|
||||||
|
pages: result,
|
||||||
|
truncated: result.length >= MAX_NODES && queue.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw page info including the ProseMirror JSON content and slugId. */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overridable seam over convertProseMirrorToMarkdown (issue #479). Production
|
||||||
|
* just delegates; it exists as a method so a unit test can spy on it and
|
||||||
|
* assert the conversion is genuinely SKIPPED on a getPage cache HIT (the whole
|
||||||
|
* point of the cache) — an ESM named import cannot be intercepted otherwise.
|
||||||
|
*/
|
||||||
|
protected convertPageMarkdown(
|
||||||
|
content: any,
|
||||||
|
options: ConvertProseMirrorToMarkdownOptions,
|
||||||
|
): string {
|
||||||
|
return convertProseMirrorToMarkdown(content, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPage(pageId: string) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
const resultData = await this.getPageRaw(pageId);
|
||||||
|
|
||||||
|
// Agent read: hide resolved-comment anchors so the agent sees only active
|
||||||
|
// discussions. Active anchors are kept. (The lossless exportPageMarkdown
|
||||||
|
// round-trip deliberately does NOT pass this flag — resolved anchors there
|
||||||
|
// must be preserved.)
|
||||||
|
//
|
||||||
|
// Content-addressed conversion cache (issue #479): the PM->Markdown walk is
|
||||||
|
// the dominant cost of this hot read op. Key on the page's canonical UUID +
|
||||||
|
// updatedAt (both from THIS /pages/info response, so mutually consistent) +
|
||||||
|
// a hash of the conversion options. A hit returns the cached markdown and
|
||||||
|
// skips the walk; a miss converts and stores. The cached value is the
|
||||||
|
// conversion output BEFORE the {{SUBPAGES}} substitution below, which uses
|
||||||
|
// live subpage data and stays outside the cache — so the final result is
|
||||||
|
// byte-identical to the uncached path.
|
||||||
|
const convertOptions = { dropResolvedCommentAnchors: true };
|
||||||
|
let content = "";
|
||||||
|
if (resultData.content) {
|
||||||
|
// Only cache when we have a stable identity+version for the key. Both come
|
||||||
|
// from the same response; if either is missing (unexpected server shape),
|
||||||
|
// fall back to converting uncached rather than keying on a partial tuple.
|
||||||
|
const cacheable =
|
||||||
|
typeof resultData.id === "string" &&
|
||||||
|
typeof resultData.updatedAt === "string";
|
||||||
|
const cacheKey = cacheable
|
||||||
|
? GetPageConversionCache.key(
|
||||||
|
resultData.id,
|
||||||
|
resultData.updatedAt,
|
||||||
|
hashConvertOptions(convertOptions),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const cached = cacheKey ? this.getPageCache.get(cacheKey) : undefined;
|
||||||
|
if (cached !== undefined) {
|
||||||
|
content = cached;
|
||||||
|
this.onMetricFn?.("mcp_getpage_cache_hits_total", 1);
|
||||||
|
} else {
|
||||||
|
// Goes through the convertPageMarkdown seam (not the raw import) so a
|
||||||
|
// test can assert the conversion is SKIPPED on a hit (issue #479 F2).
|
||||||
|
content = this.convertPageMarkdown(resultData.content, convertOptions);
|
||||||
|
if (cacheKey) this.getPageCache.set(cacheKey, content);
|
||||||
|
// A non-cacheable page (missing id/updatedAt) is still a genuine
|
||||||
|
// conversion, so it counts as a miss for an honest hit-rate.
|
||||||
|
this.onMetricFn?.("mcp_getpage_cache_misses_total", 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always fetch subpages to provide context to the agent.
|
||||||
|
//
|
||||||
|
// NOT parallelizable with the page fetch (issue #479 asked to check): the
|
||||||
|
// sidebar-pages endpoint REQUIRES spaceId in its POST body, and spaceId is
|
||||||
|
// only known FROM this page fetch's response (resolvePageId yields the UUID
|
||||||
|
// but never the spaceId). So `Promise.all([pageFetch, subpagesFetch])` would
|
||||||
|
// have to invent a spaceId it does not have — the two calls are inherently
|
||||||
|
// sequential. Correctness wins; the conversion cache above is the real speedup.
|
||||||
|
let subpages: any[] = [];
|
||||||
|
try {
|
||||||
|
// `pageId` may be a slugId, but the sidebar-pages endpoint requires the
|
||||||
|
// UUID; `resultData.id` holds the resolved UUID returned by getPageRaw.
|
||||||
|
subpages = await this.listSidebarPages(resultData.spaceId, resultData.id);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.warn("Failed to fetch subpages:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve subpages if the placeholder exists
|
||||||
|
if (content && content.includes("{{SUBPAGES}}")) {
|
||||||
|
if (subpages && subpages.length > 0) {
|
||||||
|
const list = subpages
|
||||||
|
.map((p: any) => `- [${p.title}](page:${p.id})`)
|
||||||
|
.join("\n");
|
||||||
|
content = content.replace("{{SUBPAGES}}", `### Subpages\n${list}`);
|
||||||
|
} else {
|
||||||
|
content = content.replace("{{SUBPAGES}}", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: filterPage(resultData, content, subpages),
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Page info + raw ProseMirror JSON content (lossless representation). */
|
||||||
|
async getPageJson(pageId: string) {
|
||||||
|
const data = await this.getPageRaw(pageId);
|
||||||
|
return {
|
||||||
|
id: data.id,
|
||||||
|
slugId: data.slugId,
|
||||||
|
title: data.title,
|
||||||
|
parentPageId: data.parentPageId,
|
||||||
|
spaceId: data.spaceId,
|
||||||
|
updatedAt: data.updatedAt,
|
||||||
|
content: data.content || { type: "doc", content: [] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch an INTERNAL Docmost file (authed loopback) for sandbox mirroring.
|
||||||
|
* `src` is normalized to `/api/files/<id>/<file>`; `this.client.baseURL`
|
||||||
|
* already ends in `/api`, so we strip the leading `/api` and request the
|
||||||
|
* relative path with the client's Authorization header. Returns the raw bytes
|
||||||
|
* and the response Content-Type (mime), defaulting to octet-stream.
|
||||||
|
*
|
||||||
|
* The fetch is size-bounded (hard 64 MiB ceiling) purely to protect memory;
|
||||||
|
* the authoritative per-blob cap is enforced by the sandbox `put`. The path is
|
||||||
|
* resolved via resolveInternalFilePath, which REJECTS (throws) any traversal
|
||||||
|
* or percent-encoded src that would let an attacker-controlled `attrs.src`
|
||||||
|
* escape `/api/files/` and reach another internal endpoint (SSRF). That throw
|
||||||
|
* happens before this.client.get, so a malicious src is counted as a failed
|
||||||
|
* mirror — it never reaches the network.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact outline of a page's top-level blocks (no full document body).
|
||||||
|
* Cheap way to locate sections/tables and grab block ids before drilling in
|
||||||
|
* with getNode / patchNode / insertNode.
|
||||||
|
*/
|
||||||
|
async getOutline(pageId: string) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
const data = await this.getPageRaw(pageId);
|
||||||
|
return {
|
||||||
|
pageId,
|
||||||
|
slugId: data.slugId,
|
||||||
|
title: data.title,
|
||||||
|
outline: buildOutline(data.content ?? { type: "doc", content: [] }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
format: "markdown" | "json" = "markdown",
|
||||||
|
) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
const data = await this.getPageRaw(pageId);
|
||||||
|
const hit = getNodeByRef(
|
||||||
|
data.content ?? { type: "doc", content: [] },
|
||||||
|
nodeId,
|
||||||
|
);
|
||||||
|
if (!hit) {
|
||||||
|
throw new Error(
|
||||||
|
`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,
|
||||||
|
format: "markdown" as const,
|
||||||
|
markdown,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find every occurrence of `query` on a page IN MEMORY, over the plain text of
|
||||||
|
* each text container (reusing the same `getPageRaw` fetch as the other read
|
||||||
|
* tools) — no server search endpoint, no whole-document round-trip through the
|
||||||
|
* model. Returns `{ total, truncated, matches }`; each match carries a ref for
|
||||||
|
* getNode/patchNode (the `#<index>` form resolves with getNode but NOT
|
||||||
|
* patchNode — see SearchMatch.nodeId), plus the top-level block index and a
|
||||||
|
* short context window used to build a unique text `selection` for
|
||||||
|
* createComment (createComment has no nodeId param). The pure engine
|
||||||
|
* (`searchInDoc`) owns the traversal, glue, the RE2 ReDoS-safe regex engine
|
||||||
|
* and the empty-query / invalid-or-unsupported-regex errors.
|
||||||
|
*/
|
||||||
|
async searchInPage(pageId: string, query: string, opts: SearchOptions = {}) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
const data = await this.getPageRaw(pageId);
|
||||||
|
const result = searchInDoc(
|
||||||
|
data.content ?? { type: "doc", content: [] },
|
||||||
|
query,
|
||||||
|
opts,
|
||||||
|
);
|
||||||
|
return { pageId, query, ...result };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a table as a matrix. `tableRef` is `#<index>` (from getOutline) or a
|
||||||
|
* block id of any node inside the table. Returns the cell texts plus a
|
||||||
|
* parallel cellIds matrix (each cell's first paragraph id, or null) so a
|
||||||
|
* caller can patchNode a cell for rich-formatted edits. Throws when no table
|
||||||
|
* resolves for the reference.
|
||||||
|
*/
|
||||||
|
async getTable(pageId: string, tableRef: string) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
const data = await this.getPageRaw(pageId);
|
||||||
|
const t = readTable(data.content ?? { type: "doc", content: [] }, tableRef);
|
||||||
|
if (!t) {
|
||||||
|
throw new Error(
|
||||||
|
`tableGet: no table found for "${tableRef}" on page ${pageId} (use "#<index>" from getOutline, or a block id inside the table)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
pageId,
|
||||||
|
table: tableRef,
|
||||||
|
rows: t.rows,
|
||||||
|
cols: t.cols,
|
||||||
|
path: t.path,
|
||||||
|
cells: t.cells,
|
||||||
|
cellIds: t.cellIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a row of plain-text cells into a table on the LIVE collab document.
|
||||||
|
* `tableRef` is `#<index>` or a block id inside the target table. `cells` is
|
||||||
|
* padded to the table's column count (more cells than columns throws); `index`
|
||||||
|
* is a 0-based insert position (omit/out-of-range to append). Throws when no
|
||||||
|
* table resolves for the reference.
|
||||||
|
*/
|
||||||
|
|
||||||
|
async search(
|
||||||
|
query: string,
|
||||||
|
spaceId?: string,
|
||||||
|
limit?: number,
|
||||||
|
opts: { parentPageId?: string; titleOnly?: boolean } = {},
|
||||||
|
) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
// 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(50, limit));
|
||||||
|
}
|
||||||
|
const response = await this.client.post("/search", payload);
|
||||||
|
|
||||||
|
// Normalize both response shapes: bare array and paginated { items: [...] }
|
||||||
|
const data = response.data?.data;
|
||||||
|
const items = Array.isArray(data) ? data : data?.items || [];
|
||||||
|
const filteredItems = items.map((item: any) => filterSearchResult(item));
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: filteredItems,
|
||||||
|
success: response.data?.success || false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
return ReadMixin;
|
||||||
|
}
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
|
||||||
|
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
|
||||||
|
// changed to a mixin factory. See client/context.ts for the shared base.
|
||||||
|
import type { GConstructor, DocmostClientContext } from "./context.js";
|
||||||
|
import {
|
||||||
|
collectInternalFileNodes,
|
||||||
|
normalizeFileUrl,
|
||||||
|
resolveInternalFilePath,
|
||||||
|
} from "../lib/internal-file-urls.js";
|
||||||
|
|
||||||
|
// Public method surface of StashMixin (issue #450) — a NAMED type so the factory
|
||||||
|
// return type is expressible in the emitted .d.ts (the anonymous mixin class
|
||||||
|
// carries the base's protected shared state, which would otherwise trip TS4094).
|
||||||
|
// Derived from the class below; `implements IStashMixin` fails to compile on drift.
|
||||||
|
export interface IStashMixin {
|
||||||
|
stashPage(pageId: string): Promise<{ uri: string; sha256: string; size: number; images: { mirrored: number; failed: number }; }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StashMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & IStashMixin> & TBase {
|
||||||
|
abstract class StashMixin extends Base implements IStashMixin {
|
||||||
|
/**
|
||||||
|
* Fetch an INTERNAL Docmost file (authed loopback) for sandbox mirroring.
|
||||||
|
* `src` is normalized to `/api/files/<id>/<file>`; `this.client.baseURL`
|
||||||
|
* already ends in `/api`, so we strip the leading `/api` and request the
|
||||||
|
* relative path with the client's Authorization header. Returns the raw bytes
|
||||||
|
* and the response Content-Type (mime), defaulting to octet-stream.
|
||||||
|
*
|
||||||
|
* The fetch is size-bounded (hard 64 MiB ceiling) purely to protect memory;
|
||||||
|
* the authoritative per-blob cap is enforced by the sandbox `put`. The path is
|
||||||
|
* resolved via resolveInternalFilePath, which REJECTS (throws) any traversal
|
||||||
|
* or percent-encoded src that would let an attacker-controlled `attrs.src`
|
||||||
|
* escape `/api/files/` and reach another internal endpoint (SSRF). That throw
|
||||||
|
* happens before this.client.get, so a malicious src is counted as a failed
|
||||||
|
* mirror — it never reaches the network.
|
||||||
|
*/
|
||||||
|
protected async fetchInternalFile(
|
||||||
|
src: string,
|
||||||
|
): Promise<{ buffer: Buffer; mime: string }> {
|
||||||
|
const HARD_CEILING = 64 * 1024 * 1024; // 64 MiB memory guard
|
||||||
|
const relPath = resolveInternalFilePath(src);
|
||||||
|
const response = await this.client.get(relPath, {
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
timeout: 30000,
|
||||||
|
maxContentLength: HARD_CEILING,
|
||||||
|
maxBodyLength: HARD_CEILING,
|
||||||
|
});
|
||||||
|
const buffer = Buffer.from(response.data);
|
||||||
|
if (buffer.length === 0) {
|
||||||
|
throw new Error(`Empty file response from "${src}"`);
|
||||||
|
}
|
||||||
|
const rawCt = response.headers?.["content-type"];
|
||||||
|
const mime =
|
||||||
|
typeof rawCt === "string" && rawCt.length > 0
|
||||||
|
? rawCt.split(";")[0].trim().toLowerCase()
|
||||||
|
: "application/octet-stream";
|
||||||
|
return { buffer, mime };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stash a page's full content into the in-RAM blob sandbox and return ONLY a
|
||||||
|
* short anonymous URL — the body never enters the model context (this is the
|
||||||
|
* whole point: ~30KB+ ProseMirror docs blow the model context if passed as a
|
||||||
|
* tool argument). Every INTERNAL file/image src (the type-agnostic criterion,
|
||||||
|
* so drawio/excalidraw/video/file nodes are covered too) is mirrored into the
|
||||||
|
* sandbox and its `src` rewritten to the sandbox URL, so an external consumer
|
||||||
|
* can fetch the images anonymously. External http(s) srcs are left untouched.
|
||||||
|
*
|
||||||
|
* Blobs live in RAM with a short TTL and are cleared on restart — consume the
|
||||||
|
* URLs within the TTL and one uptime. A failed image fetch never aborts the
|
||||||
|
* doc: the original src is kept and the failure counted.
|
||||||
|
*
|
||||||
|
* Returns { uri, sha256, size, images:{mirrored, failed} }. `uri` and `sha256`
|
||||||
|
* are for the document blob; `sha256` is also the blob's ETag (integrity).
|
||||||
|
*/
|
||||||
|
async stashPage(pageId: string): Promise<{
|
||||||
|
uri: string;
|
||||||
|
sha256: string;
|
||||||
|
size: number;
|
||||||
|
images: { mirrored: number; failed: number };
|
||||||
|
}> {
|
||||||
|
if (!this.sandboxPut) {
|
||||||
|
throw new Error(
|
||||||
|
"stashPage is unavailable: the blob sandbox is not configured on this server",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
|
||||||
|
// Stash the SAME shape getPageJson returns (id/title/.../content), with a
|
||||||
|
// deep clone so the rewrite never mutates anything shared.
|
||||||
|
const pageJson = await this.getPageJson(pageId);
|
||||||
|
const cloned: any = structuredClone(pageJson);
|
||||||
|
|
||||||
|
// Group internal-file nodes by normalized src so each unique resource is
|
||||||
|
// fetched + stored ONCE (dedup), and every node sharing that src points at
|
||||||
|
// the one sandbox blob. Capture each node's ORIGINAL raw src per-node:
|
||||||
|
// dedup groups nodes whose normalized src is equal even when their raw srcs
|
||||||
|
// differ (e.g. `/api/files/...` vs the bare `/files/...`), so on a revert we
|
||||||
|
// must restore each node's own original value, not the group key.
|
||||||
|
const bySrc = new Map<string, Array<{ node: any; origSrc: string }>>();
|
||||||
|
for (const node of collectInternalFileNodes(cloned.content)) {
|
||||||
|
const origSrc = String(node.attrs.src);
|
||||||
|
const src = normalizeFileUrl(origSrc);
|
||||||
|
const entry = { node, origSrc };
|
||||||
|
const group = bySrc.get(src);
|
||||||
|
if (group) group.push(entry);
|
||||||
|
else bySrc.set(src, [entry]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mirrored = 0;
|
||||||
|
let failed = 0;
|
||||||
|
// Record every successful mirror so it can be (a) reverted if its blob gets
|
||||||
|
// FIFO-evicted by a LATER put in this same stash, and (b) freed if the final
|
||||||
|
// doc put throws.
|
||||||
|
const mirrors: Array<{
|
||||||
|
uri: string;
|
||||||
|
entries: Array<{ node: any; origSrc: string }>;
|
||||||
|
}> = [];
|
||||||
|
const MAX_CONCURRENCY = 5;
|
||||||
|
const groups = [...bySrc.entries()];
|
||||||
|
for (let i = 0; i < groups.length; i += MAX_CONCURRENCY) {
|
||||||
|
const batch = groups.slice(i, i + MAX_CONCURRENCY);
|
||||||
|
await Promise.all(
|
||||||
|
batch.map(async ([src, entries]) => {
|
||||||
|
try {
|
||||||
|
const { buffer, mime } = await this.fetchInternalFile(src);
|
||||||
|
// put may throw if the blob exceeds the per-blob/total caps.
|
||||||
|
const stored = this.sandboxPut!(buffer, mime);
|
||||||
|
for (const entry of entries) entry.node.attrs.src = stored.uri;
|
||||||
|
mirrors.push({ uri: stored.uri, entries });
|
||||||
|
mirrored++;
|
||||||
|
} catch (err) {
|
||||||
|
// One bad/oversized image (or a rejected traversal src) must not
|
||||||
|
// abort the document. Logged unconditionally (never the blob body),
|
||||||
|
// matching the package's ungated console.warn convention.
|
||||||
|
failed++;
|
||||||
|
console.warn(
|
||||||
|
`stashPage: failed to mirror "${src}": ${
|
||||||
|
err instanceof Error ? err.message : String(err)
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revert one mirror's nodes to their original internal srcs and re-count it
|
||||||
|
// as failed (its blob was FIFO-evicted before the doc could reference it
|
||||||
|
// safely).
|
||||||
|
const revertMirror = (mirror: {
|
||||||
|
uri: string;
|
||||||
|
entries: Array<{ node: any; origSrc: string }>;
|
||||||
|
}) => {
|
||||||
|
for (const entry of mirror.entries) entry.node.attrs.src = entry.origSrc;
|
||||||
|
mirrored--;
|
||||||
|
failed++;
|
||||||
|
console.warn(
|
||||||
|
`stashPage: mirrored blob ${mirror.uri} was evicted before the doc ` +
|
||||||
|
`could safely reference it; reverted its src and counted it as failed`,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Pre-put reconciliation: an image put earlier in THIS stash can FIFO-evict
|
||||||
|
// an even-earlier image of the same stash. Drop those from the live set
|
||||||
|
// first so the first serialized doc is already mostly correct.
|
||||||
|
let liveMirrors = mirrors;
|
||||||
|
if (this.sandboxHas) {
|
||||||
|
liveMirrors = [];
|
||||||
|
for (const mirror of mirrors) {
|
||||||
|
if (this.sandboxHas(mirror.uri)) liveMirrors.push(mirror);
|
||||||
|
else revertMirror(mirror);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Put the document, then reconcile against eviction caused by the doc put
|
||||||
|
// ITSELF (the doc is newest, FIFO drops oldest = this stash's images). Each
|
||||||
|
// iteration reverts >=1 mirror, so the loop terminates (worst case: all
|
||||||
|
// images reverted and the doc references no sandbox image URLs).
|
||||||
|
let stored: { uri: string; sha256: string; size: number };
|
||||||
|
for (;;) {
|
||||||
|
const docBuf = Buffer.from(JSON.stringify(cloned), "utf8");
|
||||||
|
let docStored: { uri: string; sha256: string; size: number };
|
||||||
|
try {
|
||||||
|
docStored = this.sandboxPut(docBuf, "application/json");
|
||||||
|
} catch (err) {
|
||||||
|
// The doc put failed (e.g. doc exceeds the cap). Free this op's image
|
||||||
|
// blobs instead of leaking them in RAM for the whole TTL, then
|
||||||
|
// re-throw.
|
||||||
|
if (this.sandboxEvict) {
|
||||||
|
for (const mirror of liveMirrors) this.sandboxEvict(mirror.uri);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.sandboxHas) {
|
||||||
|
stored = docStored;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const evictedNow = liveMirrors.filter((m) => !this.sandboxHas!(m.uri));
|
||||||
|
if (evictedNow.length === 0) {
|
||||||
|
stored = docStored;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// The doc we just stored references now-dead blobs. Revert those nodes,
|
||||||
|
// drop the stale doc blob, and loop to re-serialize + re-put the
|
||||||
|
// corrected doc.
|
||||||
|
for (const mirror of evictedNow) revertMirror(mirror);
|
||||||
|
liveMirrors = liveMirrors.filter((m) => this.sandboxHas!(m.uri));
|
||||||
|
if (this.sandboxEvict) this.sandboxEvict(docStored.uri);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
uri: stored.uri,
|
||||||
|
sha256: stored.sha256,
|
||||||
|
size: stored.size,
|
||||||
|
images: { mirrored, failed },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact outline of a page's top-level blocks (no full document body).
|
||||||
|
* Cheap way to locate sections/tables and grab block ids before drilling in
|
||||||
|
* with getNode / patchNode / insertNode.
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
return StashMixin;
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
|
||||||
|
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
|
||||||
|
// changed to a mixin factory. See client/context.ts for the shared base.
|
||||||
|
import type { GConstructor, DocmostClientContext } from "./context.js";
|
||||||
|
import {
|
||||||
|
updatePageContentRealtime,
|
||||||
|
replacePageContent,
|
||||||
|
markdownToProseMirror,
|
||||||
|
markdownToProseMirrorCanonical,
|
||||||
|
mutatePageContent,
|
||||||
|
assertYjsEncodable,
|
||||||
|
MutationResult,
|
||||||
|
} from "../lib/collaboration.js";
|
||||||
|
import {
|
||||||
|
replaceNodeById,
|
||||||
|
replaceNodeByIdWithMany,
|
||||||
|
reassignCollidingBlockIds,
|
||||||
|
deleteNodeById,
|
||||||
|
assertUnambiguousMatch,
|
||||||
|
insertNodeRelative,
|
||||||
|
insertNodesRelative,
|
||||||
|
blockPlainText,
|
||||||
|
buildOutline,
|
||||||
|
getNodeByRef,
|
||||||
|
readTable,
|
||||||
|
insertTableRow,
|
||||||
|
deleteTableRow,
|
||||||
|
updateTableCell,
|
||||||
|
findInvalidNode,
|
||||||
|
} from "@docmost/prosemirror-markdown";
|
||||||
|
import { withPageLock, isUuid } from "../lib/page-lock.js";
|
||||||
|
import {
|
||||||
|
blockText,
|
||||||
|
walk,
|
||||||
|
getList,
|
||||||
|
insertMarkerAfter,
|
||||||
|
setCalloutRange,
|
||||||
|
noteItem,
|
||||||
|
mdToInlineNodes,
|
||||||
|
commentsToFootnotes,
|
||||||
|
canonicalizeFootnotes,
|
||||||
|
insertInlineFootnote,
|
||||||
|
mergeFootnoteDefinitions,
|
||||||
|
} from "../lib/transforms.js";
|
||||||
|
|
||||||
|
// Public method surface of TablesMixin (issue #450) — a NAMED type so the factory
|
||||||
|
// return type is expressible in the emitted .d.ts (the anonymous mixin class
|
||||||
|
// carries the base's protected shared state, which would otherwise trip TS4094).
|
||||||
|
// Derived from the class below; `implements ITablesMixin` fails to compile on drift.
|
||||||
|
export interface ITablesMixin {
|
||||||
|
insertFootnote(pageId: string, anchorText: string, text: string): any;
|
||||||
|
tableInsertRow(pageId: string, tableRef: string, cells: string[], index?: number): any;
|
||||||
|
tableDeleteRow(pageId: string, tableRef: string, index: number): any;
|
||||||
|
tableUpdateCell(pageId: string, tableRef: string, row: number, col: number, text: string): any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TablesMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & ITablesMixin> & TBase {
|
||||||
|
abstract class TablesMixin extends Base implements ITablesMixin {
|
||||||
|
/**
|
||||||
|
* AUTHOR-INLINE footnote insertion. The agent supplies only WHERE
|
||||||
|
* (`anchorText`, a snippet of body text to attach the marker after) and WHAT
|
||||||
|
* (`text`, the footnote content as markdown). Numbering and the bottom
|
||||||
|
* `footnotesList` are derived deterministically server-side
|
||||||
|
* (`insertInlineFootnote` -> `canonicalizeFootnotes`): the agent never sees,
|
||||||
|
* assigns, or edits a footnote number or the list, so it CANNOT desync.
|
||||||
|
*
|
||||||
|
* Content DEDUP: when an existing definition has the same content, its id is
|
||||||
|
* reused (one number, one definition, several references). The write is atomic
|
||||||
|
* via `mutatePageContent` (single-writer, page-locked); if the anchor text is
|
||||||
|
* not found the transform aborts with a clear error and no write happens.
|
||||||
|
*/
|
||||||
|
async insertFootnote(pageId: string, anchorText: string, text: string) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
if (!anchorText || !anchorText.trim()) {
|
||||||
|
throw new Error("insertFootnote: anchorText is required");
|
||||||
|
}
|
||||||
|
if (text == null || `${text}`.trim() === "") {
|
||||||
|
throw new Error("insertFootnote: text is required");
|
||||||
|
}
|
||||||
|
const collabToken = await this.getCollabTokenWithReauth();
|
||||||
|
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||||
|
const pageUuid = await this.resolvePageId(pageId);
|
||||||
|
let result: { footnoteId: string; reused: boolean } | null = null;
|
||||||
|
const mutation = await this.mutatePage(
|
||||||
|
pageUuid,
|
||||||
|
collabToken,
|
||||||
|
this.apiUrl,
|
||||||
|
(liveDoc: any) => {
|
||||||
|
const r = insertInlineFootnote(liveDoc, { anchorText, text });
|
||||||
|
if (!r.inserted) {
|
||||||
|
// Abort the page-locked write by throwing: mutatePageContent does not
|
||||||
|
// persist when the transform throws, so a missing anchor leaves the
|
||||||
|
// page untouched (no partial write).
|
||||||
|
throw new Error(
|
||||||
|
`insertFootnote: anchor text not found: ${JSON.stringify(
|
||||||
|
anchorText.slice(0, 80),
|
||||||
|
)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
result = { footnoteId: r.footnoteId, reused: r.reused };
|
||||||
|
return r.doc;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// The not-found path throws inside the transform (aborting mutatePage), so by
|
||||||
|
// here `result` is always set.
|
||||||
|
const r = result!;
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
modified: true,
|
||||||
|
pageId,
|
||||||
|
footnoteId: r.footnoteId,
|
||||||
|
reused: r.reused,
|
||||||
|
message: r.reused
|
||||||
|
? "Footnote inserted (reused an existing same-content definition)."
|
||||||
|
: "Footnote inserted.",
|
||||||
|
verify: mutation.verify,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page-locked write seam over collaboration.mutatePageContent. Production just
|
||||||
|
* delegates; it exists as an overridable method so the insertFootnote wrapper
|
||||||
|
* (transform abort-on-not-found + response shaping) can be unit-tested without
|
||||||
|
* standing up a live Hocuspocus collab socket.
|
||||||
|
*
|
||||||
|
* SELF-RESOLVES the pageId to the canonical UUID (issue #449, "resolve-then-
|
||||||
|
* lock"): every write must lock and key its CollabSession by the UUID, never a
|
||||||
|
* raw slugId (#260). resolvePageId is cached/idempotent, so a caller that
|
||||||
|
* already resolved pays no extra round-trip; centralizing it here means a
|
||||||
|
* caller that reaches this seam with a raw slugId still locks correctly instead
|
||||||
|
* of silently splitting the mutex key. withPageLock also asserts the key is a
|
||||||
|
* UUID as a hard backstop.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a row of plain-text cells into a table on the LIVE collab document.
|
||||||
|
* `tableRef` is `#<index>` or a block id inside the target table. `cells` is
|
||||||
|
* padded to the table's column count (more cells than columns throws); `index`
|
||||||
|
* is a 0-based insert position (omit/out-of-range to append). Throws when no
|
||||||
|
* table resolves for the reference.
|
||||||
|
*/
|
||||||
|
async tableInsertRow(
|
||||||
|
pageId: string,
|
||||||
|
tableRef: string,
|
||||||
|
cells: string[],
|
||||||
|
index?: number,
|
||||||
|
) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
const collabToken = await this.getCollabTokenWithReauth();
|
||||||
|
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||||
|
const pageUuid = await this.resolvePageId(pageId);
|
||||||
|
|
||||||
|
// Track insertion in an outer var, reset per-transform, so a collab retry
|
||||||
|
// recomputes it cleanly (mirrors insertNode's pattern).
|
||||||
|
let inserted = false;
|
||||||
|
const mutation = await mutatePageContent(
|
||||||
|
pageUuid,
|
||||||
|
collabToken,
|
||||||
|
this.apiUrl,
|
||||||
|
(liveDoc) => {
|
||||||
|
inserted = false;
|
||||||
|
const { doc: nd, inserted: ins } = insertTableRow(
|
||||||
|
liveDoc,
|
||||||
|
tableRef,
|
||||||
|
cells,
|
||||||
|
index,
|
||||||
|
);
|
||||||
|
inserted = ins;
|
||||||
|
if (!inserted) return null; // table not found -> skip the write entirely
|
||||||
|
return nd;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!inserted) {
|
||||||
|
throw new Error(
|
||||||
|
`tableInsertRow: no table found for "${tableRef}" on page ${pageId} (use "#<index>" from getOutline, or a block id inside the table)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
table: tableRef,
|
||||||
|
inserted: true,
|
||||||
|
verify: mutation.verify,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the row at 0-based `index` from a table on the LIVE collab document.
|
||||||
|
* `tableRef` is `#<index>` or a block id inside the target table. The helper's
|
||||||
|
* out-of-range and last-row errors propagate; a missing table throws here.
|
||||||
|
*/
|
||||||
|
async tableDeleteRow(pageId: string, tableRef: string, index: number) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
const collabToken = await this.getCollabTokenWithReauth();
|
||||||
|
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||||
|
const pageUuid = await this.resolvePageId(pageId);
|
||||||
|
|
||||||
|
let deleted = false;
|
||||||
|
const mutation = await mutatePageContent(
|
||||||
|
pageUuid,
|
||||||
|
collabToken,
|
||||||
|
this.apiUrl,
|
||||||
|
(liveDoc) => {
|
||||||
|
deleted = false;
|
||||||
|
const { doc: nd, deleted: del } = deleteTableRow(
|
||||||
|
liveDoc,
|
||||||
|
tableRef,
|
||||||
|
index,
|
||||||
|
);
|
||||||
|
deleted = del;
|
||||||
|
if (!deleted) return null; // table not found -> skip the write entirely
|
||||||
|
return nd;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
throw new Error(
|
||||||
|
`tableDeleteRow: no table found for "${tableRef}" on page ${pageId} (use "#<index>" from getOutline, or a block id inside the table)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
table: tableRef,
|
||||||
|
deleted: true,
|
||||||
|
verify: mutation.verify,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the plain-text content of cell `[row, col]` (0-based) in a table on the
|
||||||
|
* LIVE collab document, replacing the cell's content with a single text
|
||||||
|
* paragraph (the cell's first-paragraph id is preserved). `tableRef` is
|
||||||
|
* `#<index>` or a block id inside the target table. The helper's out-of-range
|
||||||
|
* error propagates; a missing table throws here.
|
||||||
|
*/
|
||||||
|
async tableUpdateCell(
|
||||||
|
pageId: string,
|
||||||
|
tableRef: string,
|
||||||
|
row: number,
|
||||||
|
col: number,
|
||||||
|
text: string,
|
||||||
|
) {
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
const collabToken = await this.getCollabTokenWithReauth();
|
||||||
|
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||||
|
const pageUuid = await this.resolvePageId(pageId);
|
||||||
|
|
||||||
|
let updated = false;
|
||||||
|
const mutation = await mutatePageContent(
|
||||||
|
pageUuid,
|
||||||
|
collabToken,
|
||||||
|
this.apiUrl,
|
||||||
|
(liveDoc) => {
|
||||||
|
updated = false;
|
||||||
|
const { doc: nd, updated: upd } = updateTableCell(
|
||||||
|
liveDoc,
|
||||||
|
tableRef,
|
||||||
|
row,
|
||||||
|
col,
|
||||||
|
text,
|
||||||
|
);
|
||||||
|
updated = upd;
|
||||||
|
if (!updated) return null; // table not found -> skip the write entirely
|
||||||
|
return nd;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new Error(
|
||||||
|
`tableUpdateCell: no table found for "${tableRef}" on page ${pageId} (use "#<index>" from getOutline, or a block id inside the table)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
table: tableRef,
|
||||||
|
row,
|
||||||
|
col,
|
||||||
|
verify: mutation.verify,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new page with title and content.
|
||||||
|
* Uses the /pages/import workaround (the only endpoint accepting content),
|
||||||
|
* then moves the page and restores the exact title: the import endpoint
|
||||||
|
* derives the title from the FILENAME and replaces spaces with
|
||||||
|
* underscores, so we explicitly re-set it via /pages/update afterwards.
|
||||||
|
*/
|
||||||
|
}
|
||||||
|
return TablesMixin;
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
// Auto-split from client.ts (issue #450). Mixin over the shared client context.
|
||||||
|
// Bodies are VERBATIM from the original DocmostClient; only the enclosing class
|
||||||
|
// changed to a mixin factory. See client/context.ts for the shared base.
|
||||||
|
import type { GConstructor, DocmostClientContext } from "./context.js";
|
||||||
|
import {
|
||||||
|
updatePageContentRealtime,
|
||||||
|
replacePageContent,
|
||||||
|
markdownToProseMirror,
|
||||||
|
markdownToProseMirrorCanonical,
|
||||||
|
mutatePageContent,
|
||||||
|
assertYjsEncodable,
|
||||||
|
MutationResult,
|
||||||
|
} from "../lib/collaboration.js";
|
||||||
|
import { diffDocs, summarizeChange } from "../lib/diff.js";
|
||||||
|
import {
|
||||||
|
blockText,
|
||||||
|
walk,
|
||||||
|
getList,
|
||||||
|
insertMarkerAfter,
|
||||||
|
setCalloutRange,
|
||||||
|
noteItem,
|
||||||
|
mdToInlineNodes,
|
||||||
|
commentsToFootnotes,
|
||||||
|
canonicalizeFootnotes,
|
||||||
|
insertInlineFootnote,
|
||||||
|
mergeFootnoteDefinitions,
|
||||||
|
} from "../lib/transforms.js";
|
||||||
|
import { normalizeAndMergeFootnotes } from "../lib/footnote-normalize-merge.js";
|
||||||
|
import vm from "node:vm";
|
||||||
|
|
||||||
|
// Public method surface of TransformsMixin (issue #450) — a NAMED type so the factory
|
||||||
|
// return type is expressible in the emitted .d.ts (the anonymous mixin class
|
||||||
|
// carries the base's protected shared state, which would otherwise trip TS4094).
|
||||||
|
// Derived from the class below; `implements ITransformsMixin` fails to compile on drift.
|
||||||
|
export interface ITransformsMixin {
|
||||||
|
transformPage(pageId: string, transformJs: string, opts?: { dryRun?: boolean; deleteComments?: boolean }): any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TransformsMixin<TBase extends GConstructor<DocmostClientContext>>(Base: TBase): GConstructor<DocmostClientContext & ITransformsMixin> & TBase {
|
||||||
|
abstract class TransformsMixin extends Base implements ITransformsMixin {
|
||||||
|
/**
|
||||||
|
* Edit a page by running an arbitrary user-supplied JS transform against the
|
||||||
|
* live document, with a diff preview + page-history safety net.
|
||||||
|
*
|
||||||
|
* The transform string is evaluated as `(doc, ctx) => doc` inside a node:vm
|
||||||
|
* sandbox: it gets ONLY `{ doc, ctx, structuredClone, console }` as globals,
|
||||||
|
* a 5s timeout, and NO access to require/process/fs/network. It must return a
|
||||||
|
* `{ type: "doc" }` node, which is validated structurally before any write.
|
||||||
|
*
|
||||||
|
* `ctx` exposes:
|
||||||
|
* - comments: the page's comments (fetched before the live read);
|
||||||
|
* - log: an array the transform can push diagnostics to (via console.log);
|
||||||
|
* - consume(id): mark a comment id as consumed (for deleteComments);
|
||||||
|
* - helpers: the transforms.ts primitives + commentsToFootnotes.
|
||||||
|
*
|
||||||
|
* Footnote convention used by the helpers: footnote markers are plain "[N]"
|
||||||
|
* text in the body, and the notes are an orderedList under a heading whose
|
||||||
|
* text is "Примечания переводчика".
|
||||||
|
*
|
||||||
|
* dryRun (default true): read the page's current content, run the transform,
|
||||||
|
* and return `{ pushed:false, diff, log }` WITHOUT opening the collab socket.
|
||||||
|
* Otherwise the transform runs atomically inside mutatePageContent, optionally
|
||||||
|
* deletes consumed comments, and returns the new historyId + diff + log.
|
||||||
|
*/
|
||||||
|
async transformPage(
|
||||||
|
pageId: string,
|
||||||
|
transformJs: string,
|
||||||
|
opts: { dryRun?: boolean; deleteComments?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const dryRun = opts.dryRun ?? true;
|
||||||
|
const deleteComments = opts.deleteComments ?? false;
|
||||||
|
|
||||||
|
await this.ensureAuthenticated();
|
||||||
|
// Full feed (incl. resolved): a page transform (e.g. comments -> footnotes)
|
||||||
|
// must operate on every comment, so it opts into the unfiltered feed.
|
||||||
|
const comments = (await this.listComments(pageId, true)).items;
|
||||||
|
|
||||||
|
// ctx handed to the sandbox. consume() records ids; helpers are the pure
|
||||||
|
// transform primitives. log is captured from console.log inside the sandbox.
|
||||||
|
const ctx = {
|
||||||
|
comments,
|
||||||
|
log: [] as string[],
|
||||||
|
consumed: new Set<string>(),
|
||||||
|
consume(id: string) {
|
||||||
|
this.consumed.add(id);
|
||||||
|
},
|
||||||
|
helpers: {
|
||||||
|
blockText,
|
||||||
|
walk,
|
||||||
|
getList,
|
||||||
|
insertMarkerAfter,
|
||||||
|
setCalloutRange,
|
||||||
|
noteItem,
|
||||||
|
mdToInlineNodes,
|
||||||
|
commentsToFootnotes,
|
||||||
|
canonicalizeFootnotes,
|
||||||
|
insertInlineFootnote,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Captured oldDoc / newDoc for the diff (set inside runTransform).
|
||||||
|
let oldDoc: any;
|
||||||
|
let newDoc: any;
|
||||||
|
|
||||||
|
// SYNCHRONOUS transform runner — safe to call inside mutatePageContent's
|
||||||
|
// onSynced (no await between the live read and the write).
|
||||||
|
const runTransform = (liveDoc: any): any => {
|
||||||
|
oldDoc = structuredClone(liveDoc);
|
||||||
|
const sandbox: Record<string, any> = {
|
||||||
|
doc: structuredClone(liveDoc),
|
||||||
|
ctx,
|
||||||
|
structuredClone,
|
||||||
|
console: {
|
||||||
|
log: (...a: any[]) => ctx.log.push(a.map((x) => String(x)).join(" ")),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// Wrap the provided string in parentheses so both an expression-arrow
|
||||||
|
// (`(doc, ctx) => {...}`) and a parenthesized function work. Run it in a
|
||||||
|
// fresh context with no require/process/module so the transform cannot
|
||||||
|
// touch fs/network/process. 5s wall-clock timeout.
|
||||||
|
let fn: any;
|
||||||
|
try {
|
||||||
|
fn = vm.runInNewContext("(" + transformJs + ")", sandbox, {
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
throw new Error(`transform did not compile: ${e?.message ?? e}`);
|
||||||
|
}
|
||||||
|
if (typeof fn !== "function") {
|
||||||
|
throw new Error(
|
||||||
|
"transform must evaluate to a function (doc, ctx) => doc",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const raw = vm.runInNewContext(
|
||||||
|
"f(d, c)",
|
||||||
|
{ f: fn, d: sandbox.doc, c: ctx },
|
||||||
|
{ timeout: 5000 },
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!raw ||
|
||||||
|
typeof raw !== "object" ||
|
||||||
|
raw.type !== "doc" ||
|
||||||
|
!Array.isArray(raw.content)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
'transform must return a ProseMirror doc node ({ type:"doc", content:[...] })',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Validate the RAW transform output FIRST (structure — including the
|
||||||
|
// MAX_DEPTH guard — and URLs), mirroring updatePageJson. The canonicalizer
|
||||||
|
// recurses without a depth limiter, so validating after it would turn a
|
||||||
|
// too-deep doc into an opaque "Maximum call stack size exceeded" instead of
|
||||||
|
// the intended "nesting exceeds the maximum depth" error.
|
||||||
|
this.validateDocStructure(raw);
|
||||||
|
this.validateDocUrls(raw);
|
||||||
|
// Auto-canonicalize footnotes after the transform (idempotent): no write
|
||||||
|
// path can leave footnotes out of order / orphaned / in a raw `[^id]`
|
||||||
|
// block. In a dryRun preview this may surface footnote edits the script
|
||||||
|
// author did not write (the canonicalizer tidied them) — that is expected.
|
||||||
|
// #419: normalize + merge glyph-forked definitions before canonicalizing.
|
||||||
|
const result = canonicalizeFootnotes(normalizeAndMergeFootnotes(raw));
|
||||||
|
newDoc = result;
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (dryRun) {
|
||||||
|
// Preview only: run against the current REST snapshot, never open the
|
||||||
|
// socket. oldDoc/newDoc are captured by runTransform.
|
||||||
|
const raw = await this.getPageRaw(pageId);
|
||||||
|
const current = raw.content || { type: "doc", content: [] };
|
||||||
|
runTransform(current);
|
||||||
|
// Run an independent Yjs-encodability check (same sanitize + schema as the
|
||||||
|
// apply path), so the preview fails with the same descriptive error when
|
||||||
|
// the doc is not encodable instead of returning a misleadingly-green diff.
|
||||||
|
assertYjsEncodable(newDoc);
|
||||||
|
return {
|
||||||
|
pushed: false,
|
||||||
|
diff: diffDocs(oldDoc, newDoc),
|
||||||
|
log: ctx.log,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply atomically against the live doc.
|
||||||
|
const collabToken = await this.getCollabTokenWithReauth();
|
||||||
|
// Open the collab doc by the canonical UUID, never the slugId (#260).
|
||||||
|
const pageUuid = await this.resolvePageId(pageId);
|
||||||
|
const mutation = await mutatePageContent(
|
||||||
|
pageUuid,
|
||||||
|
collabToken,
|
||||||
|
this.apiUrl,
|
||||||
|
runTransform,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Optionally delete consumed comments (best-effort; a delete failure must
|
||||||
|
// not undo the successful write).
|
||||||
|
const deletedComments: string[] = [];
|
||||||
|
if (deleteComments) {
|
||||||
|
for (const id of ctx.consumed) {
|
||||||
|
try {
|
||||||
|
await this.deleteComment(id);
|
||||||
|
deletedComments.push(id);
|
||||||
|
} catch (e) {
|
||||||
|
if (process.env.DEBUG) {
|
||||||
|
console.error(`transform: failed to delete comment ${id}:`, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the newest historyId (Docmost snapshots on the write above).
|
||||||
|
let historyId: string | null = null;
|
||||||
|
try {
|
||||||
|
const hist = await this.listPageHistory(pageId);
|
||||||
|
historyId = hist.items?.[0]?.id ?? null;
|
||||||
|
} catch (e) {
|
||||||
|
if (process.env.DEBUG) {
|
||||||
|
console.error("transform: failed to fetch history id:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pushed: true,
|
||||||
|
historyId,
|
||||||
|
diff: diffDocs(oldDoc, newDoc),
|
||||||
|
deletedComments,
|
||||||
|
log: ctx.log,
|
||||||
|
verify: mutation.verify,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TransformsMixin;
|
||||||
|
}
|
||||||
+44
-11
@@ -435,30 +435,63 @@ server.registerTool(
|
|||||||
// Tool: search
|
// Tool: search
|
||||||
// INTENTIONAL per-transport divergence (not shared): the in-app `searchPages`
|
// INTENTIONAL per-transport divergence (not shared): the in-app `searchPages`
|
||||||
// runs a semantic + keyword hybrid (RRF) with in-process access control and a
|
// runs a semantic + keyword hybrid (RRF) with in-process access control and a
|
||||||
// different schema (limit 1-20); this transport is a plain REST full-text search
|
// different schema; this transport is the #443 agent-lookup search — a hybrid
|
||||||
// (limit up to 100). Different behaviour AND schema, so kept per-layer.
|
// substring + full-text search that also returns each hit's location (`path`)
|
||||||
|
// and a windowed `snippet`, so one call answers "where is it and what's in it".
|
||||||
|
// The in-app hybrid-RRF search is deliberately NOT touched. Different behaviour
|
||||||
|
// AND schema, so kept per-layer.
|
||||||
|
//
|
||||||
|
// STANDALONE-vs-STOCK-UPSTREAM: the client sends the opt-in `substring`/
|
||||||
|
// `parentPageId`/`titleOnly` DTO fields. A stock upstream server validates the
|
||||||
|
// DTO with `whitelist: true` and silently strips these unknown fields, so the
|
||||||
|
// request degrades gracefully to plain FTS (no path/snippet, current shape).
|
||||||
|
//
|
||||||
|
// EE/TYPESENSE DEGRADATION (#443): on an instance whose SEARCH_DRIVER is
|
||||||
|
// `typesense`, the server routes this request to the Typesense backend, which
|
||||||
|
// does NOT implement agent-lookup — the substring/path/snippet/tiering is
|
||||||
|
// ignored and the response degrades to plain Typesense FTS. The rich lookup
|
||||||
|
// shape is only produced by the native Postgres search driver.
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"search",
|
"search",
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Full-text search for pages and content across the whole workspace. " +
|
"Find pages by a fragment of a technical string (hostnames, IPs, IDs " +
|
||||||
"Results are bounded by `limit` (1-100; when omitted the server applies " +
|
"like `srv.local`, `10.0.12`, `WB-MGE-30D86B`) — one call returns each " +
|
||||||
"its own default).",
|
"hit's location (`path`: ancestor titles root→parent) and a `snippet` " +
|
||||||
|
"around the first match, so you rarely need a follow-up get_page. " +
|
||||||
|
"Matches substrings literally (dots/dashes/digits are not tokenized) as " +
|
||||||
|
"well as full-text. Returns `{ pageId, title, path, snippet, score }` " +
|
||||||
|
"sorted by `score` (a per-response relevance float).",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
query: z.string().min(1).describe("Search query"),
|
query: z.string().min(1).describe("Search query"),
|
||||||
|
spaceId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Restrict the search to a single space"),
|
||||||
|
parentPageId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Restrict to a page and all its descendants (the page itself included)",
|
||||||
|
),
|
||||||
|
titleOnly: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe("Match page titles only; skip page text"),
|
||||||
limit: z
|
limit: z
|
||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
.min(1)
|
.min(1)
|
||||||
.max(100)
|
.max(50)
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Max results to return (max 100)"),
|
.describe("Max results to return (1-50, default 10)"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ query, limit }) => {
|
async ({ query, spaceId, parentPageId, titleOnly, limit }) => {
|
||||||
// The tool exposes no spaceId filter, so pass undefined for the client's
|
const result = await docmostClient.search(query, spaceId, limit, {
|
||||||
// optional spaceId parameter and forward limit into its correct slot.
|
parentPageId,
|
||||||
const result = await docmostClient.search(query, undefined, limit);
|
titleOnly,
|
||||||
|
});
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -440,9 +440,16 @@ export class CollabSession {
|
|||||||
// must stay synchronous (no await). While the JS event loop is not
|
// must stay synchronous (no await). While the JS event loop is not
|
||||||
// yielded, no incoming remote update can interleave, so any already-synced
|
// yielded, no incoming remote update can interleave, so any already-synced
|
||||||
// concurrent edits are preserved in liveDoc.
|
// concurrent edits are preserved in liveDoc.
|
||||||
|
//
|
||||||
|
// INVARIANT 1 is machine-checked: the BEGIN/END markers below delimit the
|
||||||
|
// no-await window, and test/unit/no-await-critical-window.test.mjs scans
|
||||||
|
// this source and FAILS if any `await` (or `for await`/`yield`) appears
|
||||||
|
// between them. Do NOT add an await inside this block — an accidental
|
||||||
|
// async boundary here silently reopens the clobber-live-edits race (#152).
|
||||||
let newDoc: any;
|
let newDoc: any;
|
||||||
let beforeDoc: any;
|
let beforeDoc: any;
|
||||||
try {
|
try {
|
||||||
|
// === MUTATE-CRITICAL-WINDOW: BEGIN (no await between here and END #449) ===
|
||||||
let liveDoc = TiptapTransformer.fromYdoc(this.ydoc, "default");
|
let liveDoc = TiptapTransformer.fromYdoc(this.ydoc, "default");
|
||||||
if (
|
if (
|
||||||
!liveDoc ||
|
!liveDoc ||
|
||||||
@@ -480,6 +487,7 @@ export class CollabSession {
|
|||||||
// ids of unchanged nodes, so an open editor's cursor is not yanked to the
|
// ids of unchanged nodes, so an open editor's cursor is not yanked to the
|
||||||
// end of the document on every agent write.
|
// end of the document on every agent write.
|
||||||
applyDocToFragment(this.ydoc, newDoc);
|
applyDocToFragment(this.ydoc, newDoc);
|
||||||
|
// === MUTATE-CRITICAL-WINDOW: END (#449) ===
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Includes errors thrown by transform (e.g. "afterText not found",
|
// Includes errors thrown by transform (e.g. "afterText not found",
|
||||||
// "text not found"): propagate them verbatim to the caller.
|
// "text not found"): propagate them verbatim to the caller.
|
||||||
|
|||||||
+161
-51
@@ -14,7 +14,19 @@
|
|||||||
* signature.
|
* signature.
|
||||||
*
|
*
|
||||||
* If recreateTransform / the changeset throws on a pathological document pair,
|
* If recreateTransform / the changeset throws on a pathological document pair,
|
||||||
* we fall back to a coarse block-level text diff so the tool never hard-fails.
|
* OR the pair is too large to diff cheaply (see the size guard below), we fall
|
||||||
|
* back to a coarse block-level text diff so the tool never hard-fails and never
|
||||||
|
* pins the event loop.
|
||||||
|
*
|
||||||
|
* SIZE GUARD (issue #464 — prod CPU-DoS). recreateTransform computes its diff via
|
||||||
|
* rfc6902.createPatch, whose array diff is O(n·m) Levenshtein per array pair and
|
||||||
|
* whose per-run word diff is O(w²); on a large/heavily-changed doc this runs for
|
||||||
|
* seconds-to-hours and starves the whole process (BullMQ, Redis lock renewals,
|
||||||
|
* embeddings). It never THROWS — it just never finishes — so the try/catch below
|
||||||
|
* cannot save us. Because diffDocs runs on EVERY in-app/MCP content edit's verify
|
||||||
|
* report, we PRE-FLIGHT the doc size and route anything above a cheap cap straight
|
||||||
|
* to the coarse fallback (the same shape the catch produces). Same cap+fallback
|
||||||
|
* pattern as the ELK-layout DoS fix (#440 / c917dcc3).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Node } from "@tiptap/pm/model";
|
import { Node } from "@tiptap/pm/model";
|
||||||
@@ -72,6 +84,56 @@ function countNodes(doc: any, pred: (node: any) => boolean): number {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Issue #464: pre-flight size guard for the precise diff ------------------
|
||||||
|
// Defaults are BENCHMARK-derived on the recreateTransform(complexSteps:false,
|
||||||
|
// wordDiffs:true, simplifyDiff:true) pipeline, chosen so the WORST case (a fully
|
||||||
|
// re-written doc — the adversarial shape that drove the incident) keeps the
|
||||||
|
// synchronous block under ~200ms REGARDLESS of input:
|
||||||
|
// - 150 total nodes: worst-case pair ~176ms; the O(node²) array diff crosses
|
||||||
|
// 200ms at ~170 nodes and then explodes super-linearly (400 nodes ~1.3s,
|
||||||
|
// 800 ~5.5s), so cap just below the crossover.
|
||||||
|
// - 12 KiB serialized JSON: an independent axis, because the per-run word diff
|
||||||
|
// is O(words²) — a FEW nodes with very long text runs is dangerous even at a
|
||||||
|
// low node count (17 nodes / ~11 KiB ~176ms, / ~14 KiB ~290ms). A node-light
|
||||||
|
// but byte-heavy doc is still refused.
|
||||||
|
// Either metric over its cap routes to the coarse fallback. Both are env-tunable
|
||||||
|
// for operators who accept more CPU in exchange for exact diffs on larger docs.
|
||||||
|
const DEFAULT_MAX_NODES = 150;
|
||||||
|
const DEFAULT_MAX_BYTES = 12 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a positive-integer env override, falling back to `dflt`. Garbage / unset /
|
||||||
|
* non-finite / non-positive all fall back (so the guard can never be accidentally
|
||||||
|
* disabled by a malformed value). Read fresh on every call so a test / operator
|
||||||
|
* can flip the knob without a restart.
|
||||||
|
*/
|
||||||
|
function readPositiveIntEnv(name: string, dflt: number): number {
|
||||||
|
const raw = parseInt(process.env[name] ?? "", 10);
|
||||||
|
return Number.isFinite(raw) && raw > 0 ? raw : dflt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the pair is too large for the precise (recreateTransform) diff and
|
||||||
|
* must degrade to the coarse fallback. Takes the MAX of the two docs on each
|
||||||
|
* metric so an ASYMMETRIC pair (a small new doc vs a huge old doc, or vice
|
||||||
|
* versa) — which still explodes rfc6902 — is caught. Cheap: one node walk +
|
||||||
|
* one JSON.stringify per doc, both O(size).
|
||||||
|
*/
|
||||||
|
function exceedsDiffSizeGuard(oldDoc: any, newDoc: any): boolean {
|
||||||
|
const maxNodes = readPositiveIntEnv("MCP_DIFF_MAX_NODES", DEFAULT_MAX_NODES);
|
||||||
|
const maxBytes = readPositiveIntEnv("MCP_DIFF_MAX_BYTES", DEFAULT_MAX_BYTES);
|
||||||
|
const nodes = Math.max(
|
||||||
|
countNodes(oldDoc, () => true),
|
||||||
|
countNodes(newDoc, () => true),
|
||||||
|
);
|
||||||
|
if (nodes > maxNodes) return true;
|
||||||
|
const bytes = Math.max(
|
||||||
|
JSON.stringify(oldDoc)?.length ?? 0,
|
||||||
|
JSON.stringify(newDoc)?.length ?? 0,
|
||||||
|
);
|
||||||
|
return bytes > maxBytes;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Count UNIQUE links in a JSON doc by their `href`. A single link can be split
|
* Count UNIQUE links in a JSON doc by their `href`. A single link can be split
|
||||||
* across several adjacent text runs (e.g. a "link+bold" run followed by a "link"
|
* across several adjacent text runs (e.g. a "link+bold" run followed by a "link"
|
||||||
@@ -226,6 +288,81 @@ function coarseDiff(oldDoc: any, newDoc: any): DiffChange[] {
|
|||||||
return changes;
|
return changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Accumulated textual changes plus their derived char/block tallies. */
|
||||||
|
interface DiffTally {
|
||||||
|
changes: DiffChange[];
|
||||||
|
inserted: number;
|
||||||
|
deleted: number;
|
||||||
|
changedBlocks: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produce the coarse-fallback tally for a pair. This is the SINGLE source of the
|
||||||
|
* `fellBack:true` result shape, shared by BOTH degrade paths in diffDocs (the
|
||||||
|
* pre-flight size guard and the recreateTransform catch) so they behave and
|
||||||
|
* report identically.
|
||||||
|
*/
|
||||||
|
function coarseDiffTally(oldDoc: any, newDoc: any): DiffTally {
|
||||||
|
const changes = coarseDiff(oldDoc, newDoc);
|
||||||
|
let inserted = 0;
|
||||||
|
let deleted = 0;
|
||||||
|
const changedBlocks = new Set<string>();
|
||||||
|
for (const c of changes) {
|
||||||
|
if (c.op === "insert") inserted += c.text.length;
|
||||||
|
else deleted += c.text.length;
|
||||||
|
if (c.block) changedBlocks.add(c.op[0] + ":" + c.block);
|
||||||
|
}
|
||||||
|
return { changes, inserted, deleted, changedBlocks };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the PRECISE tally via the recreateTransform pipeline. Callers MUST
|
||||||
|
* gate this behind the size guard (it can block the event loop for a large pair)
|
||||||
|
* and wrap it in try/catch (a pathological pair can throw); on either the guard
|
||||||
|
* or a throw, use `coarseDiffTally` instead. Kept as a sibling of
|
||||||
|
* `coarseDiffTally` so both produce the same `DiffTally` shape.
|
||||||
|
*/
|
||||||
|
function preciseDiffTally(oldDocJson: any, newDocJson: any): DiffTally {
|
||||||
|
const oldNode = Node.fromJSON(docmostSchema, oldDocJson);
|
||||||
|
const newNode = Node.fromJSON(docmostSchema, newDocJson);
|
||||||
|
const tr = recreateTransform(oldNode, newNode, {
|
||||||
|
complexSteps: false,
|
||||||
|
wordDiffs: true,
|
||||||
|
simplifyDiff: true,
|
||||||
|
});
|
||||||
|
const changeSet = ChangeSet.create(oldNode).addSteps(tr.doc, tr.mapping.maps, []);
|
||||||
|
const simplified = simplifyChanges(changeSet.changes, newNode);
|
||||||
|
|
||||||
|
const changes: DiffChange[] = [];
|
||||||
|
let inserted = 0;
|
||||||
|
let deleted = 0;
|
||||||
|
const changedBlocks = new Set<string>();
|
||||||
|
|
||||||
|
for (const change of simplified) {
|
||||||
|
// Deleted text lives in the OLD doc coordinate range [fromA, toA).
|
||||||
|
if (change.toA > change.fromA) {
|
||||||
|
const text = oldNode.textBetween(change.fromA, change.toA, "\n", " ");
|
||||||
|
if (text.length > 0) {
|
||||||
|
deleted += text.length;
|
||||||
|
const block = blockContextAt(oldNode, change.fromA);
|
||||||
|
changes.push({ op: "delete", block, text });
|
||||||
|
if (block) changedBlocks.add("d:" + block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Inserted text lives in the NEW doc coordinate range [fromB, toB).
|
||||||
|
if (change.toB > change.fromB) {
|
||||||
|
const text = newNode.textBetween(change.fromB, change.toB, "\n", " ");
|
||||||
|
if (text.length > 0) {
|
||||||
|
inserted += text.length;
|
||||||
|
const block = blockContextAt(newNode, change.fromB);
|
||||||
|
changes.push({ op: "insert", block, text });
|
||||||
|
if (block) changedBlocks.add("i:" + block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { changes, inserted, deleted, changedBlocks };
|
||||||
|
}
|
||||||
|
|
||||||
/** Build the human-readable unified-ish markdown summary. */
|
/** Build the human-readable unified-ish markdown summary. */
|
||||||
function renderMarkdown(
|
function renderMarkdown(
|
||||||
result: Omit<DiffResult, "markdown">,
|
result: Omit<DiffResult, "markdown">,
|
||||||
@@ -276,66 +413,39 @@ export function diffDocs(
|
|||||||
newDocJson: any,
|
newDocJson: any,
|
||||||
notesHeading: string = "Примечания переводчика",
|
notesHeading: string = "Примечания переводчика",
|
||||||
): DiffResult {
|
): DiffResult {
|
||||||
|
// computeIntegrity is cheap (linear node walks) and its counts are needed in
|
||||||
|
// BOTH the precise and coarse paths, so it always runs first.
|
||||||
const integrity = computeIntegrity(oldDocJson, newDocJson, notesHeading);
|
const integrity = computeIntegrity(oldDocJson, newDocJson, notesHeading);
|
||||||
|
|
||||||
let changes: DiffChange[] = [];
|
|
||||||
let inserted = 0;
|
|
||||||
let deleted = 0;
|
|
||||||
let fellBack = false;
|
let fellBack = false;
|
||||||
const changedBlocks = new Set<string>();
|
let tally: DiffTally;
|
||||||
|
|
||||||
try {
|
// Pre-flight size guard (#464): a too-large pair would make recreateTransform
|
||||||
const oldNode = Node.fromJSON(docmostSchema, oldDocJson);
|
// block the event loop for seconds-to-hours WITHOUT throwing, so route it to
|
||||||
const newNode = Node.fromJSON(docmostSchema, newDocJson);
|
// the coarse fallback BEFORE calling recreateTransform at all. Both this path
|
||||||
const tr = recreateTransform(oldNode, newNode, {
|
// and the catch below go through coarseDiffTally for an identical `fellBack`
|
||||||
complexSteps: false,
|
// result shape.
|
||||||
wordDiffs: true,
|
if (exceedsDiffSizeGuard(oldDocJson, newDocJson)) {
|
||||||
simplifyDiff: true,
|
|
||||||
});
|
|
||||||
const changeSet = ChangeSet.create(oldNode).addSteps(
|
|
||||||
tr.doc,
|
|
||||||
tr.mapping.maps,
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const simplified = simplifyChanges(changeSet.changes, newNode);
|
|
||||||
|
|
||||||
for (const change of simplified) {
|
|
||||||
// Deleted text lives in the OLD doc coordinate range [fromA, toA).
|
|
||||||
if (change.toA > change.fromA) {
|
|
||||||
const text = oldNode.textBetween(change.fromA, change.toA, "\n", " ");
|
|
||||||
if (text.length > 0) {
|
|
||||||
deleted += text.length;
|
|
||||||
const block = blockContextAt(oldNode, change.fromA);
|
|
||||||
changes.push({ op: "delete", block, text });
|
|
||||||
if (block) changedBlocks.add("d:" + block);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Inserted text lives in the NEW doc coordinate range [fromB, toB).
|
|
||||||
if (change.toB > change.fromB) {
|
|
||||||
const text = newNode.textBetween(change.fromB, change.toB, "\n", " ");
|
|
||||||
if (text.length > 0) {
|
|
||||||
inserted += text.length;
|
|
||||||
const block = blockContextAt(newNode, change.fromB);
|
|
||||||
changes.push({ op: "insert", block, text });
|
|
||||||
if (block) changedBlocks.add("i:" + block);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Pathological pair: degrade to a coarse block-level diff so we never throw.
|
|
||||||
fellBack = true;
|
fellBack = true;
|
||||||
changes = coarseDiff(oldDocJson, newDocJson);
|
tally = coarseDiffTally(oldDocJson, newDocJson);
|
||||||
for (const c of changes) {
|
} else {
|
||||||
if (c.op === "insert") inserted += c.text.length;
|
try {
|
||||||
else deleted += c.text.length;
|
tally = preciseDiffTally(oldDocJson, newDocJson);
|
||||||
if (c.block) changedBlocks.add(c.op[0] + ":" + c.block);
|
} catch {
|
||||||
|
// Pathological pair: degrade to a coarse block-level diff so we never throw.
|
||||||
|
fellBack = true;
|
||||||
|
tally = coarseDiffTally(oldDocJson, newDocJson);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const partial: Omit<DiffResult, "markdown"> = {
|
const partial: Omit<DiffResult, "markdown"> = {
|
||||||
summary: { inserted, deleted, blocksChanged: changedBlocks.size },
|
summary: {
|
||||||
|
inserted: tally.inserted,
|
||||||
|
deleted: tally.deleted,
|
||||||
|
blocksChanged: tally.changedBlocks.size,
|
||||||
|
},
|
||||||
integrity,
|
integrity,
|
||||||
changes,
|
changes: tally.changes,
|
||||||
};
|
};
|
||||||
return { ...partial, markdown: renderMarkdown(partial, fellBack) };
|
return { ...partial, markdown: renderMarkdown(partial, fellBack) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// ID-based cell operations for `drawioEditCells` (issue #425, stage 3).
|
||||||
|
//
|
||||||
|
// Instead of resending the whole XML (whose diff is fragile — draw.io reorders
|
||||||
|
// attributes, and a {search,replace} text match breaks on it), the model sends
|
||||||
|
// targeted operations keyed by cell id:
|
||||||
|
//
|
||||||
|
// { op: "add", xml: "<mxCell .../>" } // append a new cell
|
||||||
|
// { op: "update", cellId: "n3", xml: "<mxCell .../>" } // replace that cell
|
||||||
|
// { op: "delete", cellId: "n5" } // + CASCADE
|
||||||
|
//
|
||||||
|
// `delete` CASCADES: it removes the cell, every descendant cell whose parent
|
||||||
|
// chain leads to it (container children), AND every edge whose source or target
|
||||||
|
// is any deleted cell. Ids are STABLE across edits so diffs stay meaningful.
|
||||||
|
//
|
||||||
|
// Operations apply to the parsed DOM of the current model; the caller re-lints
|
||||||
|
// and rebuilds the .drawio.svg through the existing #423 pipeline afterwards.
|
||||||
|
|
||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
|
||||||
|
let _window: any = null;
|
||||||
|
function xmlWindow(): any {
|
||||||
|
if (!_window) _window = new JSDOM("").window;
|
||||||
|
return _window;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CellOp =
|
||||||
|
| { op: "add"; xml: string }
|
||||||
|
| { op: "update"; cellId: string; xml: string }
|
||||||
|
| { op: "delete"; cellId: string };
|
||||||
|
|
||||||
|
export class CellOpsError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(`drawioEditCells: ${message}`);
|
||||||
|
this.name = "CellOpsError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mxGraph root sentinels. id="0" is the graph root; id="1" is the default
|
||||||
|
// layer that parents every real cell. A delete targeting either would cascade
|
||||||
|
// through the whole diagram body (every cell chains up to "1"), so such an op is
|
||||||
|
// rejected outright.
|
||||||
|
const SENTINEL_IDS = new Set(["0", "1"]);
|
||||||
|
|
||||||
|
/** Parse a single `<mxCell …>…</mxCell>` fragment into an element, or throw. */
|
||||||
|
function parseCellFragment(xml: string): any {
|
||||||
|
const parser = new (xmlWindow().DOMParser)();
|
||||||
|
// Wrap so a self-closed or child-bearing single cell parses as one root.
|
||||||
|
const doc = parser.parseFromString(`<root>${xml}</root>`, "application/xml");
|
||||||
|
if (doc.getElementsByTagName("parsererror").length > 0) {
|
||||||
|
throw new CellOpsError(`operation xml is not well-formed: ${xml.slice(0, 120)}`);
|
||||||
|
}
|
||||||
|
const cells = doc.getElementsByTagName("mxCell");
|
||||||
|
if (cells.length !== 1) {
|
||||||
|
throw new CellOpsError(
|
||||||
|
`each add/update op must carry exactly one <mxCell> (got ${cells.length})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return cells[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All ids reachable as descendants of `rootId` via the parent relation. */
|
||||||
|
function collectDescendants(
|
||||||
|
rootId: string,
|
||||||
|
parentOf: Map<string, string | undefined>,
|
||||||
|
): Set<string> {
|
||||||
|
const doomed = new Set<string>([rootId]);
|
||||||
|
let grew = true;
|
||||||
|
while (grew) {
|
||||||
|
grew = false;
|
||||||
|
for (const [id, parent] of parentOf) {
|
||||||
|
if (!doomed.has(id) && parent != null && doomed.has(parent)) {
|
||||||
|
doomed.add(id);
|
||||||
|
grew = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return doomed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the operation list to a model XML string and return the new model XML.
|
||||||
|
* Uses the DOM so attribute order / formatting is preserved for untouched cells.
|
||||||
|
* Throws CellOpsError on an unknown target id or a malformed op fragment (so the
|
||||||
|
* model gets a precise error and nothing is half-applied).
|
||||||
|
*/
|
||||||
|
export function applyCellOps(modelXml: string, ops: CellOp[]): string {
|
||||||
|
if (!Array.isArray(ops) || ops.length === 0) {
|
||||||
|
throw new CellOpsError("operations must be a non-empty array");
|
||||||
|
}
|
||||||
|
const parser = new (xmlWindow().DOMParser)();
|
||||||
|
const doc = parser.parseFromString(modelXml, "application/xml");
|
||||||
|
if (doc.getElementsByTagName("parsererror").length > 0) {
|
||||||
|
throw new CellOpsError("the current diagram XML is not well-formed");
|
||||||
|
}
|
||||||
|
const root = doc.getElementsByTagName("root")[0];
|
||||||
|
if (!root) throw new CellOpsError("the current diagram has no <root> element");
|
||||||
|
|
||||||
|
const cellEls = () => Array.from(root.getElementsByTagName("mxCell")) as any[];
|
||||||
|
const byId = () => {
|
||||||
|
const m = new Map<string, any>();
|
||||||
|
for (const el of cellEls()) m.set(el.getAttribute("id") ?? "", el);
|
||||||
|
return m;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const op of ops) {
|
||||||
|
if (op.op === "add") {
|
||||||
|
const frag = parseCellFragment(op.xml);
|
||||||
|
const id = frag.getAttribute("id");
|
||||||
|
if (!id) throw new CellOpsError("an add op's <mxCell> is missing an id");
|
||||||
|
if (byId().has(id))
|
||||||
|
throw new CellOpsError(`add op id "${id}" already exists (use update)`);
|
||||||
|
root.appendChild(doc.importNode(frag, true));
|
||||||
|
} else if (op.op === "update") {
|
||||||
|
const map = byId();
|
||||||
|
const target = map.get(op.cellId);
|
||||||
|
if (!target)
|
||||||
|
throw new CellOpsError(`update target cell "${op.cellId}" does not exist`);
|
||||||
|
const frag = parseCellFragment(op.xml);
|
||||||
|
const newId = frag.getAttribute("id");
|
||||||
|
if (newId && newId !== op.cellId)
|
||||||
|
throw new CellOpsError(
|
||||||
|
`update op cellId "${op.cellId}" != the <mxCell> id "${newId}" (ids are stable)`,
|
||||||
|
);
|
||||||
|
// Replace the element in place so surrounding cells are untouched.
|
||||||
|
const imported = doc.importNode(frag, true);
|
||||||
|
target.parentNode.replaceChild(imported, target);
|
||||||
|
} else if (op.op === "delete") {
|
||||||
|
// Reject a sentinel-targeted delete BEFORE collecting descendants: "0"/"1"
|
||||||
|
// parent the entire diagram, so a cascade from either would wipe the whole
|
||||||
|
// model body (doomed.delete("0"/"1") only spared the sentinel itself, not
|
||||||
|
// its children).
|
||||||
|
if (SENTINEL_IDS.has(op.cellId))
|
||||||
|
throw new CellOpsError(
|
||||||
|
`cannot delete sentinel cell "${op.cellId}" (the graph root/default layer)`,
|
||||||
|
);
|
||||||
|
const map = byId();
|
||||||
|
if (!map.has(op.cellId))
|
||||||
|
throw new CellOpsError(`delete target cell "${op.cellId}" does not exist`);
|
||||||
|
// Build the parent relation over the CURRENT cells for the cascade.
|
||||||
|
const parentOf = new Map<string, string | undefined>();
|
||||||
|
for (const el of cellEls()) {
|
||||||
|
parentOf.set(el.getAttribute("id") ?? "", el.getAttribute("parent") ?? undefined);
|
||||||
|
}
|
||||||
|
const doomed = collectDescendants(op.cellId, parentOf);
|
||||||
|
// Cascade to edges whose source/target is any doomed cell.
|
||||||
|
for (const el of cellEls()) {
|
||||||
|
if (el.getAttribute("edge") !== "1") continue;
|
||||||
|
const src = el.getAttribute("source");
|
||||||
|
const tgt = el.getAttribute("target");
|
||||||
|
if ((src && doomed.has(src)) || (tgt && doomed.has(tgt))) {
|
||||||
|
doomed.add(el.getAttribute("id") ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Never delete the sentinels even if referenced by a malformed op.
|
||||||
|
doomed.delete("0");
|
||||||
|
doomed.delete("1");
|
||||||
|
for (const el of cellEls()) {
|
||||||
|
const id = el.getAttribute("id") ?? "";
|
||||||
|
if (doomed.has(id)) el.parentNode.removeChild(el);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new CellOpsError(`unknown op "${(op as any).op}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ser = new (xmlWindow().XMLSerializer)();
|
||||||
|
return ser.serializeToString(doc.documentElement);
|
||||||
|
}
|
||||||
@@ -0,0 +1,916 @@
|
|||||||
|
// Semantic graph -> draw.io pipeline for `drawioFromGraph` (issue #425, stage 3).
|
||||||
|
//
|
||||||
|
// The model describes a diagram SEMANTICALLY — nodes with a `kind` and an
|
||||||
|
// optional `icon`, groups (containers), edges with a `kind` — and NEVER sees a
|
||||||
|
// coordinate or a style string. This module owns the whole server-side pipeline:
|
||||||
|
//
|
||||||
|
// 1. validateGraph — a hand-written validator (no zod dependency, so this
|
||||||
|
// lib stays importable by client.ts without coupling to
|
||||||
|
// a zod major) that rejects malformed graphs early.
|
||||||
|
// 2. resolveNodeStyle — `icon` -> exact style via the shape catalog (#424);
|
||||||
|
// an UNKNOWN icon degrades to a generic shape by `kind`
|
||||||
|
// WITH the label (never an empty square). `kind` -> the
|
||||||
|
// preset palette slot.
|
||||||
|
// 3. graphToElk — graph -> ELK-JSON, honouring the layout hints
|
||||||
|
// (`layer`/`sameLayerAs` -> layer constraints, `pinned`
|
||||||
|
// -> a fixed node) and compound group nodes.
|
||||||
|
// 4. assembleModel — graph + ELK coordinates -> a full mxGraphModel XML
|
||||||
|
// that satisfies the #423 linter BY CONSTRUCTION
|
||||||
|
// (sentinels, transparent containers, relative child
|
||||||
|
// coords, cross-container edges parent="1", >=150px
|
||||||
|
// gaps from ELK spacing, escaped labels).
|
||||||
|
//
|
||||||
|
// The `layout` mode: "full" re-lays everything; "incremental" fixes existing
|
||||||
|
// coordinates (ELK interactive mode) and places only new nodes; "none" keeps the
|
||||||
|
// caller-provided/prior coordinates untouched.
|
||||||
|
|
||||||
|
import ELK from "elkjs/lib/elk.bundled.js";
|
||||||
|
import { JSDOM } from "jsdom";
|
||||||
|
import {
|
||||||
|
searchShapes,
|
||||||
|
awsServiceStyle,
|
||||||
|
type ShapeResult,
|
||||||
|
} from "./drawio-shapes.js";
|
||||||
|
import {
|
||||||
|
getPreset,
|
||||||
|
genericNodeStyle,
|
||||||
|
iconNodeStyle,
|
||||||
|
edgeStyle,
|
||||||
|
groupStyle,
|
||||||
|
type PresetData,
|
||||||
|
} from "./drawio-presets.js";
|
||||||
|
import { MIN_SHAPE_GAP } from "./drawio-xml.js";
|
||||||
|
|
||||||
|
// --- graph schema (plain TS + a hand validator) ----------------------------
|
||||||
|
|
||||||
|
export interface GraphNode {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind?: string;
|
||||||
|
/** Icon reference, e.g. "aws:lambda" | "azure:cosmos" | "lambda". */
|
||||||
|
icon?: string;
|
||||||
|
/** Group (container) id this node belongs to. */
|
||||||
|
group?: string;
|
||||||
|
/** Layer hint (ELK layerChoiceConstraint): 0-based column/row index. */
|
||||||
|
layer?: number;
|
||||||
|
/** Put this node in the same layer as another node id. */
|
||||||
|
sameLayerAs?: string;
|
||||||
|
/** Fix this node at exact coordinates (an ELK fixed node). */
|
||||||
|
pinned?: { x: number; y: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphGroup {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind?: string;
|
||||||
|
/** Parent group id — lets a group nest inside another group (e.g. subnet in VPC). */
|
||||||
|
group?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphEdge {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
label?: string;
|
||||||
|
kind?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Graph {
|
||||||
|
nodes: GraphNode[];
|
||||||
|
groups?: GraphGroup[];
|
||||||
|
edges?: GraphEdge[];
|
||||||
|
direction?: "LR" | "RL" | "TB" | "BT";
|
||||||
|
preset?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LayoutMode = "none" | "full" | "incremental";
|
||||||
|
|
||||||
|
/** A structured validation error (mirrors the drawio linter's shape loosely). */
|
||||||
|
export class GraphValidationError extends Error {
|
||||||
|
issues: string[];
|
||||||
|
constructor(issues: string[]) {
|
||||||
|
super(`drawioFromGraph: invalid graph — ${issues.join("; ")}`);
|
||||||
|
this.name = "GraphValidationError";
|
||||||
|
this.issues = issues;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_GRAPH_NODES = 500; // parity with drawio-layout's ELK_MAX_NODES.
|
||||||
|
// Edge/group caps mirror drawio-layout's ELK_MAX_EDGES. Without an edge cap a
|
||||||
|
// tiny node set with a huge edge list (e.g. 500 nodes / 200000 edges) passes
|
||||||
|
// node validation, then graphToElk/runElk exhausts the heap SYNCHRONOUSLY inside
|
||||||
|
// elk.bundled.js — before the 5s ELK timeout can fire and OUTSIDE it entirely
|
||||||
|
// for the mapper/assembler — crashing the worker on LLM-authored input. Reject
|
||||||
|
// the over-limit shape here, before any layout or assembly runs.
|
||||||
|
export const MAX_GRAPH_EDGES = 1000; // parity with drawio-layout's ELK_MAX_EDGES.
|
||||||
|
export const MAX_GRAPH_GROUPS = 500; // groups are compound ELK nodes; bound them too.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the graph structure BEFORE any layout/assembly so the model gets a
|
||||||
|
* precise, actionable error instead of a corrupt diagram. Throws
|
||||||
|
* GraphValidationError listing every problem.
|
||||||
|
*/
|
||||||
|
export function validateGraph(graph: Graph): void {
|
||||||
|
const issues: string[] = [];
|
||||||
|
if (!graph || typeof graph !== "object") {
|
||||||
|
throw new GraphValidationError(["graph must be an object"]);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(graph.nodes) || graph.nodes.length === 0) {
|
||||||
|
throw new GraphValidationError(["graph.nodes must be a non-empty array"]);
|
||||||
|
}
|
||||||
|
// Size caps FIRST (fail fast, before touching per-element loops) so an
|
||||||
|
// over-limit graph can never reach the layout engine and OOM the worker.
|
||||||
|
if (graph.nodes.length > MAX_GRAPH_NODES) {
|
||||||
|
throw new GraphValidationError([
|
||||||
|
`graph has ${graph.nodes.length} nodes (max ${MAX_GRAPH_NODES})`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (Array.isArray(graph.edges) && graph.edges.length > MAX_GRAPH_EDGES) {
|
||||||
|
throw new GraphValidationError([
|
||||||
|
`graph has ${graph.edges.length} edges (max ${MAX_GRAPH_EDGES})`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
if (Array.isArray(graph.groups) && graph.groups.length > MAX_GRAPH_GROUPS) {
|
||||||
|
throw new GraphValidationError([
|
||||||
|
`graph has ${graph.groups.length} groups (max ${MAX_GRAPH_GROUPS})`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeIds = new Set<string>();
|
||||||
|
const groupIds = new Set<string>();
|
||||||
|
for (const g of graph.groups ?? []) {
|
||||||
|
if (!g.id) issues.push("a group is missing its id");
|
||||||
|
else if (groupIds.has(g.id)) issues.push(`duplicate group id "${g.id}"`);
|
||||||
|
groupIds.add(g.id);
|
||||||
|
}
|
||||||
|
for (const n of graph.nodes) {
|
||||||
|
if (!n.id) issues.push("a node is missing its id");
|
||||||
|
else if (nodeIds.has(n.id)) issues.push(`duplicate node id "${n.id}"`);
|
||||||
|
else if (groupIds.has(n.id))
|
||||||
|
issues.push(`node id "${n.id}" collides with a group id`);
|
||||||
|
nodeIds.add(n.id);
|
||||||
|
if (typeof n.label !== "string" || n.label === "")
|
||||||
|
issues.push(`node "${n.id}" is missing a label`);
|
||||||
|
if (n.group != null && !groupIds.has(n.group))
|
||||||
|
issues.push(`node "${n.id}" references unknown group "${n.group}"`);
|
||||||
|
if (n.pinned != null) {
|
||||||
|
if (
|
||||||
|
typeof n.pinned.x !== "number" ||
|
||||||
|
typeof n.pinned.y !== "number" ||
|
||||||
|
!Number.isFinite(n.pinned.x) ||
|
||||||
|
!Number.isFinite(n.pinned.y)
|
||||||
|
)
|
||||||
|
issues.push(`node "${n.id}" has an invalid pinned {x,y}`);
|
||||||
|
}
|
||||||
|
if (n.layer != null && (!Number.isInteger(n.layer) || n.layer < 0))
|
||||||
|
issues.push(`node "${n.id}" has an invalid layer (must be a >=0 integer)`);
|
||||||
|
}
|
||||||
|
// sameLayerAs must reference an existing node (checked after all ids known).
|
||||||
|
for (const n of graph.nodes) {
|
||||||
|
if (n.sameLayerAs != null && !nodeIds.has(n.sameLayerAs))
|
||||||
|
issues.push(
|
||||||
|
`node "${n.id}" sameLayerAs references unknown node "${n.sameLayerAs}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const e of graph.edges ?? []) {
|
||||||
|
if (!e.from || !e.to) {
|
||||||
|
issues.push("an edge is missing from/to");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!nodeIds.has(e.from) && !groupIds.has(e.from))
|
||||||
|
issues.push(`edge from "${e.from}" resolves to no node/group`);
|
||||||
|
if (!nodeIds.has(e.to) && !groupIds.has(e.to))
|
||||||
|
issues.push(`edge to "${e.to}" resolves to no node/group`);
|
||||||
|
}
|
||||||
|
if (issues.length > 0) throw new GraphValidationError(issues);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- icon resolution -------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a node's `icon` reference to a concrete style-string + size via the
|
||||||
|
* shape catalog. Accepts "aws:lambda", "azure:cosmos", or a bare "lambda". An
|
||||||
|
* AWS `resIcon` name is built directly (exact service-icon template). Anything
|
||||||
|
* else goes through searchShapes. Returns null when nothing resolves — the
|
||||||
|
* caller then falls back to a generic shape by kind (never an empty box).
|
||||||
|
*/
|
||||||
|
export function resolveIcon(icon: string): ShapeResult | null {
|
||||||
|
const raw = icon.trim();
|
||||||
|
if (raw === "") return null;
|
||||||
|
let provider = "";
|
||||||
|
let name = raw;
|
||||||
|
const colon = raw.indexOf(":");
|
||||||
|
if (colon !== -1) {
|
||||||
|
provider = raw.slice(0, colon).trim().toLowerCase();
|
||||||
|
name = raw.slice(colon + 1).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider === "aws") {
|
||||||
|
// Prefer an exact resIcon match from the catalog (carries the right size and
|
||||||
|
// any rebrand/blocklist note); if the underscore/space name doesn't hit,
|
||||||
|
// build the canonical service-icon style directly so it is never an empty box.
|
||||||
|
const results = searchShapes(name.replace(/_/g, " "), { limit: 5 });
|
||||||
|
const aws4 = results.find((r) => r.style.includes("mxgraph.aws4"));
|
||||||
|
if (aws4) return aws4;
|
||||||
|
return {
|
||||||
|
style: awsServiceStyle(name.replace(/\s+/g, "_")),
|
||||||
|
w: 78,
|
||||||
|
h: 78,
|
||||||
|
title: name,
|
||||||
|
type: "vertex",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-AWS or bare name: fuzzy search the catalog. Take the top vertex hit,
|
||||||
|
// but REJECT a weak match (the fuzzy scorer can prefix-match an unrelated
|
||||||
|
// stencil, e.g. "not..." -> "Notebook"); require the hit's title to actually
|
||||||
|
// share a meaningful token with the query, otherwise degrade to generic-by-kind.
|
||||||
|
const q = provider ? `${provider} ${name}` : name;
|
||||||
|
const results = searchShapes(q, { limit: 8 });
|
||||||
|
const hit = results.find((r) => r.type !== "edge") ?? results[0];
|
||||||
|
if (!hit) return null;
|
||||||
|
if (!isRelevantMatch(name, hit.title)) return null;
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a resolved stencil is a genuine match for the requested icon name (as
|
||||||
|
* opposed to a loose prefix hit on an unrelated shape). True if any 3+ char
|
||||||
|
* token of the query appears in the stencil title, or vice-versa.
|
||||||
|
*/
|
||||||
|
function isRelevantMatch(name: string, title: string): boolean {
|
||||||
|
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
||||||
|
const qTokens = norm(name).split(/\s+/).filter((t) => t.length >= 3);
|
||||||
|
if (qTokens.length === 0) return true; // very short names: trust the scorer
|
||||||
|
const t = norm(title);
|
||||||
|
const tTokens = new Set(t.split(/\s+/));
|
||||||
|
for (const qt of qTokens) {
|
||||||
|
if (tTokens.has(qt)) return true;
|
||||||
|
if (t.includes(qt)) return true;
|
||||||
|
for (const tt of tTokens) if (tt.length >= 3 && qt.includes(tt)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide the final style + size for a node. When `icon` resolves, use the icon
|
||||||
|
* style (overlaid with a dark-preset font fix); otherwise a GENERIC shape by
|
||||||
|
* `kind` carrying the label. `resolved` reports whether an icon was found (used
|
||||||
|
* by the acceptance test that asserts no empty squares).
|
||||||
|
*/
|
||||||
|
export function resolveNodeStyle(
|
||||||
|
preset: PresetData,
|
||||||
|
node: GraphNode,
|
||||||
|
): { style: string; w: number; h: number; iconResolved: boolean } {
|
||||||
|
if (node.icon) {
|
||||||
|
const shape = resolveIcon(node.icon);
|
||||||
|
if (shape) {
|
||||||
|
return {
|
||||||
|
style: iconNodeStyle(preset, shape.style),
|
||||||
|
w: shape.w,
|
||||||
|
h: shape.h,
|
||||||
|
iconResolved: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Generic shape by kind, sized to the label so a long label never overflows.
|
||||||
|
const w = Math.max(120, estimateLabelWidth(node.label) + 32);
|
||||||
|
return { style: genericNodeStyle(preset, node.kind), w, h: 60, iconResolved: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rough rendered width of the longest label line at 12px (~0.6em/glyph). */
|
||||||
|
function estimateLabelWidth(label: string): number {
|
||||||
|
const lines = label.split(/\r?\n|
|<br\s*\/?>/i);
|
||||||
|
let longest = 0;
|
||||||
|
for (const l of lines) longest = Math.max(longest, l.trim().length);
|
||||||
|
return Math.ceil(longest * 12 * 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- graph -> ELK-JSON -----------------------------------------------------
|
||||||
|
|
||||||
|
interface ElkNode {
|
||||||
|
id: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
children?: ElkNode[];
|
||||||
|
layoutOptions?: Record<string, string>;
|
||||||
|
}
|
||||||
|
interface ElkEdge {
|
||||||
|
id: string;
|
||||||
|
sources: string[];
|
||||||
|
targets: string[];
|
||||||
|
}
|
||||||
|
interface ElkGraph extends ElkNode {
|
||||||
|
edges?: ElkEdge[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ELK_DIRECTION: Record<string, string> = {
|
||||||
|
LR: "RIGHT",
|
||||||
|
RL: "LEFT",
|
||||||
|
TB: "DOWN",
|
||||||
|
BT: "UP",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Sizes resolved per node id (from resolveNodeStyle), fed to the ELK mapper. */
|
||||||
|
export interface NodeSize {
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the ELK graph from the semantic graph + resolved node sizes. Compound
|
||||||
|
* group nodes nest their members (a group may itself nest in another group).
|
||||||
|
* `only` restricts the graph to a subset of node ids (used by the incremental
|
||||||
|
* path to lay out ONLY the new nodes). Layout HINTS (`layer`/`sameLayerAs`/
|
||||||
|
* `pinned`) are NOT encoded as ELK constraints here — ELK's constraint knobs are
|
||||||
|
* unreliable across versions — they are enforced deterministically AFTER layout
|
||||||
|
* by applyHints, which is exact and testable.
|
||||||
|
*/
|
||||||
|
export function graphToElk(
|
||||||
|
graph: Graph,
|
||||||
|
sizes: Map<string, NodeSize>,
|
||||||
|
opts: { only?: Set<string> } = {},
|
||||||
|
): ElkGraph {
|
||||||
|
const direction = ELK_DIRECTION[graph.direction ?? "LR"] ?? "RIGHT";
|
||||||
|
const only = opts.only;
|
||||||
|
const include = (id: string) => !only || only.has(id);
|
||||||
|
|
||||||
|
const makeNode = (n: GraphNode): ElkNode => {
|
||||||
|
const size = sizes.get(n.id) ?? { w: 140, h: 60 };
|
||||||
|
return { id: n.id, width: size.w, height: size.h };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Group children nest under their group node; ungrouped nodes are roots.
|
||||||
|
const groupNode = new Map<string, ElkNode>();
|
||||||
|
const usedGroups = new Set<string>();
|
||||||
|
for (const g of graph.groups ?? []) {
|
||||||
|
const size = sizes.get(g.id) ?? { w: 200, h: 150 };
|
||||||
|
groupNode.set(g.id, {
|
||||||
|
id: g.id,
|
||||||
|
width: size.w,
|
||||||
|
height: size.h,
|
||||||
|
children: [],
|
||||||
|
layoutOptions: {
|
||||||
|
"elk.algorithm": "layered",
|
||||||
|
"elk.direction": direction,
|
||||||
|
"elk.padding": "[top=40,left=30,bottom=30,right=30]",
|
||||||
|
"elk.layered.spacing.nodeNodeBetweenLayers": "170",
|
||||||
|
"elk.spacing.nodeNode": "170",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const roots: ElkNode[] = [];
|
||||||
|
for (const n of graph.nodes) {
|
||||||
|
if (!include(n.id)) continue;
|
||||||
|
const en = makeNode(n);
|
||||||
|
if (n.group && groupNode.has(n.group)) {
|
||||||
|
groupNode.get(n.group)!.children!.push(en);
|
||||||
|
usedGroups.add(n.group);
|
||||||
|
} else {
|
||||||
|
roots.push(en);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Nest group nodes into their parent group (a subnet inside a VPC); groups
|
||||||
|
// with no parent group become roots. Only groups that hold an included node.
|
||||||
|
const groupIdSet = new Set((graph.groups ?? []).map((g) => g.id));
|
||||||
|
for (const g of graph.groups ?? []) {
|
||||||
|
if (only && !usedGroups.has(g.id)) continue;
|
||||||
|
const en = groupNode.get(g.id)!;
|
||||||
|
if (g.group && groupIdSet.has(g.group) && g.group !== g.id && (!only || usedGroups.has(g.group))) {
|
||||||
|
groupNode.get(g.group)!.children!.push(en);
|
||||||
|
} else {
|
||||||
|
roots.push(en);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edges: endpoints may be nodes or groups; INCLUDE_CHILDREN spans the nesting.
|
||||||
|
const validIds = new Set<string>([
|
||||||
|
...graph.nodes.filter((n) => include(n.id)).map((n) => n.id),
|
||||||
|
...(graph.groups ?? []).map((g) => g.id),
|
||||||
|
]);
|
||||||
|
const edges: ElkEdge[] = [];
|
||||||
|
(graph.edges ?? []).forEach((e, i) => {
|
||||||
|
if (!validIds.has(e.from) || !validIds.has(e.to)) return;
|
||||||
|
edges.push({ id: `e${i}`, sources: [e.from], targets: [e.to] });
|
||||||
|
});
|
||||||
|
|
||||||
|
const rootOptions: Record<string, string> = {
|
||||||
|
"elk.algorithm": "layered",
|
||||||
|
"elk.direction": direction,
|
||||||
|
"elk.hierarchyHandling": "INCLUDE_CHILDREN",
|
||||||
|
"elk.layered.spacing.nodeNodeBetweenLayers": "170",
|
||||||
|
"elk.spacing.nodeNode": "170",
|
||||||
|
"elk.spacing.edgeNode": "40",
|
||||||
|
"elk.spacing.edgeEdge": "30",
|
||||||
|
"elk.padding": "[top=20,left=20,bottom=20,right=20]",
|
||||||
|
};
|
||||||
|
|
||||||
|
return { id: "root", layoutOptions: rootOptions, children: roots, edges };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforce the layout hints DETERMINISTICALLY on ELK's output (mutates `geo`):
|
||||||
|
* - `sameLayerAs`: snap the dependent node's LAYER-AXIS coordinate to its
|
||||||
|
* anchor's, so the pair lands in the same layer (x for LR/RL, y for TB/BT).
|
||||||
|
* `layer` groups nodes with the same index onto the same anchor coordinate.
|
||||||
|
* - `pinned`: override the node's coordinate with the exact pinned {x,y}.
|
||||||
|
* Applied only to top-level (ungrouped) nodes, whose ELK coords are absolute.
|
||||||
|
*/
|
||||||
|
export function applyHints(
|
||||||
|
graph: Graph,
|
||||||
|
geo: Map<string, { x: number; y: number; w: number; h: number }>,
|
||||||
|
): void {
|
||||||
|
const dir = graph.direction ?? "LR";
|
||||||
|
const layerAxis: "x" | "y" = dir === "TB" || dir === "BT" ? "y" : "x";
|
||||||
|
// The perpendicular (cross-layer) axis: members snapped onto one layer must be
|
||||||
|
// spread along THIS axis so they don't stack onto the same point.
|
||||||
|
const crossAxis: "x" | "y" = layerAxis === "x" ? "y" : "x";
|
||||||
|
const crossSize: "w" | "h" = crossAxis === "x" ? "w" : "h";
|
||||||
|
const grouped = new Set(
|
||||||
|
graph.nodes.filter((n) => n.group).map((n) => n.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
// sameLayerAs / layer: co-assign the layer-axis coordinate.
|
||||||
|
// Build the effective layer key per node, then pick a representative coord.
|
||||||
|
const layerKeyOf = new Map<string, string>();
|
||||||
|
const explicitLayer = new Map<string, number>();
|
||||||
|
for (const n of graph.nodes) if (n.layer != null) explicitLayer.set(n.id, n.layer);
|
||||||
|
const byId = new Map(graph.nodes.map((n) => [n.id, n]));
|
||||||
|
const resolveKey = (n: GraphNode): string | null => {
|
||||||
|
if (explicitLayer.has(n.id)) return `L${explicitLayer.get(n.id)}`;
|
||||||
|
const seen = new Set<string>([n.id]);
|
||||||
|
let cur: GraphNode | undefined = n;
|
||||||
|
while (cur && cur.sameLayerAs != null && !seen.has(cur.sameLayerAs)) {
|
||||||
|
seen.add(cur.sameLayerAs);
|
||||||
|
const t = byId.get(cur.sameLayerAs);
|
||||||
|
if (!t) break;
|
||||||
|
if (explicitLayer.has(t.id)) return `L${explicitLayer.get(t.id)}`;
|
||||||
|
cur = t;
|
||||||
|
}
|
||||||
|
// A sameLayerAs chain with no explicit layer: key on the chain's root id.
|
||||||
|
if (n.sameLayerAs != null) {
|
||||||
|
let root = n.id;
|
||||||
|
const s2 = new Set<string>([n.id]);
|
||||||
|
let c: GraphNode | undefined = n;
|
||||||
|
while (c && c.sameLayerAs != null && !s2.has(c.sameLayerAs)) {
|
||||||
|
s2.add(c.sameLayerAs);
|
||||||
|
root = c.sameLayerAs;
|
||||||
|
c = byId.get(c.sameLayerAs);
|
||||||
|
}
|
||||||
|
return `C${root}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
for (const n of graph.nodes) {
|
||||||
|
if (grouped.has(n.id)) continue; // group children are relative — skip
|
||||||
|
const key = resolveKey(n);
|
||||||
|
if (key) layerKeyOf.set(n.id, key);
|
||||||
|
}
|
||||||
|
// Group members of each layer key so we can snap AND spread them together.
|
||||||
|
const membersOf = new Map<string, string[]>();
|
||||||
|
for (const n of graph.nodes) {
|
||||||
|
const key = layerKeyOf.get(n.id);
|
||||||
|
if (key == null) continue;
|
||||||
|
if (!geo.has(n.id)) continue;
|
||||||
|
(membersOf.get(key) ?? membersOf.set(key, []).get(key)!).push(n.id);
|
||||||
|
}
|
||||||
|
// For each layer key: snap every member to the FIRST member's layer-axis coord,
|
||||||
|
// then SPREAD them along the perpendicular (cross-layer) axis with a >=
|
||||||
|
// MIN_SHAPE_GAP gap. Without the spread, a sameLayerAs chain whose nodes ELK
|
||||||
|
// happened to give the same cross-axis coordinate would collapse onto one point
|
||||||
|
// -> shape-overlap + edge-through-shape quality warnings (breaking the
|
||||||
|
// "0 warnings by construction" guarantee for these AUTO-positioned hints). We
|
||||||
|
// start from the members' minimum cross-axis coord and stack them with a gap
|
||||||
|
// of MIN_SHAPE_GAP beyond each shape's cross-axis size.
|
||||||
|
for (const [key, members] of membersOf) {
|
||||||
|
if (members.length === 0) continue;
|
||||||
|
// Snap layer-axis coord to the first member.
|
||||||
|
const repCoord = geo.get(members[0])![layerAxis];
|
||||||
|
// Preserve the members' existing relative order along the cross axis so the
|
||||||
|
// spread stays visually stable, then re-lay them contiguously.
|
||||||
|
const sorted = [...members].sort(
|
||||||
|
(a, b) => geo.get(a)![crossAxis] - geo.get(b)![crossAxis],
|
||||||
|
);
|
||||||
|
let cursor = geo.get(sorted[0])![crossAxis];
|
||||||
|
for (const id of sorted) {
|
||||||
|
const g = geo.get(id)!;
|
||||||
|
g[layerAxis] = repCoord;
|
||||||
|
g[crossAxis] = cursor;
|
||||||
|
cursor += g[crossSize] + MIN_SHAPE_GAP;
|
||||||
|
}
|
||||||
|
void key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// pinned: exact override (wins over any layer snap). Explicit user coordinates
|
||||||
|
// are user intent, but CLAMP to non-negative so an out-of-bounds pin (e.g.
|
||||||
|
// x:-500) never renders off-canvas. Two user-pinned nodes at the same point is
|
||||||
|
// user error the server can't silently relocate — the assembler docstring
|
||||||
|
// documents that explicit pins are user-directed and MAY warn (see #423/#425
|
||||||
|
// acceptance: the "0 quality-warnings by construction" guarantee is for
|
||||||
|
// AUTO-LAYOUT, not for coordinates the user pinned by hand).
|
||||||
|
for (const n of graph.nodes) {
|
||||||
|
if (!n.pinned) continue;
|
||||||
|
const px = Math.max(0, n.pinned.x);
|
||||||
|
const py = Math.max(0, n.pinned.y);
|
||||||
|
const g = geo.get(n.id);
|
||||||
|
if (g) {
|
||||||
|
g.x = px;
|
||||||
|
g.y = py;
|
||||||
|
} else {
|
||||||
|
const sz = { w: 140, h: 60 };
|
||||||
|
geo.set(n.id, { x: px, y: py, w: sz.w, h: sz.h });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Incremental variant of applyHints: apply `pinned` only to NEW nodes (those
|
||||||
|
* absent from `existing`); an existing node's coordinates are NEVER changed
|
||||||
|
* (acceptance #3). sameLayerAs/layer snapping is intentionally skipped in the
|
||||||
|
* incremental path — moving a new node's layer axis could still be desired, but
|
||||||
|
* it must never move an existing cell, so we keep the incremental contract
|
||||||
|
* simple: existing cells are frozen, new pinned nodes honour their pin.
|
||||||
|
*/
|
||||||
|
export function applyHintsForNew(
|
||||||
|
graph: Graph,
|
||||||
|
geo: Map<string, { x: number; y: number; w: number; h: number }>,
|
||||||
|
existing: Map<string, { x: number; y: number }>,
|
||||||
|
): void {
|
||||||
|
for (const n of graph.nodes) {
|
||||||
|
if (existing.has(n.id)) continue; // never move an existing cell
|
||||||
|
if (!n.pinned) continue;
|
||||||
|
const px = Math.max(0, n.pinned.x); // clamp out-of-bounds pins non-negative
|
||||||
|
const py = Math.max(0, n.pinned.y);
|
||||||
|
const g = geo.get(n.id);
|
||||||
|
if (g) {
|
||||||
|
g.x = px;
|
||||||
|
g.y = py;
|
||||||
|
} else {
|
||||||
|
geo.set(n.id, { x: px, y: py, w: 140, h: 60 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- layout runner ---------------------------------------------------------
|
||||||
|
|
||||||
|
const ELK_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run ELK over the mapped graph and return computed geometry per id (coords are
|
||||||
|
* parent-relative, matching mxGraph's convention for container children). On any
|
||||||
|
* ELK failure/timeout the returned map is empty and the caller falls back to a
|
||||||
|
* deterministic grid placement (so the write never fails on a layout hiccup).
|
||||||
|
*/
|
||||||
|
export async function runElk(
|
||||||
|
elk: ElkGraph,
|
||||||
|
): Promise<Map<string, { x: number; y: number; w: number; h: number }>> {
|
||||||
|
const geo = new Map<string, { x: number; y: number; w: number; h: number }>();
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
const Ctor: any = (ELK as any).default ?? ELK;
|
||||||
|
const inst = new Ctor();
|
||||||
|
const timeout = new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new Error("ELK timed out")), ELK_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
const laid = (await Promise.race([inst.layout(elk as any), timeout])) as ElkGraph;
|
||||||
|
const walk = (n: ElkNode) => {
|
||||||
|
if (n.id !== "root") {
|
||||||
|
geo.set(n.id, {
|
||||||
|
x: Math.round(n.x ?? 0),
|
||||||
|
y: Math.round(n.y ?? 0),
|
||||||
|
w: Math.round(n.width ?? 140),
|
||||||
|
h: Math.round(n.height ?? 60),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const c of n.children ?? []) walk(c);
|
||||||
|
};
|
||||||
|
walk(laid);
|
||||||
|
} catch {
|
||||||
|
return new Map(); // best-effort: empty -> caller uses fallback grid.
|
||||||
|
} finally {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
}
|
||||||
|
return geo;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- XML assembler ---------------------------------------------------------
|
||||||
|
|
||||||
|
/** Order groups so a parent group always precedes its nested children. */
|
||||||
|
function topoSortGroups(groups: GraphGroup[], groupIds: Set<string>): GraphGroup[] {
|
||||||
|
const byId = new Map(groups.map((g) => [g.id, g]));
|
||||||
|
const out: GraphGroup[] = [];
|
||||||
|
const done = new Set<string>();
|
||||||
|
const visit = (g: GraphGroup, stack: Set<string>) => {
|
||||||
|
if (done.has(g.id)) return;
|
||||||
|
if (stack.has(g.id)) return; // cycle guard
|
||||||
|
stack.add(g.id);
|
||||||
|
if (g.group && groupIds.has(g.group) && g.group !== g.id) {
|
||||||
|
const parent = byId.get(g.group);
|
||||||
|
if (parent) visit(parent, stack);
|
||||||
|
}
|
||||||
|
stack.delete(g.id);
|
||||||
|
if (!done.has(g.id)) {
|
||||||
|
done.add(g.id);
|
||||||
|
out.push(g);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const g of groups) visit(g, new Set());
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function xmlEscapeAttr(s: string): string {
|
||||||
|
return s
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/\r\n|\r|\n/g, "
"); // literal newline -> the linter-approved entity
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssembleResult {
|
||||||
|
modelXml: string;
|
||||||
|
iconsResolved: number;
|
||||||
|
iconsMissing: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assemble the final mxGraphModel XML from the graph + resolved styles/coords.
|
||||||
|
* Guarantees BY CONSTRUCTION that the #423 linter passes:
|
||||||
|
* - id=0 and id=1(parent=0) sentinels;
|
||||||
|
* - each node/group is vertex="1" (containers get container=1 via the style);
|
||||||
|
* - each edge is edge="1" with a child <mxGeometry relative="1" as="geometry"/>;
|
||||||
|
* - group children set parent=<groupId> and RELATIVE coords; an edge between
|
||||||
|
* two different parents is parent="1";
|
||||||
|
* - labels are XML-escaped and any newline is 
.
|
||||||
|
* `geo` may be empty (ELK failed) — then a deterministic grid is used so the
|
||||||
|
* output is still valid and non-overlapping (>=170px stride).
|
||||||
|
*
|
||||||
|
* QUALITY-WARNING GUARANTEE: the "0 quality-warnings by construction" promise
|
||||||
|
* holds for AUTO-LAYOUT — ELK spacing plus applyHints' cross-axis spread for the
|
||||||
|
* server-positioned `layer`/`sameLayerAs` hints keep shapes >=MIN_SHAPE_GAP
|
||||||
|
* apart. It does NOT extend to explicit `pinned` coordinates: those are
|
||||||
|
* user-directed, so two nodes the user pins to the same/overlapping point are
|
||||||
|
* user error the server honours verbatim (only clamped non-negative) and MAY
|
||||||
|
* therefore produce a quality warning.
|
||||||
|
*/
|
||||||
|
export function assembleModel(
|
||||||
|
graph: Graph,
|
||||||
|
opts: {
|
||||||
|
preset: PresetData;
|
||||||
|
styles: Map<string, { style: string; w: number; h: number; iconResolved: boolean }>;
|
||||||
|
geo: Map<string, { x: number; y: number; w: number; h: number }>;
|
||||||
|
},
|
||||||
|
): AssembleResult {
|
||||||
|
const { preset, styles, geo } = opts;
|
||||||
|
const groupIds = new Set((graph.groups ?? []).map((g) => g.id));
|
||||||
|
const nodeById = new Map(graph.nodes.map((n) => [n.id, n]));
|
||||||
|
|
||||||
|
// Fallback grid when ELK produced nothing: lay ungrouped nodes on a grid with
|
||||||
|
// a 190px stride (>150 gap). Grouped nodes/groups are placed inside their group.
|
||||||
|
const fallback = geo.size === 0;
|
||||||
|
const gridPos = (i: number) => ({ x: 40 + (i % 5) * 200, y: 40 + Math.floor(i / 5) * 140 });
|
||||||
|
|
||||||
|
const cells: string[] = ['<mxCell id="0"/>', '<mxCell id="1" parent="0"/>'];
|
||||||
|
|
||||||
|
// Groups first (they are parents of their members). A nested group sets
|
||||||
|
// parent=<parentGroupId>; emit parents before children so parent-exists holds.
|
||||||
|
let gi = 0;
|
||||||
|
const groupGeo = new Map<string, { x: number; y: number; w: number; h: number }>();
|
||||||
|
const orderedGroups = topoSortGroups(graph.groups ?? [], groupIds);
|
||||||
|
for (const g of orderedGroups) {
|
||||||
|
const gg = geo.get(g.id) ?? { ...gridPos(gi++), w: 320, h: 220 };
|
||||||
|
groupGeo.set(g.id, gg);
|
||||||
|
const style = groupStyle(preset);
|
||||||
|
const gParent = g.group && groupIds.has(g.group) && g.group !== g.id ? g.group : "1";
|
||||||
|
cells.push(
|
||||||
|
`<mxCell id="${xmlEscapeAttr(g.id)}" value="${xmlEscapeAttr(g.label)}" style="${style}" vertex="1" parent="${xmlEscapeAttr(gParent)}">` +
|
||||||
|
`<mxGeometry x="${gg.x}" y="${gg.y}" width="${gg.w}" height="${gg.h}" as="geometry"/></mxCell>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nodes. A grouped node's coords are RELATIVE to its group (ELK already
|
||||||
|
// returns child coords relative to the parent; for the fallback grid we place
|
||||||
|
// children on a small in-group grid).
|
||||||
|
let ungrouped = (graph.groups?.length ?? 0);
|
||||||
|
const inGroupIndex = new Map<string, number>();
|
||||||
|
let iconsResolved = 0;
|
||||||
|
const iconsMissing: string[] = [];
|
||||||
|
for (const n of graph.nodes) {
|
||||||
|
const st = styles.get(n.id)!;
|
||||||
|
if (n.icon) {
|
||||||
|
if (st.iconResolved) iconsResolved++;
|
||||||
|
else iconsMissing.push(n.id);
|
||||||
|
}
|
||||||
|
let x: number;
|
||||||
|
let y: number;
|
||||||
|
const g = geo.get(n.id);
|
||||||
|
if (g && !fallback) {
|
||||||
|
x = g.x;
|
||||||
|
y = g.y;
|
||||||
|
} else if (n.group && groupIds.has(n.group)) {
|
||||||
|
const k = inGroupIndex.get(n.group) ?? 0;
|
||||||
|
inGroupIndex.set(n.group, k + 1);
|
||||||
|
x = 30 + (k % 3) * 180;
|
||||||
|
y = 40 + Math.floor(k / 3) * 120;
|
||||||
|
} else {
|
||||||
|
const p = gridPos(ungrouped++);
|
||||||
|
x = p.x;
|
||||||
|
y = p.y;
|
||||||
|
}
|
||||||
|
const parent = n.group && groupIds.has(n.group) ? n.group : "1";
|
||||||
|
cells.push(
|
||||||
|
`<mxCell id="${xmlEscapeAttr(n.id)}" value="${xmlEscapeAttr(n.label)}" style="${st.style}" vertex="1" parent="${xmlEscapeAttr(parent)}">` +
|
||||||
|
`<mxGeometry x="${x}" y="${y}" width="${st.w}" height="${st.h}" as="geometry"/></mxCell>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edges. parent="1" whenever the two endpoints have different container
|
||||||
|
// parents (or either is a group); otherwise the shared group id.
|
||||||
|
(graph.edges ?? []).forEach((e, i) => {
|
||||||
|
const style = edgeStyle(preset, e.kind);
|
||||||
|
const fromNode = nodeById.get(e.from);
|
||||||
|
const toNode = nodeById.get(e.to);
|
||||||
|
const fromParent = fromNode?.group && groupIds.has(fromNode.group) ? fromNode.group : "1";
|
||||||
|
const toParent = toNode?.group && groupIds.has(toNode.group) ? toNode.group : "1";
|
||||||
|
const parent = fromParent === toParent ? fromParent : "1";
|
||||||
|
const label = e.label ? ` value="${xmlEscapeAttr(e.label)}"` : "";
|
||||||
|
cells.push(
|
||||||
|
`<mxCell id="ge${i}"${label} style="${style}" edge="1" parent="${xmlEscapeAttr(parent)}" ` +
|
||||||
|
`source="${xmlEscapeAttr(e.from)}" target="${xmlEscapeAttr(e.to)}">` +
|
||||||
|
`<mxGeometry relative="1" as="geometry"/></mxCell>`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const modelAttrs =
|
||||||
|
'dx="0" dy="0" grid="1" gridSize="10" page="1" pageWidth="850" pageHeight="1100" adaptiveColors="auto"';
|
||||||
|
const modelXml = `<mxGraphModel ${modelAttrs}><root>${cells.join("")}</root></mxGraphModel>`;
|
||||||
|
return { modelXml, iconsResolved, iconsMissing };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- incremental merge -----------------------------------------------------
|
||||||
|
|
||||||
|
let _mergeWindow: any = null;
|
||||||
|
function mergeWindow(): any {
|
||||||
|
if (!_mergeWindow) _mergeWindow = new JSDOM("").window;
|
||||||
|
return _mergeWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge the freshly-assembled graph XML with the EXISTING diagram model so an
|
||||||
|
* incremental "add a node" call never drops a hand-placed cell. `assembleModel`
|
||||||
|
* emits ONLY the passed graph's cells; on its own it would replace the whole
|
||||||
|
* model, wiping any existing cell the caller didn't re-list. This splices every
|
||||||
|
* existing cell that the graph does NOT re-list (preserved verbatim: coords,
|
||||||
|
* style, edges) into the assembled root:
|
||||||
|
* - id in the graph -> the graph's (re-laid) cell wins (already assembled;
|
||||||
|
* coords are frozen for existing ids via the incremental geo path);
|
||||||
|
* - id NOT in the graph -> the existing cell is preserved verbatim;
|
||||||
|
* - a graph node absent from the existing model -> added (offset clear).
|
||||||
|
* The sentinels ("0"/"1") come from the assembled model and are never doubled.
|
||||||
|
*/
|
||||||
|
function mergeExistingCells(
|
||||||
|
assembledXml: string,
|
||||||
|
existingModelXml: string,
|
||||||
|
graph: Graph,
|
||||||
|
): string {
|
||||||
|
const win = mergeWindow();
|
||||||
|
const parser = new win.DOMParser();
|
||||||
|
const existingDoc = parser.parseFromString(existingModelXml, "application/xml");
|
||||||
|
if (existingDoc.getElementsByTagName("parsererror").length > 0) {
|
||||||
|
// Existing model unreadable: fall back to the assembled model alone (still a
|
||||||
|
// valid diagram — better than throwing on a corrupt prior file).
|
||||||
|
return assembledXml;
|
||||||
|
}
|
||||||
|
const assembledDoc = parser.parseFromString(assembledXml, "application/xml");
|
||||||
|
const root = assembledDoc.getElementsByTagName("root")[0];
|
||||||
|
if (!root) return assembledXml;
|
||||||
|
|
||||||
|
// Ids the assembled model already emitted (graph nodes/groups/edges + sentinels).
|
||||||
|
const assembledIds = new Set<string>();
|
||||||
|
for (const el of Array.from(root.getElementsByTagName("mxCell")) as any[]) {
|
||||||
|
const id = el.getAttribute("id");
|
||||||
|
if (id) assembledIds.add(id);
|
||||||
|
}
|
||||||
|
// The graph's own ids: any existing cell with one of these is superseded by the
|
||||||
|
// assembled version and must NOT be re-imported.
|
||||||
|
const graphIds = new Set<string>([
|
||||||
|
...graph.nodes.map((n) => n.id),
|
||||||
|
...(graph.groups ?? []).map((g) => g.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const existingCells = Array.from(
|
||||||
|
existingDoc.getElementsByTagName("mxCell"),
|
||||||
|
) as any[];
|
||||||
|
for (const el of existingCells) {
|
||||||
|
const id = el.getAttribute("id") ?? "";
|
||||||
|
if (id === "0" || id === "1") continue; // sentinels come from the assembled model
|
||||||
|
if (graphIds.has(id)) continue; // graph re-lists it -> assembled version wins
|
||||||
|
if (assembledIds.has(id)) continue; // id collision guard -> keep assembled
|
||||||
|
root.appendChild(assembledDoc.importNode(el, true));
|
||||||
|
assembledIds.add(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ser = new win.XMLSerializer();
|
||||||
|
return ser.serializeToString(assembledDoc.documentElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- top-level: graph -> mxGraphModel XML ----------------------------------
|
||||||
|
|
||||||
|
export interface BuildFromGraphResult {
|
||||||
|
modelXml: string;
|
||||||
|
iconsResolved: number;
|
||||||
|
iconsMissing: string[];
|
||||||
|
layout: LayoutMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full server-side pipeline: validate -> resolve styles/icons -> map to ELK
|
||||||
|
* -> run ELK (or fall back) -> assemble linter-clean XML. `existingCoords` is
|
||||||
|
* supplied for `layout:"incremental"` (the coordinates of the diagram's current
|
||||||
|
* cells, so they are preserved and only new nodes are placed). `existingModelXml`
|
||||||
|
* is the current diagram's full model XML — in incremental mode every existing
|
||||||
|
* cell the graph does NOT re-list is MERGED back in verbatim so a hand-placed
|
||||||
|
* cell is never dropped (WARNING #4). Pure — no network.
|
||||||
|
*/
|
||||||
|
export async function buildFromGraph(
|
||||||
|
graph: Graph,
|
||||||
|
layout: LayoutMode = "full",
|
||||||
|
existingCoords?: Map<string, { x: number; y: number }>,
|
||||||
|
existingModelXml?: string,
|
||||||
|
): Promise<BuildFromGraphResult> {
|
||||||
|
validateGraph(graph);
|
||||||
|
const preset = getPreset(graph.preset);
|
||||||
|
|
||||||
|
// Resolve every node's style + size (icon or generic-by-kind).
|
||||||
|
const styles = new Map<
|
||||||
|
string,
|
||||||
|
{ style: string; w: number; h: number; iconResolved: boolean }
|
||||||
|
>();
|
||||||
|
const sizes = new Map<string, NodeSize>();
|
||||||
|
for (const n of graph.nodes) {
|
||||||
|
const s = resolveNodeStyle(preset, n);
|
||||||
|
styles.set(n.id, s);
|
||||||
|
sizes.set(n.id, { w: s.w, h: s.h });
|
||||||
|
}
|
||||||
|
// Group sizes: seed a min box; ELK computes the real size when it lays out.
|
||||||
|
for (const g of graph.groups ?? []) sizes.set(g.id, { w: 240, h: 180 });
|
||||||
|
|
||||||
|
let geo = new Map<string, { x: number; y: number; w: number; h: number }>();
|
||||||
|
|
||||||
|
if (layout === "incremental" && existingCoords && existingCoords.size > 0) {
|
||||||
|
// INCREMENTAL: keep every existing cell's coords VERBATIM (acceptance #3 —
|
||||||
|
// never move a hand-arranged cell) and lay out ONLY the new nodes, then
|
||||||
|
// offset that block clear of the existing bbox so nothing overlaps.
|
||||||
|
for (const [id, c] of existingCoords) {
|
||||||
|
const sz = sizes.get(id) ?? { w: 140, h: 60 };
|
||||||
|
geo.set(id, { x: c.x, y: c.y, w: sz.w, h: sz.h });
|
||||||
|
}
|
||||||
|
const newIds = new Set(
|
||||||
|
graph.nodes.filter((n) => !existingCoords.has(n.id)).map((n) => n.id),
|
||||||
|
);
|
||||||
|
if (newIds.size > 0) {
|
||||||
|
const elk = graphToElk(graph, sizes, { only: newIds });
|
||||||
|
const laid = await runElk(elk);
|
||||||
|
// Place the new block below the existing content (a clear >=170px gap).
|
||||||
|
let maxY = 0;
|
||||||
|
for (const c of existingCoords.values()) maxY = Math.max(maxY, c.y);
|
||||||
|
const offsetY = maxY + 200;
|
||||||
|
for (const [id, g] of laid) {
|
||||||
|
if (newIds.has(id)) geo.set(id, { ...g, y: g.y + offsetY });
|
||||||
|
else if (!geo.has(id)) geo.set(id, g); // a new group container
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Hints still apply to NEW pinned nodes only (existing ones stay put).
|
||||||
|
applyHintsForNew(graph, geo, existingCoords);
|
||||||
|
} else if (layout !== "none") {
|
||||||
|
const elk = graphToElk(graph, sizes);
|
||||||
|
geo = await runElk(elk);
|
||||||
|
applyHints(graph, geo);
|
||||||
|
} else if (existingCoords) {
|
||||||
|
// layout:"none" with prior coords -> keep them verbatim.
|
||||||
|
for (const [id, c] of existingCoords) {
|
||||||
|
const sz = sizes.get(id) ?? { w: 140, h: 60 };
|
||||||
|
geo.set(id, { x: c.x, y: c.y, w: sz.w, h: sz.h });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assembled = assembleModel(graph, { preset, styles, geo });
|
||||||
|
// In incremental mode, splice back every existing cell the graph didn't
|
||||||
|
// re-list so an "add one node" call preserves the user's manual layout.
|
||||||
|
let modelXml = assembled.modelXml;
|
||||||
|
if (
|
||||||
|
layout === "incremental" &&
|
||||||
|
existingModelXml &&
|
||||||
|
existingCoords &&
|
||||||
|
existingCoords.size > 0
|
||||||
|
) {
|
||||||
|
modelXml = mergeExistingCells(modelXml, existingModelXml, graph);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
modelXml,
|
||||||
|
iconsResolved: assembled.iconsResolved,
|
||||||
|
iconsMissing: assembled.iconsMissing,
|
||||||
|
layout,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
// Pure Mermaid `flowchart` -> graph-JSON parser for `drawioFromMermaid` (issue
|
||||||
|
// #425, stage 3, OPTIONAL). The escape clause in the issue: convert WITHOUT
|
||||||
|
// Electron/draw.io-CLI, so a pure text parser only. It handles the common wiki
|
||||||
|
// flowchart subset — node shapes, labelled/dashed edges, subgraphs (-> groups),
|
||||||
|
// and the direction header — and emits a Graph the drawioFromGraph pipeline
|
||||||
|
// renders as an EDITABLE draw.io diagram. Anything beyond flowchart (sequence /
|
||||||
|
// class / state) throws a clear error so the model falls back to drawioFromGraph.
|
||||||
|
//
|
||||||
|
// DELIBERATELY NARROW: this is not a full Mermaid grammar (Mermaid's own parser
|
||||||
|
// is a 100KB+ browser dependency). It covers `flowchart`/`graph` with the node
|
||||||
|
// shapes and edge arrows that show up in practice; unusual syntax is skipped
|
||||||
|
// rather than mis-parsed, and a diagram that yields no nodes throws.
|
||||||
|
|
||||||
|
import type { Graph, GraphNode, GraphEdge, GraphGroup } from "./drawio-graph.js";
|
||||||
|
|
||||||
|
export class MermaidParseError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(`drawioFromMermaid: ${message}`);
|
||||||
|
this.name = "MermaidParseError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input-size bounds applied BEFORE parsing. Without them a pathological mermaid
|
||||||
|
// string (e.g. 300000 connection lines, or 20000 nested `subgraph`s) builds a
|
||||||
|
// huge intermediate node/edge/group structure that OOM-crashes the worker — the
|
||||||
|
// downstream validateGraph caps in drawio-graph can't help because the parser
|
||||||
|
// exhausts the heap constructing the intermediate FIRST. These caps reject the
|
||||||
|
// over-limit input fast, before a single line is parsed.
|
||||||
|
const MAX_MERMAID_CHARS = 200_000; // ~200 KB of source is far beyond any real diagram.
|
||||||
|
const MAX_MERMAID_LINES = 20_000;
|
||||||
|
const MAX_MERMAID_GROUPS = 500; // parity with drawio-graph's MAX_GRAPH_GROUPS.
|
||||||
|
// Per connection line, the number of chained nodes we will expand (`A-->B-->C`).
|
||||||
|
const MAX_CHAIN_NODES = 500;
|
||||||
|
|
||||||
|
const DIRECTIONS: Record<string, Graph["direction"]> = {
|
||||||
|
LR: "LR",
|
||||||
|
RL: "RL",
|
||||||
|
TB: "TB",
|
||||||
|
TD: "TB",
|
||||||
|
BT: "BT",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node-shape delimiters -> a semantic `kind`. Mermaid encodes shape in the
|
||||||
|
* bracket style; we map the common ones to the palette kinds so the diagram is
|
||||||
|
* colored meaningfully (a decision/diamond -> queue, a database cylinder -> db,
|
||||||
|
* a rounded/stadium -> service, a subroutine/hexagon -> gateway, default rect ->
|
||||||
|
* service). The label text lives between the delimiters.
|
||||||
|
*/
|
||||||
|
interface ShapeDef {
|
||||||
|
open: string;
|
||||||
|
close: string;
|
||||||
|
kind: string;
|
||||||
|
}
|
||||||
|
// Order matters: longer/multi-char delimiters first so "([" beats "(".
|
||||||
|
const SHAPES: ShapeDef[] = [
|
||||||
|
{ open: "([", close: "])", kind: "service" }, // stadium
|
||||||
|
{ open: "[[", close: "]]", kind: "gateway" }, // subroutine
|
||||||
|
{ open: "[(", close: ")]", kind: "db" }, // cylinder-ish / database
|
||||||
|
{ open: "((", close: "))", kind: "external" }, // circle
|
||||||
|
{ open: "{{", close: "}}", kind: "gateway" }, // hexagon
|
||||||
|
{ open: "[", close: "]", kind: "service" }, // rectangle
|
||||||
|
{ open: "(", close: ")", kind: "service" }, // rounded
|
||||||
|
{ open: "{", close: "}", kind: "queue" }, // rhombus / decision
|
||||||
|
{ open: ">", close: "]", kind: "external" }, // asymmetric flag
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Strip Mermaid label quoting/escapes and normalise whitespace. */
|
||||||
|
function cleanLabel(raw: string): string {
|
||||||
|
let s = raw.trim();
|
||||||
|
if (
|
||||||
|
(s.startsWith('"') && s.endsWith('"')) ||
|
||||||
|
(s.startsWith("'") && s.endsWith("'"))
|
||||||
|
) {
|
||||||
|
s = s.slice(1, -1);
|
||||||
|
}
|
||||||
|
return s.replace(/<br\s*\/?>/gi, " ").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single edge-arrow spec: its regex and the resulting edge `kind`. */
|
||||||
|
interface ArrowDef {
|
||||||
|
re: RegExp;
|
||||||
|
kind: string;
|
||||||
|
}
|
||||||
|
// Dotted arrows (`-.->`) -> async; thick (`==>`) stay sync; normal `-->`/`---`.
|
||||||
|
// Each captures an optional `|label|` OR inline label between the two arrow
|
||||||
|
// halves. Applied to the segment between two node tokens.
|
||||||
|
const ARROWS: ArrowDef[] = [
|
||||||
|
{ re: /-\.->|-\.-/, kind: "async" },
|
||||||
|
{ re: /==>|===/, kind: "sync" },
|
||||||
|
{ re: /-->|---/, kind: "sync" },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ParsedRef {
|
||||||
|
id: string;
|
||||||
|
node?: GraphNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a single node token like `A`, `A[Label]`, `db[(Orders)]`, `d{Choose}`.
|
||||||
|
* Returns the id and, when the token declares a shape/label, a GraphNode.
|
||||||
|
*/
|
||||||
|
function parseNodeToken(token: string): ParsedRef | null {
|
||||||
|
const t = token.trim();
|
||||||
|
if (t === "") return null;
|
||||||
|
for (const shape of SHAPES) {
|
||||||
|
const oi = t.indexOf(shape.open);
|
||||||
|
if (oi <= 0) continue;
|
||||||
|
if (!t.endsWith(shape.close)) continue;
|
||||||
|
const id = t.slice(0, oi).trim();
|
||||||
|
const label = cleanLabel(t.slice(oi + shape.open.length, t.length - shape.close.length));
|
||||||
|
if (!id) return null;
|
||||||
|
return { id, node: { id, label: label || id, kind: shape.kind } };
|
||||||
|
}
|
||||||
|
// Bare id (no shape declared here — may be defined elsewhere).
|
||||||
|
if (/^[A-Za-z0-9_.-]+$/.test(t)) return { id: t };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a connection line into [leftToken, arrowSegment, rightToken]. Returns
|
||||||
|
* null if the line has no arrow. The arrow segment may embed a label as
|
||||||
|
* `-->|text|` or `-- text -->`.
|
||||||
|
*/
|
||||||
|
function splitConnection(
|
||||||
|
line: string,
|
||||||
|
): { left: string; right: string; kind: string; label?: string } | null {
|
||||||
|
for (const arrow of ARROWS) {
|
||||||
|
// Find the arrow occurrence. Support a mid-arrow label: `A -- text --> B`.
|
||||||
|
const m = arrow.re.exec(line);
|
||||||
|
if (!m) continue;
|
||||||
|
const idx = m.index;
|
||||||
|
let left = line.slice(0, idx).trim();
|
||||||
|
let rest = line.slice(idx + m[0].length).trim();
|
||||||
|
let label: string | undefined;
|
||||||
|
// Pipe label: `-->|HTTPS| B`.
|
||||||
|
const pipe = /^\|([^|]*)\|\s*(.*)$/.exec(rest);
|
||||||
|
if (pipe) {
|
||||||
|
label = cleanLabel(pipe[1]);
|
||||||
|
rest = pipe[2].trim();
|
||||||
|
}
|
||||||
|
// Mid-arrow label on the left side: `A -- text` before the arrow half.
|
||||||
|
const midLeft = /^(.*?)\s*--\s*(.+)$/.exec(left);
|
||||||
|
if (!label && midLeft && /-\.|--|==/.test(line.slice(0, idx))) {
|
||||||
|
// Only treat as a label when there's clearly text after `--`.
|
||||||
|
if (!/[\[\](){}]/.test(midLeft[2])) {
|
||||||
|
left = midLeft[1].trim();
|
||||||
|
label = cleanLabel(midLeft[2]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!left || !rest) return null;
|
||||||
|
return { left, right: rest, kind: arrow.kind, label };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse Mermaid flowchart text into a Graph. Handles the header
|
||||||
|
* (`flowchart LR` / `graph TD`), `subgraph <id>[title] … end` blocks (-> groups),
|
||||||
|
* node declarations, and connection lines. Throws MermaidParseError for a
|
||||||
|
* non-flowchart diagram or when nothing parses.
|
||||||
|
*/
|
||||||
|
export function mermaidToGraph(mermaid: string): Graph {
|
||||||
|
if (typeof mermaid !== "string" || mermaid.trim() === "") {
|
||||||
|
throw new MermaidParseError("empty mermaid input");
|
||||||
|
}
|
||||||
|
// Size guards FIRST — bound the raw input before building any intermediate.
|
||||||
|
if (mermaid.length > MAX_MERMAID_CHARS) {
|
||||||
|
throw new MermaidParseError(
|
||||||
|
`input is ${mermaid.length} chars (max ${MAX_MERMAID_CHARS}); split the diagram or use drawioFromGraph`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rawLines = mermaid.split(/\r?\n/);
|
||||||
|
if (rawLines.length > MAX_MERMAID_LINES) {
|
||||||
|
throw new MermaidParseError(
|
||||||
|
`input has ${rawLines.length} lines (max ${MAX_MERMAID_LINES}); split the diagram or use drawioFromGraph`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const nodes = new Map<string, GraphNode>();
|
||||||
|
const groups: GraphGroup[] = [];
|
||||||
|
const edges: GraphEdge[] = [];
|
||||||
|
let direction: Graph["direction"] = "LR";
|
||||||
|
let sawHeader = false;
|
||||||
|
|
||||||
|
// Stack of active subgraph ids (nesting); the top is the current group.
|
||||||
|
const groupStack: string[] = [];
|
||||||
|
let anonGroup = 0;
|
||||||
|
|
||||||
|
const ensureNode = (ref: ParsedRef) => {
|
||||||
|
const existing = nodes.get(ref.id);
|
||||||
|
if (ref.node) {
|
||||||
|
if (existing) {
|
||||||
|
// Fill in a label/kind if this token declared a shape and the prior didn't.
|
||||||
|
if (existing.label === existing.id && ref.node.label !== ref.node.id)
|
||||||
|
existing.label = ref.node.label;
|
||||||
|
if (!existing.kind) existing.kind = ref.node.kind;
|
||||||
|
} else {
|
||||||
|
nodes.set(ref.id, { ...ref.node });
|
||||||
|
}
|
||||||
|
} else if (!existing) {
|
||||||
|
nodes.set(ref.id, { id: ref.id, label: ref.id, kind: "service" });
|
||||||
|
}
|
||||||
|
// Assign to the current subgraph if inside one and not yet grouped.
|
||||||
|
const cur = groupStack[groupStack.length - 1];
|
||||||
|
const n = nodes.get(ref.id)!;
|
||||||
|
if (cur && n.group == null) n.group = cur;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const raw of rawLines) {
|
||||||
|
let line = raw.trim();
|
||||||
|
if (line === "" || line.startsWith("%%")) continue; // blank / comment
|
||||||
|
|
||||||
|
// Header.
|
||||||
|
const header = /^(flowchart|graph)\s+([A-Za-z]{2})\b/.exec(line);
|
||||||
|
if (header) {
|
||||||
|
sawHeader = true;
|
||||||
|
const dir = DIRECTIONS[header[2].toUpperCase()];
|
||||||
|
if (dir) direction = dir;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^(sequenceDiagram|classDiagram|stateDiagram|erDiagram|gantt|pie|journey)\b/.test(line)) {
|
||||||
|
throw new MermaidParseError(
|
||||||
|
`only 'flowchart'/'graph' is supported (got '${line.split(/\s+/)[0]}'); use drawioFromGraph instead`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subgraph open: `subgraph id [Title]` or `subgraph Title`.
|
||||||
|
const sg = /^subgraph\s+(.+)$/.exec(line);
|
||||||
|
if (sg) {
|
||||||
|
const spec = sg[1].trim();
|
||||||
|
let id: string;
|
||||||
|
let label: string;
|
||||||
|
const bracket = /^([A-Za-z0-9_.-]+)\s*\[(.+)\]$/.exec(spec);
|
||||||
|
if (bracket) {
|
||||||
|
id = bracket[1];
|
||||||
|
label = cleanLabel(bracket[2]);
|
||||||
|
} else if (/^[A-Za-z0-9_.-]+$/.test(spec)) {
|
||||||
|
id = spec;
|
||||||
|
label = spec;
|
||||||
|
} else {
|
||||||
|
id = `sg${anonGroup++}`;
|
||||||
|
label = cleanLabel(spec);
|
||||||
|
}
|
||||||
|
if (!groups.some((g) => g.id === id)) {
|
||||||
|
if (groups.length >= MAX_MERMAID_GROUPS) {
|
||||||
|
throw new MermaidParseError(
|
||||||
|
`too many subgraphs (max ${MAX_MERMAID_GROUPS}); use drawioFromGraph for a diagram this large`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
groups.push({ id, label, kind: "group" });
|
||||||
|
}
|
||||||
|
groupStack.push(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (/^end\b/.test(line)) {
|
||||||
|
groupStack.pop();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// `direction LR` inside a subgraph — apply to the top-level direction.
|
||||||
|
const innerDir = /^direction\s+([A-Za-z]{2})\b/.exec(line);
|
||||||
|
if (innerDir) {
|
||||||
|
const dir = DIRECTIONS[innerDir[1].toUpperCase()];
|
||||||
|
if (dir) direction = dir;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Style/class/click directives: ignore (no visual mapping in our palette).
|
||||||
|
if (/^(style|classDef|class|click|linkStyle)\b/.test(line)) continue;
|
||||||
|
|
||||||
|
// Strip a trailing semicolon.
|
||||||
|
if (line.endsWith(";")) line = line.slice(0, -1).trim();
|
||||||
|
|
||||||
|
// Connection line (possibly chained: A --> B --> C).
|
||||||
|
const conn = splitConnection(line);
|
||||||
|
if (conn) {
|
||||||
|
// Handle a simple chain by re-splitting the right side.
|
||||||
|
let leftTok = conn.left;
|
||||||
|
let seg: typeof conn | null = conn;
|
||||||
|
let guard = 0;
|
||||||
|
while (seg) {
|
||||||
|
if (guard++ >= MAX_CHAIN_NODES) {
|
||||||
|
// Don't silently drop the tail of an over-long chain — surface it so
|
||||||
|
// the model knows the diagram was too large rather than getting a
|
||||||
|
// quietly-truncated result.
|
||||||
|
throw new MermaidParseError(
|
||||||
|
`a single connection chain exceeds ${MAX_CHAIN_NODES} nodes; split it or use drawioFromGraph`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const leftRef = parseNodeToken(leftTok);
|
||||||
|
// The right side may itself contain another arrow (a chain).
|
||||||
|
const nextSeg = splitConnection(seg.right);
|
||||||
|
const rightTokenStr = nextSeg ? seg.right.slice(0, splitIndex(seg.right)) : seg.right;
|
||||||
|
const rightRef = parseNodeToken(nextSeg ? nextSeg.left : seg.right);
|
||||||
|
if (leftRef && rightRef) {
|
||||||
|
ensureNode(leftRef);
|
||||||
|
ensureNode(rightRef);
|
||||||
|
edges.push({
|
||||||
|
from: leftRef.id,
|
||||||
|
to: rightRef.id,
|
||||||
|
label: seg.label,
|
||||||
|
kind: seg.kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!nextSeg) break;
|
||||||
|
leftTok = nextSeg.left;
|
||||||
|
seg = nextSeg;
|
||||||
|
void rightTokenStr;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standalone node declaration `A[Label]` OR a bare member ref `C` inside a
|
||||||
|
// subgraph (which claims that node for the current group).
|
||||||
|
const nodeRef = parseNodeToken(line);
|
||||||
|
if (nodeRef && (nodeRef.node || groupStack.length > 0)) {
|
||||||
|
ensureNode(nodeRef);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Unknown line: skip silently (robustness over strictness).
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sawHeader && nodes.size === 0) {
|
||||||
|
throw new MermaidParseError(
|
||||||
|
"input does not look like a mermaid flowchart (no 'flowchart'/'graph' header and no nodes)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (nodes.size === 0) {
|
||||||
|
throw new MermaidParseError("no nodes parsed from the flowchart");
|
||||||
|
}
|
||||||
|
|
||||||
|
const graph: Graph = {
|
||||||
|
nodes: Array.from(nodes.values()),
|
||||||
|
direction,
|
||||||
|
};
|
||||||
|
if (groups.length > 0) graph.groups = groups;
|
||||||
|
if (edges.length > 0) graph.edges = edges;
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index of the first arrow in a segment (for chain splitting). */
|
||||||
|
function splitIndex(s: string): number {
|
||||||
|
let best = -1;
|
||||||
|
for (const arrow of ARROWS) {
|
||||||
|
const m = arrow.re.exec(s);
|
||||||
|
if (m && (best === -1 || m.index < best)) best = m.index;
|
||||||
|
}
|
||||||
|
return best === -1 ? s.length : best;
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
// Semantic color/line presets for the graph tools (issue #425, stage 3). The
|
||||||
|
// PALETTE is DATA (packages/mcp/data/drawio-presets.json), not code: a node
|
||||||
|
// `kind` maps to a { fillColor, strokeColor, fontColor } slot and an edge `kind`
|
||||||
|
// maps to line-style props, per named preset (`default` / `dark` /
|
||||||
|
// `colorblind-safe`). This module only loads that data and turns a slot into a
|
||||||
|
// draw.io style fragment. The INVARIANT of the graph tools is that the model
|
||||||
|
// never sees a style string — it names a `kind`, the server picks the slot.
|
||||||
|
//
|
||||||
|
// Loading mirrors drawio-shapes.ts: the JSON is read once via `import.meta.url`
|
||||||
|
// relative to the built module. That is why this module (and drawio-graph.ts
|
||||||
|
// which imports it) is reached ONLY through client.ts's ESM build and never
|
||||||
|
// value-imported into the zod-agnostic tool-specs.ts (which the in-app server
|
||||||
|
// type-checks under module:commonjs, where `import.meta` is a TS1343 error).
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
|
/** A node color slot: the three draw.io color values for a `kind`. */
|
||||||
|
export interface NodeSlot {
|
||||||
|
fillColor: string;
|
||||||
|
strokeColor: string;
|
||||||
|
fontColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An edge line style: the extra style props appended for an edge `kind`. */
|
||||||
|
export interface EdgeStyle {
|
||||||
|
props: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresetData {
|
||||||
|
canvasDark: boolean;
|
||||||
|
okabeIto?: string[];
|
||||||
|
nodes: Record<string, NodeSlot>;
|
||||||
|
edges: Record<string, EdgeStyle>;
|
||||||
|
edgeDefault: { strokeColor: string; fontColor: string };
|
||||||
|
group: { strokeColor: string; fontColor: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PresetsFile {
|
||||||
|
presets: Record<string, PresetData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The three shipped preset names. */
|
||||||
|
export const PRESET_NAMES = ["default", "dark", "colorblind-safe"] as const;
|
||||||
|
export type PresetName = (typeof PRESET_NAMES)[number];
|
||||||
|
|
||||||
|
/** Every node `kind` the base palette defines (also the generic-shape kinds). */
|
||||||
|
export const NODE_KINDS = [
|
||||||
|
"service",
|
||||||
|
"db",
|
||||||
|
"queue",
|
||||||
|
"gateway",
|
||||||
|
"error",
|
||||||
|
"external",
|
||||||
|
"security",
|
||||||
|
] as const;
|
||||||
|
export type NodeKind = (typeof NODE_KINDS)[number];
|
||||||
|
|
||||||
|
/** Edge `kind`s the palette styles; anything else falls back to `sync`. */
|
||||||
|
export const EDGE_KINDS = ["sync", "async", "error"] as const;
|
||||||
|
export type EdgeKind = (typeof EDGE_KINDS)[number];
|
||||||
|
|
||||||
|
let _presets: Record<string, PresetData> | null = null;
|
||||||
|
|
||||||
|
function presetsPath(): URL {
|
||||||
|
// build/lib/drawio-presets.js -> ../../data/drawio-presets.json
|
||||||
|
return new URL("../../data/drawio-presets.json", import.meta.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load + parse the bundled preset table once, then cache it. */
|
||||||
|
export function loadPresets(): Record<string, PresetData> {
|
||||||
|
if (_presets) return _presets;
|
||||||
|
const json = readFileSync(presetsPath(), "utf-8");
|
||||||
|
const parsed = JSON.parse(json) as PresetsFile;
|
||||||
|
_presets = parsed.presets;
|
||||||
|
return _presets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve a preset by name, defaulting to `default` for an unknown name. */
|
||||||
|
export function getPreset(name?: string): PresetData {
|
||||||
|
const presets = loadPresets();
|
||||||
|
if (name && presets[name]) return presets[name];
|
||||||
|
return presets["default"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The slot for a node `kind` in a preset, falling back to `service`. */
|
||||||
|
export function nodeSlot(preset: PresetData, kind?: string): NodeSlot {
|
||||||
|
if (kind && preset.nodes[kind]) return preset.nodes[kind];
|
||||||
|
return preset.nodes["service"];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the draw.io style string for a GENERIC (no-icon) node of a given kind.
|
||||||
|
* A rounded rectangle carrying the slot's fill/stroke/font. `whiteSpace=wrap`
|
||||||
|
* and `html=1` let a long label wrap inside the shape (the assembler also sizes
|
||||||
|
* the shape to the label, so the linter's label-overflow warning never fires).
|
||||||
|
*/
|
||||||
|
export function genericNodeStyle(preset: PresetData, kind?: string): string {
|
||||||
|
const s = nodeSlot(preset, kind);
|
||||||
|
return (
|
||||||
|
`rounded=1;whiteSpace=wrap;html=1;` +
|
||||||
|
`fillColor=${s.fillColor};strokeColor=${s.strokeColor};fontColor=${s.fontColor};`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overlay the preset's node slot colors onto a resolved ICON style-string
|
||||||
|
* (from the shape catalog). An AWS/Azure icon carries its OWN mandatory
|
||||||
|
* fill/stroke (the category color / white outline) that MUST NOT be recolored,
|
||||||
|
* so for an icon we only ensure a readable fontColor when the preset is dark;
|
||||||
|
* otherwise the icon style is returned verbatim. Keeping the icon's own colors
|
||||||
|
* is deliberate: recoloring an AWS service icon breaks its category semantics.
|
||||||
|
*/
|
||||||
|
export function iconNodeStyle(preset: PresetData, iconStyle: string): string {
|
||||||
|
if (!preset.canvasDark) return iconStyle;
|
||||||
|
// On a dark canvas an icon's fontColor is usually a dark ink that vanishes;
|
||||||
|
// append a light fontColor (icons put their label BELOW the glyph, so this
|
||||||
|
// only affects the caption, never the glyph fill).
|
||||||
|
if (/fontColor=/.test(iconStyle)) {
|
||||||
|
return iconStyle.replace(/fontColor=[^;]*/, "fontColor=#e0e0e0");
|
||||||
|
}
|
||||||
|
return iconStyle + (iconStyle.endsWith(";") ? "" : ";") + "fontColor=#e0e0e0;";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the draw.io style for an edge of a given `kind`. Base is an orthogonal
|
||||||
|
* connector (edgeStyle=orthogonalEdgeStyle) with rounded corners and an open
|
||||||
|
* arrowhead, plus the preset's default stroke/font, then the kind's extra props
|
||||||
|
* (dashed / colored) overlaid. An unknown kind falls back to `sync` (solid).
|
||||||
|
*/
|
||||||
|
export function edgeStyle(preset: PresetData, kind?: string): string {
|
||||||
|
const k = kind && preset.edges[kind] ? kind : "sync";
|
||||||
|
const base =
|
||||||
|
`edgeStyle=orthogonalEdgeStyle;rounded=1;html=1;endArrow=open;` +
|
||||||
|
`strokeColor=${preset.edgeDefault.strokeColor};fontColor=${preset.edgeDefault.fontColor};`;
|
||||||
|
return base + preset.edges[k].props;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group (container) style: ALWAYS transparent (`fillColor=none;container=1;`)
|
||||||
|
* per the spec, carrying the preset's group stroke/font. `dropTarget=1` marks it
|
||||||
|
* a drop target in the editor; `verticalAlign=top;align=left;spacingLeft=8;` puts
|
||||||
|
* the group label in the top-left like draw.io's own boundary containers.
|
||||||
|
*/
|
||||||
|
export function groupStyle(preset: PresetData): string {
|
||||||
|
return (
|
||||||
|
`rounded=0;whiteSpace=wrap;html=1;` +
|
||||||
|
`fillColor=none;container=1;dropTarget=1;collapsible=0;` +
|
||||||
|
`strokeColor=${preset.group.strokeColor};fontColor=${preset.group.fontColor};` +
|
||||||
|
`verticalAlign=top;align=left;spacingLeft=8;spacingTop=4;`
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -83,16 +83,32 @@ export function filterComment(comment: any, markdownContent?: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map one server search hit to the MCP output contract (#443):
|
||||||
|
// { pageId, title, path, snippet, score }
|
||||||
|
//
|
||||||
|
// INVARIANT: the only page identifier exposed is `pageId` (the server `id`
|
||||||
|
// UUID). The server also carries `slugId` — it is NEVER surfaced.
|
||||||
|
//
|
||||||
|
// GRACEFUL DEGRADATION: against a stock upstream server the opt-in lookup DTO
|
||||||
|
// fields are stripped, so the response is the legacy FTS shape (no path/snippet/
|
||||||
|
// score, a `highlight` + `rank` instead). We synthesize the contract from
|
||||||
|
// whatever is present: `snippet` falls back to the FTS `highlight`, `score` to
|
||||||
|
// the FTS `rank`, and `path` to [] (upstream has no path). This keeps the tool
|
||||||
|
// usable even when the server has not been upgraded.
|
||||||
export function filterSearchResult(result: any) {
|
export function filterSearchResult(result: any) {
|
||||||
return {
|
return {
|
||||||
id: result.id,
|
pageId: result.id,
|
||||||
title: result.title,
|
title: result.title,
|
||||||
parentPageId: result.parentPageId,
|
path: Array.isArray(result.path) ? result.path : [],
|
||||||
createdAt: result.createdAt,
|
snippet:
|
||||||
updatedAt: result.updatedAt,
|
typeof result.snippet === "string"
|
||||||
rank: result.rank,
|
? result.snippet
|
||||||
highlight: result.highlight,
|
: (result.highlight ?? ""),
|
||||||
spaceId: result.space?.id,
|
score:
|
||||||
spaceName: result.space?.name,
|
typeof result.score === "number"
|
||||||
|
? result.score
|
||||||
|
: typeof result.rank === "number"
|
||||||
|
? result.rank
|
||||||
|
: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,20 @@
|
|||||||
|
|
||||||
const chains = new Map<string, Promise<unknown>>();
|
const chains = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
|
// Canonical UUID shape (versions 1–8, matching the `uuid` package's `validate`
|
||||||
|
// that the server's isValidUUID uses). This is the SINGLE source of truth for
|
||||||
|
// "is this a canonical page UUID?" in the MCP: client.ts's resolvePageId
|
||||||
|
// imports isUuid from here to decide whether a pageId already IS a UUID (and so
|
||||||
|
// needs no /pages/info round-trip). page.repo.ts treats any non-UUID pageId as
|
||||||
|
// a slugId; a 10-char nanoid slugId never contains dashes, so it can never be
|
||||||
|
// misread as a UUID here.
|
||||||
|
export const UUID_RE =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
export function isUuid(value: string): boolean {
|
||||||
|
return typeof value === "string" && UUID_RE.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
// The returned promise carries the real result/rejection of `fn` and MUST be
|
// The returned promise carries the real result/rejection of `fn` and MUST be
|
||||||
// awaited/handled by the caller; only the internal chaining tail swallows
|
// awaited/handled by the caller; only the internal chaining tail swallows
|
||||||
// errors (purely to gate ordering).
|
// errors (purely to gate ordering).
|
||||||
@@ -17,6 +31,25 @@ export function withPageLock<T>(
|
|||||||
pageId: string,
|
pageId: string,
|
||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
// STRUCTURAL INVARIANT (issue #449, "resolve-then-lock"): the mutex key MUST
|
||||||
|
// be the canonical page UUID, never a raw slugId. The whole write path relies
|
||||||
|
// on the lock key AND the CollabSession cache key being the resolved UUID
|
||||||
|
// (#260) — if a future write method forgot to call resolvePageId and locked
|
||||||
|
// under a slugId, two writes to the same page would take DIFFERENT mutex keys
|
||||||
|
// and silently lose serialization (clobbering live human edits). This was an
|
||||||
|
// invariant enforced only by comments/convention; assert it in CODE so the
|
||||||
|
// violation fails fast and loud at the lock instead of corrupting data in
|
||||||
|
// prod. The centralizing helper (mutatePageContent/replacePageContent) already
|
||||||
|
// guards a raw-input caller, but this backstop catches ANY path.
|
||||||
|
if (!isUuid(pageId)) {
|
||||||
|
throw new Error(
|
||||||
|
`withPageLock: key must be a canonical page UUID, got '${pageId}'. ` +
|
||||||
|
`The write path must resolvePageId(pageId) BEFORE locking so the ` +
|
||||||
|
`mutex/CollabSession cache key is the UUID (invariant "resolve-then-` +
|
||||||
|
`lock", #260/#449). A slugId or other non-UUID key would silently lose ` +
|
||||||
|
`per-page serialization.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
// Wait for the previous op on this page; swallow its error so a failure does
|
// Wait for the previous op on this page; swallow its error so a failure does
|
||||||
// not poison the queue for the next caller.
|
// not poison the queue for the next caller.
|
||||||
const prev = (chains.get(pageId) ?? Promise.resolve()).catch(() => {});
|
const prev = (chains.get(pageId) ?? Promise.resolve()).catch(() => {});
|
||||||
|
|||||||
@@ -1,11 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Options for `buildPageTree`. Fully OPTIONAL so the existing call form
|
||||||
|
* `buildPageTree(nodes)` keeps its historic behaviour (lean `{id, slugId,
|
||||||
|
* title, children?}` output, no depth cut) unchanged.
|
||||||
|
*
|
||||||
|
* - `shape: "getTree"` — emit the #443 `getTree` output node shape
|
||||||
|
* `{pageId, title, children?, hasChildren?}` instead of the lean
|
||||||
|
* `{id, slugId, title, children?}` shape. `slugId`/`icon`/`position` are
|
||||||
|
* never exposed (INVARIANT: only the UUID `pageId` leaves the MCP layer).
|
||||||
|
* - `maxDepth` — trim the built tree to this many levels (root nodes are
|
||||||
|
* depth 1). Only meaningful together with `shape: "getTree"` (the lean shape
|
||||||
|
* has no `hasChildren` to signal a cut). See the depth logic below.
|
||||||
|
*/
|
||||||
|
export interface BuildPageTreeOptions {
|
||||||
|
shape?: "lean" | "getTree";
|
||||||
|
maxDepth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pure tree-builder: turn a flat array of sidebar-style page nodes (as produced
|
* Pure tree-builder: turn a flat array of sidebar-style page nodes (as produced
|
||||||
* by `enumerateSpacePages`) into a nested tree.
|
* by `enumerateSpacePages`) into a nested tree.
|
||||||
*
|
*
|
||||||
* Input: a flat array of nodes. Each node is expected to carry at least
|
* Input: a flat array of nodes. Each node is expected to carry at least
|
||||||
* { id, slugId, title, position, parentPageId } (extra fields are ignored).
|
* { id, slugId, title, position, parentPageId } (extra fields are ignored),
|
||||||
|
* plus a server `hasChildren` boolean used by the `getTree` shape below.
|
||||||
*
|
*
|
||||||
* Output: an array of ROOT nodes, each shaped as
|
* Output (default / `shape: "lean"`): an array of ROOT nodes, each shaped as
|
||||||
* { id, slugId, title, children? }
|
* { id, slugId, title, children? }
|
||||||
* where `children` is the array of child nodes (same shape, recursively). The
|
* where `children` is the array of child nodes (same shape, recursively). The
|
||||||
* `children` key is OMITTED entirely when a node has no children — consistent
|
* `children` key is OMITTED entirely when a node has no children — consistent
|
||||||
@@ -13,6 +32,14 @@
|
|||||||
* lean (nesting alone conveys the structure; parentPageId/position/hasChildren
|
* lean (nesting alone conveys the structure; parentPageId/position/hasChildren
|
||||||
* are intentionally dropped from the output).
|
* are intentionally dropped from the output).
|
||||||
*
|
*
|
||||||
|
* Output (`shape: "getTree"`, the #443 tool shape): each node is
|
||||||
|
* { pageId, title, children?, hasChildren? }
|
||||||
|
* — the server `id` is exposed as `pageId` (never `slugId`/`icon`/`position`).
|
||||||
|
* `children` is omitted for leaves and for nodes trimmed by `maxDepth`.
|
||||||
|
* `hasChildren: true` is set ONLY on a node whose children exist on the server
|
||||||
|
* (per the flat item's `hasChildren`) but were CUT by `maxDepth`; on leaves and
|
||||||
|
* on fully-expanded interior nodes the field is omitted (see `maxDepth` below).
|
||||||
|
*
|
||||||
* Linking rule: a node is attached as a child of `parentPageId` only when that
|
* Linking rule: a node is attached as a child of `parentPageId` only when that
|
||||||
* parent id is actually present in the input. Otherwise — including a null /
|
* parent id is actually present in the input. Otherwise — including a null /
|
||||||
* undefined `parentPageId`, or a parent that was capped out of the bounded walk
|
* undefined `parentPageId`, or a parent that was capped out of the bounded walk
|
||||||
@@ -26,18 +53,42 @@
|
|||||||
* fractional-index ASCII keys (e.g. "a0", "a1"). Nodes with a missing/undefined
|
* fractional-index ASCII keys (e.g. "a0", "a1"). Nodes with a missing/undefined
|
||||||
* `position` sort last.
|
* `position` sort last.
|
||||||
*
|
*
|
||||||
|
* maxDepth (getTree shape only): the tree is built in FULL first, then trimmed
|
||||||
|
* on the way out. Root nodes are depth 1. `maxDepth: N` keeps nodes at depth
|
||||||
|
* <= N and drops the `children` of any node AT depth N. A node whose children
|
||||||
|
* were dropped this way gets `hasChildren: true` when it actually had children
|
||||||
|
* in the flat input (source of truth = the server `hasChildren` flag), so the
|
||||||
|
* caller knows it can descend further with a follow-up `rootPageId` call. An
|
||||||
|
* absent/undefined `maxDepth` means no cut (whole tree). `maxDepth <= 0` is
|
||||||
|
* treated as "no cut" (defensive; the tool schema clamps to >= 1).
|
||||||
|
*
|
||||||
* Pure: no I/O, no network, deterministic.
|
* Pure: no I/O, no network, deterministic.
|
||||||
*/
|
*/
|
||||||
export function buildPageTree(nodes: any[]): any[] {
|
export function buildPageTree(
|
||||||
type OutputNode = {
|
nodes: any[],
|
||||||
|
options: BuildPageTreeOptions = {},
|
||||||
|
): any[] {
|
||||||
|
const getTreeShape = options.shape === "getTree";
|
||||||
|
// A finite, positive cut only; anything else means "no cut".
|
||||||
|
const maxDepth =
|
||||||
|
typeof options.maxDepth === "number" &&
|
||||||
|
Number.isFinite(options.maxDepth) &&
|
||||||
|
options.maxDepth > 0
|
||||||
|
? Math.floor(options.maxDepth)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
type InternalNode = {
|
||||||
id: string;
|
id: string;
|
||||||
|
// Retained internally for shaping; never all emitted at once.
|
||||||
slugId: any;
|
slugId: any;
|
||||||
title: any;
|
title: any;
|
||||||
children?: OutputNode[];
|
hasServerChildren: boolean;
|
||||||
|
children?: InternalNode[];
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map id -> output node. Build the lean output shape up front.
|
// Map id -> internal node. Build up front; the output shape is projected at
|
||||||
const byId = new Map<string, OutputNode>();
|
// the very end so the maxDepth cut can consult `hasServerChildren`.
|
||||||
|
const byId = new Map<string, InternalNode>();
|
||||||
// Preserve the original position string for sorting (kept off the output).
|
// Preserve the original position string for sorting (kept off the output).
|
||||||
const positionById = new Map<string, string | undefined>();
|
const positionById = new Map<string, string | undefined>();
|
||||||
|
|
||||||
@@ -49,6 +100,7 @@ export function buildPageTree(nodes: any[]): any[] {
|
|||||||
id: node.id,
|
id: node.id,
|
||||||
slugId: node.slugId,
|
slugId: node.slugId,
|
||||||
title: node.title,
|
title: node.title,
|
||||||
|
hasServerChildren: node.hasChildren === true,
|
||||||
});
|
});
|
||||||
positionById.set(node.id, node.position);
|
positionById.set(node.id, node.position);
|
||||||
}
|
}
|
||||||
@@ -90,5 +142,30 @@ export function buildPageTree(nodes: any[]): any[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
roots.sort(byPosition);
|
roots.sort(byPosition);
|
||||||
return roots.map((id) => byId.get(id)!);
|
const rootNodes = roots.map((id) => byId.get(id)!);
|
||||||
|
|
||||||
|
// Project the internal nodes into the requested OUTPUT shape, applying the
|
||||||
|
// maxDepth cut for the getTree shape. `depth` is 1-based (roots = depth 1).
|
||||||
|
const project = (node: InternalNode, depth: number): any => {
|
||||||
|
if (getTreeShape) {
|
||||||
|
const out: any = { pageId: node.id, title: node.title };
|
||||||
|
const atCut = maxDepth !== undefined && depth >= maxDepth;
|
||||||
|
if (!atCut && node.children && node.children.length > 0) {
|
||||||
|
out.children = node.children.map((c) => project(c, depth + 1));
|
||||||
|
} else if (atCut && node.hasServerChildren) {
|
||||||
|
// Children exist on the server but were trimmed by maxDepth: signal it
|
||||||
|
// so the caller can descend with a follow-up rootPageId call.
|
||||||
|
out.hasChildren = true;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// Lean (historic) shape: cycle-safe, no depth cut, no hasChildren.
|
||||||
|
const out: any = { id: node.id, slugId: node.slugId, title: node.title };
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
out.children = node.children.map((c) => project(c, depth + 1));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
return rootNodes.map((n) => project(n, 1));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ import { SHARED_TOOL_SPECS, SharedToolSpec } from "./tool-specs.js";
|
|||||||
*/
|
*/
|
||||||
export const ROUTING_PROSE =
|
export const ROUTING_PROSE =
|
||||||
"Docmost editing guide — choose the tool by intent. The <tool_inventory> at the end lists every tool with a one-line purpose; the notes below are the routing hints for WHEN to reach for each.\n" +
|
"Docmost editing guide — choose the tool by intent. The <tool_inventory> at the end lists every tool with a one-line purpose; the notes below are the routing hints for WHEN to reach for each.\n" +
|
||||||
"READ: find a page -> search (workspace-wide full-text); list -> 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, 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" +
|
"READ: find a page by a fragment of a technical string (hostname/IP/ID like srv.local, 10.0.12, WB-MGE-30D86B) -> search — hybrid substring + full-text, returns each hit's location (path: root->parent titles) and a snippet around the match, so you rarely need a follow-up getPage; scope with spaceId or parentPageId (a subtree), titleOnly to match titles only. A space's page HIERARCHY (or one subtree) -> getTree (one request, complete, `{pageId,title,children?}`; rootPageId for a subtree, maxDepth to trim depth — a trimmed node gets hasChildren:true); prefer it over listPages tree:true (deprecated). Have a pageId, need WHERE-AM-I / what's around it (its breadcrumbs + direct children, metadata only) -> getPageContext (one call; parent = last breadcrumb, [] for a root page). list -> listPages / listSpaces. Locate blocks and their ids CHEAPLY -> getOutline (compact top-level map; start here, not getPageJson). One block, for editing -> getNode (by attrs.id, or \"#<index>\" for tables, which carry no id) — returns MARKDOWN by default (comment anchors kept for safe write-back); pass format:\"json\" for the raw ProseMirror subtree. Find every occurrence of a string/regex ON a page (and where each is) -> searchInPage, NOT block-by-block getNode — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> getPage (Markdown, canonical for text; drops only block ids, resolved-comment anchors, and a fixed no-md-representation attr set: table spans/colwidth/bg, indent, callout.icon, orderedList.type, link internal/target/rel/class; inline <span data-comment-id> tags are comment anchors — markup, not text) or getPageJson (full ProseMirror with block ids, for those dropped attrs). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stashPage (returns a short-lived anonymous URL).\n" +
|
||||||
"EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#<index>\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> 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" +
|
"EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#<index>\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> PREFER the high-level semantic tools that hide coordinates/styles: drawioFromGraph (architecture/cloud/network diagrams — describe nodes/groups/edges by kind+icon, the server picks layout, colors and verified icons; hints layer/sameLayerAs/pinned and layout:full|incremental|none) and drawioFromMermaid (standard flowcharts — write Mermaid, get an editable diagram). For targeted tweaks of an existing diagram use drawioEditCells (id-based add/update/delete with cascade delete + baseHash lock). Raw mxGraph XML via drawioCreate/drawioUpdate is the escape-hatch for exotic/wireframe diagrams; drawioGet reads a diagram as mxGraph XML + a hash (pass it as baseHash to drawioUpdate/drawioEditCells for optimistic locking). Before authoring raw XML, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
|
||||||
"PAGES: new -> createPage (Markdown). Rename (title only) -> renamePage. Move -> movePage. Delete -> deletePage (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copyPageContent. Sharing -> sharePage / unsharePage / listShares; sharePage makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
|
"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" +
|
"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.";
|
"HISTORY: review what changed -> diffPageVersions (a historyId vs current, or two versions). List saved versions -> listPageHistory. Undo a bad edit -> restorePageVersion (writes a past version back as current; itself revertible). Export a page to self-contained Docmost Markdown (with comment anchors) -> exportPageMarkdown.";
|
||||||
@@ -82,6 +82,8 @@ const TOOL_FAMILY: Record<string, Family> = {
|
|||||||
// READ
|
// READ
|
||||||
search: "READ",
|
search: "READ",
|
||||||
listPages: "READ",
|
listPages: "READ",
|
||||||
|
getTree: "READ",
|
||||||
|
getPageContext: "READ",
|
||||||
listSpaces: "READ",
|
listSpaces: "READ",
|
||||||
getOutline: "READ",
|
getOutline: "READ",
|
||||||
getNode: "READ",
|
getNode: "READ",
|
||||||
@@ -107,6 +109,9 @@ const TOOL_FAMILY: Record<string, Family> = {
|
|||||||
drawioGet: "EDIT",
|
drawioGet: "EDIT",
|
||||||
drawioCreate: "EDIT",
|
drawioCreate: "EDIT",
|
||||||
drawioUpdate: "EDIT",
|
drawioUpdate: "EDIT",
|
||||||
|
drawioEditCells: "EDIT",
|
||||||
|
drawioFromGraph: "EDIT",
|
||||||
|
drawioFromMermaid: "EDIT",
|
||||||
drawioShapes: "EDIT",
|
drawioShapes: "EDIT",
|
||||||
drawioGuide: "EDIT",
|
drawioGuide: "EDIT",
|
||||||
docmostTransform: "EDIT",
|
docmostTransform: "EDIT",
|
||||||
@@ -152,7 +157,7 @@ export const INLINE_MCP_INVENTORY: ToolInventoryLine[] = [
|
|||||||
{
|
{
|
||||||
name: "search",
|
name: "search",
|
||||||
purpose:
|
purpose:
|
||||||
"full-text search for pages and content across the whole workspace.",
|
"find pages by a fragment of a technical string (hybrid substring + full-text); returns each hit's path and a snippet.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "docmostTransform",
|
name: "docmostTransform",
|
||||||
|
|||||||
+334
-16
@@ -63,6 +63,8 @@ export type DocmostClientLike = Pick<
|
|||||||
| 'getSpaces'
|
| 'getSpaces'
|
||||||
| 'listShares'
|
| 'listShares'
|
||||||
| 'listPages'
|
| 'listPages'
|
||||||
|
| 'getTree'
|
||||||
|
| 'getPageContext'
|
||||||
| 'getPage'
|
| 'getPage'
|
||||||
| 'getPageJson'
|
| 'getPageJson'
|
||||||
| 'getOutline'
|
| 'getOutline'
|
||||||
@@ -98,6 +100,9 @@ export type DocmostClientLike = Pick<
|
|||||||
| 'drawioGet'
|
| 'drawioGet'
|
||||||
| 'drawioCreate'
|
| 'drawioCreate'
|
||||||
| 'drawioUpdate'
|
| 'drawioUpdate'
|
||||||
|
| 'drawioEditCells'
|
||||||
|
| 'drawioFromGraph'
|
||||||
|
| 'drawioFromMermaid'
|
||||||
| 'createComment'
|
| 'createComment'
|
||||||
| 'resolveComment'
|
| 'resolveComment'
|
||||||
>;
|
>;
|
||||||
@@ -877,11 +882,17 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
inAppKey: 'getPage',
|
inAppKey: 'getPage',
|
||||||
description:
|
description:
|
||||||
'Fetch a single page as Markdown by its id. Returns the page title and ' +
|
'Fetch a single page as Markdown by its id. Returns the page title and ' +
|
||||||
'its Markdown content. The Markdown conversion is LOSSY (block ids, exact ' +
|
'its Markdown content. The converter is canonical (round-trips text and ' +
|
||||||
'table/callout structure are approximated); for a lossless representation ' +
|
'block structure), so this is sufficient for text edits; use the ' +
|
||||||
'use the lossless page-JSON read tool. Inline <span data-comment-id> tags in the markdown ' +
|
'page-JSON read tool only when you need what Markdown cannot carry. The ' +
|
||||||
'are comment highlight anchors (also present for RESOLVED threads) — ' +
|
'Markdown drops exactly: (1) block ids (not visible in Markdown); ' +
|
||||||
'treat them as markup, not page text.',
|
'(2) resolved-comment anchors (hidden here; only active <span ' +
|
||||||
|
'data-comment-id> anchors remain); (3) a fixed set of attributes with no ' +
|
||||||
|
'Markdown representation — table-cell colspan/rowspan/colwidth/' +
|
||||||
|
'backgroundColor/backgroundColorName, heading/paragraph indent, ' +
|
||||||
|
'callout.icon, orderedList.type, and link internal/target/rel/class. ' +
|
||||||
|
'Inline <span data-comment-id> tags in the markdown are comment highlight ' +
|
||||||
|
'anchors — treat them as markup, not page text.',
|
||||||
tier: 'core',
|
tier: 'core',
|
||||||
catalogLine: 'getPage — fetch a page as Markdown by its id.',
|
catalogLine: 'getPage — fetch a page as Markdown by its id.',
|
||||||
// Reconciled: MCP's stricter .min(1) kept; in-app's more-informative
|
// Reconciled: MCP's stricter .min(1) kept; in-app's more-informative
|
||||||
@@ -911,11 +922,13 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
description:
|
description:
|
||||||
'List the most recent pages (ordered by updatedAt, descending), ' +
|
'List the most recent pages (ordered by updatedAt, descending), ' +
|
||||||
'optionally scoped to a single space. Returns a bounded list (default ' +
|
'optionally scoped to a single space. Returns a bounded list (default ' +
|
||||||
'50, max 100) — use search for lookups in large spaces. Pass tree:true ' +
|
'50, max 100) — use search for lookups in large spaces. tree:true (with ' +
|
||||||
"(with spaceId) to instead get the space's full page hierarchy as a " +
|
"spaceId) returns the space's full page hierarchy as a nested tree, but " +
|
||||||
'nested tree.',
|
'is DEPRECATED — use getTree instead (leaner nodes, plus rootPageId / ' +
|
||||||
|
'maxDepth).',
|
||||||
tier: 'core',
|
tier: 'core',
|
||||||
catalogLine: "listPages — list recent pages, or a space's full page tree.",
|
catalogLine:
|
||||||
|
"listPages — list recent pages (tree:true is deprecated; use getTree for the hierarchy).",
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
spaceId: z
|
spaceId: z
|
||||||
.string()
|
.string()
|
||||||
@@ -948,6 +961,79 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getTree: {
|
||||||
|
mcpName: 'getTree',
|
||||||
|
inAppKey: 'getTree',
|
||||||
|
description:
|
||||||
|
"Get a space's page hierarchy (or one subtree) as a nested tree in a " +
|
||||||
|
'SINGLE request — completely and without loss. Each node is ' +
|
||||||
|
'`{ pageId, title, children? }`; children are ordered as in the sidebar. ' +
|
||||||
|
'Pass rootPageId to return only that page and its descendants (exactly ' +
|
||||||
|
'one root). Pass maxDepth to trim depth and save tokens (root nodes are ' +
|
||||||
|
'depth 1, so maxDepth:1 returns only the roots); a node whose children ' +
|
||||||
|
'were trimmed carries `hasChildren:true` so you can descend later with ' +
|
||||||
|
'getTree(rootPageId=that page). Prefer this over listPages tree:true.',
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine:
|
||||||
|
"getTree — a space's page hierarchy (or a subtree) as a nested tree in one request.",
|
||||||
|
buildShape: (z) => ({
|
||||||
|
spaceId: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe('The id of the space whose page tree to return.'),
|
||||||
|
rootPageId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Optional page id: return only this page and its descendants (one root).',
|
||||||
|
),
|
||||||
|
maxDepth: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Optional depth cap (roots are depth 1). maxDepth:1 returns only the ' +
|
||||||
|
'roots; trimmed nodes carry hasChildren:true.',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
execute: (client, { spaceId, rootPageId, maxDepth }) =>
|
||||||
|
client.getTree(
|
||||||
|
spaceId as string,
|
||||||
|
rootPageId as string | undefined,
|
||||||
|
maxDepth as number | undefined,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
getPageContext: {
|
||||||
|
mcpName: 'getPageContext',
|
||||||
|
inAppKey: 'getPageContext',
|
||||||
|
description:
|
||||||
|
'Given a pageId, get its LOCATION and immediate surroundings (metadata ' +
|
||||||
|
'only, no page content) in one call — answers "where am I / what is ' +
|
||||||
|
"around this page\". Returns `{ page: { pageId, title, spaceId }, " +
|
||||||
|
'breadcrumbs: [{ pageId, title }], children: [{ pageId, title, ' +
|
||||||
|
'hasChildren }] }`. `breadcrumbs` is the ancestor chain from the space ' +
|
||||||
|
'root down to the PARENT (the parent is its last element; a root page ' +
|
||||||
|
'has `breadcrumbs: []`). `children` are the direct children in sidebar ' +
|
||||||
|
'order, each flagged `hasChildren` so you know which can be expanded ' +
|
||||||
|
'(descend with getTree(rootPageId=that child) or another getPageContext). ' +
|
||||||
|
'Ids, titles and child order are consistent with getTree.',
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine:
|
||||||
|
'getPageContext — a page’s breadcrumbs + direct children (where-am-I) in one call.',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe(
|
||||||
|
'The id of the page to locate (a pageId/UUID, or a slugId from a URL).',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
execute: (client, { pageId }) =>
|
||||||
|
client.getPageContext(pageId as string),
|
||||||
|
},
|
||||||
|
|
||||||
createPage: {
|
createPage: {
|
||||||
mcpName: 'createPage',
|
mcpName: 'createPage',
|
||||||
inAppKey: 'createPage',
|
inAppKey: 'createPage',
|
||||||
@@ -1240,13 +1326,17 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
inAppKey: 'exportPageMarkdown',
|
inAppKey: 'exportPageMarkdown',
|
||||||
// CANONICAL: the MCP copy (a strict superset of the terse in-app wording).
|
// CANONICAL: the MCP copy (a strict superset of the terse in-app wording).
|
||||||
description:
|
description:
|
||||||
'Export a page to a single self-contained, lossless Docmost-flavoured ' +
|
'Export a page to a single self-contained Docmost-flavoured Markdown ' +
|
||||||
'Markdown file (custom extensions): YAML-free meta header, body with ' +
|
'file (custom extensions): YAML-free meta header, body with inline ' +
|
||||||
'inline comment anchors and diagrams, and a trailing comments-thread ' +
|
'comment anchors (resolved ones kept) and diagrams, and a trailing ' +
|
||||||
'block. Designed for a download -> edit body -> page-Markdown import ' +
|
'comments-thread block. Designed for a download -> edit body -> ' +
|
||||||
'round-trip that preserves everything, including comment highlights. ' +
|
'page-Markdown import round-trip; block ids regenerate and comment ' +
|
||||||
'Comment THREADS are preserved in the file but are not re-pushed to the ' +
|
'THREADS, though kept in the file, are not re-pushed to the server on ' +
|
||||||
'server on import.',
|
'import. The round-trip SILENTLY DROPS a fixed set of attributes with no ' +
|
||||||
|
'Markdown representation — table-cell merge spans (colspan/rowspan), ' +
|
||||||
|
'colwidth, backgroundColor/backgroundColorName, heading/paragraph indent, ' +
|
||||||
|
'callout.icon, orderedList.type, and link internal/target/rel/class. Use ' +
|
||||||
|
'the page-JSON tools if those must survive.',
|
||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine:
|
catalogLine:
|
||||||
'exportPageMarkdown — export a page to self-contained Markdown (body + comments).',
|
'exportPageMarkdown — export a page to self-contained Markdown (body + comments).',
|
||||||
@@ -1926,6 +2016,234 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
drawioEditCells: {
|
||||||
|
mcpName: 'drawioEditCells',
|
||||||
|
inAppKey: 'drawioEditCells',
|
||||||
|
description:
|
||||||
|
'Make TARGETED, id-based edits to an existing draw.io diagram instead of ' +
|
||||||
|
'resending the whole XML (a full-XML diff is fragile — draw.io reorders ' +
|
||||||
|
'attributes). `operations` is an ordered list of: ' +
|
||||||
|
'{ op:"add", xml:"<mxCell .../>" } (append a new cell), ' +
|
||||||
|
'{ op:"update", cellId:"n3", xml:"<mxCell id=\\"n3\\" .../>" } (replace that ' +
|
||||||
|
'cell; the id MUST stay the same), or { op:"delete", cellId:"n5" } — a ' +
|
||||||
|
'delete CASCADES to the cell\'s container children AND to every edge whose ' +
|
||||||
|
'source/target is deleted. Ids are STABLE across edits so diffs stay ' +
|
||||||
|
'meaningful. `baseHash` is MANDATORY: pass the hash from the drawioGet you ' +
|
||||||
|
'based the edit on; if the diagram changed since, the edit is refused with ' +
|
||||||
|
'a conflict error — re-read with drawioGet and retry. The edited model goes ' +
|
||||||
|
'through the same lint + quality-warning pipeline as drawioUpdate. `node` is ' +
|
||||||
|
'the drawio node attrs.id or "#<index>". Use this to tweak a diagram (move ' +
|
||||||
|
'or restyle a few cells, add/remove nodes); to (re)generate a whole diagram ' +
|
||||||
|
'from a description use drawioFromGraph.' +
|
||||||
|
DRAWIO_HARD_RULES,
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'drawioEditCells — id-based add/update/delete edits to a draw.io diagram (cascade delete).',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1),
|
||||||
|
node: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe('The drawio node attrs.id, or "#<index>" for a top-level block.'),
|
||||||
|
operations: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
op: z.enum(['add', 'update', 'delete']),
|
||||||
|
cellId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Target cell id (required for update/delete).'),
|
||||||
|
xml: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('The <mxCell> element (required for add/update).'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.describe('Ordered add/update/delete operations keyed by cell id.'),
|
||||||
|
baseHash: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe('The meta.hash from the drawioGet this edit is based on.'),
|
||||||
|
}),
|
||||||
|
execute: (client, { pageId, node, operations, baseHash }) =>
|
||||||
|
client.drawioEditCells(
|
||||||
|
pageId as string,
|
||||||
|
node as string,
|
||||||
|
operations as any,
|
||||||
|
baseHash as string,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
drawioFromGraph: {
|
||||||
|
mcpName: 'drawioFromGraph',
|
||||||
|
inAppKey: 'drawioFromGraph',
|
||||||
|
description:
|
||||||
|
'Build a draw.io diagram from a SEMANTIC graph — you describe nodes, groups ' +
|
||||||
|
'and edges by MEANING and the server picks every coordinate, color and icon ' +
|
||||||
|
'so the whole class of layout/icon mistakes (overlaps, edges through shapes, ' +
|
||||||
|
'empty-box stencils) cannot happen. This is the PREFERRED tool for ' +
|
||||||
|
'architecture / cloud / network diagrams. `graph` = { nodes:[{ id, label, ' +
|
||||||
|
'kind?, icon?, group?, layer?, sameLayerAs?, pinned? }], groups?:[{ id, ' +
|
||||||
|
'label, kind? }], edges?:[{ from, to, label?, kind? }] }. Node `kind` picks ' +
|
||||||
|
'a palette color (service/db/queue/gateway/error/external/security); `icon` ' +
|
||||||
|
'(e.g. "aws:lambda", "aws:dynamodb", "azure:cosmos") resolves to the exact ' +
|
||||||
|
'verified stencil — an unknown icon degrades to a labelled generic shape, ' +
|
||||||
|
'never an empty box. Edge `kind` sets the line style (sync=solid, ' +
|
||||||
|
'async=dashed, error=red-dashed). Groups are TRANSPARENT containers. ' +
|
||||||
|
'`direction` (LR/RL/TB/BT) and `preset` (default/dark/colorblind-safe) tune ' +
|
||||||
|
'the layout/palette. Layout hints: `layer` (column index), `sameLayerAs` ' +
|
||||||
|
'(align two nodes), `pinned:{x,y}` (fix a node). `layout`: "full" (default, ' +
|
||||||
|
'auto-place everything), "incremental" (with `node`: keep the existing ' +
|
||||||
|
'diagram\'s coordinates, place only new cells), "none" (no auto-layout). The ' +
|
||||||
|
'result reports { iconsResolved, iconsMissing } so you can verify all icons ' +
|
||||||
|
'resolved. For standard flowcharts you can also write Mermaid and call ' +
|
||||||
|
'drawioFromMermaid; for exotic/wireframe diagrams use raw XML via drawioCreate.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'drawioFromGraph — build a draw.io diagram from a semantic node/group/edge graph (server picks layout+icons).',
|
||||||
|
buildShape: (z) => {
|
||||||
|
const node = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
label: z.string().min(1),
|
||||||
|
kind: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Palette slot: service/db/queue/gateway/error/external/security.',
|
||||||
|
),
|
||||||
|
icon: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Icon ref, e.g. "aws:lambda", "aws:dynamodb", "azure:cosmos".'),
|
||||||
|
group: z.string().optional().describe('Id of the group (container) it sits in.'),
|
||||||
|
layer: z.number().optional().describe('Layer/column index hint (>=0).'),
|
||||||
|
sameLayerAs: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Put this node in the same layer as another node id.'),
|
||||||
|
pinned: z
|
||||||
|
.object({ x: z.number(), y: z.number() })
|
||||||
|
.optional()
|
||||||
|
.describe('Fix the node at these exact coordinates.'),
|
||||||
|
});
|
||||||
|
const group = z.object({
|
||||||
|
id: z.string().min(1),
|
||||||
|
label: z.string().min(1),
|
||||||
|
kind: z.string().optional(),
|
||||||
|
});
|
||||||
|
const edge = z.object({
|
||||||
|
from: z.string().min(1),
|
||||||
|
to: z.string().min(1),
|
||||||
|
label: z.string().optional(),
|
||||||
|
kind: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('sync (solid), async (dashed), error (red-dashed).'),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
pageId: z.string().min(1),
|
||||||
|
graph: z
|
||||||
|
.object({
|
||||||
|
nodes: z.array(node),
|
||||||
|
groups: z.array(group).optional(),
|
||||||
|
edges: z.array(edge).optional(),
|
||||||
|
direction: z.enum(['LR', 'RL', 'TB', 'BT']).optional(),
|
||||||
|
preset: z.enum(['default', 'dark', 'colorblind-safe']).optional(),
|
||||||
|
})
|
||||||
|
.describe('The semantic graph: nodes, groups, edges.'),
|
||||||
|
position: z
|
||||||
|
.enum(['before', 'after', 'append'])
|
||||||
|
.describe('Where to insert relative to the anchor.'),
|
||||||
|
anchorNodeId: z.string().optional().describe('Anchor block id (for before/after).'),
|
||||||
|
anchorText: z.string().optional().describe('Anchor text fragment (for before/after).'),
|
||||||
|
direction: z
|
||||||
|
.enum(['LR', 'RL', 'TB', 'BT'])
|
||||||
|
.optional()
|
||||||
|
.describe('Layout direction (overrides graph.direction).'),
|
||||||
|
preset: z
|
||||||
|
.enum(['default', 'dark', 'colorblind-safe'])
|
||||||
|
.optional()
|
||||||
|
.describe('Color preset (overrides graph.preset).'),
|
||||||
|
layout: z
|
||||||
|
.enum(['none', 'full', 'incremental'])
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'"full" (default) auto-places all; "incremental" (with node) keeps ' +
|
||||||
|
'existing coords and places only new cells; "none" no auto-layout.',
|
||||||
|
),
|
||||||
|
node: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'An existing diagram to (re)build into — required for layout:"incremental".',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
execute: (
|
||||||
|
client,
|
||||||
|
{ pageId, graph, position, anchorNodeId, anchorText, direction, preset, layout, node },
|
||||||
|
) =>
|
||||||
|
client.drawioFromGraph(
|
||||||
|
pageId as string,
|
||||||
|
{
|
||||||
|
position: position as 'before' | 'after' | 'append',
|
||||||
|
anchorNodeId: anchorNodeId as string | undefined,
|
||||||
|
anchorText: anchorText as string | undefined,
|
||||||
|
},
|
||||||
|
graph as any,
|
||||||
|
direction as 'LR' | 'RL' | 'TB' | 'BT' | undefined,
|
||||||
|
preset as string | undefined,
|
||||||
|
layout as 'none' | 'full' | 'incremental' | undefined,
|
||||||
|
node as string | undefined,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
|
drawioFromMermaid: {
|
||||||
|
mcpName: 'drawioFromMermaid',
|
||||||
|
inAppKey: 'drawioFromMermaid',
|
||||||
|
description:
|
||||||
|
'Convert Mermaid `flowchart` text into an EDITABLE draw.io diagram (LLMs ' +
|
||||||
|
'write Mermaid reliably). Best for STANDARD flowcharts/decision trees: ' +
|
||||||
|
'write the mermaid, the server parses it (pure parser — no browser/CLI), ' +
|
||||||
|
'maps it to the same semantic pipeline as drawioFromGraph, and inserts a ' +
|
||||||
|
'real draw.io diagram you can then refine with drawioEditCells. Node shapes ' +
|
||||||
|
'map to palette colors (a `{decision}` -> yellow, a `[(db)]` -> green, etc.); ' +
|
||||||
|
'`subgraph … end` becomes a transparent group; dotted `-.->` edges become ' +
|
||||||
|
'dashed. ONLY flowchart/graph is supported — for sequence/class diagrams, or ' +
|
||||||
|
'for cloud/architecture diagrams with real service icons, use drawioFromGraph ' +
|
||||||
|
'instead. `where` positions the block like insertNode.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'drawioFromMermaid — turn Mermaid flowchart text into an editable draw.io diagram.',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1),
|
||||||
|
mermaid: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe('Mermaid flowchart source (flowchart/graph LR|TB|...).'),
|
||||||
|
position: z
|
||||||
|
.enum(['before', 'after', 'append'])
|
||||||
|
.describe('Where to insert relative to the anchor.'),
|
||||||
|
anchorNodeId: z.string().optional().describe('Anchor block id (for before/after).'),
|
||||||
|
anchorText: z.string().optional().describe('Anchor text fragment (for before/after).'),
|
||||||
|
preset: z
|
||||||
|
.enum(['default', 'dark', 'colorblind-safe'])
|
||||||
|
.optional()
|
||||||
|
.describe('Color preset.'),
|
||||||
|
}),
|
||||||
|
execute: (client, { pageId, mermaid, position, anchorNodeId, anchorText, preset }) =>
|
||||||
|
client.drawioFromMermaid(
|
||||||
|
pageId as string,
|
||||||
|
{
|
||||||
|
position: position as 'before' | 'after' | 'append',
|
||||||
|
anchorNodeId: anchorNodeId as string | undefined,
|
||||||
|
anchorText: anchorText as string | undefined,
|
||||||
|
},
|
||||||
|
mermaid as string,
|
||||||
|
preset as string | undefined,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
drawioShapes: {
|
drawioShapes: {
|
||||||
mcpName: 'drawioShapes',
|
mcpName: 'drawioShapes',
|
||||||
inAppKey: 'drawioShapes',
|
inAppKey: 'drawioShapes',
|
||||||
|
|||||||
@@ -316,7 +316,8 @@ async function main() {
|
|||||||
const [idA, idB, idC] = seedIds;
|
const [idA, idB, idC] = seedIds;
|
||||||
|
|
||||||
// patchNode: replace the middle paragraph; siblings' ids must be unchanged.
|
// patchNode: replace the middle paragraph; siblings' ids must be unchanged.
|
||||||
await client.patchNode(nid, idB, mkPara(idB, "Bravo PATCHED."));
|
// #413 XOR input: the raw ProseMirror node goes under the `node` key.
|
||||||
|
await client.patchNode(nid, idB, { node: mkPara(idB, "Bravo PATCHED.") });
|
||||||
await new Promise((r) => setTimeout(r, 16000));
|
await new Promise((r) => setTimeout(r, 16000));
|
||||||
const afterPatch = (await client.getPageJson(nid)).content;
|
const afterPatch = (await client.getPageJson(nid)).content;
|
||||||
const patchText = JSON.stringify(afterPatch);
|
const patchText = JSON.stringify(afterPatch);
|
||||||
@@ -327,7 +328,7 @@ async function main() {
|
|||||||
// insertNode: place a new block after the first paragraph.
|
// insertNode: place a new block after the first paragraph.
|
||||||
await client.insertNode(
|
await client.insertNode(
|
||||||
nid,
|
nid,
|
||||||
mkPara("nodeops-ins", "Inserted paragraph."),
|
{ node: mkPara("nodeops-ins", "Inserted paragraph.") },
|
||||||
{ position: "after", anchorNodeId: idA },
|
{ position: "after", anchorNodeId: idA },
|
||||||
);
|
);
|
||||||
await new Promise((r) => setTimeout(r, 16000));
|
await new Promise((r) => setTimeout(r, 16000));
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
// Contract tests for the stage-3 drawio client methods (issue #425):
|
||||||
|
// drawioEditCells / drawioFromGraph / drawioFromMermaid. Same seam-override
|
||||||
|
// pattern as drawio-tools.test.mjs: a DocmostClient subclass stubs the I/O seams
|
||||||
|
// so the tool logic runs without a live Docmost / collab socket.
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { DocmostClient } from "../../build/client.js";
|
||||||
|
import {
|
||||||
|
buildDrawioSvg,
|
||||||
|
normalizeXml,
|
||||||
|
mxHash,
|
||||||
|
decodeDrawioSvg,
|
||||||
|
parseCells,
|
||||||
|
} from "../../build/lib/drawio-xml.js";
|
||||||
|
|
||||||
|
const DRAWIO_SCHEMA_ATTRS = new Set([
|
||||||
|
"src", "title", "alt", "width", "height", "size", "aspectRatio", "align", "attachmentId",
|
||||||
|
]);
|
||||||
|
function applyDrawioSchemaDrop(node) {
|
||||||
|
if (!node || typeof node !== "object") return;
|
||||||
|
if (node.type === "drawio" && node.attrs && typeof node.attrs === "object") {
|
||||||
|
for (const key of Object.keys(node.attrs))
|
||||||
|
if (!DRAWIO_SCHEMA_ATTRS.has(key)) delete node.attrs[key];
|
||||||
|
}
|
||||||
|
if (Array.isArray(node.content)) for (const c of node.content) applyDrawioSchemaDrop(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
function svgFor(model, bbox = { width: 400, height: 300 }) {
|
||||||
|
return buildDrawioSvg(normalizeXml(model), "<g/>", bbox);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeClient({ pageDoc, attachmentSvg } = {}) {
|
||||||
|
const calls = { uploads: [], mutations: [] };
|
||||||
|
class TestClient extends DocmostClient {
|
||||||
|
async ensureAuthenticated() {}
|
||||||
|
async getCollabTokenWithReauth() {
|
||||||
|
return "collab-token";
|
||||||
|
}
|
||||||
|
async resolvePageId(pageId) {
|
||||||
|
return `uuid-${pageId}`;
|
||||||
|
}
|
||||||
|
async getPageRaw(pageId) {
|
||||||
|
return {
|
||||||
|
id: pageId, slugId: "s", title: "P", spaceId: "sp",
|
||||||
|
content: pageDoc ?? { type: "doc", content: [] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async uploadAttachmentBuffer(pageId, buffer, fileName) {
|
||||||
|
const id = `att-${calls.uploads.length + 1}`;
|
||||||
|
calls.uploads.push({ pageId, fileName, svg: buffer.toString("utf-8") });
|
||||||
|
return { id, fileName, fileSize: buffer.length };
|
||||||
|
}
|
||||||
|
async fetchAttachmentText() {
|
||||||
|
return attachmentSvg;
|
||||||
|
}
|
||||||
|
mutatePage(pageId, token, apiUrl, transform) {
|
||||||
|
const clone = structuredClone(pageDoc ?? { type: "doc", content: [] });
|
||||||
|
const doc = transform(clone);
|
||||||
|
if (doc) applyDrawioSchemaDrop(doc);
|
||||||
|
calls.mutations.push({ pageId, doc });
|
||||||
|
return Promise.resolve({ doc, verify: { changed: doc != null } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const client = new TestClient("http://127.0.0.1:1/api", "e@x.com", "pw");
|
||||||
|
return { client, calls };
|
||||||
|
}
|
||||||
|
|
||||||
|
function findDrawio(node, acc = []) {
|
||||||
|
if (!node || typeof node !== "object") return acc;
|
||||||
|
if (node.type === "drawio") acc.push(node);
|
||||||
|
if (Array.isArray(node.content)) for (const c of node.content) findDrawio(c, acc);
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stored diagram: a group with two children and an edge.
|
||||||
|
const STORED =
|
||||||
|
"<mxGraphModel><root><mxCell id=\"0\"/><mxCell id=\"1\" parent=\"0\"/>" +
|
||||||
|
'<mxCell id="grp" value="G" style="container=1;fillColor=none;" vertex="1" parent="1">' +
|
||||||
|
'<mxGeometry x="0" y="0" width="300" height="200" as="geometry"/></mxCell>' +
|
||||||
|
'<mxCell id="a" value="A" style="rounded=1;" vertex="1" parent="grp">' +
|
||||||
|
'<mxGeometry x="10" y="10" width="80" height="40" as="geometry"/></mxCell>' +
|
||||||
|
'<mxCell id="b" value="B" style="rounded=1;" vertex="1" parent="grp">' +
|
||||||
|
'<mxGeometry x="10" y="90" width="80" height="40" as="geometry"/></mxCell>' +
|
||||||
|
'<mxCell id="e" style="" edge="1" parent="grp" source="a" target="b">' +
|
||||||
|
'<mxGeometry relative="1" as="geometry"/></mxCell>' +
|
||||||
|
"</root></mxGraphModel>";
|
||||||
|
|
||||||
|
function drawioPageDoc() {
|
||||||
|
return {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "drawio",
|
||||||
|
attrs: {
|
||||||
|
id: "d1", src: "/api/files/att-1/diagram.drawio.svg",
|
||||||
|
attachmentId: "att-1", width: 400, height: 300,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- drawioEditCells --------------------------------------------------------
|
||||||
|
|
||||||
|
test("drawioEditCells: applies ops and repoints the node (current baseHash)", async () => {
|
||||||
|
const { client, calls } = makeClient({
|
||||||
|
pageDoc: drawioPageDoc(),
|
||||||
|
attachmentSvg: svgFor(STORED),
|
||||||
|
});
|
||||||
|
const baseHash = mxHash(normalizeXml(STORED));
|
||||||
|
const res = await client.drawioEditCells(
|
||||||
|
"page1",
|
||||||
|
"d1",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
op: "update",
|
||||||
|
cellId: "a",
|
||||||
|
xml:
|
||||||
|
'<mxCell id="a" value="Renamed" style="rounded=1;" vertex="1" parent="grp">' +
|
||||||
|
'<mxGeometry x="10" y="10" width="80" height="40" as="geometry"/></mxCell>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
baseHash,
|
||||||
|
);
|
||||||
|
assert.equal(res.success, true);
|
||||||
|
assert.equal(calls.uploads.length, 1);
|
||||||
|
const written = decodeDrawioSvg(calls.uploads[0].svg);
|
||||||
|
const cells = parseCells(written);
|
||||||
|
assert.equal(cells.find((c) => c.id === "a").value, "Renamed");
|
||||||
|
assert.equal(cells.find((c) => c.id === "b").value, "B"); // untouched
|
||||||
|
const n = findDrawio(calls.mutations[0].doc)[0];
|
||||||
|
// The stub numbers uploads from 1; this edit is the first upload -> att-1.
|
||||||
|
assert.equal(n.attrs.attachmentId, "att-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("drawioEditCells: delete of the container cascades to children + edge", async () => {
|
||||||
|
const { client, calls } = makeClient({
|
||||||
|
pageDoc: drawioPageDoc(),
|
||||||
|
attachmentSvg: svgFor(STORED),
|
||||||
|
});
|
||||||
|
const baseHash = mxHash(normalizeXml(STORED));
|
||||||
|
await client.drawioEditCells("page1", "d1", [{ op: "delete", cellId: "grp" }], baseHash);
|
||||||
|
const written = decodeDrawioSvg(calls.uploads[0].svg);
|
||||||
|
const ids = parseCells(written).filter((c) => c.id !== "0" && c.id !== "1").map((c) => c.id);
|
||||||
|
assert.deepEqual(ids, [], "grp + a + b + edge all cascaded away");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("drawioEditCells: stale baseHash -> conflict, no upload", async () => {
|
||||||
|
const { client, calls } = makeClient({
|
||||||
|
pageDoc: drawioPageDoc(),
|
||||||
|
attachmentSvg: svgFor(STORED),
|
||||||
|
});
|
||||||
|
await assert.rejects(
|
||||||
|
() => client.drawioEditCells("page1", "d1", [{ op: "delete", cellId: "a" }], "stale"),
|
||||||
|
/conflict/,
|
||||||
|
);
|
||||||
|
assert.equal(calls.uploads.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("drawioEditCells: baseHash is mandatory", async () => {
|
||||||
|
const { client } = makeClient({ pageDoc: drawioPageDoc(), attachmentSvg: svgFor(STORED) });
|
||||||
|
await assert.rejects(
|
||||||
|
() => client.drawioEditCells("page1", "d1", [{ op: "delete", cellId: "a" }], ""),
|
||||||
|
/baseHash is mandatory/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- drawioFromGraph --------------------------------------------------------
|
||||||
|
|
||||||
|
test("drawioFromGraph: builds a diagram from a graph and inserts a node", async () => {
|
||||||
|
const pageDoc = { type: "doc", content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }] };
|
||||||
|
const { client, calls } = makeClient({ pageDoc });
|
||||||
|
const res = await client.drawioFromGraph(
|
||||||
|
"page1",
|
||||||
|
{ position: "append" },
|
||||||
|
{
|
||||||
|
nodes: [
|
||||||
|
{ id: "api", label: "API", kind: "gateway", icon: "aws:api_gateway", group: "vpc" },
|
||||||
|
{ id: "fn", label: "Handler", kind: "service", icon: "aws:lambda", group: "vpc" },
|
||||||
|
{ id: "db", label: "Orders", kind: "db", icon: "aws:dynamodb" },
|
||||||
|
],
|
||||||
|
groups: [{ id: "vpc", label: "VPC" }],
|
||||||
|
edges: [{ from: "api", to: "fn", kind: "sync" }, { from: "fn", to: "db", kind: "async" }],
|
||||||
|
},
|
||||||
|
"LR",
|
||||||
|
"default",
|
||||||
|
);
|
||||||
|
assert.equal(res.success, true);
|
||||||
|
assert.equal(res.nodeId, "#1");
|
||||||
|
assert.equal(res.iconsMissing.length, 0, `unresolved: ${res.iconsMissing}`);
|
||||||
|
assert.equal(res.iconsResolved, 3);
|
||||||
|
// The uploaded model decodes back and carries the group + nodes.
|
||||||
|
const written = decodeDrawioSvg(calls.uploads[0].svg);
|
||||||
|
const cells = parseCells(written);
|
||||||
|
assert.ok(cells.some((c) => c.id === "vpc"));
|
||||||
|
assert.ok(cells.some((c) => c.id === "api"));
|
||||||
|
// Group is transparent.
|
||||||
|
const vpc = cells.find((c) => c.id === "vpc");
|
||||||
|
assert.equal(vpc.styleMap.fillColor, "none");
|
||||||
|
assert.equal(vpc.styleMap.container, "1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("drawioFromGraph: an invalid graph throws before any upload", async () => {
|
||||||
|
const { client, calls } = makeClient({ pageDoc: { type: "doc", content: [] } });
|
||||||
|
await assert.rejects(
|
||||||
|
() => client.drawioFromGraph("page1", { position: "append" }, { nodes: [] }),
|
||||||
|
/non-empty/,
|
||||||
|
);
|
||||||
|
assert.equal(calls.uploads.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("drawioFromGraph incremental into an existing node keeps prior coords", async () => {
|
||||||
|
// The stored diagram has a,b at known coords; add a new node c incrementally.
|
||||||
|
const { client, calls } = makeClient({
|
||||||
|
pageDoc: drawioPageDoc(),
|
||||||
|
attachmentSvg: svgFor(STORED),
|
||||||
|
});
|
||||||
|
const res = await client.drawioFromGraph(
|
||||||
|
"page1",
|
||||||
|
{ position: "append" },
|
||||||
|
{
|
||||||
|
nodes: [
|
||||||
|
{ id: "a", label: "A" },
|
||||||
|
{ id: "b", label: "B" },
|
||||||
|
{ id: "c", label: "C new" },
|
||||||
|
],
|
||||||
|
edges: [{ from: "b", to: "c" }],
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
"incremental",
|
||||||
|
"d1", // target the existing diagram
|
||||||
|
);
|
||||||
|
assert.equal(res.success, true);
|
||||||
|
const written = decodeDrawioSvg(calls.uploads[0].svg);
|
||||||
|
const cells = parseCells(written);
|
||||||
|
const a = cells.find((c) => c.id === "a");
|
||||||
|
const b = cells.find((c) => c.id === "b");
|
||||||
|
// Existing coords preserved (the stored a/b absolute coords from STORED).
|
||||||
|
assert.equal(a.geometry.x, 10);
|
||||||
|
assert.equal(a.geometry.y, 10);
|
||||||
|
assert.equal(b.geometry.x, 10);
|
||||||
|
assert.equal(b.geometry.y, 90);
|
||||||
|
assert.ok(cells.some((c) => c.id === "c"), "new node c added");
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- drawioFromMermaid ------------------------------------------------------
|
||||||
|
|
||||||
|
test("drawioFromMermaid: converts a flowchart and inserts a diagram", async () => {
|
||||||
|
const pageDoc = { type: "doc", content: [{ type: "paragraph", attrs: { id: "p1" }, content: [] }] };
|
||||||
|
const { client, calls } = makeClient({ pageDoc });
|
||||||
|
const res = await client.drawioFromMermaid(
|
||||||
|
"page1",
|
||||||
|
{ position: "append" },
|
||||||
|
"flowchart LR\n A[Start] --> B{Choose}\n B -->|yes| C[Done]\n B -->|no| D[Stop]",
|
||||||
|
);
|
||||||
|
assert.equal(res.success, true);
|
||||||
|
const written = decodeDrawioSvg(calls.uploads[0].svg);
|
||||||
|
const cells = parseCells(written);
|
||||||
|
for (const id of ["A", "B", "C", "D"]) {
|
||||||
|
assert.ok(cells.some((c) => c.id === id), `node ${id} present`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("drawioFromMermaid: a non-flowchart is rejected, no upload", async () => {
|
||||||
|
const { client, calls } = makeClient({ pageDoc: { type: "doc", content: [] } });
|
||||||
|
await assert.rejects(
|
||||||
|
() => client.drawioFromMermaid("page1", { position: "append" }, "sequenceDiagram\n A->>B: x"),
|
||||||
|
/only 'flowchart'\/'graph' is supported/,
|
||||||
|
);
|
||||||
|
assert.equal(calls.uploads.length, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
// Mock-HTTP tests for DocmostClient.getPageContext — the #443 "where am I /
|
||||||
|
// what's around" read tool. A local http.createServer stands in for Docmost
|
||||||
|
// (same harness style as pagination-cursor.test.mjs) so everything is
|
||||||
|
// deterministic and offline.
|
||||||
|
//
|
||||||
|
// Contract pinned here:
|
||||||
|
// - Two requests: POST /pages/breadcrumbs (ancestor chain root->page, page
|
||||||
|
// INCLUDED as the LAST element) + listSidebarPages (direct children).
|
||||||
|
// - Split: last chain element -> `page`; the rest (root->parent) ->
|
||||||
|
// `breadcrumbs`. A ROOT page (chain length 1) -> breadcrumbs: [].
|
||||||
|
// - children: {pageId, title, hasChildren} in sidebar order.
|
||||||
|
// - INVARIANT: only the UUID `pageId` is exposed, never `slugId`.
|
||||||
|
// - A slugId input is resolved via /pages/info first (adds one request); a
|
||||||
|
// UUID input short-circuits (stays at two requests).
|
||||||
|
// - A bad/inaccessible pageId throws a CLEAR error, not an empty object.
|
||||||
|
import { test, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import http from "node:http";
|
||||||
|
import { DocmostClient } from "../../build/client.js";
|
||||||
|
|
||||||
|
function readBody(req) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let raw = "";
|
||||||
|
req.on("data", (chunk) => {
|
||||||
|
raw += chunk;
|
||||||
|
});
|
||||||
|
req.on("end", () => resolve(raw));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startServer(handler) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const server = http.createServer(handler);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const { port } = server.address();
|
||||||
|
resolve({ server, baseURL: `http://127.0.0.1:${port}/api` });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeServer(server) {
|
||||||
|
return new Promise((resolve) => server.close(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(res, status, obj, extraHeaders = {}) {
|
||||||
|
res.writeHead(status, { "Content-Type": "application/json", ...extraHeaders });
|
||||||
|
res.end(JSON.stringify(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
const openServers = [];
|
||||||
|
async function spawn(handler) {
|
||||||
|
const { server, baseURL } = await startServer(handler);
|
||||||
|
openServers.push(server);
|
||||||
|
return { server, baseURL };
|
||||||
|
}
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
await Promise.all(openServers.map((s) => closeServer(s)));
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleLogin(req, res) {
|
||||||
|
if (req.url === "/api/auth/login") {
|
||||||
|
sendJson(res, 200, { success: true }, {
|
||||||
|
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two real UUIDs so resolvePageId short-circuits (no /pages/info round-trip).
|
||||||
|
const ROOT_UUID = "00000000-0000-4000-8000-000000000001";
|
||||||
|
const MID_UUID = "00000000-0000-4000-8000-000000000002";
|
||||||
|
const PAGE_UUID = "00000000-0000-4000-8000-000000000003";
|
||||||
|
const CHILD_A = "00000000-0000-4000-8000-00000000000a";
|
||||||
|
const CHILD_B = "00000000-0000-4000-8000-00000000000b";
|
||||||
|
|
||||||
|
// Build a breadcrumbs response as the server sends it: root->page order, page
|
||||||
|
// LAST, wrapped in the {data,success} envelope. slugId/icon/position are present
|
||||||
|
// on the wire (they must NOT leak into the tool output).
|
||||||
|
function breadcrumbsEnvelope(chain) {
|
||||||
|
return { success: true, data: chain };
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 1) 3rd-level page: page = last chain element; breadcrumbs = the two ancestors
|
||||||
|
// root->parent; children mapped {pageId,title,hasChildren} in order; no leak;
|
||||||
|
// exactly two requests for a UUID input.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
test("getPageContext: 3rd-level page splits chain, maps children, no slugId leak, 2 requests", async () => {
|
||||||
|
let breadcrumbReqs = 0;
|
||||||
|
let sidebarReqs = 0;
|
||||||
|
let infoReqs = 0;
|
||||||
|
let breadcrumbBody = null;
|
||||||
|
|
||||||
|
const { baseURL } = await spawn(async (req, res) => {
|
||||||
|
const raw = await readBody(req);
|
||||||
|
if (handleLogin(req, res)) return;
|
||||||
|
if (req.url === "/api/pages/info") {
|
||||||
|
infoReqs++;
|
||||||
|
sendJson(res, 404, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === "/api/pages/breadcrumbs") {
|
||||||
|
breadcrumbReqs++;
|
||||||
|
breadcrumbBody = JSON.parse(raw || "{}");
|
||||||
|
// root -> parent -> page (page LAST). slugId/icon/position on the wire.
|
||||||
|
sendJson(
|
||||||
|
res,
|
||||||
|
200,
|
||||||
|
breadcrumbsEnvelope([
|
||||||
|
{ id: ROOT_UUID, slugId: "rootSlug", title: "Infrastructure", spaceId: "sp1", position: "a", icon: null, parentPageId: null, hasChildren: true },
|
||||||
|
{ id: MID_UUID, slugId: "midSlug", title: "Datacenter A", spaceId: "sp1", position: "a", icon: null, parentPageId: ROOT_UUID, hasChildren: true },
|
||||||
|
{ id: PAGE_UUID, slugId: "pageSlug", title: "Rack 12", spaceId: "sp1", position: "b", icon: null, parentPageId: MID_UUID, hasChildren: true },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === "/api/pages/sidebar-pages") {
|
||||||
|
sidebarReqs++;
|
||||||
|
const body = JSON.parse(raw || "{}");
|
||||||
|
assert.equal(body.pageId, PAGE_UUID, "children scoped to the page UUID");
|
||||||
|
assert.equal(body.spaceId, "sp1", "children scoped to the page's space");
|
||||||
|
sendJson(res, 200, {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{ id: CHILD_A, slugId: "aSlug", title: "Servers", parentPageId: PAGE_UUID, hasChildren: true, position: "a" },
|
||||||
|
{ id: CHILD_B, slugId: "bSlug", title: "Network", parentPageId: PAGE_UUID, hasChildren: false, position: "b" },
|
||||||
|
],
|
||||||
|
meta: { hasNextPage: false, nextCursor: null },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(res, 404, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
|
const result = await client.getPageContext(PAGE_UUID);
|
||||||
|
|
||||||
|
assert.equal(infoReqs, 0, "UUID input short-circuits resolvePageId (no /pages/info)");
|
||||||
|
assert.equal(breadcrumbReqs, 1, "exactly one breadcrumbs request");
|
||||||
|
assert.equal(sidebarReqs, 1, "exactly one sidebar request");
|
||||||
|
assert.deepEqual(breadcrumbBody, { pageId: PAGE_UUID }, "breadcrumbs posts the UUID");
|
||||||
|
|
||||||
|
// page = the LAST chain element.
|
||||||
|
assert.deepEqual(result.page, {
|
||||||
|
pageId: PAGE_UUID,
|
||||||
|
title: "Rack 12",
|
||||||
|
spaceId: "sp1",
|
||||||
|
});
|
||||||
|
// breadcrumbs = root->parent (the chain minus the page itself).
|
||||||
|
assert.deepEqual(result.breadcrumbs, [
|
||||||
|
{ pageId: ROOT_UUID, title: "Infrastructure" },
|
||||||
|
{ pageId: MID_UUID, title: "Datacenter A" },
|
||||||
|
]);
|
||||||
|
// children mapped in order, hasChildren coerced to boolean.
|
||||||
|
assert.deepEqual(result.children, [
|
||||||
|
{ pageId: CHILD_A, title: "Servers", hasChildren: true },
|
||||||
|
{ pageId: CHILD_B, title: "Network", hasChildren: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// No slugId anywhere in the output.
|
||||||
|
const dump = JSON.stringify(result);
|
||||||
|
assert.ok(!dump.includes("Slug"), "no slugId leaks into the output");
|
||||||
|
assert.ok(!/\bslugId\b/.test(dump), "no slugId key in the output");
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 2) ROOT page: chain has ONE element (the page itself) -> breadcrumbs: [].
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
test("getPageContext: a root page has breadcrumbs: []", async () => {
|
||||||
|
const { baseURL } = await spawn(async (req, res) => {
|
||||||
|
const raw = await readBody(req);
|
||||||
|
if (handleLogin(req, res)) return;
|
||||||
|
if (req.url === "/api/pages/breadcrumbs") {
|
||||||
|
// A root page: the CTE returns only the page itself.
|
||||||
|
sendJson(
|
||||||
|
res,
|
||||||
|
200,
|
||||||
|
breadcrumbsEnvelope([
|
||||||
|
{ id: ROOT_UUID, slugId: "rootSlug", title: "Infrastructure", spaceId: "sp1", parentPageId: null, hasChildren: false },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === "/api/pages/sidebar-pages") {
|
||||||
|
sendJson(res, 200, {
|
||||||
|
success: true,
|
||||||
|
data: { items: [], meta: { hasNextPage: false, nextCursor: null } },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(res, 404, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
|
const result = await client.getPageContext(ROOT_UUID);
|
||||||
|
|
||||||
|
assert.deepEqual(result.page, {
|
||||||
|
pageId: ROOT_UUID,
|
||||||
|
title: "Infrastructure",
|
||||||
|
spaceId: "sp1",
|
||||||
|
});
|
||||||
|
assert.deepEqual(result.breadcrumbs, [], "root page: no ancestors");
|
||||||
|
assert.deepEqual(result.children, [], "no children");
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 3) A slugId input is resolved via /pages/info first (one extra request), then
|
||||||
|
// breadcrumbs/sidebar use the resolved UUID.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
test("getPageContext: a slugId input is resolved via /pages/info", async () => {
|
||||||
|
let infoReqs = 0;
|
||||||
|
let infoBody = null;
|
||||||
|
let breadcrumbBody = null;
|
||||||
|
|
||||||
|
const { baseURL } = await spawn(async (req, res) => {
|
||||||
|
const raw = await readBody(req);
|
||||||
|
if (handleLogin(req, res)) return;
|
||||||
|
if (req.url === "/api/pages/info") {
|
||||||
|
infoReqs++;
|
||||||
|
infoBody = JSON.parse(raw || "{}");
|
||||||
|
// getPageRaw: slugId -> canonical UUID.
|
||||||
|
sendJson(res, 200, {
|
||||||
|
success: true,
|
||||||
|
data: { id: PAGE_UUID, slugId: "pageSlug", title: "Rack 12", spaceId: "sp1" },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === "/api/pages/breadcrumbs") {
|
||||||
|
breadcrumbBody = JSON.parse(raw || "{}");
|
||||||
|
sendJson(
|
||||||
|
res,
|
||||||
|
200,
|
||||||
|
breadcrumbsEnvelope([
|
||||||
|
{ id: ROOT_UUID, slugId: "rootSlug", title: "Infrastructure", spaceId: "sp1", parentPageId: null },
|
||||||
|
{ id: PAGE_UUID, slugId: "pageSlug", title: "Rack 12", spaceId: "sp1", parentPageId: ROOT_UUID },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === "/api/pages/sidebar-pages") {
|
||||||
|
sendJson(res, 200, {
|
||||||
|
success: true,
|
||||||
|
data: { items: [], meta: { hasNextPage: false, nextCursor: null } },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(res, 404, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
|
const result = await client.getPageContext("pageSlug");
|
||||||
|
|
||||||
|
assert.equal(infoReqs, 1, "slugId resolved via one /pages/info");
|
||||||
|
assert.deepEqual(infoBody, { pageId: "pageSlug" }, "resolve posts the raw slugId");
|
||||||
|
assert.deepEqual(
|
||||||
|
breadcrumbBody,
|
||||||
|
{ pageId: PAGE_UUID },
|
||||||
|
"breadcrumbs posts the RESOLVED uuid, not the slugId",
|
||||||
|
);
|
||||||
|
assert.equal(result.page.pageId, PAGE_UUID, "page.pageId is the UUID");
|
||||||
|
assert.deepEqual(result.breadcrumbs, [
|
||||||
|
{ pageId: ROOT_UUID, title: "Infrastructure" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 4) >20 children: cursor pagination returns ALL of them, no dupes (regression
|
||||||
|
// on the #442 bug class — getPageContext must not re-introduce a cap).
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
test("getPageContext: a page with >20 children returns ALL of them (no cap, no dupes)", async () => {
|
||||||
|
// 45 children spread over three cursor pages.
|
||||||
|
const all = Array.from({ length: 45 }, (_, i) => ({
|
||||||
|
id: `child-${i}`,
|
||||||
|
slugId: `slug-${i}`,
|
||||||
|
title: `Child ${i}`,
|
||||||
|
parentPageId: PAGE_UUID,
|
||||||
|
hasChildren: i % 2 === 0,
|
||||||
|
}));
|
||||||
|
const PAGES = {
|
||||||
|
"": { items: all.slice(0, 20), nextCursor: "c1" },
|
||||||
|
c1: { items: all.slice(20, 40), nextCursor: "c2" },
|
||||||
|
c2: { items: all.slice(40), nextCursor: null },
|
||||||
|
};
|
||||||
|
|
||||||
|
const { baseURL } = await spawn(async (req, res) => {
|
||||||
|
const raw = await readBody(req);
|
||||||
|
if (handleLogin(req, res)) return;
|
||||||
|
if (req.url === "/api/pages/breadcrumbs") {
|
||||||
|
sendJson(
|
||||||
|
res,
|
||||||
|
200,
|
||||||
|
breadcrumbsEnvelope([
|
||||||
|
{ id: PAGE_UUID, slugId: "pageSlug", title: "Big Parent", spaceId: "sp1", parentPageId: null },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === "/api/pages/sidebar-pages") {
|
||||||
|
const body = JSON.parse(raw || "{}");
|
||||||
|
const page = PAGES[body.cursor ?? ""] ?? { items: [], nextCursor: null };
|
||||||
|
sendJson(res, 200, {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: page.items,
|
||||||
|
meta: { hasNextPage: page.nextCursor != null, nextCursor: page.nextCursor },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(res, 404, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
|
const result = await client.getPageContext(PAGE_UUID);
|
||||||
|
|
||||||
|
assert.equal(result.children.length, 45, "all 45 children returned");
|
||||||
|
const ids = result.children.map((c) => c.pageId);
|
||||||
|
assert.equal(new Set(ids).size, 45, "no duplicate children");
|
||||||
|
assert.deepEqual(ids, all.map((c) => c.id), "children in server order across cursor pages");
|
||||||
|
assert.equal(result.children[0].hasChildren, true, "hasChildren preserved (child 0)");
|
||||||
|
assert.equal(result.children[1].hasChildren, false, "hasChildren preserved (child 1)");
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 5) A nonexistent / inaccessible pageId -> a CLEAR error, NOT an empty object.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
test("getPageContext: a bad/inaccessible pageId throws a clear error (not {})", async () => {
|
||||||
|
const { baseURL } = await spawn(async (req, res) => {
|
||||||
|
await readBody(req);
|
||||||
|
if (handleLogin(req, res)) return;
|
||||||
|
if (req.url === "/api/pages/breadcrumbs") {
|
||||||
|
// Server rejects an unknown/forbidden page.
|
||||||
|
sendJson(res, 404, { message: "Page not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(res, 404, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
|
await assert.rejects(
|
||||||
|
() => client.getPageContext(PAGE_UUID),
|
||||||
|
(err) => {
|
||||||
|
assert.ok(err instanceof Error, "throws an Error");
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
"a 404 from breadcrumbs propagates as a thrown error, not a hollow {}",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// 6) An empty breadcrumbs chain (should never happen — the endpoint always
|
||||||
|
// includes the page itself) is treated as not-found, not a hollow {page:...}.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
test("getPageContext: an empty breadcrumbs chain throws (defensive)", async () => {
|
||||||
|
const { baseURL } = await spawn(async (req, res) => {
|
||||||
|
await readBody(req);
|
||||||
|
if (handleLogin(req, res)) return;
|
||||||
|
if (req.url === "/api/pages/breadcrumbs") {
|
||||||
|
sendJson(res, 200, breadcrumbsEnvelope([]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(res, 404, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
|
await assert.rejects(
|
||||||
|
() => client.getPageContext(PAGE_UUID),
|
||||||
|
/not found or inaccessible/,
|
||||||
|
"an empty chain is a clear error, not {}",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
// Mock-HTTP integration tests for the getPage conversion cache (issue #479).
|
||||||
|
// A local http.createServer stands in for Docmost (same harness style as
|
||||||
|
// get-page-context.test.mjs) so everything is deterministic and offline.
|
||||||
|
//
|
||||||
|
// Verifies end-to-end through the real client that:
|
||||||
|
// - the FIRST getPage of a page is a MISS (mcp_getpage_cache_misses_total)
|
||||||
|
// and converts the content (the server's convert-representative counter);
|
||||||
|
// - a SECOND getPage of the same (pageId, updatedAt) is a HIT
|
||||||
|
// (mcp_getpage_cache_hits_total) and returns BYTE-IDENTICAL output while
|
||||||
|
// skipping the conversion;
|
||||||
|
// - a changed updatedAt is a fresh key -> MISS again;
|
||||||
|
// - the returned shape still resolves page + subpages.
|
||||||
|
import { test, after, mock } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import http from "node:http";
|
||||||
|
import { DocmostClient } from "../../build/client.js";
|
||||||
|
|
||||||
|
function readBody(req) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let raw = "";
|
||||||
|
req.on("data", (chunk) => (raw += chunk));
|
||||||
|
req.on("end", () => resolve(raw));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendJson(res, status, obj, extraHeaders = {}) {
|
||||||
|
res.writeHead(status, { "Content-Type": "application/json", ...extraHeaders });
|
||||||
|
res.end(JSON.stringify(obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
const openServers = [];
|
||||||
|
after(async () => {
|
||||||
|
await Promise.all(openServers.map((s) => new Promise((r) => s.close(r))));
|
||||||
|
});
|
||||||
|
|
||||||
|
const PAGE_UUID = "00000000-0000-4000-8000-000000000010";
|
||||||
|
const SPACE_UUID = "00000000-0000-4000-8000-0000000000aa";
|
||||||
|
const CHILD_UUID = "00000000-0000-4000-8000-0000000000bb";
|
||||||
|
|
||||||
|
// A small ProseMirror doc so the converter produces non-trivial markdown.
|
||||||
|
function makeDoc(text) {
|
||||||
|
return {
|
||||||
|
type: "doc",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// state.info counts /pages/info hits; state.updatedAt / state.text drive the
|
||||||
|
// content+version returned; state.sidebar counts sidebar-pages hits;
|
||||||
|
// state.subpages (when set) drives the child list the sidebar endpoint returns,
|
||||||
|
// so a test can vary the live subpages across two reads of the same page.
|
||||||
|
function spawn(state) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
await readBody(req);
|
||||||
|
if (req.url === "/api/auth/login") {
|
||||||
|
return sendJson(res, 200, { success: true }, {
|
||||||
|
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (req.url === "/api/pages/info") {
|
||||||
|
state.info++;
|
||||||
|
return sendJson(res, 200, {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: PAGE_UUID,
|
||||||
|
slugId: "slug123456",
|
||||||
|
title: "Cached Page",
|
||||||
|
parentPageId: null,
|
||||||
|
spaceId: SPACE_UUID,
|
||||||
|
updatedAt: state.updatedAt,
|
||||||
|
content: makeDoc(state.text),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (req.url === "/api/pages/sidebar-pages") {
|
||||||
|
state.sidebar++;
|
||||||
|
const items = state.subpages ?? [
|
||||||
|
{ id: CHILD_UUID, title: "Child", hasChildren: false },
|
||||||
|
];
|
||||||
|
return sendJson(res, 200, {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items,
|
||||||
|
meta: { hasNextPage: false, nextCursor: null },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sendJson(res, 404, { message: "not found" });
|
||||||
|
});
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
openServers.push(server);
|
||||||
|
resolve(`http://127.0.0.1:${server.address().port}/api`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeClient(baseURL, metrics) {
|
||||||
|
return new DocmostClient({
|
||||||
|
apiUrl: baseURL,
|
||||||
|
getToken: async () => "access",
|
||||||
|
onMetric: (name, value) => {
|
||||||
|
metrics[name] = (metrics[name] ?? 0) + value;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("first read MISS, second read HIT with byte-identical output; convert runs once", async () => {
|
||||||
|
const state = { info: 0, sidebar: 0, updatedAt: "2026-01-01T00:00:00Z", text: "Hello world" };
|
||||||
|
const baseURL = await spawn(state);
|
||||||
|
const metrics = {};
|
||||||
|
const client = makeClient(baseURL, metrics);
|
||||||
|
|
||||||
|
const first = await client.getPage(PAGE_UUID);
|
||||||
|
assert.equal(metrics["mcp_getpage_cache_misses_total"], 1, "first read is a miss");
|
||||||
|
assert.equal(metrics["mcp_getpage_cache_hits_total"] ?? 0, 0, "no hit yet");
|
||||||
|
|
||||||
|
const second = await client.getPage(PAGE_UUID);
|
||||||
|
assert.equal(metrics["mcp_getpage_cache_hits_total"], 1, "second read is a hit");
|
||||||
|
assert.equal(metrics["mcp_getpage_cache_misses_total"], 1, "still one miss");
|
||||||
|
|
||||||
|
// BYTE-IDENTICAL: the cache only skips recomputation, never changes output.
|
||||||
|
assert.deepEqual(second, first, "cached result is identical to the uncached one");
|
||||||
|
assert.equal(
|
||||||
|
JSON.stringify(second),
|
||||||
|
JSON.stringify(first),
|
||||||
|
"serialized output is byte-identical",
|
||||||
|
);
|
||||||
|
|
||||||
|
// The page fetch + subpages fetch still happen every call (only the CPU
|
||||||
|
// conversion is cached); both reads hit /pages/info and sidebar-pages.
|
||||||
|
assert.equal(state.info, 2, "both reads still fetch /pages/info");
|
||||||
|
assert.equal(state.sidebar, 2, "both reads still fetch subpages");
|
||||||
|
|
||||||
|
// Shape sanity: content present, subpages resolved.
|
||||||
|
assert.equal(typeof second.data.content, "string");
|
||||||
|
assert.ok(second.data.content.includes("Hello world"));
|
||||||
|
assert.deepEqual(second.data.subpages, [{ id: CHILD_UUID, title: "Child" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a changed updatedAt is a fresh key -> MISS again, with the NEW content", async () => {
|
||||||
|
const state = { info: 0, sidebar: 0, updatedAt: "2026-01-01T00:00:00Z", text: "Version one" };
|
||||||
|
const baseURL = await spawn(state);
|
||||||
|
const metrics = {};
|
||||||
|
const client = makeClient(baseURL, metrics);
|
||||||
|
|
||||||
|
const a = await client.getPage(PAGE_UUID); // miss
|
||||||
|
const b = await client.getPage(PAGE_UUID); // hit
|
||||||
|
assert.equal(metrics["mcp_getpage_cache_misses_total"], 1);
|
||||||
|
assert.equal(metrics["mcp_getpage_cache_hits_total"], 1);
|
||||||
|
assert.ok(a.data.content.includes("Version one"));
|
||||||
|
|
||||||
|
// The page changes: new updatedAt AND new content.
|
||||||
|
state.updatedAt = "2026-02-02T00:00:00Z";
|
||||||
|
state.text = "Version two";
|
||||||
|
|
||||||
|
const c = await client.getPage(PAGE_UUID); // miss on the new key
|
||||||
|
assert.equal(metrics["mcp_getpage_cache_misses_total"], 2, "changed version -> miss");
|
||||||
|
assert.equal(metrics["mcp_getpage_cache_hits_total"], 1, "no stale hit");
|
||||||
|
assert.ok(c.data.content.includes("Version two"), "the NEW content is served");
|
||||||
|
assert.ok(!c.data.content.includes("Version one"), "no stale markdown");
|
||||||
|
|
||||||
|
const d = await client.getPage(PAGE_UUID); // hit on the new key
|
||||||
|
assert.equal(metrics["mcp_getpage_cache_hits_total"], 2, "the new snapshot caches too");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a slugId read and a UUID read of the same page share one cache entry", async () => {
|
||||||
|
// resolvePageId maps the slugId -> UUID via /pages/info; the cache keys on the
|
||||||
|
// canonical UUID (resultData.id), so both inputs land on the same entry.
|
||||||
|
const state = { info: 0, sidebar: 0, updatedAt: "2026-01-01T00:00:00Z", text: "Shared" };
|
||||||
|
const baseURL = await spawn(state);
|
||||||
|
const metrics = {};
|
||||||
|
const client = makeClient(baseURL, metrics);
|
||||||
|
|
||||||
|
await client.getPage(PAGE_UUID); // miss (keyed on UUID)
|
||||||
|
await client.getPage("slug123456"); // the server returns the same id -> HIT
|
||||||
|
assert.equal(metrics["mcp_getpage_cache_misses_total"], 1, "one conversion total");
|
||||||
|
assert.equal(metrics["mcp_getpage_cache_hits_total"], 1, "slugId read hits the UUID entry");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("on a conversion HIT, the {{SUBPAGES}} block reflects the LIVE subpages, not the cached ones", async () => {
|
||||||
|
// The whole byte-identity guarantee: the cache stores the conversion output
|
||||||
|
// BEFORE the {{SUBPAGES}} substitution, so a re-read of an UNCHANGED page still
|
||||||
|
// splices the FRESH subpage list. The page body itself contains {{SUBPAGES}}
|
||||||
|
// (converts to a literal placeholder); getPage replaces it with the live list.
|
||||||
|
const CHILD_A = "00000000-0000-4000-8000-0000000000a1";
|
||||||
|
const CHILD_B = "00000000-0000-4000-8000-0000000000b2";
|
||||||
|
const state = {
|
||||||
|
info: 0,
|
||||||
|
sidebar: 0,
|
||||||
|
updatedAt: "2026-01-01T00:00:00Z", // FIXED across both reads -> conversion cache HIT
|
||||||
|
text: "Body before {{SUBPAGES}} body after",
|
||||||
|
subpages: [{ id: CHILD_A, title: "Alpha", hasChildren: false }],
|
||||||
|
};
|
||||||
|
const baseURL = await spawn(state);
|
||||||
|
const metrics = {};
|
||||||
|
const client = makeClient(baseURL, metrics);
|
||||||
|
|
||||||
|
// Read 1: MISS (converts). The substitution runs with list A.
|
||||||
|
const first = await client.getPage(PAGE_UUID);
|
||||||
|
assert.equal(metrics["mcp_getpage_cache_misses_total"], 1, "first read converts (miss)");
|
||||||
|
assert.ok(first.data.content.includes("[Alpha](page:" + CHILD_A + ")"), "list A spliced in");
|
||||||
|
assert.ok(!first.data.content.includes("{{SUBPAGES}}"), "placeholder consumed");
|
||||||
|
|
||||||
|
// The subpages change while the PAGE CONTENT/updatedAt do NOT: same conversion
|
||||||
|
// cache key -> a HIT that skips the CPU walk, but the live substitution must
|
||||||
|
// still run on the NEW list B.
|
||||||
|
state.subpages = [{ id: CHILD_B, title: "Beta", hasChildren: false }];
|
||||||
|
|
||||||
|
const second = await client.getPage(PAGE_UUID);
|
||||||
|
assert.equal(metrics["mcp_getpage_cache_hits_total"], 1, "second read is a conversion HIT");
|
||||||
|
assert.equal(metrics["mcp_getpage_cache_misses_total"], 1, "no second conversion");
|
||||||
|
|
||||||
|
// The cache did NOT freeze the subpages block: list B is present, list A gone.
|
||||||
|
assert.ok(second.data.content.includes("[Beta](page:" + CHILD_B + ")"), "live list B spliced in on a HIT");
|
||||||
|
assert.ok(!second.data.content.includes("Alpha"), "stale list A is NOT frozen into the output");
|
||||||
|
assert.deepEqual(second.data.subpages, [{ id: CHILD_B, title: "Beta" }], "subpages field reflects list B");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a cache HIT SKIPS the convertProseMirrorToMarkdown CPU walk (called once across MISS+HIT)", async () => {
|
||||||
|
// The single reason the cache exists: on a hit the expensive PM-tree walk must
|
||||||
|
// NOT run. The miss counter alone can't prove this — a broken hit branch that
|
||||||
|
// re-converted (same output, misses=1) would leave every other assert green.
|
||||||
|
// So spy directly on the conversion seam and assert the call COUNT.
|
||||||
|
const state = { info: 0, sidebar: 0, updatedAt: "2026-01-01T00:00:00Z", text: "Body text" };
|
||||||
|
const baseURL = await spawn(state);
|
||||||
|
const metrics = {};
|
||||||
|
const client = makeClient(baseURL, metrics);
|
||||||
|
|
||||||
|
// Spy on the seam that wraps convertProseMirrorToMarkdown; it still delegates,
|
||||||
|
// so output stays real and byte-identical — we only count invocations.
|
||||||
|
const spy = mock.method(client, "convertPageMarkdown");
|
||||||
|
|
||||||
|
await client.getPage(PAGE_UUID); // MISS -> converts once
|
||||||
|
assert.equal(spy.mock.callCount(), 1, "the miss converts exactly once");
|
||||||
|
|
||||||
|
await client.getPage(PAGE_UUID); // HIT -> must NOT convert again
|
||||||
|
assert.equal(
|
||||||
|
spy.mock.callCount(),
|
||||||
|
1,
|
||||||
|
"the hit skips the conversion: still exactly one call across MISS+HIT",
|
||||||
|
);
|
||||||
|
assert.equal(metrics["mcp_getpage_cache_hits_total"], 1, "and it was recorded as a hit");
|
||||||
|
|
||||||
|
spy.mock.restore();
|
||||||
|
});
|
||||||
@@ -372,7 +372,11 @@ test("replaceImage opens by the resolved UUID AND keys its page lock by that UUI
|
|||||||
// single flush. This proves the flush actually executes queued callbacks, so
|
// single flush. This proves the flush actually executes queued callbacks, so
|
||||||
// probeRan === false above means "blocked", not "the flush never ran anyone".
|
// probeRan === false above means "blocked", not "the flush never ran anyone".
|
||||||
let freeRan = false;
|
let freeRan = false;
|
||||||
const freeDone = withPageLock(`page.free-${UUID}`, async () => {
|
// A DIFFERENT canonical UUID (unrelated to the page under test). withPageLock
|
||||||
|
// now asserts its key is a canonical UUID (#449), so the "free" probe key must
|
||||||
|
// also be a valid — but distinct — UUID, not a synthetic label.
|
||||||
|
const FREE_UUID = "99999999-9999-4999-8999-999999999999";
|
||||||
|
const freeDone = withPageLock(FREE_UUID, async () => {
|
||||||
freeRan = true;
|
freeRan = true;
|
||||||
});
|
});
|
||||||
await new Promise((r) => setImmediate(r));
|
await new Promise((r) => setImmediate(r));
|
||||||
|
|||||||
@@ -323,7 +323,9 @@ test("MCP_COLLAB_SESSION_IDLE_MS=0 disables the cache (legacy provider-per-op)",
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("replaceImage-shaped flow: acquire under an EXTERNAL page lock does not deadlock and reuses one session", async () => {
|
test("replaceImage-shaped flow: acquire under an EXTERNAL page lock does not deadlock and reuses one session", async () => {
|
||||||
const pageId = "page-lock";
|
// withPageLock now asserts a canonical UUID key (#449); this flow takes the
|
||||||
|
// real page lock (mirroring replaceImage), so the key must be a valid UUID.
|
||||||
|
const pageId = "77777777-7777-4777-8777-777777777777";
|
||||||
// Mirror replaceImage: hold ONE withPageLock across scan (read-only) + write,
|
// Mirror replaceImage: hold ONE withPageLock across scan (read-only) + write,
|
||||||
// each going through the non-locking acquireCollabSession.
|
// each going through the non-locking acquireCollabSession.
|
||||||
const result = await withPageLock(pageId, async () => {
|
const result = await withPageLock(pageId, async () => {
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// Issue #464 — prove the size guard SKIPS the recreateTransform pipeline over
|
||||||
|
// the cap, not merely that it returns "coarse". node:test's mock.module needs an
|
||||||
|
// experimental flag the suite does not pass, so instead of a module spy we use a
|
||||||
|
// deterministic BEHAVIORAL proxy that isolates the one variable — the guard:
|
||||||
|
//
|
||||||
|
// Same over-cap pair, run twice:
|
||||||
|
// (a) default caps -> guard trips -> recreateTransform skipped,
|
||||||
|
// (b) caps raised above the doc -> guard OFF -> recreateTransform DOES run.
|
||||||
|
//
|
||||||
|
// The only code path that differs between (a) and (b) is whether
|
||||||
|
// recreateTransform executes. recreateTransform on this pair is O(n²) and takes
|
||||||
|
// SECONDS; the guarded path is a linear coarse diff taking milliseconds. So a
|
||||||
|
// large (a)≪(b) time ratio can ONLY be explained by (a) skipping the transform.
|
||||||
|
// This asserts the skip without depending on mock.module.
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { diffDocs } from "../../build/lib/diff.js";
|
||||||
|
|
||||||
|
const t = (text) => ({ type: "text", text });
|
||||||
|
const para = (text) => ({ type: "paragraph", content: text ? [t(text)] : [] });
|
||||||
|
const doc = (children) => ({ type: "doc", content: children });
|
||||||
|
function buildDoc(n, seed) {
|
||||||
|
return doc(
|
||||||
|
Array.from({ length: n }, (_, i) =>
|
||||||
|
para(Array.from({ length: 8 }, (_, w) => `${seed}${i}_${w}`).join(" ")),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function clearEnv() {
|
||||||
|
delete process.env.MCP_DIFF_MAX_NODES;
|
||||||
|
delete process.env.MCP_DIFF_MAX_BYTES;
|
||||||
|
}
|
||||||
|
function timed(fn) {
|
||||||
|
const s = performance.now();
|
||||||
|
const out = fn();
|
||||||
|
return { out, ms: performance.now() - s };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A 300-para (~600-node) pair: comfortably over the 150-node default, yet small
|
||||||
|
// enough that the un-guarded recreateTransform still FINISHES (~1-3s) so the
|
||||||
|
// test can time the contrast without hanging.
|
||||||
|
const OLD = buildDoc(300, "a");
|
||||||
|
const NEW = buildDoc(300, "b");
|
||||||
|
|
||||||
|
test("guard skips recreateTransform over-cap (guarded run is far faster than un-guarded)", () => {
|
||||||
|
// (a) Guarded: default caps -> should short-circuit to coarse, near-instant.
|
||||||
|
clearEnv();
|
||||||
|
const guarded = timed(() => diffDocs(OLD, NEW));
|
||||||
|
assert.match(
|
||||||
|
guarded.out.markdown,
|
||||||
|
/coarse block-level diff/,
|
||||||
|
"guarded run must be coarse (guard tripped)",
|
||||||
|
);
|
||||||
|
|
||||||
|
// (b) Un-guarded: raise both caps above the doc so the precise path runs.
|
||||||
|
process.env.MCP_DIFF_MAX_NODES = "1000000";
|
||||||
|
process.env.MCP_DIFF_MAX_BYTES = "100000000";
|
||||||
|
let unguarded;
|
||||||
|
try {
|
||||||
|
unguarded = timed(() => diffDocs(OLD, NEW));
|
||||||
|
} finally {
|
||||||
|
clearEnv();
|
||||||
|
}
|
||||||
|
assert.doesNotMatch(
|
||||||
|
unguarded.out.markdown,
|
||||||
|
/coarse block-level diff/,
|
||||||
|
"with caps raised, the precise recreateTransform path runs",
|
||||||
|
);
|
||||||
|
|
||||||
|
// The precise run executed recreateTransform (O(n²)); the guarded run did not.
|
||||||
|
// Require a large speedup so the ONLY explanation is the skipped transform.
|
||||||
|
assert.ok(
|
||||||
|
guarded.ms * 5 < unguarded.ms,
|
||||||
|
`guarded (${guarded.ms.toFixed(1)}ms) must be >=5x faster than un-guarded ` +
|
||||||
|
`(${unguarded.ms.toFixed(1)}ms); a small gap would mean the transform still ran`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("guarded over-cap call stays within the ~200ms event-loop budget", () => {
|
||||||
|
clearEnv();
|
||||||
|
// Best-of-3 to shed GC/JIT noise; the guarded coarse path is a linear walk.
|
||||||
|
let best = Infinity;
|
||||||
|
for (let i = 0; i < 3; i++) best = Math.min(best, timed(() => diffDocs(OLD, NEW)).ms);
|
||||||
|
assert.ok(best < 200, `guarded over-cap diff must be <200ms, was ${best.toFixed(1)}ms`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
// Issue #464 — prod CPU-DoS pre-flight size guard for diffDocs.
|
||||||
|
//
|
||||||
|
// diffDocs synchronously calls recreateTransform (rfc6902) which is O(n·m) in
|
||||||
|
// node count and O(w²) in per-run word count; on a large/heavily-changed doc it
|
||||||
|
// pins the event loop for seconds-to-hours WITHOUT throwing. A pre-flight size
|
||||||
|
// guard routes any doc over MCP_DIFF_MAX_NODES / MCP_DIFF_MAX_BYTES straight to
|
||||||
|
// the coarse fallback (`fellBack:true`), so the sync block stays ~<200ms.
|
||||||
|
//
|
||||||
|
// These tests assert the BEHAVIOR of the guard (fast + coarse-mode + asymmetry +
|
||||||
|
// env knobs). A sibling test (diff-guard-skips-recreate.test.mjs) proves
|
||||||
|
// recreateTransform is skipped over the cap via a behavioral proxy (guarded run
|
||||||
|
// is orders of magnitude faster than the same pair with the caps raised).
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
import { diffDocs } from "../../build/lib/diff.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Builders
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
const t = (text) => ({ type: "text", text });
|
||||||
|
const para = (text) => ({ type: "paragraph", content: text ? [t(text)] : [] });
|
||||||
|
const doc = (children) => ({ type: "doc", content: children });
|
||||||
|
|
||||||
|
/** A doc of `n` paragraphs whose words are seeded from `seed` (fully changeable). */
|
||||||
|
function buildDoc(n, wordsPerPara, seed) {
|
||||||
|
const blocks = [];
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const words = [];
|
||||||
|
for (let w = 0; w < wordsPerPara; w++) words.push(`${seed}${i}_${w}`);
|
||||||
|
blocks.push(para(words.join(" ")));
|
||||||
|
}
|
||||||
|
return doc(blocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset the env knobs to their unset default between tests. */
|
||||||
|
function clearEnv() {
|
||||||
|
delete process.env.MCP_DIFF_MAX_NODES;
|
||||||
|
delete process.env.MCP_DIFF_MAX_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Over-threshold (by node count) -> FAST + coarse mode.
|
||||||
|
// A fully re-written 600-para doc is the worst case that drove the incident;
|
||||||
|
// with the guard it must return in well under the ~200ms budget and in coarse
|
||||||
|
// mode. Without the guard this single call takes multiple SECONDS.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
test("over-threshold doc falls back to coarse mode and returns fast", () => {
|
||||||
|
clearEnv();
|
||||||
|
// 600 paragraphs -> ~1200 nodes, far over the 150-node default.
|
||||||
|
const oldDoc = buildDoc(600, 8, "a");
|
||||||
|
const newDoc = buildDoc(600, 8, "b");
|
||||||
|
|
||||||
|
const start = performance.now();
|
||||||
|
const r = diffDocs(oldDoc, newDoc);
|
||||||
|
const elapsed = performance.now() - start;
|
||||||
|
|
||||||
|
// Coarse mode is signalled in the markdown note (fellBack path).
|
||||||
|
assert.match(
|
||||||
|
r.markdown,
|
||||||
|
/coarse block-level diff/,
|
||||||
|
"over-threshold pair must use the coarse fallback",
|
||||||
|
);
|
||||||
|
// Budget: the guard makes this near-instant. Generous 1s ceiling to avoid CI
|
||||||
|
// flake while still being ~10x under the multi-second un-guarded cost.
|
||||||
|
assert.ok(
|
||||||
|
elapsed < 1000,
|
||||||
|
`expected fast coarse fallback, took ${elapsed.toFixed(0)}ms`,
|
||||||
|
);
|
||||||
|
// Coarse diff still detects the wholesale change.
|
||||||
|
assert.ok(r.summary.inserted > 0 || r.summary.deleted > 0, "reports changes");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Under-threshold (small) doc -> precise diff, NOT coarse mode. No regression.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
test("under-threshold doc uses the precise diff (no fallback note)", () => {
|
||||||
|
clearEnv();
|
||||||
|
const oldDoc = doc([para("Hello world")]);
|
||||||
|
const newDoc = doc([para("Hello brave world")]);
|
||||||
|
const r = diffDocs(oldDoc, newDoc);
|
||||||
|
|
||||||
|
assert.doesNotMatch(
|
||||||
|
r.markdown,
|
||||||
|
/coarse block-level diff/,
|
||||||
|
"a small doc must take the precise path",
|
||||||
|
);
|
||||||
|
// Precise word diff finds exactly the inserted word.
|
||||||
|
const ins = r.changes.find((c) => c.op === "insert");
|
||||||
|
assert.ok(ins && /brave/.test(ins.text), "precise diff isolates the inserted word");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Asymmetry: a small NEW doc vs a huge OLD doc (and vice versa) still explodes
|
||||||
|
// rfc6902, so max(old,new) must trip the guard in BOTH directions.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
test("asymmetric pair (huge old, tiny new) falls back to coarse", () => {
|
||||||
|
clearEnv();
|
||||||
|
const hugeOld = buildDoc(600, 8, "a");
|
||||||
|
const tinyNew = doc([para("just one line")]);
|
||||||
|
const r = diffDocs(hugeOld, tinyNew);
|
||||||
|
assert.match(r.markdown, /coarse block-level diff/, "huge-old side must trip the guard");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("asymmetric pair (tiny old, huge new) falls back to coarse", () => {
|
||||||
|
clearEnv();
|
||||||
|
const tinyOld = doc([para("just one line")]);
|
||||||
|
const hugeNew = buildDoc(600, 8, "b");
|
||||||
|
const r = diffDocs(tinyOld, hugeNew);
|
||||||
|
assert.match(r.markdown, /coarse block-level diff/, "huge-new side must trip the guard");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Byte axis: a FEW nodes but a very large serialized size (long text runs) is
|
||||||
|
// dangerous too (per-run word diff is O(words²)), so the byte cap must trip
|
||||||
|
// independently of the node count.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
test("node-light but byte-heavy doc falls back on the byte cap", () => {
|
||||||
|
clearEnv();
|
||||||
|
// 5 paragraphs (~11 nodes, well under the node cap) but each a very long run,
|
||||||
|
// pushing the serialized size far over the 12 KiB byte default.
|
||||||
|
const bigRun = (seed) =>
|
||||||
|
doc(
|
||||||
|
Array.from({ length: 5 }, (_, i) =>
|
||||||
|
para(Array.from({ length: 800 }, (_, w) => `${seed}${i}_${w}`).join(" ")),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const oldDoc = bigRun("a");
|
||||||
|
const newDoc = bigRun("b");
|
||||||
|
// Sanity: node count is under the default node cap, so ONLY the byte cap can
|
||||||
|
// be what trips the guard here.
|
||||||
|
const nodeCount = (d) => {
|
||||||
|
let n = 0;
|
||||||
|
const v = (x) => {
|
||||||
|
if (!x || typeof x !== "object") return;
|
||||||
|
n++;
|
||||||
|
if (Array.isArray(x.content)) for (const c of x.content) v(c);
|
||||||
|
};
|
||||||
|
v(d);
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
assert.ok(nodeCount(oldDoc) < 150, "node count is under the node cap");
|
||||||
|
assert.ok(JSON.stringify(oldDoc).length > 12 * 1024, "serialized size is over the byte cap");
|
||||||
|
|
||||||
|
const r = diffDocs(oldDoc, newDoc);
|
||||||
|
assert.match(r.markdown, /coarse block-level diff/, "byte cap must trip independently");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Env override: a very low MCP_DIFF_MAX_NODES forces fallback on a tiny doc,
|
||||||
|
// proving the knob is read fresh and actually gates the diff.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
test("MCP_DIFF_MAX_NODES override forces fallback on a small doc", () => {
|
||||||
|
clearEnv();
|
||||||
|
const oldDoc = doc([para("Hello world")]);
|
||||||
|
const newDoc = doc([para("Hello brave world")]);
|
||||||
|
|
||||||
|
// Baseline: default caps -> precise diff.
|
||||||
|
assert.doesNotMatch(diffDocs(oldDoc, newDoc).markdown, /coarse block-level diff/);
|
||||||
|
|
||||||
|
// Knob set absurdly low -> even this 4-node doc trips the guard.
|
||||||
|
process.env.MCP_DIFF_MAX_NODES = "1";
|
||||||
|
try {
|
||||||
|
const r = diffDocs(oldDoc, newDoc);
|
||||||
|
assert.match(r.markdown, /coarse block-level diff/, "low node cap forces fallback");
|
||||||
|
} finally {
|
||||||
|
clearEnv();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MCP_DIFF_MAX_BYTES override forces fallback on a small doc", () => {
|
||||||
|
clearEnv();
|
||||||
|
const oldDoc = doc([para("Hello world")]);
|
||||||
|
const newDoc = doc([para("Hello brave world")]);
|
||||||
|
|
||||||
|
process.env.MCP_DIFF_MAX_BYTES = "1";
|
||||||
|
try {
|
||||||
|
const r = diffDocs(oldDoc, newDoc);
|
||||||
|
assert.match(r.markdown, /coarse block-level diff/, "low byte cap forces fallback");
|
||||||
|
} finally {
|
||||||
|
clearEnv();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Garbage / unset env values fall back to the DEFAULT (the guard can never be
|
||||||
|
// accidentally disabled by a malformed knob). A small doc must still diff
|
||||||
|
// precisely under a garbage cap.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
test("garbage env values fall back to the default cap (guard not disabled)", () => {
|
||||||
|
clearEnv();
|
||||||
|
const oldDoc = doc([para("Hello world")]);
|
||||||
|
const newDoc = doc([para("Hello brave world")]);
|
||||||
|
|
||||||
|
for (const bad of ["not-a-number", "0", "-5", "", "NaN", "1e999"]) {
|
||||||
|
process.env.MCP_DIFF_MAX_NODES = bad;
|
||||||
|
process.env.MCP_DIFF_MAX_BYTES = bad;
|
||||||
|
// Under the DEFAULT caps this small doc is precise (garbage did not raise
|
||||||
|
// OR disable the cap). "1e999" -> parseInt yields 1 (finite) which is a
|
||||||
|
// valid low cap and would fall back; exclude that from the precise check.
|
||||||
|
const r = diffDocs(oldDoc, newDoc);
|
||||||
|
if (bad === "1e999") {
|
||||||
|
// parseInt("1e999",10) === 1 -> a legit low cap -> fallback. Guard active.
|
||||||
|
assert.match(r.markdown, /coarse block-level diff/);
|
||||||
|
} else {
|
||||||
|
assert.doesNotMatch(
|
||||||
|
r.markdown,
|
||||||
|
/coarse block-level diff/,
|
||||||
|
`garbage value ${JSON.stringify(bad)} must fall back to the default cap`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clearEnv();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// A large doc that trips the guard must still return the correct INTEGRITY
|
||||||
|
// counts (computeIntegrity runs before the diff and is unaffected by fallback).
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
test("integrity counts are still correct on a guard-tripped (coarse) doc", () => {
|
||||||
|
clearEnv();
|
||||||
|
const image = { type: "image", attrs: { src: "/api/files/a.png" } };
|
||||||
|
const oldDoc = doc([image, ...buildDoc(600, 8, "a").content]);
|
||||||
|
const newDoc = doc([...buildDoc(600, 8, "b").content]); // image removed
|
||||||
|
|
||||||
|
const r = diffDocs(oldDoc, newDoc);
|
||||||
|
assert.match(r.markdown, /coarse block-level diff/, "large pair fell back");
|
||||||
|
assert.deepEqual(r.integrity.images, [1, 0], "integrity is computed regardless of fallback");
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user