Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4369bbc53d | |||
| 8e5ad8070b | |||
| cfc105c7d6 | |||
| d7fa6738e5 | |||
| e6d8eda8e5 | |||
| 8d8ecaed82 | |||
| eacc1c4811 | |||
| 8e12aa8ebf | |||
| 348dcd0802 | |||
| 086bc1bf8b | |||
| 77b245461f | |||
| 77c64c4fd9 | |||
| 2bb71c1a45 | |||
| 20248b8c95 | |||
| 9274c51053 | |||
| 832c3cafdf | |||
| 94f60cf0ec | |||
| 40d42d61e6 | |||
| bcd194ee5d | |||
| f13105333a | |||
| 08222345ef | |||
| baa41d66ad | |||
| 1a7b817250 | |||
| 52beae85b3 | |||
| 124f5a45a2 | |||
| b751852425 | |||
| 65d81f745a | |||
| bfbd927866 | |||
| 77f5224b55 | |||
| e2a3b5fc4d | |||
| d7d8db2102 | |||
| e814bca243 | |||
| f1ab76e879 | |||
| 6dcc19ce59 | |||
| d6d7dd82f6 | |||
| f5d19f9728 | |||
| 351615e5bc | |||
| 1fda0ec8b0 | |||
| 2637640291 | |||
| aa0428e28b |
@@ -72,6 +72,14 @@ jobs:
|
|||||||
- name: Build editor-ext
|
- name: Build editor-ext
|
||||||
run: pnpm --filter @docmost/editor-ext build
|
run: pnpm --filter @docmost/editor-ext build
|
||||||
|
|
||||||
|
# @docmost/prosemirror-markdown is the shared converter (#293/#326); its
|
||||||
|
# build/ is gitignored, and plain `pnpm -r test` does NOT honour nx
|
||||||
|
# `dependsOn: ^build`, so its consumers (mcp `pretest: tsc`, git-sync vitest
|
||||||
|
# typecheck) fail with TS2307 Cannot find module '@docmost/prosemirror-markdown'
|
||||||
|
# unless it is built first. Build it before the recursive test run.
|
||||||
|
- name: Build prosemirror-markdown
|
||||||
|
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: pnpm -r test
|
run: pnpm -r test
|
||||||
|
|
||||||
|
|||||||
+10
-1
@@ -4,12 +4,21 @@
|
|||||||
data
|
data
|
||||||
# compiled output
|
# compiled output
|
||||||
/dist
|
/dist
|
||||||
node_modules/
|
node_modules
|
||||||
|
|
||||||
# git-sync compiled output (built in CI/Docker via `pnpm build`, never committed,
|
# git-sync compiled output (built in CI/Docker via `pnpm build`, never committed,
|
||||||
# so src/ and prod can never silently diverge).
|
# so src/ and prod can never silently diverge).
|
||||||
packages/git-sync/build/
|
packages/git-sync/build/
|
||||||
|
|
||||||
|
# prosemirror-markdown compiled output (built in CI/Docker via `pnpm build`,
|
||||||
|
# never committed, so src/ and prod can never silently diverge).
|
||||||
|
packages/prosemirror-markdown/build/
|
||||||
|
|
||||||
|
# mcp compiled output (built in CI/Docker via `pnpm build`, never committed, so
|
||||||
|
# src/ and prod can never silently diverge). Matches the git-sync/prosemirror-
|
||||||
|
# markdown convention; the package is private and rebuilt at deploy.
|
||||||
|
packages/mcp/build/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -200,7 +200,8 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
|
|||||||
| `apps/server` | `server` | NestJS 11 + Fastify, Kysely (Postgres), Redis | Backend API, collaboration, AI |
|
| `apps/server` | `server` | NestJS 11 + Fastify, Kysely (Postgres), Redis | Backend API, collaboration, AI |
|
||||||
| `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`. Does **not** import `editor-ext` — it keeps its own vendored mirror of the schema in `packages/mcp/src/lib/` |
|
| `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` and `git-sync`; 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`.
|
||||||
|
|
||||||
@@ -282,7 +283,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, import/export) — editor schema changes often need to be made in `editor-ext`, not just the client. Note `packages/mcp` does *not* depend on `editor-ext`; it carries its own mirrored copy of the schema, so keep the two in sync manually when the document schema changes.
|
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, import/export) — editor schema changes often need to be made in `editor-ext`, not just the client. The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by both `mcp` and `git-sync` — 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.
|
||||||
- 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`.
|
||||||
|
|
||||||
@@ -293,6 +294,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
|||||||
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
||||||
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
||||||
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons.
|
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons.
|
||||||
|
- **Adding/renaming/removing an MCP tool requires updating `SERVER_INSTRUCTIONS`** in `packages/mcp/src/index.ts` — the intent-routing guide MCP clients receive on initialize. This applies both to inline `server.registerTool(...)` calls in `index.ts` and to specs in `packages/mcp/src/tool-specs.ts`. Enforced by `packages/mcp/test/unit/server-instructions.test.mjs`, which fails when a registered tool is not mentioned in the guide (deliberate opt-outs go into its `EXCEPTIONS` list). `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
|
||||||
|
|
||||||
## CI / release
|
## CI / release
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,14 @@ 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
|
||||||
|
# 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/
|
||||||
|
# markdown-converter.js). Ship the built package + its manifest, or the prod
|
||||||
|
# install resolves a broken workspace symlink and every ai-chat tool dies with
|
||||||
|
# ERR_MODULE_NOT_FOUND (#293/#326 step 5). (git-sync has no runtime consumer yet;
|
||||||
|
# revisit at step 6 when #119 lands.)
|
||||||
|
COPY --from=builder /app/packages/prosemirror-markdown/build /app/packages/prosemirror-markdown/build
|
||||||
|
COPY --from=builder /app/packages/prosemirror-markdown/package.json /app/packages/prosemirror-markdown/package.json
|
||||||
|
|
||||||
# Copy root package files
|
# Copy root package files
|
||||||
COPY --from=builder /app/package.json /app/package.json
|
COPY --from=builder /app/package.json /app/package.json
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ roles:
|
|||||||
Read the whole text first. Think at the level of sections and paragraphs, not sentences.
|
Read the whole text first. Think at the level of sections and paragraphs, not sentences.
|
||||||
|
|
||||||
HOW TO LEAVE COMMENTS
|
HOW TO LEAVE COMMENTS
|
||||||
You don't edit the text yourself. For each note, select the relevant span via the MCP tool and leave a comment. Open the comment with the label `[Structure]`. Then: state the problem briefly, propose a concrete fix (move, merge, cut, add, reorder, strengthen the lead/headline), and explain why if it isn't obvious. Tag severity:
|
You don't edit the text yourself. For each note, select the relevant span via the MCP tool and leave a comment. State the problem briefly, propose a concrete fix (move, merge, cut, add, reorder, strengthen the lead/headline), and explain why if it isn't obvious. Tag severity:
|
||||||
- [Critical] — broken logic, the text doesn't deliver what the headline promises, a key link in the argument is missing.
|
- [Critical] — broken logic, the text doesn't deliver what the headline promises, a key link in the argument is missing.
|
||||||
- [Major] — weak structure, a noticeable gap or redundancy, a sagging lead/headline.
|
- [Major] — weak structure, a noticeable gap or redundancy, a sagging lead/headline.
|
||||||
- [Minor] — an optional improvement to framing or flow.
|
- [Minor] — an optional improvement to framing or flow.
|
||||||
@@ -87,7 +87,7 @@ roles:
|
|||||||
- Don't rewrite the text yourself or impose your own voice. Your job is to make the author's voice livelier, not to replace it.
|
- Don't rewrite the text yourself or impose your own voice. Your job is to make the author's voice livelier, not to replace it.
|
||||||
|
|
||||||
HOW TO LEAVE COMMENTS
|
HOW TO LEAVE COMMENTS
|
||||||
You don't edit the text directly. For each note, select the span via the MCP tool and leave a comment. Open the comment with the label `[Style]`. Give a concrete rephrasing, not "revise", and attach it to the comment as a suggested replacement (the `suggestedText` parameter): the exact new text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Tag severity:
|
You don't edit the text directly. For each note, select the span via the MCP tool and leave a comment. Give a concrete rephrasing, not "revise", and attach it to the comment as a suggested replacement (the `suggestedText` parameter): the exact new text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Tag severity:
|
||||||
- [Critical] — the sentence is unclear or distorts the meaning.
|
- [Critical] — the sentence is unclear or distorts the meaning.
|
||||||
- [Major] — an obvious LLM cliché, heavy bureaucratese, filler that breaks the reading.
|
- [Major] — an obvious LLM cliché, heavy bureaucratese, filler that breaks the reading.
|
||||||
- [Minor] — a stylistic improvement to taste.
|
- [Minor] — a stylistic improvement to taste.
|
||||||
@@ -128,7 +128,7 @@ roles:
|
|||||||
- Don't fabricate confirmations. If you can't verify, honestly mark [Unverified] or [Unverifiable].
|
- Don't fabricate confirmations. If you can't verify, honestly mark [Unverified] or [Unverifiable].
|
||||||
|
|
||||||
HOW TO LEAVE COMMENTS
|
HOW TO LEAVE COMMENTS
|
||||||
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Open the comment with the label `[Facts]`, then the verdict, the correction (if any), and the source. For an [Incorrect] verdict, ALWAYS attach the ready correction as a suggested replacement (the `suggestedText` parameter): since you found the correct value in the sources, propose the ready fix right away instead of merely describing the error. The replacement is the exact new text for the selected fragment, plain text with no markup; the author applies it with one click instead of retyping the fragment. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do not attach a replacement to [Unverified], [Unverifiable], or [Opinion] verdicts. Tag severity:
|
You don't edit the text directly. For each problem claim (an error, a doubt, an unverifiable statement), select the span via the MCP tool and leave a comment; leave no comment on correct facts. Give the verdict, the correction (if any), and the source. For an [Incorrect] verdict, ALWAYS attach the ready correction as a suggested replacement (the `suggestedText` parameter): since you found the correct value in the sources, propose the ready fix right away instead of merely describing the error. The replacement is the exact new text for the selected fragment, plain text with no markup; the author applies it with one click instead of retyping the fragment. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. When a figure, name, term, or version to check recurs across the page, use search_in_page to find every occurrence in one call first, then place a targeted comment per hit instead of reading block by block. Do not attach a replacement to [Unverified], [Unverifiable], or [Opinion] verdicts. Tag severity:
|
||||||
- [Critical] — a factual error, especially in numbers, names, or quotes, or a claim that risks misinformation.
|
- [Critical] — a factual error, especially in numbers, names, or quotes, or a claim that risks misinformation.
|
||||||
- [Major] — a doubtful or unconfirmed claim that needs a source.
|
- [Major] — a doubtful or unconfirmed claim that needs a source.
|
||||||
- [Minor] — a small correction, or false precision worth rounding or confirming.
|
- [Minor] — a small correction, or false precision worth rounding or confirming.
|
||||||
@@ -168,8 +168,11 @@ roles:
|
|||||||
- Don't verify facts — that's the Fact-checker.
|
- Don't verify facts — that's the Fact-checker.
|
||||||
- Don't make substantive changes. Edits are minimal and mechanical.
|
- Don't make substantive changes. Edits are minimal and mechanical.
|
||||||
|
|
||||||
|
HOW TO WORK
|
||||||
|
Go through the whole text from start to finish in a single pass. Flag EVERY violation, including all repeat occurrences of the same error and minor items tagged [Minor] — don't stop at the first few or the most conspicuous. Don't summarize instead of marking up: until you've reached the end of the document, the job isn't done. One run covers the whole text, not just "the most important". For a systematic issue that recurs — straight quotes, a hyphen used as a dash, an inconsistent unit or spelling — use search_in_page to list every occurrence in one call first, then leave a targeted comment (with its replacement) on each hit, instead of scanning block by block.
|
||||||
|
|
||||||
HOW TO LEAVE COMMENTS
|
HOW TO LEAVE COMMENTS
|
||||||
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Attach a suggested replacement to every fix (the `suggestedText` parameter): the exact corrected text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do NOT leave summary notes like "throughout, replace X with Y" or "make the units/quotes/spelling consistent": such a comment can't be applied with a button. If the same error occurs in several places, walk EVERY occurrence and leave a separate targeted comment with its own replacement on each — ten targeted fixes instead of one blanket note. The only exception is a note that genuinely cannot be expressed as a replacement of a concrete fragment; leave those rare cases as an ordinary comment without a replacement. Open the comment with the label `[Copyedit]`. Tag severity:
|
You don't edit the text directly. For each fix, select the span via the MCP tool and leave a comment with the concrete correction. Attach a suggested replacement to every fix (the `suggestedText` parameter): the exact corrected text for the selected fragment, plain text with no markup — the author applies it with one click. The selected fragment must occur exactly once in the text; if it isn't unique, extend the selection with surrounding context. Do NOT leave summary notes like "throughout, replace X with Y" or "make the units/quotes/spelling consistent": such a comment can't be applied with a button. If the same error occurs in several places, walk EVERY occurrence and leave a separate targeted comment with its own replacement on each — ten targeted fixes instead of one blanket note. The only exception is a note that genuinely cannot be expressed as a replacement of a concrete fragment; leave those rare cases as an ordinary comment without a replacement. Tag severity:
|
||||||
- [Critical] — a grammar/spelling error or typo visible to the reader.
|
- [Critical] — a grammar/spelling error or typo visible to the reader.
|
||||||
- [Major] — a consistency or typography break (wrong quotes, hyphen for a dash, missing serial comma where the rest of the text has it).
|
- [Major] — a consistency or typography break (wrong quotes, hyphen for a dash, missing serial comma where the rest of the text has it).
|
||||||
- [Minor] — optional polish.
|
- [Minor] — optional polish.
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ roles:
|
|||||||
Сначала прочитай весь текст целиком. Думай на уровне разделов и абзацев, а не предложений.
|
Сначала прочитай весь текст целиком. Думай на уровне разделов и абзацев, а не предложений.
|
||||||
|
|
||||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||||
Ты не редактируешь текст сам. Для каждого замечания через MCP-инструмент выдели соответствующий фрагмент и оставь к нему комментарий. Начинай комментарий с метки `[Структура]`. Дальше: коротко назови проблему, предложи конкретное решение (перенести, объединить, вырезать, добавить, переставить, усилить лид/заголовок) и при необходимости поясни, почему. Помечай важность:
|
Ты не редактируешь текст сам. Для каждого замечания через MCP-инструмент выдели соответствующий фрагмент и оставь к нему комментарий. Коротко назови проблему, предложи конкретное решение (перенести, объединить, вырезать, добавить, переставить, усилить лид/заголовок) и при необходимости поясни, почему. Помечай важность:
|
||||||
- [Критично] — сломана логика, текст не отвечает на заявленное в заголовке, отсутствует ключевое звено аргумента.
|
- [Критично] — сломана логика, текст не отвечает на заявленное в заголовке, отсутствует ключевое звено аргумента.
|
||||||
- [Существенно] — слабая структура, заметный пробел или избыточность, провисающий лид/заголовок.
|
- [Существенно] — слабая структура, заметный пробел или избыточность, провисающий лид/заголовок.
|
||||||
- [Незначительно] — улучшение подачи или стройности, не обязательное.
|
- [Незначительно] — улучшение подачи или стройности, не обязательное.
|
||||||
@@ -87,7 +87,7 @@ roles:
|
|||||||
- Не переписываешь текст сам и не навязываешь свой голос. Твоя задача — сделать авторскую интонацию живее, а не заменить собой.
|
- Не переписываешь текст сам и не навязываешь свой голос. Твоя задача — сделать авторскую интонацию живее, а не заменить собой.
|
||||||
|
|
||||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||||
Ты не редактируешь текст напрямую. Для каждого замечания через MCP-инструмент выдели фрагмент и оставь к нему комментарий. Начинай комментарий с метки `[Стиль]`. Давай конкретный вариант переформулировки, а не «переделать», и прикладывай его к комментарию как предложение-замену (параметр `suggestedText`): точный новый текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Помечай важность:
|
Ты не редактируешь текст напрямую. Для каждого замечания через MCP-инструмент выдели фрагмент и оставь к нему комментарий. Давай конкретный вариант переформулировки, а не «переделать», и прикладывай его к комментарию как предложение-замену (параметр `suggestedText`): точный новый текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Помечай важность:
|
||||||
- [Критично] — предложение непонятно или искажает смысл.
|
- [Критично] — предложение непонятно или искажает смысл.
|
||||||
- [Существенно] — явный штамп LLM, заметный канцелярит, вода, ломающая чтение.
|
- [Существенно] — явный штамп LLM, заметный канцелярит, вода, ломающая чтение.
|
||||||
- [Незначительно] — стилистическое улучшение на вкус.
|
- [Незначительно] — стилистическое улучшение на вкус.
|
||||||
@@ -128,7 +128,7 @@ roles:
|
|||||||
- Не выдумываешь подтверждения. Если не можешь проверить — честно ставь [Не проверено] или [Непроверяемо].
|
- Не выдумываешь подтверждения. Если не можешь проверить — честно ставь [Не проверено] или [Непроверяемо].
|
||||||
|
|
||||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||||
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. Начинай комментарий с метки `[Факты]`, затем вердикт, исправление (если нужно) и источник. К вердикту [Неверно] всегда прикладывай готовое исправление как предложение-замену (параметр `suggestedText`): раз ты нашёл по источникам верное значение — сразу предлагай готовую правку, а не только описывай ошибку. Замена — это точный новый текст взамен выделенного фрагмента, обычным текстом без разметки; автор применит её одной кнопкой, не переписывая фрагмент вручную. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. К вердиктам [Не проверено], [Непроверяемо] и [Это мнение] замену не прикладывай. Помечай важность:
|
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. В комментарии дай вердикт, исправление (если нужно) и источник. К вердикту [Неверно] всегда прикладывай готовое исправление как предложение-замену (параметр `suggestedText`): раз ты нашёл по источникам верное значение — сразу предлагай готовую правку, а не только описывай ошибку. Замена — это точный новый текст взамен выделенного фрагмента, обычным текстом без разметки; автор применит её одной кнопкой, не переписывая фрагмент вручную. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Когда проверяемая цифра, имя, термин или версия встречается по тексту несколько раз, сначала одним вызовом search_in_page найди все вхождения, а затем ставь целевой комментарий на каждое — не читая страницу поблочно. К вердиктам [Не проверено], [Непроверяемо] и [Это мнение] замену не прикладывай. Помечай важность:
|
||||||
- [Критично] — фактическая ошибка, особенно в числах, именах, цитатах, или утверждение с риском дезинформации.
|
- [Критично] — фактическая ошибка, особенно в числах, именах, цитатах, или утверждение с риском дезинформации.
|
||||||
- [Существенно] — сомнительное или непроверенное утверждение, требующее источника.
|
- [Существенно] — сомнительное или непроверенное утверждение, требующее источника.
|
||||||
- [Незначительно] — мелкое уточнение, псевдоточность, которую стоит округлить или подтвердить.
|
- [Незначительно] — мелкое уточнение, псевдоточность, которую стоит округлить или подтвердить.
|
||||||
@@ -169,8 +169,11 @@ roles:
|
|||||||
- Не проверяешь достоверность фактов — это фактчекер.
|
- Не проверяешь достоверность фактов — это фактчекер.
|
||||||
- Не вносишь содержательных изменений. Правки — минимальные и механические.
|
- Не вносишь содержательных изменений. Правки — минимальные и механические.
|
||||||
|
|
||||||
|
КАК РАБОТАТЬ
|
||||||
|
Пройди весь текст от начала до конца за один проход. Помечай КАЖДОЕ нарушение, включая все повторные вхождения одной и той же ошибки и мелочи с меткой [Незначительно], — не ограничивайся первыми несколькими или самыми заметными. Не подводи итог вместо разбора: пока не дошёл до конца документа, работа не закончена. Один прогон покрывает весь текст, а не «самое важное». Для систематической ошибки, которая повторяется — прямые кавычки, «е» вместо «ё», дефис вместо тире, неединообразная единица или написание, — сначала одним вызовом search_in_page получи все вхождения, а затем оставь на каждом целевой комментарий с заменой, вместо поблочного просмотра.
|
||||||
|
|
||||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||||
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. К каждой правке прикладывай предложение-замену (параметр `suggestedText`): точный исправленный текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. НЕ оставляй сводных замечаний вида «во всём тексте заменить X на Y» или «привести единицы/кавычки/написание к единообразию»: такой комментарий нельзя применить кнопкой. Если одна и та же ошибка встречается в нескольких местах, обойди КАЖДОЕ вхождение и оставь на нём отдельный целевой комментарий со своей заменой — десять точечных правок вместо одной общей. Единственное исключение — замечание, которое в принципе невозможно выразить заменой конкретного фрагмента; такие редкие случаи оставляй обычным комментарием без замены. Начинай комментарий с метки `[Корректура]`. Помечай важность:
|
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. К каждой правке прикладывай предложение-замену (параметр `suggestedText`): точный исправленный текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. НЕ оставляй сводных замечаний вида «во всём тексте заменить X на Y» или «привести единицы/кавычки/написание к единообразию»: такой комментарий нельзя применить кнопкой. Если одна и та же ошибка встречается в нескольких местах, обойди КАЖДОЕ вхождение и оставь на нём отдельный целевой комментарий со своей заменой — десять точечных правок вместо одной общей. Единственное исключение — замечание, которое в принципе невозможно выразить заменой конкретного фрагмента; такие редкие случаи оставляй обычным комментарием без замены. Помечай важность:
|
||||||
- [Критично] — грамматическая/орфографическая ошибка или опечатка, видимая читателю.
|
- [Критично] — грамматическая/орфографическая ошибка или опечатка, видимая читателю.
|
||||||
- [Существенно] — нарушение единообразия или типографики (неверные кавычки, дефис вместо тире, отсутствие неразрывного пробела в критичном месте).
|
- [Существенно] — нарушение единообразия или типографики (неверные кавычки, дефис вместо тире, отсутствие неразрывного пробела в критичном месте).
|
||||||
- [Незначительно] — необязательная шлифовка.
|
- [Незначительно] — необязательная шлифовка.
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ bundles:
|
|||||||
- en
|
- en
|
||||||
roles:
|
roles:
|
||||||
- slug: structural-editor
|
- slug: structural-editor
|
||||||
version: 3
|
|
||||||
- slug: line-editor
|
|
||||||
version: 3
|
|
||||||
- slug: fact-checker
|
|
||||||
version: 4
|
version: 4
|
||||||
|
- slug: line-editor
|
||||||
|
version: 4
|
||||||
|
- slug: fact-checker
|
||||||
|
version: 6
|
||||||
- slug: proofreader
|
- slug: proofreader
|
||||||
version: 5
|
version: 8
|
||||||
- slug: narrator
|
- slug: narrator
|
||||||
version: 2
|
version: 2
|
||||||
- id: research
|
- id: research
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
{
|
{
|
||||||
"fact-checker": {
|
"fact-checker": {
|
||||||
"version": 4,
|
"version": 6,
|
||||||
"hash": "9160ead04d86aaa5dc7a51dd7e971c272ce0ca97cb24bf2b6ee5779deb1b19c0"
|
"hash": "6bb22a9e5a5079b5cb287b5b26addbd36b9afeb7c9508287dcad9343fc53d685"
|
||||||
},
|
},
|
||||||
"line-editor": {
|
"line-editor": {
|
||||||
"version": 3,
|
"version": 4,
|
||||||
"hash": "7f200863080799b08d5af5d1648befa0843cc5db79bb994b07baa5ad12df5123"
|
"hash": "890d10f3f0bd7f2b2cfcc94463634221c557a3140e3794721748dc8d99979780"
|
||||||
},
|
},
|
||||||
"narrator": {
|
"narrator": {
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"hash": "66fe653003b4f63ef3c3a5c5c48552fe47daeefffc16907c37c35f0e8da98851"
|
"hash": "66fe653003b4f63ef3c3a5c5c48552fe47daeefffc16907c37c35f0e8da98851"
|
||||||
},
|
},
|
||||||
"proofreader": {
|
"proofreader": {
|
||||||
"version": 5,
|
"version": 8,
|
||||||
"hash": "40af08c51e03c24b1986ac5cd679434e023afe31a819748966ccb0c6c62f0401"
|
"hash": "cef39fed321779631ddd1077fcba53399adf0e48b301df281c71eb042610900d"
|
||||||
},
|
},
|
||||||
"researcher": {
|
"researcher": {
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
|
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
|
||||||
},
|
},
|
||||||
"structural-editor": {
|
"structural-editor": {
|
||||||
"version": 3,
|
"version": 4,
|
||||||
"hash": "f6936e4c152c1b78980e74045658d87743f26f900c12f61fd7a45c6a0ec19425"
|
"hash": "89100e0a00b88daa0d2118fd98ec1c27d06b972bfc6ec58b705553a4daed85df"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"axios": "1.16.0",
|
"axios": "1.16.0",
|
||||||
"blueimp-load-image": "5.16.0",
|
"blueimp-load-image": "5.16.0",
|
||||||
"clsx": "2.1.1",
|
"clsx": "2.1.1",
|
||||||
|
"diff": "8.0.3",
|
||||||
"dompurify": "3.4.1",
|
"dompurify": "3.4.1",
|
||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
"highlightjs-sap-abap": "0.3.0",
|
"highlightjs-sap-abap": "0.3.0",
|
||||||
@@ -81,6 +82,7 @@
|
|||||||
"@types/react": "18.3.12",
|
"@types/react": "18.3.12",
|
||||||
"@types/react-dom": "18.3.1",
|
"@types/react-dom": "18.3.1",
|
||||||
"@vitejs/plugin-react": "6.0.1",
|
"@vitejs/plugin-react": "6.0.1",
|
||||||
|
"@vitest/coverage-v8": "4.1.6",
|
||||||
"eslint": "9.28.0",
|
"eslint": "9.28.0",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
"eslint-plugin-react-hooks": "7.0.1",
|
"eslint-plugin-react-hooks": "7.0.1",
|
||||||
|
|||||||
@@ -1382,5 +1382,8 @@
|
|||||||
"Applied": "Applied",
|
"Applied": "Applied",
|
||||||
"Suggestion applied": "Suggestion applied",
|
"Suggestion applied": "Suggestion applied",
|
||||||
"Failed to apply suggestion": "Failed to apply suggestion",
|
"Failed to apply suggestion": "Failed to apply suggestion",
|
||||||
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied."
|
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied.",
|
||||||
|
"Dismiss": "Dismiss",
|
||||||
|
"Suggestion dismissed": "Suggestion dismissed",
|
||||||
|
"Failed to dismiss suggestion": "Failed to dismiss suggestion"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1245,5 +1245,8 @@
|
|||||||
"Applied": "Применено",
|
"Applied": "Применено",
|
||||||
"Suggestion applied": "Предложение применено",
|
"Suggestion applied": "Предложение применено",
|
||||||
"Failed to apply suggestion": "Не удалось применить предложение",
|
"Failed to apply suggestion": "Не удалось применить предложение",
|
||||||
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено."
|
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено.",
|
||||||
|
"Dismiss": "Не применять",
|
||||||
|
"Suggestion dismissed": "Предложение отклонено",
|
||||||
|
"Failed to dismiss suggestion": "Не удалось отклонить предложение"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
import { render, screen, fireEvent, act } from "@testing-library/react";
|
import { render, screen, fireEvent, act, cleanup } from "@testing-library/react";
|
||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
|
||||||
// Shared, hoisted mock state so the @ai-sdk/react and "ai" module mocks (hoisted
|
// Shared, hoisted mock state so the @ai-sdk/react and "ai" module mocks (hoisted
|
||||||
@@ -140,3 +140,91 @@ describe("ChatThread — send now (#198)", () => {
|
|||||||
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
expect(prep({ messages: [], body: {} }).body.interrupted).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The turn-end decision lives in the `onFinish` handler: given the terminal
|
||||||
|
// outcome of a turn (`isAbort` / `isDisconnect` / `isError`, or none = clean),
|
||||||
|
// it decides whether to CONTINUE (flush the next queued message) or END (leave
|
||||||
|
// the queue intact for the user), and which stop notice — if any — to show.
|
||||||
|
// `sendNow` is exercised above; these tests pin down the plain outcomes.
|
||||||
|
describe("ChatThread — turn-end decision (onFinish)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
h.state.status = "streaming";
|
||||||
|
h.state.onFinish = null;
|
||||||
|
h.state.sendMessage.mockClear();
|
||||||
|
h.state.stop.mockClear();
|
||||||
|
h.state.transport = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drive a fresh onFinish with the given terminal flags after queueing a
|
||||||
|
// message, and report both what the parent was told and whether the queue was
|
||||||
|
// flushed (a resend to the sendMessage spy).
|
||||||
|
function finishWith(flags: {
|
||||||
|
isAbort?: boolean;
|
||||||
|
isDisconnect?: boolean;
|
||||||
|
isError?: boolean;
|
||||||
|
}) {
|
||||||
|
// Tear down any prior render so the loop-driven "every outcome" case does
|
||||||
|
// not leave duplicate queue buttons in the DOM.
|
||||||
|
cleanup();
|
||||||
|
h.state.sendMessage.mockClear();
|
||||||
|
const { onTurnFinished } = renderThread();
|
||||||
|
// Populate the queue while the turn is streaming.
|
||||||
|
fireEvent.click(screen.getByTestId("queue-btn"));
|
||||||
|
act(() => {
|
||||||
|
h.state.onFinish?.({
|
||||||
|
message: { id: "a", role: "assistant", parts: [] },
|
||||||
|
isAbort: false,
|
||||||
|
isDisconnect: false,
|
||||||
|
isError: false,
|
||||||
|
...flags,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { onTurnFinished };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("CONTINUES — flushes the next queued message on a clean finish", () => {
|
||||||
|
finishWith({});
|
||||||
|
// Clean finish (no terminal flag): the queued message is auto-sent.
|
||||||
|
expect(h.state.sendMessage).toHaveBeenCalledWith({ text: "queued text" });
|
||||||
|
// A clean finish shows no stop notice.
|
||||||
|
expect(screen.queryByText("Response stopped.")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ENDS — keeps the queue intact on a user abort and shows the stopped notice", () => {
|
||||||
|
finishWith({ isAbort: true });
|
||||||
|
// A plain Stop (not the sendNow interrupt path) must NOT auto-resend: the
|
||||||
|
// queue is preserved for the user to decide.
|
||||||
|
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText("Response stopped.")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ENDS — keeps the queue intact on a disconnect and shows the connection-lost notice", () => {
|
||||||
|
finishWith({ isDisconnect: true });
|
||||||
|
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||||
|
expect(
|
||||||
|
screen.getByText("Connection lost — the answer was interrupted."),
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ENDS — keeps the queue intact on a stream error (no auto-retry, no stopped notice)", () => {
|
||||||
|
finishWith({ isError: true });
|
||||||
|
// Blindly retrying after a failure would be wrong; the queue is left alone.
|
||||||
|
expect(h.state.sendMessage).not.toHaveBeenCalled();
|
||||||
|
// isError clears the neutral notice (the error banner covers this case).
|
||||||
|
expect(screen.queryByText("Response stopped.")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies the parent on EVERY terminal outcome", () => {
|
||||||
|
// The chat-list refresh / new-chat id adoption must run on success and on
|
||||||
|
// every failure path alike.
|
||||||
|
for (const flags of [
|
||||||
|
{},
|
||||||
|
{ isAbort: true },
|
||||||
|
{ isDisconnect: true },
|
||||||
|
{ isError: true },
|
||||||
|
]) {
|
||||||
|
const { onTurnFinished } = finishWith(flags);
|
||||||
|
expect(onTurnFinished).toHaveBeenCalled();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { IComment } from "@/features/comment/types/comment.types";
|
|||||||
// The comment mutation hooks reach out to react-query/network — stub them so the
|
// The comment mutation hooks reach out to react-query/network — stub them so the
|
||||||
// component renders in isolation. We only assert the AI-badge rendering branch.
|
// component renders in isolation. We only assert the AI-badge rendering branch.
|
||||||
const applyMutateAsync = vi.fn();
|
const applyMutateAsync = vi.fn();
|
||||||
|
const dismissMutateAsync = vi.fn();
|
||||||
vi.mock("@/features/comment/queries/comment-query", () => ({
|
vi.mock("@/features/comment/queries/comment-query", () => ({
|
||||||
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
@@ -16,6 +17,10 @@ vi.mock("@/features/comment/queries/comment-query", () => ({
|
|||||||
mutateAsync: applyMutateAsync,
|
mutateAsync: applyMutateAsync,
|
||||||
isPending: false,
|
isPending: false,
|
||||||
}),
|
}),
|
||||||
|
useDismissSuggestionMutation: () => ({
|
||||||
|
mutateAsync: dismissMutateAsync,
|
||||||
|
isPending: false,
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
|
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
|
||||||
@@ -24,7 +29,10 @@ vi.mock("@/features/comment/components/comment-editor", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import CommentListItem from "./comment-list-item";
|
import CommentListItem from "./comment-list-item";
|
||||||
import { canShowApply } from "@/features/comment/utils/suggestion";
|
import {
|
||||||
|
canShowApply,
|
||||||
|
canShowDismiss,
|
||||||
|
} from "@/features/comment/utils/suggestion";
|
||||||
|
|
||||||
const baseComment = (over?: Partial<IComment>): IComment =>
|
const baseComment = (over?: Partial<IComment>): IComment =>
|
||||||
({
|
({
|
||||||
@@ -38,14 +46,20 @@ const baseComment = (over?: Partial<IComment>): IComment =>
|
|||||||
...over,
|
...over,
|
||||||
}) as IComment;
|
}) as IComment;
|
||||||
|
|
||||||
function renderItem(comment: IComment, canEdit = true) {
|
function renderItem(
|
||||||
|
comment: IComment,
|
||||||
|
canEdit = true,
|
||||||
|
canComment = true,
|
||||||
|
userSpaceRole?: string,
|
||||||
|
) {
|
||||||
return render(
|
return render(
|
||||||
<MantineProvider>
|
<MantineProvider>
|
||||||
<CommentListItem
|
<CommentListItem
|
||||||
comment={comment}
|
comment={comment}
|
||||||
pageId="page-1"
|
pageId="page-1"
|
||||||
canComment={true}
|
canComment={canComment}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
|
userSpaceRole={userSpaceRole}
|
||||||
/>
|
/>
|
||||||
</MantineProvider>,
|
</MantineProvider>,
|
||||||
);
|
);
|
||||||
@@ -108,10 +122,12 @@ describe("CommentListItem — suggested edit (#315)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders the было→стало diff and an Apply button when canEdit and not applied/resolved", () => {
|
it("renders the было→стало diff and an Apply button when canEdit and not applied/resolved", () => {
|
||||||
renderItem(suggestion(), true);
|
const { container } = renderItem(suggestion(), true);
|
||||||
// Old text appears both as the selection quote and as the struck diff row.
|
// Old text appears as the selection quote (a single unsplit Text node).
|
||||||
expect(screen.getAllByText("old wording here").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("old wording here").length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText("new wording here")).toBeDefined();
|
// The new line is now rendered as per-fragment spans (intraline diff, #331),
|
||||||
|
// so it is no longer a single text node — assert the concatenated content.
|
||||||
|
expect(container.textContent).toContain("new wording here");
|
||||||
// Apply button is present.
|
// Apply button is present.
|
||||||
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
||||||
// No Applied badge yet.
|
// No Applied badge yet.
|
||||||
@@ -119,9 +135,9 @@ describe("CommentListItem — suggested edit (#315)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("hides the Apply button when canEdit is false", () => {
|
it("hides the Apply button when canEdit is false", () => {
|
||||||
renderItem(suggestion(), false);
|
const { container } = renderItem(suggestion(), false);
|
||||||
// Diff still renders...
|
// Diff still renders (as per-fragment spans, #331)...
|
||||||
expect(screen.getByText("new wording here")).toBeDefined();
|
expect(container.textContent).toContain("new wording here");
|
||||||
// ...but no Apply button.
|
// ...but no Apply button.
|
||||||
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -157,6 +173,65 @@ describe("CommentListItem — suggested edit (#315)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("CommentListItem — dismiss suggestion (#329)", () => {
|
||||||
|
const suggestion = (over?: Partial<IComment>): IComment =>
|
||||||
|
baseComment({
|
||||||
|
selection: "old wording here",
|
||||||
|
suggestedText: "new wording here",
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A space admin (userSpaceRole="admin") satisfies the owner-or-admin gate
|
||||||
|
// regardless of who authored the comment; the tests below use it as the lever
|
||||||
|
// since the currentUser atom is unseeded (null) in this harness.
|
||||||
|
it("renders a Dismiss button alongside Apply when canEdit and canComment (owner/admin)", () => {
|
||||||
|
renderItem(suggestion(), true, true, "admin");
|
||||||
|
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
||||||
|
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Dismiss but NOT Apply for an admin commenter who cannot edit", () => {
|
||||||
|
renderItem(suggestion(), false, true, "admin");
|
||||||
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
|
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Dismiss when the viewer cannot comment", () => {
|
||||||
|
renderItem(suggestion(), false, false, "admin");
|
||||||
|
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Dismiss for a non-owner non-admin even with canComment (#338 F5: mirrors server 403)", () => {
|
||||||
|
// canComment=true but NOT a space admin and NOT the comment owner (the
|
||||||
|
// currentUser atom is null while the comment is authored by user-1), so the
|
||||||
|
// server would 403 a dismiss — the button must not be shown at all.
|
||||||
|
renderItem(suggestion(), false, true, "member");
|
||||||
|
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Dismiss once the thread is resolved", () => {
|
||||||
|
renderItem(suggestion({ resolvedAt: new Date() }), true, true, "admin");
|
||||||
|
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Dismiss (shows the Applied badge) once applied", () => {
|
||||||
|
renderItem(suggestion({ suggestionAppliedAt: new Date() }), true, true, "admin");
|
||||||
|
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
|
||||||
|
expect(screen.getByText("Applied")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls the dismiss mutation when the Dismiss button is clicked", () => {
|
||||||
|
dismissMutateAsync.mockClear();
|
||||||
|
renderItem(suggestion(), true, true, "admin");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||||
|
expect(dismissMutateAsync).toHaveBeenCalledWith({
|
||||||
|
commentId: "c-1",
|
||||||
|
pageId: "page-1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("canShowApply predicate", () => {
|
describe("canShowApply predicate", () => {
|
||||||
const c = (over?: Partial<IComment>): IComment =>
|
const c = (over?: Partial<IComment>): IComment =>
|
||||||
({ suggestedText: "x", ...over }) as IComment;
|
({ suggestedText: "x", ...over }) as IComment;
|
||||||
@@ -182,3 +257,32 @@ describe("canShowApply predicate", () => {
|
|||||||
expect(canShowApply(c({ parentCommentId: "p" }), true)).toBe(false);
|
expect(canShowApply(c({ parentCommentId: "p" }), true)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("canShowDismiss predicate", () => {
|
||||||
|
const c = (over?: Partial<IComment>): IComment =>
|
||||||
|
({ suggestedText: "x", ...over }) as IComment;
|
||||||
|
|
||||||
|
it("true when suggestion present, can comment, owner/admin, not applied/resolved, top-level", () => {
|
||||||
|
expect(canShowDismiss(c(), true, true)).toBe(true);
|
||||||
|
});
|
||||||
|
it("false without comment permission", () => {
|
||||||
|
expect(canShowDismiss(c(), false, true)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false when not owner and not admin (#338 F5)", () => {
|
||||||
|
expect(canShowDismiss(c(), true, false)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false when no suggestion", () => {
|
||||||
|
expect(canShowDismiss(c({ suggestedText: null }), true, true)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false when already applied", () => {
|
||||||
|
expect(canShowDismiss(c({ suggestionAppliedAt: new Date() }), true, true)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("false when resolved", () => {
|
||||||
|
expect(canShowDismiss(c({ resolvedAt: new Date() }), true, true)).toBe(false);
|
||||||
|
});
|
||||||
|
it("false for a reply comment", () => {
|
||||||
|
expect(canShowDismiss(c({ parentCommentId: "p" }), true, true)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Group, Text, Box, Badge, Button } from "@mantine/core";
|
import { Group, Text, Box, Badge, Button } from "@mantine/core";
|
||||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import classes from "./comment.module.css";
|
import classes from "./comment.module.css";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||||
@@ -13,11 +13,16 @@ import { useHover } from "@mantine/hooks";
|
|||||||
import {
|
import {
|
||||||
useApplySuggestionMutation,
|
useApplySuggestionMutation,
|
||||||
useDeleteCommentMutation,
|
useDeleteCommentMutation,
|
||||||
|
useDismissSuggestionMutation,
|
||||||
useResolveCommentMutation,
|
useResolveCommentMutation,
|
||||||
useUpdateCommentMutation,
|
useUpdateCommentMutation,
|
||||||
} from "@/features/comment/queries/comment-query";
|
} from "@/features/comment/queries/comment-query";
|
||||||
import { IComment } from "@/features/comment/types/comment.types";
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
import { canShowApply } from "@/features/comment/utils/suggestion";
|
import {
|
||||||
|
canShowApply,
|
||||||
|
canShowDismiss,
|
||||||
|
computeSuggestionDiff,
|
||||||
|
} from "@/features/comment/utils/suggestion";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -51,9 +56,28 @@ function CommentListItem({
|
|||||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||||
const resolveCommentMutation = useResolveCommentMutation();
|
const resolveCommentMutation = useResolveCommentMutation();
|
||||||
const applySuggestionMutation = useApplySuggestionMutation();
|
const applySuggestionMutation = useApplySuggestionMutation();
|
||||||
|
const dismissSuggestionMutation = useDismissSuggestionMutation();
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
const createdAtAgo = useTimeAgo(comment.createdAt);
|
const createdAtAgo = useTimeAgo(comment.createdAt);
|
||||||
|
|
||||||
|
// Intraline "before -> after" diff (#331) for a suggested edit: only the
|
||||||
|
// fragments that actually changed get emphasised inside the red/green block,
|
||||||
|
// instead of striking through / greening the whole line. Memoised on the
|
||||||
|
// (selection, suggestedText) pair so it recomputes only when they change.
|
||||||
|
const suggestionDiff = useMemo(
|
||||||
|
() =>
|
||||||
|
comment.suggestedText != null
|
||||||
|
? computeSuggestionDiff(comment.selection ?? "", comment.suggestedText)
|
||||||
|
: null,
|
||||||
|
[comment.selection, comment.suggestedText],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Owner-or-space-admin gate (#338): mirrors the server authz for both the
|
||||||
|
// comment menu (edit/delete) and the suggestion Dismiss button, so we never
|
||||||
|
// render an action the server will 403.
|
||||||
|
const isOwnerOrAdmin =
|
||||||
|
currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setContent(comment.content);
|
setContent(comment.content);
|
||||||
}, [comment]);
|
}, [comment]);
|
||||||
@@ -115,6 +139,19 @@ function CommentListItem({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDismissSuggestion() {
|
||||||
|
try {
|
||||||
|
await dismissSuggestionMutation.mutateAsync({
|
||||||
|
commentId: comment.id,
|
||||||
|
pageId: comment.pageId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Idempotent races are reconciled to success in the mutation's onError;
|
||||||
|
// anything else surfaces there as a notification.
|
||||||
|
console.error("Failed to dismiss suggestion:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleCommentClick(comment: IComment) {
|
function handleCommentClick(comment: IComment) {
|
||||||
const el = document.querySelector(
|
const el = document.querySelector(
|
||||||
`.comment-mark[data-comment-id="${comment.id}"]`,
|
`.comment-mark[data-comment-id="${comment.id}"]`,
|
||||||
@@ -190,7 +227,7 @@ function CommentListItem({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && (
|
{isOwnerOrAdmin && (
|
||||||
<CommentMenu
|
<CommentMenu
|
||||||
onEditComment={handleEditToggle}
|
onEditComment={handleEditToggle}
|
||||||
onDeleteComment={handleDeleteComment}
|
onDeleteComment={handleDeleteComment}
|
||||||
@@ -236,12 +273,28 @@ function CommentListItem({
|
|||||||
{!comment.parentCommentId && comment.suggestedText && (
|
{!comment.parentCommentId && comment.suggestedText && (
|
||||||
<Box className={classes.suggestionBlock}>
|
<Box className={classes.suggestionBlock}>
|
||||||
{comment.selection && (
|
{comment.selection && (
|
||||||
|
// Old line: read as removed as a whole (line-through/red); only the
|
||||||
|
// changed fragments carry the extra intraline emphasis.
|
||||||
<Text size="xs" className={classes.suggestionOld}>
|
<Text size="xs" className={classes.suggestionOld}>
|
||||||
{comment.selection}
|
{suggestionDiff?.old.map((segment, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={segment.changed ? classes.suggestionChanged : undefined}
|
||||||
|
>
|
||||||
|
{segment.text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text size="xs" className={classes.suggestionNew}>
|
<Text size="xs" className={classes.suggestionNew}>
|
||||||
{comment.suggestedText}
|
{suggestionDiff?.new.map((segment, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={segment.changed ? classes.suggestionChanged : undefined}
|
||||||
|
>
|
||||||
|
{segment.text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{comment.suggestionAppliedAt ? (
|
{comment.suggestionAppliedAt ? (
|
||||||
@@ -255,18 +308,42 @@ function CommentListItem({
|
|||||||
{t("Applied")}
|
{t("Applied")}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
canShowApply(comment, canEdit) && (
|
(canShowApply(comment, canEdit) ||
|
||||||
<Button
|
canShowDismiss(comment, canComment, isOwnerOrAdmin)) && (
|
||||||
size="compact-xs"
|
<Group gap="xs" mt={6}>
|
||||||
variant="light"
|
{canShowApply(comment, canEdit) && (
|
||||||
color="green"
|
<Button
|
||||||
mt={6}
|
size="compact-xs"
|
||||||
onClick={handleApplySuggestion}
|
variant="light"
|
||||||
loading={applySuggestionMutation.isPending}
|
color="green"
|
||||||
disabled={applySuggestionMutation.isPending}
|
onClick={handleApplySuggestion}
|
||||||
>
|
loading={applySuggestionMutation.isPending}
|
||||||
{t("Apply")}
|
disabled={
|
||||||
</Button>
|
applySuggestionMutation.isPending ||
|
||||||
|
dismissSuggestionMutation.isPending
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Apply")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{/* Dismiss ("Не применять", #329): removes the suggestion
|
||||||
|
without changing the page text. Gated on canComment. */}
|
||||||
|
{canShowDismiss(comment, canComment, isOwnerOrAdmin) && (
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="subtle"
|
||||||
|
color="gray"
|
||||||
|
onClick={handleDismissSuggestion}
|
||||||
|
loading={dismissSuggestionMutation.isPending}
|
||||||
|
disabled={
|
||||||
|
applySuggestionMutation.isPending ||
|
||||||
|
dismissSuggestionMutation.isPending
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("Dismiss")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -53,6 +53,21 @@
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Intraline diff (#331): the fragment that actually changed within the
|
||||||
|
red "before" / green "after" block. It inherits the surrounding red/green
|
||||||
|
framing and adds a stronger tint plus bold weight so the eye lands on the
|
||||||
|
changed letters/words (git/GitHub-style) rather than the whole line. The
|
||||||
|
container's line-through (old) / green (new) still marks the full line. */
|
||||||
|
.suggestionChanged {
|
||||||
|
/* Stronger tint of the surrounding red/green so the changed fragment pops
|
||||||
|
within the block. `currentColor` follows the parent's red (old) or green
|
||||||
|
(new) text colour. No `text-decoration` here on purpose: the old block's
|
||||||
|
inherited line-through must survive on the changed letters too. */
|
||||||
|
background: color-mix(in srgb, currentColor 22%, transparent);
|
||||||
|
border-radius: 2px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.commentEditor {
|
.commentEditor {
|
||||||
|
|
||||||
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
|
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import React from "react";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import {
|
||||||
|
QueryClient,
|
||||||
|
QueryClientProvider,
|
||||||
|
InfiniteData,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coverage for the ephemeral-suggestion (#329) cache reconciliation in
|
||||||
|
* useApplySuggestionMutation / useDismissSuggestionMutation: the mutations act on
|
||||||
|
* the server `outcome` — 'deleted' drops the comment from the local list,
|
||||||
|
* 'resolved' relocates it (by stamping resolvedAt, which the tabs split on).
|
||||||
|
*/
|
||||||
|
|
||||||
|
vi.mock("@mantine/notifications", () => ({
|
||||||
|
notifications: { show: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||||
|
applySuggestion: vi.fn(),
|
||||||
|
dismissSuggestion: vi.fn(),
|
||||||
|
createComment: vi.fn(),
|
||||||
|
updateComment: vi.fn(),
|
||||||
|
deleteComment: vi.fn(),
|
||||||
|
resolveComment: vi.fn(),
|
||||||
|
getPageComments: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import {
|
||||||
|
applySuggestion,
|
||||||
|
dismissSuggestion,
|
||||||
|
} from "@/features/comment/services/comment-service";
|
||||||
|
import {
|
||||||
|
useApplySuggestionMutation,
|
||||||
|
useDismissSuggestionMutation,
|
||||||
|
RQ_KEY,
|
||||||
|
} from "@/features/comment/queries/comment-query";
|
||||||
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
|
const PAGE_ID = "page-1";
|
||||||
|
|
||||||
|
function seededClient(comment: IComment) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { mutations: { retry: false } },
|
||||||
|
});
|
||||||
|
const seed: InfiniteData<any> = {
|
||||||
|
pageParams: [undefined],
|
||||||
|
pages: [{ items: [comment], meta: { hasNextPage: false, nextCursor: null } }],
|
||||||
|
};
|
||||||
|
queryClient.setQueryData(RQ_KEY(PAGE_ID), seed);
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
);
|
||||||
|
return { queryClient, wrapper };
|
||||||
|
}
|
||||||
|
|
||||||
|
function items(queryClient: QueryClient): IComment[] {
|
||||||
|
const cache = queryClient.getQueryData(RQ_KEY(PAGE_ID)) as
|
||||||
|
| InfiniteData<any>
|
||||||
|
| undefined;
|
||||||
|
return cache?.pages.flatMap((p) => p.items) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = (over?: Partial<IComment>): IComment =>
|
||||||
|
({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
content: "{}",
|
||||||
|
creatorId: "u-1",
|
||||||
|
workspaceId: "ws-1",
|
||||||
|
createdAt: new Date(),
|
||||||
|
suggestedText: "new",
|
||||||
|
...over,
|
||||||
|
}) as IComment;
|
||||||
|
|
||||||
|
describe("useApplySuggestionMutation — outcome handling (#329)", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("outcome=deleted → removes the comment from the list", async () => {
|
||||||
|
vi.mocked(applySuggestion).mockResolvedValue({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
outcome: "deleted",
|
||||||
|
} as any);
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(items(queryClient)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("outcome=resolved → keeps the comment and stamps resolvedAt/applied fields", async () => {
|
||||||
|
const resolvedAt = new Date();
|
||||||
|
vi.mocked(applySuggestion).mockResolvedValue({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
outcome: "resolved",
|
||||||
|
resolvedAt,
|
||||||
|
resolvedById: "u-1",
|
||||||
|
resolvedBy: { id: "u-1", name: "A" },
|
||||||
|
suggestionAppliedAt: resolvedAt,
|
||||||
|
suggestionAppliedById: "u-1",
|
||||||
|
} as any);
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
const list = items(queryClient);
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(list[0].resolvedAt).toBe(resolvedAt);
|
||||||
|
expect(list[0].suggestionAppliedAt).toBe(resolvedAt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useDismissSuggestionMutation — outcome handling (#329)", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("outcome=deleted → removes the comment from the list", async () => {
|
||||||
|
vi.mocked(dismissSuggestion).mockResolvedValue({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
outcome: "deleted",
|
||||||
|
} as any);
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
expect(items(queryClient)).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("outcome=resolved → keeps the comment and stamps resolvedAt", async () => {
|
||||||
|
const resolvedAt = new Date();
|
||||||
|
vi.mocked(dismissSuggestion).mockResolvedValue({
|
||||||
|
id: "c-1",
|
||||||
|
pageId: PAGE_ID,
|
||||||
|
outcome: "resolved",
|
||||||
|
resolvedAt,
|
||||||
|
resolvedById: "u-1",
|
||||||
|
resolvedBy: { id: "u-1", name: "A" },
|
||||||
|
} as any);
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current.mutateAsync({ commentId: "c-1", pageId: PAGE_ID });
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
|
||||||
|
const list = items(queryClient);
|
||||||
|
expect(list).toHaveLength(1);
|
||||||
|
expect(list[0].resolvedAt).toBe(resolvedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("idempotent race (404) → treated as success, comment removed from the list", async () => {
|
||||||
|
vi.mocked(dismissSuggestion).mockRejectedValue({
|
||||||
|
response: { status: 404 },
|
||||||
|
});
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
// mutateAsync rejects even though onError reconciles the cache; swallow it.
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
expect(items(queryClient)).toHaveLength(0);
|
||||||
|
// #338 F3: the idempotent race must still fire the SUCCESS toast, not just
|
||||||
|
// silently drop the comment.
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith({
|
||||||
|
message: "Suggestion dismissed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dismiss 400 (thread still alive) → NOT a success, comment kept, no green toast (#338 F2)", async () => {
|
||||||
|
// 400 means the thread is alive (already resolved / a reply raced in).
|
||||||
|
// Narrowed onError: only 404 is a success-noop; 400 must surface a real error
|
||||||
|
// and keep the comment in the cache.
|
||||||
|
vi.mocked(dismissSuggestion).mockRejectedValue({
|
||||||
|
response: { status: 400 },
|
||||||
|
});
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDismissSuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
// Comment NOT dropped from the cache.
|
||||||
|
expect(items(queryClient)).toHaveLength(1);
|
||||||
|
// A real (red) error, never the success message.
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ color: "red" }),
|
||||||
|
);
|
||||||
|
expect(notifications.show).not.toHaveBeenCalledWith({
|
||||||
|
message: "Suggestion dismissed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("APPLY idempotent race (404) → treated as success, comment removed from the list", async () => {
|
||||||
|
// After #329 an applied reply-less suggestion is hard-deleted, so a racing
|
||||||
|
// second apply hits 404 — must reconcile to success like dismiss, not a red
|
||||||
|
// error (restores the #315 apply idempotency).
|
||||||
|
vi.mocked(applySuggestion).mockRejectedValue({
|
||||||
|
response: { status: 404 },
|
||||||
|
});
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
expect(items(queryClient)).toHaveLength(0);
|
||||||
|
// #338 F3: the idempotent race must still fire the SUCCESS toast.
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith({
|
||||||
|
message: "Suggestion applied",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("APPLY 400 (thread resolved, not applied) → NOT a success, comment kept, red error (#338 F2)", async () => {
|
||||||
|
// apply's only 400 is "Cannot apply … on a resolved comment thread" — the
|
||||||
|
// thread was resolved (often with discussion) but NOT applied. It must be a
|
||||||
|
// real error surfacing the server message, and must NOT drop the live thread.
|
||||||
|
vi.mocked(applySuggestion).mockRejectedValue({
|
||||||
|
response: {
|
||||||
|
status: 400,
|
||||||
|
data: {
|
||||||
|
message: "Cannot apply a suggested edit on a resolved comment thread",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const { queryClient, wrapper } = seededClient(comment());
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useApplySuggestionMutation(), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
await result.current
|
||||||
|
.mutateAsync({ commentId: "c-1", pageId: PAGE_ID })
|
||||||
|
.catch(() => undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
|
||||||
|
// The live thread is NOT dropped from the cache.
|
||||||
|
expect(items(queryClient)).toHaveLength(1);
|
||||||
|
// Surfaces the server's specific message as a red error, never a success.
|
||||||
|
expect(notifications.show).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
message: "Cannot apply a suggested edit on a resolved comment thread",
|
||||||
|
color: "red",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(notifications.show).not.toHaveBeenCalledWith({
|
||||||
|
message: "Suggestion applied",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
applySuggestion,
|
applySuggestion,
|
||||||
createComment,
|
createComment,
|
||||||
deleteComment,
|
deleteComment,
|
||||||
|
dismissSuggestion,
|
||||||
getPageComments,
|
getPageComments,
|
||||||
resolveComment,
|
resolveComment,
|
||||||
updateComment,
|
updateComment,
|
||||||
@@ -16,6 +17,7 @@ import {
|
|||||||
ICommentParams,
|
ICommentParams,
|
||||||
IComment,
|
IComment,
|
||||||
IResolveComment,
|
IResolveComment,
|
||||||
|
ISuggestionOutcome,
|
||||||
} from "@/features/comment/types/comment.types";
|
} from "@/features/comment/types/comment.types";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
@@ -177,40 +179,121 @@ function updateCommentInCache(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeCommentFromCache(
|
||||||
|
cache: InfiniteData<IPagination<IComment>>,
|
||||||
|
commentId: string,
|
||||||
|
): InfiniteData<IPagination<IComment>> {
|
||||||
|
return {
|
||||||
|
...cache,
|
||||||
|
pages: cache.pages.map((page) => ({
|
||||||
|
...page,
|
||||||
|
items: page.items.filter((comment) => comment.id !== commentId),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile the local comment cache with an ephemeral-suggestion outcome (#329)
|
||||||
|
// returned by apply/dismiss: 'deleted' → drop the comment (it disappeared);
|
||||||
|
// 'resolved' → the thread had replies and was resolved, so carry the resolved
|
||||||
|
// state through (which relocates it to the resolved tab).
|
||||||
|
function applySuggestionOutcomeToCache(
|
||||||
|
queryClient: ReturnType<typeof useQueryClient>,
|
||||||
|
pageId: string,
|
||||||
|
commentId: string,
|
||||||
|
data: ISuggestionOutcome,
|
||||||
|
) {
|
||||||
|
const cache = queryClient.getQueryData(RQ_KEY(pageId)) as
|
||||||
|
| InfiniteData<IPagination<IComment>>
|
||||||
|
| undefined;
|
||||||
|
if (!cache) return;
|
||||||
|
|
||||||
|
if (data.outcome === "deleted") {
|
||||||
|
queryClient.setQueryData(RQ_KEY(pageId), removeCommentFromCache(cache, commentId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'resolved' (or an older server that omits outcome): reflect the resolved
|
||||||
|
// state and the applied stamps (apply sets them; dismiss leaves them null).
|
||||||
|
queryClient.setQueryData(
|
||||||
|
RQ_KEY(pageId),
|
||||||
|
updateCommentInCache(cache, commentId, (comment) => ({
|
||||||
|
...comment,
|
||||||
|
suggestionAppliedAt: data.suggestionAppliedAt,
|
||||||
|
suggestionAppliedById: data.suggestionAppliedById,
|
||||||
|
resolvedAt: data.resolvedAt,
|
||||||
|
resolvedById: data.resolvedById,
|
||||||
|
resolvedBy: data.resolvedBy,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function useApplySuggestionMutation() {
|
export function useApplySuggestionMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation<IComment, any, { commentId: string; pageId: string }>({
|
return useMutation<
|
||||||
|
ISuggestionOutcome,
|
||||||
|
any,
|
||||||
|
{ commentId: string; pageId: string }
|
||||||
|
>({
|
||||||
// No optimistic update: apply can fail with 409 (the commented text drifted),
|
// No optimistic update: apply can fail with 409 (the commented text drifted),
|
||||||
// so we only mutate the cache once the server confirms.
|
// so we only mutate the cache once the server confirms.
|
||||||
mutationFn: ({ commentId }) => applySuggestion(commentId),
|
mutationFn: ({ commentId }) => applySuggestion(commentId),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
const cache = queryClient.getQueryData(
|
// Ephemeral (#329): the server hard-deletes the applied suggestion when the
|
||||||
RQ_KEY(variables.pageId),
|
// thread has no replies ('deleted') or resolves it when it does ('resolved').
|
||||||
) as InfiniteData<IPagination<IComment>> | undefined;
|
applySuggestionOutcomeToCache(
|
||||||
|
queryClient,
|
||||||
if (cache) {
|
variables.pageId,
|
||||||
queryClient.setQueryData(
|
variables.commentId,
|
||||||
RQ_KEY(variables.pageId),
|
data,
|
||||||
updateCommentInCache(cache, variables.commentId, (comment) => ({
|
);
|
||||||
...comment,
|
|
||||||
suggestionAppliedAt: data.suggestionAppliedAt,
|
|
||||||
suggestionAppliedById: data.suggestionAppliedById,
|
|
||||||
// The server auto-resolves the thread on apply — carry that through.
|
|
||||||
resolvedAt: data.resolvedAt,
|
|
||||||
resolvedById: data.resolvedById,
|
|
||||||
resolvedBy: data.resolvedBy,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
notifications.show({ message: t("Suggestion applied") });
|
notifications.show({ message: t("Suggestion applied") });
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any, variables) => {
|
||||||
|
const status = err?.response?.status;
|
||||||
|
// Idempotent race (double-click, or apply↔dismiss): after #329 an applied
|
||||||
|
// reply-less suggestion is hard-deleted, so a second/racing apply hits 404
|
||||||
|
// (already gone). ONLY 404 is a real success-noop — drop it from the cache
|
||||||
|
// and report success, the user's intent is already satisfied (restores the
|
||||||
|
// #315 apply idempotency the ephemeral delete would otherwise break).
|
||||||
|
//
|
||||||
|
// 400 is NOT success (#338 F2): apply's only 400 is "Cannot apply … on a
|
||||||
|
// resolved comment thread" — the thread was resolved (often WITH a live
|
||||||
|
// discussion) but the edit was NOT applied. Treating it as "Suggestion
|
||||||
|
// applied" is a false success that also drops a live thread from the cache.
|
||||||
|
// The #315 idempotent repeat does NOT produce 400 (childless → 404;
|
||||||
|
// with-replies → 200), so we never lose idempotency by excluding it here.
|
||||||
|
if (status === 404) {
|
||||||
|
const cache = queryClient.getQueryData(RQ_KEY(variables.pageId)) as
|
||||||
|
| InfiniteData<IPagination<IComment>>
|
||||||
|
| undefined;
|
||||||
|
if (cache) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
RQ_KEY(variables.pageId),
|
||||||
|
removeCommentFromCache(cache, variables.commentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
notifications.show({ message: t("Suggestion applied") });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 400 => the thread was resolved and the edit could not be applied. Show a
|
||||||
|
// real error and KEEP the comment in the cache (it is still alive). Prefer
|
||||||
|
// the server's specific message when it carries one.
|
||||||
|
if (status === 400) {
|
||||||
|
const serverMsg = err?.response?.data?.message;
|
||||||
|
notifications.show({
|
||||||
|
message:
|
||||||
|
typeof serverMsg === "string" && serverMsg.length > 0
|
||||||
|
? serverMsg
|
||||||
|
: t("Failed to apply suggestion"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
// 409 => the commented text changed since the suggestion was made. Surface
|
// 409 => the commented text changed since the suggestion was made. Surface
|
||||||
// a specific message (with the current text) rather than a generic error.
|
// a specific message (with the current text) rather than a generic error.
|
||||||
const status = err?.response?.status;
|
|
||||||
const currentText = err?.response?.data?.currentText;
|
const currentText = err?.response?.data?.currentText;
|
||||||
if (status === 409 && typeof currentText === "string") {
|
if (status === 409 && typeof currentText === "string") {
|
||||||
const shortText =
|
const shortText =
|
||||||
@@ -234,6 +317,58 @@ export function useApplySuggestionMutation() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useDismissSuggestionMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useMutation<
|
||||||
|
ISuggestionOutcome,
|
||||||
|
any,
|
||||||
|
{ commentId: string; pageId: string }
|
||||||
|
>({
|
||||||
|
mutationFn: ({ commentId }) => dismissSuggestion(commentId),
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
// Ephemeral (#329): dismiss hard-deletes the suggestion when the thread has
|
||||||
|
// no replies ('deleted') or resolves it when it does ('resolved').
|
||||||
|
applySuggestionOutcomeToCache(
|
||||||
|
queryClient,
|
||||||
|
variables.pageId,
|
||||||
|
variables.commentId,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
|
||||||
|
notifications.show({ message: t("Suggestion dismissed") });
|
||||||
|
},
|
||||||
|
onError: (err: any, variables) => {
|
||||||
|
// Idempotent race (double-click, or apply↔dismiss): the comment is already
|
||||||
|
// gone (404). ONLY 404 is a real success-noop — drop it from the cache and
|
||||||
|
// report success, the user's intent (make it disappear) is satisfied.
|
||||||
|
//
|
||||||
|
// 400 is NOT success (#338 F2): it means the thread is still ALIVE (already
|
||||||
|
// resolved, or a reply raced in), so treating it as "dismissed" would drop
|
||||||
|
// a live thread from the cache. Show a real error and keep the comment.
|
||||||
|
const status = err?.response?.status;
|
||||||
|
if (status === 404) {
|
||||||
|
const cache = queryClient.getQueryData(RQ_KEY(variables.pageId)) as
|
||||||
|
| InfiniteData<IPagination<IComment>>
|
||||||
|
| undefined;
|
||||||
|
if (cache) {
|
||||||
|
queryClient.setQueryData(
|
||||||
|
RQ_KEY(variables.pageId),
|
||||||
|
removeCommentFromCache(cache, variables.commentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
notifications.show({ message: t("Suggestion dismissed") });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifications.show({
|
||||||
|
message: t("Failed to dismiss suggestion"),
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useResolveCommentMutation() {
|
export function useResolveCommentMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
ICommentParams,
|
ICommentParams,
|
||||||
IComment,
|
IComment,
|
||||||
IResolveComment,
|
IResolveComment,
|
||||||
|
ISuggestionOutcome,
|
||||||
} from "@/features/comment/types/comment.types";
|
} from "@/features/comment/types/comment.types";
|
||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
|
|
||||||
@@ -18,13 +19,24 @@ export async function resolveComment(data: IResolveComment): Promise<IComment> {
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applySuggestion(commentId: string): Promise<IComment> {
|
export async function applySuggestion(
|
||||||
|
commentId: string,
|
||||||
|
): Promise<ISuggestionOutcome> {
|
||||||
// Mirrors resolveComment: let axios reject on non-2xx so the mutation can read
|
// Mirrors resolveComment: let axios reject on non-2xx so the mutation can read
|
||||||
// the 409 body (`{ message, currentText }`) off err.response.data.
|
// the 409 body (`{ message, currentText }`) off err.response.data.
|
||||||
const req = await api.post("/comments/apply-suggestion", { commentId });
|
const req = await api.post("/comments/apply-suggestion", { commentId });
|
||||||
return req.data.data ?? req.data;
|
return req.data.data ?? req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function dismissSuggestion(
|
||||||
|
commentId: string,
|
||||||
|
): Promise<ISuggestionOutcome> {
|
||||||
|
// Dismiss ("Не применять") a suggested edit (#329): the server hard-deletes
|
||||||
|
// the comment (or resolves it when it has replies) and returns the outcome.
|
||||||
|
const req = await api.post("/comments/dismiss-suggestion", { commentId });
|
||||||
|
return req.data.data ?? req.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateComment(
|
export async function updateComment(
|
||||||
data: Partial<IComment>,
|
data: Partial<IComment>,
|
||||||
): Promise<IComment> {
|
): Promise<IComment> {
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ export interface IResolveComment {
|
|||||||
resolved: boolean;
|
resolved: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Result of applying or dismissing an ephemeral suggested edit (#329). The
|
||||||
|
// server hard-deletes the comment (`deleted`) unless the thread has replies, in
|
||||||
|
// which case it is resolved (`resolved`). The returned comment fields carry the
|
||||||
|
// resolved-branch state; `outcome` tells the client which optimistic action to
|
||||||
|
// take (drop the comment vs. move it to the resolved tab).
|
||||||
|
export type ISuggestionOutcome = IComment & {
|
||||||
|
outcome?: "deleted" | "resolved";
|
||||||
|
};
|
||||||
|
|
||||||
export interface ICommentParams extends QueryParams {
|
export interface ICommentParams extends QueryParams {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { computeSuggestionDiff, Segment } from "@/features/comment/utils/suggestion";
|
||||||
|
|
||||||
|
// Reconstruct the plain string from a segment stream — the diff must be
|
||||||
|
// lossless (concatenating every fragment yields the original input).
|
||||||
|
const join = (segments: Segment[]): string =>
|
||||||
|
segments.map((s) => s.text).join("");
|
||||||
|
|
||||||
|
// The subset of segments (in order) that the UI would emphasise.
|
||||||
|
const changed = (segments: Segment[]): string[] =>
|
||||||
|
segments.filter((s) => s.changed).map((s) => s.text);
|
||||||
|
|
||||||
|
// Find the segment that contains a substring, to assert its `changed` flag.
|
||||||
|
const segmentWith = (segments: Segment[], needle: string): Segment | undefined =>
|
||||||
|
segments.find((s) => s.text.includes(needle));
|
||||||
|
|
||||||
|
describe("computeSuggestionDiff", () => {
|
||||||
|
it("highlights only the single changed letter in a one-letter edit", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff("заведем", "заведём");
|
||||||
|
|
||||||
|
// Lossless.
|
||||||
|
expect(join(old)).toBe("заведем");
|
||||||
|
expect(join(neu)).toBe("заведём");
|
||||||
|
|
||||||
|
// Old side: exactly the `е` is changed, the rest is common.
|
||||||
|
expect(changed(old)).toEqual(["е"]);
|
||||||
|
expect(old).toEqual([
|
||||||
|
{ text: "завед", changed: false },
|
||||||
|
{ text: "е", changed: true },
|
||||||
|
{ text: "м", changed: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// New side: exactly the `ё` is changed.
|
||||||
|
expect(changed(neu)).toEqual(["ё"]);
|
||||||
|
expect(neu).toEqual([
|
||||||
|
{ text: "завед", changed: false },
|
||||||
|
{ text: "ё", changed: true },
|
||||||
|
{ text: "м", changed: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the differing words changed but keeps the shared word common", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff(
|
||||||
|
"привет мир",
|
||||||
|
"здравствуй мир",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Lossless.
|
||||||
|
expect(join(old)).toBe("привет мир");
|
||||||
|
expect(join(neu)).toBe("здравствуй мир");
|
||||||
|
|
||||||
|
// The shared trailing word stays common on both sides (no per-letter noise
|
||||||
|
// leaking across the differing words into `мир`).
|
||||||
|
expect(segmentWith(old, "мир")?.changed).toBe(false);
|
||||||
|
expect(segmentWith(neu, "мир")?.changed).toBe(false);
|
||||||
|
|
||||||
|
// The differing words are emphasised somewhere on each side.
|
||||||
|
expect(changed(old).length).toBeGreaterThan(0);
|
||||||
|
expect(changed(neu).length).toBeGreaterThan(0);
|
||||||
|
expect(changed(old).join("")).toContain("п"); // from `привет`
|
||||||
|
expect(changed(neu).join("")).toContain("зд"); // from `здравствуй`
|
||||||
|
|
||||||
|
// No changed fragment on either side touches the word `мир`.
|
||||||
|
expect(changed(old).some((t) => t.includes("мир"))).toBe(false);
|
||||||
|
expect(changed(neu).some((t) => t.includes("мир"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a whole inserted word changed and leaves the old line common", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff("a c", "a b c");
|
||||||
|
|
||||||
|
expect(join(old)).toBe("a c");
|
||||||
|
expect(join(neu)).toBe("a b c");
|
||||||
|
|
||||||
|
// Old line has no changed fragment (nothing was removed).
|
||||||
|
expect(changed(old)).toEqual([]);
|
||||||
|
// The inserted word is the only changed fragment on the new side.
|
||||||
|
expect(neu).toContainEqual({ text: "b ", changed: true });
|
||||||
|
expect(changed(neu)).toEqual(["b "]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a whole deleted word changed and leaves the new line common", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff("a b c", "a c");
|
||||||
|
|
||||||
|
expect(join(old)).toBe("a b c");
|
||||||
|
expect(join(neu)).toBe("a c");
|
||||||
|
|
||||||
|
// The deleted word is the only changed fragment on the old side.
|
||||||
|
expect(old).toContainEqual({ text: "b ", changed: true });
|
||||||
|
expect(changed(old)).toEqual(["b "]);
|
||||||
|
// New line has no changed fragment (nothing was added).
|
||||||
|
expect(changed(neu)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks everything common for identical strings", () => {
|
||||||
|
const { old, new: neu } = computeSuggestionDiff("hello", "hello");
|
||||||
|
|
||||||
|
expect(old).toEqual([{ text: "hello", changed: false }]);
|
||||||
|
expect(neu).toEqual([{ text: "hello", changed: false }]);
|
||||||
|
expect(changed(old)).toEqual([]);
|
||||||
|
expect(changed(neu)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { diffWordsWithSpace, diffChars } from "diff";
|
||||||
import { IComment } from "@/features/comment/types/comment.types";
|
import { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
// Whether the suggested-edit (#315) "Apply" button should be shown for a
|
// Whether the suggested-edit (#315) "Apply" button should be shown for a
|
||||||
@@ -12,3 +13,127 @@ export function canShowApply(comment: IComment, canEdit?: boolean): boolean {
|
|||||||
!comment.parentCommentId,
|
!comment.parentCommentId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One contiguous run of text within a suggestion's "before" or "after" line.
|
||||||
|
// `changed` marks the fragment that actually differs from the other side, so
|
||||||
|
// the UI can emphasise only the intraline delta (git/GitHub-style) instead of
|
||||||
|
// the whole line.
|
||||||
|
export interface Segment {
|
||||||
|
text: string;
|
||||||
|
changed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pure "before -> after" intraline diff (#331): the old line split into
|
||||||
|
// common vs. removed-and-changed fragments, and the new line split into common
|
||||||
|
// vs. added-and-changed fragments. Concatenating each side's `text` reproduces
|
||||||
|
// the original strings.
|
||||||
|
export interface SuggestionDiff {
|
||||||
|
old: Segment[];
|
||||||
|
new: Segment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push a segment, coalescing runs of the same `changed` flag on the same side
|
||||||
|
// so the render emits as few spans as possible and tests stay predictable.
|
||||||
|
function pushSegment(segments: Segment[], text: string, changed: boolean): void {
|
||||||
|
if (text === "") return;
|
||||||
|
const last = segments[segments.length - 1];
|
||||||
|
if (last && last.changed === changed) {
|
||||||
|
last.text += text;
|
||||||
|
} else {
|
||||||
|
segments.push({ text, changed });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute an intraline diff between the old `selection` and the new
|
||||||
|
// `suggestedText` of a suggestion. PURE — no React, no DOM, no I/O.
|
||||||
|
//
|
||||||
|
// Hybrid word + char algorithm (per #331):
|
||||||
|
// 1. `diffWordsWithSpace` yields word-granular parts [{value, added, removed}].
|
||||||
|
// 2. An ADJACENT removed+added pair (a word replacement) is refined with
|
||||||
|
// `diffChars`: shared characters stay common, differing characters are
|
||||||
|
// marked `changed` on their respective side. This is what keeps a
|
||||||
|
// one-letter edit (заведем -> заведём) from highlighting the whole word.
|
||||||
|
// 3. A lone `added` (insertion) or lone `removed` (deletion) marks the whole
|
||||||
|
// fragment `changed`.
|
||||||
|
// 4. An unchanged part is `common` on both sides.
|
||||||
|
//
|
||||||
|
// Rejected alternatives: pure `diffChars` is noisy on word swaps; pure
|
||||||
|
// `diffWordsWithSpace` highlights the whole word rather than the changed letter.
|
||||||
|
export function computeSuggestionDiff(
|
||||||
|
oldStr: string,
|
||||||
|
newStr: string,
|
||||||
|
): SuggestionDiff {
|
||||||
|
const oldSegments: Segment[] = [];
|
||||||
|
const newSegments: Segment[] = [];
|
||||||
|
|
||||||
|
const parts = diffWordsWithSpace(oldStr, newStr);
|
||||||
|
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const part = parts[i];
|
||||||
|
const next = parts[i + 1];
|
||||||
|
|
||||||
|
// A word replacement: a removed part immediately followed by an added part
|
||||||
|
// (or the reverse). Refine it character-by-character so only the differing
|
||||||
|
// letters are highlighted while shared letters stay common.
|
||||||
|
const isReplacementPair =
|
||||||
|
next &&
|
||||||
|
((part.removed && next.added) || (part.added && next.removed));
|
||||||
|
|
||||||
|
if (isReplacementPair) {
|
||||||
|
const removedPart = part.removed ? part : next;
|
||||||
|
const addedPart = part.added ? part : next;
|
||||||
|
|
||||||
|
const charParts = diffChars(removedPart.value, addedPart.value);
|
||||||
|
for (const cp of charParts) {
|
||||||
|
if (cp.added) {
|
||||||
|
pushSegment(newSegments, cp.value, true);
|
||||||
|
} else if (cp.removed) {
|
||||||
|
pushSegment(oldSegments, cp.value, true);
|
||||||
|
} else {
|
||||||
|
// Shared character: common on both sides.
|
||||||
|
pushSegment(oldSegments, cp.value, false);
|
||||||
|
pushSegment(newSegments, cp.value, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
i++; // consume the paired part as well
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.added) {
|
||||||
|
// Lone insertion: only present in the new line, wholly changed.
|
||||||
|
pushSegment(newSegments, part.value, true);
|
||||||
|
} else if (part.removed) {
|
||||||
|
// Lone deletion: only present in the old line, wholly changed.
|
||||||
|
pushSegment(oldSegments, part.value, true);
|
||||||
|
} else {
|
||||||
|
// Unchanged: common on both sides.
|
||||||
|
pushSegment(oldSegments, part.value, false);
|
||||||
|
pushSegment(newSegments, part.value, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { old: oldSegments, new: newSegments };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether the suggested-edit (#329) "Не применять" (Dismiss) button should be
|
||||||
|
// shown. Dismiss does NOT change the page text (so it needs only canComment, not
|
||||||
|
// canEdit), BUT a childless dismiss IRREVERSIBLY hard-deletes the comment, so the
|
||||||
|
// server gates it on comment-owner-OR-space-admin (#338 F5). The button must
|
||||||
|
// mirror that authz or a non-owner non-admin sees a live Dismiss that always
|
||||||
|
// 403s → red error. Hence isOwnerOrAdmin is required IN ADDITION to canComment.
|
||||||
|
// Same not-applied/not-resolved/top-level conditions as Apply.
|
||||||
|
export function canShowDismiss(
|
||||||
|
comment: IComment,
|
||||||
|
canComment?: boolean,
|
||||||
|
isOwnerOrAdmin?: boolean,
|
||||||
|
): boolean {
|
||||||
|
return Boolean(
|
||||||
|
canComment &&
|
||||||
|
isOwnerOrAdmin &&
|
||||||
|
comment.suggestedText &&
|
||||||
|
!comment.suggestionAppliedAt &&
|
||||||
|
!comment.resolvedAt &&
|
||||||
|
!comment.parentCommentId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { useAtom, useStore } from "jotai";
|
import { useAtom, useSetAtom, useStore } from "jotai";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from "@/features/page/queries/page-query.ts";
|
} from "@/features/page/queries/page-query.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
import { getSpaceUrl } from "@/lib/config.ts";
|
import { getSpaceUrl } from "@/lib/config.ts";
|
||||||
|
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
|
|
||||||
export type UseTreeMutation = {
|
export type UseTreeMutation = {
|
||||||
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
|
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
|
||||||
@@ -43,6 +44,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
|||||||
const removePageMutation = useRemovePageMutation();
|
const removePageMutation = useRemovePageMutation();
|
||||||
const movePageMutation = useMovePageMutation();
|
const movePageMutation = useMovePageMutation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const setMobileSidebar = useSetAtom(mobileSidebarAtom);
|
||||||
const { spaceSlug, pageSlug } = useParams();
|
const { spaceSlug, pageSlug } = useParams();
|
||||||
|
|
||||||
const handleMove = useCallback(
|
const handleMove = useCallback(
|
||||||
@@ -201,8 +203,23 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
|||||||
createdPage.title,
|
createdPage.title,
|
||||||
);
|
);
|
||||||
navigate(pageUrl);
|
navigate(pageUrl);
|
||||||
|
// On mobile the create action is triggered from inside the off-canvas
|
||||||
|
// sidebar drawer (space sidebar "+", tree-row "add subpage"). Navigating
|
||||||
|
// alone leaves that drawer open on top of the freshly created page, so the
|
||||||
|
// editor stays hidden behind the tree. Close it here so the new page opens
|
||||||
|
// in the editor — mirrors the row-click drawer-close in space-tree-row.
|
||||||
|
// No-op on desktop, where the mobile drawer atom is already false.
|
||||||
|
setMobileSidebar(false);
|
||||||
},
|
},
|
||||||
[spaceId, createPageMutation, setData, store, navigate, spaceSlug],
|
[
|
||||||
|
spaceId,
|
||||||
|
createPageMutation,
|
||||||
|
setData,
|
||||||
|
store,
|
||||||
|
navigate,
|
||||||
|
spaceSlug,
|
||||||
|
setMobileSidebar,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRename = useCallback(
|
const handleRename = useCallback(
|
||||||
|
|||||||
@@ -13,5 +13,22 @@ export default defineConfig({
|
|||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
globals: true,
|
globals: true,
|
||||||
setupFiles: ['./vitest.setup.ts'],
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
|
// Coverage gate (issue #324). v8 provider (not istanbul) so ESM barrels
|
||||||
|
// like `@docmost/editor-ext` are not re-parsed/instrumented. Thresholds are
|
||||||
|
// set a few points below the level measured on develop, scoped to the files
|
||||||
|
// the suite exercises (`all: false`) rather than the whole app, so the gate
|
||||||
|
// passes today but fails on a genuine coverage regression.
|
||||||
|
coverage: {
|
||||||
|
enabled: true,
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text-summary', 'text'],
|
||||||
|
all: false,
|
||||||
|
thresholds: {
|
||||||
|
statements: 55,
|
||||||
|
branches: 53,
|
||||||
|
functions: 44,
|
||||||
|
lines: 55,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -130,3 +130,59 @@ describe('CollaborationHandler.applyCommentSuggestion', () => {
|
|||||||
expect(value).toBe(42);
|
expect(value).toBe(42);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('CollaborationHandler.deleteCommentMark', () => {
|
||||||
|
it('strips the comment mark for the given commentId (ephemeral suggestion #329)', async () => {
|
||||||
|
const doc = buildDocWithComment('Hello world', 'c1');
|
||||||
|
const { hocuspocus, connection } = fakeHocuspocus(doc);
|
||||||
|
const handler = new CollaborationHandler();
|
||||||
|
const handlers = handler.getHandlers(hocuspocus);
|
||||||
|
|
||||||
|
await handlers.deleteCommentMark('doc-1', { commentId: 'c1', user });
|
||||||
|
|
||||||
|
// The mark is gone; the text itself stays (deleting the anchor, not the run).
|
||||||
|
const xmlText = (
|
||||||
|
doc.getXmlFragment('default').get(0) as Y.XmlElement
|
||||||
|
).get(0) as Y.XmlText;
|
||||||
|
expect(xmlText.toDelta()).toEqual([{ insert: 'Hello world' }]);
|
||||||
|
expect(connection.transact).toHaveBeenCalledTimes(1);
|
||||||
|
expect(connection.disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes the removal through removeYjsMarkByAttribute with the right args', async () => {
|
||||||
|
const doc = buildDocWithComment('abc', 'c9');
|
||||||
|
const { hocuspocus } = fakeHocuspocus(doc);
|
||||||
|
const spy = jest.spyOn(yjsUtil, 'removeYjsMarkByAttribute');
|
||||||
|
const handler = new CollaborationHandler();
|
||||||
|
const handlers = handler.getHandlers(hocuspocus);
|
||||||
|
|
||||||
|
await handlers.deleteCommentMark('doc-1', { commentId: 'c9', user });
|
||||||
|
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
doc.getXmlFragment('default'),
|
||||||
|
'comment',
|
||||||
|
'commentId',
|
||||||
|
'c9',
|
||||||
|
);
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a different comment\'s mark intact', async () => {
|
||||||
|
const doc = buildDocWithComment('keep me', 'other');
|
||||||
|
const { hocuspocus } = fakeHocuspocus(doc);
|
||||||
|
const handler = new CollaborationHandler();
|
||||||
|
const handlers = handler.getHandlers(hocuspocus);
|
||||||
|
|
||||||
|
await handlers.deleteCommentMark('doc-1', { commentId: 'c1', user });
|
||||||
|
|
||||||
|
const xmlText = (
|
||||||
|
doc.getXmlFragment('default').get(0) as Y.XmlElement
|
||||||
|
).get(0) as Y.XmlText;
|
||||||
|
expect(xmlText.toDelta()).toEqual([
|
||||||
|
{
|
||||||
|
insert: 'keep me',
|
||||||
|
attributes: { comment: { commentId: 'other', resolved: false } },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
tiptapExtensions,
|
tiptapExtensions,
|
||||||
} from './collaboration.util';
|
} from './collaboration.util';
|
||||||
import {
|
import {
|
||||||
|
removeYjsMarkByAttribute,
|
||||||
replaceYjsMarkedText,
|
replaceYjsMarkedText,
|
||||||
setYjsMark,
|
setYjsMark,
|
||||||
updateYjsMarkAttribute,
|
updateYjsMarkAttribute,
|
||||||
@@ -78,6 +79,40 @@ export class CollaborationHandler {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
deleteCommentMark: async (
|
||||||
|
documentName: string,
|
||||||
|
payload: {
|
||||||
|
commentId: string;
|
||||||
|
user: User;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const { commentId, user } = payload;
|
||||||
|
// Ephemeral suggestions (#329): when a suggestion-edit is dismissed or an
|
||||||
|
// applied one has no replies, the comment is hard-deleted and its inline
|
||||||
|
// anchor must vanish too. Mirror resolveCommentMark exactly, but instead
|
||||||
|
// of flipping the mark's `resolved` attribute we STRIP the `comment` mark
|
||||||
|
// entirely via removeYjsMarkByAttribute so no orphan highlight remains in
|
||||||
|
// the collaborative document.
|
||||||
|
//
|
||||||
|
// Routing this through collaboration.gateway's handleYjsEvent means the
|
||||||
|
// COLLAB_DISABLE_REDIS path invokes this handler directly (never a silent
|
||||||
|
// no-op) and a missing live instance is a hard error — the same guarantee
|
||||||
|
// applyCommentSuggestion/resolveCommentMark rely on.
|
||||||
|
await this.withYdocConnection(
|
||||||
|
hocuspocus,
|
||||||
|
documentName,
|
||||||
|
{ user },
|
||||||
|
(doc) => {
|
||||||
|
const fragment = doc.getXmlFragment('default');
|
||||||
|
removeYjsMarkByAttribute(
|
||||||
|
fragment,
|
||||||
|
'comment',
|
||||||
|
'commentId',
|
||||||
|
commentId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
applyCommentSuggestion: async (
|
applyCommentSuggestion: async (
|
||||||
documentName: string,
|
documentName: string,
|
||||||
payload: {
|
payload: {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export const AuditEvent = {
|
|||||||
COMMENT_RESOLVED: 'comment.resolved',
|
COMMENT_RESOLVED: 'comment.resolved',
|
||||||
COMMENT_REOPENED: 'comment.reopened',
|
COMMENT_REOPENED: 'comment.reopened',
|
||||||
COMMENT_SUGGESTION_APPLIED: 'comment.suggestion_applied',
|
COMMENT_SUGGESTION_APPLIED: 'comment.suggestion_applied',
|
||||||
|
COMMENT_SUGGESTION_DISMISSED: 'comment.suggestion_dismissed',
|
||||||
|
|
||||||
// Page
|
// Page
|
||||||
PAGE_CREATED: 'page.created',
|
PAGE_CREATED: 'page.created',
|
||||||
|
|||||||
@@ -303,7 +303,9 @@ export class AiChatToolsService {
|
|||||||
getPage: tool({
|
getPage: tool({
|
||||||
description:
|
description:
|
||||||
'Fetch a single page as Markdown by its page id. Returns the page ' +
|
'Fetch a single page as Markdown by its page id. Returns the page ' +
|
||||||
'title and its Markdown content.',
|
'title and its Markdown content. Inline <span data-comment-id> tags ' +
|
||||||
|
'in the markdown are comment highlight anchors (also present for ' +
|
||||||
|
'RESOLVED threads) — treat them as markup, not page text.',
|
||||||
inputSchema: modelFriendlyInput({
|
inputSchema: modelFriendlyInput({
|
||||||
pageId: z.string().describe('The id (or slugId) of the page.'),
|
pageId: z.string().describe('The id (or slugId) of the page.'),
|
||||||
}),
|
}),
|
||||||
@@ -628,6 +630,16 @@ export class AiChatToolsService {
|
|||||||
async ({ pageId, nodeId }) => await client.getNode(pageId, nodeId),
|
async ({ pageId, nodeId }) => await client.getNode(pageId, nodeId),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
searchInPage: sharedTool(
|
||||||
|
sharedToolSpecs.searchInPage,
|
||||||
|
async ({ pageId, query, regex, caseSensitive, limit }) =>
|
||||||
|
await client.searchInPage(pageId, query, {
|
||||||
|
regex,
|
||||||
|
caseSensitive,
|
||||||
|
limit,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
getTable: tool({
|
getTable: tool({
|
||||||
description:
|
description:
|
||||||
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
|
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
|
||||||
@@ -647,11 +659,21 @@ export class AiChatToolsService {
|
|||||||
|
|
||||||
listComments: tool({
|
listComments: tool({
|
||||||
description:
|
description:
|
||||||
'List all comments on a page (content as Markdown).',
|
'List comments on a page in one call. By DEFAULT only ACTIVE ' +
|
||||||
|
'threads are returned; resolved threads (a resolved top-level ' +
|
||||||
|
'comment and all its replies) are hidden and their count reported ' +
|
||||||
|
'as `resolvedThreadsHidden` so you can re-query with ' +
|
||||||
|
'`includeResolved: true` to see everything. Returns ' +
|
||||||
|
'`{ items, resolvedThreadsHidden }`. Content is returned as Markdown.',
|
||||||
inputSchema: modelFriendlyInput({
|
inputSchema: modelFriendlyInput({
|
||||||
pageId: z.string().describe('The id of the page.'),
|
pageId: z.string().describe('The id of the page.'),
|
||||||
|
includeResolved: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe('default only active threads; true — include resolved'),
|
||||||
}),
|
}),
|
||||||
execute: async ({ pageId }) => await client.listComments(pageId),
|
execute: async ({ pageId, includeResolved }) =>
|
||||||
|
await client.listComments(pageId, includeResolved),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getComment: tool({
|
getComment: tool({
|
||||||
|
|||||||
@@ -55,8 +55,18 @@ export interface DocmostClientLike {
|
|||||||
getOutline(pageId: string): Promise<Record<string, unknown>>;
|
getOutline(pageId: string): Promise<Record<string, unknown>>;
|
||||||
getPageJson(pageId: string): Promise<Record<string, unknown>>;
|
getPageJson(pageId: string): Promise<Record<string, unknown>>;
|
||||||
getNode(pageId: string, nodeId: string): Promise<Record<string, unknown>>;
|
getNode(pageId: string, nodeId: string): Promise<Record<string, unknown>>;
|
||||||
|
searchInPage(
|
||||||
|
pageId: string,
|
||||||
|
query: string,
|
||||||
|
opts?: { regex?: boolean; caseSensitive?: boolean; limit?: number },
|
||||||
|
): Promise<Record<string, unknown>>;
|
||||||
getTable(pageId: string, tableRef: string): Promise<Record<string, unknown>>;
|
getTable(pageId: string, tableRef: string): Promise<Record<string, unknown>>;
|
||||||
listComments(pageId: string): Promise<unknown[]>;
|
// Returns `{ items, resolvedThreadsHidden }`. DEFAULT (includeResolved unset/
|
||||||
|
// false) hides resolved threads wholesale; pass true for the full feed.
|
||||||
|
listComments(
|
||||||
|
pageId: string,
|
||||||
|
includeResolved?: boolean,
|
||||||
|
): Promise<{ items: unknown[]; resolvedThreadsHidden: number }>;
|
||||||
getComment(
|
getComment(
|
||||||
commentId: string,
|
commentId: string,
|
||||||
): Promise<{ data: Record<string, unknown>; success: boolean }>;
|
): Promise<{ data: Record<string, unknown>; success: boolean }>;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -117,3 +118,207 @@ describe('CommentController apply-suggestion authz', () => {
|
|||||||
expect(commentService.applySuggestion).not.toHaveBeenCalled();
|
expect(commentService.applySuggestion).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authz-gate tests for the dismiss-suggestion route (#329). Dismissing a
|
||||||
|
* suggestion does NOT change the page text, so it authorizes with
|
||||||
|
* validateCanComment (NOT validateCanEdit) — a viewer allowed to comment but not
|
||||||
|
* edit can still dismiss. The gate MUST run BEFORE the service (which performs
|
||||||
|
* the delete/resolve + mark removal). These tests pin that boundary.
|
||||||
|
*/
|
||||||
|
describe('CommentController dismiss-suggestion authz', () => {
|
||||||
|
// isAdmin=false → ability.cannot(Manage, Settings) returns true (i.e. the user
|
||||||
|
// is NOT a space admin). Flip to true to model a space admin.
|
||||||
|
function makeController(isAdmin = false) {
|
||||||
|
const commentService = {
|
||||||
|
dismissSuggestion: jest.fn(async () => ({
|
||||||
|
id: 'c-1',
|
||||||
|
outcome: 'deleted',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const commentRepo = { findById: jest.fn() };
|
||||||
|
const pageRepo = { findById: jest.fn() };
|
||||||
|
const spaceAbility = {
|
||||||
|
createForUser: jest.fn(async () => ({
|
||||||
|
cannot: jest.fn(() => !isAdmin),
|
||||||
|
})),
|
||||||
|
} as any;
|
||||||
|
const pageAccessService = {
|
||||||
|
validateCanComment: jest.fn(async () => undefined),
|
||||||
|
validateCanEdit: jest.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
const wsService = {} as any;
|
||||||
|
const auditService = { log: jest.fn() };
|
||||||
|
|
||||||
|
const controller = new CommentController(
|
||||||
|
commentService as any,
|
||||||
|
commentRepo as any,
|
||||||
|
pageRepo as any,
|
||||||
|
spaceAbility,
|
||||||
|
pageAccessService as any,
|
||||||
|
wsService,
|
||||||
|
auditService as any,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
controller,
|
||||||
|
commentService,
|
||||||
|
commentRepo,
|
||||||
|
pageRepo,
|
||||||
|
pageAccessService,
|
||||||
|
spaceAbility,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const user: any = { id: 'u-1' };
|
||||||
|
const workspace: any = { id: 'ws-1' };
|
||||||
|
const provenance: any = undefined;
|
||||||
|
const dto: any = { commentId: 'c-1' };
|
||||||
|
// Owned by the acting user (u-1) unless a test overrides creatorId.
|
||||||
|
const comment = {
|
||||||
|
id: 'c-1',
|
||||||
|
pageId: 'p-1',
|
||||||
|
spaceId: 'sp-1',
|
||||||
|
creatorId: 'u-1',
|
||||||
|
suggestedText: 'new text',
|
||||||
|
selection: 'old text',
|
||||||
|
};
|
||||||
|
const page = { id: 'p-1', spaceId: 'sp-1', deletedAt: null };
|
||||||
|
|
||||||
|
it('authorizes with validateCanComment (NOT validateCanEdit) then calls the service', async () => {
|
||||||
|
const {
|
||||||
|
controller,
|
||||||
|
commentRepo,
|
||||||
|
pageRepo,
|
||||||
|
pageAccessService,
|
||||||
|
commentService,
|
||||||
|
} = makeController();
|
||||||
|
commentRepo.findById.mockResolvedValue(comment);
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
const dismissed = { id: 'c-1', outcome: 'deleted' };
|
||||||
|
commentService.dismissSuggestion.mockResolvedValue(dismissed);
|
||||||
|
|
||||||
|
const result = await controller.dismissSuggestion(
|
||||||
|
dto,
|
||||||
|
user,
|
||||||
|
workspace,
|
||||||
|
provenance,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(pageAccessService.validateCanComment).toHaveBeenCalledWith(
|
||||||
|
page,
|
||||||
|
user,
|
||||||
|
workspace.id,
|
||||||
|
);
|
||||||
|
// Dismiss must NOT require edit access.
|
||||||
|
expect(pageAccessService.validateCanEdit).not.toHaveBeenCalled();
|
||||||
|
expect(commentService.dismissSuggestion).toHaveBeenCalledWith(
|
||||||
|
comment,
|
||||||
|
user,
|
||||||
|
provenance,
|
||||||
|
);
|
||||||
|
expect(result).toBe(dismissed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validateCanComment throwing Forbidden rejects AND dismissSuggestion is never called', async () => {
|
||||||
|
const {
|
||||||
|
controller,
|
||||||
|
commentRepo,
|
||||||
|
pageRepo,
|
||||||
|
pageAccessService,
|
||||||
|
commentService,
|
||||||
|
} = makeController();
|
||||||
|
commentRepo.findById.mockResolvedValue(comment);
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
pageAccessService.validateCanComment.mockRejectedValue(
|
||||||
|
new ForbiddenException('no comment access'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.dismissSuggestion(dto, user, workspace, provenance),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
|
||||||
|
expect(commentService.dismissSuggestion).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('missing comment: NotFound without authorizing or dismissing', async () => {
|
||||||
|
const { controller, commentRepo, pageRepo, pageAccessService, commentService } =
|
||||||
|
makeController();
|
||||||
|
commentRepo.findById.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.dismissSuggestion(dto, user, workspace, provenance),
|
||||||
|
).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
|
||||||
|
expect(pageRepo.findById).not.toHaveBeenCalled();
|
||||||
|
expect(pageAccessService.validateCanComment).not.toHaveBeenCalled();
|
||||||
|
expect(commentService.dismissSuggestion).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates a service BadRequest (e.g. already applied/resolved) unchanged', async () => {
|
||||||
|
const { controller, commentRepo, pageRepo, commentService } =
|
||||||
|
makeController();
|
||||||
|
commentRepo.findById.mockResolvedValue(comment);
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
commentService.dismissSuggestion.mockRejectedValue(
|
||||||
|
new BadRequestException('already applied'),
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.dismissSuggestion(dto, user, workspace, provenance),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- #338 owner-or-space-admin gate (mirrors POST /comments/delete) --------
|
||||||
|
// A childless dismiss irreversibly hard-deletes the comment, so canComment is
|
||||||
|
// not enough: only the comment owner or a space admin may dismiss.
|
||||||
|
|
||||||
|
it('owner dismisses their own suggestion → allowed, no admin check needed', async () => {
|
||||||
|
const { controller, commentRepo, pageRepo, commentService, spaceAbility } =
|
||||||
|
makeController(false);
|
||||||
|
// comment.creatorId === user.id (owner).
|
||||||
|
commentRepo.findById.mockResolvedValue(comment);
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
|
||||||
|
await controller.dismissSuggestion(dto, user, workspace, provenance);
|
||||||
|
|
||||||
|
// Owner short-circuits the admin lookup.
|
||||||
|
expect(spaceAbility.createForUser).not.toHaveBeenCalled();
|
||||||
|
expect(commentService.dismissSuggestion).toHaveBeenCalledWith(
|
||||||
|
comment,
|
||||||
|
user,
|
||||||
|
provenance,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-owner non-admin → Forbidden AND the service is never called', async () => {
|
||||||
|
const { controller, commentRepo, pageRepo, commentService, spaceAbility } =
|
||||||
|
makeController(false); // NOT a space admin
|
||||||
|
commentRepo.findById.mockResolvedValue({
|
||||||
|
...comment,
|
||||||
|
creatorId: 'someone-else',
|
||||||
|
});
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
controller.dismissSuggestion(dto, user, workspace, provenance),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
|
||||||
|
expect(spaceAbility.createForUser).toHaveBeenCalledWith(user, comment.spaceId);
|
||||||
|
expect(commentService.dismissSuggestion).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-owner space admin → allowed to dismiss another user’s suggestion', async () => {
|
||||||
|
const { controller, commentRepo, pageRepo, commentService, spaceAbility } =
|
||||||
|
makeController(true); // space admin
|
||||||
|
commentRepo.findById.mockResolvedValue({
|
||||||
|
...comment,
|
||||||
|
creatorId: 'someone-else',
|
||||||
|
});
|
||||||
|
pageRepo.findById.mockResolvedValue(page);
|
||||||
|
|
||||||
|
await controller.dismissSuggestion(dto, user, workspace, provenance);
|
||||||
|
|
||||||
|
expect(spaceAbility.createForUser).toHaveBeenCalledWith(user, comment.spaceId);
|
||||||
|
expect(commentService.dismissSuggestion).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { CreateCommentDto } from './dto/create-comment.dto';
|
|||||||
import { UpdateCommentDto } from './dto/update-comment.dto';
|
import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||||
import { ResolveCommentDto } from './dto/resolve-comment.dto';
|
import { ResolveCommentDto } from './dto/resolve-comment.dto';
|
||||||
import { ApplySuggestionDto } from './dto/apply-suggestion.dto';
|
import { ApplySuggestionDto } from './dto/apply-suggestion.dto';
|
||||||
|
import { DismissSuggestionDto } from './dto/dismiss-suggestion.dto';
|
||||||
import { PageIdDto, CommentIdDto } from './dto/comments.input';
|
import { PageIdDto, CommentIdDto } from './dto/comments.input';
|
||||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||||
@@ -234,6 +235,59 @@ export class CommentController {
|
|||||||
return this.commentService.applySuggestion(comment, user, provenance);
|
return this.commentService.applySuggestion(comment, user, provenance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('dismiss-suggestion')
|
||||||
|
async dismissSuggestion(
|
||||||
|
@Body() dto: DismissSuggestionDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
@AuthProvenance() provenance: AuthProvenanceData,
|
||||||
|
) {
|
||||||
|
const comment = await this.commentRepo.findById(dto.commentId, {
|
||||||
|
includeCreator: true,
|
||||||
|
includeResolvedBy: true,
|
||||||
|
});
|
||||||
|
if (!comment) {
|
||||||
|
throw new NotFoundException('Comment not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = await this.pageRepo.findById(comment.pageId);
|
||||||
|
if (!page || page.deletedAt) {
|
||||||
|
throw new NotFoundException('Page not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorize BEFORE revealing any structural detail (metadata-disclosure
|
||||||
|
// hygiene, mirroring apply-suggestion). Dismissing a suggestion does NOT
|
||||||
|
// change the page text — it only removes/resolves the comment — so the
|
||||||
|
// page-level gate is comment access (canComment), NOT edit access. A viewer
|
||||||
|
// allowed to comment but not edit can still dismiss their own suggestion.
|
||||||
|
// The structural 400s (top-level / has-a-suggested-edit / not applied /
|
||||||
|
// not resolved) are re-checked by the service below.
|
||||||
|
await this.pageAccessService.validateCanComment(page, user, workspace.id);
|
||||||
|
|
||||||
|
// AUTHZ (#338): a childless dismiss IRREVERSIBLY hard-deletes the comment,
|
||||||
|
// so — beyond canComment — restrict it to the comment owner OR a space
|
||||||
|
// admin, exactly like POST /comments/delete. canComment alone is not enough:
|
||||||
|
// it would let any bystander commenter erase another user's suggestion for
|
||||||
|
// good. (apply-suggestion deliberately stays on canEdit: accepting an edit
|
||||||
|
// is the editor's semantics, not the suggestion author's.)
|
||||||
|
const isOwner = comment.creatorId === user.id;
|
||||||
|
if (!isOwner) {
|
||||||
|
const ability = await this.spaceAbility.createForUser(
|
||||||
|
user,
|
||||||
|
comment.spaceId,
|
||||||
|
);
|
||||||
|
// Space admin can dismiss any suggestion.
|
||||||
|
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'You can only dismiss your own suggestions',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.commentService.dismissSuggestion(comment, user, provenance);
|
||||||
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('delete')
|
@Post('delete')
|
||||||
async delete(@Body() input: CommentIdDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) {
|
async delete(@Body() input: CommentIdDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) {
|
||||||
|
|||||||
@@ -13,17 +13,27 @@ import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
|||||||
*
|
*
|
||||||
* The collaboration gateway verdict is the pivot of the whole flow, so each test
|
* The collaboration gateway verdict is the pivot of the whole flow, so each test
|
||||||
* pins a specific { applied, currentText } and asserts the DB persistence,
|
* pins a specific { applied, currentText } and asserts the DB persistence,
|
||||||
* auto-resolve, audit, ws broadcast, and error mapping that follow from it.
|
* settle (ephemeral delete vs. resolve), audit, ws broadcast, and error mapping
|
||||||
|
* that follow from it.
|
||||||
|
*
|
||||||
|
* Ephemeral rule (#329): once applied a suggestion DISAPPEARS (hard-delete +
|
||||||
|
* strip the inline anchor mark) UNLESS the thread has replies, in which case it
|
||||||
|
* is resolved to preserve the discussion. `hasChildren` selects the branch.
|
||||||
*/
|
*/
|
||||||
describe('CommentService — applySuggestion', () => {
|
describe('CommentService — applySuggestion', () => {
|
||||||
const UPDATED = { id: 'c-1', __updated: true } as any;
|
const UPDATED = { id: 'c-1', __updated: true } as any;
|
||||||
|
|
||||||
function makeService(verdict: unknown) {
|
function makeService(verdict: unknown, hasChildren = false, deletedRows = 1) {
|
||||||
const commentRepo: any = {
|
const commentRepo: any = {
|
||||||
// Both the applied-stamp re-read and resolveComment's re-read go through
|
// Both the applied-stamp re-read and resolveComment's re-read go through
|
||||||
// findById; return a recognizable enriched row.
|
// findById; return a recognizable enriched row.
|
||||||
findById: jest.fn(async () => UPDATED),
|
findById: jest.fn(async () => UPDATED),
|
||||||
updateComment: jest.fn(async () => undefined),
|
updateComment: jest.fn(async () => undefined),
|
||||||
|
hasChildren: jest.fn(async () => hasChildren),
|
||||||
|
deleteComment: jest.fn(async () => undefined),
|
||||||
|
// #338 F1: the childless ephemeral delete is atomic-conditional and
|
||||||
|
// returns the number of rows removed (1 = deleted, 0 = a reply raced in).
|
||||||
|
deleteCommentIfChildless: jest.fn(async () => deletedRows),
|
||||||
};
|
};
|
||||||
const pageRepo: any = {};
|
const pageRepo: any = {};
|
||||||
const wsService: any = { emitCommentEvent: jest.fn() };
|
const wsService: any = { emitCommentEvent: jest.fn() };
|
||||||
@@ -74,7 +84,9 @@ describe('CommentService — applySuggestion', () => {
|
|||||||
.map((c: any[]) => c[0])
|
.map((c: any[]) => c[0])
|
||||||
.find((patch: any) => 'suggestionAppliedAt' in patch);
|
.find((patch: any) => 'suggestionAppliedAt' in patch);
|
||||||
|
|
||||||
it('applied=true → replaces text, persists applied stamps, auto-resolves, audits, returns updated', async () => {
|
// --- no replies → ephemeral delete branch -------------------------------
|
||||||
|
|
||||||
|
it('applied=true, no replies → replaces text, hard-deletes, strips the anchor mark, audits APPLIED, outcome=deleted', async () => {
|
||||||
const { service, commentRepo, wsService, collaborationGateway, auditService } =
|
const { service, commentRepo, wsService, collaborationGateway, auditService } =
|
||||||
makeService({ applied: true, currentText: 'new text' });
|
makeService({ applied: true, currentText: 'new text' });
|
||||||
|
|
||||||
@@ -92,37 +104,34 @@ describe('CommentService — applySuggestion', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Applied stamps persisted.
|
// Ephemeral: the redundant comment is hard-deleted (atomic-conditional) and
|
||||||
const patch = appliedPatch(commentRepo);
|
// its inline anchor mark removed via the deleteCommentMark collab event.
|
||||||
expect(patch.suggestionAppliedAt).toBeInstanceOf(Date);
|
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||||
expect(patch.suggestionAppliedById).toBe('user-1');
|
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||||
|
'deleteCommentMark',
|
||||||
|
'page.page-1',
|
||||||
|
expect.objectContaining({ commentId: 'c-1', user: expect.any(Object) }),
|
||||||
|
);
|
||||||
|
// No applied stamps are written for a row about to be deleted.
|
||||||
|
expect(appliedPatch(commentRepo)).toBeUndefined();
|
||||||
|
|
||||||
// Auto-resolved: resolveComment writes a resolvedAt/resolvedById patch too.
|
// Broadcast a deletion, audit the (still-applied) suggestion, report outcome.
|
||||||
const resolvePatch = commentRepo.updateComment.mock.calls
|
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
|
||||||
.map((c: any[]) => c[0])
|
'space-1',
|
||||||
.find((p: any) => 'resolvedAt' in p);
|
'page-1',
|
||||||
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
expect.objectContaining({ operation: 'commentDeleted', commentId: 'c-1' }),
|
||||||
expect(resolvePatch.resolvedById).toBe('user-1');
|
);
|
||||||
|
|
||||||
// Audit + broadcast + return.
|
|
||||||
expect(auditService.log).toHaveBeenCalledWith(
|
expect(auditService.log).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
||||||
resourceType: AuditResource.COMMENT,
|
resourceType: AuditResource.COMMENT,
|
||||||
resourceId: 'c-1',
|
resourceId: 'c-1',
|
||||||
spaceId: 'space-1',
|
|
||||||
metadata: { pageId: 'page-1' },
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
|
expect(result.outcome).toBe('deleted');
|
||||||
'space-1',
|
|
||||||
'page-1',
|
|
||||||
expect.objectContaining({ operation: 'commentUpdated', comment: UPDATED }),
|
|
||||||
);
|
|
||||||
expect(result).toBe(UPDATED);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applied=false but currentText === suggestedText → idempotent success (no 409)', async () => {
|
it('applied=false but currentText === suggestedText, no replies → idempotent delete (no 409)', async () => {
|
||||||
const { service, commentRepo, auditService } = makeService({
|
const { service, commentRepo, auditService } = makeService({
|
||||||
applied: false,
|
applied: false,
|
||||||
currentText: 'new text',
|
currentText: 'new text',
|
||||||
@@ -130,15 +139,55 @@ describe('CommentService — applySuggestion', () => {
|
|||||||
|
|
||||||
const result = await service.applySuggestion(suggestionComment(), user());
|
const result = await service.applySuggestion(suggestionComment(), user());
|
||||||
|
|
||||||
// The stamps are still persisted (reconciling a crash between the doc
|
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||||
// mutation and the DB write) and the call succeeds.
|
expect(auditService.log).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.outcome).toBe('deleted');
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- has replies → resolve branch (discussion preserved) ----------------
|
||||||
|
|
||||||
|
it('applied=true, WITH replies → resolves (not delete), persists applied stamps, audits, outcome=resolved', async () => {
|
||||||
|
const { service, commentRepo, wsService, collaborationGateway, auditService } =
|
||||||
|
makeService({ applied: true, currentText: 'new text' }, true);
|
||||||
|
|
||||||
|
const result = await service.applySuggestion(suggestionComment(), user());
|
||||||
|
|
||||||
|
// Applied stamps persisted.
|
||||||
const patch = appliedPatch(commentRepo);
|
const patch = appliedPatch(commentRepo);
|
||||||
expect(patch.suggestionAppliedAt).toBeInstanceOf(Date);
|
expect(patch.suggestionAppliedAt).toBeInstanceOf(Date);
|
||||||
expect(patch.suggestionAppliedById).toBe('user-1');
|
expect(patch.suggestionAppliedById).toBe('user-1');
|
||||||
expect(auditService.log).toHaveBeenCalledTimes(1);
|
|
||||||
expect(result).toBe(UPDATED);
|
// Auto-resolved (resolveComment writes the resolve patch + resolve mark).
|
||||||
|
const resolvePatch = commentRepo.updateComment.mock.calls
|
||||||
|
.map((c: any[]) => c[0])
|
||||||
|
.find((p: any) => 'resolvedAt' in p);
|
||||||
|
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
||||||
|
expect(resolvePatch.resolvedById).toBe('user-1');
|
||||||
|
|
||||||
|
// NOT deleted; broadcast an update, not a deletion.
|
||||||
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
|
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
|
||||||
|
'deleteCommentMark',
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
|
||||||
|
'space-1',
|
||||||
|
'page-1',
|
||||||
|
expect.objectContaining({ operation: 'commentUpdated', comment: UPDATED }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(auditService.log).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.id).toBe('c-1');
|
||||||
|
expect(result.outcome).toBe('resolved');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- error / rejection branches -----------------------------------------
|
||||||
|
|
||||||
it('applied=false and currentText differs → ConflictException with currentText in payload', async () => {
|
it('applied=false and currentText differs → ConflictException with currentText in payload', async () => {
|
||||||
const { service, commentRepo, auditService } = makeService({
|
const { service, commentRepo, auditService } = makeService({
|
||||||
applied: false,
|
applied: false,
|
||||||
@@ -153,14 +202,14 @@ describe('CommentService — applySuggestion', () => {
|
|||||||
expect(err.getResponse()).toMatchObject({
|
expect(err.getResponse()).toMatchObject({
|
||||||
currentText: 'someone else edited this',
|
currentText: 'someone else edited this',
|
||||||
});
|
});
|
||||||
// No persistence and no audit on a conflict.
|
// No delete and no audit on a conflict.
|
||||||
expect(appliedPatch(commentRepo)).toBeUndefined();
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
expect(auditService.log).not.toHaveBeenCalled();
|
expect(auditService.log).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('already-applied AND already-resolved → idempotent success, no collab call, no re-resolve (#315 double-click)', async () => {
|
it('already-applied WITH replies → idempotent success, no re-apply, resolve branch', async () => {
|
||||||
const { service, collaborationGateway, commentRepo, auditService } =
|
const { service, collaborationGateway, commentRepo, auditService } =
|
||||||
makeService({ applied: true, currentText: 'new text' });
|
makeService({ applied: true, currentText: 'new text' }, true);
|
||||||
|
|
||||||
const result = await service.applySuggestion(
|
const result = await service.applySuggestion(
|
||||||
suggestionComment({
|
suggestionComment({
|
||||||
@@ -171,17 +220,20 @@ describe('CommentService — applySuggestion', () => {
|
|||||||
user(),
|
user(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Idempotent SUCCESS, not a 409. The suggestion is already applied, so the
|
// Idempotent SUCCESS. The suggestion is already applied, so the document is
|
||||||
// collaborative document is never touched again and nothing is re-stamped
|
// never re-mutated (no applyCommentSuggestion) and nothing is re-stamped.
|
||||||
// or re-resolved.
|
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
|
||||||
expect(result).toBe(UPDATED);
|
'applyCommentSuggestion',
|
||||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalled();
|
expect.anything(),
|
||||||
expect(commentRepo.updateComment).not.toHaveBeenCalled();
|
expect.anything(),
|
||||||
// Same success shape as the applied path (broadcast + audit).
|
);
|
||||||
|
expect(appliedPatch(commentRepo)).toBeUndefined();
|
||||||
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
expect(auditService.log).toHaveBeenCalledTimes(1);
|
expect(auditService.log).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.outcome).toBe('resolved');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('already-applied but NOT resolved (crash window) → idempotent success, self-heals resolve, no re-apply', async () => {
|
it('already-applied, no replies (double-click after a delete) → deletes idempotently', async () => {
|
||||||
const { service, collaborationGateway, commentRepo } = makeService({
|
const { service, collaborationGateway, commentRepo } = makeService({
|
||||||
applied: true,
|
applied: true,
|
||||||
currentText: 'new text',
|
currentText: 'new text',
|
||||||
@@ -192,28 +244,43 @@ describe('CommentService — applySuggestion', () => {
|
|||||||
user(),
|
user(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toBe(UPDATED);
|
// No re-apply to the document; the childless applied comment is removed.
|
||||||
|
|
||||||
// The suggestion is NOT re-applied to the document…
|
|
||||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
|
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
|
||||||
'applyCommentSuggestion',
|
'applyCommentSuggestion',
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
);
|
);
|
||||||
// …but the open thread is self-healed to resolved via resolveComment, which
|
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||||
// writes the resolve patch and updates the resolve mark.
|
expect(result.outcome).toBe('deleted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applied=true, no replies at read time but a reply races in (conditional delete → 0 rows) → resolves instead, no hard-delete, outcome=resolved (#338 F1)', async () => {
|
||||||
|
// The suggested text is already applied to the document, but between the
|
||||||
|
// hasChildren read and the atomic delete a reply landed. The parent must NOT
|
||||||
|
// be hard-deleted (cascade would destroy the reply); resolve the thread.
|
||||||
|
const { service, commentRepo, wsService, collaborationGateway } =
|
||||||
|
makeService({ applied: true, currentText: 'new text' }, false, 0);
|
||||||
|
|
||||||
|
const result = await service.applySuggestion(suggestionComment(), user());
|
||||||
|
|
||||||
|
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||||
|
// No deletion broadcast — the row + the racing reply survive.
|
||||||
|
expect(wsService.emitCommentEvent).not.toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ operation: 'commentDeleted' }),
|
||||||
|
);
|
||||||
|
// Fell back to resolving.
|
||||||
const resolvePatch = commentRepo.updateComment.mock.calls
|
const resolvePatch = commentRepo.updateComment.mock.calls
|
||||||
.map((c: any[]) => c[0])
|
.map((c: any[]) => c[0])
|
||||||
.find((p: any) => 'resolvedAt' in p);
|
.find((p: any) => 'resolvedAt' in p);
|
||||||
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
||||||
expect(resolvePatch.resolvedById).toBe('user-1');
|
|
||||||
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||||
'resolveCommentMark',
|
'resolveCommentMark',
|
||||||
'page.page-1',
|
'page.page-1',
|
||||||
expect.objectContaining({ commentId: 'c-1', resolved: true }),
|
expect.objectContaining({ commentId: 'c-1', resolved: true }),
|
||||||
);
|
);
|
||||||
// The applied stamps are NOT re-written (already stamped).
|
expect(result.outcome).toBe('resolved');
|
||||||
expect(appliedPatch(commentRepo)).toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a comment with no suggestedText', async () => {
|
it('rejects a comment with no suggestedText', async () => {
|
||||||
@@ -238,8 +305,8 @@ describe('CommentService — applySuggestion', () => {
|
|||||||
service.applySuggestion(suggestionComment(), user()),
|
service.applySuggestion(suggestionComment(), user()),
|
||||||
).rejects.toThrow(InternalServerErrorException);
|
).rejects.toThrow(InternalServerErrorException);
|
||||||
|
|
||||||
// Nothing persisted, nothing audited.
|
// Nothing deleted, nothing audited.
|
||||||
expect(appliedPatch(commentRepo)).toBeUndefined();
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
expect(auditService.log).not.toHaveBeenCalled();
|
expect(auditService.log).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { CommentService } from './comment.service';
|
||||||
|
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coverage for CommentService.dismissSuggestion (#329). Dismiss ("Не применять")
|
||||||
|
* removes a suggested edit WITHOUT changing the page text: the comment
|
||||||
|
* disappears (hard-delete + strip the inline anchor mark) unless the thread has
|
||||||
|
* replies, in which case it is resolved to preserve the discussion.
|
||||||
|
*
|
||||||
|
* The permission gate (canComment, NOT canEdit) lives in the controller and is
|
||||||
|
* covered in comment.controller.spec.ts; here we pin the service's own state
|
||||||
|
* guards and the delete-vs-resolve fork.
|
||||||
|
*/
|
||||||
|
describe('CommentService — dismissSuggestion', () => {
|
||||||
|
const UPDATED = { id: 'c-1', __updated: true } as any;
|
||||||
|
|
||||||
|
function makeService(hasChildren = false, deletedRows = 1) {
|
||||||
|
const commentRepo: any = {
|
||||||
|
findById: jest.fn(async () => UPDATED),
|
||||||
|
updateComment: jest.fn(async () => undefined),
|
||||||
|
hasChildren: jest.fn(async () => hasChildren),
|
||||||
|
deleteComment: jest.fn(async () => undefined),
|
||||||
|
// #338 F1: the childless ephemeral delete is now atomic-conditional and
|
||||||
|
// returns the number of rows removed (1 = deleted, 0 = a reply raced in).
|
||||||
|
deleteCommentIfChildless: jest.fn(async () => deletedRows),
|
||||||
|
};
|
||||||
|
const pageRepo: any = {};
|
||||||
|
const wsService: any = { emitCommentEvent: jest.fn() };
|
||||||
|
const collaborationGateway: any = {
|
||||||
|
handleYjsEvent: jest.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
const generalQueue: any = { add: jest.fn(() => Promise.resolve()) };
|
||||||
|
const notificationQueue: any = { add: jest.fn(async () => undefined) };
|
||||||
|
const auditService: any = { log: jest.fn() };
|
||||||
|
|
||||||
|
const service = new CommentService(
|
||||||
|
commentRepo,
|
||||||
|
pageRepo,
|
||||||
|
wsService,
|
||||||
|
collaborationGateway,
|
||||||
|
generalQueue,
|
||||||
|
notificationQueue,
|
||||||
|
auditService,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { service, commentRepo, wsService, collaborationGateway, auditService };
|
||||||
|
}
|
||||||
|
|
||||||
|
const suggestionComment = (over?: Partial<any>): any => ({
|
||||||
|
id: 'c-1',
|
||||||
|
pageId: 'page-1',
|
||||||
|
spaceId: 'space-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
creatorId: 'user-1',
|
||||||
|
parentCommentId: null,
|
||||||
|
selection: 'old text',
|
||||||
|
suggestedText: 'new text',
|
||||||
|
suggestionAppliedAt: null,
|
||||||
|
resolvedAt: null,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
const user = (over?: Partial<any>): any => ({ id: 'user-1', ...over });
|
||||||
|
|
||||||
|
it('no replies → hard-deletes, strips the anchor mark, does NOT touch page text, audits DISMISSED, outcome=deleted', async () => {
|
||||||
|
const { service, commentRepo, wsService, collaborationGateway, auditService } =
|
||||||
|
makeService(false);
|
||||||
|
|
||||||
|
const result = await service.dismissSuggestion(suggestionComment(), user());
|
||||||
|
|
||||||
|
// Never applies the suggestion to the document.
|
||||||
|
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
|
||||||
|
'applyCommentSuggestion',
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
// Hard-delete (atomic-conditional) + strip mark.
|
||||||
|
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||||
|
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||||
|
'deleteCommentMark',
|
||||||
|
'page.page-1',
|
||||||
|
expect.objectContaining({ commentId: 'c-1', user: expect.any(Object) }),
|
||||||
|
);
|
||||||
|
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
|
||||||
|
'space-1',
|
||||||
|
'page-1',
|
||||||
|
expect.objectContaining({ operation: 'commentDeleted', commentId: 'c-1' }),
|
||||||
|
);
|
||||||
|
expect(auditService.log).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
event: AuditEvent.COMMENT_SUGGESTION_DISMISSED,
|
||||||
|
resourceType: AuditResource.COMMENT,
|
||||||
|
resourceId: 'c-1',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.outcome).toBe('deleted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no replies → if the anchor-mark removal FAILS, the row is NOT deleted and the error propagates (#329: no orphan anchor)', async () => {
|
||||||
|
const { service, commentRepo, wsService, collaborationGateway } =
|
||||||
|
makeService(false);
|
||||||
|
// Mark removal is FATAL and runs BEFORE the irreversible row delete: a collab
|
||||||
|
// failure (e.g. COLLAB_DISABLE_REDIS "no live instance") must abort the whole
|
||||||
|
// operation, leaving row + mark consistent — never a deleted row with an
|
||||||
|
// orphan anchor left in the document reporting success.
|
||||||
|
collaborationGateway.handleYjsEvent = jest.fn(async () => {
|
||||||
|
throw new Error('requires a live collaboration instance');
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.dismissSuggestion(suggestionComment(), user()),
|
||||||
|
).rejects.toThrow(/live collaboration/);
|
||||||
|
|
||||||
|
expect(commentRepo.deleteCommentIfChildless).not.toHaveBeenCalled();
|
||||||
|
expect(wsService.emitCommentEvent).not.toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ operation: 'commentDeleted' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('WITH replies → resolves (not delete), does NOT apply, audits DISMISSED, outcome=resolved', async () => {
|
||||||
|
const { service, commentRepo, wsService, collaborationGateway, auditService } =
|
||||||
|
makeService(true);
|
||||||
|
|
||||||
|
const result = await service.dismissSuggestion(suggestionComment(), user());
|
||||||
|
|
||||||
|
// Resolved via resolveComment (resolve patch + resolve mark), NOT deleted.
|
||||||
|
const resolvePatch = commentRepo.updateComment.mock.calls
|
||||||
|
.map((c: any[]) => c[0])
|
||||||
|
.find((p: any) => 'resolvedAt' in p);
|
||||||
|
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
||||||
|
expect(resolvePatch.resolvedById).toBe('user-1');
|
||||||
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
|
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||||
|
'resolveCommentMark',
|
||||||
|
'page.page-1',
|
||||||
|
expect.objectContaining({ commentId: 'c-1', resolved: true }),
|
||||||
|
);
|
||||||
|
// No applied stamp — dismiss does not apply the edit.
|
||||||
|
const appliedPatch = commentRepo.updateComment.mock.calls
|
||||||
|
.map((c: any[]) => c[0])
|
||||||
|
.find((p: any) => 'suggestionAppliedAt' in p);
|
||||||
|
expect(appliedPatch).toBeUndefined();
|
||||||
|
|
||||||
|
expect(auditService.log).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
event: AuditEvent.COMMENT_SUGGESTION_DISMISSED,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.outcome).toBe('resolved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reply races in after the childless read (conditional delete → 0 rows) → resolves instead, does NOT hard-delete, reply survives, outcome=resolved (#338 F1)', async () => {
|
||||||
|
// hasChildren=false selects the ephemeral branch (the read saw no replies),
|
||||||
|
// but the atomic delete matches 0 rows because a reply landed in the window
|
||||||
|
// between that read and the delete. The parent must NOT be hard-deleted
|
||||||
|
// (a cascade would destroy the just-added reply); the thread is resolved.
|
||||||
|
const { service, commentRepo, wsService, collaborationGateway } =
|
||||||
|
makeService(false, 0);
|
||||||
|
|
||||||
|
const result = await service.dismissSuggestion(suggestionComment(), user());
|
||||||
|
|
||||||
|
// The conditional delete was attempted (and matched nothing).
|
||||||
|
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||||
|
// No commentDeleted broadcast — the row (and the racing reply) survive.
|
||||||
|
expect(wsService.emitCommentEvent).not.toHaveBeenCalledWith(
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.objectContaining({ operation: 'commentDeleted' }),
|
||||||
|
);
|
||||||
|
// Fell back to resolving the thread.
|
||||||
|
const resolvePatch = commentRepo.updateComment.mock.calls
|
||||||
|
.map((c: any[]) => c[0])
|
||||||
|
.find((p: any) => 'resolvedAt' in p);
|
||||||
|
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
||||||
|
expect(resolvePatch.resolvedById).toBe('user-1');
|
||||||
|
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||||
|
'resolveCommentMark',
|
||||||
|
'page.page-1',
|
||||||
|
expect.objectContaining({ commentId: 'c-1', resolved: true }),
|
||||||
|
);
|
||||||
|
expect(result.outcome).toBe('resolved');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a reply (non-top-level) comment', async () => {
|
||||||
|
const { service, commentRepo } = makeService();
|
||||||
|
await expect(
|
||||||
|
service.dismissSuggestion(
|
||||||
|
suggestionComment({ parentCommentId: 'parent-1' }),
|
||||||
|
user(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a comment without a suggested edit', async () => {
|
||||||
|
const { service, commentRepo } = makeService();
|
||||||
|
await expect(
|
||||||
|
service.dismissSuggestion(
|
||||||
|
suggestionComment({ suggestedText: null }),
|
||||||
|
user(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an already-applied suggestion', async () => {
|
||||||
|
const { service, commentRepo } = makeService();
|
||||||
|
await expect(
|
||||||
|
service.dismissSuggestion(
|
||||||
|
suggestionComment({ suggestionAppliedAt: new Date() }),
|
||||||
|
user(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an already-resolved thread', async () => {
|
||||||
|
const { service, commentRepo } = makeService();
|
||||||
|
await expect(
|
||||||
|
service.dismissSuggestion(
|
||||||
|
suggestionComment({ resolvedAt: new Date() }),
|
||||||
|
user(),
|
||||||
|
),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,12 @@ import {
|
|||||||
IAuditService,
|
IAuditService,
|
||||||
} from '../../integrations/audit/audit.service';
|
} from '../../integrations/audit/audit.service';
|
||||||
|
|
||||||
|
// Ephemeral-suggestion settle result (#329): 'deleted' → the comment vanished
|
||||||
|
// (hard-delete + anchor mark stripped); 'resolved' → the thread had replies and
|
||||||
|
// was resolved instead. Returned to the client so it can pick the optimistic
|
||||||
|
// cache action.
|
||||||
|
export type SuggestionOutcome = 'deleted' | 'resolved';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CommentService {
|
export class CommentService {
|
||||||
private readonly logger = new Logger(CommentService.name);
|
private readonly logger = new Logger(CommentService.name);
|
||||||
@@ -362,7 +368,7 @@ export class CommentService {
|
|||||||
comment: Comment,
|
comment: Comment,
|
||||||
user: User,
|
user: User,
|
||||||
provenance?: AuthProvenanceData,
|
provenance?: AuthProvenanceData,
|
||||||
): Promise<Comment> {
|
): Promise<Comment & { outcome: SuggestionOutcome }> {
|
||||||
// Structural guards.
|
// Structural guards.
|
||||||
if (comment.parentCommentId) {
|
if (comment.parentCommentId) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
@@ -449,42 +455,148 @@ export class CommentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist the applied stamps (idempotently), auto-resolve the thread and
|
* Dismiss ("Не применять") a suggested edit without touching the page text:
|
||||||
* broadcast + audit the applied suggestion. Shared by the applied and the
|
* the suggestion disappears. Ephemeral rule (#329) — a top-level suggestion
|
||||||
|
* comment is transient UI, so dismissing it hard-deletes the comment AND strips
|
||||||
|
* its inline anchor mark UNLESS the thread has replies, in which case the
|
||||||
|
* discussion is preserved by resolving it instead.
|
||||||
|
*
|
||||||
|
* Dismiss does NOT change the document text, so the controller authorizes it
|
||||||
|
* with canComment (NOT canEdit). This re-checks the comment's own state so the
|
||||||
|
* invariant holds regardless of caller.
|
||||||
|
*/
|
||||||
|
async dismissSuggestion(
|
||||||
|
comment: Comment,
|
||||||
|
user: User,
|
||||||
|
provenance?: AuthProvenanceData,
|
||||||
|
): Promise<Comment & { outcome: SuggestionOutcome }> {
|
||||||
|
// Structural guards (mirror applySuggestion).
|
||||||
|
if (comment.parentCommentId) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Only a top-level comment can carry a suggested edit',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!comment.suggestedText) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'This comment has no suggested edit to dismiss',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// State guards: dismissing an already-applied or already-resolved thread is
|
||||||
|
// meaningless. On an apply↔dismiss race the loser sees the comment already
|
||||||
|
// gone (404 at the controller) or already resolved (this 400); the client
|
||||||
|
// treats both as "already resolved".
|
||||||
|
if (comment.suggestionAppliedAt) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Cannot dismiss a suggested edit that was already applied',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (comment.resolvedAt) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Cannot dismiss a suggested edit on a resolved comment thread',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasChildren = await this.commentRepo.hasChildren(comment.id);
|
||||||
|
|
||||||
|
if (hasChildren) {
|
||||||
|
// Preserve the discussion: resolve (never delete) a thread with replies.
|
||||||
|
const updatedComment = await this.resolveComment(
|
||||||
|
comment,
|
||||||
|
true,
|
||||||
|
user,
|
||||||
|
provenance,
|
||||||
|
);
|
||||||
|
this.auditService.log({
|
||||||
|
event: AuditEvent.COMMENT_SUGGESTION_DISMISSED,
|
||||||
|
resourceType: AuditResource.COMMENT,
|
||||||
|
resourceId: comment.id,
|
||||||
|
spaceId: comment.spaceId,
|
||||||
|
metadata: { pageId: comment.pageId },
|
||||||
|
});
|
||||||
|
return { ...updatedComment, outcome: 'resolved' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ephemeral: no replies → the suggestion vanishes entirely. The atomic
|
||||||
|
// conditional delete may still fall back to a resolve if a reply raced in
|
||||||
|
// (see deleteEphemeralSuggestion), so the outcome is whatever it settled on.
|
||||||
|
const settled = await this.deleteEphemeralSuggestion(comment, user, provenance);
|
||||||
|
this.auditService.log({
|
||||||
|
event: AuditEvent.COMMENT_SUGGESTION_DISMISSED,
|
||||||
|
resourceType: AuditResource.COMMENT,
|
||||||
|
resourceId: comment.id,
|
||||||
|
spaceId: comment.spaceId,
|
||||||
|
metadata: { pageId: comment.pageId },
|
||||||
|
});
|
||||||
|
return settled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the applied stamps (idempotently), then settle the suggestion under
|
||||||
|
* the ephemeral rule (#329): a suggestion whose thread has NO replies
|
||||||
|
* DISAPPEARS after apply (hard-delete + strip the inline anchor mark), since
|
||||||
|
* the suggested text is now in the document and a stand-alone resolved thread
|
||||||
|
* would only pile up an orphan anchor. A thread WITH replies is preserved by
|
||||||
|
* auto-resolving it (the historical behaviour). Shared by the applied and the
|
||||||
* idempotent "already-applied" branches of applySuggestion.
|
* idempotent "already-applied" branches of applySuggestion.
|
||||||
|
*
|
||||||
|
* Returns the comment augmented with `outcome` so the client can pick the
|
||||||
|
* optimistic action ('deleted' → drop it, 'resolved' → move to the resolved
|
||||||
|
* tab).
|
||||||
*/
|
*/
|
||||||
private async finalizeAppliedSuggestion(
|
private async finalizeAppliedSuggestion(
|
||||||
comment: Comment,
|
comment: Comment,
|
||||||
user: User,
|
user: User,
|
||||||
provenance?: AuthProvenanceData,
|
provenance?: AuthProvenanceData,
|
||||||
): Promise<Comment> {
|
): Promise<Comment & { outcome: SuggestionOutcome }> {
|
||||||
if (!comment.suggestionAppliedAt) {
|
const hasChildren = await this.commentRepo.hasChildren(comment.id);
|
||||||
await this.commentRepo.updateComment(
|
|
||||||
{
|
if (hasChildren) {
|
||||||
suggestionAppliedAt: new Date(),
|
// Thread has replies → preserve the discussion: stamp applied + resolve.
|
||||||
suggestionAppliedById: user.id,
|
if (!comment.suggestionAppliedAt) {
|
||||||
},
|
await this.commentRepo.updateComment(
|
||||||
comment.id,
|
{
|
||||||
);
|
suggestionAppliedAt: new Date(),
|
||||||
|
suggestionAppliedById: user.id,
|
||||||
|
},
|
||||||
|
comment.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-resolve the thread. resolveComment handles the resolve mark, its ws
|
||||||
|
// broadcast and the resolve notification. Stay defensive on re-entry.
|
||||||
|
if (!comment.resolvedAt) {
|
||||||
|
await this.resolveComment(comment, true, user, provenance);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedComment = await this.commentRepo.findById(comment.id, {
|
||||||
|
includeCreator: true,
|
||||||
|
includeResolvedBy: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
|
||||||
|
operation: 'commentUpdated',
|
||||||
|
pageId: comment.pageId,
|
||||||
|
comment: updatedComment,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.auditService.log({
|
||||||
|
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
||||||
|
resourceType: AuditResource.COMMENT,
|
||||||
|
resourceId: comment.id,
|
||||||
|
spaceId: comment.spaceId,
|
||||||
|
metadata: { pageId: comment.pageId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...updatedComment, outcome: 'resolved' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-resolve the thread. resolveComment handles the resolve mark, its ws
|
// No replies → ephemeral: the suggested text is already in the document, so
|
||||||
// broadcast and the resolve notification. The guard above guarantees the
|
// the comment is redundant. Hard-delete it and strip its inline anchor. We
|
||||||
// thread was open when we entered, but stay defensive on re-entry.
|
// deliberately do NOT write the applied stamps first (the row is about to be
|
||||||
if (!comment.resolvedAt) {
|
// deleted); the audit event still records that the suggestion was applied.
|
||||||
await this.resolveComment(comment, true, user, provenance);
|
// The delete is atomic-conditional: if a reply raced in after the
|
||||||
}
|
// hasChildren read, it falls back to resolving instead (outcome 'resolved').
|
||||||
|
const settled = await this.deleteEphemeralSuggestion(comment, user, provenance);
|
||||||
const updatedComment = await this.commentRepo.findById(comment.id, {
|
|
||||||
includeCreator: true,
|
|
||||||
includeResolvedBy: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
|
|
||||||
operation: 'commentUpdated',
|
|
||||||
pageId: comment.pageId,
|
|
||||||
comment: updatedComment,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.auditService.log({
|
this.auditService.log({
|
||||||
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
||||||
@@ -494,7 +606,86 @@ export class CommentService {
|
|||||||
metadata: { pageId: comment.pageId },
|
metadata: { pageId: comment.pageId },
|
||||||
});
|
});
|
||||||
|
|
||||||
return updatedComment;
|
return settled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settle an ephemeral suggestion whose thread looked childless: remove its
|
||||||
|
* inline `comment` anchor mark, then ATOMICALLY hard-delete the row only if it
|
||||||
|
* is still childless. Shared by the apply/dismiss no-replies branches (#329).
|
||||||
|
*
|
||||||
|
* ORDER MATTERS: the anchor mark is removed FIRST and FATALLY (mirrors
|
||||||
|
* applySuggestion, which mutates the doc before writing the DB). The row
|
||||||
|
* delete is irreversible, so if the mark removal fails — including the
|
||||||
|
* COLLAB_DISABLE_REDIS "no live instance" hard-error — we must NOT delete the
|
||||||
|
* row and report success, or the document is left with a permanent orphan
|
||||||
|
* anchor pointing at a comment that no longer exists (the exact data-integrity
|
||||||
|
* bug #329 targets). Let the exception propagate (→ 5xx); the operation is
|
||||||
|
* then repeatable with row + mark still consistent.
|
||||||
|
*
|
||||||
|
* RACE (#338 F4): the caller read `hasChildren` BEFORE the (slow) mark
|
||||||
|
* removal, so a reply can land in that window. `comments.parent_comment_id` is
|
||||||
|
* ON DELETE CASCADE, so an unconditional delete here would cascade-destroy the
|
||||||
|
* just-added reply forever. Instead we use `deleteCommentIfChildless`, which
|
||||||
|
* re-checks childlessness under a FOR UPDATE lock inside a transaction (a plain
|
||||||
|
* anti-join DELETE is NOT race-safe under READ COMMITTED — see the repo method
|
||||||
|
* docstring). If it removes the row (outcome 'deleted') we broadcast the
|
||||||
|
* deletion as before. If it removes 0 rows (a reply interleaved) we do NOT
|
||||||
|
* hard-delete — we resolve the thread instead (outcome 'resolved'), preserving
|
||||||
|
* the discussion and the new reply. The anchor mark is already gone by then, an
|
||||||
|
* accepted degradation: the thread lands in the resolved tab without its inline
|
||||||
|
* highlight — far better than losing a reply.
|
||||||
|
*/
|
||||||
|
private async deleteEphemeralSuggestion(
|
||||||
|
comment: Comment,
|
||||||
|
user: User,
|
||||||
|
provenance?: AuthProvenanceData,
|
||||||
|
): Promise<Comment & { outcome: SuggestionOutcome }> {
|
||||||
|
await this.deleteCommentMark(comment, user);
|
||||||
|
|
||||||
|
const deletedRows = await this.commentRepo.deleteCommentIfChildless(
|
||||||
|
comment.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deletedRows > 0) {
|
||||||
|
this.wsService.emitCommentEvent(comment.spaceId, comment.pageId, {
|
||||||
|
operation: 'commentDeleted',
|
||||||
|
pageId: comment.pageId,
|
||||||
|
commentId: comment.id,
|
||||||
|
});
|
||||||
|
return { ...comment, outcome: 'deleted' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reply interleaved between the hasChildren read and this delete, so the
|
||||||
|
// conditional delete matched nothing. Preserve the discussion + the new
|
||||||
|
// reply by resolving the thread instead of hard-deleting it. resolveComment
|
||||||
|
// handles the resolve patch, its ws broadcast and the resolve notification;
|
||||||
|
// its collab call is best-effort, so the already-stripped mark is fine.
|
||||||
|
const resolvedComment = await this.resolveComment(
|
||||||
|
comment,
|
||||||
|
true,
|
||||||
|
user,
|
||||||
|
provenance,
|
||||||
|
);
|
||||||
|
return { ...resolvedComment, outcome: 'resolved' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the inline `comment` mark for a comment from the collaborative
|
||||||
|
* document. FATAL, NOT best-effort: unlike resolveComment (which keeps the row,
|
||||||
|
* so a failed mark update is recoverable), this is used before an irreversible
|
||||||
|
* hard-delete, so the mark removal MUST succeed or throw. Under
|
||||||
|
* COLLAB_DISABLE_REDIS the gateway invokes the deleteCommentMark handler
|
||||||
|
* directly (never a silent no-op) and a missing live instance surfaces as a
|
||||||
|
* thrown error, which we let propagate so the caller aborts before deleting.
|
||||||
|
*/
|
||||||
|
private async deleteCommentMark(comment: Comment, user: User): Promise<void> {
|
||||||
|
const documentName = `page.${comment.pageId}`;
|
||||||
|
await this.collaborationGateway.handleYjsEvent(
|
||||||
|
'deleteCommentMark',
|
||||||
|
documentName,
|
||||||
|
{ commentId: comment.id, user },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async queueCommentNotification(
|
private async queueCommentNotification(
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class DismissSuggestionDto {
|
||||||
|
@IsUUID()
|
||||||
|
commentId: string;
|
||||||
|
}
|
||||||
@@ -139,6 +139,65 @@ export class CommentRepo {
|
|||||||
await this.db.deleteFrom('comments').where('id', '=', commentId).execute();
|
await this.db.deleteFrom('comments').where('id', '=', commentId).execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an ephemeral suggestion row ONLY if it is still childless, returning
|
||||||
|
* the number of rows removed (0 or 1). Closes the data-loss race in
|
||||||
|
* dismiss/apply (#338 F4): the service reads `hasChildren`, then removes the
|
||||||
|
* anchor mark (a collab round-trip of tens-to-hundreds of ms), then calls this.
|
||||||
|
* `comments.parent_comment_id` is ON DELETE CASCADE, so a reply landing in that
|
||||||
|
* window would be cascade-destroyed by a blind delete.
|
||||||
|
*
|
||||||
|
* A single anti-join `DELETE … WHERE NOT EXISTS(child)` is NOT sufficient under
|
||||||
|
* READ COMMITTED: if a reply INSERT (holding FOR KEY SHARE on the parent, not
|
||||||
|
* yet committed) interleaves, the DELETE's snapshot does not see the
|
||||||
|
* uncommitted child, so `NOT EXISTS` is true and the parent qualifies; the
|
||||||
|
* DELETE then blocks on the child's key-share lock, and when it wakes the row
|
||||||
|
* was only LOCKED (not modified), so EvalPlanQual does NOT re-evaluate the
|
||||||
|
* predicate → the parent is deleted and the just-committed reply cascades away.
|
||||||
|
*
|
||||||
|
* So we do a lock-then-recheck in ONE transaction:
|
||||||
|
* 1. `SELECT id … FOR UPDATE` on the parent. FOR UPDATE conflicts with the
|
||||||
|
* FOR KEY SHARE a concurrent reply INSERT takes on its parent (FK), so a
|
||||||
|
* reply in the window serializes against us: it either commits before we
|
||||||
|
* acquire the lock, or it must wait until this tx ends.
|
||||||
|
* 2. Re-read childlessness with a FRESH statement in the SAME tx. Under RC a
|
||||||
|
* new statement gets a new snapshot, so a reply that committed while we
|
||||||
|
* waited on the lock is now visible.
|
||||||
|
* 3. Delete only if still childless (return 1); otherwise return 0 so the
|
||||||
|
* caller resolves the thread instead. The FOR UPDATE lock is held to
|
||||||
|
* end-of-tx, so no new reply can insert between the re-check and the delete.
|
||||||
|
*/
|
||||||
|
async deleteCommentIfChildless(commentId: string): Promise<number> {
|
||||||
|
return this.db.transaction().execute(async (trx) => {
|
||||||
|
const parent = await trx
|
||||||
|
.selectFrom('comments')
|
||||||
|
.select('id')
|
||||||
|
.where('id', '=', commentId)
|
||||||
|
.forUpdate()
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
// Already gone (e.g. a racing delete won) → nothing to remove.
|
||||||
|
if (!parent) return 0;
|
||||||
|
|
||||||
|
const child = await trx
|
||||||
|
.selectFrom('comments')
|
||||||
|
.select('id')
|
||||||
|
.where('parentCommentId', '=', commentId)
|
||||||
|
.limit(1)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
// A reply exists (possibly one that just committed) → do NOT hard-delete;
|
||||||
|
// the cascade would destroy it. Caller falls back to resolving the thread.
|
||||||
|
if (child) return 0;
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.deleteFrom('comments')
|
||||||
|
.where('id', '=', commentId)
|
||||||
|
.execute();
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async hasChildren(commentId: string): Promise<boolean> {
|
async hasChildren(commentId: string): Promise<boolean> {
|
||||||
const result = await this.db
|
const result = await this.db
|
||||||
.selectFrom('comments')
|
.selectFrom('comments')
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { Kysely } from 'kysely';
|
||||||
|
import { CommentRepo } from '../../src/database/repos/comment/comment.repo';
|
||||||
|
import {
|
||||||
|
getTestDb,
|
||||||
|
destroyTestDb,
|
||||||
|
buildTestDb,
|
||||||
|
createWorkspace,
|
||||||
|
createSpace,
|
||||||
|
createPage,
|
||||||
|
createUser,
|
||||||
|
createComment,
|
||||||
|
} from './db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Real-DB coverage for CommentRepo.deleteCommentIfChildless (#338 F4/F6).
|
||||||
|
*
|
||||||
|
* This is the guard that keeps an ephemeral-suggestion hard-delete from
|
||||||
|
* cascade-destroying a reply (`comments.parent_comment_id` is ON DELETE CASCADE).
|
||||||
|
* The unit tests MOCK this method to 0/1, so only an int-spec actually exercises
|
||||||
|
* the SQL — the FOR UPDATE lock-then-recheck transaction — against Postgres.
|
||||||
|
*
|
||||||
|
* The concurrency case is the whole point: a plain anti-join
|
||||||
|
* `DELETE … WHERE NOT EXISTS(child)` passes (a) and (b) but SILENTLY loses a
|
||||||
|
* reply that commits mid-operation under READ COMMITTED (EvalPlanQual does not
|
||||||
|
* re-check a merely-locked row). Test (c) reproduces exactly that interleaving
|
||||||
|
* and asserts the row + reply both survive.
|
||||||
|
*/
|
||||||
|
describe('CommentRepo.deleteCommentIfChildless [integration]', () => {
|
||||||
|
let db: Kysely<any>;
|
||||||
|
let repo: CommentRepo;
|
||||||
|
let workspaceId: string;
|
||||||
|
let spaceId: string;
|
||||||
|
let pageId: string;
|
||||||
|
let userId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
db = getTestDb();
|
||||||
|
repo = new CommentRepo(db as any);
|
||||||
|
workspaceId = (await createWorkspace(db)).id;
|
||||||
|
spaceId = (await createSpace(db, workspaceId)).id;
|
||||||
|
pageId = (await createPage(db, { workspaceId, spaceId })).id;
|
||||||
|
userId = (await createUser(db, workspaceId)).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await destroyTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function rowExists(id: string): Promise<boolean> {
|
||||||
|
const row = await db
|
||||||
|
.selectFrom('comments')
|
||||||
|
.select('id')
|
||||||
|
.where('id', '=', id)
|
||||||
|
.executeTakeFirst();
|
||||||
|
return Boolean(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedTopLevel() {
|
||||||
|
return createComment(db, {
|
||||||
|
workspaceId,
|
||||||
|
spaceId,
|
||||||
|
pageId,
|
||||||
|
creatorId: userId,
|
||||||
|
selection: 'old text',
|
||||||
|
suggestedText: 'new text',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedReply(parentId: string) {
|
||||||
|
return createComment(db, {
|
||||||
|
workspaceId,
|
||||||
|
spaceId,
|
||||||
|
pageId,
|
||||||
|
creatorId: userId,
|
||||||
|
parentCommentId: parentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('(a) childless top-level → returns 1 and the row is gone', async () => {
|
||||||
|
const parent = await seedTopLevel();
|
||||||
|
expect(await rowExists(parent.id)).toBe(true);
|
||||||
|
|
||||||
|
const deleted = await repo.deleteCommentIfChildless(parent.id);
|
||||||
|
|
||||||
|
expect(deleted).toBe(1);
|
||||||
|
expect(await rowExists(parent.id)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(b) top-level WITH a committed reply → returns 0, parent AND reply survive (gate blocks the cascade)', async () => {
|
||||||
|
const parent = await seedTopLevel();
|
||||||
|
const reply = await seedReply(parent.id);
|
||||||
|
|
||||||
|
const deleted = await repo.deleteCommentIfChildless(parent.id);
|
||||||
|
|
||||||
|
expect(deleted).toBe(0);
|
||||||
|
expect(await rowExists(parent.id)).toBe(true);
|
||||||
|
expect(await rowExists(reply.id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('(c) reply COMMITS mid-operation (FOR UPDATE path) → returns 0, parent + reply survive; a blind anti-join would lose the reply', async () => {
|
||||||
|
const parent = await seedTopLevel();
|
||||||
|
|
||||||
|
// Second connection holds an open transaction that inserts a reply (taking
|
||||||
|
// FOR KEY SHARE on the parent via the FK) and does NOT commit until we open
|
||||||
|
// the gate — reproducing the "reply not yet committed" window.
|
||||||
|
const conn2 = buildTestDb();
|
||||||
|
let openGate!: () => void;
|
||||||
|
const gate = new Promise<void>((resolve) => {
|
||||||
|
openGate = resolve;
|
||||||
|
});
|
||||||
|
let replyId: string | undefined;
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const replyTx = conn2.transaction().execute(async (trx) => {
|
||||||
|
const row = await trx
|
||||||
|
.insertInto('comments')
|
||||||
|
.values({
|
||||||
|
workspaceId,
|
||||||
|
spaceId,
|
||||||
|
pageId,
|
||||||
|
creatorId: userId,
|
||||||
|
parentCommentId: parent.id,
|
||||||
|
})
|
||||||
|
.returning(['id'])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
replyId = row.id as string;
|
||||||
|
// Hold the FOR KEY SHARE lock on the parent until the gate opens.
|
||||||
|
await gate;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Let the reply INSERT acquire its lock before the delete starts.
|
||||||
|
await sleep(250);
|
||||||
|
|
||||||
|
// deleteCommentIfChildless does SELECT ... FOR UPDATE on the parent, which
|
||||||
|
// conflicts with the reply's FOR KEY SHARE, so it BLOCKS here.
|
||||||
|
const deletePromise = repo.deleteCommentIfChildless(parent.id);
|
||||||
|
|
||||||
|
// Give the delete time to reach (and block on) its FOR UPDATE, then let the
|
||||||
|
// reply commit. The delete then wakes, re-checks under the lock, sees the
|
||||||
|
// now-committed reply, and returns 0.
|
||||||
|
await sleep(250);
|
||||||
|
openGate();
|
||||||
|
await replyTx;
|
||||||
|
|
||||||
|
const deleted = await deletePromise;
|
||||||
|
|
||||||
|
expect(deleted).toBe(0);
|
||||||
|
expect(await rowExists(parent.id)).toBe(true);
|
||||||
|
expect(replyId).toBeDefined();
|
||||||
|
expect(await rowExists(replyId!)).toBe(true);
|
||||||
|
} finally {
|
||||||
|
// Always release the gate (in case an assertion threw before openGate) and
|
||||||
|
// close the extra connection so global-teardown can DROP the database.
|
||||||
|
openGate();
|
||||||
|
await conn2.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -132,6 +132,62 @@ export async function createUser(
|
|||||||
return { id: row.id as string };
|
return { id: row.id as string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The default group every workspace has; `groupUserRepo.addUserToDefaultGroup`
|
||||||
|
// (invoked by acceptInvitation) looks it up by `isDefault = true`, so a
|
||||||
|
// workspace under test must have exactly one for the accept path to complete.
|
||||||
|
export async function createDefaultGroup(
|
||||||
|
db: Kysely<any>,
|
||||||
|
workspaceId: string,
|
||||||
|
overrides: { name?: string } = {},
|
||||||
|
): Promise<{ id: string }> {
|
||||||
|
const id = randomUUID();
|
||||||
|
const suffix = shortId(id);
|
||||||
|
const row = await db
|
||||||
|
.insertInto('groups')
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
// name is unique per workspace + NOT NULL.
|
||||||
|
name: overrides.name ?? `group-${suffix}`,
|
||||||
|
isDefault: true,
|
||||||
|
workspaceId,
|
||||||
|
})
|
||||||
|
.returning(['id'])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
return { id: row.id as string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pending workspace invitation. `role`/`token` are NOT NULL; `groupIds` is a
|
||||||
|
// nullable uuid[] and `invitedById` a nullable FK to users. Returns the fields a
|
||||||
|
// spec needs to drive acceptInvitation (id + token + the invited email).
|
||||||
|
export async function createInvitation(
|
||||||
|
db: Kysely<any>,
|
||||||
|
args: {
|
||||||
|
workspaceId: string;
|
||||||
|
email: string;
|
||||||
|
invitedById?: string | null;
|
||||||
|
role?: string;
|
||||||
|
token?: string;
|
||||||
|
groupIds?: string[] | null;
|
||||||
|
},
|
||||||
|
): Promise<{ id: string; token: string; email: string }> {
|
||||||
|
const id = randomUUID();
|
||||||
|
const token = args.token ?? `tok-${shortId(id)}`;
|
||||||
|
const row = await db
|
||||||
|
.insertInto('workspaceInvitations')
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
email: args.email,
|
||||||
|
role: args.role ?? 'member',
|
||||||
|
token,
|
||||||
|
groupIds: (args.groupIds ?? null) as any,
|
||||||
|
invitedById: args.invitedById ?? null,
|
||||||
|
workspaceId: args.workspaceId,
|
||||||
|
})
|
||||||
|
.returning(['id'])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
return { id: row.id as string, token, email: args.email };
|
||||||
|
}
|
||||||
|
|
||||||
export async function createSpace(
|
export async function createSpace(
|
||||||
db: Kysely<any>,
|
db: Kysely<any>,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
@@ -174,6 +230,40 @@ export async function createPage(
|
|||||||
return { id: row.id as string };
|
return { id: row.id as string };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function createComment(
|
||||||
|
db: Kysely<any>,
|
||||||
|
args: {
|
||||||
|
workspaceId: string;
|
||||||
|
spaceId: string;
|
||||||
|
pageId: string;
|
||||||
|
creatorId?: string | null;
|
||||||
|
parentCommentId?: string | null;
|
||||||
|
content?: unknown;
|
||||||
|
selection?: string | null;
|
||||||
|
suggestedText?: string | null;
|
||||||
|
type?: string | null;
|
||||||
|
},
|
||||||
|
): Promise<{ id: string }> {
|
||||||
|
const id = randomUUID();
|
||||||
|
const row = await db
|
||||||
|
.insertInto('comments')
|
||||||
|
.values({
|
||||||
|
id,
|
||||||
|
workspaceId: args.workspaceId,
|
||||||
|
spaceId: args.spaceId,
|
||||||
|
pageId: args.pageId,
|
||||||
|
creatorId: args.creatorId ?? null,
|
||||||
|
parentCommentId: args.parentCommentId ?? null,
|
||||||
|
content: (args.content ?? null) as any,
|
||||||
|
selection: args.selection ?? null,
|
||||||
|
suggestedText: args.suggestedText ?? null,
|
||||||
|
type: args.type ?? 'page',
|
||||||
|
})
|
||||||
|
.returning(['id'])
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
return { id: row.id as string };
|
||||||
|
}
|
||||||
|
|
||||||
export async function createRole(
|
export async function createRole(
|
||||||
db: Kysely<any>,
|
db: Kysely<any>,
|
||||||
args: {
|
args: {
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { Kysely } from 'kysely';
|
||||||
|
import { Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||||
|
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||||
|
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||||
|
import { WorkspaceInvitationService } from 'src/core/workspace/services/workspace-invitation.service';
|
||||||
|
import {
|
||||||
|
getTestDb,
|
||||||
|
destroyTestDb,
|
||||||
|
createWorkspace,
|
||||||
|
createUser,
|
||||||
|
createDefaultGroup,
|
||||||
|
createInvitation,
|
||||||
|
} from './db';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* acceptInvitation atomicity (issue #324, tail of #244).
|
||||||
|
*
|
||||||
|
* acceptInvitation() reads the invitation OUTSIDE the transaction, then inside a
|
||||||
|
* single tx: inserts the invited user, adds them to the default group, and
|
||||||
|
* deletes the invitation. Two accepts of the SAME invitation therefore race to
|
||||||
|
* insert a user with the same (email, workspaceId) — which the
|
||||||
|
* `users_email_workspace_id_unique` constraint forbids. The service catches that
|
||||||
|
* violation and reports "Invitation already accepted".
|
||||||
|
*
|
||||||
|
* These specs pin the INVARIANT that path protects: no matter how many times the
|
||||||
|
* invitation is accepted (concurrently or repeatedly), the workspace ends up
|
||||||
|
* with exactly ONE membership for the invited email and the invitation is
|
||||||
|
* consumed exactly once — never a duplicate user and never a half-applied state.
|
||||||
|
*
|
||||||
|
* The service is wired with the REAL repos (UserRepo / GroupRepo / GroupUserRepo)
|
||||||
|
* against the test Kysely; only the peripheral collaborators that acceptInvitation
|
||||||
|
* touches AFTER the transaction (mail, session token, billing, audit, env) are
|
||||||
|
* stubbed, so the exercised DB write path is the production one.
|
||||||
|
*/
|
||||||
|
describe('WorkspaceInvitationService.acceptInvitation atomicity [integration]', () => {
|
||||||
|
let db: Kysely<any>;
|
||||||
|
let service: WorkspaceInvitationService;
|
||||||
|
|
||||||
|
// Count the memberships (user rows) for an email within a workspace — the
|
||||||
|
// quantity the atomicity guarantee is about.
|
||||||
|
async function membershipCount(
|
||||||
|
workspaceId: string,
|
||||||
|
email: string,
|
||||||
|
): Promise<number> {
|
||||||
|
const rows = await db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select('id')
|
||||||
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
.where('email', '=', email.toLowerCase())
|
||||||
|
.execute();
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invitationExists(invitationId: string): Promise<boolean> {
|
||||||
|
const row = await db
|
||||||
|
.selectFrom('workspaceInvitations')
|
||||||
|
.select('id')
|
||||||
|
.where('id', '=', invitationId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
db = getTestDb();
|
||||||
|
|
||||||
|
const userRepo = new UserRepo(db as any);
|
||||||
|
const groupRepo = new GroupRepo(db as any);
|
||||||
|
const groupUserRepo = new GroupUserRepo(db as any, groupRepo, userRepo);
|
||||||
|
|
||||||
|
// Collaborators used only on the post-commit success tail; safe to stub.
|
||||||
|
const mailService = { sendToQueue: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const domainService = {} as any;
|
||||||
|
const tokenService = {} as any;
|
||||||
|
const sessionService = {
|
||||||
|
createSessionAndToken: jest.fn().mockResolvedValue('test-auth-token'),
|
||||||
|
};
|
||||||
|
const billingQueue = { add: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
const environmentService = { isCloud: () => false };
|
||||||
|
const auditService = { log: jest.fn() };
|
||||||
|
|
||||||
|
service = new WorkspaceInvitationService(
|
||||||
|
userRepo,
|
||||||
|
groupUserRepo,
|
||||||
|
mailService as any,
|
||||||
|
domainService,
|
||||||
|
tokenService,
|
||||||
|
sessionService as any,
|
||||||
|
db as any,
|
||||||
|
billingQueue as any,
|
||||||
|
environmentService as any,
|
||||||
|
auditService as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await destroyTestDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
// A workspace with its default group, an inviter, and a pending invitation.
|
||||||
|
async function seedInvite(): Promise<{
|
||||||
|
workspace: Workspace;
|
||||||
|
invitationId: string;
|
||||||
|
token: string;
|
||||||
|
email: string;
|
||||||
|
}> {
|
||||||
|
const { id: workspaceId } = await createWorkspace(db);
|
||||||
|
await createDefaultGroup(db, workspaceId);
|
||||||
|
const inviter = await createUser(db, workspaceId);
|
||||||
|
// Distinct address per invite so specs never collide across the suite.
|
||||||
|
const email = `invitee-${workspaceId.slice(0, 8)}@example.test`;
|
||||||
|
const invite = await createInvitation(db, {
|
||||||
|
workspaceId,
|
||||||
|
email,
|
||||||
|
invitedById: inviter.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// acceptInvitation only reads id/hostname/enforceSso/emailDomains/enforceMfa
|
||||||
|
// off the workspace; a minimal plain object is sufficient.
|
||||||
|
const workspace = {
|
||||||
|
id: workspaceId,
|
||||||
|
hostname: `host-${workspaceId.slice(0, 8)}`,
|
||||||
|
enforceSso: false,
|
||||||
|
enforceMfa: false,
|
||||||
|
emailDomains: [] as string[],
|
||||||
|
} as unknown as Workspace;
|
||||||
|
|
||||||
|
return { workspace, invitationId: invite.id, token: invite.token, email };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('concurrent accepts create a single membership and consume the invitation once', async () => {
|
||||||
|
const { workspace, invitationId, token, email } = await seedInvite();
|
||||||
|
|
||||||
|
const dto = { invitationId, token, name: 'Invited User', password: 'password123' };
|
||||||
|
|
||||||
|
// Fire two accepts of the SAME invitation at once. They race to insert the
|
||||||
|
// same (email, workspaceId); the unique constraint lets exactly one win.
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
service.acceptInvitation({ ...dto }, workspace),
|
||||||
|
service.acceptInvitation({ ...dto }, workspace),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const fulfilled = results.filter((r) => r.status === 'fulfilled');
|
||||||
|
const rejected = results.filter(
|
||||||
|
(r): r is PromiseRejectedResult => r.status === 'rejected',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Exactly one accept succeeds; the other is rejected.
|
||||||
|
expect(fulfilled).toHaveLength(1);
|
||||||
|
expect(rejected).toHaveLength(1);
|
||||||
|
|
||||||
|
// The loser fails via the caught unique-constraint path with the specific
|
||||||
|
// "already accepted" message — not a half-state / generic failure.
|
||||||
|
expect(rejected[0].reason).toBeInstanceOf(BadRequestException);
|
||||||
|
expect(rejected[0].reason.message).toBe('Invitation already accepted');
|
||||||
|
|
||||||
|
// Invariant: exactly one membership, and the invitation is gone.
|
||||||
|
expect(await membershipCount(workspace.id, email)).toBe(1);
|
||||||
|
expect(await invitationExists(invitationId)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a repeated (sequential) accept does not create a duplicate membership', async () => {
|
||||||
|
const { workspace, invitationId, token, email } = await seedInvite();
|
||||||
|
const dto = { invitationId, token, name: 'Invited User', password: 'password123' };
|
||||||
|
|
||||||
|
// First accept succeeds and returns an auth token.
|
||||||
|
const first = await service.acceptInvitation({ ...dto }, workspace);
|
||||||
|
expect(first?.authToken).toBe('test-auth-token');
|
||||||
|
expect(await membershipCount(workspace.id, email)).toBe(1);
|
||||||
|
expect(await invitationExists(invitationId)).toBe(false);
|
||||||
|
|
||||||
|
// Re-accepting the (now consumed) invitation must be rejected and must NOT
|
||||||
|
// add a second membership. The invitation row is gone, so this hits the
|
||||||
|
// "Invitation not found" guard rather than the unique-constraint path.
|
||||||
|
await expect(
|
||||||
|
service.acceptInvitation({ ...dto }, workspace),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
||||||
|
expect(await membershipCount(workspace.id, email)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the single created membership is added to the default group (no partial state)', async () => {
|
||||||
|
const { workspace, invitationId, token, email } = await seedInvite();
|
||||||
|
const dto = { invitationId, token, name: 'Invited User', password: 'password123' };
|
||||||
|
|
||||||
|
await Promise.allSettled([
|
||||||
|
service.acceptInvitation({ ...dto }, workspace),
|
||||||
|
service.acceptInvitation({ ...dto }, workspace),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Resolve the one surviving user and assert the whole tx applied: they exist
|
||||||
|
// AND are in the workspace default group (the mid-transaction step), proving
|
||||||
|
// the winning accept committed as a whole rather than leaving a torn state.
|
||||||
|
const user = await db
|
||||||
|
.selectFrom('users')
|
||||||
|
.select(['id'])
|
||||||
|
.where('workspaceId', '=', workspace.id)
|
||||||
|
.where('email', '=', email.toLowerCase())
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
const defaultGroup = await db
|
||||||
|
.selectFrom('groups')
|
||||||
|
.select(['id'])
|
||||||
|
.where('workspaceId', '=', workspace.id)
|
||||||
|
.where('isDefault', '=', true)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
const membership = await db
|
||||||
|
.selectFrom('groupUsers')
|
||||||
|
.select(['userId'])
|
||||||
|
.where('groupId', '=', defaultGroup.id)
|
||||||
|
.where('userId', '=', user.id)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
expect(membership).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,5 +13,9 @@
|
|||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"marked": "17.0.5"
|
"marked": "17.0.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitest/coverage-v8": "4.1.6",
|
||||||
|
"vitest": "4.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,21 @@ export default defineConfig({
|
|||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
globals: true,
|
globals: true,
|
||||||
include: ["src/**/*.{test,spec}.ts"],
|
include: ["src/**/*.{test,spec}.ts"],
|
||||||
|
// Coverage gate (issue #324). v8 provider avoids the istanbul AST-rewrite
|
||||||
|
// that broke on this package's ESM barrel. Thresholds sit a few points
|
||||||
|
// below the level measured on develop, over the files the suite exercises
|
||||||
|
// (`all: false`), so the gate passes today and catches a real regression.
|
||||||
|
coverage: {
|
||||||
|
enabled: true,
|
||||||
|
provider: "v8",
|
||||||
|
reporter: ["text-summary", "text"],
|
||||||
|
all: false,
|
||||||
|
thresholds: {
|
||||||
|
statements: 54,
|
||||||
|
branches: 44,
|
||||||
|
functions: 60,
|
||||||
|
lines: 54,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@docmost/prosemirror-markdown": "workspace:*",
|
||||||
"@tiptap/core": "3.20.4",
|
"@tiptap/core": "3.20.4",
|
||||||
"@tiptap/extension-highlight": "3.20.4",
|
"@tiptap/extension-highlight": "3.20.4",
|
||||||
"@tiptap/extension-image": "3.20.4",
|
"@tiptap/extension-image": "3.20.4",
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
"@docmost/editor-ext": "workspace:*",
|
"@docmost/editor-ext": "workspace:*",
|
||||||
"@types/jsdom": "^21.1.7",
|
"@types/jsdom": "^21.1.7",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
|
"@vitest/coverage-v8": "4.1.6",
|
||||||
"fast-check": "^4.8.0",
|
"fast-check": "^4.8.0",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"vitest": "4.1.6"
|
"vitest": "4.1.6"
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
*/
|
*/
|
||||||
import { dirname } from "node:path";
|
import { dirname } from "node:path";
|
||||||
import { sep } from "node:path";
|
import { sep } from "node:path";
|
||||||
import { parsePageFile, serializePageFile } from "../lib/page-file.js";
|
import { parsePageFile, serializePageFile } from "@docmost/prosemirror-markdown";
|
||||||
import type { GitSyncClient } from "./client.types.js";
|
import type { GitSyncClient } from "./client.types.js";
|
||||||
import { buildVaultLayout, type PageNode } from "./layout.js";
|
import { buildVaultLayout, type PageNode } from "./layout.js";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -26,8 +26,11 @@
|
|||||||
* the gitmost server drives the engine in-process (there is no standalone CLI
|
* the gitmost server drives the engine in-process (there is no standalone CLI
|
||||||
* entry point).
|
* entry point).
|
||||||
*/
|
*/
|
||||||
import { type DocmostMdMeta } from "../lib/index.js";
|
import {
|
||||||
import { parsePageFile, serializePageFile } from "../lib/page-file.js";
|
type DocmostMdMeta,
|
||||||
|
parsePageFile,
|
||||||
|
serializePageFile,
|
||||||
|
} from "@docmost/prosemirror-markdown";
|
||||||
import type { GitSyncClient } from "./client.types.js";
|
import type { GitSyncClient } from "./client.types.js";
|
||||||
import type { DiffEntry } from "./git.js";
|
import type { DiffEntry } from "./git.js";
|
||||||
import { VaultGit, DEFAULT_BRANCH } from "./git.js";
|
import { VaultGit, DEFAULT_BRANCH } from "./git.js";
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
markdownToProseMirror,
|
markdownToProseMirror,
|
||||||
serializeDocmostMarkdownBody,
|
serializeDocmostMarkdownBody,
|
||||||
type DocmostMdMeta,
|
type DocmostMdMeta,
|
||||||
} from "../lib/index.js";
|
} from "@docmost/prosemirror-markdown";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Meta object as `exportPageBody` builds it (SPEC §4). Kept byte-for-byte
|
* Meta object as `exportPageBody` builds it (SPEC §4). Kept byte-for-byte
|
||||||
|
|||||||
@@ -8,6 +8,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Pure converter (markdown <-> ProseMirror, file envelope, canonicalization).
|
// Pure converter (markdown <-> ProseMirror, file envelope, canonicalization).
|
||||||
|
// Re-exported from the standalone `@docmost/prosemirror-markdown` package,
|
||||||
|
// which is the single source of truth for the converter core; git-sync keeps
|
||||||
|
// only the engine (vault/git/orchestrator) and re-surfaces the converter for
|
||||||
|
// in-process consumers of the git-sync barrel.
|
||||||
export {
|
export {
|
||||||
serializeDocmostMarkdown,
|
serializeDocmostMarkdown,
|
||||||
serializeDocmostMarkdownBody,
|
serializeDocmostMarkdownBody,
|
||||||
@@ -16,8 +20,8 @@ export {
|
|||||||
markdownToProseMirror,
|
markdownToProseMirror,
|
||||||
canonicalizeContent,
|
canonicalizeContent,
|
||||||
docsCanonicallyEqual,
|
docsCanonicallyEqual,
|
||||||
} from "./lib/index.js";
|
} from "@docmost/prosemirror-markdown";
|
||||||
export type { DocmostMdMeta } from "./lib/index.js";
|
export type { DocmostMdMeta } from "@docmost/prosemirror-markdown";
|
||||||
|
|
||||||
// Pure engine (no IO): reconcile planner, vault layout, sanitize, stabilize,
|
// Pure engine (no IO): reconcile planner, vault layout, sanitize, stabilize,
|
||||||
// loop-guard body hash.
|
// loop-guard body hash.
|
||||||
@@ -123,4 +127,4 @@ export {
|
|||||||
} from "./engine/path-guard.js";
|
} from "./engine/path-guard.js";
|
||||||
export type { PathGuardIo, VaultPathUnsafeReason } from "./engine/path-guard.js";
|
export type { PathGuardIo, VaultPathUnsafeReason } from "./engine/path-guard.js";
|
||||||
|
|
||||||
export { parsePageFile, serializePageFile } from "./lib/page-file.js";
|
export { parsePageFile, serializePageFile } from "@docmost/prosemirror-markdown";
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,365 +0,0 @@
|
|||||||
/**
|
|
||||||
* Pure markdown -> ProseMirror conversion.
|
|
||||||
*
|
|
||||||
* The converter path is `markdownToProseMirror` (marked -> HTML ->
|
|
||||||
* generateJSON) plus the two pre/post processors it needs (`preprocessCallouts`,
|
|
||||||
* `bridgeTaskLists`). The gitmost server writes the resulting page bodies
|
|
||||||
* natively through the collab gateway, so no websocket/Yjs write-path lives
|
|
||||||
* here.
|
|
||||||
*/
|
|
||||||
import { generateJSON } from "@tiptap/html";
|
|
||||||
import { JSDOM } from "jsdom";
|
|
||||||
import { marked } from "marked";
|
|
||||||
import { docmostExtensions } from "./docmost-schema.js";
|
|
||||||
|
|
||||||
// Setup DOM environment for Tiptap HTML parsing in Node.js
|
|
||||||
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
|
|
||||||
global.window = dom.window as any;
|
|
||||||
global.document = dom.window.document;
|
|
||||||
// @ts-ignore
|
|
||||||
global.Element = dom.window.Element;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hard ceiling above which we skip callout preprocessing entirely. The linear
|
|
||||||
* scanner below has no quadratic blow-up, but we still cap input defensively so
|
|
||||||
* a pathological multi-megabyte payload cannot tie up the event loop; in that
|
|
||||||
* case the markdown is passed through verbatim (callouts are simply not
|
|
||||||
* detected) rather than risking a slow scan.
|
|
||||||
*/
|
|
||||||
const MAX_CALLOUT_PREPROCESS_BYTES = 4 * 1024 * 1024; // 4 MB
|
|
||||||
|
|
||||||
/** Matches an opening callout fence: `:::type` (type captured, lower-cased). */
|
|
||||||
const CALLOUT_OPEN_RE = /^:::\s*(\w+)\s*$/;
|
|
||||||
/** Matches a bare closing callout fence: `:::`. */
|
|
||||||
const CALLOUT_CLOSE_RE = /^:::\s*$/;
|
|
||||||
/**
|
|
||||||
* Matches an Obsidian-native callout opener: `> [!type]` (type captured). An
|
|
||||||
* optional title after the type is allowed but ignored (the Docmost callout
|
|
||||||
* schema has no title). The body is the following contiguous blockquote lines.
|
|
||||||
*/
|
|
||||||
const CALLOUT_BQ_OPEN_RE = /^>\s*\[!(\w+)\]/;
|
|
||||||
/** Matches any blockquote continuation line (`>` … ). */
|
|
||||||
const BLOCKQUOTE_LINE_RE = /^>/;
|
|
||||||
/** Matches the start/end of a code fence (``` or ~~~), capturing the marker. */
|
|
||||||
const CODE_FENCE_RE = /^(\s*)(`{3,}|~{3,})/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pre-process Docmost-flavoured markdown: convert `:::type ... :::`
|
|
||||||
* callout blocks (the syntax our markdown export produces) into HTML
|
|
||||||
* divs that the callout extension parses. The inner content is rendered
|
|
||||||
* through marked as regular markdown.
|
|
||||||
*
|
|
||||||
* Implemented as a single linear pass over the lines (no quadratic regex
|
|
||||||
* rescan). It:
|
|
||||||
* - tracks fenced code regions (```...``` and ~~~...~~~) and never treats a
|
|
||||||
* `:::` line that lives inside a code fence as a callout delimiter, so a
|
|
||||||
* callout body that itself contains a fenced code block with a `:::` line is
|
|
||||||
* no longer corrupted;
|
|
||||||
* - matches an opening `:::type` line with the next CLOSING `:::` at the SAME
|
|
||||||
* nesting level, supporting NESTED callouts via a depth counter (an inner
|
|
||||||
* `:::type` opens a deeper level and consumes a matching `:::`);
|
|
||||||
* - emits the same `<div data-type="callout" data-callout-type="TYPE">` output
|
|
||||||
* (inner rendered through marked) as the previous regex implementation.
|
|
||||||
*/
|
|
||||||
async function preprocessCallouts(markdown: string): Promise<string> {
|
|
||||||
// Defensive cap: skip preprocessing for pathologically large inputs.
|
|
||||||
if (markdown.length > MAX_CALLOUT_PREPROCESS_BYTES) {
|
|
||||||
return markdown;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recursively transform a slice of lines, converting top-level callouts in
|
|
||||||
// that slice into <div> blocks and rendering their inner content (which may
|
|
||||||
// itself contain nested callouts) through this same function.
|
|
||||||
const transform = async (lines: string[]): Promise<string> => {
|
|
||||||
const out: string[] = [];
|
|
||||||
let inCodeFence = false;
|
|
||||||
let codeFenceMarker = ""; // the exact run of backticks/tildes that opened it
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while (i < lines.length) {
|
|
||||||
const line = lines[i];
|
|
||||||
|
|
||||||
// Inside a code fence, only its matching closing fence is significant;
|
|
||||||
// everything else (including `:::` lines) is copied through verbatim.
|
|
||||||
if (inCodeFence) {
|
|
||||||
out.push(line);
|
|
||||||
const fence = line.match(CODE_FENCE_RE);
|
|
||||||
if (fence && fence[2].startsWith(codeFenceMarker[0]) &&
|
|
||||||
fence[2].length >= codeFenceMarker.length) {
|
|
||||||
inCodeFence = false;
|
|
||||||
codeFenceMarker = "";
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A code fence opening outside any callout body: enter code-fence mode.
|
|
||||||
const fenceOpen = line.match(CODE_FENCE_RE);
|
|
||||||
if (fenceOpen) {
|
|
||||||
inCodeFence = true;
|
|
||||||
codeFenceMarker = fenceOpen[2];
|
|
||||||
out.push(line);
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// An opening callout fence: scan forward (with code-fence and nested
|
|
||||||
// callout awareness) for its matching closing `:::` at the same level.
|
|
||||||
const open = line.match(CALLOUT_OPEN_RE);
|
|
||||||
if (open) {
|
|
||||||
const type = open[1].toLowerCase();
|
|
||||||
const bodyLines: string[] = [];
|
|
||||||
let depth = 1;
|
|
||||||
let innerInCodeFence = false;
|
|
||||||
let innerCodeFenceMarker = "";
|
|
||||||
let j = i + 1;
|
|
||||||
for (; j < lines.length; j++) {
|
|
||||||
const bl = lines[j];
|
|
||||||
if (innerInCodeFence) {
|
|
||||||
const f = bl.match(CODE_FENCE_RE);
|
|
||||||
if (f && f[2].startsWith(innerCodeFenceMarker[0]) &&
|
|
||||||
f[2].length >= innerCodeFenceMarker.length) {
|
|
||||||
innerInCodeFence = false;
|
|
||||||
innerCodeFenceMarker = "";
|
|
||||||
}
|
|
||||||
bodyLines.push(bl);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const innerFence = bl.match(CODE_FENCE_RE);
|
|
||||||
if (innerFence) {
|
|
||||||
innerInCodeFence = true;
|
|
||||||
innerCodeFenceMarker = innerFence[2];
|
|
||||||
bodyLines.push(bl);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (CALLOUT_OPEN_RE.test(bl)) {
|
|
||||||
depth++;
|
|
||||||
bodyLines.push(bl);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (CALLOUT_CLOSE_RE.test(bl)) {
|
|
||||||
depth--;
|
|
||||||
if (depth === 0) break; // matching close for THIS callout
|
|
||||||
bodyLines.push(bl);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
bodyLines.push(bl);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (j < lines.length) {
|
|
||||||
// Found the matching closing fence: render the body (recursively, so
|
|
||||||
// nested callouts are handled) and emit the callout div.
|
|
||||||
const inner = await transform(bodyLines);
|
|
||||||
const renderedInner = await marked.parse(inner);
|
|
||||||
out.push(
|
|
||||||
`\n<div data-type="callout" data-callout-type="${type}">${renderedInner}</div>\n`,
|
|
||||||
);
|
|
||||||
i = j + 1; // skip past the closing `:::`
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// No matching close (unterminated callout): treat the opener as a
|
|
||||||
// literal line and continue, preserving the original text.
|
|
||||||
out.push(line);
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// An Obsidian-native callout: `> [!type]` opener; the body is the following
|
|
||||||
// CONTIGUOUS blockquote (`>`-prefixed) lines. Strip ONE blockquote level and
|
|
||||||
// recurse so nested callouts (`> > [!type]`) are handled, then emit the same
|
|
||||||
// callout div the `:::` path produces. A normal blockquote (no `[!type]` on
|
|
||||||
// its first line) does not match and stays a blockquote.
|
|
||||||
const bqOpen = line.match(CALLOUT_BQ_OPEN_RE);
|
|
||||||
if (bqOpen) {
|
|
||||||
const type = bqOpen[1].toLowerCase();
|
|
||||||
const bodyLines: string[] = [];
|
|
||||||
let j = i + 1;
|
|
||||||
for (; j < lines.length; j++) {
|
|
||||||
if (!BLOCKQUOTE_LINE_RE.test(lines[j])) break;
|
|
||||||
bodyLines.push(lines[j].replace(/^>\s?/, ""));
|
|
||||||
}
|
|
||||||
const inner = await transform(bodyLines);
|
|
||||||
const renderedInner = await marked.parse(inner);
|
|
||||||
out.push(
|
|
||||||
`\n<div data-type="callout" data-callout-type="${type}">${renderedInner}</div>\n`,
|
|
||||||
);
|
|
||||||
i = j;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
out.push(line);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return out.join("\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
return transform(markdown.split("\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bridge marked's checkbox lists to TipTap task lists.
|
|
||||||
*
|
|
||||||
* marked renders GitHub task list items (`- [x] done`) as a plain
|
|
||||||
* `<ul><li><p><input type="checkbox" checked> text</p></li></ul>` WITHOUT the
|
|
||||||
* markup TipTap's TaskList/TaskItem extensions parse. This rewrites such lists
|
|
||||||
* into the shape those extensions expect:
|
|
||||||
* TaskList parseHTML matches `ul[data-type="taskList"]`,
|
|
||||||
* TaskItem matches `li[data-type="taskItem"]`,
|
|
||||||
* the checked state is read from `data-checked === "true"`.
|
|
||||||
*
|
|
||||||
* A list is only converted when it has at least one `<li>` and EVERY direct
|
|
||||||
* `<li>` contains a checkbox input. Both `<ul>` and `<ol>` are considered: a
|
|
||||||
* numbered checklist (`1. [x] a`, which marked renders as an `<ol>` of checkbox
|
|
||||||
* `<li>`s) would otherwise lose its task state. TipTap task lists are unordered,
|
|
||||||
* so a matching `<ol>` is emitted as `data-type="taskList"` exactly like a
|
|
||||||
* `<ul>`. Mixed or ordinary lists (including ordinary `<ol>` lists) are left
|
|
||||||
* untouched so they keep rendering as bullet/numbered lists. The marked `<p>`
|
|
||||||
* wrapper is kept inside the `<li>` because TaskItem content allows paragraphs.
|
|
||||||
*/
|
|
||||||
function bridgeTaskLists(html: string): string {
|
|
||||||
// Cheap early-out: if the markup contains no checkbox input at all there is
|
|
||||||
// nothing to bridge, so skip the expensive JSDOM parse entirely. This is the
|
|
||||||
// common case (most pages have no task lists).
|
|
||||||
if (!/type=["']?checkbox/i.test(html)) {
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
// Defensive cap (consistent with preprocessCallouts): skip the bridge for
|
|
||||||
// pathologically large inputs rather than running a second expensive JSDOM
|
|
||||||
// parse on a multi-megabyte payload. The markup is passed through verbatim.
|
|
||||||
if (html.length > MAX_CALLOUT_PREPROCESS_BYTES) {
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
const dom = new JSDOM(html);
|
|
||||||
const document = dom.window.document;
|
|
||||||
// Collect the checkbox(es) that belong to THIS <li> directly: either direct
|
|
||||||
// child <input type="checkbox"> elements or ones inside the <li>'s direct <p>
|
|
||||||
// child (the shape marked emits: `<li><p><input type="checkbox"> text</p></li>`).
|
|
||||||
// Checkboxes nested deeper (e.g. inside a child <ul>/<ol>) are excluded so a
|
|
||||||
// bullet <li> that merely contains a nested task sublist is not misdetected.
|
|
||||||
// Raw inline HTML can put more than one checkbox in a single <li>; we gather
|
|
||||||
// ALL of them so none survive into the converted item.
|
|
||||||
const directCheckboxes = (li: Element): Element[] => {
|
|
||||||
const found: Element[] = [];
|
|
||||||
for (const child of Array.from(li.children)) {
|
|
||||||
if (
|
|
||||||
child.tagName === "INPUT" &&
|
|
||||||
child.getAttribute("type") === "checkbox"
|
|
||||||
) {
|
|
||||||
found.push(child);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (child.tagName === "P") {
|
|
||||||
for (const inp of Array.from(
|
|
||||||
child.querySelectorAll(":scope > input[type='checkbox']"),
|
|
||||||
)) {
|
|
||||||
found.push(inp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return found;
|
|
||||||
};
|
|
||||||
// Both <ul> and <ol> are candidates: an <ol> whose every direct <li> carries
|
|
||||||
// its own checkbox is a numbered checklist that must also become a taskList.
|
|
||||||
const lists = Array.from(document.querySelectorAll("ul, ol"));
|
|
||||||
for (const list of lists) {
|
|
||||||
// Only consider DIRECT child <li> elements; nested lists are handled by
|
|
||||||
// their own iteration of the outer loop.
|
|
||||||
const items = Array.from(list.children).filter(
|
|
||||||
(child) => child.tagName === "LI",
|
|
||||||
);
|
|
||||||
if (items.length === 0) continue;
|
|
||||||
const itemCheckboxes = items.map((li) => directCheckboxes(li));
|
|
||||||
// Convert only when every direct <li> carries at least one OWN checkbox.
|
|
||||||
if (!itemCheckboxes.every((boxes) => boxes.length > 0)) continue;
|
|
||||||
|
|
||||||
// A numbered checklist arrives as an <ol>. We must NOT leave the tag as
|
|
||||||
// <ol> while tagging it data-type="taskList": generateJSON would then match
|
|
||||||
// BOTH the orderedList rule (tag ol) and the taskList rule (data-type),
|
|
||||||
// emitting a phantom empty orderedList beside the real taskList. So rename a
|
|
||||||
// qualifying <ol> to a <ul> — move its <li> children over and replace it —
|
|
||||||
// leaving only the taskList rule to match. Already-<ul> lists are unchanged.
|
|
||||||
let target: Element = list;
|
|
||||||
if (list.tagName === "OL") {
|
|
||||||
const ul = document.createElement("ul");
|
|
||||||
// Carry over existing attributes (e.g. class) so nothing is silently lost.
|
|
||||||
for (const attr of Array.from(list.attributes)) {
|
|
||||||
ul.setAttribute(attr.name, attr.value);
|
|
||||||
}
|
|
||||||
// Move every child node (including the <li>s we collected) into the <ul>.
|
|
||||||
while (list.firstChild) {
|
|
||||||
ul.appendChild(list.firstChild);
|
|
||||||
}
|
|
||||||
list.replaceWith(ul);
|
|
||||||
target = ul;
|
|
||||||
}
|
|
||||||
|
|
||||||
target.setAttribute("data-type", "taskList");
|
|
||||||
items.forEach((li, index) => {
|
|
||||||
const boxes = itemCheckboxes[index];
|
|
||||||
// The first checkbox determines the checked state (matches the previous
|
|
||||||
// single-checkbox behaviour); any extras only need removing.
|
|
||||||
const input = boxes[0] ?? null;
|
|
||||||
li.setAttribute("data-type", "taskItem");
|
|
||||||
const checked =
|
|
||||||
input != null &&
|
|
||||||
(input.hasAttribute("checked") || (input as any).checked);
|
|
||||||
li.setAttribute("data-checked", checked ? "true" : "false");
|
|
||||||
// Remove ALL direct checkbox inputs so none survive into the content
|
|
||||||
// (a raw-inline-HTML <li> may carry more than one).
|
|
||||||
for (const box of boxes) {
|
|
||||||
box.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return document.body.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively strip content-less paragraph nodes from a generated doc.
|
|
||||||
*
|
|
||||||
* A block-level atom whose markdown form is INLINE (e.g. the block `image`'s
|
|
||||||
* ``, or a bare media element) is wrapped by marked in a <p>; the schema
|
|
||||||
* then HOISTS the block atom out of that paragraph, leaving an EMPTY paragraph
|
|
||||||
* sibling. On the next export that empty `<p>` renders to "" and the doc "\n\n"
|
|
||||||
* join injects a phantom blank gap, so the markdown is not byte-stable.
|
|
||||||
*
|
|
||||||
* Markdown blank lines are separators, never content, so generateJSON only ever
|
|
||||||
* produces an empty paragraph as such a hoist artifact — removing them is safe
|
|
||||||
* and general (it also subsumes the <div>-wrapper workaround the `video` case
|
|
||||||
* uses). We remove ONLY `type === 'paragraph'` nodes whose `content` is absent
|
|
||||||
* or an empty array; every other node (including atoms without `content`) is
|
|
||||||
* preserved, and we recurse into the content of any node that has children.
|
|
||||||
*/
|
|
||||||
function stripEmptyParagraphs(node: any): any {
|
|
||||||
if (!node || !Array.isArray(node.content)) {
|
|
||||||
// Atom / leaf node (no children to recurse into): keep as-is.
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
const mapped = node.content.map((child: any) => stripEmptyParagraphs(child));
|
|
||||||
const isEmptyParagraph = (child: any): boolean =>
|
|
||||||
!!child &&
|
|
||||||
child.type === "paragraph" &&
|
|
||||||
(!Array.isArray(child.content) || child.content.length === 0);
|
|
||||||
const filtered = mapped.filter((child: any) => !isEmptyParagraph(child));
|
|
||||||
// Schema-validity guard: several nodes require NON-empty block content
|
|
||||||
// (`content: "block+"` — tableCell, tableHeader, blockquote, column, callout,
|
|
||||||
// and the doc root). For an empty one of those, generateJSON materializes a
|
|
||||||
// single empty paragraph as its OBLIGATORY content — that is not a hoist
|
|
||||||
// artifact. If stripping would empty the container, keep ONE empty paragraph
|
|
||||||
// so the result stays schema-valid (an empty cell/quote must not become `[]`).
|
|
||||||
const cleaned =
|
|
||||||
filtered.length === 0 && mapped.length > 0 ? [mapped[0]] : filtered;
|
|
||||||
return { ...node, content: cleaned };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Convert markdown to a ProseMirror doc using the full Docmost schema. */
|
|
||||||
export async function markdownToProseMirror(
|
|
||||||
markdownContent: string,
|
|
||||||
): Promise<any> {
|
|
||||||
const withCallouts = await preprocessCallouts(markdownContent);
|
|
||||||
const html = await marked.parse(withCallouts);
|
|
||||||
const bridged = bridgeTaskLists(html);
|
|
||||||
const doc = generateJSON(bridged, docmostExtensions);
|
|
||||||
return stripEmptyParagraphs(doc);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|||||||
import { applyPushActions, LAST_PUSHED_REF } from '../src/engine/push';
|
import { applyPushActions, LAST_PUSHED_REF } from '../src/engine/push';
|
||||||
import { bodyHash } from '../src/engine/loop-guard';
|
import { bodyHash } from '../src/engine/loop-guard';
|
||||||
import type { ApplyPushDeps, PushActions } from '../src/engine/push';
|
import type { ApplyPushDeps, PushActions } from '../src/engine/push';
|
||||||
import { parsePageFile, serializePageFile } from '../src/lib/page-file';
|
import { parsePageFile, serializePageFile } from '@docmost/prosemirror-markdown';
|
||||||
|
|
||||||
// The Docmost space this vault mirrors (native files carry no spaceId; the run
|
// The Docmost space this vault mirrors (native files carry no spaceId; the run
|
||||||
// supplies it). A CREATE targets this space.
|
// supplies it). A CREATE targets this space.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type {
|
|||||||
MetaSide,
|
MetaSide,
|
||||||
RenameMoveAction,
|
RenameMoveAction,
|
||||||
} from '../src/engine/push';
|
} from '../src/engine/push';
|
||||||
import type { DocmostMdMeta } from '../src/lib/index';
|
import type { DocmostMdMeta } from '@docmost/prosemirror-markdown';
|
||||||
|
|
||||||
// FS→Docmost push #3 (SPEC §5/§6/§16). `classifyRenameMoves` is the PURE half of
|
// FS→Docmost push #3 (SPEC §5/§6/§16). `classifyRenameMoves` is the PURE half of
|
||||||
// the move/rename apply: it resolves each `{pageId, oldPath, newPath}` into the
|
// the move/rename apply: it resolves each `{pageId, oldPath, newPath}` into the
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { computePushActions } from '../src/engine/push';
|
import { computePushActions } from '../src/engine/push';
|
||||||
import type { DiffEntry, MetaSide } from '../src/engine/push';
|
import type { DiffEntry, MetaSide } from '../src/engine/push';
|
||||||
import type { DocmostMdMeta } from '../src/lib/index';
|
import type { DocmostMdMeta } from '@docmost/prosemirror-markdown';
|
||||||
|
|
||||||
// FS→Docmost push, FIRST increment (SPEC §6). `computePushActions` is the PURE
|
// FS→Docmost push, FIRST increment (SPEC §6). `computePushActions` is the PURE
|
||||||
// half: it classifies each `git diff --name-status` row into a Docmost action by
|
// half: it classifies each `git diff --name-status` row into a Docmost action by
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { runCycle } from "../src/engine/cycle";
|
|||||||
import type { CycleFs } from "../src/engine/cycle";
|
import type { CycleFs } from "../src/engine/cycle";
|
||||||
import { VaultGit } from "../src/engine/git";
|
import { VaultGit } from "../src/engine/git";
|
||||||
import type { Settings } from "../src/engine/settings";
|
import type { Settings } from "../src/engine/settings";
|
||||||
import { serializeDocmostMarkdownBody } from "../src/lib/index";
|
import { serializeDocmostMarkdownBody } from "@docmost/prosemirror-markdown";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { firstDivergence } from './roundtrip-helpers';
|
|||||||
import { applyPullActions } from '../src/engine/pull';
|
import { applyPullActions } from '../src/engine/pull';
|
||||||
import type { PullActions, ApplyPullActionsDeps } from '../src/engine/pull';
|
import type { PullActions, ApplyPullActionsDeps } from '../src/engine/pull';
|
||||||
import type { DeletionDecision } from '../src/engine/reconcile';
|
import type { DeletionDecision } from '../src/engine/reconcile';
|
||||||
import { serializePageFile, parsePageFile } from '../src/lib/page-file';
|
import { serializePageFile, parsePageFile } from '@docmost/prosemirror-markdown';
|
||||||
|
|
||||||
// Engine-layer coverage gaps flagged by the PR #119 reviewers (test-strategy
|
// Engine-layer coverage gaps flagged by the PR #119 reviewers (test-strategy
|
||||||
// report, Module 2 `src/engine`). Each block targets a specific under-covered
|
// report, Module 2 `src/engine`). Each block targets a specific under-covered
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { readExisting } from '../src/engine/pull';
|
import { readExisting } from '../src/engine/pull';
|
||||||
import { serializePageFile } from '../src/lib/page-file';
|
import { serializePageFile } from '@docmost/prosemirror-markdown';
|
||||||
|
|
||||||
// R-Pull-1 (test-strategy report §5): `readExisting` now takes injectable IO
|
// R-Pull-1 (test-strategy report §5): `readExisting` now takes injectable IO
|
||||||
// (`listTracked` / `readFile`), so its parsing + skip rules are unit-testable
|
// (`listTracked` / `readFile`), so its parsing + skip rules are unit-testable
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
MetaSide,
|
MetaSide,
|
||||||
RenameMoveAction,
|
RenameMoveAction,
|
||||||
} from '../src/engine/push.js';
|
} from '../src/engine/push.js';
|
||||||
import type { DocmostMdMeta } from '../src/lib/index.js';
|
import type { DocmostMdMeta } from '@docmost/prosemirror-markdown';
|
||||||
|
|
||||||
// RED-TEAM finding #4 (two facets):
|
// RED-TEAM finding #4 (two facets):
|
||||||
// (a) buildVaultLayout disambiguation is ORDER-DEPENDENT: which of two
|
// (a) buildVaultLayout disambiguation is ORDER-DEPENDENT: which of two
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
import type { PushDeps } from '../src/engine/push';
|
import type { PushDeps } from '../src/engine/push';
|
||||||
import type { Settings } from '../src/engine/settings';
|
import type { Settings } from '../src/engine/settings';
|
||||||
import { runCycle, type RunCycleDeps } from '../src/engine/cycle';
|
import { runCycle, type RunCycleDeps } from '../src/engine/cycle';
|
||||||
import { serializePageFile } from '../src/lib/page-file';
|
import { serializePageFile } from '@docmost/prosemirror-markdown';
|
||||||
|
|
||||||
// Red-team confirmations for PR #119 (git-sync). Each test asserts the DESIRED
|
// Red-team confirmations for PR #119 (git-sync). Each test asserts the DESIRED
|
||||||
// behavior, so it FAILS today iff the bug is real.
|
// behavior, so it FAILS today iff the bug is real.
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
|
||||||
import { readdirSync } from 'node:fs';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
import { dirname, join } from 'node:path';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import {
|
|
||||||
convertProseMirrorToMarkdown,
|
|
||||||
markdownToProseMirror,
|
|
||||||
docsCanonicallyEqual,
|
|
||||||
} from 'docmost-client';
|
|
||||||
|
|
||||||
// Resolve fixtures relative to this test file so the test is CWD-independent.
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const CORPUS_DIR = join(here, 'fixtures', 'corpus');
|
|
||||||
const KNOWN_LIMITATIONS_DIR = join(here, 'fixtures', 'known-limitations');
|
|
||||||
|
|
||||||
/** Run a single document through export -> import -> export. */
|
|
||||||
async function roundTrip(doc: any) {
|
|
||||||
const md1 = convertProseMirrorToMarkdown(doc);
|
|
||||||
const doc2 = await markdownToProseMirror(md1);
|
|
||||||
const md2 = convertProseMirrorToMarkdown(doc2);
|
|
||||||
return { md1, md2, doc2 };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('round-trip corpus (SPEC §11)', () => {
|
|
||||||
// Discover the corpus synchronously at collection time so each fixture gets
|
|
||||||
// its own `it` with the file name in the test title.
|
|
||||||
const files = readdirSync(CORPUS_DIR)
|
|
||||||
.filter((name) => name.endsWith('.json'))
|
|
||||||
.sort();
|
|
||||||
|
|
||||||
it('has a non-empty corpus', () => {
|
|
||||||
expect(files.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const name of files) {
|
|
||||||
it(`${name}: markdown byte-stable AND canonically stable`, async () => {
|
|
||||||
const doc = JSON.parse(await readFile(join(CORPUS_DIR, name), 'utf8'));
|
|
||||||
const { md1, md2, doc2 } = await roundTrip(doc);
|
|
||||||
|
|
||||||
// 1) The byte-stable markdown property git actually needs.
|
|
||||||
expect(md2, `${name}: markdown not byte-stable`).toBe(md1);
|
|
||||||
// 2) Semantic stability (block ids stripped, default-null normalized).
|
|
||||||
expect(
|
|
||||||
docsCanonicallyEqual(doc, doc2),
|
|
||||||
`${name}: document not canonically stable`,
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// KNOWN CONVERTER LIMITATIONS (isolated so they do NOT make CI red).
|
|
||||||
//
|
|
||||||
// SPEC §11 explicitly flags images and diagrams as high round-trip risk. These
|
|
||||||
// fixtures are kept OUT of the green corpus above and asserted with `it.fails`
|
|
||||||
// so the documented divergence is locked in (the test FAILS if the converter
|
|
||||||
// ever starts round-tripping them — at which point promote the fixture into
|
|
||||||
// the corpus). The precise divergences for `image-diagrams.json` are:
|
|
||||||
//
|
|
||||||
// * A BLOCK-LEVEL image preceded by a paragraph is NOT byte-stable on the
|
|
||||||
// FIRST re-export. The HTML re-parser hoists the block <img> out of its
|
|
||||||
// line and leaves an empty paragraph behind, so `paragraph` + ``
|
|
||||||
// re-imports as paragraph + empty-paragraph + image; the empty paragraph
|
|
||||||
// adds one blank line, so export #2 grows by a one-time "\n\n" (md1 !== md2).
|
|
||||||
// This is NOT non-convergence: the growth happens exactly ONCE. The doc
|
|
||||||
// CONVERGES to a fixpoint after one extra `export→import→export` pass — the
|
|
||||||
// empty paragraph is already present after the first import, so export #2
|
|
||||||
// and export #3 are byte-identical (md2 === md3, verified).
|
|
||||||
//
|
|
||||||
// * drawio / excalidraw diagrams gain `data-align="center"` on the second
|
|
||||||
// export: the schema's diagram `align` attribute has a NON-null default of
|
|
||||||
// "center", which materializes on import; the converter only emits
|
|
||||||
// data-align when set, so it appears on export #2 but not #1. Like the
|
|
||||||
// image case, this is one-time and converges after one extra pass.
|
|
||||||
//
|
|
||||||
// * A STANDALONE block image (no preceding paragraph) IS byte-stable from
|
|
||||||
// export #1 (md1 === md2) — but it is still NOT canonically stable: on
|
|
||||||
// import the bare <img> is wrapped, gaining a leading EMPTY paragraph, so
|
|
||||||
// the canonical doc differs by that spurious paragraph node even though the
|
|
||||||
// markdown bytes match.
|
|
||||||
//
|
|
||||||
// Resolution (SPEC §11, "normalize-on-write"): rather than deep-fixing the
|
|
||||||
// converter, the engine runs ONE `export→import→export` pass when writing into
|
|
||||||
// the vault; from that fixpoint onward the form is byte-stable, so git sees no
|
|
||||||
// phantom diff. The green corpus above avoids these one-time asymmetries by
|
|
||||||
// pre-authoring the materialized defaults (e.g. `align: "center"` on the
|
|
||||||
// diagrams in 06-diagrams.json) so a single pass is already at the fixpoint.
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
describe('round-trip KNOWN LIMITATIONS (SPEC §11 image/diagram risk)', () => {
|
|
||||||
it.fails(
|
|
||||||
'image-diagrams.json is NOT byte-stable on export #1 (block image hoist + diagram align default; converges after one extra pass — SPEC §11 normalize-on-write)',
|
|
||||||
async () => {
|
|
||||||
const doc = JSON.parse(
|
|
||||||
await readFile(join(KNOWN_LIMITATIONS_DIR, 'image-diagrams.json'), 'utf8'),
|
|
||||||
);
|
|
||||||
const { md1, md2 } = await roundTrip(doc);
|
|
||||||
// This assertion FAILS today (documented divergence). `it.fails` turns a
|
|
||||||
// failing body into a PASS; if the converter is fixed this flips and the
|
|
||||||
// test goes red, prompting promotion into the green corpus.
|
|
||||||
expect(md2).toBe(md1);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -8,7 +8,7 @@ import { runPush, LAST_PUSHED_REF } from '../src/engine/push';
|
|||||||
import type { PushDeps } from '../src/engine/push';
|
import type { PushDeps } from '../src/engine/push';
|
||||||
import { VaultGit } from '../src/engine/git';
|
import { VaultGit } from '../src/engine/git';
|
||||||
import type { Settings } from '../src/engine/settings';
|
import type { Settings } from '../src/engine/settings';
|
||||||
import { serializeDocmostMarkdownBody } from '../src/lib/index';
|
import { serializeDocmostMarkdownBody } from '@docmost/prosemirror-markdown';
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
import { runPush, LAST_PUSHED_REF, DOCMOST_BRANCH } from '../src/engine/push';
|
import { runPush, LAST_PUSHED_REF, DOCMOST_BRANCH } from '../src/engine/push';
|
||||||
import type { PushDeps } from '../src/engine/push';
|
import type { PushDeps } from '../src/engine/push';
|
||||||
import type { Settings } from '../src/engine/settings';
|
import type { Settings } from '../src/engine/settings';
|
||||||
import { serializePageFile } from '../src/lib/page-file';
|
import { serializePageFile } from '@docmost/prosemirror-markdown';
|
||||||
|
|
||||||
/** A native page file: `gitmost_id` frontmatter + clean body (title = filename). */
|
/** A native page file: `gitmost_id` frontmatter + clean body (title = filename). */
|
||||||
function fileFor(pageId: string, body = 'body'): string {
|
function fileFor(pageId: string, body = 'body'): string {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import { stabilizePageFile, type PageMeta } from '../src/engine/stabilize.js';
|
import { stabilizePageFile, type PageMeta } from '../src/engine/stabilize.js';
|
||||||
// markdownToProseMirror lives in collaboration.ts; importing it mutates the
|
// markdownToProseMirror lives in collaboration.ts; importing it mutates the
|
||||||
// global DOM via jsdom at module load time (required for @tiptap/html under Node).
|
// global DOM via jsdom at module load time (required for @tiptap/html under Node).
|
||||||
import { markdownToProseMirror } from '../src/lib/markdown-to-prosemirror.js';
|
import { markdownToProseMirror } from '@docmost/prosemirror-markdown';
|
||||||
import { parseDocmostMarkdown } from '../src/lib/markdown-document.js';
|
import { parseDocmostMarkdown } from '@docmost/prosemirror-markdown';
|
||||||
|
|
||||||
// stabilize.ts (SPEC §11 normalize-on-write) was 0% covered (only the gated e2e
|
// stabilize.ts (SPEC §11 normalize-on-write) was 0% covered (only the gated e2e
|
||||||
// touched it). stabilizePageFile is import-testable: build a small ProseMirror
|
// touched it). stabilizePageFile is import-testable: build a small ProseMirror
|
||||||
@@ -22,16 +22,27 @@ const meta: PageMeta = {
|
|||||||
|
|
||||||
describe('stabilizePageFile — normalize-on-write fixpoint (SPEC §11)', () => {
|
describe('stabilizePageFile — normalize-on-write fixpoint (SPEC §11)', () => {
|
||||||
it('reaches a byte-identical fixpoint after one extra export/import/export pass', async () => {
|
it('reaches a byte-identical fixpoint after one extra export/import/export pass', async () => {
|
||||||
// A diagram is the canonical one-pass asymmetry: drawio's `align` default of
|
// A diagram inside a column is the canonical one-pass asymmetry: on the
|
||||||
// "center" materializes on import, so a NAIVE export differs on the second
|
// raw-HTML/columns path a diagram's `align` default of "center" materializes
|
||||||
// export. stabilizePageFile runs the convergence pass at write time, so the
|
// on import, so a NAIVE export differs on the second export. (#293 canon #8
|
||||||
// written body must already be at the fixpoint: re-importing its body and
|
// made the TOP-LEVEL diagram form — `<!--drawio …-->` — byte-stable by
|
||||||
|
// omitting the default, so the asymmetry now lives only on the columns path
|
||||||
|
// where the schema `<div data-type="drawio">` form is retained.)
|
||||||
|
// stabilizePageFile runs the convergence pass at write time, so the written
|
||||||
|
// body must already be at the fixpoint: re-importing its body and
|
||||||
// re-stabilizing yields the exact same bytes.
|
// re-stabilizing yields the exact same bytes.
|
||||||
const content = {
|
const content = {
|
||||||
type: 'doc',
|
type: 'doc',
|
||||||
content: [
|
content: [
|
||||||
{ type: 'paragraph', content: [{ type: 'text', text: 'intro' }] },
|
{ type: 'paragraph', content: [{ type: 'text', text: 'intro' }] },
|
||||||
{ type: 'drawio', attrs: { src: '/d.drawio' } },
|
{
|
||||||
|
type: 'columns',
|
||||||
|
attrs: { layout: 'two_equal' },
|
||||||
|
content: [
|
||||||
|
{ type: 'column', content: [{ type: 'drawio', attrs: { src: '/d.drawio' } }] },
|
||||||
|
{ type: 'column', content: [{ type: 'paragraph', content: [{ type: 'text', text: 'side' }] }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ type: 'paragraph', content: [{ type: 'text', text: 'outro' }] },
|
{ type: 'paragraph', content: [{ type: 'text', text: 'outro' }] },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { getSchema } from "@tiptap/core";
|
import { getSchema } from "@tiptap/core";
|
||||||
|
|
||||||
import { markdownToProseMirror } from "../src/lib/markdown-to-prosemirror";
|
import { markdownToProseMirror } from "@docmost/prosemirror-markdown";
|
||||||
import { docmostExtensions } from "../src/lib/docmost-schema";
|
import { docmostExtensions } from "@docmost/prosemirror-markdown";
|
||||||
|
|
||||||
// REGRESSION LOCK for the stripEmptyParagraphs schema-validity guard.
|
// REGRESSION LOCK for the stripEmptyParagraphs schema-validity guard.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -18,6 +18,25 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
|
// Coverage gate (issue #324). The v8 provider is used deliberately: the
|
||||||
|
// istanbul provider instruments sources by rewriting their AST, which broke
|
||||||
|
// on the ESM `@docmost/editor-ext` barrel import; v8 collects native
|
||||||
|
// coverage from the runtime and never re-parses ESM, so it sidesteps that.
|
||||||
|
// Thresholds are calibrated a few points BELOW the level measured on
|
||||||
|
// develop so the gate passes today but fails on a real regression. Numbers
|
||||||
|
// reflect the files actually exercised by the suite (`all: false`).
|
||||||
|
coverage: {
|
||||||
|
enabled: true,
|
||||||
|
provider: 'v8',
|
||||||
|
reporter: ['text-summary', 'text'],
|
||||||
|
all: false,
|
||||||
|
thresholds: {
|
||||||
|
statements: 88,
|
||||||
|
branches: 75,
|
||||||
|
functions: 72,
|
||||||
|
lines: 88,
|
||||||
|
},
|
||||||
|
},
|
||||||
// Runtime suites. The `.test.ts` glob deliberately EXCLUDES the type-only
|
// Runtime suites. The `.test.ts` glob deliberately EXCLUDES the type-only
|
||||||
// contract file (`*.test-d.ts`), which is enforced by the typecheck pass
|
// contract file (`*.test-d.ts`), which is enforced by the typecheck pass
|
||||||
// below instead — so the 35 runtime suites are never typechecked.
|
// below instead — so the 35 runtime suites are never typechecked.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,133 +0,0 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
||||||
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
||||||
import { createDocmostMcpServer } from "./index.js";
|
|
||||||
/**
|
|
||||||
* Build a stateful Streamable-HTTP handler for the Docmost MCP server. The
|
|
||||||
* embedding host (the gitmost NestJS server) bridges its raw Node req/res into
|
|
||||||
* `handleRequest`. One McpServer + transport is created per MCP session and
|
|
||||||
* kept alive between requests, keyed by the `mcp-session-id` header.
|
|
||||||
*
|
|
||||||
* `config` is EITHER a static `DocmostMcpConfig` (back-compat: stdio + the env
|
|
||||||
* service account, unchanged) OR a `McpConfigResolver` run once per session at
|
|
||||||
* `initialize` to bind that session to the request's identity.
|
|
||||||
*/
|
|
||||||
export function createMcpHttpHandler(config, options = {}) {
|
|
||||||
// One transport (and one McpServer) per MCP session, keyed by session id.
|
|
||||||
const transports = {};
|
|
||||||
// Last activity timestamp per session id, used for idle eviction.
|
|
||||||
const lastSeen = {};
|
|
||||||
// Anti-session-fixation: the opaque identity key bound to each session at
|
|
||||||
// initialize. A later request for that session whose key differs is rejected.
|
|
||||||
const sessionIdentity = {};
|
|
||||||
// Write a JSON-RPC error and end the response. Used for the 400/401 paths so
|
|
||||||
// every early rejection is a well-formed JSON-RPC error, not a torn response.
|
|
||||||
const sendJsonRpcError = (res, statusCode, code, message) => {
|
|
||||||
res.statusCode = statusCode;
|
|
||||||
res.setHeader("Content-Type", "application/json");
|
|
||||||
res.end(JSON.stringify({
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
error: { code, message },
|
|
||||||
id: null,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
// Idle session TTL (ms): a session with no activity for this long is evicted.
|
|
||||||
// Defaults to 30 min; overridable via MCP_SESSION_IDLE_MS.
|
|
||||||
const idleTtlMs = (() => {
|
|
||||||
const parsed = parseInt(process.env.MCP_SESSION_IDLE_MS ?? "", 10);
|
|
||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 30 * 60 * 1000;
|
|
||||||
})();
|
|
||||||
// Periodically close transports idle longer than the TTL. transport.close()
|
|
||||||
// triggers its onclose, which removes it from `transports`; we also drop the
|
|
||||||
// lastSeen entry. unref() so this timer never keeps the process alive.
|
|
||||||
const sweepIntervalMs = 5 * 60 * 1000;
|
|
||||||
const sweepTimer = setInterval(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const sid of Object.keys(transports)) {
|
|
||||||
if (now - (lastSeen[sid] ?? 0) > idleTtlMs) {
|
|
||||||
void transports[sid].close();
|
|
||||||
delete lastSeen[sid];
|
|
||||||
delete sessionIdentity[sid];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, sweepIntervalMs);
|
|
||||||
sweepTimer.unref();
|
|
||||||
async function handleRequest(req, res, parsedBody) {
|
|
||||||
const sessionId = req.headers["mcp-session-id"];
|
|
||||||
const method = (req.method || "GET").toUpperCase();
|
|
||||||
let transport = sessionId ? transports[sessionId] : undefined;
|
|
||||||
if (method === "POST" && !transport) {
|
|
||||||
// A new session may only be created by an initialize request without a
|
|
||||||
// session id.
|
|
||||||
if (sessionId || !isInitializeRequest(parsedBody)) {
|
|
||||||
sendJsonRpcError(res, 400, -32000, "Bad Request: no valid session ID provided");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Resolve the per-session config from the request (per-user identity) when
|
|
||||||
// a resolver was supplied; otherwise use the static config unchanged. The
|
|
||||||
// resolver may throw (e.g. bad credentials) — surface a clean 401, never
|
|
||||||
// a created session.
|
|
||||||
let sessionConfig;
|
|
||||||
let identity;
|
|
||||||
try {
|
|
||||||
sessionConfig =
|
|
||||||
typeof config === "function" ? await config(req) : config;
|
|
||||||
if (options.identify)
|
|
||||||
identity = await options.identify(req);
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
sendJsonRpcError(res, 401, -32001, err instanceof Error ? err.message : "Unauthorized");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
transport = new StreamableHTTPServerTransport({
|
|
||||||
sessionIdGenerator: () => randomUUID(),
|
|
||||||
onsessioninitialized: (sid) => {
|
|
||||||
transports[sid] = transport;
|
|
||||||
lastSeen[sid] = Date.now();
|
|
||||||
// Bind the resolved identity to the new session id for anti-fixation.
|
|
||||||
if (identity !== undefined)
|
|
||||||
sessionIdentity[sid] = identity;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
transport.onclose = () => {
|
|
||||||
const sid = transport.sessionId;
|
|
||||||
if (sid && transports[sid])
|
|
||||||
delete transports[sid];
|
|
||||||
if (sid)
|
|
||||||
delete sessionIdentity[sid];
|
|
||||||
};
|
|
||||||
const server = createDocmostMcpServer(sessionConfig);
|
|
||||||
await server.connect(transport);
|
|
||||||
await transport.handleRequest(req, res, parsedBody);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!transport) {
|
|
||||||
sendJsonRpcError(res, 400, -32000, "Bad Request: no valid session ID provided");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Anti-session-fixation: a request reusing an existing session id must
|
|
||||||
// present credentials/token that resolve to the SAME identity bound at
|
|
||||||
// initialize, otherwise reject with 401. This prevents hijacking another
|
|
||||||
// user's established session by replaying its session id with different
|
|
||||||
// credentials.
|
|
||||||
if (options.identify && sessionId && sessionId in sessionIdentity) {
|
|
||||||
let presented;
|
|
||||||
try {
|
|
||||||
presented = await options.identify(req);
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
sendJsonRpcError(res, 401, -32001, err instanceof Error ? err.message : "Unauthorized");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (presented !== sessionIdentity[sessionId]) {
|
|
||||||
sendJsonRpcError(res, 401, -32001, "Credentials do not match the user that owns this MCP session.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Routing to an existing transport: refresh its idle timestamp.
|
|
||||||
if (sessionId)
|
|
||||||
lastSeen[sessionId] = Date.now();
|
|
||||||
await transport.handleRequest(req, res, parsedBody);
|
|
||||||
}
|
|
||||||
return { handleRequest };
|
|
||||||
}
|
|
||||||
@@ -1,782 +0,0 @@
|
|||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { readFileSync } from "fs";
|
|
||||||
import { fileURLToPath } from "url";
|
|
||||||
import { dirname, join } from "path";
|
|
||||||
import { DocmostClient } from "./client.js";
|
|
||||||
import { parseNodeArg } from "./lib/parse-node-arg.js";
|
|
||||||
import { SHARED_TOOL_SPECS } from "./tool-specs.js";
|
|
||||||
// Re-export the client and its config type so embedding hosts (e.g. the gitmost
|
|
||||||
// NestJS server) can `import('@docmost/mcp')` and construct a DocmostClient
|
|
||||||
// directly — for the credentials variant OR the per-user getToken variant.
|
|
||||||
export { DocmostClient } from "./client.js";
|
|
||||||
// Re-export the zod-agnostic shared tool-spec registry so the in-app AI-SDK
|
|
||||||
// service can read it off the loaded module (it cannot import the ESM package's
|
|
||||||
// internals directly; it goes through loadDocmostMcp()).
|
|
||||||
export { SHARED_TOOL_SPECS } from "./tool-specs.js";
|
|
||||||
// Read version from package.json
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
const packageJson = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
|
|
||||||
const VERSION = packageJson.version;
|
|
||||||
// Configuration for an MCP server instance is the DocmostMcpConfig union
|
|
||||||
// (credentials OR getToken) defined and re-exported above. The factory below is
|
|
||||||
// fully side-effect-free on import: it reads no environment variables and opens
|
|
||||||
// no transport. The standalone stdio entrypoint (stdio.ts) and the HTTP handler
|
|
||||||
// (http.ts) supply this config and own the process/transport lifecycle.
|
|
||||||
// --- Modern McpServer Implementation ---
|
|
||||||
// Editing guide surfaced to MCP clients in the initialize result so they can
|
|
||||||
// pick the right tool by intent and avoid resending whole documents.
|
|
||||||
const SERVER_INSTRUCTIONS = "Docmost editing guide — choose the tool by intent: fix wording/typos/numbers (text inside blocks) -> edit_page_text (no node id needed). Change ONE block (paragraph/heading/callout/table cell/etc.) structurally -> patch_node (address by attrs.id from get_page_json). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Images -> insert_image (add an image from a web URL) / replace_image (swap an existing image for one from a web URL). New page -> create_page (Markdown). Bulk/structural rewrite or nodes without an id -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Rename a page (title only) -> rename_page. Read -> get_page (Markdown, lossy) or get_page_json (lossless ProseMirror with block ids). Comments -> create_comment (always inline; requires an EXACT selection — the contiguous text to anchor/highlight on; fails rather than leaving an unanchored comment), list_comments, update_comment, resolve_comment (resolve/reopen a thread, reversible — prefer over delete to close), delete_comment, check_new_comments. Propose a concrete text fix for one-click human approval -> create_comment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Tip: read block ids via get_page_json, then use patch_node/insert_node/delete_node so you never resend the full document. " +
|
|
||||||
"Complex/scripted rewrite (multiple coordinated edits, footnotes, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes. " +
|
|
||||||
"Review what changed -> diff_page_versions (compare a historyId to current, or two history versions). See a page's saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). " +
|
|
||||||
"Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown.";
|
|
||||||
// Helper to format JSON responses
|
|
||||||
const jsonContent = (data) => ({
|
|
||||||
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
|
||||||
});
|
|
||||||
/**
|
|
||||||
* Create a fully configured Docmost MCP server. Side-effect-free: it does not
|
|
||||||
* read environment variables and does not connect any transport — the caller
|
|
||||||
* decides how to expose it (stdio or HTTP). The client talks to Docmost over
|
|
||||||
* REST + the collaboration WebSocket using the provided service-account
|
|
||||||
* credentials and auto-re-authenticates.
|
|
||||||
*/
|
|
||||||
export function createDocmostMcpServer(config) {
|
|
||||||
// Pass the whole config union through: the client branches internally on
|
|
||||||
// credentials vs. getToken, so both the external /mcp (creds) and the
|
|
||||||
// internal per-user (getToken) paths are wired here unchanged.
|
|
||||||
const docmostClient = new DocmostClient(config);
|
|
||||||
const server = new McpServer({
|
|
||||||
name: "docmost-mcp",
|
|
||||||
version: VERSION,
|
|
||||||
}, { instructions: SERVER_INSTRUCTIONS });
|
|
||||||
// Register a tool from the shared, zod-agnostic spec registry. The spec owns
|
|
||||||
// the canonical name + model-facing description + (optional) schema builder;
|
|
||||||
// only the execute body is supplied per call. buildShape is invoked with THIS
|
|
||||||
// package's zod (v3); the in-app layer passes its own zod (v4).
|
|
||||||
//
|
|
||||||
// The spec's schema builder returns a plain ZodRawShape (Record<string,
|
|
||||||
// unknown> in the shared module since it must stay zod-agnostic), so the
|
|
||||||
// McpServer.registerTool overloads cannot infer the execute arg's shape from
|
|
||||||
// it. We type `execute` loosely and cast the call through `any`; runtime
|
|
||||||
// behaviour is unchanged — each execute body destructures the same fields the
|
|
||||||
// builder declares.
|
|
||||||
const registerShared = (spec, execute) => server.registerTool(spec.mcpName, spec.buildShape
|
|
||||||
? { description: spec.description, inputSchema: spec.buildShape(z) }
|
|
||||||
: { description: spec.description }, execute);
|
|
||||||
// Tool: get_workspace
|
|
||||||
registerShared(SHARED_TOOL_SPECS.getWorkspace, async () => {
|
|
||||||
const workspace = await docmostClient.getWorkspace();
|
|
||||||
return jsonContent(workspace);
|
|
||||||
});
|
|
||||||
// Tool: list_spaces
|
|
||||||
registerShared(SHARED_TOOL_SPECS.listSpaces, async () => {
|
|
||||||
const spaces = await docmostClient.getSpaces();
|
|
||||||
return jsonContent(spaces);
|
|
||||||
});
|
|
||||||
// Tool: list_pages
|
|
||||||
// INTENTIONAL per-transport divergence (not in the shared registry): this
|
|
||||||
// transport exposes a `tree:true` mode that returns the full nested hierarchy;
|
|
||||||
// the in-app copy keeps the same tree option but is worded for the in-app agent.
|
|
||||||
// Kept per-layer so each side can tune its own guidance.
|
|
||||||
server.registerTool("list_pages", {
|
|
||||||
description: "List most recent pages in a space ordered by updatedAt (descending). " +
|
|
||||||
"Returns a bounded list (default 50, max 100) — use search for lookups " +
|
|
||||||
"in large spaces. Pass tree:true (with spaceId) to instead get the " +
|
|
||||||
"space's full page hierarchy as a nested tree.",
|
|
||||||
inputSchema: {
|
|
||||||
spaceId: z.string().optional(),
|
|
||||||
limit: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.min(1)
|
|
||||||
.max(100)
|
|
||||||
.optional()
|
|
||||||
.describe("Max pages to return (default 50, max 100)"),
|
|
||||||
tree: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.describe("When true, return the space's full page hierarchy as a nested tree (each node has a children array) instead of the recent-by-updatedAt flat list. Requires spaceId; ignores limit."),
|
|
||||||
},
|
|
||||||
}, async ({ spaceId, limit, tree }) => {
|
|
||||||
const result = await docmostClient.listPages(spaceId, limit ?? 50, tree ?? false);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: get_page
|
|
||||||
server.registerTool("get_page", {
|
|
||||||
description: "Get page details with content converted to Markdown. The conversion is " +
|
|
||||||
"LOSSY (block ids, exact table/callout structure are approximated); for a " +
|
|
||||||
"lossless representation use get_page_json.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
},
|
|
||||||
}, async ({ pageId }) => {
|
|
||||||
const page = await docmostClient.getPage(pageId);
|
|
||||||
return jsonContent(page);
|
|
||||||
});
|
|
||||||
// Tool: get_page_json
|
|
||||||
registerShared(SHARED_TOOL_SPECS.getPageJson, async ({ pageId }) => {
|
|
||||||
const page = await docmostClient.getPageJson(pageId);
|
|
||||||
return jsonContent(page);
|
|
||||||
});
|
|
||||||
// Tool: get_outline
|
|
||||||
registerShared(SHARED_TOOL_SPECS.getOutline, async ({ pageId }) => {
|
|
||||||
const result = await docmostClient.getOutline(pageId);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: get_node
|
|
||||||
registerShared(SHARED_TOOL_SPECS.getNode, async ({ pageId, nodeId }) => {
|
|
||||||
const result = await docmostClient.getNode(pageId, nodeId);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: table_get
|
|
||||||
server.registerTool("table_get", {
|
|
||||||
description: "Read a table as a matrix. Returns {rows, cols, cells (text[][]), " +
|
|
||||||
"cellIds (paragraph id per cell, or null)}. `table` = `#<index>` from " +
|
|
||||||
"get_outline, or any block id inside the table. Use cellIds with " +
|
|
||||||
"patch_node for rich-formatted cell edits. `cols` is the FIRST row's " +
|
|
||||||
"width; ragged tables may vary per row, so use the per-row length of " +
|
|
||||||
"`cells` for each row.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
table: z.string().min(1),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, table }) => {
|
|
||||||
const result = await docmostClient.getTable(pageId, table);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: table_insert_row
|
|
||||||
// NOT in the shared registry: this transport names the table argument `table`,
|
|
||||||
// while the in-app tool names it `tableRef` (ai-chat-tools.service.ts). Sharing
|
|
||||||
// one buildShape would rename a public MCP parameter, so the table row/cell
|
|
||||||
// tools stay per-transport by design.
|
|
||||||
server.registerTool("table_insert_row", {
|
|
||||||
description: "Insert a row of plain-text cells into a table. `table` = `#<index>` or " +
|
|
||||||
"a block id inside it. `cells` = text per column (padded to the table's " +
|
|
||||||
"column count; error if more cells than columns). `index` = 0-based " +
|
|
||||||
"insert position (0 inserts before the header); omit to append at the end.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
table: z.string().min(1),
|
|
||||||
cells: z.array(z.string()),
|
|
||||||
index: z.number().int().optional(),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, table, cells, index }) => {
|
|
||||||
const result = await docmostClient.tableInsertRow(pageId, table, cells, index);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: table_delete_row
|
|
||||||
// NOT shared — same `table` (here) vs `tableRef` (in-app) parameter-name
|
|
||||||
// divergence as table_insert_row.
|
|
||||||
server.registerTool("table_delete_row", {
|
|
||||||
description: "Delete the row at 0-based `index` from a table (`table` = `#<index>` or " +
|
|
||||||
"a block id inside it). Refuses to delete the table's only row. An " +
|
|
||||||
"out-of-range `index` throws. Deleting `index` 0 removes the header row, " +
|
|
||||||
"and the next row becomes the new header.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
table: z.string().min(1),
|
|
||||||
index: z.number().int(),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, table, index }) => {
|
|
||||||
const result = await docmostClient.tableDeleteRow(pageId, table, index);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: table_update_cell
|
|
||||||
// NOT shared — same `table` (here) vs `tableRef` (in-app) parameter-name
|
|
||||||
// divergence as table_insert_row.
|
|
||||||
server.registerTool("table_update_cell", {
|
|
||||||
description: "Set the plain-text content of cell [row,col] (0-based) in a table " +
|
|
||||||
"(`table` = `#<index>` or a block id inside it). Replaces the cell's " +
|
|
||||||
"content with a single text paragraph; for rich formatting use patch_node " +
|
|
||||||
"on the cell's paragraph id from table_get.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
table: z.string().min(1),
|
|
||||||
row: z.number().int(),
|
|
||||||
col: z.number().int(),
|
|
||||||
text: z.string(),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, table, row, col, text }) => {
|
|
||||||
const result = await docmostClient.tableUpdateCell(pageId, table, row, col, text);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: create_page
|
|
||||||
server.registerTool("create_page", {
|
|
||||||
description: "Create a new page with content (automatically moves it to the correct hierarchy).",
|
|
||||||
inputSchema: {
|
|
||||||
title: z.string().min(1).describe("Title of the page"),
|
|
||||||
content: z.string().min(1).describe("Markdown content"),
|
|
||||||
spaceId: z.string().min(1),
|
|
||||||
parentPageId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("Optional parent page ID to nest under"),
|
|
||||||
},
|
|
||||||
}, async ({ title, content, spaceId, parentPageId }) => {
|
|
||||||
const result = await docmostClient.createPage(title, content, spaceId, parentPageId);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: update_page_json
|
|
||||||
server.registerTool("update_page_json", {
|
|
||||||
description: "Replace a page's content with a raw ProseMirror JSON document " +
|
|
||||||
"(lossless write: preserves the block ids, callouts, tables and " +
|
|
||||||
"attributes you pass in). Typical flow: get_page_json -> modify the " +
|
|
||||||
"JSON -> update_page_json. Keep existing node ids intact so heading " +
|
|
||||||
"anchors and history stay stable. Minimal full-doc example: " +
|
|
||||||
'{"type":"doc","content":[{"type":"paragraph","content":' +
|
|
||||||
'[{"type":"text","text":"Hi"}]}]}. `content` may be a JSON object or a ' +
|
|
||||||
"JSON string (both accepted), and is OPTIONAL: omit it to update only " +
|
|
||||||
"the title (though prefer rename_page for a title-only change). " +
|
|
||||||
"Supplying neither content nor title is an error.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1).describe("ID of the page to update"),
|
|
||||||
content: z
|
|
||||||
.any()
|
|
||||||
.optional()
|
|
||||||
.describe('ProseMirror document {"type":"doc","content":[...]} (JSON object or ' +
|
|
||||||
"JSON string). Omit to rename only."),
|
|
||||||
title: z.string().optional().describe("Optional new title"),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, content, title }) => {
|
|
||||||
// Only parse/validate the document when it was actually supplied; when it
|
|
||||||
// is omitted, pass it straight through so the client performs a title-only
|
|
||||||
// (or no-op) update.
|
|
||||||
let doc;
|
|
||||||
if (content === undefined || content === null) {
|
|
||||||
doc = undefined;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// String -> JSON.parse (throwing on invalid); object passes through.
|
|
||||||
doc = parseNodeArg(content, "content was a string but not valid JSON");
|
|
||||||
}
|
|
||||||
const result = await docmostClient.updatePageJson(pageId, doc, title);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: export_page_markdown
|
|
||||||
server.registerTool("export_page_markdown", {
|
|
||||||
description: "Export a page to a single self-contained, lossless Docmost-flavoured " +
|
|
||||||
"Markdown file (custom extensions): YAML-free meta header, body with " +
|
|
||||||
"inline comment anchors and diagrams, and a trailing comments-thread " +
|
|
||||||
"block. Designed for a download -> edit body -> import_page_markdown " +
|
|
||||||
"round-trip that preserves everything, including comment highlights. " +
|
|
||||||
"Comment THREADS are preserved in the file but are not re-pushed to the " +
|
|
||||||
"server on import.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
},
|
|
||||||
}, async ({ pageId }) => {
|
|
||||||
const md = await docmostClient.exportPageMarkdown(pageId);
|
|
||||||
return { content: [{ type: "text", text: md }] };
|
|
||||||
});
|
|
||||||
// Tool: import_page_markdown
|
|
||||||
registerShared(SHARED_TOOL_SPECS.importPageMarkdown, async ({ pageId, markdown }) => {
|
|
||||||
const res = await docmostClient.importPageMarkdown(pageId, markdown);
|
|
||||||
return jsonContent(res);
|
|
||||||
});
|
|
||||||
// Tool: copy_page_content
|
|
||||||
registerShared(SHARED_TOOL_SPECS.copyPageContent, async ({ sourcePageId, targetPageId }) => {
|
|
||||||
const result = await docmostClient.copyPageContent(sourcePageId, targetPageId);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: rename_page
|
|
||||||
server.registerTool("rename_page", {
|
|
||||||
description: "Rename a page (change its title only) without touching or resending " +
|
|
||||||
"its content.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1).describe("ID of the page to rename"),
|
|
||||||
title: z.string().min(1).describe("New title"),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, title }) => {
|
|
||||||
const result = await docmostClient.renamePage(pageId, title);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: edit_page_text
|
|
||||||
registerShared(SHARED_TOOL_SPECS.editPageText, async ({ pageId, edits }) => {
|
|
||||||
const result = await docmostClient.editPageText(pageId, edits);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: stash_page — returns a resource_link (NOT embedded text) so the doc
|
|
||||||
// body never enters the model context. Registered directly (not via
|
|
||||||
// registerShared) because that helper only emits text content. Also returns
|
|
||||||
// `structuredContent` carrying the full documented `{uri, sha256, size, images}`
|
|
||||||
// shape alongside the resource_link, so MCP clients receive the blob's sha256
|
|
||||||
// (its ETag, for integrity) and mirror counts, not just the link.
|
|
||||||
server.registerTool(SHARED_TOOL_SPECS.stashPage.mcpName, {
|
|
||||||
description: SHARED_TOOL_SPECS.stashPage.description,
|
|
||||||
inputSchema: SHARED_TOOL_SPECS.stashPage.buildShape(z),
|
|
||||||
}, async ({ pageId }) => {
|
|
||||||
const result = await docmostClient.stashPage(pageId);
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "resource_link",
|
|
||||||
uri: result.uri,
|
|
||||||
name: "page.json",
|
|
||||||
mimeType: "application/json",
|
|
||||||
size: result.size,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
// Mirror the full documented result shape ({ uri, size, sha256, images })
|
|
||||||
// as structuredContent so MCP clients get the blob's sha256 (its ETag, for
|
|
||||||
// integrity) and the mirror counts, not just the resource_link.
|
|
||||||
structuredContent: {
|
|
||||||
uri: result.uri,
|
|
||||||
sha256: result.sha256,
|
|
||||||
size: result.size,
|
|
||||||
images: result.images,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
// Tool: patch_node — schema + description from the shared registry (identical
|
|
||||||
// across both transports). The execute body keeps its own parseNodeArg
|
|
||||||
// normalization (the model sometimes serializes `node` as a JSON string).
|
|
||||||
registerShared(SHARED_TOOL_SPECS.patchNode, async ({ pageId, nodeId, node }) => {
|
|
||||||
const parsedNode = parseNodeArg(node);
|
|
||||||
const result = await docmostClient.patchNode(pageId, nodeId, parsedNode);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: insert_node — schema + description from the shared registry. As with
|
|
||||||
// patch_node, the execute body retains parseNodeArg on the incoming node.
|
|
||||||
registerShared(SHARED_TOOL_SPECS.insertNode, async ({ pageId, node, position, anchorNodeId, anchorText }) => {
|
|
||||||
const parsedNode = parseNodeArg(node);
|
|
||||||
const result = await docmostClient.insertNode(pageId, parsedNode, {
|
|
||||||
position,
|
|
||||||
anchorNodeId,
|
|
||||||
anchorText,
|
|
||||||
});
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: delete_node
|
|
||||||
registerShared(SHARED_TOOL_SPECS.deleteNode, async ({ pageId, nodeId }) => {
|
|
||||||
const result = await docmostClient.deleteNode(pageId, nodeId);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: insert_image
|
|
||||||
server.registerTool("insert_image", {
|
|
||||||
description: "Download an image from a web (http/https) URL and insert it into " +
|
|
||||||
"a page in one step. By default " +
|
|
||||||
"appends the image at the end of the page. With replaceText, replaces the " +
|
|
||||||
"first top-level block whose text contains that string (handy for " +
|
|
||||||
'swapping a text placeholder like "[image: foo.png]" for the real image). ' +
|
|
||||||
"With afterText, inserts the image right after the first block containing " +
|
|
||||||
"that string. Preserves all other block ids.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
imageUrl: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.describe("http(s) URL of the image to download and upload"),
|
|
||||||
align: z.enum(["left", "center", "right"]).optional(),
|
|
||||||
alt: z.string().optional(),
|
|
||||||
replaceText: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("Replace the first top-level block whose text contains this string with the image"),
|
|
||||||
afterText: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("Insert the image right after the first top-level block whose text contains this string"),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, imageUrl, align, alt, replaceText, afterText }) => {
|
|
||||||
const result = await docmostClient.insertImage(pageId, imageUrl, {
|
|
||||||
align,
|
|
||||||
alt,
|
|
||||||
replaceText,
|
|
||||||
afterText,
|
|
||||||
});
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: replace_image
|
|
||||||
server.registerTool("replace_image", {
|
|
||||||
description: "Replace an existing image on a page with a new image fetched from a web " +
|
|
||||||
"(http/https) URL: uploads the new file as a NEW " +
|
|
||||||
"attachment (fresh clean URL that renders and busts browser caches), then " +
|
|
||||||
"repoints every image node referencing the old attachmentId (recursively, " +
|
|
||||||
"incl. callouts/tables) via the live document, preserving comments, " +
|
|
||||||
"alignment and alt. The old attachment is left as an unreferenced orphan " +
|
|
||||||
"(Docmost has no API to delete a single attachment; it is removed only when " +
|
|
||||||
"the page/space is deleted). In-place byte overwrite is avoided because some " +
|
|
||||||
"Docmost versions corrupt the attachment (HTTP 500) on overwrite.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
attachmentId: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.describe("attachmentId of the image currently in the page to replace"),
|
|
||||||
imageUrl: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.describe("http(s) URL of the new image to download"),
|
|
||||||
align: z.enum(["left", "center", "right"]).optional(),
|
|
||||||
alt: z.string().optional(),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, attachmentId, imageUrl, align, alt }) => {
|
|
||||||
const result = await docmostClient.replaceImage(pageId, attachmentId, imageUrl, {
|
|
||||||
align,
|
|
||||||
alt,
|
|
||||||
});
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: share_page
|
|
||||||
// INTENTIONAL per-transport divergence (not shared): the in-app copy adds a
|
|
||||||
// security-confirmation framing ("only share when the user explicitly asked,
|
|
||||||
// since this exposes the page to anyone with the link") tuned for the in-app
|
|
||||||
// agent; this transport keeps the plain public-URL wording.
|
|
||||||
server.registerTool("share_page", {
|
|
||||||
description: "Make a page publicly accessible (idempotent) and return its public " +
|
|
||||||
"URL. The URL format is <app>/share/<key>/p/<slugId>.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1).describe("ID of the page to share"),
|
|
||||||
searchIndexing: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.describe("Allow search engines to index the page (default true)"),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, searchIndexing }) => {
|
|
||||||
const result = await docmostClient.sharePage(pageId, searchIndexing ?? true);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: unshare_page
|
|
||||||
registerShared(SHARED_TOOL_SPECS.unsharePage, async ({ pageId }) => {
|
|
||||||
const result = await docmostClient.unsharePage(pageId);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: list_shares
|
|
||||||
registerShared(SHARED_TOOL_SPECS.listShares, async () => {
|
|
||||||
const result = await docmostClient.listShares();
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: move_page
|
|
||||||
server.registerTool("move_page", {
|
|
||||||
description: "Move a page to a new parent (nesting) or root. Essential for organizing pages created via 'create_page'.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
parentPageId: z
|
|
||||||
.string()
|
|
||||||
.nullable()
|
|
||||||
.optional()
|
|
||||||
.describe("Target parent page ID. Pass 'null' or empty string to move to root."),
|
|
||||||
position: z
|
|
||||||
.string()
|
|
||||||
.min(5)
|
|
||||||
.optional()
|
|
||||||
.describe("fractional-index position key; min 5 chars; omit to append at the end."),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, parentPageId, position }) => {
|
|
||||||
const finalParentId = parentPageId === "" || parentPageId === "null" ? null : parentPageId;
|
|
||||||
// Cheap cycle guard: a page cannot be moved directly under itself.
|
|
||||||
// (Deeper descendant-cycle detection is intentionally out of scope.)
|
|
||||||
if (finalParentId !== null && finalParentId === pageId) {
|
|
||||||
throw new Error("cannot move a page under itself");
|
|
||||||
}
|
|
||||||
const result = await docmostClient.movePage(pageId, finalParentId || null, position);
|
|
||||||
// Require POSITIVE confirmation: the live /pages/move success shape is
|
|
||||||
// exactly { success: true, status: 200 }. An empty body, a 204, or any odd
|
|
||||||
// shape lacking success === true must NOT be reported as a successful move,
|
|
||||||
// so we surface the raw API result instead of declaring success.
|
|
||||||
if (!(result && typeof result === "object" && result.success === true)) {
|
|
||||||
throw new Error(`Failed to move page ${pageId}: ${JSON.stringify(result)}`);
|
|
||||||
}
|
|
||||||
return jsonContent({
|
|
||||||
message: `Successfully moved page ${pageId} to parent ${finalParentId || "root"}`,
|
|
||||||
result,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// Tool: delete_page
|
|
||||||
server.registerTool("delete_page", {
|
|
||||||
description: "Delete a single page by ID.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
},
|
|
||||||
}, async ({ pageId }) => {
|
|
||||||
await docmostClient.deletePage(pageId);
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{ type: "text", text: `Successfully deleted page ${pageId}` },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
// --- Comment tools (ported from upstream PR #3 by Max Nikitin) ---
|
|
||||||
// Tool: list_comments
|
|
||||||
server.registerTool("list_comments", {
|
|
||||||
description: "List all comments on a page (paginated). Content is returned as Markdown.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().describe("ID of the page"),
|
|
||||||
},
|
|
||||||
}, async ({ pageId }) => {
|
|
||||||
const comments = await docmostClient.listComments(pageId);
|
|
||||||
return jsonContent(comments);
|
|
||||||
});
|
|
||||||
// Tool: create_comment
|
|
||||||
// INTENTIONAL per-transport divergence (not shared): the in-app copy tunes the
|
|
||||||
// guidance for the in-app agent (e.g. "retry with a corrected EXACT selection"
|
|
||||||
// and "Reversible via the comment UI"); this transport keeps its own wording.
|
|
||||||
server.registerTool("create_comment", {
|
|
||||||
description: "Create a new comment on a page. The comment is ALWAYS inline and is " +
|
|
||||||
"anchored to (highlights) its `selection` text — there are no page-level " +
|
|
||||||
"comments. Content is provided as Markdown and automatically converted. " +
|
|
||||||
"A top-level comment REQUIRES an exact `selection`; if the selection " +
|
|
||||||
"cannot be found in the page the call fails (no orphan comment is left). " +
|
|
||||||
"Replies (parentCommentId set) inherit the parent's anchor and take no " +
|
|
||||||
"selection. You may also attach a `suggestedText` proposing a replacement " +
|
|
||||||
"for the `selection`; a human applies (or rejects) it from the UI. When " +
|
|
||||||
"`suggestedText` is set the `selection` MUST occur exactly once in the " +
|
|
||||||
"page — expand it with surrounding context if it is ambiguous.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().describe("ID of the page to comment on"),
|
|
||||||
content: z.string().min(1).describe("Comment content in Markdown format"),
|
|
||||||
selection: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
// Enforce the documented 250-char cap to match the description above.
|
|
||||||
.max(250)
|
|
||||||
.optional()
|
|
||||||
.describe("EXACT contiguous text from a single paragraph/block to anchor the " +
|
|
||||||
"comment on (<=250 chars). Required for a top-level comment; omit " +
|
|
||||||
"only when replying via parentCommentId."),
|
|
||||||
parentCommentId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("Parent comment ID to create a reply (max 2 nesting levels)"),
|
|
||||||
suggestedText: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.max(2000)
|
|
||||||
.optional()
|
|
||||||
.describe("Optional proposed replacement (PLAIN TEXT) for the `selection`, " +
|
|
||||||
"applied by a human via the UI (never auto-applied). REQUIRES a " +
|
|
||||||
"`selection`; NOT allowed on a reply. When set, the `selection` must " +
|
|
||||||
"be UNIQUE in the page — expand it with surrounding context (still " +
|
|
||||||
"<=250 chars) if it occurs more than once, or the call is refused."),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, content, selection, parentCommentId, suggestedText }) => {
|
|
||||||
if (!parentCommentId && (!selection || !selection.trim())) {
|
|
||||||
throw new Error("create_comment: a 'selection' (exact text to anchor on) is required for a top-level comment; omit it only when replying via parentCommentId.");
|
|
||||||
}
|
|
||||||
if (suggestedText !== undefined) {
|
|
||||||
if (parentCommentId) {
|
|
||||||
throw new Error("create_comment: 'suggestedText' cannot be attached to a reply; it applies only to a top-level inline comment.");
|
|
||||||
}
|
|
||||||
if (!selection || !selection.trim()) {
|
|
||||||
throw new Error("create_comment: 'suggestedText' requires a 'selection' to anchor and rewrite.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const result = await docmostClient.createComment(pageId, content, "inline", selection, parentCommentId, suggestedText);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: update_comment
|
|
||||||
server.registerTool("update_comment", {
|
|
||||||
description: "Update an existing comment's content. Only the comment creator can " +
|
|
||||||
"update it. Content is provided as Markdown.",
|
|
||||||
inputSchema: {
|
|
||||||
commentId: z.string().min(1).describe("ID of the comment to update"),
|
|
||||||
content: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.describe("New comment content in Markdown format"),
|
|
||||||
},
|
|
||||||
}, async ({ commentId, content }) => {
|
|
||||||
const result = await docmostClient.updateComment(commentId, content);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: delete_comment
|
|
||||||
server.registerTool("delete_comment", {
|
|
||||||
description: "Delete a comment. Only the comment creator or space admin can delete it.",
|
|
||||||
inputSchema: {
|
|
||||||
commentId: z.string().min(1).describe("ID of the comment to delete"),
|
|
||||||
},
|
|
||||||
}, async ({ commentId }) => {
|
|
||||||
await docmostClient.deleteComment(commentId);
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: `Successfully deleted comment ${commentId}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
// Tool: resolve_comment
|
|
||||||
server.registerTool("resolve_comment", {
|
|
||||||
description: "Resolve (close) or reopen a comment thread. Only top-level comments can " +
|
|
||||||
"be resolved — the server rejects resolving a reply. Reversible: pass " +
|
|
||||||
"resolved=false to reopen. Resolving keeps the thread and its replies " +
|
|
||||||
"(unlike delete_comment, which permanently removes them).",
|
|
||||||
inputSchema: {
|
|
||||||
commentId: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.describe("ID of the top-level comment thread to resolve or reopen"),
|
|
||||||
resolved: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.default(true)
|
|
||||||
.describe("true (default) marks the thread resolved/closed; false reopens it"),
|
|
||||||
},
|
|
||||||
}, async ({ commentId, resolved }) => {
|
|
||||||
const result = await docmostClient.resolveComment(commentId, resolved);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: check_new_comments
|
|
||||||
server.registerTool("check_new_comments", {
|
|
||||||
description: "Check for new comments across pages in a space since a given timestamp. " +
|
|
||||||
"Optionally scope to a page subtree (folder). Returns only comments " +
|
|
||||||
"created after the specified time.",
|
|
||||||
inputSchema: {
|
|
||||||
spaceId: z.string().describe("Space ID to check for new comments"),
|
|
||||||
since: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.describe("ISO 8601 timestamp — only return comments created after this time (e.g. '2026-03-10T00:00:00Z')"),
|
|
||||||
parentPageId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("Optional root page ID to scope the check to a subtree (folder). " +
|
|
||||||
"Only pages under this parent will be checked."),
|
|
||||||
},
|
|
||||||
}, async ({ spaceId, since, parentPageId }) => {
|
|
||||||
// Reject an unparseable timestamp up front: otherwise the comparison
|
|
||||||
// against NaN silently treats every comment as "not new" and the tool
|
|
||||||
// returns zero results without signalling the bad input.
|
|
||||||
if (Number.isNaN(Date.parse(since))) {
|
|
||||||
throw new Error(`Invalid 'since' timestamp: ${JSON.stringify(since)} — expected an ISO 8601 date (e.g. '2026-03-10T00:00:00Z')`);
|
|
||||||
}
|
|
||||||
const result = await docmostClient.checkNewComments(spaceId, since, parentPageId);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: search
|
|
||||||
// INTENTIONAL per-transport divergence (not shared): the in-app `searchPages`
|
|
||||||
// runs a semantic + keyword hybrid (RRF) with in-process access control and a
|
|
||||||
// different schema (limit 1-20); this transport is a plain REST full-text search
|
|
||||||
// (limit up to 100). Different behaviour AND schema, so kept per-layer.
|
|
||||||
server.registerTool("search", {
|
|
||||||
description: "Search for pages and content. Results are bounded by `limit` " +
|
|
||||||
"(default applied by the client, max 100).",
|
|
||||||
inputSchema: {
|
|
||||||
query: z.string().min(1).describe("Search query"),
|
|
||||||
limit: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.min(1)
|
|
||||||
.max(100)
|
|
||||||
.optional()
|
|
||||||
.describe("Max results to return (max 100)"),
|
|
||||||
},
|
|
||||||
}, async ({ query, limit }) => {
|
|
||||||
// The tool exposes no spaceId filter, so pass undefined for the client's
|
|
||||||
// optional spaceId parameter and forward limit into its correct slot.
|
|
||||||
const result = await docmostClient.search(query, undefined, limit);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: docmost_transform
|
|
||||||
// INTENTIONAL per-transport divergence (not shared): the in-app `transformPage`
|
|
||||||
// deliberately omits the `deleteComments` schema field (comment-deletion
|
|
||||||
// guardrail) and carries a much shorter description; this transport exposes the
|
|
||||||
// full helper catalogue. Different schema, so kept per-layer.
|
|
||||||
server.registerTool("docmost_transform", {
|
|
||||||
description: "Edit a page by running an arbitrary JS transform `(doc, ctx) => doc` " +
|
|
||||||
"against its LIVE ProseMirror document, with a diff preview and page " +
|
|
||||||
"history as the safety net. By default dryRun=true: returns a diff " +
|
|
||||||
"preview WITHOUT writing. Set dryRun=false to apply (atomic, won't " +
|
|
||||||
"clobber concurrent edits). `doc` is the lossless ProseMirror document " +
|
|
||||||
"({type:'doc',content:[...]}); return a new doc of the same shape. " +
|
|
||||||
"`ctx` gives you: comments (the page's comments, each {id, content " +
|
|
||||||
"(markdown), selection, type}); log (array; console.log pushes to it); " +
|
|
||||||
"consume(id) (mark a comment id as consumed — those are deleted when " +
|
|
||||||
"deleteComments=true after a successful apply); and helpers: " +
|
|
||||||
"blockText(node) (plain text), walk(node, fn) (depth-first over all " +
|
|
||||||
"nodes incl. callouts/tables/lists), getList(doc, predicate) (find a " +
|
|
||||||
"node even without attrs.id), insertMarkerAfter(doc, anchor, marker, " +
|
|
||||||
"{beforeBlock}) (insert a plain unmarked text run after anchor, " +
|
|
||||||
"mark-safe), setCalloutRange(doc, n) (sync a [1]…[K] callout range to " +
|
|
||||||
"[1]…[n]), noteItem(inlineNodes) (wrap inline nodes in a listItem with a " +
|
|
||||||
"fresh id), mdToInlineNodes(markdown) (comment markdown -> inline nodes), " +
|
|
||||||
"commentsToFootnotes(doc, comments, {notesHeading}) (turn inline " +
|
|
||||||
"comments into numbered footnotes), canonicalizeFootnotes(doc) (derive " +
|
|
||||||
"footnote numbering + the single bottom list from reference order, drop " +
|
|
||||||
"orphans/duplicates — runs AUTOMATICALLY on the transform RESULT, so the " +
|
|
||||||
"applied (and dryRun-previewed) doc is always footnote-canonical; a dryRun " +
|
|
||||||
"diff may therefore show footnote tidy-ups your script did not make, and " +
|
|
||||||
"it is idempotent after the first run), and " +
|
|
||||||
"insertInlineFootnote(doc, {anchorText, text}) (author-inline footnote: " +
|
|
||||||
"marker + dedup'd definition, list derived). Footnote convention: markers are " +
|
|
||||||
"plain '[N]' text in the body; the notes are an orderedList under a " +
|
|
||||||
"heading whose text is 'Примечания переводчика'. The transform runs " +
|
|
||||||
"sandboxed (no require/process/fs/network, 5s timeout) and must return a " +
|
|
||||||
"{type:'doc'} node.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
transformJs: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.describe("A JS function `(doc, ctx) => doc` (expression-arrow or " +
|
|
||||||
"parenthesized function). It receives a clone of the live doc and " +
|
|
||||||
"ctx (comments, log, consume(id), helpers: blockText/walk/getList/" +
|
|
||||||
"insertMarkerAfter/setCalloutRange/noteItem/mdToInlineNodes/" +
|
|
||||||
"commentsToFootnotes/canonicalizeFootnotes/insertInlineFootnote) " +
|
|
||||||
"and must return a {type:'doc'} node."),
|
|
||||||
dryRun: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.default(true)
|
|
||||||
.describe("Preview only (no write) when true (default)."),
|
|
||||||
deleteComments: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.default(false)
|
|
||||||
.describe("After a successful apply, delete every comment id passed to " +
|
|
||||||
"ctx.consume(id)."),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, transformJs, dryRun, deleteComments }) => {
|
|
||||||
const result = await docmostClient.transformPage(pageId, transformJs, {
|
|
||||||
dryRun,
|
|
||||||
deleteComments,
|
|
||||||
});
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: insert_footnote
|
|
||||||
server.registerTool("insert_footnote", {
|
|
||||||
description: "Insert an AUTHOR-INLINE footnote: you specify only WHERE (anchorText) " +
|
|
||||||
"and WHAT (text). The footnote marker is placed right after anchorText in " +
|
|
||||||
"the body, and the bottom footnotes list + the numbering are derived " +
|
|
||||||
"deterministically server-side. You do NOT assign a number, and you " +
|
|
||||||
"never see or edit the footnotes list — so footnotes cannot end up out " +
|
|
||||||
"of order, orphaned, or as a raw '[^id]' block. If a footnote with the " +
|
|
||||||
"SAME text already exists, its number is REUSED (one definition, several " +
|
|
||||||
"references). The write is atomic and won't clobber concurrent edits; if " +
|
|
||||||
"anchorText is not found, nothing is written and an error is returned.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
anchorText: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.describe("A snippet of existing body text; the footnote marker is inserted " +
|
|
||||||
"immediately after its first occurrence (mark-safe)."),
|
|
||||||
text: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.describe("The footnote content as markdown (becomes the definition)."),
|
|
||||||
},
|
|
||||||
}, async ({ pageId, anchorText, text }) => {
|
|
||||||
const result = await docmostClient.insertFootnote(pageId, anchorText, text);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: diff_page_versions
|
|
||||||
registerShared(SHARED_TOOL_SPECS.diffPageVersions, async ({ pageId, from, to }) => {
|
|
||||||
const result = await docmostClient.diffPageVersions(pageId, from, to);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: list_page_history
|
|
||||||
registerShared(SHARED_TOOL_SPECS.listPageHistory, async ({ pageId, cursor }) => {
|
|
||||||
const result = await docmostClient.listPageHistory(pageId, cursor);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
// Tool: restore_page_version
|
|
||||||
registerShared(SHARED_TOOL_SPECS.restorePageVersion, async ({ historyId }) => {
|
|
||||||
const result = await docmostClient.restorePageVersion(historyId);
|
|
||||||
return jsonContent(result);
|
|
||||||
});
|
|
||||||
return server;
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import axios from "axios";
|
|
||||||
export async function getCollabToken(baseUrl, apiToken) {
|
|
||||||
try {
|
|
||||||
const response = await axios.post(`${baseUrl}/auth/collab-token`, {}, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${apiToken}`,
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// console.error('Collab Token Response:', response.data);
|
|
||||||
// Response is wrapped in { data: { token: ... } }
|
|
||||||
return response.data.data?.token || response.data.token;
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
if (axios.isAxiosError(error)) {
|
|
||||||
// Attach the HTTP status to the plain Error so callers (e.g.
|
|
||||||
// getCollabTokenWithReauth) can still detect a 401/403 after the
|
|
||||||
// original AxiosError has been wrapped away.
|
|
||||||
// Avoid leaking the full server response body by default; include only
|
|
||||||
// status + statusText. Append the body only when DEBUG is set.
|
|
||||||
let message = `Failed to get collab token: ${error.response?.status} ${error.response?.statusText}`;
|
|
||||||
if (process.env.DEBUG) {
|
|
||||||
message += ` - ${JSON.stringify(error.response?.data)}`;
|
|
||||||
}
|
|
||||||
const err = new Error(message);
|
|
||||||
err.status = error.response?.status;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Pure cookie-parsing helper extracted from `performLogin` so the parsing logic
|
|
||||||
* can be unit-tested without performing the login network request. Given the
|
|
||||||
* raw `Set-Cookie` header array from the login response, return the `authToken`
|
|
||||||
* cookie's value.
|
|
||||||
*
|
|
||||||
* Behavior (kept identical to the original inline logic):
|
|
||||||
* - throws if there is no Set-Cookie header at all;
|
|
||||||
* - matches the cookie NAME exactly (`authToken`), so a future
|
|
||||||
* `authTokenRefresh=...` cookie is NOT picked up (a `startsWith` would be);
|
|
||||||
* - returns everything after the FIRST `=` up to the first `;`, so a base64
|
|
||||||
* value containing `=` padding is preserved (a naive `split("=")` would
|
|
||||||
* truncate it);
|
|
||||||
* - cookie attributes after the first `;` (Path, HttpOnly, Expires, …) are
|
|
||||||
* ignored;
|
|
||||||
* - throws if no `authToken` cookie is present.
|
|
||||||
*/
|
|
||||||
export function extractAuthTokenFromSetCookie(cookies) {
|
|
||||||
if (!cookies) {
|
|
||||||
throw new Error("No Set-Cookie header found in login response");
|
|
||||||
}
|
|
||||||
// Match the cookie name exactly to avoid matching a future
|
|
||||||
// authTokenRefresh cookie (startsWith would catch it).
|
|
||||||
const authCookie = cookies.find((c) => {
|
|
||||||
const kv = c.split(";")[0];
|
|
||||||
return kv.slice(0, kv.indexOf("=")) === "authToken";
|
|
||||||
});
|
|
||||||
if (!authCookie) {
|
|
||||||
throw new Error("No authToken cookie found in login response");
|
|
||||||
}
|
|
||||||
// Take everything after the FIRST "=" up to the first ";".
|
|
||||||
// Splitting on "=" would truncate base64 values containing "=" padding.
|
|
||||||
const kv = authCookie.split(";")[0];
|
|
||||||
return kv.slice(kv.indexOf("=") + 1);
|
|
||||||
}
|
|
||||||
export async function performLogin(baseUrl, email, password) {
|
|
||||||
try {
|
|
||||||
const response = await axios.post(`${baseUrl}/auth/login`, {
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
// Extract token from Set-Cookie header
|
|
||||||
return extractAuthTokenFromSetCookie(response.headers["set-cookie"]);
|
|
||||||
}
|
|
||||||
catch (error) {
|
|
||||||
// Avoid leaking the full server response body by default; log only the
|
|
||||||
// HTTP status. Log the verbose body only when DEBUG is set.
|
|
||||||
if (axios.isAxiosError(error)) {
|
|
||||||
if (process.env.DEBUG) {
|
|
||||||
console.error("Login failed:", error.response?.data);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
console.error("Login failed:", error.response?.status);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
console.error("Login failed:", error.message);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,743 +0,0 @@
|
|||||||
import { HocuspocusProvider } from "@hocuspocus/provider";
|
|
||||||
import { TiptapTransformer } from "@hocuspocus/transformer";
|
|
||||||
import * as Y from "yjs";
|
|
||||||
import WebSocket from "ws";
|
|
||||||
import { marked } from "marked";
|
|
||||||
import { generateJSON } from "@tiptap/html";
|
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
|
||||||
import { updateYFragment } from "y-prosemirror";
|
|
||||||
import { JSDOM } from "jsdom";
|
|
||||||
import { docmostExtensions, docmostSchema } from "./docmost-schema.js";
|
|
||||||
import { withPageLock } from "./page-lock.js";
|
|
||||||
import { sanitizeForYjs, findUnstorableAttr } from "./node-ops.js";
|
|
||||||
import { lexFootnoteLines } from "./footnote-lex.js";
|
|
||||||
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
|
|
||||||
import { summarizeChange } from "./diff.js";
|
|
||||||
/**
|
|
||||||
* Build the descriptive error for an opaque Yjs encode failure ("Unexpected
|
|
||||||
* content type"), shared by both encode paths (`buildYDoc` -> `toYdoc` and
|
|
||||||
* `applyDocToFragment` -> `updateYFragment`) so the message wording stays in one
|
|
||||||
* place. `label` names the stage that failed (diagnostic). `sanitizeForYjs`
|
|
||||||
* already stripped `undefined` attrs, so a remaining failure is pinpointed via
|
|
||||||
* `findUnstorableAttr`.
|
|
||||||
*/
|
|
||||||
function unstorableYjsError(safe, label, e) {
|
|
||||||
const bad = findUnstorableAttr(safe);
|
|
||||||
return new Error(`Failed to encode document to Yjs (${label}): ${e instanceof Error ? e.message : String(e)}.${bad ? ` Offending attribute: ${bad}.` : " A node/mark attribute likely holds a value Yjs cannot store (e.g. undefined)."}`);
|
|
||||||
}
|
|
||||||
// Setup DOM environment for Tiptap HTML parsing in Node.js
|
|
||||||
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
|
|
||||||
global.window = dom.window;
|
|
||||||
global.document = dom.window.document;
|
|
||||||
// @ts-ignore
|
|
||||||
global.Element = dom.window.Element;
|
|
||||||
// @ts-ignore
|
|
||||||
global.WebSocket = WebSocket;
|
|
||||||
// Navigator is read-only in newer Node versions and already exists
|
|
||||||
// global.navigator = dom.window.navigator;
|
|
||||||
/**
|
|
||||||
* Hard ceiling above which we skip callout preprocessing entirely. The linear
|
|
||||||
* scanner below has no quadratic blow-up, but we still cap input defensively so
|
|
||||||
* a pathological multi-megabyte payload cannot tie up the event loop; in that
|
|
||||||
* case the markdown is passed through verbatim (callouts are simply not
|
|
||||||
* detected) rather than risking a slow scan.
|
|
||||||
*/
|
|
||||||
const MAX_CALLOUT_PREPROCESS_BYTES = 4 * 1024 * 1024; // 4 MB
|
|
||||||
/** Matches an opening callout fence: `:::type` (type captured, lower-cased). */
|
|
||||||
const CALLOUT_OPEN_RE = /^:::\s*(\w+)\s*$/;
|
|
||||||
/** Matches a bare closing callout fence: `:::`. */
|
|
||||||
const CALLOUT_CLOSE_RE = /^:::\s*$/;
|
|
||||||
/** Matches the start/end of a code fence (``` or ~~~), capturing the marker. */
|
|
||||||
const CODE_FENCE_RE = /^(\s*)(`{3,}|~{3,})/;
|
|
||||||
/**
|
|
||||||
* Pre-process Docmost-flavoured markdown: convert `:::type ... :::`
|
|
||||||
* callout blocks (the syntax our markdown export produces) into HTML
|
|
||||||
* divs that the callout extension parses. The inner content is rendered
|
|
||||||
* through marked as regular markdown.
|
|
||||||
*
|
|
||||||
* Implemented as a single linear pass over the lines (no quadratic regex
|
|
||||||
* rescan). It:
|
|
||||||
* - tracks fenced code regions (```...``` and ~~~...~~~) and never treats a
|
|
||||||
* `:::` line that lives inside a code fence as a callout delimiter, so a
|
|
||||||
* callout body that itself contains a fenced code block with a `:::` line is
|
|
||||||
* no longer corrupted;
|
|
||||||
* - matches an opening `:::type` line with the next CLOSING `:::` at the SAME
|
|
||||||
* nesting level, supporting NESTED callouts via a depth counter (an inner
|
|
||||||
* `:::type` opens a deeper level and consumes a matching `:::`);
|
|
||||||
* - emits the same `<div data-type="callout" data-callout-type="TYPE">` output
|
|
||||||
* (inner rendered through marked) as the previous regex implementation.
|
|
||||||
*/
|
|
||||||
async function preprocessCallouts(markdown) {
|
|
||||||
// Defensive cap: skip preprocessing for pathologically large inputs.
|
|
||||||
if (markdown.length > MAX_CALLOUT_PREPROCESS_BYTES) {
|
|
||||||
return markdown;
|
|
||||||
}
|
|
||||||
// Recursively transform a slice of lines, converting top-level callouts in
|
|
||||||
// that slice into <div> blocks and rendering their inner content (which may
|
|
||||||
// itself contain nested callouts) through this same function.
|
|
||||||
const transform = async (lines) => {
|
|
||||||
const out = [];
|
|
||||||
let inCodeFence = false;
|
|
||||||
let codeFenceMarker = ""; // the exact run of backticks/tildes that opened it
|
|
||||||
let i = 0;
|
|
||||||
while (i < lines.length) {
|
|
||||||
const line = lines[i];
|
|
||||||
// Inside a code fence, only its matching closing fence is significant;
|
|
||||||
// everything else (including `:::` lines) is copied through verbatim.
|
|
||||||
if (inCodeFence) {
|
|
||||||
out.push(line);
|
|
||||||
const fence = line.match(CODE_FENCE_RE);
|
|
||||||
if (fence && fence[2].startsWith(codeFenceMarker[0]) &&
|
|
||||||
fence[2].length >= codeFenceMarker.length) {
|
|
||||||
inCodeFence = false;
|
|
||||||
codeFenceMarker = "";
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// A code fence opening outside any callout body: enter code-fence mode.
|
|
||||||
const fenceOpen = line.match(CODE_FENCE_RE);
|
|
||||||
if (fenceOpen) {
|
|
||||||
inCodeFence = true;
|
|
||||||
codeFenceMarker = fenceOpen[2];
|
|
||||||
out.push(line);
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// An opening callout fence: scan forward (with code-fence and nested
|
|
||||||
// callout awareness) for its matching closing `:::` at the same level.
|
|
||||||
const open = line.match(CALLOUT_OPEN_RE);
|
|
||||||
if (open) {
|
|
||||||
const type = open[1].toLowerCase();
|
|
||||||
const bodyLines = [];
|
|
||||||
let depth = 1;
|
|
||||||
let innerInCodeFence = false;
|
|
||||||
let innerCodeFenceMarker = "";
|
|
||||||
let j = i + 1;
|
|
||||||
for (; j < lines.length; j++) {
|
|
||||||
const bl = lines[j];
|
|
||||||
if (innerInCodeFence) {
|
|
||||||
const f = bl.match(CODE_FENCE_RE);
|
|
||||||
if (f && f[2].startsWith(innerCodeFenceMarker[0]) &&
|
|
||||||
f[2].length >= innerCodeFenceMarker.length) {
|
|
||||||
innerInCodeFence = false;
|
|
||||||
innerCodeFenceMarker = "";
|
|
||||||
}
|
|
||||||
bodyLines.push(bl);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const innerFence = bl.match(CODE_FENCE_RE);
|
|
||||||
if (innerFence) {
|
|
||||||
innerInCodeFence = true;
|
|
||||||
innerCodeFenceMarker = innerFence[2];
|
|
||||||
bodyLines.push(bl);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (CALLOUT_OPEN_RE.test(bl)) {
|
|
||||||
depth++;
|
|
||||||
bodyLines.push(bl);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (CALLOUT_CLOSE_RE.test(bl)) {
|
|
||||||
depth--;
|
|
||||||
if (depth === 0)
|
|
||||||
break; // matching close for THIS callout
|
|
||||||
bodyLines.push(bl);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
bodyLines.push(bl);
|
|
||||||
}
|
|
||||||
if (j < lines.length) {
|
|
||||||
// Found the matching closing fence: render the body (recursively, so
|
|
||||||
// nested callouts are handled) and emit the callout div.
|
|
||||||
const inner = await transform(bodyLines);
|
|
||||||
const renderedInner = await marked.parse(inner);
|
|
||||||
out.push(`\n<div data-type="callout" data-callout-type="${type}">${renderedInner}</div>\n`);
|
|
||||||
i = j + 1; // skip past the closing `:::`
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// No matching close (unterminated callout): treat the opener as a
|
|
||||||
// literal line and continue, preserving the original text.
|
|
||||||
out.push(line);
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
out.push(line);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
return out.join("\n");
|
|
||||||
};
|
|
||||||
return transform(markdown.split("\n"));
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Bridge marked's checkbox lists to TipTap task lists.
|
|
||||||
*
|
|
||||||
* marked renders GitHub task list items (`- [x] done`) as a plain
|
|
||||||
* `<ul><li><p><input type="checkbox" checked> text</p></li></ul>` WITHOUT the
|
|
||||||
* markup TipTap's TaskList/TaskItem extensions parse. This rewrites such lists
|
|
||||||
* into the shape those extensions expect:
|
|
||||||
* TaskList parseHTML matches `ul[data-type="taskList"]`,
|
|
||||||
* TaskItem matches `li[data-type="taskItem"]`,
|
|
||||||
* the checked state is read from `data-checked === "true"`.
|
|
||||||
*
|
|
||||||
* A list is only converted when it has at least one `<li>` and EVERY direct
|
|
||||||
* `<li>` contains a checkbox input. Both `<ul>` and `<ol>` are considered: a
|
|
||||||
* numbered checklist (`1. [x] a`, which marked renders as an `<ol>` of checkbox
|
|
||||||
* `<li>`s) would otherwise lose its task state. TipTap task lists are unordered,
|
|
||||||
* so a matching `<ol>` is emitted as `data-type="taskList"` exactly like a
|
|
||||||
* `<ul>`. Mixed or ordinary lists (including ordinary `<ol>` lists) are left
|
|
||||||
* untouched so they keep rendering as bullet/numbered lists. The marked `<p>`
|
|
||||||
* wrapper is kept inside the `<li>` because TaskItem content allows paragraphs.
|
|
||||||
*/
|
|
||||||
function bridgeTaskLists(html) {
|
|
||||||
// Cheap early-out: if the markup contains no checkbox input at all there is
|
|
||||||
// nothing to bridge, so skip the expensive JSDOM parse entirely. This is the
|
|
||||||
// common case (most pages have no task lists).
|
|
||||||
if (!/type=["']?checkbox/i.test(html)) {
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
// Defensive cap (consistent with preprocessCallouts): skip the bridge for
|
|
||||||
// pathologically large inputs rather than running a second expensive JSDOM
|
|
||||||
// parse on a multi-megabyte payload. The markup is passed through verbatim.
|
|
||||||
if (html.length > MAX_CALLOUT_PREPROCESS_BYTES) {
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
const dom = new JSDOM(html);
|
|
||||||
const document = dom.window.document;
|
|
||||||
// Collect the checkbox(es) that belong to THIS <li> directly: either direct
|
|
||||||
// child <input type="checkbox"> elements or ones inside the <li>'s direct <p>
|
|
||||||
// child (the shape marked emits: `<li><p><input type="checkbox"> text</p></li>`).
|
|
||||||
// Checkboxes nested deeper (e.g. inside a child <ul>/<ol>) are excluded so a
|
|
||||||
// bullet <li> that merely contains a nested task sublist is not misdetected.
|
|
||||||
// Raw inline HTML can put more than one checkbox in a single <li>; we gather
|
|
||||||
// ALL of them so none survive into the converted item.
|
|
||||||
const directCheckboxes = (li) => {
|
|
||||||
const found = [];
|
|
||||||
for (const child of Array.from(li.children)) {
|
|
||||||
if (child.tagName === "INPUT" &&
|
|
||||||
child.getAttribute("type") === "checkbox") {
|
|
||||||
found.push(child);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (child.tagName === "P") {
|
|
||||||
for (const inp of Array.from(child.querySelectorAll(":scope > input[type='checkbox']"))) {
|
|
||||||
found.push(inp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return found;
|
|
||||||
};
|
|
||||||
// Both <ul> and <ol> are candidates: an <ol> whose every direct <li> carries
|
|
||||||
// its own checkbox is a numbered checklist that must also become a taskList.
|
|
||||||
const lists = Array.from(document.querySelectorAll("ul, ol"));
|
|
||||||
for (const list of lists) {
|
|
||||||
// Only consider DIRECT child <li> elements; nested lists are handled by
|
|
||||||
// their own iteration of the outer loop.
|
|
||||||
const items = Array.from(list.children).filter((child) => child.tagName === "LI");
|
|
||||||
if (items.length === 0)
|
|
||||||
continue;
|
|
||||||
const itemCheckboxes = items.map((li) => directCheckboxes(li));
|
|
||||||
// Convert only when every direct <li> carries at least one OWN checkbox.
|
|
||||||
if (!itemCheckboxes.every((boxes) => boxes.length > 0))
|
|
||||||
continue;
|
|
||||||
// A numbered checklist arrives as an <ol>. We must NOT leave the tag as
|
|
||||||
// <ol> while tagging it data-type="taskList": generateJSON would then match
|
|
||||||
// BOTH the orderedList rule (tag ol) and the taskList rule (data-type),
|
|
||||||
// emitting a phantom empty orderedList beside the real taskList. So rename a
|
|
||||||
// qualifying <ol> to a <ul> — move its <li> children over and replace it —
|
|
||||||
// leaving only the taskList rule to match. Already-<ul> lists are unchanged.
|
|
||||||
let target = list;
|
|
||||||
if (list.tagName === "OL") {
|
|
||||||
const ul = document.createElement("ul");
|
|
||||||
// Carry over existing attributes (e.g. class) so nothing is silently lost.
|
|
||||||
for (const attr of Array.from(list.attributes)) {
|
|
||||||
ul.setAttribute(attr.name, attr.value);
|
|
||||||
}
|
|
||||||
// Move every child node (including the <li>s we collected) into the <ul>.
|
|
||||||
while (list.firstChild) {
|
|
||||||
ul.appendChild(list.firstChild);
|
|
||||||
}
|
|
||||||
list.replaceWith(ul);
|
|
||||||
target = ul;
|
|
||||||
}
|
|
||||||
target.setAttribute("data-type", "taskList");
|
|
||||||
items.forEach((li, index) => {
|
|
||||||
const boxes = itemCheckboxes[index];
|
|
||||||
// The first checkbox determines the checked state (matches the previous
|
|
||||||
// single-checkbox behaviour); any extras only need removing.
|
|
||||||
const input = boxes[0] ?? null;
|
|
||||||
li.setAttribute("data-type", "taskItem");
|
|
||||||
const checked = input != null &&
|
|
||||||
(input.hasAttribute("checked") || input.checked);
|
|
||||||
li.setAttribute("data-checked", checked ? "true" : "false");
|
|
||||||
// Remove ALL direct checkbox inputs so none survive into the content
|
|
||||||
// (a raw-inline-HTML <li> may carry more than one).
|
|
||||||
for (const box of boxes) {
|
|
||||||
box.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return document.body.innerHTML;
|
|
||||||
}
|
|
||||||
// Mirror of packages/editor-ext footnote markdown handling. A `[^id]` inline
|
|
||||||
// marker becomes <sup data-footnote-ref data-id="id">, and `[^id]: text`
|
|
||||||
// definition lines are collected into a single <section data-footnotes>.
|
|
||||||
// Definition detection + fence handling are shared with analyzeFootnotes via
|
|
||||||
// lexFootnoteLines (footnote-lex.js). FOOTNOTE_REF_RE is the inline tokenizer's.
|
|
||||||
const FOOTNOTE_REF_RE = /\[\^([^\]\s]+)\]/;
|
|
||||||
function escapeFootnoteAttr(value) {
|
|
||||||
return String(value).replace(/&/g, "&").replace(/"/g, """);
|
|
||||||
}
|
|
||||||
const footnoteRefMarkedExtension = {
|
|
||||||
name: "footnoteRef",
|
|
||||||
level: "inline",
|
|
||||||
start(src) {
|
|
||||||
return src.match(/\[\^/)?.index ?? -1;
|
|
||||||
},
|
|
||||||
tokenizer(src) {
|
|
||||||
const match = FOOTNOTE_REF_RE.exec(src);
|
|
||||||
if (match && match.index === 0) {
|
|
||||||
return { type: "footnoteRef", raw: match[0], id: match[1] };
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
renderer(token) {
|
|
||||||
return `<sup data-footnote-ref data-id="${escapeFootnoteAttr(token.id)}"></sup>`;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
marked.use({ extensions: [footnoteRefMarkedExtension] });
|
|
||||||
/**
|
|
||||||
* Pull `[^id]: text` definition lines out of the body and render a single
|
|
||||||
* <section data-footnotes> for them (or "" when there are none).
|
|
||||||
*/
|
|
||||||
function extractFootnotes(markdown) {
|
|
||||||
const bodyLines = [];
|
|
||||||
const defs = [];
|
|
||||||
// Shared lexer (footnote-lex): a `[^id]: ...` line inside a ``` / ~~~ code
|
|
||||||
// block is inert and stays in the body verbatim; only real definition lines
|
|
||||||
// are pulled out. analyzeFootnotes() consumes the SAME lexer so its diagnostics
|
|
||||||
// match exactly what import keeps/strips (#166).
|
|
||||||
for (const tok of lexFootnoteLines(markdown)) {
|
|
||||||
if (!tok.inFence && tok.definition)
|
|
||||||
defs.push(tok.definition);
|
|
||||||
else
|
|
||||||
bodyLines.push(tok.line);
|
|
||||||
}
|
|
||||||
if (defs.length === 0)
|
|
||||||
return { body: markdown, section: "" };
|
|
||||||
// Duplicate definition ids: FIRST WINS, the rest are DROPPED (mirror of
|
|
||||||
// editor-ext extractFootnoteDefinitions). Reference markers are left untouched
|
|
||||||
// so repeated `[^a]` references reuse the single footnote (Pandoc semantics,
|
|
||||||
// #166). The dropped duplicate is surfaced to the caller via analyzeFootnotes
|
|
||||||
// (`duplicateDefinitions`), not silently lost. MUST stay in sync with the
|
|
||||||
// editor-ext mirror.
|
|
||||||
const firstById = new Map(); // id -> first definition text
|
|
||||||
for (const def of defs) {
|
|
||||||
if (!firstById.has(def.id))
|
|
||||||
firstById.set(def.id, def.text);
|
|
||||||
}
|
|
||||||
const inner = [...firstById.entries()]
|
|
||||||
.map(([id, text]) => `<div data-footnote-def data-id="${escapeFootnoteAttr(id)}"><p>${marked.parseInline(text || "")}</p></div>`)
|
|
||||||
.join("");
|
|
||||||
return {
|
|
||||||
body: bodyLines.join("\n"),
|
|
||||||
section: `<section data-footnotes>${inner}</section>`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Convert markdown to a ProseMirror doc using the full Docmost schema.
|
|
||||||
*
|
|
||||||
* This conversion does NOT canonicalize footnotes — it is the shared, content-
|
|
||||||
* preserving primitive used by BOTH page write paths and COMMENT bodies
|
|
||||||
* (createComment / updateComment). Canonicalization MUST NOT run on a comment
|
|
||||||
* body: a comment may legitimately contain a footnote-definition line
|
|
||||||
* (`[^1]: text`) with no matching reference, and the canonicalizer drops a
|
|
||||||
* reference-less footnotesList — which would silently delete the comment's text.
|
|
||||||
*
|
|
||||||
* Page write paths that DO need the canonical footnote topology call
|
|
||||||
* `markdownToProseMirrorCanonical` instead (markdown import, update_page markdown
|
|
||||||
* path). Keep this function reference-loss-free.
|
|
||||||
*/
|
|
||||||
export async function markdownToProseMirror(markdownContent) {
|
|
||||||
const withCallouts = await preprocessCallouts(markdownContent);
|
|
||||||
const { body, section } = extractFootnotes(withCallouts);
|
|
||||||
const html = (await marked.parse(body)) + section;
|
|
||||||
const bridged = bridgeTaskLists(html);
|
|
||||||
return generateJSON(bridged, docmostExtensions);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Page-write variant of `markdownToProseMirror`: converts markdown then enforces
|
|
||||||
* the canonical footnote topology. The footnote `section` markdown is emitted in
|
|
||||||
* DEFINITION order, but numbering derives from REFERENCE order, so without this
|
|
||||||
* the bottom list renders out of order (`1, 4, 2, 3, …`); orphan definitions and
|
|
||||||
* duplicate lists are also normalized. Idempotent — a no-op once canonical, and a
|
|
||||||
* no-op for footnote-free content.
|
|
||||||
*
|
|
||||||
* Use this ONLY for full-document PAGE writes (never for comment bodies, where it
|
|
||||||
* would drop a reference-less footnote definition — see `markdownToProseMirror`).
|
|
||||||
*/
|
|
||||||
export async function markdownToProseMirrorCanonical(markdownContent) {
|
|
||||||
return canonicalizeFootnotes(await markdownToProseMirror(markdownContent));
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Build the collaboration WebSocket URL from an API base URL:
|
|
||||||
* switch http(s)->ws(s), strip a trailing /api, mount on /collab.
|
|
||||||
* Shared by the live read and the mutate path so both target the same socket.
|
|
||||||
*/
|
|
||||||
export function buildCollabWsUrl(baseUrl) {
|
|
||||||
let wsUrl = baseUrl.replace(/^http/, "ws");
|
|
||||||
try {
|
|
||||||
const urlObj = new URL(wsUrl);
|
|
||||||
if (urlObj.pathname.endsWith("/api") || urlObj.pathname.endsWith("/api/")) {
|
|
||||||
urlObj.pathname = urlObj.pathname.replace(/\/api\/?$/, "");
|
|
||||||
}
|
|
||||||
urlObj.pathname = urlObj.pathname.replace(/\/$/, "") + "/collab";
|
|
||||||
// Drop any query/hash from the base URL so it is not carried into the
|
|
||||||
// collaboration ws URL.
|
|
||||||
urlObj.search = "";
|
|
||||||
urlObj.hash = "";
|
|
||||||
wsUrl = urlObj.toString();
|
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
// Fallback if URL parsing fails
|
|
||||||
if (!wsUrl.endsWith("/collab")) {
|
|
||||||
wsUrl = wsUrl.replace(/\/$/, "") + "/collab";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return wsUrl;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Encode a ProseMirror doc to a Yjs document, sanitizing it first and turning
|
|
||||||
* the opaque yjs "Unexpected content type" failure into a descriptive error.
|
|
||||||
*
|
|
||||||
* `sanitizeForYjs` strips `undefined` node/mark attributes (the common cause of
|
|
||||||
* the failure); if `toYdoc` still throws, `findUnstorableAttr` is used to point
|
|
||||||
* at the offending attribute path.
|
|
||||||
*/
|
|
||||||
export function buildYDoc(doc) {
|
|
||||||
const safe = sanitizeForYjs(doc);
|
|
||||||
try {
|
|
||||||
return TiptapTransformer.toYdoc(safe, "default", docmostExtensions);
|
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
throw unstorableYjsError(safe, "toYdoc", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Write a new ProseMirror doc into the live Yjs fragment by STRUCTURAL DIFF,
|
|
||||||
* preserving the Yjs identity of unchanged nodes (issue #152).
|
|
||||||
*
|
|
||||||
* The previous approach deleted the whole fragment and re-applied a fresh Y.Doc,
|
|
||||||
* which discarded every Yjs node id. y-prosemirror anchors the editor selection
|
|
||||||
* to those ids, so an open editor's cursor lost its anchor and snapped to the
|
|
||||||
* end of the document on every agent write (most visibly on comment anchoring,
|
|
||||||
* which changes no text at all). `updateYFragment` is exactly the routine the
|
|
||||||
* editor itself uses to sync ProseMirror edits into Yjs: it diffs the new node
|
|
||||||
* against the current fragment and touches only the changed children, so
|
|
||||||
* unchanged nodes keep their ids and the live cursor stays put.
|
|
||||||
*
|
|
||||||
* Must run inside a single `transact` so the diff applies atomically (no remote
|
|
||||||
* update interleaves). Keeps `buildYDoc`'s `findUnstorableAttr` diagnostic for
|
|
||||||
* the opaque "Unexpected content type" encode failure.
|
|
||||||
*/
|
|
||||||
export function applyDocToFragment(ydoc, newDoc) {
|
|
||||||
const safe = sanitizeForYjs(newDoc);
|
|
||||||
const fragment = ydoc.getXmlFragment("default");
|
|
||||||
// Hydrate the ProseMirror node in its OWN try so a failure here (e.g. an
|
|
||||||
// unknown node type) is labelled "fromJSON" — the stage that actually threw —
|
|
||||||
// instead of being misattributed to the Yjs write stage (#154 review).
|
|
||||||
let pmNode;
|
|
||||||
try {
|
|
||||||
pmNode = PMNode.fromJSON(docmostSchema, safe);
|
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
throw unstorableYjsError(safe, "fromJSON", e);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
ydoc.transact(() => {
|
|
||||||
updateYFragment(ydoc, fragment, pmNode, {
|
|
||||||
mapping: new Map(),
|
|
||||||
isOMark: new Map(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
throw unstorableYjsError(safe, "updateYFragment", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Run an independent Yjs-encodability check (the same `sanitizeForYjs` + schema
|
|
||||||
* the apply path uses) and throw the same descriptive error when the doc cannot
|
|
||||||
* be stored. Used by the dry-run preview.
|
|
||||||
*
|
|
||||||
* Note: it does NOT run `updateYFragment` against the live fragment, so it is an
|
|
||||||
* encodability GATE, not a byte-for-byte rehearsal of apply — `buildYDoc`
|
|
||||||
* (`toYdoc`) and `applyDocToFragment` (`updateYFragment`) are two different
|
|
||||||
* encoders that nonetheless reject the same unstorable attributes. To narrow the
|
|
||||||
* preview/apply gap it ALSO rehearses the apply path's `PMNode.fromJSON`
|
|
||||||
* hydration, so a doc that would only fail there (e.g. an unknown node type) is
|
|
||||||
* rejected at preview time too (#154 review). Still cheap: no live fragment, no
|
|
||||||
* `updateYFragment`.
|
|
||||||
*/
|
|
||||||
export function assertYjsEncodable(doc) {
|
|
||||||
buildYDoc(doc);
|
|
||||||
const safe = sanitizeForYjs(doc);
|
|
||||||
try {
|
|
||||||
PMNode.fromJSON(docmostSchema, safe);
|
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
throw unstorableYjsError(safe, "fromJSON", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/** Time we wait for the initial handshake/sync before giving up. */
|
|
||||||
const CONNECT_TIMEOUT_MS = 25000;
|
|
||||||
/** Time we wait for the server to acknowledge our write before giving up. */
|
|
||||||
const PERSIST_TIMEOUT_MS = 20000;
|
|
||||||
/**
|
|
||||||
* Safely mutate the live content of a page over the collaboration websocket.
|
|
||||||
*
|
|
||||||
* This is the single safe write path for every MCP content mutation. It:
|
|
||||||
* 1. serializes per-page writes through withPageLock (no two MCP writes on
|
|
||||||
* the same page overlap);
|
|
||||||
* 2. connects to Hocuspocus and waits for the initial sync so the local ydoc
|
|
||||||
* mirrors the authoritative server doc — INCLUDING edits/comments/images
|
|
||||||
* that are not yet in the debounced REST snapshot;
|
|
||||||
* 3. inside onSynced, SYNCHRONOUSLY reads the live doc, runs `transform`, and
|
|
||||||
* writes the result back — with no `await` between read and write so no
|
|
||||||
* remote update can interleave and clobber concurrent human edits;
|
|
||||||
* 4. waits for the server to acknowledge the write (unsyncedChanges -> 0)
|
|
||||||
* before resolving, so the next operation observes our change.
|
|
||||||
*
|
|
||||||
* `transform` receives the live ProseMirror doc and returns the NEW full
|
|
||||||
* ProseMirror doc to write, or `null` to abort with no write (a no-op). If
|
|
||||||
* `transform` throws, the error is propagated to the caller (not swallowed).
|
|
||||||
*
|
|
||||||
* Resolves a `MutationResult { doc, verify }`: `doc` is the doc that was
|
|
||||||
* written (or the live doc when the transform aborted), and `verify` is a
|
|
||||||
* verifiable change report (text/block/mark deltas) of what actually changed.
|
|
||||||
* The report is computed AFTER the atomic read->write, so it never widens the
|
|
||||||
* read->write window, and it never throws (it can NEVER break a write).
|
|
||||||
*/
|
|
||||||
export async function mutatePageContent(pageId, collabToken, baseUrl, transform) {
|
|
||||||
return withPageLock(pageId, () => {
|
|
||||||
if (process.env.DEBUG) {
|
|
||||||
console.error(`Starting realtime content mutate for page ${pageId}`);
|
|
||||||
// Token prefix is sensitive; only log it under DEBUG.
|
|
||||||
console.error(`Token prefix: ${collabToken ? collabToken.substring(0, 5) : "NONE"}...`);
|
|
||||||
}
|
|
||||||
const ydoc = new Y.Doc();
|
|
||||||
const wsUrl = buildCollabWsUrl(baseUrl);
|
|
||||||
if (process.env.DEBUG)
|
|
||||||
console.error(`Connecting to WebSocket: ${wsUrl}`);
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let provider;
|
|
||||||
let applied = false; // onSynced may fire again on reconnect — apply once.
|
|
||||||
let settled = false;
|
|
||||||
// Set true on disconnect/close so a reconnect-driven unsyncedChanges->0
|
|
||||||
// cannot be mistaken for a successful persist of our write.
|
|
||||||
let connectionLost = false;
|
|
||||||
let connectTimer;
|
|
||||||
let persistTimer;
|
|
||||||
let unsyncedHandler;
|
|
||||||
const cleanup = () => {
|
|
||||||
if (connectTimer)
|
|
||||||
clearTimeout(connectTimer);
|
|
||||||
if (persistTimer)
|
|
||||||
clearTimeout(persistTimer);
|
|
||||||
if (provider) {
|
|
||||||
if (unsyncedHandler) {
|
|
||||||
try {
|
|
||||||
provider.off("unsyncedChanges", unsyncedHandler);
|
|
||||||
}
|
|
||||||
catch (err) { }
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
provider.destroy();
|
|
||||||
}
|
|
||||||
catch (err) { }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const finish = (err, value) => {
|
|
||||||
if (settled)
|
|
||||||
return;
|
|
||||||
settled = true;
|
|
||||||
cleanup();
|
|
||||||
if (err)
|
|
||||||
reject(err);
|
|
||||||
else
|
|
||||||
resolve(value);
|
|
||||||
};
|
|
||||||
connectTimer = setTimeout(() => {
|
|
||||||
finish(new Error("Connection timeout to collaboration server"));
|
|
||||||
}, CONNECT_TIMEOUT_MS);
|
|
||||||
// Resolve once the server has acknowledged our update. The provider
|
|
||||||
// increments unsyncedChanges when our local update is sent and
|
|
||||||
// decrements it when the server replies with a SyncStatus(applied=true);
|
|
||||||
// reaching 0 means the authoritative in-memory ydoc on the server now
|
|
||||||
// contains our write.
|
|
||||||
const waitForPersistence = () => {
|
|
||||||
if (settled)
|
|
||||||
return;
|
|
||||||
// A missing provider is a failure, not a success: without it the write
|
|
||||||
// can never have been acknowledged. Only an actual unsyncedChanges===0
|
|
||||||
// on a live provider counts as persisted.
|
|
||||||
if (!provider) {
|
|
||||||
finish(new Error("collab provider gone before persistence"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (provider.unsyncedChanges === 0) {
|
|
||||||
finish(null, mutationResult);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
persistTimer = setTimeout(() => {
|
|
||||||
finish(new Error("Timeout waiting for collaboration server to persist the update"));
|
|
||||||
}, PERSIST_TIMEOUT_MS);
|
|
||||||
unsyncedHandler = (data) => {
|
|
||||||
// Only treat unsyncedChanges->0 as success when the connection is
|
|
||||||
// still up. A transient disconnect + reconnect handshake can drive
|
|
||||||
// the counter back to 0 without our write being re-transmitted; in
|
|
||||||
// that case let the disconnect/close error win instead.
|
|
||||||
if (data.number === 0 && !connectionLost) {
|
|
||||||
finish(null, mutationResult);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
provider.on("unsyncedChanges", unsyncedHandler);
|
|
||||||
};
|
|
||||||
// The verifiable result resolved on every success/abort path. Set on
|
|
||||||
// abort (no-op report) and after a real write (computed change report).
|
|
||||||
let mutationResult;
|
|
||||||
provider = new HocuspocusProvider({
|
|
||||||
url: wsUrl,
|
|
||||||
name: `page.${pageId}`,
|
|
||||||
document: ydoc,
|
|
||||||
token: collabToken,
|
|
||||||
// @ts-ignore - Required for Node.js environment
|
|
||||||
WebSocketPolyfill: WebSocket,
|
|
||||||
onConnect: () => {
|
|
||||||
if (process.env.DEBUG)
|
|
||||||
console.error("WS Connect");
|
|
||||||
},
|
|
||||||
// An unexpected disconnect/close while we are still waiting (during the
|
|
||||||
// connect-wait before onSynced, or during the persistence wait after the
|
|
||||||
// write) means the update will never be acknowledged — surface it now
|
|
||||||
// instead of hanging until the connect/persist timeout fires. `finish`
|
|
||||||
// is idempotent via the `settled` flag, so the onClose that our own
|
|
||||||
// cleanup()->provider.destroy() triggers (after settled=true is set) is
|
|
||||||
// a harmless no-op and cannot cause a double-resolve.
|
|
||||||
onDisconnect: () => {
|
|
||||||
if (process.env.DEBUG)
|
|
||||||
console.error("WS Disconnect");
|
|
||||||
// Mark BEFORE finish so the unsyncedChanges handler (if it races)
|
|
||||||
// sees the connection as lost and won't report a false success.
|
|
||||||
connectionLost = true;
|
|
||||||
finish(new Error("Collaboration connection closed before the update was persisted/synced"));
|
|
||||||
},
|
|
||||||
onClose: () => {
|
|
||||||
if (process.env.DEBUG)
|
|
||||||
console.error("WS Close");
|
|
||||||
// Mark BEFORE finish so the unsyncedChanges handler (if it races)
|
|
||||||
// sees the connection as lost and won't report a false success.
|
|
||||||
connectionLost = true;
|
|
||||||
finish(new Error("Collaboration connection closed before the update was persisted/synced"));
|
|
||||||
},
|
|
||||||
onSynced: () => {
|
|
||||||
if (applied || settled)
|
|
||||||
return;
|
|
||||||
applied = true;
|
|
||||||
if (process.env.DEBUG)
|
|
||||||
console.error("Connected and synced!");
|
|
||||||
// CRITICAL: everything between reading the live doc and writing it
|
|
||||||
// back must stay synchronous (no await). While the JS event loop is
|
|
||||||
// not yielded, no incoming remote update can interleave, so any
|
|
||||||
// already-synced concurrent edits are preserved in liveDoc.
|
|
||||||
let newDoc;
|
|
||||||
let beforeDoc;
|
|
||||||
try {
|
|
||||||
let liveDoc = TiptapTransformer.fromYdoc(ydoc, "default");
|
|
||||||
if (!liveDoc ||
|
|
||||||
typeof liveDoc !== "object" ||
|
|
||||||
!Array.isArray(liveDoc.content)) {
|
|
||||||
liveDoc = { type: "doc", content: [] };
|
|
||||||
}
|
|
||||||
// Snapshot the before-doc for the change report. Docs are
|
|
||||||
// JSON-serializable, so this is a safe deep clone.
|
|
||||||
beforeDoc = JSON.parse(JSON.stringify(liveDoc));
|
|
||||||
newDoc = transform(liveDoc);
|
|
||||||
if (newDoc == null) {
|
|
||||||
// Transform aborted — write nothing, return the live doc with a
|
|
||||||
// no-op change report.
|
|
||||||
mutationResult = {
|
|
||||||
doc: liveDoc,
|
|
||||||
verify: {
|
|
||||||
changed: false,
|
|
||||||
textInserted: 0,
|
|
||||||
textDeleted: 0,
|
|
||||||
blocksChanged: 0,
|
|
||||||
marks: {},
|
|
||||||
summary: "no changes (transform aborted)",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
finish(null, mutationResult);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Structural diff into the live fragment (issue #152): preserves
|
|
||||||
// the Yjs ids of unchanged nodes, so an open editor's cursor is not
|
|
||||||
// yanked to the end of the document on every agent write.
|
|
||||||
applyDocToFragment(ydoc, newDoc);
|
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
// Includes errors thrown by transform (e.g. "afterText not found",
|
|
||||||
// "text not found"): propagate them verbatim to the caller.
|
|
||||||
finish(e instanceof Error ? e : new Error(String(e)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Compute the verifiable change report AFTER the transact write: it
|
|
||||||
// only needs the JSON before/after, so it cannot affect the atomic
|
|
||||||
// read->write window, and summarizeChange never throws.
|
|
||||||
mutationResult = {
|
|
||||||
doc: newDoc,
|
|
||||||
verify: summarizeChange(beforeDoc, newDoc),
|
|
||||||
};
|
|
||||||
if (process.env.DEBUG)
|
|
||||||
console.error("Content written, waiting for server to persist...");
|
|
||||||
waitForPersistence();
|
|
||||||
},
|
|
||||||
onAuthenticationFailed: () => {
|
|
||||||
finish(new Error("Authentication failed for collaboration connection"));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Replace the live content of a page over the collaboration websocket.
|
|
||||||
* Accepts a ready ProseMirror JSON document; the caller controls whether
|
|
||||||
* it was produced from markdown (ids regenerate) or edited in place
|
|
||||||
* (existing block ids preserved).
|
|
||||||
*
|
|
||||||
* This is an intentional full replace (used by update_page / update_page_json),
|
|
||||||
* but now runs under the per-page lock and waits for server persistence via
|
|
||||||
* mutatePageContent.
|
|
||||||
*/
|
|
||||||
export async function replacePageContent(pageId, prosemirrorDoc, collabToken, baseUrl) {
|
|
||||||
// Fail fast on a bad document instead of deferring the failure into the
|
|
||||||
// collaboration write (where TiptapTransformer.toYdoc(undefined) used to
|
|
||||||
// throw). The transform must return a valid ProseMirror doc.
|
|
||||||
if (prosemirrorDoc == null ||
|
|
||||||
typeof prosemirrorDoc !== "object" ||
|
|
||||||
prosemirrorDoc.type !== "doc") {
|
|
||||||
throw new Error("replacePageContent: invalid ProseMirror document");
|
|
||||||
}
|
|
||||||
return await mutatePageContent(pageId, collabToken, baseUrl, () => prosemirrorDoc);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Markdown update path (kept for backwards compatibility).
|
|
||||||
* NOTE: this re-imports the whole document — block ids are regenerated.
|
|
||||||
* Tables and :::callout::: blocks survive thanks to the full schema.
|
|
||||||
*/
|
|
||||||
export async function updatePageContentRealtime(pageId, markdownContent, collabToken, baseUrl) {
|
|
||||||
// PAGE write: canonicalize footnotes (markdown import builds the bottom list in
|
|
||||||
// definition order; numbering is reference-ordered).
|
|
||||||
const tiptapJson = await markdownToProseMirrorCanonical(markdownContent);
|
|
||||||
return await mutatePageContent(pageId, collabToken, baseUrl, () => tiptapJson);
|
|
||||||
}
|
|
||||||
@@ -1,371 +0,0 @@
|
|||||||
/**
|
|
||||||
* Inline-comment anchoring against a ProseMirror document.
|
|
||||||
*
|
|
||||||
* Docmost stores an inline comment's highlight as a `comment` MARK on the
|
|
||||||
* document text (`{ type: "comment", attrs: { commentId, resolved } }`); the
|
|
||||||
* `/comments/create` API only records the comment row + its `selection` text and
|
|
||||||
* does NOT insert that mark, so the anchor has to be written into the page
|
|
||||||
* content separately. This module finds where a selection lives in the document
|
|
||||||
* and splices the comment mark across the matched range.
|
|
||||||
*
|
|
||||||
* Matching has to be robust because the agent supplies the selection as plain
|
|
||||||
* text while the document stores rich inline content: a selection can span
|
|
||||||
* several adjacent text nodes (inline code / bold / links each become their own
|
|
||||||
* text node), and the document may use smart/typographic quotes, dash variants,
|
|
||||||
* non-breaking spaces, or collapsed runs of whitespace that the agent typed as
|
|
||||||
* ASCII quotes/hyphens/single spaces. We therefore normalize both sides before
|
|
||||||
* comparing and match across maximal runs of consecutive text nodes within a
|
|
||||||
* single block, while mapping every normalized character back to its raw index
|
|
||||||
* so the mark lands on the exact original characters.
|
|
||||||
*/
|
|
||||||
/** Typographic double-quote variants mapped to ASCII `"`. */
|
|
||||||
const DOUBLE_QUOTES = "«»„“”‟〝〞"";
|
|
||||||
/** Typographic single-quote/apostrophe variants mapped to ASCII `'`. */
|
|
||||||
const SINGLE_QUOTES = "‘’‚‛";
|
|
||||||
/** Dash variants mapped to ASCII `-`. */
|
|
||||||
const DASHES = "–—―−‐‑‒";
|
|
||||||
/** Guard against pathological/cyclic documents in the depth-first walk. */
|
|
||||||
const MAX_DEPTH = 200;
|
|
||||||
/** The comment mark Docmost stores on anchored text. */
|
|
||||||
function makeCommentMark(commentId) {
|
|
||||||
// The comment mark schema declares both commentId and resolved; include
|
|
||||||
// resolved:false for completeness so the stored mark matches the editor's.
|
|
||||||
return { type: "comment", attrs: { commentId, resolved: false } };
|
|
||||||
}
|
|
||||||
/** True for any character we collapse/replace with a single normal space. */
|
|
||||||
function isWhitespaceChar(ch) {
|
|
||||||
// Regular ASCII whitespace plus the special spaces called out in the spec:
|
|
||||||
// nbsp, narrow nbsp, en/em/thin/hair/figure spaces, etc. \s covers tab and
|
|
||||||
// newline; the explicit code points cover the non-breaking variants \s misses
|
|
||||||
// in some engines, so list them for determinism.
|
|
||||||
return (/\s/.test(ch) ||
|
|
||||||
ch === " " || // no-break space
|
|
||||||
ch === " " || // figure space
|
|
||||||
ch === " " || // narrow no-break space
|
|
||||||
ch === " " || // thin space
|
|
||||||
ch === " " || // hair space
|
|
||||||
ch === " " || // en space
|
|
||||||
ch === " " // em space
|
|
||||||
);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Normalize a string for matching and return both the normalized text and a
|
|
||||||
* `map` where `map[i]` is the index into the ORIGINAL `s` of the i-th
|
|
||||||
* normalized character.
|
|
||||||
*
|
|
||||||
* Rules: map smart quotes / dashes / special spaces to their ASCII forms,
|
|
||||||
* collapse any run of whitespace to a SINGLE space (whose map entry points at
|
|
||||||
* the FIRST raw whitespace char of the run), and DO NOT lowercase (anchoring is
|
|
||||||
* case-sensitive to match the exact document text).
|
|
||||||
*/
|
|
||||||
export function normalizeForMatch(s) {
|
|
||||||
let norm = "";
|
|
||||||
const map = [];
|
|
||||||
let i = 0;
|
|
||||||
while (i < s.length) {
|
|
||||||
const ch = s[i];
|
|
||||||
if (isWhitespaceChar(ch)) {
|
|
||||||
// Collapse the whole whitespace run to one space mapped to the run start.
|
|
||||||
const runStart = i;
|
|
||||||
while (i < s.length && isWhitespaceChar(s[i]))
|
|
||||||
i++;
|
|
||||||
norm += " ";
|
|
||||||
map.push(runStart);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mapped = ch;
|
|
||||||
if (DOUBLE_QUOTES.indexOf(ch) !== -1)
|
|
||||||
mapped = '"';
|
|
||||||
else if (SINGLE_QUOTES.indexOf(ch) !== -1)
|
|
||||||
mapped = "'";
|
|
||||||
else if (DASHES.indexOf(ch) !== -1)
|
|
||||||
mapped = "-";
|
|
||||||
norm += mapped;
|
|
||||||
map.push(i);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
return { norm, map };
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Find a selection inside a SINGLE block's direct `content` array.
|
|
||||||
*
|
|
||||||
* Builds maximal runs of consecutive `text` nodes (any non-text inline node,
|
|
||||||
* e.g. a mention, breaks the run), normalizes each run and the selection the
|
|
||||||
* same way, then searches each run for the normalized selection. Returns the
|
|
||||||
* child/offset range of the FIRST matching run, or `null` if none match.
|
|
||||||
*/
|
|
||||||
export function findAnchorInBlock(blockContent, selection) {
|
|
||||||
if (!Array.isArray(blockContent))
|
|
||||||
return null;
|
|
||||||
const normSelObj = normalizeForMatch(selection);
|
|
||||||
// Trim leading/trailing spaces on the NORMALIZED selection only.
|
|
||||||
const normSel = normSelObj.norm.trim();
|
|
||||||
if (normSel.length === 0)
|
|
||||||
return null;
|
|
||||||
let i = 0;
|
|
||||||
while (i < blockContent.length) {
|
|
||||||
const node = blockContent[i];
|
|
||||||
if (!node || typeof node !== "object" || node.type !== "text") {
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Accumulate a maximal run of consecutive text nodes.
|
|
||||||
let rawRun = "";
|
|
||||||
const rawToChild = [];
|
|
||||||
let j = i;
|
|
||||||
while (j < blockContent.length) {
|
|
||||||
const n = blockContent[j];
|
|
||||||
if (!n || typeof n !== "object" || n.type !== "text")
|
|
||||||
break;
|
|
||||||
const text = typeof n.text === "string" ? n.text : "";
|
|
||||||
for (let k = 0; k < text.length; k++) {
|
|
||||||
rawToChild.push({ childIdx: j, offset: k });
|
|
||||||
}
|
|
||||||
rawRun += text;
|
|
||||||
j++;
|
|
||||||
}
|
|
||||||
// Try to match within this run.
|
|
||||||
const { norm, map } = normalizeForMatch(rawRun);
|
|
||||||
const idx = norm.indexOf(normSel);
|
|
||||||
if (idx !== -1) {
|
|
||||||
const rawStart = map[idx];
|
|
||||||
const rawEndExclusive = idx + normSel.length < map.length
|
|
||||||
? map[idx + normSel.length]
|
|
||||||
: rawRun.length;
|
|
||||||
const startLoc = rawToChild[rawStart];
|
|
||||||
// rawEndExclusive points at the raw char AFTER the match; the last matched
|
|
||||||
// raw char is at rawEndExclusive-1, so endOffset is its offset + 1.
|
|
||||||
const lastLoc = rawToChild[rawEndExclusive - 1];
|
|
||||||
return {
|
|
||||||
startChild: startLoc.childIdx,
|
|
||||||
startOffset: startLoc.offset,
|
|
||||||
endChild: lastLoc.childIdx,
|
|
||||||
endOffset: lastLoc.offset + 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// No match in this run: continue scanning AFTER it.
|
|
||||||
i = j > i ? j : i + 1;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Reconstruct the RAW text spanned by an AnchorMatch inside one block's
|
|
||||||
* `content` array. `startChild..endChild` are all text nodes (guaranteed by
|
|
||||||
* findAnchorInBlock, which only builds runs of `text` nodes), so concatenate
|
|
||||||
* each node's text slice: from `startOffset` on the first node, up to
|
|
||||||
* `endOffset` on the last, and the whole `.text` for any node fully inside the
|
|
||||||
* range. Mirrors spliceCommentMark's per-node slicing so the string returned
|
|
||||||
* here is EXACTLY the characters the comment mark will cover.
|
|
||||||
*/
|
|
||||||
function reconstructRawText(blockContent, match) {
|
|
||||||
const { startChild, startOffset, endChild, endOffset } = match;
|
|
||||||
let out = "";
|
|
||||||
for (let k = startChild; k <= endChild; k++) {
|
|
||||||
const n = blockContent[k];
|
|
||||||
const text = typeof n.text === "string" ? n.text : "";
|
|
||||||
const sliceStart = k === startChild ? startOffset : 0;
|
|
||||||
const sliceEnd = k === endChild ? endOffset : text.length;
|
|
||||||
out += text.slice(sliceStart, sliceEnd);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Return the RAW document substring that `selection` would anchor to — the exact
|
|
||||||
* characters the comment mark will cover — or `null` when the selection cannot
|
|
||||||
* be anchored anywhere in `doc`.
|
|
||||||
*
|
|
||||||
* This mirrors canAnchorInDoc / applyAnchorInDoc EXACTLY (same depth-first,
|
|
||||||
* document-order traversal and the same findAnchorInBlock match on the FIRST
|
|
||||||
* matching block), but instead of a boolean / an in-place mutation it
|
|
||||||
* reconstructs the raw text spanned by the matched range. Because
|
|
||||||
* findAnchorInBlock maps the normalized selection back to raw text-node
|
|
||||||
* positions, the returned string is the document's ORIGINAL characters (smart
|
|
||||||
* quotes, em-dashes, nbsp, collapsed whitespace) — NOT the normalized ASCII
|
|
||||||
* agent input.
|
|
||||||
*
|
|
||||||
* Callers store THIS as the comment's `selection` so the stored value equals the
|
|
||||||
* text actually under the mark, which is what the apply-suggestion equality
|
|
||||||
* check (replaceYjsMarkedText's `joinedText !== expectedText`) compares against.
|
|
||||||
* Without it a suggestion whose anchor only matched via normalization would be
|
|
||||||
* un-appliable (spurious 409).
|
|
||||||
*/
|
|
||||||
export function getAnchoredText(doc, selection) {
|
|
||||||
const visit = (node, depth) => {
|
|
||||||
if (depth > MAX_DEPTH || !node || typeof node !== "object")
|
|
||||||
return null;
|
|
||||||
if (!Array.isArray(node.content))
|
|
||||||
return null;
|
|
||||||
const match = findAnchorInBlock(node.content, selection);
|
|
||||||
if (match)
|
|
||||||
return reconstructRawText(node.content, match);
|
|
||||||
for (const child of node.content) {
|
|
||||||
if (child && typeof child === "object" && Array.isArray(child.content)) {
|
|
||||||
const found = visit(child, depth + 1);
|
|
||||||
if (found !== null)
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
return visit(doc, 0);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Depth-first, document-order check for whether `selection` can be anchored
|
|
||||||
* anywhere in `doc`. At each node with an array `content`, first try to match
|
|
||||||
* within that node's own content, then recurse into children that themselves
|
|
||||||
* have a `content` array.
|
|
||||||
*/
|
|
||||||
export function canAnchorInDoc(doc, selection) {
|
|
||||||
const visit = (node, depth) => {
|
|
||||||
if (depth > MAX_DEPTH || !node || typeof node !== "object")
|
|
||||||
return false;
|
|
||||||
if (!Array.isArray(node.content))
|
|
||||||
return false;
|
|
||||||
if (findAnchorInBlock(node.content, selection))
|
|
||||||
return true;
|
|
||||||
for (const child of node.content) {
|
|
||||||
if (child && typeof child === "object" && Array.isArray(child.content)) {
|
|
||||||
if (visit(child, depth + 1))
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
return visit(doc, 0);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Split the matched text nodes and splice the comment mark across the range.
|
|
||||||
* `blockContent` is mutated IN PLACE. `match.startChild..endChild` are all text
|
|
||||||
* nodes (guaranteed by findAnchorInBlock building runs of text nodes).
|
|
||||||
*/
|
|
||||||
function spliceCommentMark(blockContent, match, commentId) {
|
|
||||||
const { startChild, startOffset, endChild, endOffset } = match;
|
|
||||||
const commentMark = makeCommentMark(commentId);
|
|
||||||
const fragments = [];
|
|
||||||
for (let k = startChild; k <= endChild; k++) {
|
|
||||||
const n = blockContent[k];
|
|
||||||
const text = typeof n.text === "string" ? n.text : "";
|
|
||||||
const sliceStart = k === startChild ? startOffset : 0;
|
|
||||||
const sliceEnd = k === endChild ? endOffset : text.length;
|
|
||||||
const before = k === startChild ? text.slice(0, startOffset) : "";
|
|
||||||
const marked = text.slice(sliceStart, sliceEnd);
|
|
||||||
const after = k === endChild ? text.slice(endOffset) : "";
|
|
||||||
// Process per-node so each node's OWN marks/attrs are preserved.
|
|
||||||
const ownMarks = Array.isArray(n.marks) ? n.marks : [];
|
|
||||||
// Drop any pre-existing comment mark from the marked fragment so it ends up
|
|
||||||
// with exactly one comment mark (the new one) rather than two.
|
|
||||||
const markedBaseMarks = ownMarks.filter((m) => !(m && m.type === "comment"));
|
|
||||||
if (before.length > 0) {
|
|
||||||
fragments.push({ ...n, text: before, marks: [...ownMarks] });
|
|
||||||
}
|
|
||||||
if (marked.length > 0) {
|
|
||||||
fragments.push({
|
|
||||||
...n,
|
|
||||||
text: marked,
|
|
||||||
marks: [...markedBaseMarks, commentMark],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (after.length > 0) {
|
|
||||||
fragments.push({ ...n, text: after, marks: [...ownMarks] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
blockContent.splice(startChild, endChild - startChild + 1, ...fragments);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Count how many times `selection` occurs across the whole document, using the
|
|
||||||
* same normalization and run-matching as findAnchorInBlock but WITHOUT stopping
|
|
||||||
* at the first hit: every non-overlapping occurrence within each block's text
|
|
||||||
* runs is counted and summed across all blocks (depth-first, the same traversal
|
|
||||||
* as canAnchorInDoc).
|
|
||||||
*
|
|
||||||
* This is the uniqueness gate for SUGGESTIONS: because applying a suggestion
|
|
||||||
* rewrites the exact anchored text, an ambiguous anchor (>1 occurrence) would
|
|
||||||
* silently edit the wrong place, so a suggestion is only allowed when this
|
|
||||||
* returns exactly 1. Ordinary comments keep first-occurrence anchoring and do
|
|
||||||
* not use this. (Note: counts OCCURRENCES, not just matching blocks, so two
|
|
||||||
* occurrences inside one block are correctly reported as 2.)
|
|
||||||
*/
|
|
||||||
export function countAnchorMatches(doc, selection) {
|
|
||||||
const normSel = normalizeForMatch(selection).norm.trim();
|
|
||||||
if (normSel.length === 0)
|
|
||||||
return 0;
|
|
||||||
// Count non-overlapping occurrences of the normalized selection within a
|
|
||||||
// single block's direct content, matching findAnchorInBlock's run building.
|
|
||||||
const countInBlock = (blockContent) => {
|
|
||||||
if (!Array.isArray(blockContent))
|
|
||||||
return 0;
|
|
||||||
let count = 0;
|
|
||||||
let i = 0;
|
|
||||||
while (i < blockContent.length) {
|
|
||||||
const node = blockContent[i];
|
|
||||||
if (!node || typeof node !== "object" || node.type !== "text") {
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Accumulate a maximal run of consecutive text nodes.
|
|
||||||
let rawRun = "";
|
|
||||||
let j = i;
|
|
||||||
while (j < blockContent.length) {
|
|
||||||
const n = blockContent[j];
|
|
||||||
if (!n || typeof n !== "object" || n.type !== "text")
|
|
||||||
break;
|
|
||||||
rawRun += typeof n.text === "string" ? n.text : "";
|
|
||||||
j++;
|
|
||||||
}
|
|
||||||
const norm = normalizeForMatch(rawRun).norm;
|
|
||||||
// Count every non-overlapping occurrence in this run.
|
|
||||||
let from = 0;
|
|
||||||
for (;;) {
|
|
||||||
const idx = norm.indexOf(normSel, from);
|
|
||||||
if (idx === -1)
|
|
||||||
break;
|
|
||||||
count++;
|
|
||||||
from = idx + normSel.length;
|
|
||||||
}
|
|
||||||
i = j > i ? j : i + 1;
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
};
|
|
||||||
let total = 0;
|
|
||||||
const visit = (node, depth) => {
|
|
||||||
if (depth > MAX_DEPTH || !node || typeof node !== "object")
|
|
||||||
return;
|
|
||||||
if (!Array.isArray(node.content))
|
|
||||||
return;
|
|
||||||
total += countInBlock(node.content);
|
|
||||||
for (const child of node.content) {
|
|
||||||
if (child && typeof child === "object" && Array.isArray(child.content)) {
|
|
||||||
visit(child, depth + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
visit(doc, 0);
|
|
||||||
return total;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Depth-first (same order as canAnchorInDoc) over `doc`; on the FIRST block
|
|
||||||
* whose content matches `selection`, splice the comment mark across the matched
|
|
||||||
* range in place and return true. Returns false (and does NOT mutate) when no
|
|
||||||
* block matches.
|
|
||||||
*/
|
|
||||||
export function applyAnchorInDoc(doc, selection, commentId) {
|
|
||||||
const visit = (node, depth) => {
|
|
||||||
if (depth > MAX_DEPTH || !node || typeof node !== "object")
|
|
||||||
return false;
|
|
||||||
if (!Array.isArray(node.content))
|
|
||||||
return false;
|
|
||||||
const match = findAnchorInBlock(node.content, selection);
|
|
||||||
if (match) {
|
|
||||||
spliceCommentMark(node.content, match, commentId);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
for (const child of node.content) {
|
|
||||||
if (child && typeof child === "object" && Array.isArray(child.content)) {
|
|
||||||
if (visit(child, depth + 1))
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
return visit(doc, 0);
|
|
||||||
}
|
|
||||||
@@ -1,423 +0,0 @@
|
|||||||
/**
|
|
||||||
* Headless, Docmost-equivalent document diff.
|
|
||||||
*
|
|
||||||
* Docmost's history editor computes a change set with the exact pipeline below
|
|
||||||
* (recreateTransform -> ChangeSet.addSteps -> simplifyChanges) and renders it as
|
|
||||||
* editor decorations. This module runs the SAME computation but serializes the
|
|
||||||
* result to text + integrity counts instead of decorations, so a diff can be
|
|
||||||
* previewed without a browser.
|
|
||||||
*
|
|
||||||
* recreateTransform here comes from @fellow/prosemirror-recreate-transform, the
|
|
||||||
* maintained published fork of the MIT prosemirror-recreate-steps source that
|
|
||||||
* Docmost vendors in @docmost/editor-ext; it exposes the identical
|
|
||||||
* recreateTransform(fromDoc, toDoc, { complexSteps, wordDiffs, simplifyDiff })
|
|
||||||
* signature.
|
|
||||||
*
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
import { Node } from "@tiptap/pm/model";
|
|
||||||
import { ChangeSet, simplifyChanges } from "@tiptap/pm/changeset";
|
|
||||||
import { recreateTransform } from "@fellow/prosemirror-recreate-transform";
|
|
||||||
import { docmostSchema } from "./docmost-schema.js";
|
|
||||||
/** Recursively concatenate the plain text of a JSON node. */
|
|
||||||
function plainText(node) {
|
|
||||||
if (!node || typeof node !== "object")
|
|
||||||
return "";
|
|
||||||
let out = "";
|
|
||||||
if (typeof node.text === "string")
|
|
||||||
out += node.text;
|
|
||||||
if (Array.isArray(node.content)) {
|
|
||||||
for (const child of node.content)
|
|
||||||
out += plainText(child);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
/** Count nodes in a JSON doc that satisfy `pred` (recursive). */
|
|
||||||
function countNodes(doc, pred) {
|
|
||||||
let n = 0;
|
|
||||||
const visit = (node) => {
|
|
||||||
if (!node || typeof node !== "object")
|
|
||||||
return;
|
|
||||||
if (pred(node))
|
|
||||||
n++;
|
|
||||||
if (Array.isArray(node.content))
|
|
||||||
for (const c of node.content)
|
|
||||||
visit(c);
|
|
||||||
};
|
|
||||||
visit(doc);
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* 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"
|
|
||||||
* run); counting link-bearing runs would over-count it. Walking the tree and
|
|
||||||
* collecting hrefs into a Set keys each distinct link once. Link marks with a
|
|
||||||
* missing/empty href are bucketed under a single "" key so a malformed link is
|
|
||||||
* still counted as one.
|
|
||||||
*/
|
|
||||||
function countUniqueLinks(doc) {
|
|
||||||
const hrefs = new Set();
|
|
||||||
const visit = (node) => {
|
|
||||||
if (!node || typeof node !== "object")
|
|
||||||
return;
|
|
||||||
if (node.type === "text" && Array.isArray(node.marks)) {
|
|
||||||
for (const m of node.marks) {
|
|
||||||
if (m && m.type === "link") {
|
|
||||||
const href = m.attrs && typeof m.attrs.href === "string" ? m.attrs.href : "";
|
|
||||||
hrefs.add(href);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Array.isArray(node.content))
|
|
||||||
for (const c of node.content)
|
|
||||||
visit(c);
|
|
||||||
};
|
|
||||||
visit(doc);
|
|
||||||
return hrefs.size;
|
|
||||||
}
|
|
||||||
/** Count footnoteReference nodes anywhere under a node (reading order). */
|
|
||||||
function countFootnoteRefs(node) {
|
|
||||||
if (!node || typeof node !== "object")
|
|
||||||
return 0;
|
|
||||||
let n = node.type === "footnoteReference" ? 1 : 0;
|
|
||||||
if (Array.isArray(node.content)) {
|
|
||||||
for (const child of node.content)
|
|
||||||
n += countFootnoteRefs(child);
|
|
||||||
}
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Ordered list of footnote marker numbers found in the BODY only (every
|
|
||||||
* top-level block before the first "Примечания..." notes heading; if no such
|
|
||||||
* heading, the whole doc), in reading order.
|
|
||||||
*
|
|
||||||
* Supports BOTH representations:
|
|
||||||
* - real `footnoteReference` nodes (the current footnote feature) — numbered
|
|
||||||
* 1..n by reading position, since their visible number is derived;
|
|
||||||
* - legacy `[N]` text markers (older translated docs) — the literal N.
|
|
||||||
*/
|
|
||||||
function footnoteMarkers(doc, notesHeading) {
|
|
||||||
const top = Array.isArray(doc?.content) ? doc.content : [];
|
|
||||||
const notesIdx = top.findIndex((n) => n &&
|
|
||||||
n.type === "heading" &&
|
|
||||||
plainText(n).trim() === notesHeading);
|
|
||||||
const bodyBlocks = notesIdx >= 0 ? top.slice(0, notesIdx) : top;
|
|
||||||
// Real footnoteReference nodes take precedence: when present, number them by
|
|
||||||
// reading position (their displayed number is not stored).
|
|
||||||
let refCount = 0;
|
|
||||||
for (const block of bodyBlocks)
|
|
||||||
refCount += countFootnoteRefs(block);
|
|
||||||
if (refCount > 0) {
|
|
||||||
return Array.from({ length: refCount }, (_, i) => i + 1);
|
|
||||||
}
|
|
||||||
// Fallback: legacy `[N]` text markers.
|
|
||||||
const markers = [];
|
|
||||||
const re = /\[(\d+)\]/g;
|
|
||||||
for (const block of bodyBlocks) {
|
|
||||||
const text = plainText(block);
|
|
||||||
let m;
|
|
||||||
re.lastIndex = 0;
|
|
||||||
while ((m = re.exec(text)) !== null) {
|
|
||||||
markers.push(Number(m[1]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return markers;
|
|
||||||
}
|
|
||||||
/** Compute the [old,new] integrity tuples for two JSON docs. */
|
|
||||||
function computeIntegrity(oldDoc, newDoc, notesHeading) {
|
|
||||||
const images = [
|
|
||||||
countNodes(oldDoc, (n) => n.type === "image"),
|
|
||||||
countNodes(newDoc, (n) => n.type === "image"),
|
|
||||||
];
|
|
||||||
const links = [
|
|
||||||
countUniqueLinks(oldDoc),
|
|
||||||
countUniqueLinks(newDoc),
|
|
||||||
];
|
|
||||||
const tables = [
|
|
||||||
countNodes(oldDoc, (n) => n.type === "table"),
|
|
||||||
countNodes(newDoc, (n) => n.type === "table"),
|
|
||||||
];
|
|
||||||
const callouts = [
|
|
||||||
countNodes(oldDoc, (n) => n.type === "callout"),
|
|
||||||
countNodes(newDoc, (n) => n.type === "callout"),
|
|
||||||
];
|
|
||||||
const fns = [
|
|
||||||
footnoteMarkers(oldDoc, notesHeading),
|
|
||||||
footnoteMarkers(newDoc, notesHeading),
|
|
||||||
];
|
|
||||||
return { images, links, tables, callouts, footnoteMarkers: fns };
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Resolve the lead text of the top-level block in a ProseMirror Node that
|
|
||||||
* contains the given document position. Returns "" when out of range.
|
|
||||||
*/
|
|
||||||
function blockContextAt(node, pos) {
|
|
||||||
try {
|
|
||||||
const clamped = Math.max(0, Math.min(pos, node.content.size));
|
|
||||||
const $pos = node.resolve(clamped);
|
|
||||||
// depth 1 is the top-level block in a doc node.
|
|
||||||
const block = $pos.depth >= 1 ? $pos.node(1) : $pos.node(0);
|
|
||||||
const text = block.textContent || "";
|
|
||||||
return text.length > 80 ? text.slice(0, 77) + "..." : text;
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/** Truncate a string for the markdown summary. */
|
|
||||||
function truncate(s, n = 120) {
|
|
||||||
return s.length > n ? s.slice(0, n - 3) + "..." : s;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Coarse fallback: a block-by-block plain-text diff. Used only when the precise
|
|
||||||
* changeset pipeline throws, so the tool degrades gracefully instead of failing.
|
|
||||||
*/
|
|
||||||
function coarseDiff(oldDoc, newDoc) {
|
|
||||||
const oldBlocks = Array.isArray(oldDoc?.content) ? oldDoc.content : [];
|
|
||||||
const newBlocks = Array.isArray(newDoc?.content) ? newDoc.content : [];
|
|
||||||
const oldTexts = oldBlocks.map(plainText);
|
|
||||||
const newTexts = newBlocks.map(plainText);
|
|
||||||
const oldSet = new Set(oldTexts);
|
|
||||||
const newSet = new Set(newTexts);
|
|
||||||
const changes = [];
|
|
||||||
for (const t of oldTexts) {
|
|
||||||
if (!newSet.has(t) && t.trim() !== "") {
|
|
||||||
changes.push({ op: "delete", block: truncate(t, 80), text: t });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const t of newTexts) {
|
|
||||||
if (!oldSet.has(t) && t.trim() !== "") {
|
|
||||||
changes.push({ op: "insert", block: truncate(t, 80), text: t });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return changes;
|
|
||||||
}
|
|
||||||
/** Build the human-readable unified-ish markdown summary. */
|
|
||||||
function renderMarkdown(result, fellBack) {
|
|
||||||
const lines = [];
|
|
||||||
const { summary, integrity, changes } = result;
|
|
||||||
lines.push(`# Diff: ${summary.inserted} inserted / ${summary.deleted} deleted (${summary.blocksChanged} blocks changed)`);
|
|
||||||
if (fellBack) {
|
|
||||||
lines.push("");
|
|
||||||
lines.push("> note: precise diff failed; coarse block-level diff shown.");
|
|
||||||
}
|
|
||||||
lines.push("");
|
|
||||||
lines.push("## Integrity (old -> new)");
|
|
||||||
lines.push(`- images: ${integrity.images[0]} -> ${integrity.images[1]}`);
|
|
||||||
lines.push(`- links: ${integrity.links[0]} -> ${integrity.links[1]}`);
|
|
||||||
lines.push(`- tables: ${integrity.tables[0]} -> ${integrity.tables[1]}`);
|
|
||||||
lines.push(`- callouts: ${integrity.callouts[0]} -> ${integrity.callouts[1]}`);
|
|
||||||
lines.push(`- footnoteMarkers: [${integrity.footnoteMarkers[0].join(", ")}] -> [${integrity.footnoteMarkers[1].join(", ")}]`);
|
|
||||||
lines.push("");
|
|
||||||
lines.push("## Changes");
|
|
||||||
if (changes.length === 0) {
|
|
||||||
lines.push("(no textual changes)");
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
for (const c of changes) {
|
|
||||||
const sign = c.op === "insert" ? "+" : "-";
|
|
||||||
const ctx = c.block ? ` @ ${truncate(c.block, 60)}` : "";
|
|
||||||
lines.push(`${sign} ${truncate(c.text)}${ctx}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Diff two ProseMirror JSON documents the way Docmost's history editor does and
|
|
||||||
* serialize the result to text + integrity counts.
|
|
||||||
*
|
|
||||||
* @param oldDocJson the earlier document
|
|
||||||
* @param newDocJson the later document
|
|
||||||
* @param notesHeading heading delimiting body from notes for footnote counting
|
|
||||||
*/
|
|
||||||
export function diffDocs(oldDocJson, newDocJson, notesHeading = "Примечания переводчика") {
|
|
||||||
const integrity = computeIntegrity(oldDocJson, newDocJson, notesHeading);
|
|
||||||
let changes = [];
|
|
||||||
let inserted = 0;
|
|
||||||
let deleted = 0;
|
|
||||||
let fellBack = false;
|
|
||||||
const changedBlocks = new Set();
|
|
||||||
try {
|
|
||||||
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);
|
|
||||||
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;
|
|
||||||
changes = coarseDiff(oldDocJson, newDocJson);
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const partial = {
|
|
||||||
summary: { inserted, deleted, blocksChanged: changedBlocks.size },
|
|
||||||
integrity,
|
|
||||||
changes,
|
|
||||||
};
|
|
||||||
return { ...partial, markdown: renderMarkdown(partial, fellBack) };
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Recursively walk every `text` node and tally the count of each mark by
|
|
||||||
* `mark.type` (e.g. `{ bold: 5, strike: 3, link: 2 }`). Pure and never throws.
|
|
||||||
*/
|
|
||||||
function markCounts(doc) {
|
|
||||||
const counts = {};
|
|
||||||
const visit = (node) => {
|
|
||||||
if (!node || typeof node !== "object")
|
|
||||||
return;
|
|
||||||
if (node.type === "text" && Array.isArray(node.marks)) {
|
|
||||||
for (const m of node.marks) {
|
|
||||||
if (m && typeof m.type === "string") {
|
|
||||||
counts[m.type] = (counts[m.type] || 0) + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Array.isArray(node.content))
|
|
||||||
for (const c of node.content)
|
|
||||||
visit(c);
|
|
||||||
};
|
|
||||||
visit(doc);
|
|
||||||
return counts;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Build a VerifyReport for a content mutation. Pure and never throws — on any
|
|
||||||
* internal error it returns a minimal "changed (diff unavailable)" report so it
|
|
||||||
* can NEVER break a write.
|
|
||||||
*
|
|
||||||
* `changed` is VALUE-based, not JSON-string-based: it is derived from the actual
|
|
||||||
* deltas (text chars, blocks, mark counts, structural integrity counts), so two
|
|
||||||
* value-equal docs that differ only in JSON key order report cleanly as
|
|
||||||
* `changed:false` / "no content change" rather than a misleading +0/-0 change.
|
|
||||||
*
|
|
||||||
* The structural integrity delta (from diffDocs's `integrity` tuples) is what
|
|
||||||
* makes `changed` true for an image/table/callout/link count change that diffs
|
|
||||||
* to zero text — closing a verify blind spot for insert_image, delete_node on a
|
|
||||||
* table, etc.
|
|
||||||
*/
|
|
||||||
export function summarizeChange(before, after) {
|
|
||||||
try {
|
|
||||||
const diff = diffDocs(before, after);
|
|
||||||
// Per-mark-type delta: include a type only when its count actually changed.
|
|
||||||
const beforeMarks = markCounts(before);
|
|
||||||
const afterMarks = markCounts(after);
|
|
||||||
const marks = {};
|
|
||||||
for (const type of new Set([
|
|
||||||
...Object.keys(beforeMarks),
|
|
||||||
...Object.keys(afterMarks),
|
|
||||||
])) {
|
|
||||||
const b = beforeMarks[type] || 0;
|
|
||||||
const a = afterMarks[type] || 0;
|
|
||||||
if (b !== a)
|
|
||||||
marks[type] = [b, a];
|
|
||||||
}
|
|
||||||
// Structural integrity delta from diffDocs: count-based [old,new] tuples for
|
|
||||||
// images/links/tables/callouts. Include a type only when old != new.
|
|
||||||
const integrity = diff.integrity;
|
|
||||||
const structure = {};
|
|
||||||
const countTypes = [
|
|
||||||
"images",
|
|
||||||
"links",
|
|
||||||
"tables",
|
|
||||||
"callouts",
|
|
||||||
];
|
|
||||||
for (const type of countTypes) {
|
|
||||||
const [b, a] = integrity[type];
|
|
||||||
if (b !== a)
|
|
||||||
structure[type] = [b, a];
|
|
||||||
}
|
|
||||||
const textInserted = diff.summary.inserted;
|
|
||||||
const textDeleted = diff.summary.deleted;
|
|
||||||
const blocksChanged = diff.summary.blocksChanged;
|
|
||||||
const hasMarkDelta = Object.keys(marks).length > 0;
|
|
||||||
const hasStructureDelta = Object.keys(structure).length > 0;
|
|
||||||
// VALUE-based change decision: ignore JSON key-order no-ops entirely.
|
|
||||||
const changed = textInserted > 0 ||
|
|
||||||
textDeleted > 0 ||
|
|
||||||
blocksChanged > 0 ||
|
|
||||||
hasMarkDelta ||
|
|
||||||
hasStructureDelta;
|
|
||||||
if (!changed) {
|
|
||||||
return {
|
|
||||||
changed: false,
|
|
||||||
textInserted: 0,
|
|
||||||
textDeleted: 0,
|
|
||||||
blocksChanged: 0,
|
|
||||||
marks: {},
|
|
||||||
summary: "no content change",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const parts = [];
|
|
||||||
// Only mention text/blocks when they actually changed (avoid a misleading
|
|
||||||
// "+0/-0 chars, 0 block(s)" prefix on a pure mark/structure change).
|
|
||||||
if (textInserted > 0 || textDeleted > 0 || blocksChanged > 0) {
|
|
||||||
parts.push(`+${textInserted}/-${textDeleted} chars, ${blocksChanged} block(s)`);
|
|
||||||
}
|
|
||||||
const markParts = Object.entries(marks).map(([type, [b, a]]) => `${type} ${b}→${a}`);
|
|
||||||
if (markParts.length > 0)
|
|
||||||
parts.push(`marks: ${markParts.join(", ")}`);
|
|
||||||
const structureParts = Object.entries(structure).map(([type, [b, a]]) => `${type} ${b}→${a}`);
|
|
||||||
if (structureParts.length > 0)
|
|
||||||
parts.push(structureParts.join(", "));
|
|
||||||
// `changed` is true here, so at least one group is present and parts is non-empty.
|
|
||||||
const summary = `changed: ${parts.join("; ")}`;
|
|
||||||
const report = {
|
|
||||||
changed: true,
|
|
||||||
textInserted,
|
|
||||||
textDeleted,
|
|
||||||
blocksChanged,
|
|
||||||
marks,
|
|
||||||
summary,
|
|
||||||
};
|
|
||||||
if (hasStructureDelta)
|
|
||||||
report.structure = structure;
|
|
||||||
return report;
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
// A pathological pair must never break a write: degrade to a minimal report.
|
|
||||||
return {
|
|
||||||
changed: true,
|
|
||||||
textInserted: 0,
|
|
||||||
textDeleted: 0,
|
|
||||||
blocksChanged: 0,
|
|
||||||
marks: {},
|
|
||||||
summary: "changed (diff unavailable)",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,92 +0,0 @@
|
|||||||
/**
|
|
||||||
* Filter functions to extract only relevant information from API responses
|
|
||||||
* for better agent consumption
|
|
||||||
*/
|
|
||||||
export function filterWorkspace(data) {
|
|
||||||
return {
|
|
||||||
id: data.id,
|
|
||||||
name: data.name,
|
|
||||||
description: data.description,
|
|
||||||
defaultSpaceId: data.defaultSpaceId,
|
|
||||||
createdAt: data.createdAt,
|
|
||||||
updatedAt: data.updatedAt,
|
|
||||||
deletedAt: data.deletedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
export function filterSpace(space) {
|
|
||||||
return {
|
|
||||||
id: space.id,
|
|
||||||
name: space.name,
|
|
||||||
description: space.description,
|
|
||||||
slug: space.slug,
|
|
||||||
visibility: space.visibility,
|
|
||||||
createdAt: space.createdAt,
|
|
||||||
updatedAt: space.updatedAt,
|
|
||||||
deletedAt: space.deletedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
export function filterGroup(group) {
|
|
||||||
return {
|
|
||||||
id: group.id,
|
|
||||||
name: group.name,
|
|
||||||
description: group.description,
|
|
||||||
workspaceId: group.workspaceId,
|
|
||||||
createdAt: group.createdAt,
|
|
||||||
updatedAt: group.updatedAt,
|
|
||||||
deletedAt: group.deletedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
export function filterPage(page, content, subpages) {
|
|
||||||
return {
|
|
||||||
id: page.id,
|
|
||||||
slugId: page.slugId,
|
|
||||||
title: page.title,
|
|
||||||
parentPageId: page.parentPageId,
|
|
||||||
spaceId: page.spaceId,
|
|
||||||
isLocked: page.isLocked,
|
|
||||||
createdAt: page.createdAt,
|
|
||||||
updatedAt: page.updatedAt,
|
|
||||||
deletedAt: page.deletedAt,
|
|
||||||
// Include converted markdown content if valid string (even empty)
|
|
||||||
...(typeof content === "string" && { content }),
|
|
||||||
// Include subpages if provided
|
|
||||||
...(subpages &&
|
|
||||||
subpages.length > 0 && {
|
|
||||||
subpages: subpages.map((p) => ({ id: p.id, title: p.title })),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
export function filterComment(comment, markdownContent) {
|
|
||||||
return {
|
|
||||||
id: comment.id,
|
|
||||||
pageId: comment.pageId,
|
|
||||||
content: markdownContent ?? comment.content,
|
|
||||||
selection: comment.selection || null,
|
|
||||||
type: comment.type || "page",
|
|
||||||
parentCommentId: comment.parentCommentId || null,
|
|
||||||
creatorId: comment.creatorId,
|
|
||||||
creatorName: comment.creator?.name || null,
|
|
||||||
createdAt: comment.createdAt,
|
|
||||||
editedAt: comment.editedAt || null,
|
|
||||||
resolvedAt: comment.resolvedAt || null,
|
|
||||||
resolvedById: comment.resolvedById || null,
|
|
||||||
// Suggestion state: the proposed replacement text (if any) and, once a human
|
|
||||||
// applies it via the UI, when and by whom.
|
|
||||||
suggestedText: comment.suggestedText || null,
|
|
||||||
suggestionAppliedAt: comment.suggestionAppliedAt || null,
|
|
||||||
suggestionAppliedById: comment.suggestionAppliedById || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
export function filterSearchResult(result) {
|
|
||||||
return {
|
|
||||||
id: result.id,
|
|
||||||
title: result.title,
|
|
||||||
parentPageId: result.parentPageId,
|
|
||||||
createdAt: result.createdAt,
|
|
||||||
updatedAt: result.updatedAt,
|
|
||||||
rank: result.rank,
|
|
||||||
highlight: result.highlight,
|
|
||||||
spaceId: result.space?.id,
|
|
||||||
spaceName: result.space?.name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
/**
|
|
||||||
* Footnote diagnostics for imported Markdown (issue #166).
|
|
||||||
*
|
|
||||||
* A PURE, fence-aware text scan (independent of the Markdown->ProseMirror
|
|
||||||
* conversion path, so it reports the same problems for `create_page`,
|
|
||||||
* `update_page` and `import_page_markdown`). It never changes the document — the
|
|
||||||
* importer still creates the page; this only surfaces footnote problems to the
|
|
||||||
* caller so an agent can fix its own markup instead of shipping broken footnotes.
|
|
||||||
*
|
|
||||||
* Detected problems:
|
|
||||||
* - danglingReferences: a `[^id]` reference with no `[^id]:` definition.
|
|
||||||
* - emptyDefinitions: a `[^id]:` whose (kept) text is empty/whitespace.
|
|
||||||
* - duplicateDefinitions: an id defined by two or more `[^id]:` lines (only the
|
|
||||||
* first is kept on import — first-wins; see extractFootnotes).
|
|
||||||
* - referencesInTables: a `[^id]` marker found in a GFM table row (heuristic:
|
|
||||||
* the line, trimmed, starts with `|`) — footnotes in table cells often do not
|
|
||||||
* render as expected.
|
|
||||||
*/
|
|
||||||
import { lexFootnoteLines, forEachFootnoteReference, } from "./footnote-lex.js";
|
|
||||||
/**
|
|
||||||
* Analyze the footnotes in a Markdown string. Pure; safe to call on any body.
|
|
||||||
*/
|
|
||||||
export function analyzeFootnotes(markdown) {
|
|
||||||
// Distinct reference ids in first-appearance order, plus the set of ids seen
|
|
||||||
// inside a table row.
|
|
||||||
const refIds = [];
|
|
||||||
const refIdSet = new Set();
|
|
||||||
const referencesInTables = new Set();
|
|
||||||
const addRef = (id, inTable) => {
|
|
||||||
if (!refIdSet.has(id)) {
|
|
||||||
refIdSet.add(id);
|
|
||||||
refIds.push(id);
|
|
||||||
}
|
|
||||||
if (inTable)
|
|
||||||
referencesInTables.add(id);
|
|
||||||
};
|
|
||||||
// Definition texts per id, in first-appearance order of the id.
|
|
||||||
const defTextsById = new Map();
|
|
||||||
// Same lexer the importer uses, so the analysis matches exactly what import
|
|
||||||
// keeps/strips (#166): fenced lines are inert, definition lines are pulled.
|
|
||||||
for (const tok of lexFootnoteLines(markdown)) {
|
|
||||||
if (tok.inFence)
|
|
||||||
continue;
|
|
||||||
if (tok.definition) {
|
|
||||||
const { id, text } = tok.definition;
|
|
||||||
const arr = defTextsById.get(id);
|
|
||||||
if (arr)
|
|
||||||
arr.push(text);
|
|
||||||
else
|
|
||||||
defTextsById.set(id, [text]);
|
|
||||||
// A definition's TEXT can itself reference another footnote (`[^a]: see
|
|
||||||
// [^b]`); count those so such a `[^b]` is not falsely reported dangling.
|
|
||||||
forEachFootnoteReference(text, (rid) => addRef(rid, false));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const inTable = tok.line.trimStart().startsWith("|");
|
|
||||||
forEachFootnoteReference(tok.line, (id) => addRef(id, inTable));
|
|
||||||
}
|
|
||||||
const danglingReferences = refIds.filter((id) => !defTextsById.has(id));
|
|
||||||
const duplicateDefinitions = [];
|
|
||||||
const emptyDefinitions = [];
|
|
||||||
for (const [id, texts] of defTextsById) {
|
|
||||||
if (texts.length >= 2)
|
|
||||||
duplicateDefinitions.push(id);
|
|
||||||
// First-wins: the kept definition is the first one; flag it if it is blank.
|
|
||||||
if ((texts[0] ?? "").trim().length === 0)
|
|
||||||
emptyDefinitions.push(id);
|
|
||||||
}
|
|
||||||
const tableRefs = [...referencesInTables];
|
|
||||||
const warnings = [];
|
|
||||||
const list = (ids) => ids.map((id) => `[^${id}]`).join(", ");
|
|
||||||
if (danglingReferences.length > 0) {
|
|
||||||
warnings.push(`Footnote reference(s) with no matching definition: ${list(danglingReferences)} (each will render as an empty footnote in the editor).`);
|
|
||||||
}
|
|
||||||
if (emptyDefinitions.length > 0) {
|
|
||||||
warnings.push(`Footnote definition(s) with empty text: ${list(emptyDefinitions)}.`);
|
|
||||||
}
|
|
||||||
if (duplicateDefinitions.length > 0) {
|
|
||||||
warnings.push(`Footnote id(s) defined more than once (only the first definition was kept): ${list(duplicateDefinitions)}.`);
|
|
||||||
}
|
|
||||||
if (tableRefs.length > 0) {
|
|
||||||
warnings.push(`Footnote marker(s) inside a table row (footnotes in table cells may not render as expected): ${list(tableRefs)}.`);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
danglingReferences,
|
|
||||||
emptyDefinitions,
|
|
||||||
duplicateDefinitions,
|
|
||||||
referencesInTables: tableRefs,
|
|
||||||
warnings,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* The optional `footnoteWarnings` field for a page-write tool result: present
|
|
||||||
* (with the warning lines) only when `markdown` has footnote problems, omitted
|
|
||||||
* otherwise. One helper so all three call sites (create/update/import) attach the
|
|
||||||
* field identically. Spread into the result: `{ ...result, ...footnoteWarningsField(text) }`.
|
|
||||||
*/
|
|
||||||
export function footnoteWarningsField(markdown) {
|
|
||||||
const { warnings } = analyzeFootnotes(markdown);
|
|
||||||
return warnings.length > 0 ? { footnoteWarnings: warnings } : {};
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
/**
|
|
||||||
* Inline-authoring helpers for footnotes (MCP).
|
|
||||||
*
|
|
||||||
* These build/identify footnote DEFINITION nodes for the author-inline tool
|
|
||||||
* (`insertInlineFootnote` in transforms.ts): a content key to de-duplicate notes
|
|
||||||
* by text, a definition-node factory, and a fresh uuidv7-style id generator.
|
|
||||||
*
|
|
||||||
* Split out of `footnote-canonicalize.ts` so that module stays a pure MIRROR of
|
|
||||||
* the editor-ext canonicalizer (compositionally symmetric to the editor-ext
|
|
||||||
* copy, which keeps its authoring helpers in `footnote-util.ts`). The pure
|
|
||||||
* canonicalizer has no dependency on these.
|
|
||||||
*/
|
|
||||||
const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition";
|
|
||||||
function cloneJson(v) {
|
|
||||||
if (typeof structuredClone === "function")
|
|
||||||
return structuredClone(v);
|
|
||||||
return JSON.parse(JSON.stringify(v));
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Normalized content key for de-duplicating footnote DEFINITIONS by their text.
|
|
||||||
*
|
|
||||||
* Two definitions with the same key are the SAME footnote — so the inline
|
|
||||||
* authoring tool reuses one id (one number, one definition, several references)
|
|
||||||
* instead of minting a second definition. Key = plaintext (whitespace-collapsed,
|
|
||||||
* trimmed) PLUS a signature of the inline mark types in order, so two notes that
|
|
||||||
* read the same but differ in formatting (one bold, one plain) are NOT merged.
|
|
||||||
* Conservative: only an exact match merges.
|
|
||||||
*/
|
|
||||||
export function footnoteContentKey(defNode) {
|
|
||||||
const parts = [];
|
|
||||||
const visit = (n) => {
|
|
||||||
if (!n || typeof n !== "object")
|
|
||||||
return;
|
|
||||||
if (n.type === "text" && typeof n.text === "string") {
|
|
||||||
const marks = Array.isArray(n.marks)
|
|
||||||
? n.marks.map((m) => m?.type).filter(Boolean).sort().join(",")
|
|
||||||
: "";
|
|
||||||
parts.push(`${n.text}${marks}`);
|
|
||||||
}
|
|
||||||
if (Array.isArray(n.content))
|
|
||||||
for (const c of n.content)
|
|
||||||
visit(c);
|
|
||||||
};
|
|
||||||
visit(defNode);
|
|
||||||
// Collapse the assembled text's whitespace and trim, keeping the mark
|
|
||||||
// signature attached so formatting differences still distinguish notes.
|
|
||||||
return parts
|
|
||||||
.join("")
|
|
||||||
.replace(/[ \t\r\n]+/g, " ")
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Build a footnoteDefinition node from inline ProseMirror nodes, keyed by id.
|
|
||||||
*/
|
|
||||||
export function makeFootnoteDefinition(id, inlineNodes) {
|
|
||||||
const content = Array.isArray(inlineNodes) ? cloneJson(inlineNodes) : [];
|
|
||||||
return {
|
|
||||||
type: FOOTNOTE_DEFINITION_NAME,
|
|
||||||
attrs: { id },
|
|
||||||
content: [{ type: "paragraph", content }],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Generate a uuidv7-style id (time-ordered), matching editor-ext's
|
|
||||||
* `generateFootnoteId`. Used for a genuinely-new inline footnote id.
|
|
||||||
*/
|
|
||||||
export function generateFootnoteId() {
|
|
||||||
const now = Date.now();
|
|
||||||
const timeHex = now.toString(16).padStart(12, "0");
|
|
||||||
const rand = (length) => {
|
|
||||||
let s = "";
|
|
||||||
for (let i = 0; i < length; i++)
|
|
||||||
s += Math.floor(Math.random() * 16).toString(16);
|
|
||||||
return s;
|
|
||||||
};
|
|
||||||
const versioned = "7" + rand(3);
|
|
||||||
const variantNibble = (8 + Math.floor(Math.random() * 4)).toString(16);
|
|
||||||
const variant = variantNibble + rand(3);
|
|
||||||
return (timeHex.slice(0, 8) +
|
|
||||||
"-" +
|
|
||||||
timeHex.slice(8, 12) +
|
|
||||||
"-" +
|
|
||||||
versioned +
|
|
||||||
"-" +
|
|
||||||
variant +
|
|
||||||
"-" +
|
|
||||||
rand(12));
|
|
||||||
}
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
/**
|
|
||||||
* Server-side footnote canonicalizer (MCP mirror — PURE).
|
|
||||||
*
|
|
||||||
* `canonicalizeFootnotes(doc)` is a pure ProseMirror-JSON port of the editor's
|
|
||||||
* `footnoteSyncPlugin` end-state, identical in behaviour to
|
|
||||||
* `@docmost/editor-ext`'s `canonicalizeFootnotes`. It is mirrored here — rather
|
|
||||||
* than imported from editor-ext — for the SAME reason `footnote-lex.ts` and the
|
|
||||||
* `docmost-schema.ts` nodes are mirrored: the MCP package is deliberately
|
|
||||||
* decoupled from the browser/React-heavy editor barrel and operates on plain
|
|
||||||
* JSON. The editor-ext copy owns the golden test against the live plugin; this
|
|
||||||
* copy must stay behaviourally identical (a SHARED golden corpus, exercised by
|
|
||||||
* both test suites, pins that — see `test/unit/footnote-corpus.mjs`).
|
|
||||||
*
|
|
||||||
* This module is the pure MIRROR only. The inline-authoring helpers
|
|
||||||
* (`footnoteContentKey`, `makeFootnoteDefinition`, `generateFootnoteId`) used by
|
|
||||||
* `insertInlineFootnote` live in the sibling `footnote-authoring.ts`, so this
|
|
||||||
* file is compositionally symmetric to the editor-ext copy.
|
|
||||||
*
|
|
||||||
* Why it exists: every NON-editor write path (markdown import, update_page_json,
|
|
||||||
* docmost_transform, insert_footnote) builds ProseMirror JSON directly, so the
|
|
||||||
* editor's footnote plugins never run and the canonical topology (sequential
|
|
||||||
* numbering by first reference, one trailing list, no orphans, no raw `[^id]`)
|
|
||||||
* was never enforced. Running this at the end of every write path closes that
|
|
||||||
* gap; because it is idempotent, it is a no-op when the footnotes are already
|
|
||||||
* canonical (no spurious mutations / git-sync churn).
|
|
||||||
*
|
|
||||||
* ENFORCEMENT RULE (#228): any NEW FULL-document persist path MUST call
|
|
||||||
* `canonicalizeFootnotes(doc)` before writing — the current callers are
|
|
||||||
* `markdownToProseMirrorCanonical` (page markdown import/update; the plain
|
|
||||||
* `markdownToProseMirror` used for COMMENT bodies must NOT, or it would drop a
|
|
||||||
* reference-less definition), `update_page_json`, `docmost_transform`,
|
|
||||||
* `insert_footnote`, and `copy_page_content`. Append/prepend FRAGMENT writes MUST
|
|
||||||
* NOT canonicalize. This is deliberately per-call-site (the replace-vs-fragment
|
|
||||||
* and comment-vs-page nuances make a single naive wrapper unsafe).
|
|
||||||
*/
|
|
||||||
const FOOTNOTE_REFERENCE_NAME = "footnoteReference";
|
|
||||||
const FOOTNOTES_LIST_NAME = "footnotesList";
|
|
||||||
const FOOTNOTE_DEFINITION_NAME = "footnoteDefinition";
|
|
||||||
function cloneJson(v) {
|
|
||||||
if (typeof structuredClone === "function")
|
|
||||||
return structuredClone(v);
|
|
||||||
return JSON.parse(JSON.stringify(v));
|
|
||||||
}
|
|
||||||
function isEmptyParagraph(node) {
|
|
||||||
return (!!node &&
|
|
||||||
node.type === "paragraph" &&
|
|
||||||
(!Array.isArray(node.content) || node.content.length === 0));
|
|
||||||
}
|
|
||||||
function collectReferenceIds(node, out, seen) {
|
|
||||||
if (!node || typeof node !== "object")
|
|
||||||
return;
|
|
||||||
if (node.type === FOOTNOTE_REFERENCE_NAME) {
|
|
||||||
const id = node?.attrs?.id;
|
|
||||||
if (id && !seen.has(id)) {
|
|
||||||
seen.add(id);
|
|
||||||
out.push(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Array.isArray(node.content)) {
|
|
||||||
for (const child of node.content)
|
|
||||||
collectReferenceIds(child, out, seen);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function collectDefinitions(node, out) {
|
|
||||||
if (!node || typeof node !== "object")
|
|
||||||
return;
|
|
||||||
if (node.type === FOOTNOTE_DEFINITION_NAME)
|
|
||||||
out.push(node);
|
|
||||||
if (Array.isArray(node.content)) {
|
|
||||||
for (const child of node.content)
|
|
||||||
collectDefinitions(child, out);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function emptyDefinition(id) {
|
|
||||||
return {
|
|
||||||
type: FOOTNOTE_DEFINITION_NAME,
|
|
||||||
attrs: { id },
|
|
||||||
content: [{ type: "paragraph" }],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Deep equality over plain JSON: arrays are compared POSITIONALLY
|
|
||||||
* (order-SENSITIVE), object keys order-insensitively. The array order-sensitivity
|
|
||||||
* is required for correctness here — a reordered `footnotesList.content` must
|
|
||||||
* compare UNEQUAL so the canonical rebuild fires instead of leaving it in place.
|
|
||||||
*/
|
|
||||||
function deepEqualJson(a, b) {
|
|
||||||
if (a === b)
|
|
||||||
return true;
|
|
||||||
if (a == null || b == null || typeof a !== typeof b)
|
|
||||||
return false;
|
|
||||||
if (Array.isArray(a) || Array.isArray(b)) {
|
|
||||||
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
for (let i = 0; i < a.length; i++) {
|
|
||||||
if (!deepEqualJson(a[i], b[i]))
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (typeof a === "object") {
|
|
||||||
const ka = Object.keys(a);
|
|
||||||
const kb = Object.keys(b);
|
|
||||||
if (ka.length !== kb.length)
|
|
||||||
return false;
|
|
||||||
for (const k of ka) {
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(b, k))
|
|
||||||
return false;
|
|
||||||
if (!deepEqualJson(a[k], b[k]))
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Canonicalize footnotes in a ProseMirror-JSON document. See the file header and
|
|
||||||
* the editor-ext twin for the full contract. Pure (deep-clones input,
|
|
||||||
* deterministic, idempotent).
|
|
||||||
*/
|
|
||||||
export function canonicalizeFootnotes(doc) {
|
|
||||||
if (doc == null ||
|
|
||||||
typeof doc !== "object" ||
|
|
||||||
!Array.isArray(doc.content)) {
|
|
||||||
return doc;
|
|
||||||
}
|
|
||||||
const out = cloneJson(doc);
|
|
||||||
// 1) Distinct reference ids in document order (deep — refs can live in
|
|
||||||
// callouts, tables, list items, ...). The ordering/numbering truth.
|
|
||||||
const referenceIds = [];
|
|
||||||
collectReferenceIds(out, referenceIds, new Set());
|
|
||||||
// 2) Every definition node in document order (deep).
|
|
||||||
const defNodes = [];
|
|
||||||
collectDefinitions(out, defNodes);
|
|
||||||
// 3) First definition per id wins; later duplicates carry the SAME id, so they
|
|
||||||
// cannot be referenced separately and would be orphans — they are dropped.
|
|
||||||
const defById = new Map();
|
|
||||||
for (const d of defNodes) {
|
|
||||||
const id = d?.attrs?.id;
|
|
||||||
if (id && !defById.has(id))
|
|
||||||
defById.set(id, d);
|
|
||||||
}
|
|
||||||
// 4) Build the ordered definition list: one per referenced id, in REFERENCE
|
|
||||||
// order, reusing the existing node (shallow-copied, id normalized — `out` is
|
|
||||||
// already deep-cloned and the old lists are cut) or synthesizing an empty
|
|
||||||
// one. Definitions whose id is not referenced are orphans and never added.
|
|
||||||
const orderedDefs = [];
|
|
||||||
for (const id of referenceIds) {
|
|
||||||
const existing = defById.get(id);
|
|
||||||
if (existing) {
|
|
||||||
orderedDefs.push({
|
|
||||||
...existing,
|
|
||||||
attrs: { ...(existing.attrs ?? {}), id },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
orderedDefs.push(emptyDefinition(id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 5) No references -> there must be NO list at all (at any depth).
|
|
||||||
if (referenceIds.length === 0) {
|
|
||||||
stripFootnotesListsDeep(out);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
// 6) Placement parity with the live plugin: when the document is ALREADY in the
|
|
||||||
// canonical single-list state, leave that list exactly where it sits rather
|
|
||||||
// than cutting and re-inserting it at the end (the plugin never repositions a
|
|
||||||
// sole correct list, so moving it would silently reorder any content that
|
|
||||||
// follows the list on the first write).
|
|
||||||
const topLevelLists = out.content.filter((n) => n && n.type === FOOTNOTES_LIST_NAME);
|
|
||||||
if (topLevelLists.length === 1 &&
|
|
||||||
defNodes.length === orderedDefs.length &&
|
|
||||||
deepEqualJson(topLevelLists[0].content, orderedDefs)) {
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
// 7) Otherwise rebuild: strip every footnotesList AND every bare
|
|
||||||
// footnoteDefinition at ANY depth (collectDefinitions gathers defs
|
|
||||||
// recursively, so a list nested in a callout/blockquote — or a bare
|
|
||||||
// definition outside any list — would otherwise have its defs copied into the
|
|
||||||
// rebuilt list while the original survives in place → duplicates) and
|
|
||||||
// re-insert exactly one list after the last meaningful (non-empty paragraph)
|
|
||||||
// top-level block.
|
|
||||||
stripFootnotesListsDeep(out);
|
|
||||||
stripFootnoteDefinitionsDeep(out);
|
|
||||||
const top = out.content;
|
|
||||||
let insertAt = top.length;
|
|
||||||
while (insertAt > 0 && isEmptyParagraph(top[insertAt - 1]))
|
|
||||||
insertAt--;
|
|
||||||
top.splice(insertAt, 0, { type: FOOTNOTES_LIST_NAME, content: orderedDefs });
|
|
||||||
out.content = top;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
/** Remove every `footnotesList` node at ANY depth (mutates the given clone). */
|
|
||||||
function stripFootnotesListsDeep(node) {
|
|
||||||
if (!node || typeof node !== "object" || !Array.isArray(node.content))
|
|
||||||
return;
|
|
||||||
node.content = node.content.filter((c) => !(c && c.type === FOOTNOTES_LIST_NAME));
|
|
||||||
for (const child of node.content)
|
|
||||||
stripFootnotesListsDeep(child);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Remove every BARE `footnoteDefinition` node at ANY depth (mutates the given
|
|
||||||
* clone). Runs only in the rebuild path AFTER the lists are stripped, so it
|
|
||||||
* targets definitions that were sitting outside a list (e.g. hand-authored via a
|
|
||||||
* raw-JSON write path and nested in a callout); their content was already copied
|
|
||||||
* into the rebuilt list, so leaving the originals would duplicate them.
|
|
||||||
*/
|
|
||||||
function stripFootnoteDefinitionsDeep(node) {
|
|
||||||
if (!node || typeof node !== "object" || !Array.isArray(node.content))
|
|
||||||
return;
|
|
||||||
node.content = node.content.filter((c) => !(c && c.type === FOOTNOTE_DEFINITION_NAME));
|
|
||||||
for (const child of node.content)
|
|
||||||
stripFootnoteDefinitionsDeep(child);
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
/**
|
|
||||||
* Shared, fence-aware line lexer for footnote markdown (MCP-internal).
|
|
||||||
*
|
|
||||||
* Both the importer (`extractFootnotes` in collaboration.ts, which strips
|
|
||||||
* definition lines and rebuilds a footnotes section) and the diagnostics
|
|
||||||
* (`analyzeFootnotes` in footnote-analyze.ts) must agree EXACTLY on which lines
|
|
||||||
* are definitions and which lines are inert (inside a code fence). Sharing one
|
|
||||||
* lexer makes "the analyzer sees what the importer leaves" a structural property
|
|
||||||
* instead of two hand-kept copies that can drift (#166 review).
|
|
||||||
*
|
|
||||||
* NOTE: this is deliberately NOT shared with editor-ext's
|
|
||||||
* `extractFootnoteDefinitions` — that lives in a different package and the
|
|
||||||
* decoupling between the editor and the MCP mirror is intentional.
|
|
||||||
*/
|
|
||||||
/** A footnote DEFINITION line: `[^id]: text` (id + text captured). */
|
|
||||||
export const FOOTNOTE_DEF_RE = /^\[\^([^\]\s]+)\]:[ \t]*(.*)$/;
|
|
||||||
/** Every footnote REFERENCE `[^id]` in a line (global; id captured). */
|
|
||||||
export const FOOTNOTE_REF_RE_G = /\[\^([^\]\s]+)\]/g;
|
|
||||||
/** Opening/closing code fence marker (``` or ~~~). */
|
|
||||||
const FENCE_RE = /^(\s*)(`{3,}|~{3,})/;
|
|
||||||
/** Classify every line of `markdown`, tracking fenced-code state. Pure. */
|
|
||||||
export function lexFootnoteLines(markdown) {
|
|
||||||
const out = [];
|
|
||||||
let fence = null;
|
|
||||||
for (const line of markdown.split("\n")) {
|
|
||||||
const fenceMatch = FENCE_RE.exec(line);
|
|
||||||
if (fenceMatch) {
|
|
||||||
const marker = fenceMatch[2][0];
|
|
||||||
if (fence === null)
|
|
||||||
fence = marker; // opening fence
|
|
||||||
else if (marker === fence)
|
|
||||||
fence = null; // matching closing fence
|
|
||||||
out.push({ line, inFence: true, definition: null });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (fence !== null) {
|
|
||||||
out.push({ line, inFence: true, definition: null });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const m = FOOTNOTE_DEF_RE.exec(line);
|
|
||||||
out.push({
|
|
||||||
line,
|
|
||||||
inFence: false,
|
|
||||||
definition: m ? { id: m[1], text: m[2] } : null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
/** Scan a line for every `[^id]` reference, invoking `onRef(id)` for each. */
|
|
||||||
export function forEachFootnoteReference(line, onRef) {
|
|
||||||
FOOTNOTE_REF_RE_G.lastIndex = 0;
|
|
||||||
let m;
|
|
||||||
while ((m = FOOTNOTE_REF_RE_G.exec(line)) !== null)
|
|
||||||
onRef(m[1]);
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
// Detection + collection of INTERNAL Docmost file URLs inside a ProseMirror doc.
|
|
||||||
//
|
|
||||||
// An internal file URL is a relative path served by Docmost's authenticated
|
|
||||||
// attachment route (`GET /api/files/:fileId/:fileName`). It is useless to an
|
|
||||||
// external consumer (relative + needs a Docmost session), so the stash tool
|
|
||||||
// mirrors every such resource into the blob sandbox and rewrites its `src`.
|
|
||||||
//
|
|
||||||
// The criterion is "internal file URL", NOT the node TYPE: image, drawio,
|
|
||||||
// excalidraw, video and file nodes all carry such a `src`, so a type-agnostic
|
|
||||||
// walker covers them all. External http(s) srcs (CDNs) are left untouched.
|
|
||||||
//
|
|
||||||
// Mirrors editor-ext's isInternalFileUrl / normalizeFileUrl (kept as a local
|
|
||||||
// dup so the ESM mcp package does not depend on the editor-ext build).
|
|
||||||
function isInternalFileUrl(url) {
|
|
||||||
if (typeof url !== "string")
|
|
||||||
return false;
|
|
||||||
const normalized = url.trim();
|
|
||||||
return (normalized.startsWith("/api/files/") || normalized.startsWith("/files/"));
|
|
||||||
}
|
|
||||||
/** Normalize a bare `/files/...` src to the canonical `/api/files/...` form. */
|
|
||||||
export function normalizeFileUrl(src) {
|
|
||||||
const trimmed = src.trim();
|
|
||||||
if (trimmed.startsWith("/files/"))
|
|
||||||
return "/api" + trimmed;
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Resolve a page-content `src` into the safe, `/api`-relative path the stash
|
|
||||||
* tool may fetch over the authenticated loopback client — or THROW.
|
|
||||||
*
|
|
||||||
* SECURITY (SSRF / path-traversal): `src` comes from page content and is fully
|
|
||||||
* attacker-controllable. The mirroring fetch runs through the AUTHENTICATED
|
|
||||||
* loopback axios client whose baseURL ends in `/api`, so a naive
|
|
||||||
* `src.replace(/^\/api/, "")` lets a crafted value like
|
|
||||||
* `/api/files/../auth/whoami` collapse (via axios/WHATWG URL `..` resolution)
|
|
||||||
* into an ARBITRARY internal GET endpoint, whose authed response would then be
|
|
||||||
* stored in the anonymous sandbox (SSRF + data exfiltration). A prefix-only
|
|
||||||
* `startsWith("/api/files/")` check does NOT defend against this because the
|
|
||||||
* `..` segments are still present in the raw string and resolved later.
|
|
||||||
*
|
|
||||||
* This function defeats that by resolving the canonical pathname FIRST and only
|
|
||||||
* then asserting it still lives under `/api/files/`:
|
|
||||||
* - it rejects any percent-encoded dot/slash (`%2e` / `%2f`): the WHATWG URL
|
|
||||||
* parser collapses LITERAL `../` but does NOT decode `%2f` separators, so a
|
|
||||||
* content-controlled src must never be allowed to smuggle those past the
|
|
||||||
* canonicalization;
|
|
||||||
* - it resolves `new URL(trimmed, "http://internal.invalid").pathname`, which
|
|
||||||
* normalizes `..`/`.` segments (e.g. `/api/files/../auth/whoami` →
|
|
||||||
* `/api/auth/whoami`);
|
|
||||||
* - it then requires the canonical pathname to start with `/api/files/`, so a
|
|
||||||
* traversal that escaped that subtree is rejected.
|
|
||||||
*
|
|
||||||
* Returns the path RELATIVE to the `/api` base (e.g. `/files/<id>/<name>`),
|
|
||||||
* ready to hand to the loopback client. The throw happens BEFORE any network
|
|
||||||
* call, so a rejected src is counted as a failed mirror and its original src is
|
|
||||||
* kept (the per-image try/catch in stashPage never aborts the whole document).
|
|
||||||
*/
|
|
||||||
export function resolveInternalFilePath(src) {
|
|
||||||
const trimmed = src.trim();
|
|
||||||
// Percent-encoded dot/slash must never reach the URL canonicalizer: the
|
|
||||||
// WHATWG parser does NOT decode `%2f` into a path separator, so an encoded
|
|
||||||
// `..%2fauth` would survive canonicalization and still escape /api/files/.
|
|
||||||
if (/%2e|%2f/i.test(trimmed)) {
|
|
||||||
throw new Error(`Refusing internal file src with percent-encoded path segment: "${src}"`);
|
|
||||||
}
|
|
||||||
let pathname;
|
|
||||||
try {
|
|
||||||
// The base host is irrelevant (never contacted); it only lets the parser
|
|
||||||
// resolve a relative `src` and normalize `..`/`.` segments.
|
|
||||||
pathname = new URL(trimmed, "http://internal.invalid").pathname;
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
throw new Error(`Invalid internal file src: "${src}"`);
|
|
||||||
}
|
|
||||||
if (!pathname.startsWith("/api/files/")) {
|
|
||||||
throw new Error(`Refusing internal file src that escapes /api/files/: "${src}"`);
|
|
||||||
}
|
|
||||||
// Strip the `/api` base prefix; the loopback client's baseURL already ends
|
|
||||||
// in `/api`, so it expects the path relative to that (e.g. /files/<id>/<f>).
|
|
||||||
return pathname.replace(/^\/api/, "");
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Recursively collect every node whose `attrs.src` is an internal file URL.
|
|
||||||
* Returns references to the live nodes (so the caller can rewrite `attrs.src`
|
|
||||||
* in place on its clone). Descends `content` arrays, covering callouts, tables,
|
|
||||||
* details and any other nested container.
|
|
||||||
*/
|
|
||||||
export function collectInternalFileNodes(doc) {
|
|
||||||
const out = [];
|
|
||||||
const visit = (node) => {
|
|
||||||
if (!node)
|
|
||||||
return;
|
|
||||||
if (Array.isArray(node)) {
|
|
||||||
for (const child of node)
|
|
||||||
visit(child);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (typeof node !== "object")
|
|
||||||
return;
|
|
||||||
if (node.attrs && isInternalFileUrl(node.attrs.src)) {
|
|
||||||
out.push(node);
|
|
||||||
}
|
|
||||||
if (Array.isArray(node.content)) {
|
|
||||||
for (const child of node.content)
|
|
||||||
visit(child);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
visit(doc);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
@@ -1,393 +0,0 @@
|
|||||||
/**
|
|
||||||
* Surgical text edits on a ProseMirror document without re-importing it.
|
|
||||||
*
|
|
||||||
* Each edit replaces an exact substring of a block's inline text, preserving
|
|
||||||
* every node id, mark and attribute around it. Matching works at the
|
|
||||||
* INLINE-CONTAINER (block) level: a block's text nodes are flattened into a
|
|
||||||
* per-character array, so a `find` may freely cross bold/italic/link
|
|
||||||
* boundaries (separate text nodes). The replacement inherits marks from the
|
|
||||||
* unchanged common prefix/suffix of the match, so editing plain text next to a
|
|
||||||
* bold word keeps the bold word bold, and editing the inside of a bold word
|
|
||||||
* keeps the inserted text bold. This is the safe alternative to a full markdown
|
|
||||||
* re-import for small wording fixes.
|
|
||||||
*/
|
|
||||||
import { stripInlineMarkdown, stripBalancedWrappers } from "./text-normalize.js";
|
|
||||||
/** Placeholder code unit standing in for one opaque (non-text) inline node. */
|
|
||||||
const ATOM_PLACEHOLDER = ""; // OBJECT REPLACEMENT CHARACTER
|
|
||||||
/**
|
|
||||||
* Find every VALID occurrence of `needle` in a block's flattened slots.
|
|
||||||
*
|
|
||||||
* A candidate occurrence at slot range [start, start+needle.length) is valid
|
|
||||||
* ONLY IF none of the slots in that range are atoms (non-text inline nodes).
|
|
||||||
* This makes atom matching collision-safe against the U+FFFC placeholder: an
|
|
||||||
* atom slot can never be part of a match, while a real text node containing a
|
|
||||||
* literal U+FFFC code unit still matches normally (its slot has no `.atom`).
|
|
||||||
*
|
|
||||||
* Overlapping candidates that touch an atom are skipped (not counted, not
|
|
||||||
* spliced); the scan resumes one code unit past the rejected start so a valid
|
|
||||||
* match that begins just after an atom is not missed.
|
|
||||||
*/
|
|
||||||
function findValidMatches(chars, plain, needle) {
|
|
||||||
if (!needle)
|
|
||||||
return [];
|
|
||||||
const positions = [];
|
|
||||||
let idx = plain.indexOf(needle);
|
|
||||||
while (idx !== -1) {
|
|
||||||
const end = idx + needle.length;
|
|
||||||
let hasAtom = false;
|
|
||||||
for (let i = idx; i < end; i++) {
|
|
||||||
if (chars[i] && chars[i].atom) {
|
|
||||||
hasAtom = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!hasAtom) {
|
|
||||||
positions.push(idx);
|
|
||||||
// Non-overlapping: skip past this match.
|
|
||||||
idx = plain.indexOf(needle, end);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// This candidate crosses an atom: reject it and resume one unit later so
|
|
||||||
// an overlapping valid match starting after the atom is still found.
|
|
||||||
idx = plain.indexOf(needle, idx + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return positions;
|
|
||||||
}
|
|
||||||
/** Order-sensitive deep-equality of two marks arrays. */
|
|
||||||
function marksEqual(a, b) {
|
|
||||||
if (a === b)
|
|
||||||
return true;
|
|
||||||
if (a.length !== b.length)
|
|
||||||
return false;
|
|
||||||
for (let i = 0; i < a.length; i++) {
|
|
||||||
if (JSON.stringify(a[i]) !== JSON.stringify(b[i]))
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
/** A block is any node that DIRECTLY contains at least one inline text child. */
|
|
||||||
function isInlineBlock(node) {
|
|
||||||
return (Array.isArray(node?.content) &&
|
|
||||||
node.content.some((child) => child && child.type === "text"));
|
|
||||||
}
|
|
||||||
/** Flatten a block's inline content into a per-code-unit slot array. */
|
|
||||||
function flattenBlock(node) {
|
|
||||||
const chars = [];
|
|
||||||
for (const child of node.content || []) {
|
|
||||||
if (child && child.type === "text" && typeof child.text === "string") {
|
|
||||||
const marks = child.marks || [];
|
|
||||||
// Iterate by UTF-16 code unit so indices align with String.indexOf.
|
|
||||||
for (let i = 0; i < child.text.length; i++) {
|
|
||||||
chars.push({ ch: child.text[i], marks });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// Any non-text inline node becomes one opaque slot.
|
|
||||||
chars.push({
|
|
||||||
ch: ATOM_PLACEHOLDER,
|
|
||||||
marks: (child && child.marks) || [],
|
|
||||||
atom: child,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return chars;
|
|
||||||
}
|
|
||||||
/** Re-tokenize a slot array back into ProseMirror inline nodes. */
|
|
||||||
function tokenizeChars(chars) {
|
|
||||||
const out = [];
|
|
||||||
let buffer = "";
|
|
||||||
let bufferMarks = null;
|
|
||||||
const flush = () => {
|
|
||||||
if (buffer.length === 0)
|
|
||||||
return;
|
|
||||||
const textNode = { type: "text", text: buffer };
|
|
||||||
if (bufferMarks && bufferMarks.length > 0)
|
|
||||||
textNode.marks = bufferMarks;
|
|
||||||
out.push(textNode);
|
|
||||||
buffer = "";
|
|
||||||
bufferMarks = null;
|
|
||||||
};
|
|
||||||
for (const slot of chars) {
|
|
||||||
if (slot.atom) {
|
|
||||||
flush();
|
|
||||||
out.push(slot.atom);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (bufferMarks !== null && !marksEqual(bufferMarks, slot.marks)) {
|
|
||||||
flush();
|
|
||||||
}
|
|
||||||
if (bufferMarks === null)
|
|
||||||
bufferMarks = slot.marks;
|
|
||||||
buffer += slot.ch;
|
|
||||||
}
|
|
||||||
flush();
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
/** Longest common prefix length of two strings. */
|
|
||||||
function commonPrefixLen(a, b) {
|
|
||||||
const max = Math.min(a.length, b.length);
|
|
||||||
let i = 0;
|
|
||||||
while (i < max && a[i] === b[i])
|
|
||||||
i++;
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
/** Longest common suffix length of two strings, capped so it can't overlap. */
|
|
||||||
function commonSuffixLen(a, b, cap) {
|
|
||||||
const max = Math.min(a.length, b.length, cap);
|
|
||||||
let i = 0;
|
|
||||||
while (i < max && a[a.length - 1 - i] === b[b.length - 1 - i])
|
|
||||||
i++;
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Apply one edit to one block's flattened slot array.
|
|
||||||
*
|
|
||||||
* The caller passes only VALID (atom-free) match positions (see
|
|
||||||
* findValidMatches), so no match range can overlap an atom slot here.
|
|
||||||
*/
|
|
||||||
function applyEditToChars(chars, edit, matchPositions) {
|
|
||||||
// Pre-compute the diff slices once (find/replace are constant per edit).
|
|
||||||
const p = commonPrefixLen(edit.find, edit.replace);
|
|
||||||
const s = commonSuffixLen(edit.find, edit.replace, Math.min(edit.find.length, edit.replace.length) - p);
|
|
||||||
const insertText = edit.replace.slice(p, edit.replace.length - s);
|
|
||||||
// Rebuild the slot array in a single left-to-right pass, splicing at each
|
|
||||||
// match start. Offsets into `chars` stay valid because we copy through.
|
|
||||||
const newChars = [];
|
|
||||||
let cursor = 0;
|
|
||||||
let spliced = 0;
|
|
||||||
for (const mStart of matchPositions) {
|
|
||||||
const mEnd = mStart + edit.find.length;
|
|
||||||
const changedStart = mStart + p;
|
|
||||||
const changedEnd = mEnd - s;
|
|
||||||
// Copy through everything up to the changed region (incl. the prefix).
|
|
||||||
for (; cursor < changedStart; cursor++)
|
|
||||||
newChars.push(chars[cursor]);
|
|
||||||
const removed = chars.slice(changedStart, changedEnd);
|
|
||||||
// Choose the marks for the inserted characters.
|
|
||||||
let chosenMarks = [];
|
|
||||||
if (removed.length > 0 &&
|
|
||||||
removed.every((r) => marksEqual(r.marks, removed[0].marks))) {
|
|
||||||
// Uniform removed region: inherit its marks directly.
|
|
||||||
chosenMarks = removed[0].marks;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// Empty or non-uniform removed region: inherit from the nearest TEXT
|
|
||||||
// neighbour, skipping atom slots (an atom carries marks that do not
|
|
||||||
// belong on inserted text). Scan left first, then right; fall back to [].
|
|
||||||
let inherited = null;
|
|
||||||
for (let i = changedStart - 1; i >= 0; i--) {
|
|
||||||
if (!chars[i].atom) {
|
|
||||||
inherited = chars[i].marks;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (inherited === null) {
|
|
||||||
for (let i = changedEnd; i < chars.length; i++) {
|
|
||||||
if (!chars[i].atom) {
|
|
||||||
inherited = chars[i].marks;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
chosenMarks = inherited === null ? [] : inherited;
|
|
||||||
}
|
|
||||||
// Emit the inserted text (one slot per code unit).
|
|
||||||
for (let i = 0; i < insertText.length; i++) {
|
|
||||||
newChars.push({ ch: insertText[i], marks: chosenMarks });
|
|
||||||
}
|
|
||||||
// Skip the removed region.
|
|
||||||
cursor = changedEnd;
|
|
||||||
spliced++;
|
|
||||||
}
|
|
||||||
// Copy through the tail.
|
|
||||||
for (; cursor < chars.length; cursor++)
|
|
||||||
newChars.push(chars[cursor]);
|
|
||||||
return { newChars, spliced };
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Apply text edits to a ProseMirror doc (operates on a deep copy, returns it).
|
|
||||||
*
|
|
||||||
* Returns { doc, results, failed }:
|
|
||||||
* - results: edits that applied (replacements >= 1).
|
|
||||||
* - failed: edits that matched zero times, were ambiguous (multi-match
|
|
||||||
* without replaceAll), or whose changed region crosses a non-text inline
|
|
||||||
* node. These do NOT throw — they are recorded so the caller can surface an
|
|
||||||
* actionable message and still keep the edits that did apply.
|
|
||||||
*
|
|
||||||
* Edits apply IN ORDER to the same working copy, so a later edit can target
|
|
||||||
* text produced by an earlier one. The input doc is never mutated. The only
|
|
||||||
* thrown error is for invalid input (an empty `edit.find`).
|
|
||||||
*/
|
|
||||||
export function applyTextEdits(doc, edits) {
|
|
||||||
const copy = JSON.parse(JSON.stringify(doc));
|
|
||||||
const results = [];
|
|
||||||
const failed = [];
|
|
||||||
for (const edit of edits) {
|
|
||||||
if (!edit.find)
|
|
||||||
throw new Error("edit.find must be a non-empty string");
|
|
||||||
// HARD-REFUSE formatting changes. edit_page_text edits PLAIN TEXT only and
|
|
||||||
// writes the replacement verbatim, so it cannot add/remove marks. We refuse
|
|
||||||
// only a pure formatting TOGGLE: find and replace differ ONLY by balanced
|
|
||||||
// markdown markers (e.g. find:"~~$69~~" / replace:"$69", or find:"M5Stack" /
|
|
||||||
// replace:"**M5Stack**" which would write literal `**`).
|
|
||||||
//
|
|
||||||
// The detector is the STRICT stripBalancedWrappers, NOT the lenient locator
|
|
||||||
// stripInlineMarkdown: the lenient one also trims whitespace/emoji and
|
|
||||||
// collapses lone `*`/`_` runs, which gives false positives on ordinary
|
|
||||||
// plain-text edits (trailing-space trim, snake_case, `2 * 3 * 4`, URLs with
|
|
||||||
// underscores) and wrongly refuses them. Comparing the strict strip of both
|
|
||||||
// sides symmetrically catches every real formatting toggle while leaving
|
|
||||||
// plain text alone; a typo fix wrapped in markdown still applies because its
|
|
||||||
// stripped find != stripped replace.
|
|
||||||
const formattingOnly = edit.find !== edit.replace &&
|
|
||||||
stripBalancedWrappers(edit.find) === stripBalancedWrappers(edit.replace);
|
|
||||||
if (formattingOnly) {
|
|
||||||
failed.push({
|
|
||||||
find: edit.find,
|
|
||||||
reason: "edit_page_text edits plain text only and cannot add or remove formatting marks (bold/italic/strike/code/link); it writes the replacement as LITERAL text. This edit looks like a formatting change (markdown markers in find/replace). To change marks, read the block with get_page_json and use patch_node (or update_page_json) to set the node's marks array.",
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Gather every inline block in document order (recurse the whole tree so
|
|
||||||
// nested containers — callouts, list items, table cells, blockquotes — are
|
|
||||||
// all covered).
|
|
||||||
const blocks = [];
|
|
||||||
(function collect(node) {
|
|
||||||
if (isInlineBlock(node))
|
|
||||||
blocks.push(node);
|
|
||||||
for (const child of node.content || [])
|
|
||||||
collect(child);
|
|
||||||
})(copy);
|
|
||||||
// Find every VALID (atom-free) occurrence per block. A candidate whose slot
|
|
||||||
// range overlaps a non-text inline atom is never a match (collision-safe vs
|
|
||||||
// the U+FFFC placeholder), so it is excluded from both the uniqueness count
|
|
||||||
// and the splicing.
|
|
||||||
const blockChars = blocks.map((b) => flattenBlock(b));
|
|
||||||
const blockPlain = blockChars.map((chars) => chars.map((c) => c.ch).join(""));
|
|
||||||
// EXACT MATCH WINS: try the verbatim locator first.
|
|
||||||
let effectiveFind = edit.find;
|
|
||||||
let normalized = false;
|
|
||||||
let validPerBlock = blockChars.map((chars, b) => findValidMatches(chars, blockPlain[b], edit.find));
|
|
||||||
let total = 0;
|
|
||||||
for (const positions of validPerBlock)
|
|
||||||
total += positions.length;
|
|
||||||
// FALLBACK: only if the verbatim locator matched nothing, retry with the
|
|
||||||
// markdown-stripped form. `edit.replace` is never touched — this only
|
|
||||||
// changes what we LOCATE, not what we insert.
|
|
||||||
const stripped = stripInlineMarkdown(edit.find);
|
|
||||||
if (total === 0 && stripped !== edit.find && stripped.length > 0) {
|
|
||||||
const strippedPerBlock = blockChars.map((chars, b) => findValidMatches(chars, blockPlain[b], stripped));
|
|
||||||
let strippedTotal = 0;
|
|
||||||
for (const positions of strippedPerBlock)
|
|
||||||
strippedTotal += positions.length;
|
|
||||||
if (strippedTotal >= 1) {
|
|
||||||
validPerBlock = strippedPerBlock;
|
|
||||||
total = strippedTotal;
|
|
||||||
effectiveFind = stripped;
|
|
||||||
normalized = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (total === 0) {
|
|
||||||
// Distinguish "the text exists but only across an atom" from a plain
|
|
||||||
// not-found: if a raw substring scan (atoms included) WOULD have hit —
|
|
||||||
// for EITHER the verbatim or the stripped locator — the only thing
|
|
||||||
// blocking the edit is the atom, so report that.
|
|
||||||
const existsAcrossAtom = blockPlain.some((plain) => plain.indexOf(edit.find) !== -1 ||
|
|
||||||
(stripped !== edit.find && plain.indexOf(stripped) !== -1));
|
|
||||||
let reason;
|
|
||||||
if (existsAcrossAtom) {
|
|
||||||
reason =
|
|
||||||
"match crosses a non-text inline node (image/break/mention); use update_page_json for structural changes.";
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// Append a bounded "closest text" hint: find the FIRST block that
|
|
||||||
// contains the longest whitespace-delimited token (>= 3 chars) of the
|
|
||||||
// (stripped, then raw) locator, and quote that block's plain text.
|
|
||||||
reason = "text not found in the document.";
|
|
||||||
const tokenSource = stripped.length > 0 ? stripped : edit.find;
|
|
||||||
const longestToken = tokenSource
|
|
||||||
.split(/\s+/)
|
|
||||||
.filter((t) => t.length >= 3)
|
|
||||||
.sort((a, b) => b.length - a.length)[0];
|
|
||||||
if (longestToken) {
|
|
||||||
const hitBlock = blockPlain.find((plain) => plain.includes(longestToken));
|
|
||||||
if (hitBlock) {
|
|
||||||
// Truncate by code point (spread iterates by code point) so a
|
|
||||||
// surrogate pair is never split; append the ellipsis only when the
|
|
||||||
// text was actually longer than the limit.
|
|
||||||
const points = [...hitBlock];
|
|
||||||
const snippet = points.length > 120
|
|
||||||
? points.slice(0, 120).join("") + "…"
|
|
||||||
: hitBlock;
|
|
||||||
reason += ` Closest block text: "${snippet}".`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
failed.push({ find: edit.find, reason });
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (total > 1 && !edit.replaceAll) {
|
|
||||||
failed.push({
|
|
||||||
find: edit.find,
|
|
||||||
reason: `matches ${total} times. Provide a longer, unique fragment or set replaceAll: true.`,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// Plan the splices from the valid positions. For a non-replaceAll edit we
|
|
||||||
// splice only the first valid match (left-to-right across blocks); for
|
|
||||||
// replaceAll we splice every valid match.
|
|
||||||
const plannedPerBlock = blockChars.map(() => []);
|
|
||||||
let takenFirst = false;
|
|
||||||
for (let b = 0; b < validPerBlock.length; b++) {
|
|
||||||
for (const idx of validPerBlock[b]) {
|
|
||||||
if (edit.replaceAll) {
|
|
||||||
plannedPerBlock[b].push(idx);
|
|
||||||
}
|
|
||||||
else if (!takenFirst) {
|
|
||||||
plannedPerBlock[b].push(idx);
|
|
||||||
takenFirst = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!edit.replaceAll && takenFirst)
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
// Apply the splices block-by-block and re-tokenize changed blocks. The
|
|
||||||
// local edit uses `effectiveFind` (verbatim or normalized) so the
|
|
||||||
// prefix/suffix diff is computed against the ACTUALLY matched text, while
|
|
||||||
// `edit.replace` stays literal — never stripped.
|
|
||||||
const effectiveEdit = {
|
|
||||||
find: effectiveFind,
|
|
||||||
replace: edit.replace,
|
|
||||||
replaceAll: edit.replaceAll,
|
|
||||||
};
|
|
||||||
let spliced = 0;
|
|
||||||
for (let b = 0; b < blocks.length; b++) {
|
|
||||||
if (plannedPerBlock[b].length === 0)
|
|
||||||
continue;
|
|
||||||
const { newChars, spliced: n } = applyEditToChars(blockChars[b], effectiveEdit, plannedPerBlock[b]);
|
|
||||||
spliced += n;
|
|
||||||
blocks[b].content = tokenizeChars(newChars);
|
|
||||||
}
|
|
||||||
// Keep `find: edit.find` (the original) so the caller can correlate.
|
|
||||||
const result = { find: edit.find, replacements: spliced };
|
|
||||||
if (normalized)
|
|
||||||
result.normalized = true;
|
|
||||||
results.push(result);
|
|
||||||
}
|
|
||||||
// Safety net: drop any empty text nodes (ProseMirror forbids them). The
|
|
||||||
// re-tokenizer never emits empty text nodes, but untouched blocks could in
|
|
||||||
// principle carry one in from upstream.
|
|
||||||
(function prune(node) {
|
|
||||||
if (Array.isArray(node.content)) {
|
|
||||||
node.content = node.content.filter((child) => !(child.type === "text" && child.text === ""));
|
|
||||||
for (const child of node.content)
|
|
||||||
prune(child);
|
|
||||||
}
|
|
||||||
})(copy);
|
|
||||||
return { doc: copy, results, failed };
|
|
||||||
}
|
|
||||||
@@ -1,835 +0,0 @@
|
|||||||
/**
|
|
||||||
* Convert ProseMirror/TipTap JSON content to Markdown
|
|
||||||
* Supports all Docmost-specific node types and extensions
|
|
||||||
*/
|
|
||||||
export function convertProseMirrorToMarkdown(content) {
|
|
||||||
if (!content || !content.content)
|
|
||||||
return "";
|
|
||||||
// Escape a value interpolated into an HTML double-quoted attribute value
|
|
||||||
// (textAlign, colors, image src, math `text`, all data-* attrs, etc.). In the
|
|
||||||
// ATTRIBUTE context only the quote that delimits the value and the ampersand
|
|
||||||
// that starts an entity are special, so we escape ONLY & " (and ' for safety
|
|
||||||
// when single-quoted delimiters are used). We deliberately do NOT escape < or
|
|
||||||
// >: the HTML re-parser (parse5/jsdom via @tiptap/html) does NOT decode
|
|
||||||
// </> back inside attribute values, so escaping them would corrupt the
|
|
||||||
// stored data (e.g. a math node's LaTeX `a < b`) and ACCUMULATE escapes on
|
|
||||||
// every round-trip (`a < b` -> `a < b` -> `a &lt; b`). Escaping & "
|
|
||||||
// keeps the value inert against attribute-injection while staying idempotent.
|
|
||||||
// NOTE: escape ONLY & and " here. The value is always wrapped in double
|
|
||||||
// quotes, so " is the only delimiter; ' is NOT special in a double-quoted
|
|
||||||
// value, and parse5 does not decode ' back inside attribute values, so
|
|
||||||
// escaping ' would (like < >) corrupt the value and accumulate & on every
|
|
||||||
// round-trip. Escaping & and " is idempotent (parse5 decodes them back).
|
|
||||||
const escapeAttr = (value) => String(value)
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/"/g, """);
|
|
||||||
// Escape a value placed as HTML element TEXT content (between tags), where
|
|
||||||
// <, >, and & are all significant. Used for text rendered inside raw-HTML
|
|
||||||
// blocks (table cells / columns) so stored characters cannot inject markup.
|
|
||||||
const escapeHtmlText = (value) => String(value)
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">");
|
|
||||||
// Percent-encode characters that would break out of a markdown URL target
|
|
||||||
// (...) — whitespace/newlines and parentheses — so a stored src stays a
|
|
||||||
// single inert token (used for image/video/youtube srcs).
|
|
||||||
const encodeMdUrl = (value) => String(value || "")
|
|
||||||
.replace(/\s/g, (c) => (c === " " ? "%20" : encodeURIComponent(c)))
|
|
||||||
.replace(/\(/g, "%28")
|
|
||||||
.replace(/\)/g, "%29");
|
|
||||||
const processNode = (node) => {
|
|
||||||
const type = node.type;
|
|
||||||
const nodeContent = node.content || [];
|
|
||||||
switch (type) {
|
|
||||||
case "doc":
|
|
||||||
return nodeContent.map(processNode).join("\n\n");
|
|
||||||
case "paragraph":
|
|
||||||
const text = nodeContent.map(processNode).join("");
|
|
||||||
const align = node.attrs?.textAlign;
|
|
||||||
if (align && align !== "left") {
|
|
||||||
return `<div align="${escapeAttr(align)}">${text}</div>`;
|
|
||||||
}
|
|
||||||
return text || "";
|
|
||||||
case "heading":
|
|
||||||
const level = node.attrs?.level || 1;
|
|
||||||
const headingText = nodeContent.map(processNode).join("");
|
|
||||||
return "#".repeat(level) + " " + headingText;
|
|
||||||
case "text":
|
|
||||||
let textContent = node.text || "";
|
|
||||||
// Apply marks (bold, italic, code, etc.)
|
|
||||||
if (node.marks) {
|
|
||||||
// Markdown code spans (`...`) cannot carry inner formatting, so when a
|
|
||||||
// run has the `code` mark alongside ANY other mark, backtick syntax
|
|
||||||
// would leak literal ** / []() into the code text. In that case emit
|
|
||||||
// nested HTML (<code> innermost, the other marks wrapping it as HTML)
|
|
||||||
// so the output is at least well-formed and re-parseable.
|
|
||||||
//
|
|
||||||
// NOTE: this does NOT round-trip both marks. The schema's `code` mark
|
|
||||||
// has `excludes: "_"` (it excludes every other mark), so on import the
|
|
||||||
// co-occurring mark is always dropped — the run comes back as `code`
|
|
||||||
// only. We keep the emission simple and accept that the other mark is
|
|
||||||
// lost; preserving both is impossible while `code` excludes them.
|
|
||||||
// Only use the backtick form when `code` is the sole mark.
|
|
||||||
const markTypes = node.marks.map((m) => m.type);
|
|
||||||
const hasCode = markTypes.includes("code");
|
|
||||||
const codeCombined = hasCode && markTypes.length > 1;
|
|
||||||
for (const mark of node.marks) {
|
|
||||||
switch (mark.type) {
|
|
||||||
case "bold":
|
|
||||||
textContent = codeCombined
|
|
||||||
? `<strong>${textContent}</strong>`
|
|
||||||
: `**${textContent}**`;
|
|
||||||
break;
|
|
||||||
case "italic":
|
|
||||||
textContent = codeCombined
|
|
||||||
? `<em>${textContent}</em>`
|
|
||||||
: `*${textContent}*`;
|
|
||||||
break;
|
|
||||||
case "code":
|
|
||||||
// When combined with another mark, wrap as <code> so the
|
|
||||||
// surrounding HTML marks can nest around it; otherwise use the
|
|
||||||
// plain backtick span.
|
|
||||||
textContent = codeCombined
|
|
||||||
? `<code>${textContent}</code>`
|
|
||||||
: `\`${textContent}\``;
|
|
||||||
break;
|
|
||||||
case "link": {
|
|
||||||
const href = mark.attrs?.href || "";
|
|
||||||
const title = mark.attrs?.title;
|
|
||||||
if (codeCombined) {
|
|
||||||
// Emit an HTML anchor so it can wrap the nested <code>.
|
|
||||||
const safeHref = escapeAttr(href);
|
|
||||||
if (title) {
|
|
||||||
textContent = `<a href="${safeHref}" title="${escapeAttr(String(title))}">${textContent}</a>`;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
textContent = `<a href="${safeHref}">${textContent}</a>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (title) {
|
|
||||||
// Emit the optional markdown link title; escape an embedded
|
|
||||||
// double-quote so it cannot terminate the title string early.
|
|
||||||
const safeTitle = String(title).replace(/"/g, '\\"');
|
|
||||||
textContent = `[${textContent}](${href} "${safeTitle}")`;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
textContent = `[${textContent}](${href})`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "strike":
|
|
||||||
textContent = codeCombined
|
|
||||||
? `<s>${textContent}</s>`
|
|
||||||
: `~~${textContent}~~`;
|
|
||||||
break;
|
|
||||||
case "underline":
|
|
||||||
textContent = `<u>${textContent}</u>`;
|
|
||||||
break;
|
|
||||||
case "subscript":
|
|
||||||
textContent = `<sub>${textContent}</sub>`;
|
|
||||||
break;
|
|
||||||
case "superscript":
|
|
||||||
textContent = `<sup>${textContent}</sup>`;
|
|
||||||
break;
|
|
||||||
case "highlight": {
|
|
||||||
// Preserve a null/empty color as a plain highlight (a bare
|
|
||||||
// <mark> with no background-color); only emit the style when a
|
|
||||||
// color is actually set, so a plain highlight is not forced to
|
|
||||||
// yellow on export.
|
|
||||||
const color = mark.attrs?.color;
|
|
||||||
textContent = color
|
|
||||||
? `<mark style="background-color: ${escapeAttr(color)}">${textContent}</mark>`
|
|
||||||
: `<mark>${textContent}</mark>`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "textStyle":
|
|
||||||
if (mark.attrs?.color) {
|
|
||||||
textContent = `<span style="color: ${escapeAttr(mark.attrs.color)}">${textContent}</span>`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "comment": {
|
|
||||||
// Emit the inline comment anchor so highlights round-trip. The
|
|
||||||
// schema's Comment mark parses span[data-comment-id] (attrs
|
|
||||||
// commentId/resolved).
|
|
||||||
const cid = mark.attrs?.commentId;
|
|
||||||
if (cid) {
|
|
||||||
const resolvedAttr = mark.attrs?.resolved
|
|
||||||
? ` data-resolved="true"`
|
|
||||||
: "";
|
|
||||||
textContent = `<span data-comment-id="${escapeAttr(cid)}"${resolvedAttr}>${textContent}</span>`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "spoiler":
|
|
||||||
// Markdown has no native spoiler syntax, so emit the same
|
|
||||||
// lossless raw HTML the editor-ext turndown rule produces; the
|
|
||||||
// schema's Spoiler mark parses span[data-spoiler] back on import.
|
|
||||||
textContent = `<span data-spoiler="true">${textContent}</span>`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return textContent;
|
|
||||||
case "codeBlock":
|
|
||||||
const language = node.attrs?.language || "";
|
|
||||||
// Strip ALL trailing newlines so the export is idempotent: marked
|
|
||||||
// re-adds exactly one trailing "\n" on import, so trimming only one
|
|
||||||
// here would let the text grow by "\n" on each round-trip. Removing
|
|
||||||
// every trailing newline makes repeated cycles stable.
|
|
||||||
const code = nodeContent
|
|
||||||
.map(processNode)
|
|
||||||
.join("")
|
|
||||||
.replace(/\n+$/, "");
|
|
||||||
return "```" + language + "\n" + code + "\n```";
|
|
||||||
case "bulletList":
|
|
||||||
return nodeContent
|
|
||||||
.map((item) => processListItem(item, "-"))
|
|
||||||
.join("\n");
|
|
||||||
case "orderedList":
|
|
||||||
return nodeContent
|
|
||||||
.map((item, index) => processListItem(item, `${index + 1}.`))
|
|
||||||
.join("\n");
|
|
||||||
case "taskList":
|
|
||||||
return nodeContent.map((item) => processTaskItem(item)).join("\n");
|
|
||||||
case "taskItem":
|
|
||||||
// Delegate to the same helper used by taskList so multi-block and
|
|
||||||
// nested task items render and indent consistently.
|
|
||||||
return processTaskItem(node);
|
|
||||||
case "listItem":
|
|
||||||
return nodeContent.map(processNode).join("\n");
|
|
||||||
case "blockquote":
|
|
||||||
// Prefix EVERY line of EVERY child with "> " and separate block-level
|
|
||||||
// children with a blank ">" line so code blocks / multi-paragraph
|
|
||||||
// quotes round-trip correctly.
|
|
||||||
return nodeContent
|
|
||||||
.map((n) => processNode(n)
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => (line.length ? `> ${line}` : ">"))
|
|
||||||
.join("\n"))
|
|
||||||
.join("\n>\n");
|
|
||||||
case "horizontalRule":
|
|
||||||
return "---";
|
|
||||||
case "hardBreak":
|
|
||||||
// Two trailing spaces before the newline encode a markdown hard break;
|
|
||||||
// a bare "\n" would be reimported as a soft break and lost.
|
|
||||||
return " \n";
|
|
||||||
case "image": {
|
|
||||||
const imgAlt = node.attrs?.alt || "";
|
|
||||||
const imgCaption = node.attrs?.caption || "";
|
|
||||||
if (imgCaption) {
|
|
||||||
// ![]() can't carry a caption, so (symmetric to video) emit a raw
|
|
||||||
// <img> wrapped in a block <div>. On import marked.parse keeps the raw
|
|
||||||
// HTML and generateJSON runs the image extension's parseHTML, which
|
|
||||||
// restores the caption from data-caption.
|
|
||||||
const parts = [`src="${escapeAttr(node.attrs?.src ?? "")}"`];
|
|
||||||
if (imgAlt)
|
|
||||||
parts.push(`alt="${escapeAttr(imgAlt)}"`);
|
|
||||||
parts.push(`data-caption="${escapeAttr(imgCaption)}"`);
|
|
||||||
return `<div><img ${parts.join(" ")}></div>`;
|
|
||||||
}
|
|
||||||
// Neutralize characters that could break out of the markdown image
|
|
||||||
// URL: spaces/newlines and parentheses would terminate the (...) target
|
|
||||||
// and let a stored src inject following markdown/HTML. Percent-encode
|
|
||||||
// them so the URL stays a single inert token.
|
|
||||||
const imgSrc = encodeMdUrl(node.attrs?.src);
|
|
||||||
return ``;
|
|
||||||
}
|
|
||||||
case "video": {
|
|
||||||
// Emit the schema-matching <video> element so generateJSON rebuilds the
|
|
||||||
// node with its attrs intact. The schema's parseHTML reads src/aria-label
|
|
||||||
// from the standard attributes and the remaining attrs from data-*.
|
|
||||||
const attrs = node.attrs || {};
|
|
||||||
const parts = [`src="${escapeAttr(attrs.src ?? "")}"`];
|
|
||||||
if (attrs.alt)
|
|
||||||
parts.push(`aria-label="${escapeAttr(attrs.alt)}"`);
|
|
||||||
if (attrs.attachmentId)
|
|
||||||
parts.push(`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`);
|
|
||||||
if (attrs.width != null)
|
|
||||||
parts.push(`width="${escapeAttr(attrs.width)}"`);
|
|
||||||
if (attrs.height != null)
|
|
||||||
parts.push(`height="${escapeAttr(attrs.height)}"`);
|
|
||||||
if (attrs.size != null)
|
|
||||||
parts.push(`data-size="${escapeAttr(attrs.size)}"`);
|
|
||||||
if (attrs.align)
|
|
||||||
parts.push(`data-align="${escapeAttr(attrs.align)}"`);
|
|
||||||
if (attrs.aspectRatio != null)
|
|
||||||
parts.push(`data-aspect-ratio="${escapeAttr(attrs.aspectRatio)}"`);
|
|
||||||
// Wrap in a block <div> so marked treats it as a block (a bare <video>
|
|
||||||
// is inline-level HTML and marked wraps it in <p>, leaving a spurious
|
|
||||||
// empty paragraph beside the hoisted block atom). The wrapper has no
|
|
||||||
// data-type, so the schema parser ignores it and just hoists the video.
|
|
||||||
return `<div><video ${parts.join(" ")}></video></div>`;
|
|
||||||
}
|
|
||||||
case "youtube": {
|
|
||||||
// Emit the schema-matching div[data-type="youtube"]; the schema reads
|
|
||||||
// src from data-src and width/height/align from data-* attributes.
|
|
||||||
const attrs = node.attrs || {};
|
|
||||||
const parts = [
|
|
||||||
`data-type="youtube"`,
|
|
||||||
`data-src="${escapeAttr(attrs.src ?? "")}"`,
|
|
||||||
];
|
|
||||||
if (attrs.width != null)
|
|
||||||
parts.push(`data-width="${escapeAttr(attrs.width)}"`);
|
|
||||||
if (attrs.height != null)
|
|
||||||
parts.push(`data-height="${escapeAttr(attrs.height)}"`);
|
|
||||||
if (attrs.align)
|
|
||||||
parts.push(`data-align="${escapeAttr(attrs.align)}"`);
|
|
||||||
return `<div ${parts.join(" ")}></div>`;
|
|
||||||
}
|
|
||||||
case "table": {
|
|
||||||
// A GFM pipe table cannot represent merged cells. If ANY cell carries
|
|
||||||
// colspan>1 or rowspan>1, a pipe table would corrupt the grid on
|
|
||||||
// re-import, so emit the WHOLE table as raw HTML <table> instead: the
|
|
||||||
// schema's table family parseHTML (tag table/tr/td/th, with colspan/
|
|
||||||
// rowspan read from the same-named HTML attrs and align via parseHTML)
|
|
||||||
// round-trips it faithfully. Otherwise keep the lighter GFM pipe table.
|
|
||||||
const tableRows = nodeContent;
|
|
||||||
if (tableRows.length === 0)
|
|
||||||
return "";
|
|
||||||
const hasSpan = tableRows.some((row) => (row.content || []).some((cell) => (cell.attrs?.colspan ?? 1) > 1 || (cell.attrs?.rowspan ?? 1) > 1));
|
|
||||||
if (hasSpan) {
|
|
||||||
// Render each cell's block children to HTML (marked does NOT parse
|
|
||||||
// markdown inside a raw HTML block, so emitting markdown here would
|
|
||||||
// leak literal ** / `` into the cell). blockToHtml mirrors the schema
|
|
||||||
// HTML so inner formatting re-parses into the right marks/nodes.
|
|
||||||
const renderHtmlCell = (cell) => {
|
|
||||||
const tag = cell.type === "tableHeader" ? "th" : "td";
|
|
||||||
const a = cell.attrs || {};
|
|
||||||
const cellParts = [];
|
|
||||||
if ((a.colspan ?? 1) > 1)
|
|
||||||
cellParts.push(`colspan="${escapeAttr(a.colspan)}"`);
|
|
||||||
if ((a.rowspan ?? 1) > 1)
|
|
||||||
cellParts.push(`rowspan="${escapeAttr(a.rowspan)}"`);
|
|
||||||
if (a.align)
|
|
||||||
cellParts.push(`align="${escapeAttr(a.align)}"`);
|
|
||||||
const open = cellParts.length
|
|
||||||
? `<${tag} ${cellParts.join(" ")}>`
|
|
||||||
: `<${tag}>`;
|
|
||||||
const inner = (cell.content || [])
|
|
||||||
.map((block) => blockToHtml(block))
|
|
||||||
.join("");
|
|
||||||
return `${open}${inner}</${tag}>`;
|
|
||||||
};
|
|
||||||
const htmlRows = tableRows
|
|
||||||
.map((row) => `<tr>${(row.content || []).map(renderHtmlCell).join("")}</tr>`)
|
|
||||||
.join("");
|
|
||||||
return `<table><tbody>${htmlRows}</tbody></table>`;
|
|
||||||
}
|
|
||||||
// No merged cells: emit a GFM table (header row + separator) so the
|
|
||||||
// markdown can be parsed back into a table on re-import.
|
|
||||||
const rows = tableRows.map(processNode);
|
|
||||||
const headerCells = tableRows[0]?.content || [];
|
|
||||||
const columns = headerCells.length || 1;
|
|
||||||
// Derive alignment markers (:--, :-:, --:) from each header cell.
|
|
||||||
const markers = Array.from({ length: columns }, (_, i) => {
|
|
||||||
const align = headerCells[i]?.attrs?.align;
|
|
||||||
switch (align) {
|
|
||||||
case "left":
|
|
||||||
return ":--";
|
|
||||||
case "center":
|
|
||||||
return ":-:";
|
|
||||||
case "right":
|
|
||||||
return "--:";
|
|
||||||
default:
|
|
||||||
return "---";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const separator = "| " + markers.join(" | ") + " |";
|
|
||||||
return [rows[0], separator, ...rows.slice(1)].join("\n");
|
|
||||||
}
|
|
||||||
case "tableRow":
|
|
||||||
return "| " + nodeContent.map(processNode).join(" | ") + " |";
|
|
||||||
case "tableCell":
|
|
||||||
case "tableHeader": {
|
|
||||||
// Join multiple block children with a space (not "") so adjacent blocks
|
|
||||||
// like a paragraph followed by a list don't collide into "line1- a".
|
|
||||||
// Then collapse newlines and escape pipes so a cell containing "|" or a
|
|
||||||
// line break cannot corrupt the surrounding GFM row.
|
|
||||||
return nodeContent
|
|
||||||
.map(processNode)
|
|
||||||
.join(" ")
|
|
||||||
.replace(/\r?\n/g, " ")
|
|
||||||
.replace(/\|/g, "\\|");
|
|
||||||
}
|
|
||||||
case "callout":
|
|
||||||
const calloutType = node.attrs?.type || "info";
|
|
||||||
const calloutContent = nodeContent.map(processNode).join("\n");
|
|
||||||
return `:::${calloutType.toLowerCase()}\n${calloutContent}\n:::`;
|
|
||||||
case "details":
|
|
||||||
return nodeContent.map(processNode).join("\n");
|
|
||||||
case "detailsSummary":
|
|
||||||
const summaryText = nodeContent.map(processNode).join("");
|
|
||||||
return `<details>\n<summary>${summaryText}</summary>\n`;
|
|
||||||
case "detailsContent":
|
|
||||||
const detailsText = nodeContent.map(processNode).join("\n");
|
|
||||||
return `${detailsText}\n</details>`;
|
|
||||||
case "mathInline": {
|
|
||||||
// The schema's `text` attribute has no parseHTML, so TipTap's default
|
|
||||||
// parser reads it from the `text` HTML attribute (NOT the element's text
|
|
||||||
// content). Emit span[data-type="mathInline"] carrying the LaTeX in a
|
|
||||||
// `text="..."` attribute so it round-trips. marked cannot parse $...$
|
|
||||||
// back, so the previous form was lossy.
|
|
||||||
const inlineMath = node.attrs?.text || "";
|
|
||||||
return `<span data-type="mathInline" data-katex="true" text="${escapeAttr(inlineMath)}"></span>`;
|
|
||||||
}
|
|
||||||
case "mathBlock": {
|
|
||||||
// Same as mathInline: the LaTeX must ride in the `text` HTML attribute
|
|
||||||
// for the schema's default parser to recover it.
|
|
||||||
const blockMath = node.attrs?.text || "";
|
|
||||||
return `<div data-type="mathBlock" data-katex="true" text="${escapeAttr(blockMath)}"></div>`;
|
|
||||||
}
|
|
||||||
case "mention": {
|
|
||||||
// Emit span[data-type="mention"] with the schema's data-* attributes so
|
|
||||||
// generateJSON rebuilds the mention node instead of leaving "@label"
|
|
||||||
// plain text that cannot re-parse.
|
|
||||||
const attrs = node.attrs || {};
|
|
||||||
const parts = [`data-type="mention"`];
|
|
||||||
if (attrs.id)
|
|
||||||
parts.push(`data-id="${escapeAttr(attrs.id)}"`);
|
|
||||||
if (attrs.label)
|
|
||||||
parts.push(`data-label="${escapeAttr(attrs.label)}"`);
|
|
||||||
if (attrs.entityType)
|
|
||||||
parts.push(`data-entity-type="${escapeAttr(attrs.entityType)}"`);
|
|
||||||
if (attrs.entityId)
|
|
||||||
parts.push(`data-entity-id="${escapeAttr(attrs.entityId)}"`);
|
|
||||||
if (attrs.slugId)
|
|
||||||
parts.push(`data-slug-id="${escapeAttr(attrs.slugId)}"`);
|
|
||||||
if (attrs.creatorId)
|
|
||||||
parts.push(`data-creator-id="${escapeAttr(attrs.creatorId)}"`);
|
|
||||||
if (attrs.anchorId)
|
|
||||||
parts.push(`data-anchor-id="${escapeAttr(attrs.anchorId)}"`);
|
|
||||||
// Keep the label as visible text content too; the schema reads attrs
|
|
||||||
// from data-*, so the inner text is purely cosmetic and harmless.
|
|
||||||
const mentionLabel = attrs.label || attrs.id || "";
|
|
||||||
// The label is visible element TEXT content here (the data-* attrs above
|
|
||||||
// carry the real values), so escape it for the text context, not attrs.
|
|
||||||
return `<span ${parts.join(" ")}>@${escapeHtmlText(mentionLabel)}</span>`;
|
|
||||||
}
|
|
||||||
case "footnoteReference": {
|
|
||||||
// Pandoc/GFM inline marker. The number is derived (not stored), so the
|
|
||||||
// id is the stable anchor.
|
|
||||||
const fnId = node.attrs?.id || "";
|
|
||||||
return fnId ? `[^${fnId}]` : "";
|
|
||||||
}
|
|
||||||
case "footnotesList":
|
|
||||||
// The container renders its definitions, each on its own `[^id]: ...`
|
|
||||||
// line. A blank line separates the body from the notes block.
|
|
||||||
return nodeContent.map(processNode).join("\n");
|
|
||||||
case "footnoteDefinition": {
|
|
||||||
const defId = node.attrs?.id || "";
|
|
||||||
// Collapse the definition's paragraphs into a single line; multi-line
|
|
||||||
// footnotes are a v2 refinement.
|
|
||||||
const defText = nodeContent
|
|
||||||
.map(processNode)
|
|
||||||
.join(" ")
|
|
||||||
.replace(/\s*\n+\s*/g, " ")
|
|
||||||
.trim();
|
|
||||||
return defId ? `[^${defId}]: ${defText}` : "";
|
|
||||||
}
|
|
||||||
case "attachment": {
|
|
||||||
// BUG FIX: the old code read node.attrs.fileName / node.attrs.src, but
|
|
||||||
// the schema stores name/url (plus mime/size/attachmentId). Emit the
|
|
||||||
// schema-matching div[data-type="attachment"] with data-attachment-*
|
|
||||||
// attrs so the node round-trips instead of degrading to a markdown link.
|
|
||||||
const attrs = node.attrs || {};
|
|
||||||
const parts = [
|
|
||||||
`data-type="attachment"`,
|
|
||||||
`data-attachment-url="${escapeAttr(attrs.url ?? "")}"`,
|
|
||||||
];
|
|
||||||
if (attrs.name)
|
|
||||||
parts.push(`data-attachment-name="${escapeAttr(attrs.name)}"`);
|
|
||||||
if (attrs.mime)
|
|
||||||
parts.push(`data-attachment-mime="${escapeAttr(attrs.mime)}"`);
|
|
||||||
if (attrs.size != null)
|
|
||||||
parts.push(`data-attachment-size="${escapeAttr(attrs.size)}"`);
|
|
||||||
if (attrs.attachmentId)
|
|
||||||
parts.push(`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`);
|
|
||||||
return `<div ${parts.join(" ")}></div>`;
|
|
||||||
}
|
|
||||||
case "drawio":
|
|
||||||
case "excalidraw": {
|
|
||||||
// Emit the schema-matching div[data-type=...] carrying the diagram's
|
|
||||||
// attrs as data-* (the schema's diagramAttributes reads src/title/alt/
|
|
||||||
// width/height/size/aspectRatio/align/attachmentId from data-*), so the
|
|
||||||
// diagram round-trips instead of degrading to a lossy placeholder.
|
|
||||||
const attrs = node.attrs || {};
|
|
||||||
const parts = [
|
|
||||||
`data-type="${type}"`,
|
|
||||||
`data-src="${escapeAttr(attrs.src ?? "")}"`,
|
|
||||||
];
|
|
||||||
if (attrs.title != null)
|
|
||||||
parts.push(`data-title="${escapeAttr(attrs.title)}"`);
|
|
||||||
if (attrs.alt != null)
|
|
||||||
parts.push(`data-alt="${escapeAttr(attrs.alt)}"`);
|
|
||||||
if (attrs.width != null)
|
|
||||||
parts.push(`data-width="${escapeAttr(attrs.width)}"`);
|
|
||||||
if (attrs.height != null)
|
|
||||||
parts.push(`data-height="${escapeAttr(attrs.height)}"`);
|
|
||||||
if (attrs.size != null)
|
|
||||||
parts.push(`data-size="${escapeAttr(attrs.size)}"`);
|
|
||||||
if (attrs.aspectRatio != null)
|
|
||||||
parts.push(`data-aspect-ratio="${escapeAttr(attrs.aspectRatio)}"`);
|
|
||||||
if (attrs.align)
|
|
||||||
parts.push(`data-align="${escapeAttr(attrs.align)}"`);
|
|
||||||
if (attrs.attachmentId)
|
|
||||||
parts.push(`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`);
|
|
||||||
return `<div ${parts.join(" ")}></div>`;
|
|
||||||
}
|
|
||||||
case "embed": {
|
|
||||||
// Emit the schema-matching div[data-type="embed"]; the schema reads
|
|
||||||
// src/provider/align/width/height from data-* attributes so the node
|
|
||||||
// (and its provider iframe info) survives the round-trip.
|
|
||||||
const attrs = node.attrs || {};
|
|
||||||
const parts = [
|
|
||||||
`data-type="embed"`,
|
|
||||||
`data-src="${escapeAttr(attrs.src ?? "")}"`,
|
|
||||||
`data-provider="${escapeAttr(attrs.provider ?? "")}"`,
|
|
||||||
];
|
|
||||||
if (attrs.align)
|
|
||||||
parts.push(`data-align="${escapeAttr(attrs.align)}"`);
|
|
||||||
if (attrs.width != null)
|
|
||||||
parts.push(`data-width="${escapeAttr(attrs.width)}"`);
|
|
||||||
if (attrs.height != null)
|
|
||||||
parts.push(`data-height="${escapeAttr(attrs.height)}"`);
|
|
||||||
return `<div ${parts.join(" ")}></div>`;
|
|
||||||
}
|
|
||||||
case "audio": {
|
|
||||||
// Emit the schema-matching <audio> element (was emitting nothing). The
|
|
||||||
// schema reads src from src and attachmentId/size from data-*.
|
|
||||||
const attrs = node.attrs || {};
|
|
||||||
const parts = [`src="${escapeAttr(attrs.src ?? "")}"`];
|
|
||||||
if (attrs.attachmentId)
|
|
||||||
parts.push(`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`);
|
|
||||||
if (attrs.size != null)
|
|
||||||
parts.push(`data-size="${escapeAttr(attrs.size)}"`);
|
|
||||||
// Wrap in a block <div> for the same reason as video: a bare <audio> is
|
|
||||||
// inline-level HTML that marked would wrap in <p>.
|
|
||||||
return `<div><audio ${parts.join(" ")}></audio></div>`;
|
|
||||||
}
|
|
||||||
case "pdf": {
|
|
||||||
// Emit the schema-matching div[data-type="pdf"] (was emitting nothing).
|
|
||||||
// The schema reads src/width/height from standard attrs and name/
|
|
||||||
// attachmentId/size from data-*.
|
|
||||||
const attrs = node.attrs || {};
|
|
||||||
const parts = [
|
|
||||||
`data-type="pdf"`,
|
|
||||||
`src="${escapeAttr(attrs.src ?? "")}"`,
|
|
||||||
];
|
|
||||||
if (attrs.name)
|
|
||||||
parts.push(`data-name="${escapeAttr(attrs.name)}"`);
|
|
||||||
if (attrs.attachmentId)
|
|
||||||
parts.push(`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`);
|
|
||||||
if (attrs.size != null)
|
|
||||||
parts.push(`data-size="${escapeAttr(attrs.size)}"`);
|
|
||||||
if (attrs.width != null)
|
|
||||||
parts.push(`width="${escapeAttr(attrs.width)}"`);
|
|
||||||
if (attrs.height != null)
|
|
||||||
parts.push(`height="${escapeAttr(attrs.height)}"`);
|
|
||||||
return `<div ${parts.join(" ")}></div>`;
|
|
||||||
}
|
|
||||||
case "columns": {
|
|
||||||
// Emit the schema-matching div[data-type="columns"] wrapper so the
|
|
||||||
// multi-column layout survives. Without a case the children were
|
|
||||||
// concatenated with no separator and the text merged. The schema reads
|
|
||||||
// layout from data-layout and widthMode from data-width-mode. The whole
|
|
||||||
// block is raw HTML, so render children via blockToHtml (NOT markdown,
|
|
||||||
// which marked would not re-parse inside a raw HTML block).
|
|
||||||
const attrs = node.attrs || {};
|
|
||||||
const parts = [`data-type="columns"`];
|
|
||||||
if (attrs.layout)
|
|
||||||
parts.push(`data-layout="${escapeAttr(attrs.layout)}"`);
|
|
||||||
if (attrs.widthMode && attrs.widthMode !== "normal")
|
|
||||||
parts.push(`data-width-mode="${escapeAttr(attrs.widthMode)}"`);
|
|
||||||
const inner = nodeContent.map((n) => blockToHtml(n)).join("");
|
|
||||||
return `<div ${parts.join(" ")}>${inner}</div>`;
|
|
||||||
}
|
|
||||||
case "column": {
|
|
||||||
// Emit the schema-matching div[data-type="column"]; the schema reads the
|
|
||||||
// column width from data-width. Children are rendered as HTML so their
|
|
||||||
// formatting survives inside this raw HTML block.
|
|
||||||
const attrs = node.attrs || {};
|
|
||||||
const parts = [`data-type="column"`];
|
|
||||||
if (attrs.width)
|
|
||||||
parts.push(`data-width="${escapeAttr(attrs.width)}"`);
|
|
||||||
const inner = nodeContent.map((n) => blockToHtml(n)).join("");
|
|
||||||
return `<div ${parts.join(" ")}>${inner}</div>`;
|
|
||||||
}
|
|
||||||
case "subpages":
|
|
||||||
return "{{SUBPAGES}}";
|
|
||||||
default:
|
|
||||||
// Fallback: process children
|
|
||||||
return nodeContent.map(processNode).join("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Render inline content (text runs + their marks) to HTML. Used by the raw
|
|
||||||
// HTML fallbacks (spanned tables, columns) where marked will NOT re-parse
|
|
||||||
// markdown, so backtick/asterisk/bracket syntax would otherwise leak as
|
|
||||||
// literal characters. Each mark is mirrored to the HTML the schema's parseHTML
|
|
||||||
// accepts so it re-imports as the matching ProseMirror mark.
|
|
||||||
const inlineToHtml = (inlineNodes) => (inlineNodes || [])
|
|
||||||
.map((n) => {
|
|
||||||
if (n.type === "hardBreak")
|
|
||||||
return "<br>";
|
|
||||||
if (n.type !== "text") {
|
|
||||||
// Inline atoms (mention, mathInline) already emit schema HTML.
|
|
||||||
return processNode(n);
|
|
||||||
}
|
|
||||||
let t = escapeHtmlText(n.text || "");
|
|
||||||
for (const mark of n.marks || []) {
|
|
||||||
switch (mark.type) {
|
|
||||||
case "bold":
|
|
||||||
t = `<strong>${t}</strong>`;
|
|
||||||
break;
|
|
||||||
case "italic":
|
|
||||||
t = `<em>${t}</em>`;
|
|
||||||
break;
|
|
||||||
case "code":
|
|
||||||
t = `<code>${t}</code>`;
|
|
||||||
break;
|
|
||||||
case "strike":
|
|
||||||
t = `<s>${t}</s>`;
|
|
||||||
break;
|
|
||||||
case "underline":
|
|
||||||
t = `<u>${t}</u>`;
|
|
||||||
break;
|
|
||||||
case "subscript":
|
|
||||||
t = `<sub>${t}</sub>`;
|
|
||||||
break;
|
|
||||||
case "superscript":
|
|
||||||
t = `<sup>${t}</sup>`;
|
|
||||||
break;
|
|
||||||
case "link":
|
|
||||||
t = `<a href="${escapeAttr(mark.attrs?.href || "")}">${t}</a>`;
|
|
||||||
break;
|
|
||||||
case "highlight":
|
|
||||||
t = mark.attrs?.color
|
|
||||||
? `<mark style="background-color: ${escapeAttr(mark.attrs.color)}">${t}</mark>`
|
|
||||||
: `<mark>${t}</mark>`;
|
|
||||||
break;
|
|
||||||
case "textStyle":
|
|
||||||
if (mark.attrs?.color)
|
|
||||||
t = `<span style="color: ${escapeAttr(mark.attrs.color)}">${t}</span>`;
|
|
||||||
break;
|
|
||||||
case "comment":
|
|
||||||
// Inline comment anchor inside a raw-HTML container (columns /
|
|
||||||
// spanned table cells), so commented text there also round-trips.
|
|
||||||
if (mark.attrs?.commentId) {
|
|
||||||
const r = mark.attrs?.resolved ? ` data-resolved="true"` : "";
|
|
||||||
t = `<span data-comment-id="${escapeAttr(mark.attrs.commentId)}"${r}>${t}</span>`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return t;
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
// Emit the schema-matching <img> for an image node. Shared so the image is
|
|
||||||
// emitted as real HTML wherever a raw-HTML container needs it (inside a column
|
|
||||||
// or a spanned table cell), where markdown `` would NOT be re-parsed
|
|
||||||
// and would survive as literal text. The Image extension reads src/alt from
|
|
||||||
// the standard attributes; the Docmost extra attrs (width/height/align/size/
|
|
||||||
// attachmentId/aspectRatio) are global attributes read from same-named DOM
|
|
||||||
// attributes, so emit them by name.
|
|
||||||
const imageToHtml = (node) => {
|
|
||||||
const attrs = node.attrs || {};
|
|
||||||
const parts = [`src="${escapeAttr(attrs.src ?? "")}"`];
|
|
||||||
if (attrs.alt)
|
|
||||||
parts.push(`alt="${escapeAttr(attrs.alt)}"`);
|
|
||||||
if (attrs.caption)
|
|
||||||
parts.push(`data-caption="${escapeAttr(attrs.caption)}"`);
|
|
||||||
if (attrs.title)
|
|
||||||
parts.push(`title="${escapeAttr(attrs.title)}"`);
|
|
||||||
if (attrs.width != null)
|
|
||||||
parts.push(`width="${escapeAttr(attrs.width)}"`);
|
|
||||||
if (attrs.height != null)
|
|
||||||
parts.push(`height="${escapeAttr(attrs.height)}"`);
|
|
||||||
if (attrs.align)
|
|
||||||
parts.push(`align="${escapeAttr(attrs.align)}"`);
|
|
||||||
if (attrs.size != null)
|
|
||||||
parts.push(`data-size="${escapeAttr(attrs.size)}"`);
|
|
||||||
if (attrs.attachmentId)
|
|
||||||
parts.push(`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`);
|
|
||||||
if (attrs.aspectRatio != null)
|
|
||||||
parts.push(`data-aspect-ratio="${escapeAttr(attrs.aspectRatio)}"`);
|
|
||||||
return `<img ${parts.join(" ")}>`;
|
|
||||||
};
|
|
||||||
// Emit the schema-matching div[data-type="callout"] for a callout node. The
|
|
||||||
// schema reads the banner type from data-callout-type. Children are rendered
|
|
||||||
// as HTML so they survive inside a raw-HTML container.
|
|
||||||
const calloutToHtml = (node) => {
|
|
||||||
const type = (node.attrs?.type || "info").toLowerCase();
|
|
||||||
const inner = (node.content || []).map(blockToHtml).join("");
|
|
||||||
return `<div data-type="callout" data-callout-type="${escapeAttr(type)}">${inner}</div>`;
|
|
||||||
};
|
|
||||||
// Emit a schema-matching <details> tree. The schema parses <details>,
|
|
||||||
// summary[data-type="detailsSummary"], and div[data-type="detailsContent"].
|
|
||||||
const detailsToHtml = (node) => {
|
|
||||||
const inner = (node.content || []).map(blockToHtml).join("");
|
|
||||||
return `<details>${inner}</details>`;
|
|
||||||
};
|
|
||||||
const detailsSummaryToHtml = (node) => `<summary data-type="detailsSummary">${inlineToHtml(node.content || [])}</summary>`;
|
|
||||||
const detailsContentToHtml = (node) => {
|
|
||||||
const inner = (node.content || []).map(blockToHtml).join("");
|
|
||||||
return `<div data-type="detailsContent">${inner}</div>`;
|
|
||||||
};
|
|
||||||
// Emit the schema-matching taskList/taskItem HTML. bridgeTaskLists (in
|
|
||||||
// collaboration.ts) recognizes ul[data-type="taskList"] with
|
|
||||||
// li[data-type="taskItem"][data-checked]; emitting that directly here keeps
|
|
||||||
// task lists inside columns/cells from degrading to literal "- [ ]" text.
|
|
||||||
const taskListToHtml = (node) => {
|
|
||||||
const items = (node.content || [])
|
|
||||||
.map((it) => {
|
|
||||||
const checked = it.attrs?.checked ? "true" : "false";
|
|
||||||
return `<li data-type="taskItem" data-checked="${checked}">${blockChildrenToHtml(it)}</li>`;
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
return `<ul data-type="taskList">${items}</ul>`;
|
|
||||||
};
|
|
||||||
// Render a block node to HTML for the raw-HTML containers (spanned tables,
|
|
||||||
// columns). marked does NOT re-parse markdown inside a raw-HTML block, so
|
|
||||||
// EVERY block type that can appear inside a column or a spanned cell must be
|
|
||||||
// emitted as schema-matching HTML here — never as markdown, or it would land
|
|
||||||
// as literal text on re-import. Nodes whose processNode case already produces
|
|
||||||
// schema-matching HTML (math/media/embed/attachment/nested columns/spanned
|
|
||||||
// table) are delegated to processNode; the markdown-emitting cases
|
|
||||||
// (image/blockquote/callout/details/hr/taskList) get explicit HTML here.
|
|
||||||
const blockToHtml = (block) => {
|
|
||||||
const children = block.content || [];
|
|
||||||
switch (block.type) {
|
|
||||||
case "paragraph":
|
|
||||||
return `<p>${inlineToHtml(children)}</p>`;
|
|
||||||
case "heading": {
|
|
||||||
const level = block.attrs?.level || 1;
|
|
||||||
return `<h${level}>${inlineToHtml(children)}</h${level}>`;
|
|
||||||
}
|
|
||||||
case "bulletList":
|
|
||||||
return `<ul>${children
|
|
||||||
.map((li) => `<li>${blockChildrenToHtml(li)}</li>`)
|
|
||||||
.join("")}</ul>`;
|
|
||||||
case "orderedList":
|
|
||||||
return `<ol>${children
|
|
||||||
.map((li) => `<li>${blockChildrenToHtml(li)}</li>`)
|
|
||||||
.join("")}</ol>`;
|
|
||||||
case "codeBlock": {
|
|
||||||
const lang = block.attrs?.language || "";
|
|
||||||
// The code itself is element TEXT content (between <code> tags), so it
|
|
||||||
// must escape < > & — NOT the attribute escaper. The language rides in
|
|
||||||
// a class ATTRIBUTE, so it uses escapeAttr.
|
|
||||||
const code = escapeHtmlText(children
|
|
||||||
.map(processNode)
|
|
||||||
.join("")
|
|
||||||
.replace(/\n+$/, ""));
|
|
||||||
const cls = lang ? ` class="language-${escapeAttr(lang)}"` : "";
|
|
||||||
return `<pre><code${cls}>${code}</code></pre>`;
|
|
||||||
}
|
|
||||||
case "image":
|
|
||||||
return imageToHtml(block);
|
|
||||||
case "blockquote":
|
|
||||||
return `<blockquote>${children.map(blockToHtml).join("")}</blockquote>`;
|
|
||||||
case "horizontalRule":
|
|
||||||
return "<hr>";
|
|
||||||
case "callout":
|
|
||||||
return calloutToHtml(block);
|
|
||||||
case "details":
|
|
||||||
return detailsToHtml(block);
|
|
||||||
case "detailsSummary":
|
|
||||||
return detailsSummaryToHtml(block);
|
|
||||||
case "detailsContent":
|
|
||||||
return detailsContentToHtml(block);
|
|
||||||
case "taskList":
|
|
||||||
return taskListToHtml(block);
|
|
||||||
case "taskItem":
|
|
||||||
// A bare taskItem (outside a taskList) still needs a wrapping list so
|
|
||||||
// the schema parses it; wrap it in a single-item taskList.
|
|
||||||
return taskListToHtml({ content: [block] });
|
|
||||||
// table (incl. spanned), columns/column, math, media, embed, attachment,
|
|
||||||
// mention, etc. already emit schema-matching HTML from processNode.
|
|
||||||
case "table":
|
|
||||||
case "columns":
|
|
||||||
case "column":
|
|
||||||
case "mathBlock":
|
|
||||||
case "video":
|
|
||||||
case "audio":
|
|
||||||
case "pdf":
|
|
||||||
case "youtube":
|
|
||||||
case "embed":
|
|
||||||
case "attachment":
|
|
||||||
case "drawio":
|
|
||||||
case "excalidraw":
|
|
||||||
return processNode(block);
|
|
||||||
default:
|
|
||||||
// Any still-unhandled block type: NEVER fall back to markdown inside a
|
|
||||||
// raw-HTML block (it would become literal text). Wrap its rendered
|
|
||||||
// children in a <div> so their content is preserved; if it has no block
|
|
||||||
// children, render its inline content instead.
|
|
||||||
if (children.length && children.some((c) => c.type !== "text")) {
|
|
||||||
return `<div>${children.map(blockToHtml).join("")}</div>`;
|
|
||||||
}
|
|
||||||
return `<div>${inlineToHtml(children)}</div>`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// Render the block children of a list item to HTML (a listItem holds block+
|
|
||||||
// content). Mirrors processListItem but for the HTML fallback path.
|
|
||||||
const blockChildrenToHtml = (item) => (item.content || []).map((b) => blockToHtml(b)).join("");
|
|
||||||
// Indent the rendered children of a list item under a marker prefix.
|
|
||||||
// Each child block is a (possibly multi-line) string. The very first physical
|
|
||||||
// line of the first child carries the marker (e.g. "- " or "1. "); EVERY
|
|
||||||
// other line — the remaining lines of the first child AND all lines of every
|
|
||||||
// subsequent child (nested lists, code blocks, extra paragraphs) — is indented
|
|
||||||
// to align under the marker. Without indenting these continuation lines, the
|
|
||||||
// 2nd/3rd line of a nested child collapses to column 0 and escapes the list.
|
|
||||||
//
|
|
||||||
// The continuation indent MUST equal the LIST marker width, which is not the
|
|
||||||
// same as the visible prefix width:
|
|
||||||
// - bullet "- " -> 2 columns
|
|
||||||
// - task "- [ ] " -> marker is still "- " (the "[ ] " is content), 2
|
|
||||||
// - ordered "1. "/"10. " -> 3/4 columns, scaling with the number's digits
|
|
||||||
// CommonMark anchors nested content to the marker column, so an ordered item
|
|
||||||
// indented to only 2 columns would be re-parsed as a sibling/loose content on
|
|
||||||
// re-import. Callers therefore pass the exact indent width to use.
|
|
||||||
const indentItemChildren = (childStrings, prefix, indentWidth) => {
|
|
||||||
const indent = " ".repeat(indentWidth);
|
|
||||||
const lines = [];
|
|
||||||
childStrings.forEach((child, childIndex) => {
|
|
||||||
child.split("\n").forEach((line, lineIndex) => {
|
|
||||||
if (childIndex === 0 && lineIndex === 0) {
|
|
||||||
// First physical line of the first block gets the marker.
|
|
||||||
lines.push(`${prefix} ${line}`);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// Indent every continuation line by the marker width; keep blank
|
|
||||||
// lines blank rather than emitting trailing whitespace.
|
|
||||||
lines.push(line.length ? `${indent}${line}` : "");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return lines.join("\n");
|
|
||||||
};
|
|
||||||
const processListItem = (item, prefix) => {
|
|
||||||
const itemContent = item.content || [];
|
|
||||||
const childStrings = itemContent.map(processNode);
|
|
||||||
if (childStrings.length === 0)
|
|
||||||
return prefix;
|
|
||||||
// The rendered marker is `${prefix} ` (prefix + one space), so its width —
|
|
||||||
// and thus the continuation indent — is prefix.length + 1. This is correct
|
|
||||||
// for both bullet ("-" -> 2) and ordered ("1." -> 3, "10." -> 4) markers,
|
|
||||||
// since for those the visible prefix IS the list marker.
|
|
||||||
return indentItemChildren(childStrings, prefix, prefix.length + 1);
|
|
||||||
};
|
|
||||||
const processTaskItem = (item) => {
|
|
||||||
const checked = item.attrs?.checked || false;
|
|
||||||
const checkbox = checked ? "[x]" : "[ ]";
|
|
||||||
const prefix = `- ${checkbox}`;
|
|
||||||
const itemContent = item.content || [];
|
|
||||||
const childStrings = itemContent.map(processNode);
|
|
||||||
// An empty task item still needs its checkbox marker; without this guard
|
|
||||||
// the indent below produces "" and the "- [ ]"/"- [x]" row disappears.
|
|
||||||
if (childStrings.length === 0)
|
|
||||||
return prefix;
|
|
||||||
// The list marker for a task item is just "- " (2 columns); the "[ ] "/"[x] "
|
|
||||||
// checkbox is item content, NOT part of the marker. So the continuation
|
|
||||||
// indent is a fixed 2 — do NOT derive it from the wider prefix.length.
|
|
||||||
return indentItemChildren(childStrings, prefix, 2);
|
|
||||||
};
|
|
||||||
return processNode(content).trim();
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
/**
|
|
||||||
* Self-contained Docmost-flavoured Markdown document (custom extensions).
|
|
||||||
*
|
|
||||||
* A single `.md` file that packages everything needed to losslessly round-trip
|
|
||||||
* a page through "download -> edit body -> re-upload":
|
|
||||||
* - a leading `docmost:meta` block: a one-line JSON object with page identity;
|
|
||||||
* - the Markdown body (carrying inline comment anchors and diagrams as HTML);
|
|
||||||
* - a trailing `docmost:comments` block: a one-line JSON array of comment
|
|
||||||
* threads.
|
|
||||||
*
|
|
||||||
* Both metadata blocks are HTML comments on purpose: `marked`/`generateJSON`
|
|
||||||
* drop HTML comments, so even if the WHOLE file were ever fed straight to the
|
|
||||||
* importer without first stripping the blocks, the metadata cannot leak into the
|
|
||||||
* document. (A fenced ```docmost-comments``` block would WRONGLY become a
|
|
||||||
* codeBlock node, so a fenced block is deliberately NOT used.)
|
|
||||||
*
|
|
||||||
* The delimiter literals may legitimately appear in the BODY too (e.g. a user
|
|
||||||
* re-pastes an exported `.md` into a page, or a page documents this very
|
|
||||||
* format). To stay robust, parsing treats only the FINAL, document-ending
|
|
||||||
* `docmost:comments` block as metadata: it is the last `<!-- docmost:comments`
|
|
||||||
* opener whose closing `-->` sits at the very end of the file. Any earlier
|
|
||||||
* literal occurrence is left in the body untouched.
|
|
||||||
*
|
|
||||||
* NOTE on comments: in this version the comment THREAD records are preserved in
|
|
||||||
* the file but are NOT pushed back to the server on import — only the inline
|
|
||||||
* comment marks (anchors) embedded in the body are restored. Managing comment
|
|
||||||
* records stays with the comment tools/UI.
|
|
||||||
*/
|
|
||||||
// Match the leading meta block (allow leading whitespace). Capture group 1 is
|
|
||||||
// the JSON text between the markers.
|
|
||||||
const META_RE = /^\s*<!--\s*docmost:meta\s*\n([\s\S]*?)\n-->/;
|
|
||||||
// Match a `docmost:comments` opener. Used globally to scan for the LAST opener
|
|
||||||
// rather than end-anchoring a single regex (which would mis-capture across a
|
|
||||||
// literal opener that appears earlier in the body).
|
|
||||||
const COMMENTS_OPEN_RE = /<!--[ \t]*docmost:comments[ \t]*\r?\n/g;
|
|
||||||
/**
|
|
||||||
* Assemble the full self-contained markdown file: meta block, body, and the
|
|
||||||
* comments block. The meta block is always emitted; the comments block is always
|
|
||||||
* emitted too (with `[]` when there are no comments) so the format stays uniform
|
|
||||||
* and parsing stays simple.
|
|
||||||
*/
|
|
||||||
export function serializeDocmostMarkdown(meta, body, comments) {
|
|
||||||
const metaJson = JSON.stringify(meta);
|
|
||||||
const commentsJson = JSON.stringify(Array.isArray(comments) ? comments : []);
|
|
||||||
const trimmedBody = (body ?? "").trim();
|
|
||||||
return (`<!-- docmost:meta\n${metaJson}\n-->\n\n` +
|
|
||||||
`${trimmedBody}\n\n` +
|
|
||||||
`<!-- docmost:comments\n${commentsJson}\n-->\n`);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Split a self-contained file back into its parts. Tolerant: if the meta or
|
|
||||||
* comments block is missing (e.g. a hand-written plain-markdown file), the
|
|
||||||
* corresponding value is returned as `null` and the whole input is treated as
|
|
||||||
* the body. This never throws on a MISSING block; only a `JSON.parse` failure
|
|
||||||
* inside a block that IS present is surfaced as a thrown Error with a clear
|
|
||||||
* message. Robust to `\r\n` line endings.
|
|
||||||
*/
|
|
||||||
export function parseDocmostMarkdown(full) {
|
|
||||||
// Normalize line endings so the anchored regexes work regardless of CRLF.
|
|
||||||
const normalized = (full ?? "").replace(/\r\n/g, "\n");
|
|
||||||
// Extract the leading meta block (start-anchored — already unambiguous).
|
|
||||||
let meta = null;
|
|
||||||
let metaEnd = 0;
|
|
||||||
const metaMatch = normalized.match(META_RE);
|
|
||||||
if (metaMatch) {
|
|
||||||
try {
|
|
||||||
meta = JSON.parse(metaMatch[1]);
|
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
throw new Error(`Invalid docmost:meta JSON block: ${e instanceof Error ? e.message : String(e)}`);
|
|
||||||
}
|
|
||||||
// Body starts right after the matched meta block.
|
|
||||||
metaEnd = (metaMatch.index ?? 0) + metaMatch[0].length;
|
|
||||||
}
|
|
||||||
// Find the LAST `<!-- docmost:comments` opener; the real file-level block is
|
|
||||||
// the final one whose closing `-->` ends the document. Any earlier literal
|
|
||||||
// occurrence inside the body (e.g. a re-pasted export) is left in the body.
|
|
||||||
let lastOpenStart = -1;
|
|
||||||
let lastOpenEnd = -1;
|
|
||||||
let m;
|
|
||||||
COMMENTS_OPEN_RE.lastIndex = 0;
|
|
||||||
while ((m = COMMENTS_OPEN_RE.exec(normalized)) !== null) {
|
|
||||||
lastOpenStart = m.index;
|
|
||||||
lastOpenEnd = m.index + m[0].length;
|
|
||||||
}
|
|
||||||
let comments = null;
|
|
||||||
let bodyEnd = normalized.length;
|
|
||||||
if (lastOpenStart !== -1) {
|
|
||||||
const rest = normalized.slice(lastOpenEnd);
|
|
||||||
const close = rest.match(/\r?\n-->[ \t]*\r?\n?\s*$/); // closer must end the doc
|
|
||||||
if (close) {
|
|
||||||
const jsonText = rest.slice(0, close.index);
|
|
||||||
try {
|
|
||||||
comments = JSON.parse(jsonText);
|
|
||||||
}
|
|
||||||
catch (e) {
|
|
||||||
throw new Error(`Invalid docmost:comments JSON block: ${e instanceof Error ? e.message : String(e)}`);
|
|
||||||
}
|
|
||||||
bodyEnd = lastOpenStart; // strip from the opener to end of document
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const body = normalized.slice(metaEnd, bodyEnd).trim();
|
|
||||||
return { meta, body, comments };
|
|
||||||
}
|
|
||||||
@@ -1,821 +0,0 @@
|
|||||||
/**
|
|
||||||
* Pure, network-free helpers for manipulating a ProseMirror/TipTap document
|
|
||||||
* tree by node id.
|
|
||||||
*
|
|
||||||
* A ProseMirror node here is a plain JSON object of the shape produced by
|
|
||||||
* Docmost: `{ type, attrs?, content?, text?, marks? }`. Children live in the
|
|
||||||
* `content` array; a node carries a stable id in `attrs.id`. Callouts and
|
|
||||||
* table cells hold their children in `content` just like any other block, so a
|
|
||||||
* single recursive walk reaches them all.
|
|
||||||
*
|
|
||||||
* Every exported function operates on a DEEP CLONE of the input document and
|
|
||||||
* returns the new document. The input doc and any `newNode`/`node` argument are
|
|
||||||
* never mutated. All functions are defensively null-safe: missing/!Array
|
|
||||||
* `content`, non-object nodes, and absent `attrs` are tolerated.
|
|
||||||
*/
|
|
||||||
import { stripInlineMarkdown } from "./text-normalize.js";
|
|
||||||
/** Deep-clone a JSON-serializable value without mutating the original. */
|
|
||||||
function clone(value) {
|
|
||||||
if (typeof structuredClone === "function") {
|
|
||||||
return structuredClone(value);
|
|
||||||
}
|
|
||||||
// Fallback for environments without structuredClone.
|
|
||||||
return JSON.parse(JSON.stringify(value));
|
|
||||||
}
|
|
||||||
/** True if `value` is a non-null object (and not an array). */
|
|
||||||
function isObject(value) {
|
|
||||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
/** True if `node` carries the given id in `node.attrs.id`. */
|
|
||||||
function matchesId(node, nodeId) {
|
|
||||||
return isObject(node) && isObject(node.attrs) && node.attrs.id === nodeId;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Recursively concatenate all text contained in a node.
|
|
||||||
*
|
|
||||||
* Text nodes contribute their `text` string; container nodes contribute the
|
|
||||||
* joined `blockPlainText` of their `content` children. Returns "" for nullish
|
|
||||||
* or non-object inputs.
|
|
||||||
*/
|
|
||||||
export function blockPlainText(node) {
|
|
||||||
if (!isObject(node))
|
|
||||||
return "";
|
|
||||||
let out = "";
|
|
||||||
if (typeof node.text === "string") {
|
|
||||||
out += node.text;
|
|
||||||
}
|
|
||||||
if (Array.isArray(node.content)) {
|
|
||||||
for (const child of node.content) {
|
|
||||||
out += blockPlainText(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
/** Truncate `text` to at most `n` chars, appending an ellipsis when cut. */
|
|
||||||
function truncate(text, n) {
|
|
||||||
return text.length > n ? text.slice(0, n) + "…" : text;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Build a COMPACT outline of the TOP-LEVEL blocks of `doc` (the entries in
|
|
||||||
* `doc.content`). Deliberately does NOT recurse into paragraphs, list items, or
|
|
||||||
* table cells — compactness is the point; use `getNodeByRef` to drill into a
|
|
||||||
* specific block.
|
|
||||||
*
|
|
||||||
* Each entry carries `{ index, type, id, firstText }`, plus type-specific
|
|
||||||
* extras: headings add `level`; tables add `rows`/`cols` and the first row's
|
|
||||||
* cell texts as `header`; list blocks (types ending in "List") add `items`.
|
|
||||||
* `firstText` is the block's plain text truncated to 100 chars. Null-safe:
|
|
||||||
* a missing or non-object doc/content yields `[]`.
|
|
||||||
*/
|
|
||||||
export function buildOutline(doc) {
|
|
||||||
if (!isObject(doc) || !Array.isArray(doc.content))
|
|
||||||
return [];
|
|
||||||
const out = [];
|
|
||||||
for (let i = 0; i < doc.content.length; i++) {
|
|
||||||
const block = doc.content[i];
|
|
||||||
const type = isObject(block) ? block.type : undefined;
|
|
||||||
const entry = {
|
|
||||||
index: i,
|
|
||||||
type,
|
|
||||||
id: isObject(block) && isObject(block.attrs)
|
|
||||||
? (block.attrs.id ?? null)
|
|
||||||
: null,
|
|
||||||
firstText: truncate(blockPlainText(block), 100),
|
|
||||||
};
|
|
||||||
if (type === "heading") {
|
|
||||||
entry.level = isObject(block.attrs) ? (block.attrs.level ?? null) : null;
|
|
||||||
}
|
|
||||||
else if (type === "table") {
|
|
||||||
const headerRow = block.content?.[0]?.content ?? [];
|
|
||||||
entry.rows = block.content?.length ?? 0;
|
|
||||||
entry.cols = block.content?.[0]?.content?.length ?? 0;
|
|
||||||
entry.header = headerRow.map((cell) => truncate(blockPlainText(cell), 40));
|
|
||||||
}
|
|
||||||
else if (typeof type === "string" && type.endsWith("List")) {
|
|
||||||
entry.items = block.content?.length ?? 0;
|
|
||||||
}
|
|
||||||
out.push(entry);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Resolve a single node by reference and return `{ node, path, type }`, or
|
|
||||||
* `null` when nothing matches.
|
|
||||||
*
|
|
||||||
* - `ref` of the form `#<n>` (e.g. `#2`) selects the TOP-LEVEL block at index
|
|
||||||
* `n` in `doc.content`. This is the only way to address table/tableRow/
|
|
||||||
* tableCell nodes, which carry no `attrs.id`.
|
|
||||||
* - Otherwise `ref` is treated as a block id: the FIRST node anywhere in the
|
|
||||||
* tree with `attrs.id === ref` is returned.
|
|
||||||
*
|
|
||||||
* `path` is the array of child indices from the doc root down to the node
|
|
||||||
* (so a top-level block is `[index]`). The returned `node` is a DEEP CLONE,
|
|
||||||
* so callers can mutate it without touching the input doc. Null-safe.
|
|
||||||
*/
|
|
||||||
export function getNodeByRef(doc, ref) {
|
|
||||||
if (!isObject(doc))
|
|
||||||
return null;
|
|
||||||
// "#<n>": index into the top-level content array.
|
|
||||||
const indexMatch = typeof ref === "string" ? ref.match(/^#(\d+)$/) : null;
|
|
||||||
if (indexMatch) {
|
|
||||||
const index = Number(indexMatch[1]);
|
|
||||||
const block = Array.isArray(doc.content) ? doc.content[index] : undefined;
|
|
||||||
if (!isObject(block))
|
|
||||||
return null;
|
|
||||||
return { node: clone(block), path: [index], type: block.type };
|
|
||||||
}
|
|
||||||
// Otherwise: depth-first search for the first node with attrs.id === ref.
|
|
||||||
const search = (node, trail) => {
|
|
||||||
if (!isObject(node))
|
|
||||||
return null;
|
|
||||||
if (Array.isArray(node.content)) {
|
|
||||||
for (let i = 0; i < node.content.length; i++) {
|
|
||||||
const child = node.content[i];
|
|
||||||
const path = [...trail, i];
|
|
||||||
if (matchesId(child, ref)) {
|
|
||||||
return { node: clone(child), path, type: child.type };
|
|
||||||
}
|
|
||||||
const hit = search(child, path);
|
|
||||||
if (hit != null)
|
|
||||||
return hit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
return search(doc, []);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Replace EVERY node whose `attrs.id === nodeId` with a deep clone of
|
|
||||||
* `newNode`, anywhere in the tree (including inside callouts and table cells).
|
|
||||||
*
|
|
||||||
* Operates on a clone of `doc`; returns `{ doc, replaced }` where `replaced`
|
|
||||||
* is the number of nodes substituted. A fresh clone of `newNode` is used for
|
|
||||||
* each match so they do not share references.
|
|
||||||
*/
|
|
||||||
export function replaceNodeById(doc, nodeId, newNode) {
|
|
||||||
const out = clone(doc);
|
|
||||||
let replaced = 0;
|
|
||||||
// Walk a content array, replacing direct matches and recursing into the
|
|
||||||
// (possibly new) children of non-matching nodes.
|
|
||||||
const walkContent = (content) => {
|
|
||||||
for (let i = 0; i < content.length; i++) {
|
|
||||||
const child = content[i];
|
|
||||||
if (matchesId(child, nodeId)) {
|
|
||||||
content[i] = clone(newNode);
|
|
||||||
replaced++;
|
|
||||||
// Do not recurse into a freshly substituted node.
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (isObject(child) && Array.isArray(child.content)) {
|
|
||||||
walkContent(child.content);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (isObject(out) && Array.isArray(out.content)) {
|
|
||||||
walkContent(out.content);
|
|
||||||
}
|
|
||||||
return { doc: out, replaced };
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Remove EVERY node whose `attrs.id === nodeId` from its parent `content`
|
|
||||||
* array, anywhere in the tree (recursive, including callouts and tables).
|
|
||||||
*
|
|
||||||
* Operates on a clone of `doc`; returns `{ doc, deleted }` where `deleted` is
|
|
||||||
* the number of nodes removed.
|
|
||||||
*/
|
|
||||||
export function deleteNodeById(doc, nodeId) {
|
|
||||||
const out = clone(doc);
|
|
||||||
let deleted = 0;
|
|
||||||
// Filter a content array in place, dropping matches and recursing into the
|
|
||||||
// surviving children.
|
|
||||||
const walkContent = (content) => {
|
|
||||||
const kept = [];
|
|
||||||
for (const child of content) {
|
|
||||||
if (matchesId(child, nodeId)) {
|
|
||||||
deleted++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (isObject(child) && Array.isArray(child.content)) {
|
|
||||||
child.content = walkContent(child.content);
|
|
||||||
}
|
|
||||||
kept.push(child);
|
|
||||||
}
|
|
||||||
return kept;
|
|
||||||
};
|
|
||||||
if (isObject(out) && Array.isArray(out.content)) {
|
|
||||||
out.content = walkContent(out.content);
|
|
||||||
}
|
|
||||||
return { doc: out, deleted };
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Throw a clear, model-actionable error when a node-id write op did NOT match
|
|
||||||
* exactly one node (#159). `count === 0` -> "no node found"; `count > 1` ->
|
|
||||||
* "ambiguous, refused" — Docmost duplicates block ids on copy/paste, so a write
|
|
||||||
* by id could clobber/remove EVERY duplicate. The caller skips the write for any
|
|
||||||
* `count !== 1` (the transform returns null), so this only REPORTS; nothing was
|
|
||||||
* changed. No-op for the unambiguous single-match case.
|
|
||||||
*/
|
|
||||||
export function assertUnambiguousMatch(op, verb, count, nodeId, pageId) {
|
|
||||||
if (count === 0) {
|
|
||||||
throw new Error(`${op}: no node with id "${nodeId}" found on page ${pageId}`);
|
|
||||||
}
|
|
||||||
if (count > 1) {
|
|
||||||
throw new Error(`${op}: id "${nodeId}" is ambiguous — ${count} nodes on page ${pageId} share it (block ids are duplicated on copy/paste). Refusing to ${verb} all of them; nothing was changed. Re-target with a more specific anchor.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Deep-clone `doc` and strip every node/mark attribute whose value is strictly
|
|
||||||
* `undefined`, so the result is safe to hand to Yjs (which throws an opaque
|
|
||||||
* "Unexpected content type" when asked to store an `undefined` attribute value).
|
|
||||||
*
|
|
||||||
* Only `undefined` keys are removed; `null`, `false`, `0`, and `""` are all
|
|
||||||
* legitimate JSON-storable values and are preserved. Operates on a clone and
|
|
||||||
* returns it; the input is never mutated. Defensively null-safe like the rest
|
|
||||||
* of the file.
|
|
||||||
*/
|
|
||||||
export function sanitizeForYjs(doc) {
|
|
||||||
const out = clone(doc);
|
|
||||||
// Drop every key whose value is strictly `undefined` from an attrs object.
|
|
||||||
const stripUndefined = (attrs) => {
|
|
||||||
if (!isObject(attrs))
|
|
||||||
return;
|
|
||||||
for (const key of Object.keys(attrs)) {
|
|
||||||
if (attrs[key] === undefined) {
|
|
||||||
delete attrs[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const walk = (node) => {
|
|
||||||
if (!isObject(node))
|
|
||||||
return;
|
|
||||||
stripUndefined(node.attrs);
|
|
||||||
if (Array.isArray(node.marks)) {
|
|
||||||
for (const mark of node.marks) {
|
|
||||||
if (isObject(mark))
|
|
||||||
stripUndefined(mark.attrs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Array.isArray(node.content)) {
|
|
||||||
for (const child of node.content) {
|
|
||||||
walk(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
walk(out);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Diagnostics helper: walk the tree and return a human-readable path string for
|
|
||||||
* the FIRST attribute value (in any `node.attrs` or `mark.attrs`) that Yjs
|
|
||||||
* cannot store — i.e. `undefined`, a `function`, a `symbol`, or a `bigint`
|
|
||||||
* (e.g. `content[3].content[0].attrs.indent (undefined)`). Returns `null` when
|
|
||||||
* every attribute is storable. Null-safe.
|
|
||||||
*/
|
|
||||||
export function findUnstorableAttr(doc) {
|
|
||||||
const isUnstorable = (value) => {
|
|
||||||
if (value === undefined)
|
|
||||||
return "undefined";
|
|
||||||
const t = typeof value;
|
|
||||||
if (t === "function")
|
|
||||||
return "function";
|
|
||||||
if (t === "symbol")
|
|
||||||
return "symbol";
|
|
||||||
if (t === "bigint")
|
|
||||||
return "bigint";
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
// Check an attrs object; return the offending sub-path or null.
|
|
||||||
const checkAttrs = (attrs, basePath) => {
|
|
||||||
if (!isObject(attrs))
|
|
||||||
return null;
|
|
||||||
for (const key of Object.keys(attrs)) {
|
|
||||||
const kind = isUnstorable(attrs[key]);
|
|
||||||
if (kind != null)
|
|
||||||
return `${basePath}.${key} (${kind})`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
const walk = (node, path) => {
|
|
||||||
if (!isObject(node))
|
|
||||||
return null;
|
|
||||||
const attrHit = checkAttrs(node.attrs, `${path}.attrs`);
|
|
||||||
if (attrHit != null)
|
|
||||||
return attrHit;
|
|
||||||
if (Array.isArray(node.marks)) {
|
|
||||||
for (let i = 0; i < node.marks.length; i++) {
|
|
||||||
const markHit = checkAttrs(node.marks[i]?.attrs, `${path}.marks[${i}].attrs`);
|
|
||||||
if (markHit != null)
|
|
||||||
return markHit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (Array.isArray(node.content)) {
|
|
||||||
for (let i = 0; i < node.content.length; i++) {
|
|
||||||
const childHit = walk(node.content[i], `${path}.content[${i}]`);
|
|
||||||
if (childHit != null)
|
|
||||||
return childHit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
// The root doc node carries no useful index, so start the path at "doc".
|
|
||||||
if (!isObject(doc))
|
|
||||||
return null;
|
|
||||||
const attrHit = checkAttrs(doc.attrs, "attrs");
|
|
||||||
if (attrHit != null)
|
|
||||||
return attrHit;
|
|
||||||
if (Array.isArray(doc.content)) {
|
|
||||||
for (let i = 0; i < doc.content.length; i++) {
|
|
||||||
const childHit = walk(doc.content[i], `content[${i}]`);
|
|
||||||
if (childHit != null)
|
|
||||||
return childHit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Table structural node types and the container each must live directly inside.
|
|
||||||
* Used by `insertNodeRelative` to splice rows/cells into the correct ancestor
|
|
||||||
* rather than blindly into the anchor's direct parent (which would corrupt the
|
|
||||||
* table's nesting).
|
|
||||||
*/
|
|
||||||
const STRUCTURAL_TYPES = new Set(["tableRow", "tableCell", "tableHeader"]);
|
|
||||||
const REQUIRED_CONTAINER = {
|
|
||||||
tableRow: "table",
|
|
||||||
tableCell: "tableRow",
|
|
||||||
tableHeader: "tableRow",
|
|
||||||
};
|
|
||||||
/**
|
|
||||||
* Find the index of the first TOP-LEVEL block whose plain text includes the
|
|
||||||
* anchor, with a markdown-stripping FALLBACK. Returns -1 when none matches.
|
|
||||||
*
|
|
||||||
* Two passes preserve "exact wins globally":
|
|
||||||
* - Pass 1: first block containing the verbatim `anchorText`.
|
|
||||||
* - Pass 2 (only if pass 1 found nothing): first block containing the
|
|
||||||
* markdown-stripped anchor, when stripping actually changed it.
|
|
||||||
*/
|
|
||||||
function findAnchorTextIndex(content, anchorText) {
|
|
||||||
if (!Array.isArray(content))
|
|
||||||
return -1;
|
|
||||||
// Pass 1: exact.
|
|
||||||
for (let i = 0; i < content.length; i++) {
|
|
||||||
if (blockPlainText(content[i]).includes(anchorText))
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
// Pass 2: markdown-stripped fallback.
|
|
||||||
const a = stripInlineMarkdown(anchorText);
|
|
||||||
if (a !== anchorText && a.length > 0) {
|
|
||||||
for (let i = 0; i < content.length; i++) {
|
|
||||||
if (blockPlainText(content[i]).includes(a))
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Locate an anchor and return its ancestor chain (from `doc` down to and
|
|
||||||
* including the matched node). Each chain entry is `{ node, index }` where
|
|
||||||
* `index` is the node's position inside its parent's `content` array (the root
|
|
||||||
* doc has index -1). Returns `null` when the anchor cannot be resolved.
|
|
||||||
*/
|
|
||||||
function findAnchorChain(doc, opts) {
|
|
||||||
if (!isObject(doc))
|
|
||||||
return null;
|
|
||||||
// DFS by id anywhere in the tree, accumulating the path.
|
|
||||||
if (opts.anchorNodeId != null) {
|
|
||||||
const targetId = opts.anchorNodeId;
|
|
||||||
const search = (node, index, trail) => {
|
|
||||||
if (!isObject(node))
|
|
||||||
return null;
|
|
||||||
const here = [...trail, { node, index }];
|
|
||||||
if (matchesId(node, targetId))
|
|
||||||
return here;
|
|
||||||
if (Array.isArray(node.content)) {
|
|
||||||
for (let i = 0; i < node.content.length; i++) {
|
|
||||||
const hit = search(node.content[i], i, here);
|
|
||||||
if (hit != null)
|
|
||||||
return hit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
return search(doc, -1, []);
|
|
||||||
}
|
|
||||||
// By text: only top-level blocks are scanned (same rule as the JSON path).
|
|
||||||
// Exact match wins; a markdown-stripped fallback is tried only on a miss.
|
|
||||||
if (opts.anchorText != null && Array.isArray(doc.content)) {
|
|
||||||
const i = findAnchorTextIndex(doc.content, opts.anchorText);
|
|
||||||
if (i !== -1) {
|
|
||||||
return [
|
|
||||||
{ node: doc, index: -1 },
|
|
||||||
{ node: doc.content[i], index: i },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Insert a deep clone of `node` relative to an anchor.
|
|
||||||
*
|
|
||||||
* - position "append": push the node onto the top-level `doc.content`.
|
|
||||||
* - position "before"/"after": locate the anchor and splice the node into the
|
|
||||||
* anchor's parent `content` array immediately before / after it.
|
|
||||||
*
|
|
||||||
* Anchor resolution for before/after:
|
|
||||||
* - if `anchorNodeId` is given, find the node with `attrs.id === anchorNodeId`
|
|
||||||
* anywhere in the tree (recursive);
|
|
||||||
* - otherwise, if `anchorText` is given, scan only TOP-LEVEL `doc.content`
|
|
||||||
* blocks and pick the first whose `blockPlainText` includes `anchorText`.
|
|
||||||
*
|
|
||||||
* Operates on a clone of `doc`; returns `{ doc, inserted }`. `inserted` is
|
|
||||||
* false when the anchor could not be resolved (the doc is returned unchanged
|
|
||||||
* apart from being cloned).
|
|
||||||
*/
|
|
||||||
export function insertNodeRelative(doc, node, opts) {
|
|
||||||
const out = clone(doc);
|
|
||||||
const fresh = clone(node);
|
|
||||||
// Defensive: stay null-safe like the other exports — a missing opts means
|
|
||||||
// there is nothing actionable to do.
|
|
||||||
if (!isObject(opts))
|
|
||||||
return { doc: out, inserted: false };
|
|
||||||
const isStructural = isObject(node) && STRUCTURAL_TYPES.has(node.type);
|
|
||||||
// "append": top-level push.
|
|
||||||
if (opts.position === "append") {
|
|
||||||
// Structural table nodes (tableRow/tableCell/tableHeader) cannot live at the
|
|
||||||
// top level — appending one would produce invalid nesting.
|
|
||||||
if (isStructural) {
|
|
||||||
throw new Error(`insert_node: cannot append a ${node.type} at the top level; use ` +
|
|
||||||
`position before/after with an anchor inside the target table`);
|
|
||||||
}
|
|
||||||
if (isObject(out)) {
|
|
||||||
if (!Array.isArray(out.content))
|
|
||||||
out.content = [];
|
|
||||||
out.content.push(fresh);
|
|
||||||
return { doc: out, inserted: true };
|
|
||||||
}
|
|
||||||
return { doc: out, inserted: false };
|
|
||||||
}
|
|
||||||
const offset = opts.position === "after" ? 1 : 0;
|
|
||||||
// Structural insert (before/after a tableRow/tableCell/tableHeader): splice
|
|
||||||
// into the nearest enclosing table/tableRow rather than the anchor's direct
|
|
||||||
// parent, so the row/cell lands at the correct level of the table.
|
|
||||||
if (isStructural) {
|
|
||||||
const containerType = REQUIRED_CONTAINER[node.type];
|
|
||||||
const chain = findAnchorChain(out, opts);
|
|
||||||
// Anchor not resolved at all — keep the existing "anchor not found" path.
|
|
||||||
if (chain == null)
|
|
||||||
return { doc: out, inserted: false };
|
|
||||||
// Find the DEEPEST ancestor (including the anchor itself) of the required
|
|
||||||
// container type.
|
|
||||||
let containerIdx = -1;
|
|
||||||
for (let i = chain.length - 1; i >= 0; i--) {
|
|
||||||
if (isObject(chain[i].node) && chain[i].node.type === containerType) {
|
|
||||||
containerIdx = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (containerIdx === -1) {
|
|
||||||
throw new Error(`insert_node: cannot insert a ${node.type} here — the anchor is not ` +
|
|
||||||
`inside a ${containerType}. Anchor on a cell's text or a block id ` +
|
|
||||||
`that lives inside the target table.`);
|
|
||||||
}
|
|
||||||
const container = chain[containerIdx].node;
|
|
||||||
if (!Array.isArray(container.content))
|
|
||||||
container.content = [];
|
|
||||||
if (containerIdx === chain.length - 1) {
|
|
||||||
// The matched container IS the anchor node itself (e.g. anchorText
|
|
||||||
// resolved to the table block): append/prepend within it.
|
|
||||||
const at = opts.position === "after" ? container.content.length : 0;
|
|
||||||
container.content.splice(at, 0, fresh);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// The immediate child on the path leading to the anchor is the row/cell
|
|
||||||
// to splice next to.
|
|
||||||
const enclosingChildIndex = chain[containerIdx + 1].index;
|
|
||||||
container.content.splice(enclosingChildIndex + offset, 0, fresh);
|
|
||||||
}
|
|
||||||
return { doc: out, inserted: true };
|
|
||||||
}
|
|
||||||
// Resolve by id anywhere in the tree: splice into the parent content array.
|
|
||||||
if (opts.anchorNodeId != null) {
|
|
||||||
let inserted = false;
|
|
||||||
const walkContent = (content) => {
|
|
||||||
for (let i = 0; i < content.length; i++) {
|
|
||||||
const child = content[i];
|
|
||||||
if (matchesId(child, opts.anchorNodeId)) {
|
|
||||||
content.splice(i + offset, 0, fresh);
|
|
||||||
inserted = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (isObject(child) && Array.isArray(child.content)) {
|
|
||||||
walkContent(child.content);
|
|
||||||
if (inserted)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (isObject(out) && Array.isArray(out.content)) {
|
|
||||||
walkContent(out.content);
|
|
||||||
}
|
|
||||||
return { doc: out, inserted };
|
|
||||||
}
|
|
||||||
// Resolve by text: only top-level doc.content blocks are scanned. Exact
|
|
||||||
// match wins; a markdown-stripped fallback is tried only on a miss.
|
|
||||||
if (opts.anchorText != null && isObject(out) && Array.isArray(out.content)) {
|
|
||||||
const i = findAnchorTextIndex(out.content, opts.anchorText);
|
|
||||||
if (i !== -1) {
|
|
||||||
out.content.splice(i + offset, 0, fresh);
|
|
||||||
return { doc: out, inserted: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { doc: out, inserted: false };
|
|
||||||
}
|
|
||||||
// ===========================================================================
|
|
||||||
// Table editing helpers
|
|
||||||
//
|
|
||||||
// A Docmost table is a ProseMirror subtree with NO ids on the structural nodes:
|
|
||||||
// table -> { type:"table", content:[tableRow...] }
|
|
||||||
// row -> { type:"tableRow", content:[tableCell|tableHeader...] }
|
|
||||||
// cell -> { type:"tableCell"|"tableHeader", attrs:{colspan,rowspan,colwidth},
|
|
||||||
// content:[paragraph...] }
|
|
||||||
// para -> { type:"paragraph", attrs:{id,indent}, content:[textNode...] }
|
|
||||||
// Only paragraphs/headings carry an `attrs.id`, so a cell is addressed via the
|
|
||||||
// id of the paragraph inside it. The helpers below all operate on a DEEP CLONE
|
|
||||||
// of the input doc (via `clone`) and never mutate their inputs.
|
|
||||||
// ===========================================================================
|
|
||||||
/**
|
|
||||||
* Collect EVERY `attrs.id` present anywhere in `node` into `used`. Used to seed
|
|
||||||
* `makeFreshId` so generated paragraph ids never collide with existing ones.
|
|
||||||
*/
|
|
||||||
function collectIds(node, used) {
|
|
||||||
if (!isObject(node))
|
|
||||||
return;
|
|
||||||
if (isObject(node.attrs) && typeof node.attrs.id === "string") {
|
|
||||||
used.add(node.attrs.id);
|
|
||||||
}
|
|
||||||
if (Array.isArray(node.content)) {
|
|
||||||
for (const child of node.content)
|
|
||||||
collectIds(child, used);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Fresh-id generator: returns a random Docmost-style id (12 chars from
|
|
||||||
* lowercase `a-z0-9`) that is not already in `used`, and records it. On the
|
|
||||||
* rare collision the id is regenerated. Callers rely on uniqueness, not on the
|
|
||||||
* exact string, so randomness is fine — and unlike a module-local counter it
|
|
||||||
* needs no reset and cannot become predictable across calls.
|
|
||||||
*/
|
|
||||||
function makeFreshId(used) {
|
|
||||||
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
||||||
let id;
|
|
||||||
do {
|
|
||||||
id = "";
|
|
||||||
for (let i = 0; i < 12; i++) {
|
|
||||||
id += alphabet[Math.floor(Math.random() * alphabet.length)];
|
|
||||||
}
|
|
||||||
} while (used.has(id) || id === "");
|
|
||||||
used.add(id);
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Resolve a table reference against an ALREADY-CLONED doc and return the LIVE
|
|
||||||
* table node (a reference inside `rootClone`, so the caller may mutate it) plus
|
|
||||||
* its index path. Returns null when no table matches.
|
|
||||||
*
|
|
||||||
* - `#<n>`: the top-level block at index `n`, only if its `type === "table"`.
|
|
||||||
* - otherwise: DFS for the node with `attrs.id === tableRef`, then walk UP its
|
|
||||||
* ancestor chain to the nearest `type === "table"` ancestor.
|
|
||||||
*/
|
|
||||||
function locateTable(rootClone, tableRef) {
|
|
||||||
if (!isObject(rootClone))
|
|
||||||
return null;
|
|
||||||
// "#<n>": index into the top-level content array; must be a table.
|
|
||||||
const indexMatch = typeof tableRef === "string" ? tableRef.match(/^#(\d+)$/) : null;
|
|
||||||
if (indexMatch) {
|
|
||||||
const index = Number(indexMatch[1]);
|
|
||||||
const block = Array.isArray(rootClone.content)
|
|
||||||
? rootClone.content[index]
|
|
||||||
: undefined;
|
|
||||||
if (isObject(block) && block.type === "table") {
|
|
||||||
return { table: block, path: [index] };
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// Otherwise: DFS for attrs.id === tableRef, tracking the ancestor chain, then
|
|
||||||
// climb to the nearest enclosing table.
|
|
||||||
const search = (node, trail) => {
|
|
||||||
if (!isObject(node))
|
|
||||||
return null;
|
|
||||||
if (Array.isArray(node.content)) {
|
|
||||||
for (let i = 0; i < node.content.length; i++) {
|
|
||||||
const child = node.content[i];
|
|
||||||
const here = [...trail, { node: child, index: i }];
|
|
||||||
if (matchesId(child, tableRef)) {
|
|
||||||
// Walk UP to the nearest table ancestor (including the match itself).
|
|
||||||
for (let j = here.length - 1; j >= 0; j--) {
|
|
||||||
if (isObject(here[j].node) && here[j].node.type === "table") {
|
|
||||||
return {
|
|
||||||
table: here[j].node,
|
|
||||||
path: here.slice(0, j + 1).map((e) => e.index),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null; // id found but no enclosing table
|
|
||||||
}
|
|
||||||
const hit = search(child, here);
|
|
||||||
if (hit != null)
|
|
||||||
return hit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
return search(rootClone, []);
|
|
||||||
}
|
|
||||||
/** Build the plain-text → single-paragraph cell content used by all writers. */
|
|
||||||
function makeCellParagraph(id, text) {
|
|
||||||
return {
|
|
||||||
type: "paragraph",
|
|
||||||
attrs: { id, indent: 0 },
|
|
||||||
// Empty string → a paragraph with an empty content array.
|
|
||||||
content: text ? [{ type: "text", text }] : [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Read a table as a matrix. Returns null when `tableRef` resolves to no table.
|
|
||||||
*
|
|
||||||
* - `rows`/`cols`: the table's row count and the column count of its FIRST row.
|
|
||||||
* Tables may be ragged (rows of differing length), so `cols` reflects only
|
|
||||||
* row 0; use the per-row length of `cells`/`cellIds` for each row's actual
|
|
||||||
* width.
|
|
||||||
* - `cells`: `string[][]` of each cell's `blockPlainText`.
|
|
||||||
* - `cellIds`: `(string|null)[][]` of each cell's FIRST paragraph id (or null),
|
|
||||||
* so callers can `patch_node` a cell for rich-formatted edits.
|
|
||||||
* - `path`: index path of the table within the doc.
|
|
||||||
*/
|
|
||||||
export function readTable(doc, tableRef) {
|
|
||||||
const root = clone(doc);
|
|
||||||
const located = locateTable(root, tableRef);
|
|
||||||
if (located == null)
|
|
||||||
return null;
|
|
||||||
const { table, path } = located;
|
|
||||||
const rowNodes = Array.isArray(table.content) ? table.content : [];
|
|
||||||
const rows = rowNodes.length;
|
|
||||||
const cols = rowNodes[0]?.content?.length ?? 0;
|
|
||||||
const cells = [];
|
|
||||||
const cellIds = [];
|
|
||||||
for (const rowNode of rowNodes) {
|
|
||||||
const cellNodes = Array.isArray(rowNode?.content) ? rowNode.content : [];
|
|
||||||
const rowText = [];
|
|
||||||
const rowIds = [];
|
|
||||||
for (const cellNode of cellNodes) {
|
|
||||||
rowText.push(blockPlainText(cellNode));
|
|
||||||
// The cell's first paragraph carries the id used for patch_node.
|
|
||||||
const firstPara = Array.isArray(cellNode?.content)
|
|
||||||
? cellNode.content[0]
|
|
||||||
: undefined;
|
|
||||||
const id = isObject(firstPara) && isObject(firstPara.attrs)
|
|
||||||
? (firstPara.attrs.id ?? null)
|
|
||||||
: null;
|
|
||||||
rowIds.push(id);
|
|
||||||
}
|
|
||||||
cells.push(rowText);
|
|
||||||
cellIds.push(rowIds);
|
|
||||||
}
|
|
||||||
return { rows, cols, cells, cellIds, path };
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Insert a row of plain-text cells into a table. Returns `{ doc, inserted }`.
|
|
||||||
*
|
|
||||||
* The row is padded to the table's column count (`cells[i] ?? ""`); supplying
|
|
||||||
* MORE cells than columns throws. Each new cell copies `colwidth` for its
|
|
||||||
* column from the header row when present, gets a fresh-id paragraph, and a
|
|
||||||
* `colspan:1, rowspan:1` attrs. `index` (when an integer in `[0, rows]`) splices
|
|
||||||
* the row there; otherwise the row is appended at the end.
|
|
||||||
*/
|
|
||||||
export function insertTableRow(doc, tableRef, cells, index) {
|
|
||||||
const out = clone(doc);
|
|
||||||
const located = locateTable(out, tableRef);
|
|
||||||
if (located == null)
|
|
||||||
return { doc: out, inserted: false };
|
|
||||||
const { table } = located;
|
|
||||||
if (!Array.isArray(table.content))
|
|
||||||
table.content = [];
|
|
||||||
const rows = table.content.length;
|
|
||||||
const headerRow = table.content[0];
|
|
||||||
const headerCells = Array.isArray(headerRow?.content)
|
|
||||||
? headerRow.content
|
|
||||||
: [];
|
|
||||||
// Column count is the WIDEST existing row, so the guard below stays
|
|
||||||
// meaningful for ragged tables and the new row matches the table's width.
|
|
||||||
// Fall back to the supplied cell count only when the table has no rows.
|
|
||||||
let colCount = 0;
|
|
||||||
for (const r of table.content) {
|
|
||||||
if (isObject(r) && Array.isArray(r.content))
|
|
||||||
colCount = Math.max(colCount, r.content.length);
|
|
||||||
}
|
|
||||||
if (colCount === 0)
|
|
||||||
colCount = Array.isArray(cells) ? cells.length : 0;
|
|
||||||
if (Array.isArray(cells) && cells.length > colCount) {
|
|
||||||
throw new Error(`table_insert_row: got ${cells.length} cell(s) but the table has ${colCount} column(s)`);
|
|
||||||
}
|
|
||||||
// Resolve the landing index up front so the cell-type decision and the splice
|
|
||||||
// below agree: a valid integer in [0, rows] splices there, else we append.
|
|
||||||
const landingIndex = typeof index === "number" &&
|
|
||||||
Number.isInteger(index) &&
|
|
||||||
index >= 0 &&
|
|
||||||
index <= rows
|
|
||||||
? index
|
|
||||||
: rows;
|
|
||||||
// Seed the id generator with every id already in the doc so the new cell
|
|
||||||
// paragraph ids are unique within the whole document.
|
|
||||||
const used = new Set();
|
|
||||||
collectIds(out, used);
|
|
||||||
const newCells = [];
|
|
||||||
for (let i = 0; i < colCount; i++) {
|
|
||||||
const text = (Array.isArray(cells) ? cells[i] : undefined) ?? "";
|
|
||||||
const attrs = { colspan: 1, rowspan: 1 };
|
|
||||||
// Copy this column's colwidth from the header row's cell when present.
|
|
||||||
const colwidth = headerCells[i]?.attrs?.colwidth;
|
|
||||||
if (colwidth !== undefined)
|
|
||||||
attrs.colwidth = colwidth;
|
|
||||||
// A row landing at index 0 becomes the new header row, so inherit the
|
|
||||||
// current header cell's type per column (Docmost uses "tableHeader" there);
|
|
||||||
// every other position is a plain data cell.
|
|
||||||
const cellType = landingIndex === 0 ? (headerCells[i]?.type ?? "tableCell") : "tableCell";
|
|
||||||
newCells.push({
|
|
||||||
type: cellType,
|
|
||||||
attrs,
|
|
||||||
content: [makeCellParagraph(makeFreshId(used), text)],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const newRow = { type: "tableRow", content: newCells };
|
|
||||||
// Splice at the resolved landing index (append when index was omitted/invalid).
|
|
||||||
table.content.splice(landingIndex, 0, newRow);
|
|
||||||
return { doc: out, inserted: true };
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Delete the row at 0-based `index` from a table. Returns `{ doc, deleted }`.
|
|
||||||
* `deleted` is false only when the table cannot be located. Throws on an
|
|
||||||
* out-of-range index, and refuses to delete the table's only row.
|
|
||||||
*/
|
|
||||||
export function deleteTableRow(doc, tableRef, index) {
|
|
||||||
const out = clone(doc);
|
|
||||||
const located = locateTable(out, tableRef);
|
|
||||||
if (located == null)
|
|
||||||
return { doc: out, deleted: false };
|
|
||||||
const { table } = located;
|
|
||||||
if (!Array.isArray(table.content))
|
|
||||||
table.content = [];
|
|
||||||
const rows = table.content.length;
|
|
||||||
if (!Number.isInteger(index) || index < 0 || index >= rows) {
|
|
||||||
throw new Error(`table_delete_row: row index ${index} out of range (table has ${rows} row(s))`);
|
|
||||||
}
|
|
||||||
if (rows <= 1) {
|
|
||||||
throw new Error("table_delete_row: refusing to delete the only row of the table");
|
|
||||||
}
|
|
||||||
table.content.splice(index, 1);
|
|
||||||
return { doc: out, deleted: true };
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Set the plain-text content of cell `[row, col]` (0-based) to `text`. Returns
|
|
||||||
* `{ doc, updated }`; `updated` is false only when the table cannot be located.
|
|
||||||
* Throws when `row`/`col` is out of range. The cell's own attrs (colspan/
|
|
||||||
* rowspan/colwidth) are preserved; its content becomes a single text paragraph
|
|
||||||
* that reuses the cell's existing first-paragraph id when present, else a fresh
|
|
||||||
* one.
|
|
||||||
*/
|
|
||||||
export function updateTableCell(doc, tableRef, row, col, text) {
|
|
||||||
const out = clone(doc);
|
|
||||||
const located = locateTable(out, tableRef);
|
|
||||||
if (located == null)
|
|
||||||
return { doc: out, updated: false };
|
|
||||||
const { table } = located;
|
|
||||||
const rowNodes = Array.isArray(table.content) ? table.content : [];
|
|
||||||
const rows = rowNodes.length;
|
|
||||||
const rowNode = rowNodes[row];
|
|
||||||
const cols = isObject(rowNode) && Array.isArray(rowNode.content)
|
|
||||||
? rowNode.content.length
|
|
||||||
: 0;
|
|
||||||
if (!Number.isInteger(row) ||
|
|
||||||
row < 0 ||
|
|
||||||
row >= rows ||
|
|
||||||
!Number.isInteger(col) ||
|
|
||||||
col < 0 ||
|
|
||||||
col >= cols) {
|
|
||||||
throw new Error(`table_update_cell: cell [${row},${col}] out of range`);
|
|
||||||
}
|
|
||||||
const cellNode = rowNode.content[col];
|
|
||||||
// Reuse the cell's existing first-paragraph id, or mint a fresh unique one.
|
|
||||||
const existingPara = Array.isArray(cellNode?.content)
|
|
||||||
? cellNode.content[0]
|
|
||||||
: undefined;
|
|
||||||
let id = isObject(existingPara) && isObject(existingPara.attrs)
|
|
||||||
? existingPara.attrs.id
|
|
||||||
: undefined;
|
|
||||||
if (typeof id !== "string" || id.length === 0) {
|
|
||||||
const used = new Set();
|
|
||||||
collectIds(out, used);
|
|
||||||
id = makeFreshId(used);
|
|
||||||
}
|
|
||||||
cellNode.content = [makeCellParagraph(id, text)];
|
|
||||||
return { doc: out, updated: true };
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
/**
|
|
||||||
* Per-page async mutex.
|
|
||||||
*
|
|
||||||
* Content writes over the collaboration websocket must never overlap for the
|
|
||||||
* same page: two concurrent full-document replaces would race on the live Yjs
|
|
||||||
* fragment. We serialize them with a per-pageId promise chain — each new
|
|
||||||
* operation waits for the previous one on that page to settle (success or
|
|
||||||
* failure) before it runs. Different pages never block each other.
|
|
||||||
*/
|
|
||||||
const chains = new Map();
|
|
||||||
// The returned promise carries the real result/rejection of `fn` and MUST be
|
|
||||||
// awaited/handled by the caller; only the internal chaining tail swallows
|
|
||||||
// errors (purely to gate ordering).
|
|
||||||
export function withPageLock(pageId, fn) {
|
|
||||||
// Wait for the previous op on this page; swallow its error so a failure does
|
|
||||||
// not poison the queue for the next caller.
|
|
||||||
const prev = (chains.get(pageId) ?? Promise.resolve()).catch(() => { });
|
|
||||||
const run = prev.then(fn);
|
|
||||||
// The tail used for chaining must also swallow errors (it only gates order).
|
|
||||||
const tail = run.catch(() => { });
|
|
||||||
chains.set(pageId, tail);
|
|
||||||
// Drop the map entry once this op is the tail and has settled, to avoid an
|
|
||||||
// unbounded map of resolved promises.
|
|
||||||
tail.then(() => {
|
|
||||||
if (chains.get(pageId) === tail) {
|
|
||||||
chains.delete(pageId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Callers get the real result/rejection of fn.
|
|
||||||
return run;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
// The model sometimes serializes a ProseMirror node arg as a JSON string
|
|
||||||
// instead of an object. Normalize: parse a string to an object (throwing on
|
|
||||||
// invalid JSON), pass an object through unchanged. Shared by patch_node /
|
|
||||||
// insert_node (and the analogous update_page_json content parsing).
|
|
||||||
export function parseNodeArg(node, errMsg = "node was a string but not valid JSON") {
|
|
||||||
if (typeof node === "string") {
|
|
||||||
try {
|
|
||||||
return JSON.parse(node);
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
throw new Error(errMsg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
/**
|
|
||||||
* Locator normalization: strip inline markdown wrappers and trailing
|
|
||||||
* decoration from a LOCATOR string so a find/anchor that the model wrote with
|
|
||||||
* markdown (or a stray emoji) can still match the document's plain text.
|
|
||||||
*
|
|
||||||
* This is used ONLY as a fallback for LOCATING (after an exact match fails);
|
|
||||||
* it is never applied to replacement text or inserted node content, so no
|
|
||||||
* formatting is ever lost.
|
|
||||||
*/
|
|
||||||
/** Maximum unwrap passes, so pathological/nested input cannot loop forever. */
|
|
||||||
const MAX_PASSES = 8;
|
|
||||||
/**
|
|
||||||
* Inline emphasis/code/strikethrough wrappers, strong BEFORE emphasis so
|
|
||||||
* `**x**` collapses to `x` rather than leaving a stray `*x*`. Each pattern is
|
|
||||||
* non-greedy and capture group 1 is the inner text. Applied repeatedly until
|
|
||||||
* the string stops changing (nested wrappers like `**_x_**`).
|
|
||||||
*/
|
|
||||||
const WRAPPER_PATTERNS = [
|
|
||||||
/\*\*([^*]+?)\*\*/g, // **x**
|
|
||||||
/__([^_]+?)__/g, // __x__
|
|
||||||
/~~([^~]+?)~~/g, // ~~x~~
|
|
||||||
/\*([^*]+?)\*/g, // *x*
|
|
||||||
/_([^_]+?)_/g, // _x_
|
|
||||||
/``([^`]+?)``/g, // ``x``
|
|
||||||
/`([^`]+?)`/g, // `x`
|
|
||||||
];
|
|
||||||
/** Links/images -> their visible text. `!?` covers both `[t](u)` and ``. */
|
|
||||||
const LINK_IMAGE_RE = /!?\[([^\]]*)\]\([^)]*\)/g;
|
|
||||||
/**
|
|
||||||
* Apply ONLY the two balanced/link passes shared by both normalizers: first
|
|
||||||
* collapse links/images to their visible text, then collapse balanced inline
|
|
||||||
* wrappers repeatedly until stable. Does NOT trim decoration, does NOT guard
|
|
||||||
* against an empty result — it returns exactly the transformed string.
|
|
||||||
*/
|
|
||||||
function stripWrappersAndLinks(s) {
|
|
||||||
// 1. Links/images -> their visible text.
|
|
||||||
let out = s.replace(LINK_IMAGE_RE, "$1");
|
|
||||||
// 2. Strip balanced wrappers, repeating until the string is stable so nested
|
|
||||||
// wrappers (`**_x_**`) and adjacent runs both collapse.
|
|
||||||
for (let pass = 0; pass < MAX_PASSES; pass++) {
|
|
||||||
const before = out;
|
|
||||||
for (const re of WRAPPER_PATTERNS) {
|
|
||||||
out = out.replace(re, "$1");
|
|
||||||
}
|
|
||||||
if (out === before)
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* STRICT formatting detector — distinct from the lenient locator
|
|
||||||
* normalization below. It strips ONLY what unambiguously is markdown markup:
|
|
||||||
* 1. links/images `[text](url)` -> `text`, `` -> `alt`, and
|
|
||||||
* 2. balanced inline `**`/`__`/`~~`/`*`/`_`/`` ` `` wrappers (repeat-until-stable),
|
|
||||||
* and DELIBERATELY does NOT trim leading/trailing whitespace, emoji, or lone
|
|
||||||
* marker chars (the lenient extras `stripInlineMarkdown` does in its step 3).
|
|
||||||
*
|
|
||||||
* It exists ONLY to recognize formatting-vs-plain INTENT in `applyTextEdits`
|
|
||||||
* (deciding whether find/replace differ purely by markdown markers). Because it
|
|
||||||
* skips the lenient trimming, ordinary plain-text edits are NOT misread as
|
|
||||||
* formatting: a trailing-space trim, snake_case (`my_var_name`), math (`2 * 3`),
|
|
||||||
* and identifiers/URLs with underscores all stay untouched here (their `_x_` /
|
|
||||||
* `*x*` runs are only collapsed when actually balanced, and even then they are
|
|
||||||
* compared symmetrically, so plain text never collapses to a different string).
|
|
||||||
*
|
|
||||||
* Do NOT use this for LOCATING — the locator fallback must keep using the
|
|
||||||
* lenient `stripInlineMarkdown` (it trims stray decoration so a find still
|
|
||||||
* matches the document's plain text).
|
|
||||||
*/
|
|
||||||
export function stripBalancedWrappers(s) {
|
|
||||||
if (typeof s !== "string" || s.length === 0)
|
|
||||||
return s;
|
|
||||||
return stripWrappersAndLinks(s);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Conservatively strip inline markdown from a locator string.
|
|
||||||
*
|
|
||||||
* Deterministic, order-fixed steps:
|
|
||||||
* 1. Links/images: `[text](url)` -> `text`, `` -> `alt`.
|
|
||||||
* 2. Balanced inline wrappers (strong before emphasis, code, strikethrough),
|
|
||||||
* applied repeatedly until stable for nested cases.
|
|
||||||
* 3. Trim leading/trailing decoration only: whitespace, leftover marker chars
|
|
||||||
* (`* _ ~ \``) and emoji. Letters/digits and sentence punctuation (`.`/`,`
|
|
||||||
* etc.) are NEVER trimmed.
|
|
||||||
*
|
|
||||||
* If the result is empty (e.g. the input was only markers like `***`), the
|
|
||||||
* ORIGINAL string is returned so a locator can never normalize down to "" and
|
|
||||||
* match everything.
|
|
||||||
*/
|
|
||||||
export function stripInlineMarkdown(s) {
|
|
||||||
if (typeof s !== "string" || s.length === 0)
|
|
||||||
return s;
|
|
||||||
// 1 + 2. Shared link/image and balanced-wrapper passes.
|
|
||||||
let out = stripWrappersAndLinks(s);
|
|
||||||
// 3. Trim leading/trailing decoration: whitespace, leftover markdown markers,
|
|
||||||
// and emoji (Extended_Pictographic plus the VS16 / ZWJ joiners, plus the
|
|
||||||
// regional-indicator range U+1F1E6–U+1F1FF for flag emoji, which are NOT
|
|
||||||
// Extended_Pictographic). The `u` flag enables the Unicode property escape.
|
|
||||||
// Anchored runs only — interior text and sentence punctuation are untouched.
|
|
||||||
const DECORATION = "[\\s*_~\\x60\\p{Extended_Pictographic}\\u{1F1E6}-\\u{1F1FF}\\u{FE0F}\\u{200D}]+";
|
|
||||||
out = out
|
|
||||||
.replace(new RegExp("^" + DECORATION, "u"), "")
|
|
||||||
.replace(new RegExp(DECORATION + "$", "u"), "");
|
|
||||||
// 4. Never normalize a locator down to nothing.
|
|
||||||
if (out.length === 0)
|
|
||||||
return s;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
@@ -1,631 +0,0 @@
|
|||||||
/**
|
|
||||||
* Pure, network-free transform primitives for a ProseMirror/TipTap document
|
|
||||||
* tree, plus one higher-level orchestration (commentsToFootnotes).
|
|
||||||
*
|
|
||||||
* A ProseMirror node here is a plain JSON object of the shape produced by
|
|
||||||
* Docmost: `{ type, attrs?, content?, text?, marks? }`. Children live in the
|
|
||||||
* `content` array; callouts, tables, lists all hold their children in
|
|
||||||
* `content`, so a single recursive walk reaches them all.
|
|
||||||
*
|
|
||||||
* Conventions (matching node-ops.ts):
|
|
||||||
* - functions that produce a new document deep-clone their input and return a
|
|
||||||
* `{ doc, ... }` object; the caller's objects are never mutated.
|
|
||||||
* - functions are defensively null-safe.
|
|
||||||
* - `marks` arrays are preserved verbatim when fragments are split/reordered.
|
|
||||||
*/
|
|
||||||
import { blockPlainText } from "./node-ops.js";
|
|
||||||
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
|
|
||||||
import { footnoteContentKey, makeFootnoteDefinition, generateFootnoteId, } from "./footnote-authoring.js";
|
|
||||||
export { canonicalizeFootnotes } from "./footnote-canonicalize.js";
|
|
||||||
/** Deep-clone a JSON-serializable value without mutating the original. */
|
|
||||||
function clone(value) {
|
|
||||||
if (typeof structuredClone === "function") {
|
|
||||||
return structuredClone(value);
|
|
||||||
}
|
|
||||||
// Fallback for environments without structuredClone.
|
|
||||||
return JSON.parse(JSON.stringify(value));
|
|
||||||
}
|
|
||||||
/** True if `value` is a non-null object (and not an array). */
|
|
||||||
function isObject(value) {
|
|
||||||
return value != null && typeof value === "object" && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Plain text of a node (re-export of node-ops' blockPlainText so transform
|
|
||||||
* authors have a single import surface). Recurses through nested content.
|
|
||||||
*/
|
|
||||||
export function blockText(node) {
|
|
||||||
return blockPlainText(node);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Depth-first visit of every node in the tree, including the root and the
|
|
||||||
* nested content of callouts, tables, lists, etc. `fn` is called once per node.
|
|
||||||
* Null-safe: a nullish or non-object node is ignored.
|
|
||||||
*/
|
|
||||||
export function walk(node, fn) {
|
|
||||||
if (!isObject(node))
|
|
||||||
return;
|
|
||||||
fn(node);
|
|
||||||
if (Array.isArray(node.content)) {
|
|
||||||
for (const child of node.content) {
|
|
||||||
walk(child, fn);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Find the FIRST node (depth-first) matching `predicate`, anywhere in the tree.
|
|
||||||
* Works even when the node carries no `attrs.id` (it searches the raw tree, not
|
|
||||||
* an id index). Returns the live node reference inside `doc` (NOT a clone), or
|
|
||||||
* null when nothing matches. Typical use: `getList(doc, n => n.type ===
|
|
||||||
* "orderedList")`.
|
|
||||||
*/
|
|
||||||
export function getList(doc, predicate) {
|
|
||||||
let found = null;
|
|
||||||
walk(doc, (node) => {
|
|
||||||
if (found == null && predicate(node)) {
|
|
||||||
found = node;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Textblocks that hold raw text but do NOT accept inline atom nodes. A
|
|
||||||
* `footnoteReference` is `group:"inline", atom:true`; `codeBlock` is
|
|
||||||
* `content:"text*"` (text only), so splicing a footnoteReference into it yields
|
|
||||||
* an invalid document. (paragraph/heading/detailsSummary are `inline*` and DO
|
|
||||||
* accept it; footnote definitions live inside a footnotesList which the
|
|
||||||
* footnote inserter excludes via `beforeBlock`.)
|
|
||||||
*/
|
|
||||||
const INLINE_ATOM_FORBIDDEN_BLOCKS = new Set(["codeBlock"]);
|
|
||||||
/**
|
|
||||||
* Footnote-notes subtrees the inline footnote inserter must never split into (at
|
|
||||||
* any depth): a `footnotesList` and the `footnoteDefinition`s it holds. Anchoring
|
|
||||||
* a reference inside one of these would later be dropped as an orphan by the
|
|
||||||
* canonicalizer, taking the existing definition's text with it.
|
|
||||||
*/
|
|
||||||
const FOOTNOTE_NOTES_SUBTREES = new Set([
|
|
||||||
"footnotesList",
|
|
||||||
"footnoteDefinition",
|
|
||||||
]);
|
|
||||||
/** True if `node` IS, or contains at any depth, a footnotesList/footnoteDefinition. */
|
|
||||||
function containsFootnoteNotes(node) {
|
|
||||||
if (!isObject(node))
|
|
||||||
return false;
|
|
||||||
if (FOOTNOTE_NOTES_SUBTREES.has(node.type))
|
|
||||||
return true;
|
|
||||||
if (Array.isArray(node.content)) {
|
|
||||||
return node.content.some((c) => containsFootnoteNotes(c));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Insert `marker` as a PLAIN (unmarked) text run right after the first
|
|
||||||
* occurrence of `anchor`.
|
|
||||||
*
|
|
||||||
* The text run that contains the END of the anchor is SPLIT at the anchor end,
|
|
||||||
* so all existing marks (links, bold, ...) on the surrounding text are
|
|
||||||
* preserved, while the inserted marker run carries NO marks. The marker is
|
|
||||||
* inserted as a leading-space-padded run (`" " + marker`) so it visually
|
|
||||||
* separates from the preceding word.
|
|
||||||
*
|
|
||||||
* The anchor is matched against the concatenated plain text of each top-level
|
|
||||||
* block (so an anchor that spans several text/mark runs still matches). The
|
|
||||||
* insertion happens inside the inline content array that holds the anchor's
|
|
||||||
* final character.
|
|
||||||
*
|
|
||||||
* Operates on a clone of `doc`; returns `{ doc, inserted }`. `inserted` is
|
|
||||||
* false when the anchor text was not found in any in-scope block.
|
|
||||||
*/
|
|
||||||
export function insertMarkerAfter(doc, anchor, marker, opts = {}) {
|
|
||||||
// A plain marker is a leading-space-padded unmarked text run.
|
|
||||||
return insertNodesAfterAnchor(doc, anchor, () => [{ type: "text", text: " " + marker }], opts);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Mark-safe insertion CORE: split the inline text run that holds the END of
|
|
||||||
* `anchor` (preserving the surrounding marks) and splice the nodes produced by
|
|
||||||
* `makeMiddle()` in at the split point. `insertMarkerAfter` (plain text marker)
|
|
||||||
* and `insertInlineFootnote` (a `footnoteReference` node) are both thin callers —
|
|
||||||
* the only difference is WHAT is inserted (a space-padded text run vs. a node
|
|
||||||
* that should hug the preceding word), which is exactly what `makeMiddle`
|
|
||||||
* decides. Operates on a clone; returns `{ doc, inserted }`.
|
|
||||||
*/
|
|
||||||
function insertNodesAfterAnchor(doc, anchor, makeMiddle, opts = {}) {
|
|
||||||
const out = clone(doc);
|
|
||||||
if (!isObject(out) || !Array.isArray(out.content) || !anchor) {
|
|
||||||
return { doc: out, inserted: false };
|
|
||||||
}
|
|
||||||
const limit = typeof opts.beforeBlock === "number"
|
|
||||||
? Math.min(opts.beforeBlock, out.content.length)
|
|
||||||
: out.content.length;
|
|
||||||
for (let b = 0; b < limit; b++) {
|
|
||||||
const block = out.content[b];
|
|
||||||
if (!isObject(block))
|
|
||||||
continue;
|
|
||||||
// Quick reject: skip blocks whose plain text cannot contain the anchor.
|
|
||||||
if (!blockPlainText(block).includes(anchor))
|
|
||||||
continue;
|
|
||||||
// Walk the inline content arrays inside this block, tracking a running
|
|
||||||
// character offset so we can locate the inline array + text run that holds
|
|
||||||
// the END of the anchor's first occurrence.
|
|
||||||
let inserted = false;
|
|
||||||
let offset = 0; // characters of plain text seen so far in this block
|
|
||||||
const anchorEnd = (() => blockPlainText(block).indexOf(anchor) + anchor.length)();
|
|
||||||
// Recurse into inline-bearing containers (paragraph, heading, table cell,
|
|
||||||
// callout child paragraphs, ...). We only split inside an array of inline
|
|
||||||
// nodes (text/inline atoms); the FIRST array whose cumulative range covers
|
|
||||||
// anchorEnd receives the split + marker.
|
|
||||||
const visit = (container) => {
|
|
||||||
if (inserted || !isObject(container) || !Array.isArray(container.content)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Skip a forbidden subtree entirely (e.g. footnotesList/footnoteDefinition):
|
|
||||||
// never split into it, but keep `offset` aligned for any sibling text after
|
|
||||||
// it within this block.
|
|
||||||
if (opts.skipSubtreeTypes && opts.skipSubtreeTypes.has(container.type)) {
|
|
||||||
offset += blockPlainText(container).length;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const inline = container.content;
|
|
||||||
// Detect whether this array is an inline array (contains text nodes).
|
|
||||||
const hasText = inline.some((n) => isObject(n) && n.type === "text");
|
|
||||||
if (hasText) {
|
|
||||||
// Refuse a textblock whose content spec cannot hold the inserted nodes
|
|
||||||
// (e.g. a codeBlock for an inline atom). Keep `offset` aligned for any
|
|
||||||
// sibling textblocks in this same block, then bail so the search falls
|
|
||||||
// through to the next candidate block.
|
|
||||||
if (opts.forbidBlockTypes && opts.forbidBlockTypes.has(container.type)) {
|
|
||||||
offset += blockPlainText(container).length;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (let i = 0; i < inline.length; i++) {
|
|
||||||
const n = inline[i];
|
|
||||||
const len = isObject(n) ? blockPlainText(n).length : 0;
|
|
||||||
const runStart = offset;
|
|
||||||
const runEnd = offset + len;
|
|
||||||
// The run that contains the anchor end (anchorEnd lands inside this
|
|
||||||
// run, i.e. runStart < anchorEnd <= runEnd) is the split point.
|
|
||||||
if (!inserted &&
|
|
||||||
isObject(n) &&
|
|
||||||
n.type === "text" &&
|
|
||||||
typeof n.text === "string" &&
|
|
||||||
anchorEnd > runStart &&
|
|
||||||
anchorEnd <= runEnd) {
|
|
||||||
const cut = anchorEnd - runStart; // split index within this text run
|
|
||||||
const before = n.text.slice(0, cut);
|
|
||||||
const after = n.text.slice(cut);
|
|
||||||
const marks = Array.isArray(n.marks) ? n.marks : [];
|
|
||||||
const parts = [];
|
|
||||||
if (before.length > 0) {
|
|
||||||
parts.push({ ...n, text: before, marks: [...marks] });
|
|
||||||
}
|
|
||||||
// The inserted nodes are caller-decided (a space-padded marker run,
|
|
||||||
// or a node that hugs the word). They carry no copied marks.
|
|
||||||
parts.push(...makeMiddle());
|
|
||||||
if (after.length > 0) {
|
|
||||||
parts.push({ ...n, text: after, marks: [...marks] });
|
|
||||||
}
|
|
||||||
inline.splice(i, 1, ...parts);
|
|
||||||
inserted = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
offset = runEnd;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
// Not an inline array: recurse into children (e.g. callout -> paragraph).
|
|
||||||
for (const child of inline) {
|
|
||||||
visit(child);
|
|
||||||
if (inserted)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
visit(block);
|
|
||||||
if (inserted) {
|
|
||||||
return { doc: out, inserted: true };
|
|
||||||
}
|
|
||||||
// If the block matched in plain text but we could not split (e.g. anchor
|
|
||||||
// lands inside an atom), fall through to the next block rather than failing.
|
|
||||||
}
|
|
||||||
return { doc: out, inserted: false };
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* In the disclaimer callout, replace a `[1]…[K]` range marker with `[1]…[n]`.
|
|
||||||
*
|
|
||||||
* Docmost translations use a callout that states the footnote range, e.g.
|
|
||||||
* "[1]…[5]". When the number of notes changes, this rewrites the trailing
|
|
||||||
* number of any `[1]…[K]` (or `[1]...[K]`, ASCII ellipsis) occurrence found in a
|
|
||||||
* callout's text nodes to `[1]…[n]`. Operates on a clone; returns
|
|
||||||
* `{ doc, changed }` where `changed` is the number of text nodes rewritten.
|
|
||||||
*/
|
|
||||||
export function setCalloutRange(doc, n) {
|
|
||||||
const out = clone(doc);
|
|
||||||
let changed = 0;
|
|
||||||
// Match "[1]" + (… or ...) + "[<digits>]"; rewrite the last number to n.
|
|
||||||
const rangeRe = /(\[1\]\s*(?:…|\.\.\.)\s*\[)\d+(\])/g;
|
|
||||||
walk(out, (node) => {
|
|
||||||
if (node.type === "callout") {
|
|
||||||
walk(node, (inner) => {
|
|
||||||
if (inner.type === "text" &&
|
|
||||||
typeof inner.text === "string" &&
|
|
||||||
rangeRe.test(inner.text)) {
|
|
||||||
rangeRe.lastIndex = 0;
|
|
||||||
inner.text = inner.text.replace(rangeRe, `$1${n}$2`);
|
|
||||||
changed++;
|
|
||||||
}
|
|
||||||
rangeRe.lastIndex = 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return { doc: out, changed };
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Generate a short random id for a new block's `attrs.id`. Docmost uses nanoid;
|
|
||||||
* a base36 random string is sufficient here (uniqueness within one document).
|
|
||||||
*/
|
|
||||||
function freshId() {
|
|
||||||
return (Math.random().toString(36).slice(2, 12) +
|
|
||||||
Math.random().toString(36).slice(2, 6));
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Wrap inline ProseMirror nodes in a list item:
|
|
||||||
* { type:"listItem", content:[{ type:"paragraph", attrs:{id}, content: inlineNodes }] }
|
|
||||||
* with a fresh random block id on the paragraph. The inline nodes are cloned so
|
|
||||||
* the result shares no references with the caller's input.
|
|
||||||
*/
|
|
||||||
export function noteItem(inlineNodes) {
|
|
||||||
const content = Array.isArray(inlineNodes) ? clone(inlineNodes) : [];
|
|
||||||
return {
|
|
||||||
type: "listItem",
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: "paragraph",
|
|
||||||
attrs: { id: freshId() },
|
|
||||||
content,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Wrap inline ProseMirror nodes in a real footnoteDefinition node keyed by id:
|
|
||||||
* { type:"footnoteDefinition", attrs:{id}, content:[{ type:"paragraph", content }] }
|
|
||||||
* (mirrors the editor-ext / docmost-schema FootnoteDefinition node).
|
|
||||||
*
|
|
||||||
* Built on the shared `makeFootnoteDefinition` factory (footnote-authoring.ts);
|
|
||||||
* the only extra is a fresh block id on the inner paragraph (Docmost stamps one,
|
|
||||||
* and the canonicalizer preserves attrs as-is). Single factory, one place to
|
|
||||||
* change the definition shape.
|
|
||||||
*/
|
|
||||||
export function footnoteDefinition(id, inlineNodes) {
|
|
||||||
const node = makeFootnoteDefinition(id, inlineNodes);
|
|
||||||
node.content[0].attrs = { id: freshId() };
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Replace every `[N]` body marker and `\u0000FN<i>\u0000` comment placeholder in
|
|
||||||
* an inline content array with a real `footnoteReference` node, in reading
|
|
||||||
* order. `onMarker` is called for each replaced marker (with the original `[N]`
|
|
||||||
* number or the placeholder index) and returns the fresh footnote id to attach
|
|
||||||
* to the inserted node. Mutates `inline` in place.
|
|
||||||
*/
|
|
||||||
function replaceMarkersWithReferences(inline, onMarker) {
|
|
||||||
const re = /\[(\d+)\]|\u0000FN(\d+)\u0000/g;
|
|
||||||
for (let i = 0; i < inline.length; i++) {
|
|
||||||
const n = inline[i];
|
|
||||||
if (!isObject(n) || n.type !== "text" || typeof n.text !== "string") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!re.test(n.text))
|
|
||||||
continue;
|
|
||||||
re.lastIndex = 0;
|
|
||||||
const marks = Array.isArray(n.marks) ? n.marks : [];
|
|
||||||
const parts = [];
|
|
||||||
let last = 0;
|
|
||||||
let m;
|
|
||||||
while ((m = re.exec(n.text)) !== null) {
|
|
||||||
if (m.index > last) {
|
|
||||||
parts.push({ ...n, text: n.text.slice(last, m.index), marks: [...marks] });
|
|
||||||
}
|
|
||||||
const oldNum = m[1] != null ? Number(m[1]) : undefined;
|
|
||||||
const phIdx = m[2] != null ? Number(m[2]) : undefined;
|
|
||||||
const fnId = onMarker({ oldNum, phIdx });
|
|
||||||
parts.push({ type: "footnoteReference", attrs: { id: fnId } });
|
|
||||||
last = m.index + m[0].length;
|
|
||||||
}
|
|
||||||
if (last < n.text.length) {
|
|
||||||
parts.push({ ...n, text: n.text.slice(last), marks: [...marks] });
|
|
||||||
}
|
|
||||||
// Drop any zero-length text runs the slicing may have produced.
|
|
||||||
const cleaned = parts.filter((p) => p.type !== "text" || (typeof p.text === "string" && p.text.length > 0));
|
|
||||||
inline.splice(i, 1, ...cleaned);
|
|
||||||
i += cleaned.length - 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Convert a comment's markdown (e.g. `**Lead.** body...`) into inline
|
|
||||||
* ProseMirror nodes.
|
|
||||||
*
|
|
||||||
* A leading `комментарий: ` (case-insensitive) or `N. ` numeric prefix is
|
|
||||||
* stripped first. Then a minimal bold-split is applied: a leading
|
|
||||||
* `**bold lead**` run becomes a text node with a bold mark, and the remainder
|
|
||||||
* becomes a plain text node. This keeps the conversion synchronous (the
|
|
||||||
* transform sandbox runs synchronously) and dependency-free; the existing
|
|
||||||
* async markdownToProseMirror is intentionally NOT used here.
|
|
||||||
*/
|
|
||||||
export function mdToInlineNodes(markdown) {
|
|
||||||
let md = typeof markdown === "string" ? markdown : "";
|
|
||||||
// Strip a leading "комментарий: " prefix (case-insensitive) or a "N. " prefix.
|
|
||||||
md = md.replace(/^\s*комментарий\s*:\s*/i, "");
|
|
||||||
md = md.replace(/^\s*\d+\.\s+/, "");
|
|
||||||
md = md.trim();
|
|
||||||
if (md === "")
|
|
||||||
return [];
|
|
||||||
const nodes = [];
|
|
||||||
// Leading bold lead: **...** at the very start.
|
|
||||||
const leadMatch = /^\*\*([^*]+)\*\*\s*/.exec(md);
|
|
||||||
if (leadMatch) {
|
|
||||||
const leadText = leadMatch[1];
|
|
||||||
nodes.push({
|
|
||||||
type: "text",
|
|
||||||
text: leadText,
|
|
||||||
marks: [{ type: "bold" }],
|
|
||||||
});
|
|
||||||
const rest = md.slice(leadMatch[0].length);
|
|
||||||
if (rest.length > 0) {
|
|
||||||
// Preserve the separating space that followed the bold lead.
|
|
||||||
const sep = /^\*\*[^*]+\*\*(\s*)/.exec(md);
|
|
||||||
const spacing = sep ? sep[1] : "";
|
|
||||||
nodes.push({ type: "text", text: spacing + rest });
|
|
||||||
}
|
|
||||||
return nodes;
|
|
||||||
}
|
|
||||||
// No bold lead: emit the whole thing as a single plain text node, with any
|
|
||||||
// remaining **bold** spans split out inline.
|
|
||||||
return splitInlineBold(md);
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Split a string with inline `**bold**` spans into text nodes, bolding the
|
|
||||||
* spans. Used as the no-lead fallback in mdToInlineNodes.
|
|
||||||
*/
|
|
||||||
function splitInlineBold(text) {
|
|
||||||
const nodes = [];
|
|
||||||
const re = /\*\*([^*]+)\*\*/g;
|
|
||||||
let last = 0;
|
|
||||||
let m;
|
|
||||||
while ((m = re.exec(text)) !== null) {
|
|
||||||
if (m.index > last) {
|
|
||||||
nodes.push({ type: "text", text: text.slice(last, m.index) });
|
|
||||||
}
|
|
||||||
nodes.push({ type: "text", text: m[1], marks: [{ type: "bold" }] });
|
|
||||||
last = m.index + m[0].length;
|
|
||||||
}
|
|
||||||
if (last < text.length) {
|
|
||||||
nodes.push({ type: "text", text: text.slice(last) });
|
|
||||||
}
|
|
||||||
return nodes.length > 0 ? nodes : [{ type: "text", text }];
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Turn inline comments into numbered footnotes.
|
|
||||||
*
|
|
||||||
* For each inline comment that carries a `selection`:
|
|
||||||
* 1. insert a placeholder marker (a NUL-delimited "\u0000FN<i>\u0000"
|
|
||||||
* sentinel) right after the selection text in the BODY (before the
|
|
||||||
* notes heading);
|
|
||||||
* 2. build a note list item from the comment's markdown content.
|
|
||||||
*
|
|
||||||
* Then RENUMBER every footnote marker in the body by reading order: existing
|
|
||||||
* `[N]` markers and the new "\u0000FN<i>\u0000" placeholders are both replaced by a
|
|
||||||
* sequential `[seq]`, and the notes orderedList is reordered so each note lines
|
|
||||||
* up with its marker's reading-order position. Finally the disclaimer callout
|
|
||||||
* range is synced to the new note count.
|
|
||||||
*
|
|
||||||
* Returns `{ doc, consumed }` where `consumed` lists the ids of comments that
|
|
||||||
* were successfully anchored (their selection was found and a placeholder
|
|
||||||
* inserted). Operates on a clone of `doc`.
|
|
||||||
*/
|
|
||||||
export function commentsToFootnotes(doc, comments, opts = {}) {
|
|
||||||
let working = clone(doc);
|
|
||||||
const notesHeading = opts.notesHeading ?? "Примечания переводчика";
|
|
||||||
const top = Array.isArray(working.content) ? working.content : [];
|
|
||||||
const notesIdx = top.findIndex((n) => isObject(n) && n.type === "heading" && blockText(n).trim() === notesHeading);
|
|
||||||
if (notesIdx < 0) {
|
|
||||||
throw new Error(`heading "${notesHeading}" not found`);
|
|
||||||
}
|
|
||||||
// The notes orderedList lives at or after the heading.
|
|
||||||
const notesList = top
|
|
||||||
.slice(notesIdx)
|
|
||||||
.find((n) => isObject(n) && n.type === "orderedList");
|
|
||||||
if (!notesList) {
|
|
||||||
throw new Error("notes orderedList not found");
|
|
||||||
}
|
|
||||||
const consumed = [];
|
|
||||||
const noteInlineByPh = new Map();
|
|
||||||
(Array.isArray(comments) ? comments : []).forEach((c, i) => {
|
|
||||||
if (!c || !c.selection)
|
|
||||||
return;
|
|
||||||
// Collision-proof sentinel delimited by NUL control chars, which never occur
|
|
||||||
// in real Docmost prose - so the marker regex cannot mistake any body text
|
|
||||||
// (e.g. "Press F1 for help", model "FN2") for a placeholder. The NUL is
|
|
||||||
// transient: the placeholder is inserted here and replaced by a
|
|
||||||
// footnoteReference node below; it never persists in a returned document.
|
|
||||||
const ph = `\u0000FN${i}\u0000`;
|
|
||||||
// insertMarkerAfter returns a NEW cloned doc; reassign `working`.
|
|
||||||
const r = insertMarkerAfter(working, c.selection.trimEnd(), ph, {
|
|
||||||
beforeBlock: notesIdx,
|
|
||||||
});
|
|
||||||
if (!r.inserted)
|
|
||||||
return;
|
|
||||||
working = r.doc;
|
|
||||||
noteInlineByPh.set(ph, mdToInlineNodes(c.content));
|
|
||||||
consumed.push(c.id);
|
|
||||||
});
|
|
||||||
// Re-resolve references into the (possibly re-cloned) working doc.
|
|
||||||
const top2 = Array.isArray(working.content) ? working.content : [];
|
|
||||||
const notesIdx2 = top2.findIndex((n) => isObject(n) && n.type === "heading" && blockText(n).trim() === notesHeading);
|
|
||||||
const oldListIndex = top2.findIndex((n) => isObject(n) && n.type === "orderedList");
|
|
||||||
const notesList2 = oldListIndex >= 0 ? top2[oldListIndex] : null;
|
|
||||||
if (!notesList2) {
|
|
||||||
throw new Error("notes orderedList not found");
|
|
||||||
}
|
|
||||||
// Inline content of each existing note (listItem -> paragraph -> inline).
|
|
||||||
const oldNoteInline = (Array.isArray(notesList2.content)
|
|
||||||
? notesList2.content
|
|
||||||
: []).map((item) => {
|
|
||||||
const para = isObject(item) && Array.isArray(item.content)
|
|
||||||
? item.content.find((c) => isObject(c) && c.type === "paragraph")
|
|
||||||
: null;
|
|
||||||
return para && Array.isArray(para.content) ? para.content : [];
|
|
||||||
});
|
|
||||||
// Walk the body in reading order, turning each "[N]" / placeholder marker into
|
|
||||||
// a real footnoteReference node and collecting its definition inline content.
|
|
||||||
const definitions = [];
|
|
||||||
const disclaimerRangeRe = /(\[1\]\s*(?:…|\.\.\.)\s*\[)\d+(\])/;
|
|
||||||
// Recursively visit inline arrays inside a block (paragraph, heading, callout
|
|
||||||
// child paragraphs, table cells, ...), preserving document reading order.
|
|
||||||
const visitInlineArrays = (container) => {
|
|
||||||
if (!isObject(container) || !Array.isArray(container.content))
|
|
||||||
return;
|
|
||||||
const hasText = container.content.some((n) => isObject(n) && n.type === "text");
|
|
||||||
if (hasText) {
|
|
||||||
replaceMarkersWithReferences(container.content, ({ oldNum, phIdx }) => {
|
|
||||||
const fnId = freshId();
|
|
||||||
if (oldNum != null) {
|
|
||||||
const inline = oldNoteInline[oldNum - 1];
|
|
||||||
// Every existing body marker MUST map to a real note. An out-of-range
|
|
||||||
// marker means the document is internally inconsistent; fail loudly.
|
|
||||||
if (inline === undefined) {
|
|
||||||
throw new Error(`footnote [${oldNum}] has no matching note (notes list has ${oldNoteInline.length} items); document is inconsistent`);
|
|
||||||
}
|
|
||||||
definitions.push(footnoteDefinition(fnId, inline));
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const inline = noteInlineByPh.get(`\u0000FN${phIdx}\u0000`) || [];
|
|
||||||
definitions.push(footnoteDefinition(fnId, inline));
|
|
||||||
}
|
|
||||||
return fnId;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
for (const child of container.content)
|
|
||||||
visitInlineArrays(child);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const notesBoundary = notesIdx2 >= 0 ? notesIdx2 : oldListIndex;
|
|
||||||
for (let i = 0; i < notesBoundary; i++) {
|
|
||||||
// Skip ONLY the disclaimer callout: its "[1]...[K]" range is NOT a footnote
|
|
||||||
// marker and is synced separately by setCalloutRange.
|
|
||||||
if (isObject(top2[i]) &&
|
|
||||||
top2[i].type === "callout" &&
|
|
||||||
disclaimerRangeRe.test(blockText(top2[i]))) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
visitInlineArrays(top2[i]);
|
|
||||||
}
|
|
||||||
// Replace the old orderedList with a real footnotesList of the collected
|
|
||||||
// definitions (reading order). If there are no definitions, drop the list.
|
|
||||||
if (definitions.length > 0) {
|
|
||||||
top2[oldListIndex] = {
|
|
||||||
type: "footnotesList",
|
|
||||||
content: definitions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
top2.splice(oldListIndex, 1);
|
|
||||||
}
|
|
||||||
// Sync the disclaimer callout range to the new note count.
|
|
||||||
const synced = setCalloutRange(working, definitions.length);
|
|
||||||
return { doc: synced.doc, consumed };
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* AUTHOR-INLINE footnote insertion. The caller supplies WHERE (anchorText) and
|
|
||||||
* WHAT (markdown text); numbering and the bottom list are derived server-side by
|
|
||||||
* `canonicalizeFootnotes`. The caller never sees or edits `footnotesList`, never
|
|
||||||
* assigns a number, and cannot desync — orphans / out-of-order lists / raw
|
|
||||||
* `[^id]` markdown are structurally impossible.
|
|
||||||
*
|
|
||||||
* Content DEDUP (#3 in the issue): if an existing definition has the SAME
|
|
||||||
* normalized content key, its id is REUSED (the new reference points at it: one
|
|
||||||
* number, one definition, several references). Otherwise a fresh uuid id is
|
|
||||||
* minted and a new definition added. Conservative — only an exact content match
|
|
||||||
* merges.
|
|
||||||
*
|
|
||||||
* Mechanics: the `footnoteReference` node is inserted DIRECTLY at the anchor via
|
|
||||||
* the same mark-safe split as `insertMarkerAfter` (the shared
|
|
||||||
* `insertNodesAfterAnchor` core), so it hugs the preceding word with no text
|
|
||||||
* sentinel round-trip. The whole document is then canonicalized.
|
|
||||||
*
|
|
||||||
* Operates on a clone of `doc`. When the anchor is not found, returns the input
|
|
||||||
* unchanged with `inserted:false`.
|
|
||||||
*/
|
|
||||||
export function insertInlineFootnote(doc, opts) {
|
|
||||||
const inline = mdToInlineNodes(opts.text ?? "");
|
|
||||||
// footnoteContentKey only reads `.content`, so key off the inline array
|
|
||||||
// directly instead of building a throwaway definition node.
|
|
||||||
const key = footnoteContentKey({ content: inline });
|
|
||||||
// Content dedup: reuse an existing definition's id when its key matches.
|
|
||||||
let footnoteId = null;
|
|
||||||
let reused = false;
|
|
||||||
if (key !== "") {
|
|
||||||
walk(doc, (n) => {
|
|
||||||
if (footnoteId == null &&
|
|
||||||
isObject(n) &&
|
|
||||||
n.type === "footnoteDefinition" &&
|
|
||||||
n.attrs &&
|
|
||||||
typeof n.attrs.id === "string" &&
|
|
||||||
n.attrs.id !== "" &&
|
|
||||||
footnoteContentKey(n) === key) {
|
|
||||||
footnoteId = n.attrs.id;
|
|
||||||
reused = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (footnoteId == null)
|
|
||||||
footnoteId = generateFootnoteId();
|
|
||||||
// Insert the footnoteReference node directly after the anchor (mark-safe
|
|
||||||
// split); it hugs the preceding word with no leading space. Two guards keep the
|
|
||||||
// inline atom out of the notes section and out of blocks that cannot hold it:
|
|
||||||
// - beforeBlock bounds the search to the BODY, before the first top-level block
|
|
||||||
// that IS or CONTAINS (at any depth) a footnotesList/footnoteDefinition — so
|
|
||||||
// a NESTED list or a bare definition also bounds the search, not just a
|
|
||||||
// top-level list;
|
|
||||||
// - skipSubtreeTypes refuses to descend into any footnotesList/footnoteDefinition
|
|
||||||
// subtree, so a reference is never glued inside an existing definition (which
|
|
||||||
// the canonicalizer would then drop as an orphan, losing that definition's
|
|
||||||
// prose); and forbidBlockTypes refuses codeBlocks (an inline atom there is a
|
|
||||||
// schema-invalid doc; insert_footnote skips validateDocStructure).
|
|
||||||
// When the only anchor match is in such a place, the insert is refused and the
|
|
||||||
// write aborts cleanly (inserted:false) instead of destroying content.
|
|
||||||
const boundaryIdx = Array.isArray(doc?.content)
|
|
||||||
? doc.content.findIndex((n) => containsFootnoteNotes(n))
|
|
||||||
: -1;
|
|
||||||
const r = insertNodesAfterAnchor(doc, (opts.anchorText ?? "").trimEnd(), () => [{ type: "footnoteReference", attrs: { id: footnoteId } }], {
|
|
||||||
...(boundaryIdx >= 0 ? { beforeBlock: boundaryIdx } : {}),
|
|
||||||
forbidBlockTypes: INLINE_ATOM_FORBIDDEN_BLOCKS,
|
|
||||||
skipSubtreeTypes: FOOTNOTE_NOTES_SUBTREES,
|
|
||||||
});
|
|
||||||
if (!r.inserted) {
|
|
||||||
return { doc: clone(doc), inserted: false, footnoteId, reused };
|
|
||||||
}
|
|
||||||
let working = r.doc;
|
|
||||||
// Add a NEW definition (canonicalize will order/place it); a reused id needs
|
|
||||||
// no new definition (the existing one is shared).
|
|
||||||
if (!reused) {
|
|
||||||
appendDefinition(working, makeFootnoteDefinition(footnoteId, inline));
|
|
||||||
}
|
|
||||||
// Derive numbering + the single bottom list deterministically.
|
|
||||||
working = canonicalizeFootnotes(working);
|
|
||||||
return { doc: working, inserted: true, footnoteId, reused };
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
* Append a definition node so the canonicalizer can order/place it: into the
|
|
||||||
* first existing footnotesList, or a new trailing list when none exists.
|
|
||||||
*/
|
|
||||||
function appendDefinition(doc, defNode) {
|
|
||||||
const existingList = getList(doc, (n) => isObject(n) && n.type === "footnotesList");
|
|
||||||
if (existingList && Array.isArray(existingList.content)) {
|
|
||||||
existingList.content.push(defNode);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (Array.isArray(doc.content)) {
|
|
||||||
doc.content.push({ type: "footnotesList", content: [defNode] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
/**
|
|
||||||
* Pure tree-builder: turn a flat array of sidebar-style page nodes (as produced
|
|
||||||
* by `enumerateSpacePages`) into a nested tree.
|
|
||||||
*
|
|
||||||
* Input: a flat array of nodes. Each node is expected to carry at least
|
|
||||||
* { id, slugId, title, position, parentPageId } (extra fields are ignored).
|
|
||||||
*
|
|
||||||
* Output: an array of ROOT nodes, each shaped as
|
|
||||||
* { id, slugId, title, children? }
|
|
||||||
* where `children` is the array of child nodes (same shape, recursively). The
|
|
||||||
* `children` key is OMITTED entirely when a node has no children — consistent
|
|
||||||
* with how `filterPage` omits an empty `subpages` array — to keep the payload
|
|
||||||
* lean (nesting alone conveys the structure; parentPageId/position/hasChildren
|
|
||||||
* are intentionally dropped from the output).
|
|
||||||
*
|
|
||||||
* Linking rule: a node is attached as a child of `parentPageId` only when that
|
|
||||||
* parent id is actually present in the input. Otherwise — including a null /
|
|
||||||
* undefined `parentPageId`, or a parent that was capped out of the bounded walk
|
|
||||||
* — the node is promoted to a ROOT. So "orphan whose parent is missing" is the
|
|
||||||
* defined behavior: it surfaces at the top level rather than disappearing.
|
|
||||||
*
|
|
||||||
* Ordering rule: the roots array and every `children` array are sorted ascending
|
|
||||||
* by the node's `position` string. The comparator is a plain code-unit (byte)
|
|
||||||
* comparison — NOT localeCompare — because the server orders sidebar pages by
|
|
||||||
* `collate "C"` (byte order), which a raw `<`/`>` compare approximates for the
|
|
||||||
* fractional-index ASCII keys (e.g. "a0", "a1"). Nodes with a missing/undefined
|
|
||||||
* `position` sort last.
|
|
||||||
*
|
|
||||||
* Pure: no I/O, no network, deterministic.
|
|
||||||
*/
|
|
||||||
export function buildPageTree(nodes) {
|
|
||||||
// Map id -> output node. Build the lean output shape up front.
|
|
||||||
const byId = new Map();
|
|
||||||
// Preserve the original position string for sorting (kept off the output).
|
|
||||||
const positionById = new Map();
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (!node || typeof node !== "object" || !node.id)
|
|
||||||
continue;
|
|
||||||
// Defensive against duplicate ids: last one wins (overwrites the earlier
|
|
||||||
// entry). `enumerateSpacePages` already dedups, so this is belt-and-braces.
|
|
||||||
byId.set(node.id, {
|
|
||||||
id: node.id,
|
|
||||||
slugId: node.slugId,
|
|
||||||
title: node.title,
|
|
||||||
});
|
|
||||||
positionById.set(node.id, node.position);
|
|
||||||
}
|
|
||||||
// Stable comparator on the position string: code-unit order, missing last.
|
|
||||||
const byPosition = (aId, bId) => {
|
|
||||||
const a = positionById.get(aId);
|
|
||||||
const b = positionById.get(bId);
|
|
||||||
if (a === undefined || a === null)
|
|
||||||
return b === undefined || b === null ? 0 : 1;
|
|
||||||
if (b === undefined || b === null)
|
|
||||||
return -1;
|
|
||||||
if (a < b)
|
|
||||||
return -1;
|
|
||||||
if (a > b)
|
|
||||||
return 1;
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
const roots = [];
|
|
||||||
const childrenIdsByParent = new Map();
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (!node || typeof node !== "object" || !node.id)
|
|
||||||
continue;
|
|
||||||
const parentId = node.parentPageId;
|
|
||||||
// Child only when the parent is actually present in the input; otherwise
|
|
||||||
// (null/undefined parent, or parent capped out of the walk) -> root.
|
|
||||||
if (parentId && byId.has(parentId)) {
|
|
||||||
const list = childrenIdsByParent.get(parentId) ?? [];
|
|
||||||
list.push(node.id);
|
|
||||||
childrenIdsByParent.set(parentId, list);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
roots.push(node.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Attach sorted children arrays to each parent, omitting empty ones.
|
|
||||||
for (const [parentId, childIds] of childrenIdsByParent) {
|
|
||||||
const parent = byId.get(parentId);
|
|
||||||
if (!parent)
|
|
||||||
continue;
|
|
||||||
childIds.sort(byPosition);
|
|
||||||
parent.children = childIds.map((id) => byId.get(id));
|
|
||||||
}
|
|
||||||
roots.sort(byPosition);
|
|
||||||
return roots.map((id) => byId.get(id));
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
#!/usr/bin/env node
|
|
||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
||||||
import { createDocmostMcpServer } from "./index.js";
|
|
||||||
// Standalone stdio entrypoint. This restores the original behavior of the
|
|
||||||
// package when run as a CLI (`docmost-mcp`): it reads credentials from the
|
|
||||||
// environment and serves the MCP protocol over stdin/stdout. The factory in
|
|
||||||
// index.ts stays side-effect-free; all the process/transport lifecycle lives
|
|
||||||
// here.
|
|
||||||
const API_URL = process.env.DOCMOST_API_URL;
|
|
||||||
const EMAIL = process.env.DOCMOST_EMAIL;
|
|
||||||
const PASSWORD = process.env.DOCMOST_PASSWORD;
|
|
||||||
if (!API_URL || !EMAIL || !PASSWORD) {
|
|
||||||
console.error("Error: DOCMOST_API_URL, DOCMOST_EMAIL, and DOCMOST_PASSWORD environment variables are required.");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
async function run() {
|
|
||||||
// Global safety nets so a stray rejection/exception cannot silently kill
|
|
||||||
// the stdio server. Per-tool errors still flow through the SDK and are not
|
|
||||||
// affected by these handlers; these only catch errors raised OUTSIDE a tool
|
|
||||||
// call (e.g. a transient ws/collab socket "error" event). Such errors must
|
|
||||||
// NOT tear down the whole stdio server, so we log only and keep running.
|
|
||||||
// Genuine startup failures are still fatal via run().catch(...) below.
|
|
||||||
process.on("unhandledRejection", (reason) => {
|
|
||||||
console.error("Unhandled promise rejection:", reason);
|
|
||||||
});
|
|
||||||
process.on("uncaughtException", (error) => {
|
|
||||||
console.error("Uncaught exception:", error);
|
|
||||||
});
|
|
||||||
const server = createDocmostMcpServer({
|
|
||||||
apiUrl: API_URL,
|
|
||||||
email: EMAIL,
|
|
||||||
password: PASSWORD,
|
|
||||||
});
|
|
||||||
const transport = new StdioServerTransport();
|
|
||||||
await server.connect(transport);
|
|
||||||
}
|
|
||||||
run().catch((error) => {
|
|
||||||
console.error("Fatal error running server:", error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
// Zod-agnostic shared tool-spec registry consumed by BOTH the zod-v3 MCP server
|
|
||||||
// (packages/mcp/src/index.ts) and the zod-v4 in-app AI-SDK service
|
|
||||||
// (apps/server/src/core/ai-chat/tools/ai-chat-tools.service.ts). Intentionally
|
|
||||||
// imports NO zod: each consumer passes its OWN zod namespace into buildShape,
|
|
||||||
// because the two packages are on different zod majors (v3 here, v4 in the
|
|
||||||
// server) and a zod schema object built with one major cannot be reused by the
|
|
||||||
// other. The builders below only touch z.string()/.min()/.optional()/.describe(),
|
|
||||||
// z.array() and z.object() — API identical across v3 and v4 — so a single
|
|
||||||
// builder works with either namespace.
|
|
||||||
//
|
|
||||||
// Only tools whose snake_case/camelCase name, input schema AND model-facing
|
|
||||||
// description are genuinely identical across both layers live here. Tools that
|
|
||||||
// diverge on purpose (security guardrails, tuned UX, "Reversible" framing on
|
|
||||||
// some write tools, different limits, hybrid-RRF search, etc.) stay defined
|
|
||||||
// per-layer and are NOT represented here.
|
|
||||||
export const SHARED_TOOL_SPECS = {
|
|
||||||
// --- no-argument read tools ---
|
|
||||||
getWorkspace: {
|
|
||||||
mcpName: 'get_workspace',
|
|
||||||
inAppKey: 'getWorkspace',
|
|
||||||
description: 'Fetch metadata about the current workspace (name, settings).',
|
|
||||||
},
|
|
||||||
listSpaces: {
|
|
||||||
mcpName: 'list_spaces',
|
|
||||||
inAppKey: 'listSpaces',
|
|
||||||
description: 'List the spaces the current user can access. Returns the array of ' +
|
|
||||||
'spaces (id, name, slug, ...).',
|
|
||||||
},
|
|
||||||
listShares: {
|
|
||||||
mcpName: 'list_shares',
|
|
||||||
inAppKey: 'listShares',
|
|
||||||
description: 'List all public shares in the workspace with page titles and public URLs.',
|
|
||||||
},
|
|
||||||
// --- single-pageId read tools ---
|
|
||||||
getPageJson: {
|
|
||||||
mcpName: 'get_page_json',
|
|
||||||
inAppKey: 'getPageJson',
|
|
||||||
description: 'Get page details with the raw ProseMirror JSON content (lossless: ' +
|
|
||||||
'includes block ids, callouts, tables, link/image attributes) plus the ' +
|
|
||||||
'slugId used in URLs. Use the block ids it returns to make precise ' +
|
|
||||||
'structural edits or surgical text edits without resending the page.',
|
|
||||||
buildShape: (z) => ({
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
getOutline: {
|
|
||||||
mcpName: 'get_outline',
|
|
||||||
inAppKey: 'getOutline',
|
|
||||||
description: "Return a COMPACT outline of a page's top-level blocks ({index, type, " +
|
|
||||||
'id, level, firstText}; tables add rows/cols/header; lists add item ' +
|
|
||||||
'count) WITHOUT the full document body. Use it to locate sections/tables ' +
|
|
||||||
'and grab block ids cheaply before fetching, patching or inserting ' +
|
|
||||||
'individual blocks.',
|
|
||||||
buildShape: (z) => ({
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
// --- two-id read tool ---
|
|
||||||
getNode: {
|
|
||||||
mcpName: 'get_node',
|
|
||||||
inAppKey: 'getNode',
|
|
||||||
description: "Fetch a single node's full ProseMirror subtree (lossless) without " +
|
|
||||||
'pulling the whole document. `nodeId` is a block id from the page ' +
|
|
||||||
'outline or page-JSON view (works for headings/paragraphs/callouts/images), OR ' +
|
|
||||||
'`#<index>` to fetch a top-level block by its outline index — use the ' +
|
|
||||||
'`#<index>` form for tables/rows/cells, which carry no id.',
|
|
||||||
buildShape: (z) => ({
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
nodeId: z.string().min(1),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
// --- node delete ---
|
|
||||||
deleteNode: {
|
|
||||||
mcpName: 'delete_node',
|
|
||||||
inAppKey: 'deleteNode',
|
|
||||||
description: 'Remove a single block by its attrs.id (from the page-JSON view) WITHOUT ' +
|
|
||||||
'resending the whole document.',
|
|
||||||
buildShape: (z) => ({
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
nodeId: z.string().min(1),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
// --- single-block structural write (patch / insert) ---
|
|
||||||
//
|
|
||||||
// CANONICAL description merges both layers: the MCP copy's "WITHOUT resending
|
|
||||||
// the whole document" + "cheaper/safer than a full-document replace" guidance
|
|
||||||
// AND the in-app copy's "keeps the same node id" + "Reversible via page
|
|
||||||
// history" framing — nothing either side conveyed is dropped. Sibling tools are
|
|
||||||
// named in transport-neutral prose ("the page-JSON view", "a full-document
|
|
||||||
// replace") to match the rest of the registry, since the two layers expose
|
|
||||||
// those siblings under different (snake_case vs camelCase) identifiers.
|
|
||||||
patchNode: {
|
|
||||||
mcpName: 'patch_node',
|
|
||||||
inAppKey: 'patchNode',
|
|
||||||
description: 'Replace a single content block identified by its attrs.id with a new ' +
|
|
||||||
'ProseMirror node, WITHOUT resending the whole document; the replacement ' +
|
|
||||||
'keeps the same node id. Get the block id from the page-JSON view, then ' +
|
|
||||||
'pass a ProseMirror node to put in its place. Example node: a paragraph ' +
|
|
||||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
|
|
||||||
'heading {"type":"heading","attrs":{"level":2},"content":' +
|
|
||||||
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
|
|
||||||
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
|
|
||||||
'JSON object or a JSON string (both accepted). Cheaper and safer than ' +
|
|
||||||
'replacing the whole document for one-block structural edits. Reversible: ' +
|
|
||||||
'the previous version is kept in page history.',
|
|
||||||
buildShape: (z) => ({
|
|
||||||
pageId: z.string().min(1).describe('ID of the page containing the block'),
|
|
||||||
nodeId: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.describe('attrs.id of the block to replace (from the page outline or ' +
|
|
||||||
'page-JSON view)'),
|
|
||||||
node: z
|
|
||||||
.any()
|
|
||||||
.describe('ProseMirror node to put in place of the node with this id, e.g. ' +
|
|
||||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
|
|
||||||
'JSON object or JSON string both accepted.'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
insertNode: {
|
|
||||||
mcpName: 'insert_node',
|
|
||||||
inAppKey: 'insertNode',
|
|
||||||
description: 'Insert a block before/after another block (by attrs.id or anchor text) ' +
|
|
||||||
'or append it at the end (top level). For before/after you MUST provide ' +
|
|
||||||
'EXACTLY ONE of anchorNodeId or anchorText. Get anchor block ids from the ' +
|
|
||||||
'page-JSON view. Avoids resending the whole document. Can also insert ' +
|
|
||||||
'table structure: to add a tableRow, pass a tableRow node with position ' +
|
|
||||||
'before/after and anchor INSIDE the target table — anchorNodeId of any ' +
|
|
||||||
'block/cell in it, or anchorText matching the table; to add a ' +
|
|
||||||
'tableCell/tableHeader, use anchorNodeId of a block inside the target row ' +
|
|
||||||
'(anchorText only resolves top-level blocks, so it cannot target a row). ' +
|
|
||||||
"`anchorText` is matched against the block's literal rendered plain text " +
|
|
||||||
'(no markdown); markdown/emoji are tolerated as a fallback; prefer plain ' +
|
|
||||||
'text or anchorNodeId. Note: append is top-level only and rejects ' +
|
|
||||||
'structural table nodes. Example node: a paragraph ' +
|
|
||||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
|
|
||||||
'heading {"type":"heading","attrs":{"level":2},"content":' +
|
|
||||||
'[{"type":"text","text":"Title"}]}. Bold is a mark: ' +
|
|
||||||
'{"type":"text","text":"x","marks":[{"type":"bold"}]}. The node may be a ' +
|
|
||||||
'JSON object or a JSON string (both accepted). Reversible via page history.',
|
|
||||||
buildShape: (z) => ({
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
node: z
|
|
||||||
.any()
|
|
||||||
.describe('ProseMirror node to insert, e.g. ' +
|
|
||||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}. ' +
|
|
||||||
'JSON object or JSON string both accepted.'),
|
|
||||||
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), matched against the " +
|
|
||||||
"block's literal rendered plain text (no markdown). Markdown/emoji " +
|
|
||||||
'are tolerated as a fallback; prefer plain text or anchorNodeId.'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
// --- share management ---
|
|
||||||
unsharePage: {
|
|
||||||
mcpName: 'unshare_page',
|
|
||||||
inAppKey: 'unsharePage',
|
|
||||||
description: 'Remove the public share of a page (revokes the public URL).',
|
|
||||||
buildShape: (z) => ({
|
|
||||||
pageId: z.string().min(1).describe('ID of the page to unshare'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
// --- version history ---
|
|
||||||
diffPageVersions: {
|
|
||||||
mcpName: 'diff_page_versions',
|
|
||||||
inAppKey: 'diffPageVersions',
|
|
||||||
description: 'Diff two versions of a page and return a Docmost-equivalent change set ' +
|
|
||||||
'(inserted/deleted text, integrity counts for images/links/tables/' +
|
|
||||||
'callouts/footnote markers, and a human-readable markdown summary). ' +
|
|
||||||
"`from`/`to` each accept a historyId, or null/'current' for the page's " +
|
|
||||||
'current content (defaults: from=current, to=current — pass a historyId ' +
|
|
||||||
'from the page-history list to compare against the live page).',
|
|
||||||
buildShape: (z) => ({
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
from: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("historyId, or 'current'/omit for current content"),
|
|
||||||
to: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("historyId, or 'current'/omit for current content"),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
listPageHistory: {
|
|
||||||
mcpName: 'list_page_history',
|
|
||||||
inAppKey: 'listPageHistory',
|
|
||||||
description: "List a page's saved versions (Docmost auto-snapshots on every save), " +
|
|
||||||
'newest first, cursor-paginated. Returns { items, nextCursor }; each ' +
|
|
||||||
"item's id is the historyId to pass to the page diff or restore tools.",
|
|
||||||
buildShape: (z) => ({
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
cursor: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe('Pagination cursor from a previous nextCursor'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
restorePageVersion: {
|
|
||||||
mcpName: 'restore_page_version',
|
|
||||||
inAppKey: 'restorePageVersion',
|
|
||||||
description: 'Restore a page to a saved version: writes that version\'s content back ' +
|
|
||||||
'as the page\'s current content (Docmost has no restore endpoint, so ' +
|
|
||||||
'this creates a NEW history snapshot — the restore is itself revertible). ' +
|
|
||||||
'Get the historyId from the page-history list.',
|
|
||||||
buildShape: (z) => ({
|
|
||||||
historyId: z.string().min(1),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
// --- markdown round-trip ---
|
|
||||||
importPageMarkdown: {
|
|
||||||
mcpName: 'import_page_markdown',
|
|
||||||
inAppKey: 'importPageMarkdown',
|
|
||||||
description: "Replace a page's content from a self-contained Docmost-flavoured " +
|
|
||||||
'Markdown file produced by the page-Markdown export tool. Restores comment ' +
|
|
||||||
'highlight anchors and diagrams from their inline HTML. NOTE: comment ' +
|
|
||||||
'thread records are NOT created/updated/deleted on the server by this ' +
|
|
||||||
'tool — only the page body + inline comment marks are written; manage ' +
|
|
||||||
'comment threads via the comment tools/UI.',
|
|
||||||
buildShape: (z) => ({
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
markdown: z.string().min(1),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
// --- server-side content copy ---
|
|
||||||
copyPageContent: {
|
|
||||||
mcpName: 'copy_page_content',
|
|
||||||
inAppKey: 'copyPageContent',
|
|
||||||
description: "Replace targetPageId's content with a copy of sourcePageId's content, " +
|
|
||||||
'entirely server-side — the document is NOT sent through the model. The ' +
|
|
||||||
'target keeps its own title and slug; only its body is replaced. Ideal ' +
|
|
||||||
"for 'make page A's content equal to B' or 'replace A with B but keep A's URL'.",
|
|
||||||
buildShape: (z) => ({
|
|
||||||
sourcePageId: z.string().min(1).describe('Page to copy content FROM'),
|
|
||||||
targetPageId: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.describe('Page whose content is REPLACED (title/slug kept)'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
// --- surgical text edit (folds in the documented drift-bug fix) ---
|
|
||||||
//
|
|
||||||
// CANONICAL description is the CORRECTED in-app wording: a formatting-only
|
|
||||||
// change is REFUSED into failed[] (not silently stripped-and-retried). The
|
|
||||||
// stale MCP claim that "Markdown wrappers are tolerated via a strip-and-retry
|
|
||||||
// fallback" is intentionally absent here.
|
|
||||||
editPageText: {
|
|
||||||
mcpName: 'edit_page_text',
|
|
||||||
inAppKey: 'editPageText',
|
|
||||||
description: "Surgical find/replace inside a page's text, preserving all block " +
|
|
||||||
'ids and marks. A find MAY cross bold/italic/link boundaries; the ' +
|
|
||||||
'replacement inherits marks from the unchanged common prefix/suffix ' +
|
|
||||||
'(so editing plain text next to a bold word keeps it bold, and ' +
|
|
||||||
'editing inside a bold word keeps the new text bold). Each find must ' +
|
|
||||||
'match exactly once unless replaceAll is set. The batch applies what ' +
|
|
||||||
'it can and returns applied[] + failed[] plus a verify change-report ' +
|
|
||||||
'(the text/marks/structure that ACTUALLY changed — read it to confirm ' +
|
|
||||||
'your edit landed; do not assume success); a fully-unmatched batch ' +
|
|
||||||
'writes nothing and errors. find and replace are LITERAL text, not ' +
|
|
||||||
'markdown. This tool edits plain text ONLY and CANNOT add or remove ' +
|
|
||||||
'formatting marks: a formatting change — find/replace that differ only ' +
|
|
||||||
'in markdown markers (e.g. find:"~~x~~", replace:"x"), or a replace ' +
|
|
||||||
'containing **bold**/~~strike~~/`code` wrappers — is REFUSED into ' +
|
|
||||||
'failed[]. To change bold/italic/strike/code/link, read the block as ' +
|
|
||||||
'page JSON and use a structural node patch/update to set its marks. ' +
|
|
||||||
'Examples: edits:[{find:"teh",replace:"the"}]; edits:[{find:"Hello ' +
|
|
||||||
'world",replace:"Hello there"}] (crosses a bold boundary).',
|
|
||||||
buildShape: (z) => ({
|
|
||||||
pageId: z.string().describe('ID of the page to edit'),
|
|
||||||
edits: z
|
|
||||||
.array(z.object({
|
|
||||||
find: z.string().describe('Exact text to find'),
|
|
||||||
replace: z.string().describe('Replacement text (may be empty)'),
|
|
||||||
replaceAll: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.describe('Replace every occurrence (default: must match once)'),
|
|
||||||
}))
|
|
||||||
.min(1)
|
|
||||||
.describe('List of find/replace operations, applied in order'),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
// --- hand a large page to an external consumer without bloating context ---
|
|
||||||
stashPage: {
|
|
||||||
mcpName: 'stash_page',
|
|
||||||
inAppKey: 'stashPage',
|
|
||||||
description: 'Serialize a whole page (the full ProseMirror JSON, as get_page_json ' +
|
|
||||||
'returns) into an ephemeral in-memory blob and return ONLY a short ' +
|
|
||||||
'anonymous URL to it — the body NEVER enters the model context, so this ' +
|
|
||||||
'is the way to hand a large page (or its images) to an external consumer ' +
|
|
||||||
'without truncation. Every internal file/image attachment is mirrored ' +
|
|
||||||
'into the same sandbox and its src rewritten to a sandbox URL, so the ' +
|
|
||||||
'consumer can fetch the images anonymously too; external http(s) images ' +
|
|
||||||
'are left untouched. Returns { uri, size, sha256, images:{mirrored, ' +
|
|
||||||
'failed} }. Integrity: the blob is served with ETag = its sha256, so a ' +
|
|
||||||
'truncated/corrupted fetch is detectable. Blobs are RAM-only: they expire ' +
|
|
||||||
'after a short TTL (~1h) and are cleared on restart — consume the URL ' +
|
|
||||||
'within the TTL and one uptime, or re-stash. A blob is bound to the ' +
|
|
||||||
'server instance that created it: in a multi-replica deployment without ' +
|
|
||||||
'sticky sessions a blob stored on one instance is not retrievable via the ' +
|
|
||||||
'sandbox URL on another (it 404s like an expired one).',
|
|
||||||
buildShape: (z) => ({
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
"author": "Moritz Krause",
|
"author": "Moritz Krause",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@docmost/prosemirror-markdown": "workspace:*",
|
||||||
"@fellow/prosemirror-recreate-transform": "^1.2.3",
|
"@fellow/prosemirror-recreate-transform": "^1.2.3",
|
||||||
"@hocuspocus/provider": "^3.4.4",
|
"@hocuspocus/provider": "^3.4.4",
|
||||||
"@hocuspocus/transformer": "^3.4.4",
|
"@hocuspocus/transformer": "^3.4.4",
|
||||||
@@ -51,6 +52,7 @@
|
|||||||
"form-data": "^4.0.0",
|
"form-data": "^4.0.0",
|
||||||
"jsdom": "^27.4.0",
|
"jsdom": "^27.4.0",
|
||||||
"marked": "^17.0.1",
|
"marked": "^17.0.1",
|
||||||
|
"re2": "^1.21.0",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
"y-prosemirror": "1.3.7",
|
"y-prosemirror": "1.3.7",
|
||||||
"yjs": "^13.6.29",
|
"yjs": "^13.6.29",
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import {
|
|||||||
deleteTableRow,
|
deleteTableRow,
|
||||||
updateTableCell,
|
updateTableCell,
|
||||||
} from "./lib/node-ops.js";
|
} from "./lib/node-ops.js";
|
||||||
|
import { searchInDoc, SearchOptions } from "./lib/page-search.js";
|
||||||
import { withPageLock } from "./lib/page-lock.js";
|
import { withPageLock } from "./lib/page-lock.js";
|
||||||
import {
|
import {
|
||||||
applyTextEdits,
|
applyTextEdits,
|
||||||
@@ -807,8 +808,14 @@ export class DocmostClient {
|
|||||||
await this.ensureAuthenticated();
|
await this.ensureAuthenticated();
|
||||||
const resultData = await this.getPageRaw(pageId);
|
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 export_page_markdown
|
||||||
|
// round-trip deliberately does NOT pass this flag — resolved anchors there
|
||||||
|
// must be preserved.)
|
||||||
let content = resultData.content
|
let content = resultData.content
|
||||||
? convertProseMirrorToMarkdown(resultData.content)
|
? convertProseMirrorToMarkdown(resultData.content, {
|
||||||
|
dropResolvedCommentAnchors: true,
|
||||||
|
})
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
// Always fetch subpages to provide context to the agent
|
// Always fetch subpages to provide context to the agent
|
||||||
@@ -1093,6 +1100,29 @@ export class DocmostClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
* get_node/patch_node (the `#<index>` form resolves with get_node but NOT
|
||||||
|
* patch_node — see SearchMatch.nodeId), plus the top-level block index and a
|
||||||
|
* short context window used to build a unique text `selection` for
|
||||||
|
* create_comment (create_comment 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 get_outline) or a
|
* Read a table as a matrix. `tableRef` is `#<index>` (from get_outline) or a
|
||||||
* block id of any node inside the table. Returns the cell texts plus a
|
* block id of any node inside the table. Returns the cell texts plus a
|
||||||
@@ -1774,7 +1804,10 @@ export class DocmostClient {
|
|||||||
const body = page.content ? convertProseMirrorToMarkdown(page.content) : "";
|
const body = page.content ? convertProseMirrorToMarkdown(page.content) : "";
|
||||||
let comments: any[] = [];
|
let comments: any[] = [];
|
||||||
try {
|
try {
|
||||||
comments = await this.listComments(pageId);
|
// 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) {
|
} catch (e) {
|
||||||
// A comments fetch failure must not lose the body; export with [] and let
|
// A comments fetch failure must not lose the body; export with [] and let
|
||||||
// the caller see the (empty) comments block. Log under DEBUG only.
|
// the caller see the (empty) comments block. Log under DEBUG only.
|
||||||
@@ -2343,8 +2376,21 @@ export class DocmostClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** List all comments on a page (cursor-paginated), content as markdown. */
|
/**
|
||||||
async listComments(pageId: string) {
|
* 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();
|
await this.ensureAuthenticated();
|
||||||
let allComments: any[] = [];
|
let allComments: any[] = [];
|
||||||
let cursor: string | null = null;
|
let cursor: string | null = null;
|
||||||
@@ -2360,7 +2406,7 @@ export class DocmostClient {
|
|||||||
cursor = data.meta?.nextCursor || null;
|
cursor = data.meta?.nextCursor || null;
|
||||||
} while (cursor);
|
} while (cursor);
|
||||||
|
|
||||||
return allComments.map((comment: any) => {
|
const mapped = allComments.map((comment: any) => {
|
||||||
const markdown = comment.content
|
const markdown = comment.content
|
||||||
? convertProseMirrorToMarkdown(
|
? convertProseMirrorToMarkdown(
|
||||||
this.parseCommentContent(comment.content),
|
this.parseCommentContent(comment.content),
|
||||||
@@ -2368,6 +2414,31 @@ export class DocmostClient {
|
|||||||
: "";
|
: "";
|
||||||
return filterComment(comment, markdown);
|
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) {
|
async getComment(commentId: string) {
|
||||||
@@ -2742,7 +2813,9 @@ export class DocmostClient {
|
|||||||
const results: any[] = [];
|
const results: any[] = [];
|
||||||
for (const page of pagesInScope) {
|
for (const page of pagesInScope) {
|
||||||
try {
|
try {
|
||||||
const comments = await this.listComments(page.id);
|
// Full feed (incl. resolved): a "new comments since" scan reports all
|
||||||
|
// recent activity; the active-only filter is scoped to list_comments.
|
||||||
|
const comments = (await this.listComments(page.id, true)).items;
|
||||||
const newComments = comments.filter(
|
const newComments = comments.filter(
|
||||||
(c: any) => new Date(c.createdAt) > sinceDate,
|
(c: any) => new Date(c.createdAt) > sinceDate,
|
||||||
);
|
);
|
||||||
@@ -3488,7 +3561,9 @@ export class DocmostClient {
|
|||||||
const deleteComments = opts.deleteComments ?? false;
|
const deleteComments = opts.deleteComments ?? false;
|
||||||
|
|
||||||
await this.ensureAuthenticated();
|
await this.ensureAuthenticated();
|
||||||
const comments = await this.listComments(pageId);
|
// 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
|
// ctx handed to the sandbox. consume() records ids; helpers are the pure
|
||||||
// transform primitives. log is captured from console.log inside the sandbox.
|
// transform primitives. log is captured from console.log inside the sandbox.
|
||||||
|
|||||||
+58
-16
@@ -37,11 +37,20 @@ const VERSION = packageJson.version;
|
|||||||
|
|
||||||
// Editing guide surfaced to MCP clients in the initialize result so they can
|
// Editing guide surfaced to MCP clients in the initialize result so they can
|
||||||
// pick the right tool by intent and avoid resending whole documents.
|
// pick the right tool by intent and avoid resending whole documents.
|
||||||
const SERVER_INSTRUCTIONS =
|
//
|
||||||
"Docmost editing guide — choose the tool by intent: fix wording/typos/numbers (text inside blocks) -> edit_page_text (no node id needed). Change ONE block (paragraph/heading/callout/table cell/etc.) structurally -> patch_node (address by attrs.id from get_page_json). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Images -> insert_image (add an image from a web URL) / replace_image (swap an existing image for one from a web URL). New page -> create_page (Markdown). Bulk/structural rewrite or nodes without an id -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Rename a page (title only) -> rename_page. Read -> get_page (Markdown, lossy) or get_page_json (lossless ProseMirror with block ids). Comments -> create_comment (always inline; requires an EXACT selection — the contiguous text to anchor/highlight on; fails rather than leaving an unanchored comment), list_comments, update_comment, resolve_comment (resolve/reopen a thread, reversible — prefer over delete to close), delete_comment, check_new_comments. Propose a concrete text fix for one-click human approval -> create_comment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Tip: read block ids via get_page_json, then use patch_node/insert_node/delete_node so you never resend the full document. " +
|
// MAINTENANCE RULE: when you ADD, RENAME, or REMOVE a tool (either an inline
|
||||||
"Complex/scripted rewrite (multiple coordinated edits, footnotes, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes. " +
|
// server.registerTool(...) here or a spec in tool-specs.ts), you MUST update
|
||||||
"Review what changed -> diff_page_versions (compare a historyId to current, or two history versions). See a page's saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). " +
|
// this guide so the new tool is routed by intent. This is enforced by
|
||||||
"Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown.";
|
// test/unit/server-instructions.test.mjs, which fails when a registered tool
|
||||||
|
// name is not mentioned below (see its EXCEPTIONS list for the rare opt-outs).
|
||||||
|
// Exported for that test.
|
||||||
|
export const SERVER_INSTRUCTIONS =
|
||||||
|
"Docmost editing guide — choose the tool by intent.\n" +
|
||||||
|
"READ: find a page -> search (workspace-wide full-text); list -> list_pages / list_spaces. Locate blocks and their ids CHEAPLY -> get_outline (compact top-level map; start here, not get_page_json). One block's subtree -> get_node (by attrs.id, or \"#<index>\" for tables, which carry no id). Find every occurrence of a string/regex ON a page (and where each is) -> search_in_page, NOT block-by-block get_node — it returns each hit's node ref + block index + context for a targeted comment. Whole page -> get_page (Markdown, lossy; inline <span data-comment-id> tags are comment anchors — markup, not text) or get_page_json (lossless ProseMirror with block ids). Hand a huge page (with images) to an external consumer without pulling it through the model context -> stash_page (returns a short-lived anonymous URL).\n" +
|
||||||
|
"EDIT: fix wording/typos/numbers -> edit_page_text (find/replace inside blocks, no node id needed). Change ONE block (paragraph/heading/callout/etc.) structurally -> patch_node (by attrs.id from get_outline). Add a block -> insert_node (before/after a block by attrs.id or by anchor text, or append). Remove a block -> delete_node (by attrs.id). Tables -> table_get / table_update_cell / table_insert_row / table_delete_row (address by \"#<index>\" from get_outline; table nodes have no attrs.id). Images -> insert_image (add from a web URL) / replace_image (swap an existing image). Footnotes -> insert_footnote. Bulk/structural rewrite -> update_page_json (full ProseMirror replace; prefer the granular tools above to avoid resending the whole ~100KB+ document). Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmost_transform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
|
||||||
|
"PAGES: new -> create_page (Markdown). Rename (title only) -> rename_page. Move -> move_page. Delete -> delete_page (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copy_page_content. Sharing -> share_page / unshare_page / list_shares; share_page makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
|
||||||
|
"COMMENTS: create_comment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> create_comment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> list_comments, update_comment, resolve_comment (resolve/reopen, reversible — prefer over delete to close), delete_comment, check_new_comments.\n" +
|
||||||
|
"HISTORY: review what changed -> diff_page_versions (a historyId vs current, or two versions). List saved versions -> list_page_history. Undo a bad edit -> restore_page_version (writes a past version back as current; itself revertible). Lossless markdown round-trip (download, edit, re-upload, incl. comment anchors) -> export_page_markdown / import_page_markdown.";
|
||||||
|
|
||||||
// Helper to format JSON responses
|
// Helper to format JSON responses
|
||||||
const jsonContent = (data: any) => ({
|
const jsonContent = (data: any) => ({
|
||||||
@@ -147,7 +156,9 @@ server.registerTool(
|
|||||||
description:
|
description:
|
||||||
"Get page details with content converted to Markdown. The conversion is " +
|
"Get page details with content converted to Markdown. The conversion is " +
|
||||||
"LOSSY (block ids, exact table/callout structure are approximated); for a " +
|
"LOSSY (block ids, exact table/callout structure are approximated); for a " +
|
||||||
"lossless representation use get_page_json.",
|
"lossless representation use get_page_json. Inline <span data-comment-id> " +
|
||||||
|
"tags in the markdown are comment highlight anchors (also present for " +
|
||||||
|
"RESOLVED threads) — treat them as markup, not page text.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
pageId: z.string().min(1),
|
pageId: z.string().min(1),
|
||||||
},
|
},
|
||||||
@@ -176,6 +187,19 @@ registerShared(SHARED_TOOL_SPECS.getNode, async ({ pageId, nodeId }) => {
|
|||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Tool: search_in_page
|
||||||
|
registerShared(
|
||||||
|
SHARED_TOOL_SPECS.searchInPage,
|
||||||
|
async ({ pageId, query, regex, caseSensitive, limit }) => {
|
||||||
|
const result = await docmostClient.searchInPage(pageId, query, {
|
||||||
|
regex,
|
||||||
|
caseSensitive,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
return jsonContent(result);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Tool: table_get
|
// Tool: table_get
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"table_get",
|
"table_get",
|
||||||
@@ -288,7 +312,8 @@ server.registerTool(
|
|||||||
"create_page",
|
"create_page",
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Create a new page with content (automatically moves it to the correct hierarchy).",
|
"Create a new page from Markdown in a space. Pass parentPageId to nest " +
|
||||||
|
"it under a parent; omit it to create at the space root.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
title: z.string().min(1).describe("Title of the page"),
|
title: z.string().min(1).describe("Title of the page"),
|
||||||
content: z.string().min(1).describe("Markdown content"),
|
content: z.string().min(1).describe("Markdown content"),
|
||||||
@@ -587,7 +612,8 @@ server.registerTool(
|
|||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Make a page publicly accessible (idempotent) and return its public " +
|
"Make a page publicly accessible (idempotent) and return its public " +
|
||||||
"URL. The URL format is <app>/share/<key>/p/<slugId>.",
|
"URL. The URL format is <app>/share/<key>/p/<slugId>. This exposes the " +
|
||||||
|
"page content to ANYONE with the URL — do it only when explicitly asked.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
pageId: z.string().min(1).describe("ID of the page to share"),
|
pageId: z.string().min(1).describe("ID of the page to share"),
|
||||||
searchIndexing: z
|
searchIndexing: z
|
||||||
@@ -619,7 +645,7 @@ server.registerTool(
|
|||||||
"move_page",
|
"move_page",
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Move a page to a new parent (nesting) or root. Essential for organizing pages created via 'create_page'.",
|
"Move a page under a new parent (nesting) or to the space root.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
pageId: z.string().min(1),
|
pageId: z.string().min(1),
|
||||||
parentPageId: z
|
parentPageId: z
|
||||||
@@ -675,7 +701,9 @@ server.registerTool(
|
|||||||
server.registerTool(
|
server.registerTool(
|
||||||
"delete_page",
|
"delete_page",
|
||||||
{
|
{
|
||||||
description: "Delete a single page by ID.",
|
description:
|
||||||
|
"Delete a single page by ID. SOFT delete only: the page is moved to " +
|
||||||
|
"trash and can be restored; nothing is permanently deleted.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
pageId: z.string().min(1),
|
pageId: z.string().min(1),
|
||||||
},
|
},
|
||||||
@@ -697,13 +725,24 @@ server.registerTool(
|
|||||||
"list_comments",
|
"list_comments",
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"List all comments on a page (paginated). Content is returned as Markdown.",
|
"List comments on a page in one call (pagination is handled " +
|
||||||
|
"internally). By DEFAULT only ACTIVE threads are returned; resolved " +
|
||||||
|
"threads (a resolved top-level comment and all its replies) are hidden " +
|
||||||
|
"and their count reported as `resolvedThreadsHidden` so you can re-query " +
|
||||||
|
"with `includeResolved: true` to see everything. Returns " +
|
||||||
|
"`{ items, resolvedThreadsHidden }`. Content is returned as Markdown.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
pageId: z.string().describe("ID of the page"),
|
pageId: z.string().describe("ID of the page"),
|
||||||
|
includeResolved: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"default only active threads; true — include resolved",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async ({ pageId }) => {
|
async ({ pageId, includeResolved }) => {
|
||||||
const comments = await docmostClient.listComments(pageId);
|
const comments = await docmostClient.listComments(pageId, includeResolved);
|
||||||
return jsonContent(comments);
|
return jsonContent(comments);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -913,8 +952,9 @@ server.registerTool(
|
|||||||
"search",
|
"search",
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Search for pages and content. Results are bounded by `limit` " +
|
"Full-text search for pages and content across the whole workspace. " +
|
||||||
"(default applied by the client, max 100).",
|
"Results are bounded by `limit` (1-100; when omitted the server applies " +
|
||||||
|
"its own default).",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
query: z.string().min(1).describe("Search query"),
|
query: z.string().min(1).describe("Search query"),
|
||||||
limit: z
|
limit: z
|
||||||
@@ -970,7 +1010,9 @@ server.registerTool(
|
|||||||
"insertInlineFootnote(doc, {anchorText, text}) (author-inline footnote: " +
|
"insertInlineFootnote(doc, {anchorText, text}) (author-inline footnote: " +
|
||||||
"marker + dedup'd definition, list derived). Footnote convention: markers are " +
|
"marker + dedup'd definition, list derived). Footnote convention: markers are " +
|
||||||
"plain '[N]' text in the body; the notes are an orderedList under a " +
|
"plain '[N]' text in the body; the notes are an orderedList under a " +
|
||||||
"heading whose text is 'Примечания переводчика'. The transform runs " +
|
"heading whose text is 'Примечания переводчика' (that is only the DEFAULT " +
|
||||||
|
"notesHeading — pass the notesHeading option to the helpers to use a " +
|
||||||
|
"heading matching the page's language). The transform runs " +
|
||||||
"sandboxed (no require/process/fs/network, 5s timeout) and must return a " +
|
"sandboxed (no require/process/fs/network, 5s timeout) and must return a " +
|
||||||
"{type:'doc'} node.",
|
"{type:'doc'} node.",
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
|
|||||||
@@ -2,18 +2,24 @@ import { HocuspocusProvider } from "@hocuspocus/provider";
|
|||||||
import { TiptapTransformer } from "@hocuspocus/transformer";
|
import { TiptapTransformer } from "@hocuspocus/transformer";
|
||||||
import * as Y from "yjs";
|
import * as Y from "yjs";
|
||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
import { marked } from "marked";
|
|
||||||
import { generateJSON } from "@tiptap/html";
|
|
||||||
import { Node as PMNode } from "@tiptap/pm/model";
|
import { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import { updateYFragment } from "y-prosemirror";
|
import { updateYFragment } from "y-prosemirror";
|
||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
|
// #293 STEP 5: the pure markdown -> ProseMirror import path is now owned by the
|
||||||
|
// shared package (canonical `^[…]` footnotes, `$…$` math, `==` highlight, the
|
||||||
|
// media-family md forms, comment-directive attrs, callouts and task lists all
|
||||||
|
// handled there). MCP consumes it directly instead of maintaining its own
|
||||||
|
// drifted marked pipeline; only the collab/yjs write glue and the footnote
|
||||||
|
// canonicalization wrapper stay mcp-side.
|
||||||
|
import { markdownToProseMirror } from "@docmost/prosemirror-markdown";
|
||||||
import { docmostExtensions, docmostSchema } from "./docmost-schema.js";
|
import { docmostExtensions, docmostSchema } from "./docmost-schema.js";
|
||||||
import { withPageLock } from "./page-lock.js";
|
import { withPageLock } from "./page-lock.js";
|
||||||
import { sanitizeForYjs, findUnstorableAttr } from "./node-ops.js";
|
import { sanitizeForYjs, findUnstorableAttr } from "./node-ops.js";
|
||||||
import { lexFootnoteLines } from "./footnote-lex.js";
|
|
||||||
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
|
import { canonicalizeFootnotes } from "./footnote-canonicalize.js";
|
||||||
import { summarizeChange, VerifyReport } from "./diff.js";
|
import { summarizeChange, VerifyReport } from "./diff.js";
|
||||||
|
|
||||||
|
export { markdownToProseMirror };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the descriptive error for an opaque Yjs encode failure ("Unexpected
|
* Build the descriptive error for an opaque Yjs encode failure ("Unexpected
|
||||||
* content type"), shared by both encode paths (`buildYDoc` -> `toYdoc` and
|
* content type"), shared by both encode paths (`buildYDoc` -> `toYdoc` and
|
||||||
@@ -51,382 +57,27 @@ global.WebSocket = WebSocket;
|
|||||||
// global.navigator = dom.window.navigator;
|
// global.navigator = dom.window.navigator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hard ceiling above which we skip callout preprocessing entirely. The linear
|
* Page-write variant of the package's `markdownToProseMirror`: imports markdown
|
||||||
* scanner below has no quadratic blow-up, but we still cap input defensively so
|
* then re-runs mcp's footnote canonicalizer over the result.
|
||||||
* a pathological multi-megabyte payload cannot tie up the event loop; in that
|
|
||||||
* case the markdown is passed through verbatim (callouts are simply not
|
|
||||||
* detected) rather than risking a slow scan.
|
|
||||||
*/
|
|
||||||
const MAX_CALLOUT_PREPROCESS_BYTES = 4 * 1024 * 1024; // 4 MB
|
|
||||||
|
|
||||||
/** Matches an opening callout fence: `:::type` (type captured, lower-cased). */
|
|
||||||
const CALLOUT_OPEN_RE = /^:::\s*(\w+)\s*$/;
|
|
||||||
/** Matches a bare closing callout fence: `:::`. */
|
|
||||||
const CALLOUT_CLOSE_RE = /^:::\s*$/;
|
|
||||||
/** Matches the start/end of a code fence (``` or ~~~), capturing the marker. */
|
|
||||||
const CODE_FENCE_RE = /^(\s*)(`{3,}|~{3,})/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pre-process Docmost-flavoured markdown: convert `:::type ... :::`
|
|
||||||
* callout blocks (the syntax our markdown export produces) into HTML
|
|
||||||
* divs that the callout extension parses. The inner content is rendered
|
|
||||||
* through marked as regular markdown.
|
|
||||||
*
|
*
|
||||||
* Implemented as a single linear pass over the lines (no quadratic regex
|
* Footnote layering after #293 STEP 5:
|
||||||
* rescan). It:
|
* - The package's `markdownToProseMirror` already ASSEMBLES footnotes on import
|
||||||
* - tracks fenced code regions (```...``` and ~~~...~~~) and never treats a
|
* (canon #2): inline `^[body]` markers become the schema's
|
||||||
* `:::` line that lives inside a code fence as a callout delimiter, so a
|
* `footnoteReference` + a single doc-level `footnotesList`, with ids assigned
|
||||||
* callout body that itself contains a fenced code block with a `:::` line is
|
* sequentially (`fn-1`, `fn-2`, …) in first-reference order and identical
|
||||||
* no longer corrupted;
|
* bodies merged. So the import output is ALREADY in canonical footnote
|
||||||
* - matches an opening `:::type` line with the next CLOSING `:::` at the SAME
|
* topology.
|
||||||
* nesting level, supporting NESTED callouts via a depth counter (an inner
|
* - `canonicalizeFootnotes` runs AFTER as the mcp write-path invariant shared
|
||||||
* `:::type` opens a deeper level and consumes a matching `:::`);
|
* with every other full-document persist path (`update_page_json`,
|
||||||
* - emits the same `<div data-type="callout" data-callout-type="TYPE">` output
|
* `docmost_transform`, `insert_footnote`, …). Because the package output is
|
||||||
* (inner rendered through marked) as the previous regex implementation.
|
* already canonical, this layer is a no-op here (idempotent) — it exists so
|
||||||
*/
|
* the page-write contract is enforced uniformly regardless of how the PM doc
|
||||||
async function preprocessCallouts(markdown: string): Promise<string> {
|
* was produced, not because the import needs fixing.
|
||||||
// Defensive cap: skip preprocessing for pathologically large inputs.
|
|
||||||
if (markdown.length > MAX_CALLOUT_PREPROCESS_BYTES) {
|
|
||||||
return markdown;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Recursively transform a slice of lines, converting top-level callouts in
|
|
||||||
// that slice into <div> blocks and rendering their inner content (which may
|
|
||||||
// itself contain nested callouts) through this same function.
|
|
||||||
const transform = async (lines: string[]): Promise<string> => {
|
|
||||||
const out: string[] = [];
|
|
||||||
let inCodeFence = false;
|
|
||||||
let codeFenceMarker = ""; // the exact run of backticks/tildes that opened it
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
while (i < lines.length) {
|
|
||||||
const line = lines[i];
|
|
||||||
|
|
||||||
// Inside a code fence, only its matching closing fence is significant;
|
|
||||||
// everything else (including `:::` lines) is copied through verbatim.
|
|
||||||
if (inCodeFence) {
|
|
||||||
out.push(line);
|
|
||||||
const fence = line.match(CODE_FENCE_RE);
|
|
||||||
if (fence && fence[2].startsWith(codeFenceMarker[0]) &&
|
|
||||||
fence[2].length >= codeFenceMarker.length) {
|
|
||||||
inCodeFence = false;
|
|
||||||
codeFenceMarker = "";
|
|
||||||
}
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A code fence opening outside any callout body: enter code-fence mode.
|
|
||||||
const fenceOpen = line.match(CODE_FENCE_RE);
|
|
||||||
if (fenceOpen) {
|
|
||||||
inCodeFence = true;
|
|
||||||
codeFenceMarker = fenceOpen[2];
|
|
||||||
out.push(line);
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// An opening callout fence: scan forward (with code-fence and nested
|
|
||||||
// callout awareness) for its matching closing `:::` at the same level.
|
|
||||||
const open = line.match(CALLOUT_OPEN_RE);
|
|
||||||
if (open) {
|
|
||||||
const type = open[1].toLowerCase();
|
|
||||||
const bodyLines: string[] = [];
|
|
||||||
let depth = 1;
|
|
||||||
let innerInCodeFence = false;
|
|
||||||
let innerCodeFenceMarker = "";
|
|
||||||
let j = i + 1;
|
|
||||||
for (; j < lines.length; j++) {
|
|
||||||
const bl = lines[j];
|
|
||||||
if (innerInCodeFence) {
|
|
||||||
const f = bl.match(CODE_FENCE_RE);
|
|
||||||
if (f && f[2].startsWith(innerCodeFenceMarker[0]) &&
|
|
||||||
f[2].length >= innerCodeFenceMarker.length) {
|
|
||||||
innerInCodeFence = false;
|
|
||||||
innerCodeFenceMarker = "";
|
|
||||||
}
|
|
||||||
bodyLines.push(bl);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const innerFence = bl.match(CODE_FENCE_RE);
|
|
||||||
if (innerFence) {
|
|
||||||
innerInCodeFence = true;
|
|
||||||
innerCodeFenceMarker = innerFence[2];
|
|
||||||
bodyLines.push(bl);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (CALLOUT_OPEN_RE.test(bl)) {
|
|
||||||
depth++;
|
|
||||||
bodyLines.push(bl);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (CALLOUT_CLOSE_RE.test(bl)) {
|
|
||||||
depth--;
|
|
||||||
if (depth === 0) break; // matching close for THIS callout
|
|
||||||
bodyLines.push(bl);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
bodyLines.push(bl);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (j < lines.length) {
|
|
||||||
// Found the matching closing fence: render the body (recursively, so
|
|
||||||
// nested callouts are handled) and emit the callout div.
|
|
||||||
const inner = await transform(bodyLines);
|
|
||||||
const renderedInner = await marked.parse(inner);
|
|
||||||
out.push(
|
|
||||||
`\n<div data-type="callout" data-callout-type="${type}">${renderedInner}</div>\n`,
|
|
||||||
);
|
|
||||||
i = j + 1; // skip past the closing `:::`
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// No matching close (unterminated callout): treat the opener as a
|
|
||||||
// literal line and continue, preserving the original text.
|
|
||||||
out.push(line);
|
|
||||||
i++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
out.push(line);
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return out.join("\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
return transform(markdown.split("\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bridge marked's checkbox lists to TipTap task lists.
|
|
||||||
*
|
*
|
||||||
* marked renders GitHub task list items (`- [x] done`) as a plain
|
* Use this ONLY for full-document PAGE writes. Comment bodies call the package's
|
||||||
* `<ul><li><p><input type="checkbox" checked> text</p></li></ul>` WITHOUT the
|
* plain `markdownToProseMirror` (no canonicalization) — safe now because inline
|
||||||
* markup TipTap's TaskList/TaskItem extensions parse. This rewrites such lists
|
* `^[body]` footnotes carry their body at the reference point, so a comment can
|
||||||
* into the shape those extensions expect:
|
* no longer produce a reference-less footnote definition to be dropped.
|
||||||
* TaskList parseHTML matches `ul[data-type="taskList"]`,
|
|
||||||
* TaskItem matches `li[data-type="taskItem"]`,
|
|
||||||
* the checked state is read from `data-checked === "true"`.
|
|
||||||
*
|
|
||||||
* A list is only converted when it has at least one `<li>` and EVERY direct
|
|
||||||
* `<li>` contains a checkbox input. Both `<ul>` and `<ol>` are considered: a
|
|
||||||
* numbered checklist (`1. [x] a`, which marked renders as an `<ol>` of checkbox
|
|
||||||
* `<li>`s) would otherwise lose its task state. TipTap task lists are unordered,
|
|
||||||
* so a matching `<ol>` is emitted as `data-type="taskList"` exactly like a
|
|
||||||
* `<ul>`. Mixed or ordinary lists (including ordinary `<ol>` lists) are left
|
|
||||||
* untouched so they keep rendering as bullet/numbered lists. The marked `<p>`
|
|
||||||
* wrapper is kept inside the `<li>` because TaskItem content allows paragraphs.
|
|
||||||
*/
|
|
||||||
function bridgeTaskLists(html: string): string {
|
|
||||||
// Cheap early-out: if the markup contains no checkbox input at all there is
|
|
||||||
// nothing to bridge, so skip the expensive JSDOM parse entirely. This is the
|
|
||||||
// common case (most pages have no task lists).
|
|
||||||
if (!/type=["']?checkbox/i.test(html)) {
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
// Defensive cap (consistent with preprocessCallouts): skip the bridge for
|
|
||||||
// pathologically large inputs rather than running a second expensive JSDOM
|
|
||||||
// parse on a multi-megabyte payload. The markup is passed through verbatim.
|
|
||||||
if (html.length > MAX_CALLOUT_PREPROCESS_BYTES) {
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
const dom = new JSDOM(html);
|
|
||||||
const document = dom.window.document;
|
|
||||||
// Collect the checkbox(es) that belong to THIS <li> directly: either direct
|
|
||||||
// child <input type="checkbox"> elements or ones inside the <li>'s direct <p>
|
|
||||||
// child (the shape marked emits: `<li><p><input type="checkbox"> text</p></li>`).
|
|
||||||
// Checkboxes nested deeper (e.g. inside a child <ul>/<ol>) are excluded so a
|
|
||||||
// bullet <li> that merely contains a nested task sublist is not misdetected.
|
|
||||||
// Raw inline HTML can put more than one checkbox in a single <li>; we gather
|
|
||||||
// ALL of them so none survive into the converted item.
|
|
||||||
const directCheckboxes = (li: Element): Element[] => {
|
|
||||||
const found: Element[] = [];
|
|
||||||
for (const child of Array.from(li.children)) {
|
|
||||||
if (
|
|
||||||
child.tagName === "INPUT" &&
|
|
||||||
child.getAttribute("type") === "checkbox"
|
|
||||||
) {
|
|
||||||
found.push(child);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (child.tagName === "P") {
|
|
||||||
for (const inp of Array.from(
|
|
||||||
child.querySelectorAll(":scope > input[type='checkbox']"),
|
|
||||||
)) {
|
|
||||||
found.push(inp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return found;
|
|
||||||
};
|
|
||||||
// Both <ul> and <ol> are candidates: an <ol> whose every direct <li> carries
|
|
||||||
// its own checkbox is a numbered checklist that must also become a taskList.
|
|
||||||
const lists = Array.from(document.querySelectorAll("ul, ol"));
|
|
||||||
for (const list of lists) {
|
|
||||||
// Only consider DIRECT child <li> elements; nested lists are handled by
|
|
||||||
// their own iteration of the outer loop.
|
|
||||||
const items = Array.from(list.children).filter(
|
|
||||||
(child) => child.tagName === "LI",
|
|
||||||
);
|
|
||||||
if (items.length === 0) continue;
|
|
||||||
const itemCheckboxes = items.map((li) => directCheckboxes(li));
|
|
||||||
// Convert only when every direct <li> carries at least one OWN checkbox.
|
|
||||||
if (!itemCheckboxes.every((boxes) => boxes.length > 0)) continue;
|
|
||||||
|
|
||||||
// A numbered checklist arrives as an <ol>. We must NOT leave the tag as
|
|
||||||
// <ol> while tagging it data-type="taskList": generateJSON would then match
|
|
||||||
// BOTH the orderedList rule (tag ol) and the taskList rule (data-type),
|
|
||||||
// emitting a phantom empty orderedList beside the real taskList. So rename a
|
|
||||||
// qualifying <ol> to a <ul> — move its <li> children over and replace it —
|
|
||||||
// leaving only the taskList rule to match. Already-<ul> lists are unchanged.
|
|
||||||
let target: Element = list;
|
|
||||||
if (list.tagName === "OL") {
|
|
||||||
const ul = document.createElement("ul");
|
|
||||||
// Carry over existing attributes (e.g. class) so nothing is silently lost.
|
|
||||||
for (const attr of Array.from(list.attributes)) {
|
|
||||||
ul.setAttribute(attr.name, attr.value);
|
|
||||||
}
|
|
||||||
// Move every child node (including the <li>s we collected) into the <ul>.
|
|
||||||
while (list.firstChild) {
|
|
||||||
ul.appendChild(list.firstChild);
|
|
||||||
}
|
|
||||||
list.replaceWith(ul);
|
|
||||||
target = ul;
|
|
||||||
}
|
|
||||||
|
|
||||||
target.setAttribute("data-type", "taskList");
|
|
||||||
items.forEach((li, index) => {
|
|
||||||
const boxes = itemCheckboxes[index];
|
|
||||||
// The first checkbox determines the checked state (matches the previous
|
|
||||||
// single-checkbox behaviour); any extras only need removing.
|
|
||||||
const input = boxes[0] ?? null;
|
|
||||||
li.setAttribute("data-type", "taskItem");
|
|
||||||
const checked =
|
|
||||||
input != null &&
|
|
||||||
(input.hasAttribute("checked") || (input as any).checked);
|
|
||||||
li.setAttribute("data-checked", checked ? "true" : "false");
|
|
||||||
// Remove ALL direct checkbox inputs so none survive into the content
|
|
||||||
// (a raw-inline-HTML <li> may carry more than one).
|
|
||||||
for (const box of boxes) {
|
|
||||||
box.remove();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return document.body.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mirror of packages/editor-ext footnote markdown handling. A `[^id]` inline
|
|
||||||
// marker becomes <sup data-footnote-ref data-id="id">, and `[^id]: text`
|
|
||||||
// definition lines are collected into a single <section data-footnotes>.
|
|
||||||
// Definition detection + fence handling are shared with analyzeFootnotes via
|
|
||||||
// lexFootnoteLines (footnote-lex.js). FOOTNOTE_REF_RE is the inline tokenizer's.
|
|
||||||
const FOOTNOTE_REF_RE = /\[\^([^\]\s]+)\]/;
|
|
||||||
|
|
||||||
function escapeFootnoteAttr(value: string): string {
|
|
||||||
return String(value).replace(/&/g, "&").replace(/"/g, """);
|
|
||||||
}
|
|
||||||
|
|
||||||
const footnoteRefMarkedExtension = {
|
|
||||||
name: "footnoteRef",
|
|
||||||
level: "inline" as const,
|
|
||||||
start(src: string) {
|
|
||||||
return src.match(/\[\^/)?.index ?? -1;
|
|
||||||
},
|
|
||||||
tokenizer(src: string) {
|
|
||||||
const match = FOOTNOTE_REF_RE.exec(src);
|
|
||||||
if (match && match.index === 0) {
|
|
||||||
return { type: "footnoteRef", raw: match[0], id: match[1] };
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
},
|
|
||||||
renderer(token: any) {
|
|
||||||
return `<sup data-footnote-ref data-id="${escapeFootnoteAttr(
|
|
||||||
token.id,
|
|
||||||
)}"></sup>`;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
marked.use({ extensions: [footnoteRefMarkedExtension] });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pull `[^id]: text` definition lines out of the body and render a single
|
|
||||||
* <section data-footnotes> for them (or "" when there are none).
|
|
||||||
*/
|
|
||||||
function extractFootnotes(markdown: string): {
|
|
||||||
body: string;
|
|
||||||
section: string;
|
|
||||||
} {
|
|
||||||
const bodyLines: string[] = [];
|
|
||||||
const defs: Array<{ id: string; text: string }> = [];
|
|
||||||
// Shared lexer (footnote-lex): a `[^id]: ...` line inside a ``` / ~~~ code
|
|
||||||
// block is inert and stays in the body verbatim; only real definition lines
|
|
||||||
// are pulled out. analyzeFootnotes() consumes the SAME lexer so its diagnostics
|
|
||||||
// match exactly what import keeps/strips (#166).
|
|
||||||
for (const tok of lexFootnoteLines(markdown)) {
|
|
||||||
if (!tok.inFence && tok.definition) defs.push(tok.definition);
|
|
||||||
else bodyLines.push(tok.line);
|
|
||||||
}
|
|
||||||
if (defs.length === 0) return { body: markdown, section: "" };
|
|
||||||
|
|
||||||
// Duplicate definition ids: FIRST WINS, the rest are DROPPED (mirror of
|
|
||||||
// editor-ext extractFootnoteDefinitions). Reference markers are left untouched
|
|
||||||
// so repeated `[^a]` references reuse the single footnote (Pandoc semantics,
|
|
||||||
// #166). The dropped duplicate is surfaced to the caller via analyzeFootnotes
|
|
||||||
// (`duplicateDefinitions`), not silently lost. MUST stay in sync with the
|
|
||||||
// editor-ext mirror.
|
|
||||||
const firstById = new Map<string, string>(); // id -> first definition text
|
|
||||||
for (const def of defs) {
|
|
||||||
if (!firstById.has(def.id)) firstById.set(def.id, def.text);
|
|
||||||
}
|
|
||||||
|
|
||||||
const inner = [...firstById.entries()]
|
|
||||||
.map(
|
|
||||||
([id, text]) =>
|
|
||||||
`<div data-footnote-def data-id="${escapeFootnoteAttr(
|
|
||||||
id,
|
|
||||||
)}"><p>${marked.parseInline(text || "")}</p></div>`,
|
|
||||||
)
|
|
||||||
.join("");
|
|
||||||
return {
|
|
||||||
body: bodyLines.join("\n"),
|
|
||||||
section: `<section data-footnotes>${inner}</section>`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert markdown to a ProseMirror doc using the full Docmost schema.
|
|
||||||
*
|
|
||||||
* This conversion does NOT canonicalize footnotes — it is the shared, content-
|
|
||||||
* preserving primitive used by BOTH page write paths and COMMENT bodies
|
|
||||||
* (createComment / updateComment). Canonicalization MUST NOT run on a comment
|
|
||||||
* body: a comment may legitimately contain a footnote-definition line
|
|
||||||
* (`[^1]: text`) with no matching reference, and the canonicalizer drops a
|
|
||||||
* reference-less footnotesList — which would silently delete the comment's text.
|
|
||||||
*
|
|
||||||
* Page write paths that DO need the canonical footnote topology call
|
|
||||||
* `markdownToProseMirrorCanonical` instead (markdown import, update_page markdown
|
|
||||||
* path). Keep this function reference-loss-free.
|
|
||||||
*/
|
|
||||||
export async function markdownToProseMirror(
|
|
||||||
markdownContent: string,
|
|
||||||
): Promise<any> {
|
|
||||||
const withCallouts = await preprocessCallouts(markdownContent);
|
|
||||||
const { body, section } = extractFootnotes(withCallouts);
|
|
||||||
const html = (await marked.parse(body)) + section;
|
|
||||||
const bridged = bridgeTaskLists(html);
|
|
||||||
return generateJSON(bridged, docmostExtensions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Page-write variant of `markdownToProseMirror`: converts markdown then enforces
|
|
||||||
* the canonical footnote topology. The footnote `section` markdown is emitted in
|
|
||||||
* DEFINITION order, but numbering derives from REFERENCE order, so without this
|
|
||||||
* the bottom list renders out of order (`1, 4, 2, 3, …`); orphan definitions and
|
|
||||||
* duplicate lists are also normalized. Idempotent — a no-op once canonical, and a
|
|
||||||
* no-op for footnote-free content.
|
|
||||||
*
|
|
||||||
* Use this ONLY for full-document PAGE writes (never for comment bodies, where it
|
|
||||||
* would drop a reference-less footnote definition — see `markdownToProseMirror`).
|
|
||||||
*/
|
*/
|
||||||
export async function markdownToProseMirrorCanonical(
|
export async function markdownToProseMirrorCanonical(
|
||||||
markdownContent: string,
|
markdownContent: string,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Footnote diagnostics for imported Markdown (issue #166).
|
* Legacy footnote diagnostics for imported Markdown (issue #166).
|
||||||
*
|
*
|
||||||
* A PURE, fence-aware text scan (independent of the Markdown->ProseMirror
|
* A PURE, fence-aware text scan (independent of the Markdown->ProseMirror
|
||||||
* conversion path, so it reports the same problems for `create_page`,
|
* conversion path, so it reports the same problems for `create_page`,
|
||||||
@@ -7,11 +7,18 @@
|
|||||||
* importer still creates the page; this only surfaces footnote problems to the
|
* importer still creates the page; this only surfaces footnote problems to the
|
||||||
* caller so an agent can fix its own markup instead of shipping broken footnotes.
|
* caller so an agent can fix its own markup instead of shipping broken footnotes.
|
||||||
*
|
*
|
||||||
|
* SCOPE after #293 STEP 5: the canonical import form is now inline `^[body]`
|
||||||
|
* footnotes (handled by `@docmost/prosemirror-markdown`), where these problems
|
||||||
|
* cannot arise. This scan therefore targets the LEGACY reference-style
|
||||||
|
* (`[^id]` / `[^id]:`) markup, which is now inert on import (left as literal
|
||||||
|
* text). The warnings remain useful as an advisory nudge when an agent still
|
||||||
|
* authors the old syntax, but they no longer describe what the importer builds.
|
||||||
|
*
|
||||||
* Detected problems:
|
* Detected problems:
|
||||||
* - danglingReferences: a `[^id]` reference with no `[^id]:` definition.
|
* - danglingReferences: a `[^id]` reference with no `[^id]:` definition.
|
||||||
* - emptyDefinitions: a `[^id]:` whose (kept) text is empty/whitespace.
|
* - emptyDefinitions: a `[^id]:` whose (kept) text is empty/whitespace.
|
||||||
* - duplicateDefinitions: an id defined by two or more `[^id]:` lines (only the
|
* - duplicateDefinitions: an id defined by two or more `[^id]:` lines (only the
|
||||||
* first is kept on import — first-wins; see extractFootnotes).
|
* first would have been kept under the old first-wins import).
|
||||||
* - referencesInTables: a `[^id]` marker found in a GFM table row (heuristic:
|
* - referencesInTables: a `[^id]` marker found in a GFM table row (heuristic:
|
||||||
* the line, trimmed, starts with `|`) — footnotes in table cells often do not
|
* the line, trimmed, starts with `|`) — footnotes in table cells often do not
|
||||||
* render as expected.
|
* render as expected.
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Shared, fence-aware line lexer for footnote markdown (MCP-internal).
|
* Shared, fence-aware line lexer for legacy footnote markdown (MCP-internal).
|
||||||
*
|
*
|
||||||
* Both the importer (`extractFootnotes` in collaboration.ts, which strips
|
* Since #293 STEP 5 the markdown -> ProseMirror IMPORT path lives in the shared
|
||||||
* definition lines and rebuilds a footnotes section) and the diagnostics
|
* `@docmost/prosemirror-markdown` package (inline `^[body]` footnotes), so this
|
||||||
* (`analyzeFootnotes` in footnote-analyze.ts) must agree EXACTLY on which lines
|
* lexer no longer backs an mcp importer. It now backs ONLY the import-time
|
||||||
* are definitions and which lines are inert (inside a code fence). Sharing one
|
* diagnostics (`analyzeFootnotes` in footnote-analyze.ts), which still scan the
|
||||||
* lexer makes "the analyzer sees what the importer leaves" a structural property
|
* raw markdown for legacy reference-style `[^id]:` definition lines and surface
|
||||||
* instead of two hand-kept copies that can drift (#166 review).
|
* advisory warnings (duplicate/orphan definitions) about content that is now
|
||||||
|
* inert on import. Fence-awareness (a `[^id]:` line inside a ``` / ~~~ block is
|
||||||
|
* NOT a definition) is the property the analyzer relies on.
|
||||||
*
|
*
|
||||||
* NOTE: this is deliberately NOT shared with editor-ext's
|
* NOTE: this is deliberately NOT shared with editor-ext's
|
||||||
* `extractFootnoteDefinitions` — that lives in a different package and the
|
* `extractFootnoteDefinitions` — that lives in a different package and the
|
||||||
|
|||||||
@@ -1,903 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Convert ProseMirror/TipTap JSON content to Markdown
|
* ProseMirror -> Docmost-flavoured Markdown converter.
|
||||||
* Supports all Docmost-specific node types and extensions
|
*
|
||||||
|
* #293 STEP 5: the converter CORE now lives in the shared
|
||||||
|
* `@docmost/prosemirror-markdown` package (the canonical, lossless
|
||||||
|
* implementation carrying every git-sync fix and the #293 canon decisions).
|
||||||
|
* MCP consumes it directly instead of keeping its own drifted copy, so the two
|
||||||
|
* can never diverge again. This file is a thin re-export shim kept only so the
|
||||||
|
* many existing `./markdown-converter.js` importers (client.ts, tests) do not
|
||||||
|
* have to move.
|
||||||
*/
|
*/
|
||||||
export function convertProseMirrorToMarkdown(content: any): string {
|
export {
|
||||||
if (!content || !content.content) return "";
|
convertProseMirrorToMarkdown,
|
||||||
|
type ConvertProseMirrorToMarkdownOptions,
|
||||||
// Escape a value interpolated into an HTML double-quoted attribute value
|
} from "@docmost/prosemirror-markdown";
|
||||||
// (textAlign, colors, image src, math `text`, all data-* attrs, etc.). In the
|
|
||||||
// ATTRIBUTE context only the quote that delimits the value and the ampersand
|
|
||||||
// that starts an entity are special, so we escape ONLY & " (and ' for safety
|
|
||||||
// when single-quoted delimiters are used). We deliberately do NOT escape < or
|
|
||||||
// >: the HTML re-parser (parse5/jsdom via @tiptap/html) does NOT decode
|
|
||||||
// </> back inside attribute values, so escaping them would corrupt the
|
|
||||||
// stored data (e.g. a math node's LaTeX `a < b`) and ACCUMULATE escapes on
|
|
||||||
// every round-trip (`a < b` -> `a < b` -> `a &lt; b`). Escaping & "
|
|
||||||
// keeps the value inert against attribute-injection while staying idempotent.
|
|
||||||
// NOTE: escape ONLY & and " here. The value is always wrapped in double
|
|
||||||
// quotes, so " is the only delimiter; ' is NOT special in a double-quoted
|
|
||||||
// value, and parse5 does not decode ' back inside attribute values, so
|
|
||||||
// escaping ' would (like < >) corrupt the value and accumulate & on every
|
|
||||||
// round-trip. Escaping & and " is idempotent (parse5 decodes them back).
|
|
||||||
const escapeAttr = (value: unknown): string =>
|
|
||||||
String(value)
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/"/g, """);
|
|
||||||
|
|
||||||
// Escape a value placed as HTML element TEXT content (between tags), where
|
|
||||||
// <, >, and & are all significant. Used for text rendered inside raw-HTML
|
|
||||||
// blocks (table cells / columns) so stored characters cannot inject markup.
|
|
||||||
const escapeHtmlText = (value: unknown): string =>
|
|
||||||
String(value)
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">");
|
|
||||||
|
|
||||||
// Percent-encode characters that would break out of a markdown URL target
|
|
||||||
// (...) — whitespace/newlines and parentheses — so a stored src stays a
|
|
||||||
// single inert token (used for image/video/youtube srcs).
|
|
||||||
const encodeMdUrl = (value: unknown): string =>
|
|
||||||
String(value || "")
|
|
||||||
.replace(/\s/g, (c: string) => (c === " " ? "%20" : encodeURIComponent(c)))
|
|
||||||
.replace(/\(/g, "%28")
|
|
||||||
.replace(/\)/g, "%29");
|
|
||||||
|
|
||||||
const processNode = (node: any): string => {
|
|
||||||
const type = node.type;
|
|
||||||
const nodeContent = node.content || [];
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case "doc":
|
|
||||||
return nodeContent.map(processNode).join("\n\n");
|
|
||||||
|
|
||||||
case "paragraph":
|
|
||||||
const text = nodeContent.map(processNode).join("");
|
|
||||||
const align = node.attrs?.textAlign;
|
|
||||||
if (align && align !== "left") {
|
|
||||||
return `<div align="${escapeAttr(align)}">${text}</div>`;
|
|
||||||
}
|
|
||||||
return text || "";
|
|
||||||
|
|
||||||
case "heading":
|
|
||||||
const level = node.attrs?.level || 1;
|
|
||||||
const headingText = nodeContent.map(processNode).join("");
|
|
||||||
return "#".repeat(level) + " " + headingText;
|
|
||||||
|
|
||||||
case "text":
|
|
||||||
let textContent = node.text || "";
|
|
||||||
// Apply marks (bold, italic, code, etc.)
|
|
||||||
if (node.marks) {
|
|
||||||
// Markdown code spans (`...`) cannot carry inner formatting, so when a
|
|
||||||
// run has the `code` mark alongside ANY other mark, backtick syntax
|
|
||||||
// would leak literal ** / []() into the code text. In that case emit
|
|
||||||
// nested HTML (<code> innermost, the other marks wrapping it as HTML)
|
|
||||||
// so the output is at least well-formed and re-parseable.
|
|
||||||
//
|
|
||||||
// NOTE: this does NOT round-trip both marks. The schema's `code` mark
|
|
||||||
// has `excludes: "_"` (it excludes every other mark), so on import the
|
|
||||||
// co-occurring mark is always dropped — the run comes back as `code`
|
|
||||||
// only. We keep the emission simple and accept that the other mark is
|
|
||||||
// lost; preserving both is impossible while `code` excludes them.
|
|
||||||
// Only use the backtick form when `code` is the sole mark.
|
|
||||||
const markTypes = node.marks.map((m: any) => m.type);
|
|
||||||
const hasCode = markTypes.includes("code");
|
|
||||||
const codeCombined = hasCode && markTypes.length > 1;
|
|
||||||
for (const mark of node.marks) {
|
|
||||||
switch (mark.type) {
|
|
||||||
case "bold":
|
|
||||||
textContent = codeCombined
|
|
||||||
? `<strong>${textContent}</strong>`
|
|
||||||
: `**${textContent}**`;
|
|
||||||
break;
|
|
||||||
case "italic":
|
|
||||||
textContent = codeCombined
|
|
||||||
? `<em>${textContent}</em>`
|
|
||||||
: `*${textContent}*`;
|
|
||||||
break;
|
|
||||||
case "code":
|
|
||||||
// When combined with another mark, wrap as <code> so the
|
|
||||||
// surrounding HTML marks can nest around it; otherwise use the
|
|
||||||
// plain backtick span.
|
|
||||||
textContent = codeCombined
|
|
||||||
? `<code>${textContent}</code>`
|
|
||||||
: `\`${textContent}\``;
|
|
||||||
break;
|
|
||||||
case "link": {
|
|
||||||
const href = mark.attrs?.href || "";
|
|
||||||
const title = mark.attrs?.title;
|
|
||||||
if (codeCombined) {
|
|
||||||
// Emit an HTML anchor so it can wrap the nested <code>.
|
|
||||||
const safeHref = escapeAttr(href);
|
|
||||||
if (title) {
|
|
||||||
textContent = `<a href="${safeHref}" title="${escapeAttr(String(title))}">${textContent}</a>`;
|
|
||||||
} else {
|
|
||||||
textContent = `<a href="${safeHref}">${textContent}</a>`;
|
|
||||||
}
|
|
||||||
} else if (title) {
|
|
||||||
// Emit the optional markdown link title; escape an embedded
|
|
||||||
// double-quote so it cannot terminate the title string early.
|
|
||||||
const safeTitle = String(title).replace(/"/g, '\\"');
|
|
||||||
textContent = `[${textContent}](${href} "${safeTitle}")`;
|
|
||||||
} else {
|
|
||||||
textContent = `[${textContent}](${href})`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "strike":
|
|
||||||
textContent = codeCombined
|
|
||||||
? `<s>${textContent}</s>`
|
|
||||||
: `~~${textContent}~~`;
|
|
||||||
break;
|
|
||||||
case "underline":
|
|
||||||
textContent = `<u>${textContent}</u>`;
|
|
||||||
break;
|
|
||||||
case "subscript":
|
|
||||||
textContent = `<sub>${textContent}</sub>`;
|
|
||||||
break;
|
|
||||||
case "superscript":
|
|
||||||
textContent = `<sup>${textContent}</sup>`;
|
|
||||||
break;
|
|
||||||
case "highlight": {
|
|
||||||
// Preserve a null/empty color as a plain highlight (a bare
|
|
||||||
// <mark> with no background-color); only emit the style when a
|
|
||||||
// color is actually set, so a plain highlight is not forced to
|
|
||||||
// yellow on export.
|
|
||||||
const color = mark.attrs?.color;
|
|
||||||
textContent = color
|
|
||||||
? `<mark style="background-color: ${escapeAttr(color)}">${textContent}</mark>`
|
|
||||||
: `<mark>${textContent}</mark>`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "textStyle":
|
|
||||||
if (mark.attrs?.color) {
|
|
||||||
textContent = `<span style="color: ${escapeAttr(mark.attrs.color)}">${textContent}</span>`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "comment": {
|
|
||||||
// Emit the inline comment anchor so highlights round-trip. The
|
|
||||||
// schema's Comment mark parses span[data-comment-id] (attrs
|
|
||||||
// commentId/resolved).
|
|
||||||
const cid = mark.attrs?.commentId;
|
|
||||||
if (cid) {
|
|
||||||
const resolvedAttr = mark.attrs?.resolved
|
|
||||||
? ` data-resolved="true"`
|
|
||||||
: "";
|
|
||||||
textContent = `<span data-comment-id="${escapeAttr(cid)}"${resolvedAttr}>${textContent}</span>`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "spoiler":
|
|
||||||
// Markdown has no native spoiler syntax, so emit the same
|
|
||||||
// lossless raw HTML the editor-ext turndown rule produces; the
|
|
||||||
// schema's Spoiler mark parses span[data-spoiler] back on import.
|
|
||||||
textContent = `<span data-spoiler="true">${textContent}</span>`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return textContent;
|
|
||||||
|
|
||||||
case "codeBlock":
|
|
||||||
const language = node.attrs?.language || "";
|
|
||||||
// Strip ALL trailing newlines so the export is idempotent: marked
|
|
||||||
// re-adds exactly one trailing "\n" on import, so trimming only one
|
|
||||||
// here would let the text grow by "\n" on each round-trip. Removing
|
|
||||||
// every trailing newline makes repeated cycles stable.
|
|
||||||
const code = nodeContent
|
|
||||||
.map(processNode)
|
|
||||||
.join("")
|
|
||||||
.replace(/\n+$/, "");
|
|
||||||
return "```" + language + "\n" + code + "\n```";
|
|
||||||
|
|
||||||
case "bulletList":
|
|
||||||
return nodeContent
|
|
||||||
.map((item: any) => processListItem(item, "-"))
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
case "orderedList":
|
|
||||||
return nodeContent
|
|
||||||
.map((item: any, index: number) =>
|
|
||||||
processListItem(item, `${index + 1}.`),
|
|
||||||
)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
case "taskList":
|
|
||||||
return nodeContent.map((item: any) => processTaskItem(item)).join("\n");
|
|
||||||
|
|
||||||
case "taskItem":
|
|
||||||
// Delegate to the same helper used by taskList so multi-block and
|
|
||||||
// nested task items render and indent consistently.
|
|
||||||
return processTaskItem(node);
|
|
||||||
|
|
||||||
case "listItem":
|
|
||||||
return nodeContent.map(processNode).join("\n");
|
|
||||||
|
|
||||||
case "blockquote":
|
|
||||||
// Prefix EVERY line of EVERY child with "> " and separate block-level
|
|
||||||
// children with a blank ">" line so code blocks / multi-paragraph
|
|
||||||
// quotes round-trip correctly.
|
|
||||||
return nodeContent
|
|
||||||
.map((n: any) =>
|
|
||||||
processNode(n)
|
|
||||||
.split("\n")
|
|
||||||
.map((line: string) => (line.length ? `> ${line}` : ">"))
|
|
||||||
.join("\n"),
|
|
||||||
)
|
|
||||||
.join("\n>\n");
|
|
||||||
|
|
||||||
case "horizontalRule":
|
|
||||||
return "---";
|
|
||||||
|
|
||||||
case "hardBreak":
|
|
||||||
// Two trailing spaces before the newline encode a markdown hard break;
|
|
||||||
// a bare "\n" would be reimported as a soft break and lost.
|
|
||||||
return " \n";
|
|
||||||
|
|
||||||
case "image": {
|
|
||||||
const imgAlt = node.attrs?.alt || "";
|
|
||||||
const imgCaption = node.attrs?.caption || "";
|
|
||||||
if (imgCaption) {
|
|
||||||
// ![]() can't carry a caption, so (symmetric to video) emit a raw
|
|
||||||
// <img> wrapped in a block <div>. On import marked.parse keeps the raw
|
|
||||||
// HTML and generateJSON runs the image extension's parseHTML, which
|
|
||||||
// restores the caption from data-caption.
|
|
||||||
const parts: string[] = [`src="${escapeAttr(node.attrs?.src ?? "")}"`];
|
|
||||||
if (imgAlt) parts.push(`alt="${escapeAttr(imgAlt)}"`);
|
|
||||||
parts.push(`data-caption="${escapeAttr(imgCaption)}"`);
|
|
||||||
return `<div><img ${parts.join(" ")}></div>`;
|
|
||||||
}
|
|
||||||
// Neutralize characters that could break out of the markdown image
|
|
||||||
// URL: spaces/newlines and parentheses would terminate the (...) target
|
|
||||||
// and let a stored src inject following markdown/HTML. Percent-encode
|
|
||||||
// them so the URL stays a single inert token.
|
|
||||||
const imgSrc = encodeMdUrl(node.attrs?.src);
|
|
||||||
return ``;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "video": {
|
|
||||||
// Emit the schema-matching <video> element so generateJSON rebuilds the
|
|
||||||
// node with its attrs intact. The schema's parseHTML reads src/aria-label
|
|
||||||
// from the standard attributes and the remaining attrs from data-*.
|
|
||||||
const attrs = node.attrs || {};
|
|
||||||
const parts: string[] = [`src="${escapeAttr(attrs.src ?? "")}"`];
|
|
||||||
if (attrs.alt) parts.push(`aria-label="${escapeAttr(attrs.alt)}"`);
|
|
||||||
if (attrs.attachmentId)
|
|
||||||
parts.push(
|
|
||||||
`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`,
|
|
||||||
);
|
|
||||||
if (attrs.width != null)
|
|
||||||
parts.push(`width="${escapeAttr(attrs.width)}"`);
|
|
||||||
if (attrs.height != null)
|
|
||||||
parts.push(`height="${escapeAttr(attrs.height)}"`);
|
|
||||||
if (attrs.size != null)
|
|
||||||
parts.push(`data-size="${escapeAttr(attrs.size)}"`);
|
|
||||||
if (attrs.align)
|
|
||||||
parts.push(`data-align="${escapeAttr(attrs.align)}"`);
|
|
||||||
if (attrs.aspectRatio != null)
|
|
||||||
parts.push(`data-aspect-ratio="${escapeAttr(attrs.aspectRatio)}"`);
|
|
||||||
// Wrap in a block <div> so marked treats it as a block (a bare <video>
|
|
||||||
// is inline-level HTML and marked wraps it in <p>, leaving a spurious
|
|
||||||
// empty paragraph beside the hoisted block atom). The wrapper has no
|
|
||||||
// data-type, so the schema parser ignores it and just hoists the video.
|
|
||||||
return `<div><video ${parts.join(" ")}></video></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "youtube": {
|
|
||||||
// Emit the schema-matching div[data-type="youtube"]; the schema reads
|
|
||||||
// src from data-src and width/height/align from data-* attributes.
|
|
||||||
const attrs = node.attrs || {};
|
|
||||||
const parts: string[] = [
|
|
||||||
`data-type="youtube"`,
|
|
||||||
`data-src="${escapeAttr(attrs.src ?? "")}"`,
|
|
||||||
];
|
|
||||||
if (attrs.width != null)
|
|
||||||
parts.push(`data-width="${escapeAttr(attrs.width)}"`);
|
|
||||||
if (attrs.height != null)
|
|
||||||
parts.push(`data-height="${escapeAttr(attrs.height)}"`);
|
|
||||||
if (attrs.align)
|
|
||||||
parts.push(`data-align="${escapeAttr(attrs.align)}"`);
|
|
||||||
return `<div ${parts.join(" ")}></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "table": {
|
|
||||||
// A GFM pipe table cannot represent merged cells. If ANY cell carries
|
|
||||||
// colspan>1 or rowspan>1, a pipe table would corrupt the grid on
|
|
||||||
// re-import, so emit the WHOLE table as raw HTML <table> instead: the
|
|
||||||
// schema's table family parseHTML (tag table/tr/td/th, with colspan/
|
|
||||||
// rowspan read from the same-named HTML attrs and align via parseHTML)
|
|
||||||
// round-trips it faithfully. Otherwise keep the lighter GFM pipe table.
|
|
||||||
const tableRows: any[] = nodeContent;
|
|
||||||
if (tableRows.length === 0) return "";
|
|
||||||
const hasSpan = tableRows.some((row: any) =>
|
|
||||||
(row.content || []).some(
|
|
||||||
(cell: any) =>
|
|
||||||
(cell.attrs?.colspan ?? 1) > 1 || (cell.attrs?.rowspan ?? 1) > 1,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasSpan) {
|
|
||||||
// Render each cell's block children to HTML (marked does NOT parse
|
|
||||||
// markdown inside a raw HTML block, so emitting markdown here would
|
|
||||||
// leak literal ** / `` into the cell). blockToHtml mirrors the schema
|
|
||||||
// HTML so inner formatting re-parses into the right marks/nodes.
|
|
||||||
const renderHtmlCell = (cell: any): string => {
|
|
||||||
const tag = cell.type === "tableHeader" ? "th" : "td";
|
|
||||||
const a = cell.attrs || {};
|
|
||||||
const cellParts: string[] = [];
|
|
||||||
if ((a.colspan ?? 1) > 1)
|
|
||||||
cellParts.push(`colspan="${escapeAttr(a.colspan)}"`);
|
|
||||||
if ((a.rowspan ?? 1) > 1)
|
|
||||||
cellParts.push(`rowspan="${escapeAttr(a.rowspan)}"`);
|
|
||||||
if (a.align) cellParts.push(`align="${escapeAttr(a.align)}"`);
|
|
||||||
const open = cellParts.length
|
|
||||||
? `<${tag} ${cellParts.join(" ")}>`
|
|
||||||
: `<${tag}>`;
|
|
||||||
const inner = (cell.content || [])
|
|
||||||
.map((block: any) => blockToHtml(block))
|
|
||||||
.join("");
|
|
||||||
return `${open}${inner}</${tag}>`;
|
|
||||||
};
|
|
||||||
const htmlRows = tableRows
|
|
||||||
.map(
|
|
||||||
(row: any) =>
|
|
||||||
`<tr>${(row.content || []).map(renderHtmlCell).join("")}</tr>`,
|
|
||||||
)
|
|
||||||
.join("");
|
|
||||||
return `<table><tbody>${htmlRows}</tbody></table>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No merged cells: emit a GFM table (header row + separator) so the
|
|
||||||
// markdown can be parsed back into a table on re-import.
|
|
||||||
const rows = tableRows.map(processNode);
|
|
||||||
const headerCells = tableRows[0]?.content || [];
|
|
||||||
const columns = headerCells.length || 1;
|
|
||||||
// Derive alignment markers (:--, :-:, --:) from each header cell.
|
|
||||||
const markers = Array.from({ length: columns }, (_, i) => {
|
|
||||||
const align = headerCells[i]?.attrs?.align;
|
|
||||||
switch (align) {
|
|
||||||
case "left":
|
|
||||||
return ":--";
|
|
||||||
case "center":
|
|
||||||
return ":-:";
|
|
||||||
case "right":
|
|
||||||
return "--:";
|
|
||||||
default:
|
|
||||||
return "---";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const separator = "| " + markers.join(" | ") + " |";
|
|
||||||
return [rows[0], separator, ...rows.slice(1)].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
case "tableRow":
|
|
||||||
return "| " + nodeContent.map(processNode).join(" | ") + " |";
|
|
||||||
|
|
||||||
case "tableCell":
|
|
||||||
case "tableHeader": {
|
|
||||||
// Join multiple block children with a space (not "") so adjacent blocks
|
|
||||||
// like a paragraph followed by a list don't collide into "line1- a".
|
|
||||||
// Then collapse newlines and escape pipes so a cell containing "|" or a
|
|
||||||
// line break cannot corrupt the surrounding GFM row.
|
|
||||||
return nodeContent
|
|
||||||
.map(processNode)
|
|
||||||
.join(" ")
|
|
||||||
.replace(/\r?\n/g, " ")
|
|
||||||
.replace(/\|/g, "\\|");
|
|
||||||
}
|
|
||||||
|
|
||||||
case "callout":
|
|
||||||
const calloutType = node.attrs?.type || "info";
|
|
||||||
const calloutContent = nodeContent.map(processNode).join("\n");
|
|
||||||
return `:::${calloutType.toLowerCase()}\n${calloutContent}\n:::`;
|
|
||||||
|
|
||||||
case "details":
|
|
||||||
return nodeContent.map(processNode).join("\n");
|
|
||||||
|
|
||||||
case "detailsSummary":
|
|
||||||
const summaryText = nodeContent.map(processNode).join("");
|
|
||||||
return `<details>\n<summary>${summaryText}</summary>\n`;
|
|
||||||
|
|
||||||
case "detailsContent":
|
|
||||||
const detailsText = nodeContent.map(processNode).join("\n");
|
|
||||||
return `${detailsText}\n</details>`;
|
|
||||||
|
|
||||||
case "mathInline": {
|
|
||||||
// The schema's `text` attribute has no parseHTML, so TipTap's default
|
|
||||||
// parser reads it from the `text` HTML attribute (NOT the element's text
|
|
||||||
// content). Emit span[data-type="mathInline"] carrying the LaTeX in a
|
|
||||||
// `text="..."` attribute so it round-trips. marked cannot parse $...$
|
|
||||||
// back, so the previous form was lossy.
|
|
||||||
const inlineMath = node.attrs?.text || "";
|
|
||||||
return `<span data-type="mathInline" data-katex="true" text="${escapeAttr(inlineMath)}"></span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "mathBlock": {
|
|
||||||
// Same as mathInline: the LaTeX must ride in the `text` HTML attribute
|
|
||||||
// for the schema's default parser to recover it.
|
|
||||||
const blockMath = node.attrs?.text || "";
|
|
||||||
return `<div data-type="mathBlock" data-katex="true" text="${escapeAttr(blockMath)}"></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "mention": {
|
|
||||||
// Emit span[data-type="mention"] with the schema's data-* attributes so
|
|
||||||
// generateJSON rebuilds the mention node instead of leaving "@label"
|
|
||||||
// plain text that cannot re-parse.
|
|
||||||
const attrs = node.attrs || {};
|
|
||||||
const parts: string[] = [`data-type="mention"`];
|
|
||||||
if (attrs.id) parts.push(`data-id="${escapeAttr(attrs.id)}"`);
|
|
||||||
if (attrs.label)
|
|
||||||
parts.push(`data-label="${escapeAttr(attrs.label)}"`);
|
|
||||||
if (attrs.entityType)
|
|
||||||
parts.push(`data-entity-type="${escapeAttr(attrs.entityType)}"`);
|
|
||||||
if (attrs.entityId)
|
|
||||||
parts.push(`data-entity-id="${escapeAttr(attrs.entityId)}"`);
|
|
||||||
if (attrs.slugId)
|
|
||||||
parts.push(`data-slug-id="${escapeAttr(attrs.slugId)}"`);
|
|
||||||
if (attrs.creatorId)
|
|
||||||
parts.push(`data-creator-id="${escapeAttr(attrs.creatorId)}"`);
|
|
||||||
if (attrs.anchorId)
|
|
||||||
parts.push(`data-anchor-id="${escapeAttr(attrs.anchorId)}"`);
|
|
||||||
// Keep the label as visible text content too; the schema reads attrs
|
|
||||||
// from data-*, so the inner text is purely cosmetic and harmless.
|
|
||||||
const mentionLabel = attrs.label || attrs.id || "";
|
|
||||||
// The label is visible element TEXT content here (the data-* attrs above
|
|
||||||
// carry the real values), so escape it for the text context, not attrs.
|
|
||||||
return `<span ${parts.join(" ")}>@${escapeHtmlText(mentionLabel)}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "footnoteReference": {
|
|
||||||
// Pandoc/GFM inline marker. The number is derived (not stored), so the
|
|
||||||
// id is the stable anchor.
|
|
||||||
const fnId = node.attrs?.id || "";
|
|
||||||
return fnId ? `[^${fnId}]` : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
case "footnotesList":
|
|
||||||
// The container renders its definitions, each on its own `[^id]: ...`
|
|
||||||
// line. A blank line separates the body from the notes block.
|
|
||||||
return nodeContent.map(processNode).join("\n");
|
|
||||||
|
|
||||||
case "footnoteDefinition": {
|
|
||||||
const defId = node.attrs?.id || "";
|
|
||||||
// Collapse the definition's paragraphs into a single line; multi-line
|
|
||||||
// footnotes are a v2 refinement.
|
|
||||||
const defText = nodeContent
|
|
||||||
.map(processNode)
|
|
||||||
.join(" ")
|
|
||||||
.replace(/\s*\n+\s*/g, " ")
|
|
||||||
.trim();
|
|
||||||
return defId ? `[^${defId}]: ${defText}` : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
case "attachment": {
|
|
||||||
// BUG FIX: the old code read node.attrs.fileName / node.attrs.src, but
|
|
||||||
// the schema stores name/url (plus mime/size/attachmentId). Emit the
|
|
||||||
// schema-matching div[data-type="attachment"] with data-attachment-*
|
|
||||||
// attrs so the node round-trips instead of degrading to a markdown link.
|
|
||||||
const attrs = node.attrs || {};
|
|
||||||
const parts: string[] = [
|
|
||||||
`data-type="attachment"`,
|
|
||||||
`data-attachment-url="${escapeAttr(attrs.url ?? "")}"`,
|
|
||||||
];
|
|
||||||
if (attrs.name)
|
|
||||||
parts.push(`data-attachment-name="${escapeAttr(attrs.name)}"`);
|
|
||||||
if (attrs.mime)
|
|
||||||
parts.push(`data-attachment-mime="${escapeAttr(attrs.mime)}"`);
|
|
||||||
if (attrs.size != null)
|
|
||||||
parts.push(`data-attachment-size="${escapeAttr(attrs.size)}"`);
|
|
||||||
if (attrs.attachmentId)
|
|
||||||
parts.push(
|
|
||||||
`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`,
|
|
||||||
);
|
|
||||||
return `<div ${parts.join(" ")}></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "drawio":
|
|
||||||
case "excalidraw": {
|
|
||||||
// Emit the schema-matching div[data-type=...] carrying the diagram's
|
|
||||||
// attrs as data-* (the schema's diagramAttributes reads src/title/alt/
|
|
||||||
// width/height/size/aspectRatio/align/attachmentId from data-*), so the
|
|
||||||
// diagram round-trips instead of degrading to a lossy placeholder.
|
|
||||||
const attrs = node.attrs || {};
|
|
||||||
const parts: string[] = [
|
|
||||||
`data-type="${type}"`,
|
|
||||||
`data-src="${escapeAttr(attrs.src ?? "")}"`,
|
|
||||||
];
|
|
||||||
if (attrs.title != null)
|
|
||||||
parts.push(`data-title="${escapeAttr(attrs.title)}"`);
|
|
||||||
if (attrs.alt != null) parts.push(`data-alt="${escapeAttr(attrs.alt)}"`);
|
|
||||||
if (attrs.width != null)
|
|
||||||
parts.push(`data-width="${escapeAttr(attrs.width)}"`);
|
|
||||||
if (attrs.height != null)
|
|
||||||
parts.push(`data-height="${escapeAttr(attrs.height)}"`);
|
|
||||||
if (attrs.size != null)
|
|
||||||
parts.push(`data-size="${escapeAttr(attrs.size)}"`);
|
|
||||||
if (attrs.aspectRatio != null)
|
|
||||||
parts.push(`data-aspect-ratio="${escapeAttr(attrs.aspectRatio)}"`);
|
|
||||||
if (attrs.align)
|
|
||||||
parts.push(`data-align="${escapeAttr(attrs.align)}"`);
|
|
||||||
if (attrs.attachmentId)
|
|
||||||
parts.push(
|
|
||||||
`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`,
|
|
||||||
);
|
|
||||||
return `<div ${parts.join(" ")}></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "embed": {
|
|
||||||
// Emit the schema-matching div[data-type="embed"]; the schema reads
|
|
||||||
// src/provider/align/width/height from data-* attributes so the node
|
|
||||||
// (and its provider iframe info) survives the round-trip.
|
|
||||||
const attrs = node.attrs || {};
|
|
||||||
const parts: string[] = [
|
|
||||||
`data-type="embed"`,
|
|
||||||
`data-src="${escapeAttr(attrs.src ?? "")}"`,
|
|
||||||
`data-provider="${escapeAttr(attrs.provider ?? "")}"`,
|
|
||||||
];
|
|
||||||
if (attrs.align)
|
|
||||||
parts.push(`data-align="${escapeAttr(attrs.align)}"`);
|
|
||||||
if (attrs.width != null)
|
|
||||||
parts.push(`data-width="${escapeAttr(attrs.width)}"`);
|
|
||||||
if (attrs.height != null)
|
|
||||||
parts.push(`data-height="${escapeAttr(attrs.height)}"`);
|
|
||||||
return `<div ${parts.join(" ")}></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "audio": {
|
|
||||||
// Emit the schema-matching <audio> element (was emitting nothing). The
|
|
||||||
// schema reads src from src and attachmentId/size from data-*.
|
|
||||||
const attrs = node.attrs || {};
|
|
||||||
const parts: string[] = [`src="${escapeAttr(attrs.src ?? "")}"`];
|
|
||||||
if (attrs.attachmentId)
|
|
||||||
parts.push(
|
|
||||||
`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`,
|
|
||||||
);
|
|
||||||
if (attrs.size != null)
|
|
||||||
parts.push(`data-size="${escapeAttr(attrs.size)}"`);
|
|
||||||
// Wrap in a block <div> for the same reason as video: a bare <audio> is
|
|
||||||
// inline-level HTML that marked would wrap in <p>.
|
|
||||||
return `<div><audio ${parts.join(" ")}></audio></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "pdf": {
|
|
||||||
// Emit the schema-matching div[data-type="pdf"] (was emitting nothing).
|
|
||||||
// The schema reads src/width/height from standard attrs and name/
|
|
||||||
// attachmentId/size from data-*.
|
|
||||||
const attrs = node.attrs || {};
|
|
||||||
const parts: string[] = [
|
|
||||||
`data-type="pdf"`,
|
|
||||||
`src="${escapeAttr(attrs.src ?? "")}"`,
|
|
||||||
];
|
|
||||||
if (attrs.name) parts.push(`data-name="${escapeAttr(attrs.name)}"`);
|
|
||||||
if (attrs.attachmentId)
|
|
||||||
parts.push(
|
|
||||||
`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`,
|
|
||||||
);
|
|
||||||
if (attrs.size != null)
|
|
||||||
parts.push(`data-size="${escapeAttr(attrs.size)}"`);
|
|
||||||
if (attrs.width != null)
|
|
||||||
parts.push(`width="${escapeAttr(attrs.width)}"`);
|
|
||||||
if (attrs.height != null)
|
|
||||||
parts.push(`height="${escapeAttr(attrs.height)}"`);
|
|
||||||
return `<div ${parts.join(" ")}></div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "columns": {
|
|
||||||
// Emit the schema-matching div[data-type="columns"] wrapper so the
|
|
||||||
// multi-column layout survives. Without a case the children were
|
|
||||||
// concatenated with no separator and the text merged. The schema reads
|
|
||||||
// layout from data-layout and widthMode from data-width-mode. The whole
|
|
||||||
// block is raw HTML, so render children via blockToHtml (NOT markdown,
|
|
||||||
// which marked would not re-parse inside a raw HTML block).
|
|
||||||
const attrs = node.attrs || {};
|
|
||||||
const parts: string[] = [`data-type="columns"`];
|
|
||||||
if (attrs.layout)
|
|
||||||
parts.push(`data-layout="${escapeAttr(attrs.layout)}"`);
|
|
||||||
if (attrs.widthMode && attrs.widthMode !== "normal")
|
|
||||||
parts.push(`data-width-mode="${escapeAttr(attrs.widthMode)}"`);
|
|
||||||
const inner = nodeContent.map((n: any) => blockToHtml(n)).join("");
|
|
||||||
return `<div ${parts.join(" ")}>${inner}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "column": {
|
|
||||||
// Emit the schema-matching div[data-type="column"]; the schema reads the
|
|
||||||
// column width from data-width. Children are rendered as HTML so their
|
|
||||||
// formatting survives inside this raw HTML block.
|
|
||||||
const attrs = node.attrs || {};
|
|
||||||
const parts: string[] = [`data-type="column"`];
|
|
||||||
if (attrs.width)
|
|
||||||
parts.push(`data-width="${escapeAttr(attrs.width)}"`);
|
|
||||||
const inner = nodeContent.map((n: any) => blockToHtml(n)).join("");
|
|
||||||
return `<div ${parts.join(" ")}>${inner}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "subpages":
|
|
||||||
return "{{SUBPAGES}}";
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Fallback: process children
|
|
||||||
return nodeContent.map(processNode).join("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render inline content (text runs + their marks) to HTML. Used by the raw
|
|
||||||
// HTML fallbacks (spanned tables, columns) where marked will NOT re-parse
|
|
||||||
// markdown, so backtick/asterisk/bracket syntax would otherwise leak as
|
|
||||||
// literal characters. Each mark is mirrored to the HTML the schema's parseHTML
|
|
||||||
// accepts so it re-imports as the matching ProseMirror mark.
|
|
||||||
const inlineToHtml = (inlineNodes: any[]): string =>
|
|
||||||
(inlineNodes || [])
|
|
||||||
.map((n: any) => {
|
|
||||||
if (n.type === "hardBreak") return "<br>";
|
|
||||||
if (n.type !== "text") {
|
|
||||||
// Inline atoms (mention, mathInline) already emit schema HTML.
|
|
||||||
return processNode(n);
|
|
||||||
}
|
|
||||||
let t = escapeHtmlText(n.text || "");
|
|
||||||
for (const mark of n.marks || []) {
|
|
||||||
switch (mark.type) {
|
|
||||||
case "bold":
|
|
||||||
t = `<strong>${t}</strong>`;
|
|
||||||
break;
|
|
||||||
case "italic":
|
|
||||||
t = `<em>${t}</em>`;
|
|
||||||
break;
|
|
||||||
case "code":
|
|
||||||
t = `<code>${t}</code>`;
|
|
||||||
break;
|
|
||||||
case "strike":
|
|
||||||
t = `<s>${t}</s>`;
|
|
||||||
break;
|
|
||||||
case "underline":
|
|
||||||
t = `<u>${t}</u>`;
|
|
||||||
break;
|
|
||||||
case "subscript":
|
|
||||||
t = `<sub>${t}</sub>`;
|
|
||||||
break;
|
|
||||||
case "superscript":
|
|
||||||
t = `<sup>${t}</sup>`;
|
|
||||||
break;
|
|
||||||
case "link":
|
|
||||||
t = `<a href="${escapeAttr(mark.attrs?.href || "")}">${t}</a>`;
|
|
||||||
break;
|
|
||||||
case "highlight":
|
|
||||||
t = mark.attrs?.color
|
|
||||||
? `<mark style="background-color: ${escapeAttr(mark.attrs.color)}">${t}</mark>`
|
|
||||||
: `<mark>${t}</mark>`;
|
|
||||||
break;
|
|
||||||
case "textStyle":
|
|
||||||
if (mark.attrs?.color)
|
|
||||||
t = `<span style="color: ${escapeAttr(mark.attrs.color)}">${t}</span>`;
|
|
||||||
break;
|
|
||||||
case "comment":
|
|
||||||
// Inline comment anchor inside a raw-HTML container (columns /
|
|
||||||
// spanned table cells), so commented text there also round-trips.
|
|
||||||
if (mark.attrs?.commentId) {
|
|
||||||
const r = mark.attrs?.resolved ? ` data-resolved="true"` : "";
|
|
||||||
t = `<span data-comment-id="${escapeAttr(mark.attrs.commentId)}"${r}>${t}</span>`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return t;
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
// Emit the schema-matching <img> for an image node. Shared so the image is
|
|
||||||
// emitted as real HTML wherever a raw-HTML container needs it (inside a column
|
|
||||||
// or a spanned table cell), where markdown `` would NOT be re-parsed
|
|
||||||
// and would survive as literal text. The Image extension reads src/alt from
|
|
||||||
// the standard attributes; the Docmost extra attrs (width/height/align/size/
|
|
||||||
// attachmentId/aspectRatio) are global attributes read from same-named DOM
|
|
||||||
// attributes, so emit them by name.
|
|
||||||
const imageToHtml = (node: any): string => {
|
|
||||||
const attrs = node.attrs || {};
|
|
||||||
const parts: string[] = [`src="${escapeAttr(attrs.src ?? "")}"`];
|
|
||||||
if (attrs.alt) parts.push(`alt="${escapeAttr(attrs.alt)}"`);
|
|
||||||
if (attrs.caption)
|
|
||||||
parts.push(`data-caption="${escapeAttr(attrs.caption)}"`);
|
|
||||||
if (attrs.title) parts.push(`title="${escapeAttr(attrs.title)}"`);
|
|
||||||
if (attrs.width != null) parts.push(`width="${escapeAttr(attrs.width)}"`);
|
|
||||||
if (attrs.height != null) parts.push(`height="${escapeAttr(attrs.height)}"`);
|
|
||||||
if (attrs.align) parts.push(`align="${escapeAttr(attrs.align)}"`);
|
|
||||||
if (attrs.size != null) parts.push(`data-size="${escapeAttr(attrs.size)}"`);
|
|
||||||
if (attrs.attachmentId)
|
|
||||||
parts.push(`data-attachment-id="${escapeAttr(attrs.attachmentId)}"`);
|
|
||||||
if (attrs.aspectRatio != null)
|
|
||||||
parts.push(`data-aspect-ratio="${escapeAttr(attrs.aspectRatio)}"`);
|
|
||||||
return `<img ${parts.join(" ")}>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Emit the schema-matching div[data-type="callout"] for a callout node. The
|
|
||||||
// schema reads the banner type from data-callout-type. Children are rendered
|
|
||||||
// as HTML so they survive inside a raw-HTML container.
|
|
||||||
const calloutToHtml = (node: any): string => {
|
|
||||||
const type = (node.attrs?.type || "info").toLowerCase();
|
|
||||||
const inner = (node.content || []).map(blockToHtml).join("");
|
|
||||||
return `<div data-type="callout" data-callout-type="${escapeAttr(type)}">${inner}</div>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Emit a schema-matching <details> tree. The schema parses <details>,
|
|
||||||
// summary[data-type="detailsSummary"], and div[data-type="detailsContent"].
|
|
||||||
const detailsToHtml = (node: any): string => {
|
|
||||||
const inner = (node.content || []).map(blockToHtml).join("");
|
|
||||||
return `<details>${inner}</details>`;
|
|
||||||
};
|
|
||||||
const detailsSummaryToHtml = (node: any): string =>
|
|
||||||
`<summary data-type="detailsSummary">${inlineToHtml(node.content || [])}</summary>`;
|
|
||||||
const detailsContentToHtml = (node: any): string => {
|
|
||||||
const inner = (node.content || []).map(blockToHtml).join("");
|
|
||||||
return `<div data-type="detailsContent">${inner}</div>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Emit the schema-matching taskList/taskItem HTML. bridgeTaskLists (in
|
|
||||||
// collaboration.ts) recognizes ul[data-type="taskList"] with
|
|
||||||
// li[data-type="taskItem"][data-checked]; emitting that directly here keeps
|
|
||||||
// task lists inside columns/cells from degrading to literal "- [ ]" text.
|
|
||||||
const taskListToHtml = (node: any): string => {
|
|
||||||
const items = (node.content || [])
|
|
||||||
.map((it: any) => {
|
|
||||||
const checked = it.attrs?.checked ? "true" : "false";
|
|
||||||
return `<li data-type="taskItem" data-checked="${checked}">${blockChildrenToHtml(it)}</li>`;
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
return `<ul data-type="taskList">${items}</ul>`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render a block node to HTML for the raw-HTML containers (spanned tables,
|
|
||||||
// columns). marked does NOT re-parse markdown inside a raw-HTML block, so
|
|
||||||
// EVERY block type that can appear inside a column or a spanned cell must be
|
|
||||||
// emitted as schema-matching HTML here — never as markdown, or it would land
|
|
||||||
// as literal text on re-import. Nodes whose processNode case already produces
|
|
||||||
// schema-matching HTML (math/media/embed/attachment/nested columns/spanned
|
|
||||||
// table) are delegated to processNode; the markdown-emitting cases
|
|
||||||
// (image/blockquote/callout/details/hr/taskList) get explicit HTML here.
|
|
||||||
const blockToHtml = (block: any): string => {
|
|
||||||
const children = block.content || [];
|
|
||||||
switch (block.type) {
|
|
||||||
case "paragraph":
|
|
||||||
return `<p>${inlineToHtml(children)}</p>`;
|
|
||||||
case "heading": {
|
|
||||||
const level = block.attrs?.level || 1;
|
|
||||||
return `<h${level}>${inlineToHtml(children)}</h${level}>`;
|
|
||||||
}
|
|
||||||
case "bulletList":
|
|
||||||
return `<ul>${children
|
|
||||||
.map((li: any) => `<li>${blockChildrenToHtml(li)}</li>`)
|
|
||||||
.join("")}</ul>`;
|
|
||||||
case "orderedList":
|
|
||||||
return `<ol>${children
|
|
||||||
.map((li: any) => `<li>${blockChildrenToHtml(li)}</li>`)
|
|
||||||
.join("")}</ol>`;
|
|
||||||
case "codeBlock": {
|
|
||||||
const lang = block.attrs?.language || "";
|
|
||||||
// The code itself is element TEXT content (between <code> tags), so it
|
|
||||||
// must escape < > & — NOT the attribute escaper. The language rides in
|
|
||||||
// a class ATTRIBUTE, so it uses escapeAttr.
|
|
||||||
const code = escapeHtmlText(
|
|
||||||
children
|
|
||||||
.map(processNode)
|
|
||||||
.join("")
|
|
||||||
.replace(/\n+$/, ""),
|
|
||||||
);
|
|
||||||
const cls = lang ? ` class="language-${escapeAttr(lang)}"` : "";
|
|
||||||
return `<pre><code${cls}>${code}</code></pre>`;
|
|
||||||
}
|
|
||||||
case "image":
|
|
||||||
return imageToHtml(block);
|
|
||||||
case "blockquote":
|
|
||||||
return `<blockquote>${children.map(blockToHtml).join("")}</blockquote>`;
|
|
||||||
case "horizontalRule":
|
|
||||||
return "<hr>";
|
|
||||||
case "callout":
|
|
||||||
return calloutToHtml(block);
|
|
||||||
case "details":
|
|
||||||
return detailsToHtml(block);
|
|
||||||
case "detailsSummary":
|
|
||||||
return detailsSummaryToHtml(block);
|
|
||||||
case "detailsContent":
|
|
||||||
return detailsContentToHtml(block);
|
|
||||||
case "taskList":
|
|
||||||
return taskListToHtml(block);
|
|
||||||
case "taskItem":
|
|
||||||
// A bare taskItem (outside a taskList) still needs a wrapping list so
|
|
||||||
// the schema parses it; wrap it in a single-item taskList.
|
|
||||||
return taskListToHtml({ content: [block] });
|
|
||||||
// table (incl. spanned), columns/column, math, media, embed, attachment,
|
|
||||||
// mention, etc. already emit schema-matching HTML from processNode.
|
|
||||||
case "table":
|
|
||||||
case "columns":
|
|
||||||
case "column":
|
|
||||||
case "mathBlock":
|
|
||||||
case "video":
|
|
||||||
case "audio":
|
|
||||||
case "pdf":
|
|
||||||
case "youtube":
|
|
||||||
case "embed":
|
|
||||||
case "attachment":
|
|
||||||
case "drawio":
|
|
||||||
case "excalidraw":
|
|
||||||
return processNode(block);
|
|
||||||
default:
|
|
||||||
// Any still-unhandled block type: NEVER fall back to markdown inside a
|
|
||||||
// raw-HTML block (it would become literal text). Wrap its rendered
|
|
||||||
// children in a <div> so their content is preserved; if it has no block
|
|
||||||
// children, render its inline content instead.
|
|
||||||
if (children.length && children.some((c: any) => c.type !== "text")) {
|
|
||||||
return `<div>${children.map(blockToHtml).join("")}</div>`;
|
|
||||||
}
|
|
||||||
return `<div>${inlineToHtml(children)}</div>`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render the block children of a list item to HTML (a listItem holds block+
|
|
||||||
// content). Mirrors processListItem but for the HTML fallback path.
|
|
||||||
const blockChildrenToHtml = (item: any): string =>
|
|
||||||
(item.content || []).map((b: any) => blockToHtml(b)).join("");
|
|
||||||
|
|
||||||
// Indent the rendered children of a list item under a marker prefix.
|
|
||||||
// Each child block is a (possibly multi-line) string. The very first physical
|
|
||||||
// line of the first child carries the marker (e.g. "- " or "1. "); EVERY
|
|
||||||
// other line — the remaining lines of the first child AND all lines of every
|
|
||||||
// subsequent child (nested lists, code blocks, extra paragraphs) — is indented
|
|
||||||
// to align under the marker. Without indenting these continuation lines, the
|
|
||||||
// 2nd/3rd line of a nested child collapses to column 0 and escapes the list.
|
|
||||||
//
|
|
||||||
// The continuation indent MUST equal the LIST marker width, which is not the
|
|
||||||
// same as the visible prefix width:
|
|
||||||
// - bullet "- " -> 2 columns
|
|
||||||
// - task "- [ ] " -> marker is still "- " (the "[ ] " is content), 2
|
|
||||||
// - ordered "1. "/"10. " -> 3/4 columns, scaling with the number's digits
|
|
||||||
// CommonMark anchors nested content to the marker column, so an ordered item
|
|
||||||
// indented to only 2 columns would be re-parsed as a sibling/loose content on
|
|
||||||
// re-import. Callers therefore pass the exact indent width to use.
|
|
||||||
const indentItemChildren = (
|
|
||||||
childStrings: string[],
|
|
||||||
prefix: string,
|
|
||||||
indentWidth: number,
|
|
||||||
): string => {
|
|
||||||
const indent = " ".repeat(indentWidth);
|
|
||||||
const lines: string[] = [];
|
|
||||||
childStrings.forEach((child, childIndex) => {
|
|
||||||
child.split("\n").forEach((line, lineIndex) => {
|
|
||||||
if (childIndex === 0 && lineIndex === 0) {
|
|
||||||
// First physical line of the first block gets the marker.
|
|
||||||
lines.push(`${prefix} ${line}`);
|
|
||||||
} else {
|
|
||||||
// Indent every continuation line by the marker width; keep blank
|
|
||||||
// lines blank rather than emitting trailing whitespace.
|
|
||||||
lines.push(line.length ? `${indent}${line}` : "");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return lines.join("\n");
|
|
||||||
};
|
|
||||||
|
|
||||||
const processListItem = (item: any, prefix: string): string => {
|
|
||||||
const itemContent = item.content || [];
|
|
||||||
const childStrings = itemContent.map(processNode);
|
|
||||||
if (childStrings.length === 0) return prefix;
|
|
||||||
// The rendered marker is `${prefix} ` (prefix + one space), so its width —
|
|
||||||
// and thus the continuation indent — is prefix.length + 1. This is correct
|
|
||||||
// for both bullet ("-" -> 2) and ordered ("1." -> 3, "10." -> 4) markers,
|
|
||||||
// since for those the visible prefix IS the list marker.
|
|
||||||
return indentItemChildren(childStrings, prefix, prefix.length + 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const processTaskItem = (item: any): string => {
|
|
||||||
const checked = item.attrs?.checked || false;
|
|
||||||
const checkbox = checked ? "[x]" : "[ ]";
|
|
||||||
const prefix = `- ${checkbox}`;
|
|
||||||
const itemContent = item.content || [];
|
|
||||||
const childStrings = itemContent.map(processNode);
|
|
||||||
// An empty task item still needs its checkbox marker; without this guard
|
|
||||||
// the indent below produces "" and the "- [ ]"/"- [x]" row disappears.
|
|
||||||
if (childStrings.length === 0) return prefix;
|
|
||||||
// The list marker for a task item is just "- " (2 columns); the "[ ] "/"[x] "
|
|
||||||
// checkbox is item content, NOT part of the marker. So the continuation
|
|
||||||
// indent is a fixed 2 — do NOT derive it from the wider prefix.length.
|
|
||||||
return indentItemChildren(childStrings, prefix, 2);
|
|
||||||
};
|
|
||||||
|
|
||||||
return processNode(content).trim();
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,136 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Self-contained Docmost-flavoured Markdown document (custom extensions).
|
* Self-contained Docmost-flavoured Markdown document envelope (`docmost:meta` /
|
||||||
|
* `docmost:comments` blocks).
|
||||||
*
|
*
|
||||||
* A single `.md` file that packages everything needed to losslessly round-trip
|
* #293 STEP 5: this envelope is now owned by the shared
|
||||||
* a page through "download -> edit body -> re-upload":
|
* `@docmost/prosemirror-markdown` package (the mcp copy was byte-identical to
|
||||||
* - a leading `docmost:meta` block: a one-line JSON object with page identity;
|
* the package's, so re-exporting is lossless). Kept as a thin shim so the
|
||||||
* - the Markdown body (carrying inline comment anchors and diagrams as HTML);
|
* existing `./markdown-document.js` importers (client.ts, tests) do not move.
|
||||||
* - a trailing `docmost:comments` block: a one-line JSON array of comment
|
|
||||||
* threads.
|
|
||||||
*
|
|
||||||
* Both metadata blocks are HTML comments on purpose: `marked`/`generateJSON`
|
|
||||||
* drop HTML comments, so even if the WHOLE file were ever fed straight to the
|
|
||||||
* importer without first stripping the blocks, the metadata cannot leak into the
|
|
||||||
* document. (A fenced ```docmost-comments``` block would WRONGLY become a
|
|
||||||
* codeBlock node, so a fenced block is deliberately NOT used.)
|
|
||||||
*
|
|
||||||
* The delimiter literals may legitimately appear in the BODY too (e.g. a user
|
|
||||||
* re-pastes an exported `.md` into a page, or a page documents this very
|
|
||||||
* format). To stay robust, parsing treats only the FINAL, document-ending
|
|
||||||
* `docmost:comments` block as metadata: it is the last `<!-- docmost:comments`
|
|
||||||
* opener whose closing `-->` sits at the very end of the file. Any earlier
|
|
||||||
* literal occurrence is left in the body untouched.
|
|
||||||
*
|
|
||||||
* NOTE on comments: in this version the comment THREAD records are preserved in
|
|
||||||
* the file but are NOT pushed back to the server on import — only the inline
|
|
||||||
* comment marks (anchors) embedded in the body are restored. Managing comment
|
|
||||||
* records stays with the comment tools/UI.
|
|
||||||
*/
|
*/
|
||||||
|
export {
|
||||||
export interface DocmostMdMeta {
|
serializeDocmostMarkdown,
|
||||||
version: number;
|
parseDocmostMarkdown,
|
||||||
pageId?: string;
|
serializeDocmostMarkdownBody,
|
||||||
slugId?: string;
|
} from "@docmost/prosemirror-markdown";
|
||||||
title?: string;
|
export type { DocmostMdMeta } from "@docmost/prosemirror-markdown";
|
||||||
spaceId?: string;
|
|
||||||
parentPageId?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match the leading meta block (allow leading whitespace). Capture group 1 is
|
|
||||||
// the JSON text between the markers.
|
|
||||||
const META_RE = /^\s*<!--\s*docmost:meta\s*\n([\s\S]*?)\n-->/;
|
|
||||||
// Match a `docmost:comments` opener. Used globally to scan for the LAST opener
|
|
||||||
// rather than end-anchoring a single regex (which would mis-capture across a
|
|
||||||
// literal opener that appears earlier in the body).
|
|
||||||
const COMMENTS_OPEN_RE = /<!--[ \t]*docmost:comments[ \t]*\r?\n/g;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assemble the full self-contained markdown file: meta block, body, and the
|
|
||||||
* comments block. The meta block is always emitted; the comments block is always
|
|
||||||
* emitted too (with `[]` when there are no comments) so the format stays uniform
|
|
||||||
* and parsing stays simple.
|
|
||||||
*/
|
|
||||||
export function serializeDocmostMarkdown(
|
|
||||||
meta: DocmostMdMeta,
|
|
||||||
body: string,
|
|
||||||
comments: any[],
|
|
||||||
): string {
|
|
||||||
const metaJson = JSON.stringify(meta);
|
|
||||||
const commentsJson = JSON.stringify(Array.isArray(comments) ? comments : []);
|
|
||||||
const trimmedBody = (body ?? "").trim();
|
|
||||||
return (
|
|
||||||
`<!-- docmost:meta\n${metaJson}\n-->\n\n` +
|
|
||||||
`${trimmedBody}\n\n` +
|
|
||||||
`<!-- docmost:comments\n${commentsJson}\n-->\n`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Split a self-contained file back into its parts. Tolerant: if the meta or
|
|
||||||
* comments block is missing (e.g. a hand-written plain-markdown file), the
|
|
||||||
* corresponding value is returned as `null` and the whole input is treated as
|
|
||||||
* the body. This never throws on a MISSING block; only a `JSON.parse` failure
|
|
||||||
* inside a block that IS present is surfaced as a thrown Error with a clear
|
|
||||||
* message. Robust to `\r\n` line endings.
|
|
||||||
*/
|
|
||||||
export function parseDocmostMarkdown(full: string): {
|
|
||||||
meta: DocmostMdMeta | null;
|
|
||||||
body: string;
|
|
||||||
comments: any[] | null;
|
|
||||||
} {
|
|
||||||
// Normalize line endings so the anchored regexes work regardless of CRLF.
|
|
||||||
const normalized = (full ?? "").replace(/\r\n/g, "\n");
|
|
||||||
|
|
||||||
// Extract the leading meta block (start-anchored — already unambiguous).
|
|
||||||
let meta: DocmostMdMeta | null = null;
|
|
||||||
let metaEnd = 0;
|
|
||||||
const metaMatch = normalized.match(META_RE);
|
|
||||||
if (metaMatch) {
|
|
||||||
try {
|
|
||||||
meta = JSON.parse(metaMatch[1]);
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid docmost:meta JSON block: ${
|
|
||||||
e instanceof Error ? e.message : String(e)
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Body starts right after the matched meta block.
|
|
||||||
metaEnd = (metaMatch.index ?? 0) + metaMatch[0].length;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the LAST `<!-- docmost:comments` opener; the real file-level block is
|
|
||||||
// the final one whose closing `-->` ends the document. Any earlier literal
|
|
||||||
// occurrence inside the body (e.g. a re-pasted export) is left in the body.
|
|
||||||
let lastOpenStart = -1;
|
|
||||||
let lastOpenEnd = -1;
|
|
||||||
let m: RegExpExecArray | null;
|
|
||||||
COMMENTS_OPEN_RE.lastIndex = 0;
|
|
||||||
while ((m = COMMENTS_OPEN_RE.exec(normalized)) !== null) {
|
|
||||||
lastOpenStart = m.index;
|
|
||||||
lastOpenEnd = m.index + m[0].length;
|
|
||||||
}
|
|
||||||
|
|
||||||
let comments: any[] | null = null;
|
|
||||||
let bodyEnd = normalized.length;
|
|
||||||
if (lastOpenStart !== -1) {
|
|
||||||
const rest = normalized.slice(lastOpenEnd);
|
|
||||||
const close = rest.match(/\r?\n-->[ \t]*\r?\n?\s*$/); // closer must end the doc
|
|
||||||
if (close) {
|
|
||||||
const jsonText = rest.slice(0, close.index);
|
|
||||||
try {
|
|
||||||
comments = JSON.parse(jsonText);
|
|
||||||
} catch (e) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid docmost:comments JSON block: ${
|
|
||||||
e instanceof Error ? e.message : String(e)
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
bodyEnd = lastOpenStart; // strip from the opener to end of document
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = normalized.slice(metaEnd, bodyEnd).trim();
|
|
||||||
return { meta, body, comments };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* Pure, network-free in-page search over a ProseMirror/TipTap document tree.
|
||||||
|
*
|
||||||
|
* `searchInDoc(doc, query, opts)` finds every occurrence of a literal substring
|
||||||
|
* (default) or a regular expression across the page's TEXT CONTAINERS and
|
||||||
|
* reports WHERE each match is — the container's ref (for get_node/patch_node;
|
||||||
|
* see the SearchMatch.nodeId note for the `#<index>` caveat), the top-level
|
||||||
|
* block index, and a short context window around the hit. It never touches the
|
||||||
|
* network, the DB, or the schema mirror; like `comment-anchor.ts` it is
|
||||||
|
* isolated-testable.
|
||||||
|
*
|
||||||
|
* REGEX ENGINE: with `regex:true` the pattern is compiled with RE2 (Google's
|
||||||
|
* linear-time engine), NOT the JS `RegExp`. RE2 has no backtracking, so a
|
||||||
|
* catastrophic pattern (e.g. `(a+)+$`) can never wedge the shared event loop —
|
||||||
|
* it runs in linear time. The trade-off is that RE2 does not support the
|
||||||
|
* backtracking-only features lookaround (`(?=…)`, `(?<=…)`) and backreferences
|
||||||
|
* (`\1`); such a pattern is rejected up front with a clear tool error (see
|
||||||
|
* searchInDoc) rather than being run, which is the desired behaviour — a clear
|
||||||
|
* error the agent can fix beats a server hang.
|
||||||
|
*
|
||||||
|
* WHY plain text (not markdown): each container's inline text is glued into ONE
|
||||||
|
* string via `blockPlainText`, so a match survives inline-mark boundaries
|
||||||
|
* (bold/italic/link splits that fracture a run like "т.е." into several text
|
||||||
|
* nodes) and comment-anchor spans never clutter the haystack.
|
||||||
|
*
|
||||||
|
* The SEARCH UNIT is a text container: a node whose direct children include
|
||||||
|
* text nodes (a paragraph/heading, or the paragraph inside a table cell / list
|
||||||
|
* item). ProseMirror keeps block vs. inline content exclusive, so a container
|
||||||
|
* never nests another container — the walk reaches each cell/item's own text and
|
||||||
|
* the context window is naturally scoped to that specific cell/item, not the
|
||||||
|
* whole top-level block's glued text.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import RE2 from "re2";
|
||||||
|
|
||||||
|
import { blockPlainText } from "./node-ops.js";
|
||||||
|
|
||||||
|
/** An RE2 regex instance (RE2 extends `RegExp`, so it is usable as one). */
|
||||||
|
type Re2Regex = InstanceType<typeof RE2>;
|
||||||
|
|
||||||
|
/** True if `value` is a non-null plain object (and not an array). */
|
||||||
|
function isObject(value: any): value is Record<string, any> {
|
||||||
|
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A text container is a node with a `content` array holding at least one text
|
||||||
|
* node (a child with a string `text`). These are the paragraphs/headings whose
|
||||||
|
* glued inline text we search.
|
||||||
|
*/
|
||||||
|
function isTextContainer(node: any): boolean {
|
||||||
|
return (
|
||||||
|
isObject(node) &&
|
||||||
|
Array.isArray(node.content) &&
|
||||||
|
node.content.some((c: any) => isObject(c) && typeof c.text === "string")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options controlling the search engine and result size. */
|
||||||
|
export interface SearchOptions {
|
||||||
|
/** Treat `query` as a RegExp instead of a literal substring (default false). */
|
||||||
|
regex?: boolean;
|
||||||
|
/** Case-sensitive matching (default false). */
|
||||||
|
caseSensitive?: boolean;
|
||||||
|
/** Max matches to RETURN (default 50, clamped to [1, 200]); total is unbounded. */
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One located occurrence. */
|
||||||
|
export interface SearchMatch {
|
||||||
|
/**
|
||||||
|
* The container's ref, for addressing the block with get_node/patch_node: its
|
||||||
|
* `attrs.id` when it has one, otherwise `#<topLevelIndex>` of the nearest
|
||||||
|
* top-level block. Table-cell/list-item paragraphs that carry no id fall back
|
||||||
|
* to the `#<index>` form.
|
||||||
|
*
|
||||||
|
* CAVEAT: the `#<index>` form is accepted by get_node (getNodeByRef resolves
|
||||||
|
* it by top-level index) but NOT by patch_node (replaceNodeById resolves only
|
||||||
|
* by `attrs.id`), so id-less table/cell content can be READ by this ref but
|
||||||
|
* not PATCHED by it.
|
||||||
|
*
|
||||||
|
* To anchor a comment, do NOT pass this ref to create_comment — it has no
|
||||||
|
* nodeId parameter. A top-level comment needs an exact-text `selection` that
|
||||||
|
* occurs once on the page (it fails if the text isn't found), so build a
|
||||||
|
* UNIQUE `selection` from before+match+after and pass THAT as create_comment's
|
||||||
|
* `selection`.
|
||||||
|
*/
|
||||||
|
nodeId: string;
|
||||||
|
/** The top-level block index (as in get_outline). */
|
||||||
|
blockIndex: number;
|
||||||
|
/** The container node's type (paragraph/heading/...). */
|
||||||
|
type: string | undefined;
|
||||||
|
/** ~40 chars of context immediately before the match (from THIS container). */
|
||||||
|
before: string;
|
||||||
|
/** The matched text. */
|
||||||
|
match: string;
|
||||||
|
/** ~40 chars of context immediately after the match (from THIS container). */
|
||||||
|
after: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The search result. `truncated` is true when `total > matches.length`. */
|
||||||
|
export interface SearchResult {
|
||||||
|
total: number;
|
||||||
|
truncated: boolean;
|
||||||
|
matches: SearchMatch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result-size defaults/ceiling.
|
||||||
|
const DEFAULT_LIMIT = 50;
|
||||||
|
const MAX_LIMIT = 200;
|
||||||
|
|
||||||
|
// Context window on each side of a match.
|
||||||
|
const CONTEXT = 40;
|
||||||
|
|
||||||
|
// Cheap sanity cap on the query/pattern length. ReDoS is handled structurally
|
||||||
|
// by the RE2 engine (linear-time, no backtracking — see the module doc), so we
|
||||||
|
// no longer truncate the per-container text: RE2 scans it in linear time and a
|
||||||
|
// cap could silently drop real matches past it. This just rejects an absurdly
|
||||||
|
// long pattern early with a clear error.
|
||||||
|
const MAX_PATTERN_LENGTH = 1000;
|
||||||
|
|
||||||
|
/** Clamp the requested limit into [1, MAX_LIMIT], defaulting when absent. */
|
||||||
|
function resolveLimit(limit: number | undefined): number {
|
||||||
|
const n = typeof limit === "number" && Number.isFinite(limit) ? limit : DEFAULT_LIMIT;
|
||||||
|
return Math.min(MAX_LIMIT, Math.max(1, Math.floor(n)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Yield the [start, length] of every occurrence of the engine in `text`, in
|
||||||
|
* order. A literal engine uses indexOf (case-folded when requested); a regex
|
||||||
|
* engine uses a global RE2 regex (RE2 extends `RegExp`, so `.exec` advances
|
||||||
|
* `lastIndex` exactly like the native engine). Zero-length regex matches (e.g.
|
||||||
|
* `\b`, `a*`) are SKIPPED and lastIndex is advanced, so a pattern that can match
|
||||||
|
* the empty string cannot flood the results or spin forever.
|
||||||
|
*/
|
||||||
|
function* eachMatch(
|
||||||
|
text: string,
|
||||||
|
query: string,
|
||||||
|
re: Re2Regex | null,
|
||||||
|
caseSensitive: boolean,
|
||||||
|
): Generator<[number, number]> {
|
||||||
|
if (re) {
|
||||||
|
re.lastIndex = 0;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(text)) != null) {
|
||||||
|
const len = m[0].length;
|
||||||
|
if (len === 0) {
|
||||||
|
// Empty match: advance past this position and do not record it.
|
||||||
|
re.lastIndex = m.index + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
yield [m.index, len];
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Literal engine. For case-insensitive search, fold BOTH sides only to locate
|
||||||
|
// the indices; the reported match/context are always sliced from the original
|
||||||
|
// text so the caller gets the real casing (needed to build a unique selection).
|
||||||
|
const haystack = caseSensitive ? text : text.toLowerCase();
|
||||||
|
const needle = caseSensitive ? query : query.toLowerCase();
|
||||||
|
const len = needle.length;
|
||||||
|
let from = 0;
|
||||||
|
for (;;) {
|
||||||
|
const idx = haystack.indexOf(needle, from);
|
||||||
|
if (idx === -1) return;
|
||||||
|
yield [idx, len];
|
||||||
|
from = idx + len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search a ProseMirror document for `query` and return `{ total, truncated,
|
||||||
|
* matches }`. `total` counts EVERY occurrence (even beyond the limit) and
|
||||||
|
* `truncated` flags when the returned list was capped — nothing is silently
|
||||||
|
* dropped.
|
||||||
|
*
|
||||||
|
* Throws a clear, model-actionable error (never a generic failure) on: an
|
||||||
|
* empty/whitespace-only query, an over-long pattern, or — with `regex:true` — a
|
||||||
|
* pattern RE2 rejects (invalid syntax, or the unsupported lookaround/
|
||||||
|
* backreference features), so the agent can fix its input.
|
||||||
|
*/
|
||||||
|
export function searchInDoc(
|
||||||
|
doc: any,
|
||||||
|
query: string,
|
||||||
|
opts: SearchOptions = {},
|
||||||
|
): SearchResult {
|
||||||
|
// --- edge-case guards (fail loudly so the agent can correct the call) ---
|
||||||
|
if (typeof query !== "string" || query.trim().length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
"search_in_page: query is empty — pass the text (or regex) to look for.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (query.length > MAX_PATTERN_LENGTH) {
|
||||||
|
throw new Error(
|
||||||
|
`search_in_page: query is too long (${query.length} chars; max ${MAX_PATTERN_LENGTH}). Shorten the search text/pattern.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const caseSensitive = opts.caseSensitive === true;
|
||||||
|
const limit = resolveLimit(opts.limit);
|
||||||
|
|
||||||
|
// Compile the pattern up front with RE2 (linear-time, ReDoS-safe) so a bad
|
||||||
|
// pattern is a clean tool error rather than a failure deep in the traversal —
|
||||||
|
// and so a catastrophic-backtracking pattern can never wedge the event loop.
|
||||||
|
// RE2 throws both on syntactically invalid input AND on backtracking-only
|
||||||
|
// features it does not implement (lookaround, backreferences); both map to the
|
||||||
|
// same actionable error so the agent rewrites the pattern.
|
||||||
|
let re: Re2Regex | null = null;
|
||||||
|
if (opts.regex === true) {
|
||||||
|
try {
|
||||||
|
re = new RE2(query, caseSensitive ? "g" : "gi");
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(
|
||||||
|
`search_in_page: invalid or unsupported regular expression: ${
|
||||||
|
e instanceof Error ? e.message : String(e)
|
||||||
|
} — RE2 does not support lookaround ((?=…)/(?<=…)) or backreferences (\\1); rewrite the pattern without them.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches: SearchMatch[] = [];
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
const topLevel =
|
||||||
|
isObject(doc) && Array.isArray(doc.content) ? doc.content : [];
|
||||||
|
|
||||||
|
// Descend a top-level block, collecting matches from every text container
|
||||||
|
// within it. blockIndex/topRef stay pinned to the enclosing top-level block.
|
||||||
|
const descend = (node: any, blockIndex: number, topRef: string): void => {
|
||||||
|
if (!isObject(node)) return;
|
||||||
|
|
||||||
|
if (isTextContainer(node)) {
|
||||||
|
// Glue this container's inline text into one string (mark-safe). No length
|
||||||
|
// cap: RE2 scans it in linear time (no ReDoS) and the whole document is
|
||||||
|
// already in memory, so truncating would only risk dropping real matches
|
||||||
|
// in a very long container.
|
||||||
|
const text = blockPlainText(node);
|
||||||
|
|
||||||
|
// The container's own id addresses it verbatim in get_node/patch_node; a
|
||||||
|
// container with no id (e.g. a table-cell paragraph) falls back to the
|
||||||
|
// top-level block's #<index> (readable via get_node, but not patchable —
|
||||||
|
// see the SearchMatch.nodeId note).
|
||||||
|
const id =
|
||||||
|
isObject(node.attrs) && typeof node.attrs.id === "string" && node.attrs.id.length > 0
|
||||||
|
? node.attrs.id
|
||||||
|
: topRef;
|
||||||
|
|
||||||
|
for (const [idx, len] of eachMatch(text, query, re, caseSensitive)) {
|
||||||
|
total++;
|
||||||
|
if (matches.length < limit) {
|
||||||
|
matches.push({
|
||||||
|
nodeId: id,
|
||||||
|
blockIndex,
|
||||||
|
type: node.type,
|
||||||
|
before: text.slice(Math.max(0, idx - CONTEXT), idx),
|
||||||
|
match: text.slice(idx, idx + len),
|
||||||
|
after: text.slice(idx + len, idx + len + CONTEXT),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A text container holds inline content only — no nested containers to
|
||||||
|
// recurse into.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(node.content)) {
|
||||||
|
for (const child of node.content) descend(child, blockIndex, topRef);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < topLevel.length; i++) {
|
||||||
|
descend(topLevel[i], i, `#${i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { total, truncated: total > matches.length, matches };
|
||||||
|
}
|
||||||
@@ -13,6 +13,11 @@
|
|||||||
// diverge on purpose (security guardrails, tuned UX, "Reversible" framing on
|
// diverge on purpose (security guardrails, tuned UX, "Reversible" framing on
|
||||||
// some write tools, different limits, hybrid-RRF search, etc.) stay defined
|
// some write tools, different limits, hybrid-RRF search, etc.) stay defined
|
||||||
// per-layer and are NOT represented here.
|
// per-layer and are NOT represented here.
|
||||||
|
//
|
||||||
|
// MAINTENANCE RULE: adding, renaming, or removing a spec here (or an inline
|
||||||
|
// registerTool in index.ts) REQUIRES updating SERVER_INSTRUCTIONS in
|
||||||
|
// packages/mcp/src/index.ts — the intent-routing guide MCP clients receive on
|
||||||
|
// initialize. Enforced by test/unit/server-instructions.test.mjs.
|
||||||
|
|
||||||
// Loose on purpose — see the comment above. The two zod majors expose different
|
// Loose on purpose — see the comment above. The two zod majors expose different
|
||||||
// static type surfaces, so typing this precisely would couple the registry to
|
// static type surfaces, so typing this precisely would couple the registry to
|
||||||
@@ -105,14 +110,68 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- in-page occurrence search (client-side, over ProseMirror plain text) ---
|
||||||
|
|
||||||
|
searchInPage: {
|
||||||
|
mcpName: 'search_in_page',
|
||||||
|
inAppKey: 'searchInPage',
|
||||||
|
description:
|
||||||
|
'Find every occurrence of a string (or regex) INSIDE one page and get ' +
|
||||||
|
'WHERE each is — instead of pulling blocks one-by-one with get_node. ' +
|
||||||
|
'Searches the plain text of each text block/cell (marks glued, so a match ' +
|
||||||
|
'survives bold/italic/link splits; comment anchors do not interfere). ' +
|
||||||
|
'Returns { total, truncated, matches:[{ nodeId, blockIndex, type, before, ' +
|
||||||
|
'match, after }] }: `nodeId` is the block id (or "#<index>" for ' +
|
||||||
|
'table/cell content) — pass it to get_node/patch_node (the "#<index>" ' +
|
||||||
|
'form resolves with get_node but NOT patch_node, which only accepts a real ' +
|
||||||
|
'block id). To anchor a comment, do NOT pass nodeId to create_comment (it ' +
|
||||||
|
'has no nodeId param); build a UNIQUE text selection from before+match+' +
|
||||||
|
'after and pass it as create_comment\'s `selection`. `blockIndex` is the ' +
|
||||||
|
'get_outline index; `before`/`after` give ~40 chars of context to build ' +
|
||||||
|
'that unique selection. `total` counts all ' +
|
||||||
|
'hits and `truncated` is true when more than `limit` were found (nothing ' +
|
||||||
|
'is silently dropped). Default is a literal, case-INSENSITIVE substring; ' +
|
||||||
|
'set regex:true for an RE2 regular expression (linear-time, ReDoS-safe: ' +
|
||||||
|
'char classes, word boundaries, anchors and quantifiers work; lookaround ' +
|
||||||
|
'(?=…)/(?<=…) and backreferences \\1 are NOT supported) and ' +
|
||||||
|
'caseSensitive:true to match case. Ideal for systematic ' +
|
||||||
|
'editorial sweeps (unquoted "ё", straight quotes, "т.е.", stray units). An ' +
|
||||||
|
'invalid regex or an empty query returns a clear error to fix.',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1).describe('ID of the page to search'),
|
||||||
|
query: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe('The text to find (a literal substring, or a regex when regex:true)'),
|
||||||
|
regex: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Treat query as an RE2 regular expression — linear-time, ReDoS-safe; ' +
|
||||||
|
'no lookaround or backreferences (default false).',
|
||||||
|
),
|
||||||
|
caseSensitive: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe('Case-sensitive matching (default false).'),
|
||||||
|
limit: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(200)
|
||||||
|
.optional()
|
||||||
|
.describe('Max matches to RETURN (default 50, max 200); total is always reported.'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
// --- node delete ---
|
// --- node delete ---
|
||||||
|
|
||||||
deleteNode: {
|
deleteNode: {
|
||||||
mcpName: 'delete_node',
|
mcpName: 'delete_node',
|
||||||
inAppKey: 'deleteNode',
|
inAppKey: 'deleteNode',
|
||||||
description:
|
description:
|
||||||
'Remove a single block by its attrs.id (from the page-JSON view) WITHOUT ' +
|
'Remove a single block by its attrs.id (from the page outline or ' +
|
||||||
'resending the whole document.',
|
'page-JSON view) WITHOUT resending the whole document.',
|
||||||
buildShape: (z) => ({
|
buildShape: (z) => ({
|
||||||
pageId: z.string().min(1),
|
pageId: z.string().min(1),
|
||||||
nodeId: z.string().min(1),
|
nodeId: z.string().min(1),
|
||||||
@@ -134,7 +193,8 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
description:
|
description:
|
||||||
'Replace a single content block identified by its attrs.id with a new ' +
|
'Replace a single content block identified by its attrs.id with a new ' +
|
||||||
'ProseMirror node, WITHOUT resending the whole document; the replacement ' +
|
'ProseMirror node, WITHOUT resending the whole document; the replacement ' +
|
||||||
'keeps the same node id. Get the block id from the page-JSON view, then ' +
|
'keeps the same node id. Get the block id from the page outline (cheap) ' +
|
||||||
|
'or the page-JSON view, then ' +
|
||||||
'pass a ProseMirror node to put in its place. Example node: a paragraph ' +
|
'pass a ProseMirror node to put in its place. Example node: a paragraph ' +
|
||||||
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
|
'{"type":"paragraph","content":[{"type":"text","text":"Hello"}]} or a ' +
|
||||||
'heading {"type":"heading","attrs":{"level":2},"content":' +
|
'heading {"type":"heading","attrs":{"level":2},"content":' +
|
||||||
@@ -169,7 +229,8 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
'Insert a block before/after another block (by attrs.id or anchor text) ' +
|
'Insert a block before/after another block (by attrs.id or anchor text) ' +
|
||||||
'or append it at the end (top level). For before/after you MUST provide ' +
|
'or append it at the end (top level). For before/after you MUST provide ' +
|
||||||
'EXACTLY ONE of anchorNodeId or anchorText. Get anchor block ids from the ' +
|
'EXACTLY ONE of anchorNodeId or anchorText. Get anchor block ids from the ' +
|
||||||
'page-JSON view. Avoids resending the whole document. Can also insert ' +
|
'page outline or the page-JSON view. Avoids resending the whole document. ' +
|
||||||
|
'Can also insert ' +
|
||||||
'table structure: to add a tableRow, pass a tableRow node with position ' +
|
'table structure: to add a tableRow, pass a tableRow node with position ' +
|
||||||
'before/after and anchor INSIDE the target table — anchorNodeId of any ' +
|
'before/after and anchor INSIDE the target table — anchorNodeId of any ' +
|
||||||
'block/cell in it, or anchorText matching the table; to add a ' +
|
'block/cell in it, or anchorText matching the table; to add a ' +
|
||||||
|
|||||||
@@ -462,7 +462,7 @@ async function main() {
|
|||||||
check("create_comment: markdown round-trip", c1.data.content.includes("**комментарий**"), c1.data.content);
|
check("create_comment: markdown round-trip", c1.data.content.includes("**комментарий**"), c1.data.content);
|
||||||
const reply = await client.createComment(pageId, "Ответ на комментарий.", "page", undefined, c1.data.id);
|
const reply = await client.createComment(pageId, "Ответ на комментарий.", "page", undefined, c1.data.id);
|
||||||
check("create_comment: reply has parent", reply.data.parentCommentId === c1.data.id);
|
check("create_comment: reply has parent", reply.data.parentCommentId === c1.data.id);
|
||||||
const list = await client.listComments(pageId);
|
const list = (await client.listComments(pageId)).items;
|
||||||
check("list_comments: both visible", list.length === 2, `count=${list.length}`);
|
check("list_comments: both visible", list.length === 2, `count=${list.length}`);
|
||||||
await client.updateComment(c1.data.id, "Обновлённый текст комментария.");
|
await client.updateComment(c1.data.id, "Обновлённый текст комментария.");
|
||||||
const got = await client.getComment(c1.data.id);
|
const got = await client.getComment(c1.data.id);
|
||||||
@@ -472,17 +472,19 @@ async function main() {
|
|||||||
// resolve_comment: close the top-level thread, verify resolvedAt surfaces, then reopen
|
// resolve_comment: close the top-level thread, verify resolvedAt surfaces, then reopen
|
||||||
const resolvedRes = await client.resolveComment(c1.data.id, true);
|
const resolvedRes = await client.resolveComment(c1.data.id, true);
|
||||||
check("resolve_comment: marks resolved", resolvedRes.success === true && resolvedRes.resolved === true);
|
check("resolve_comment: marks resolved", resolvedRes.success === true && resolvedRes.resolved === true);
|
||||||
const listResolved = await client.listComments(pageId);
|
// c1 is now resolved; the default feed hides resolved threads, so pass
|
||||||
|
// includeResolved:true to still see it and assert its resolvedAt (#328).
|
||||||
|
const listResolved = (await client.listComments(pageId, true)).items;
|
||||||
const c1Resolved = listResolved.find((c) => c.id === c1.data.id);
|
const c1Resolved = listResolved.find((c) => c.id === c1.data.id);
|
||||||
check("resolve_comment: resolvedAt set in list", !!c1Resolved?.resolvedAt, `resolvedAt=${c1Resolved?.resolvedAt}`);
|
check("resolve_comment: resolvedAt set in list", !!c1Resolved?.resolvedAt, `resolvedAt=${c1Resolved?.resolvedAt}`);
|
||||||
const reopenedRes = await client.resolveComment(c1.data.id, false);
|
const reopenedRes = await client.resolveComment(c1.data.id, false);
|
||||||
check("resolve_comment: reopen succeeds", reopenedRes.resolved === false);
|
check("resolve_comment: reopen succeeds", reopenedRes.resolved === false);
|
||||||
const listReopened = await client.listComments(pageId);
|
const listReopened = (await client.listComments(pageId)).items;
|
||||||
const c1Reopened = listReopened.find((c) => c.id === c1.data.id);
|
const c1Reopened = listReopened.find((c) => c.id === c1.data.id);
|
||||||
check("resolve_comment: resolvedAt cleared on reopen", !c1Reopened?.resolvedAt, `resolvedAt=${c1Reopened?.resolvedAt}`);
|
check("resolve_comment: resolvedAt cleared on reopen", !c1Reopened?.resolvedAt, `resolvedAt=${c1Reopened?.resolvedAt}`);
|
||||||
await client.deleteComment(reply.data.id);
|
await client.deleteComment(reply.data.id);
|
||||||
await client.deleteComment(c1.data.id);
|
await client.deleteComment(c1.data.id);
|
||||||
const listAfter = await client.listComments(pageId);
|
const listAfter = (await client.listComments(pageId)).items;
|
||||||
check("delete_comment: comments removed", listAfter.length === 0, `count=${listAfter.length}`);
|
check("delete_comment: comments removed", listAfter.length === 0, `count=${listAfter.length}`);
|
||||||
} finally {
|
} finally {
|
||||||
if (pageId) {
|
if (pageId) {
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// gitmost #328 Channel 2: DocmostClient.listComments hides RESOLVED THREADS
|
||||||
|
// wholesale by default (a resolved top-level comment AND every reply under it),
|
||||||
|
// returning `{ items, resolvedThreadsHidden }`. `includeResolved: true` returns
|
||||||
|
// the full feed. These tests stand a local http.createServer in for Docmost and
|
||||||
|
// mock the /auth/login + /comments (paginated) routes.
|
||||||
|
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", (c) => (raw += c));
|
||||||
|
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)));
|
||||||
|
});
|
||||||
|
|
||||||
|
// A minimal ProseMirror comment body (a paragraph of text).
|
||||||
|
const body = (t) => ({
|
||||||
|
type: "doc",
|
||||||
|
content: [{ type: "paragraph", content: [{ type: "text", text: t }] }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Feed: an ACTIVE thread whose REPLY is resolved (root active, reply resolved —
|
||||||
|
// the thread must STAY, because a thread is gated only by its ROOT's resolvedAt)
|
||||||
|
// and a RESOLVED thread (root + reply).
|
||||||
|
const FEED = [
|
||||||
|
{
|
||||||
|
id: "a",
|
||||||
|
pageId: "page-1",
|
||||||
|
parentCommentId: null,
|
||||||
|
resolvedAt: null,
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
creatorId: "u1",
|
||||||
|
content: body("active root"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "a1",
|
||||||
|
pageId: "page-1",
|
||||||
|
parentCommentId: "a",
|
||||||
|
// A RESOLVED reply under an ACTIVE root: the thread is NOT hidden (only a
|
||||||
|
// resolved ROOT hides a thread), so this reply survives the default filter.
|
||||||
|
resolvedAt: "2026-02-15T00:00:00.000Z",
|
||||||
|
createdAt: "2026-01-01T01:00:00.000Z",
|
||||||
|
creatorId: "u1",
|
||||||
|
content: body("resolved reply of an active thread"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r",
|
||||||
|
pageId: "page-1",
|
||||||
|
parentCommentId: null,
|
||||||
|
resolvedAt: "2026-02-01T00:00:00.000Z",
|
||||||
|
createdAt: "2026-01-02T00:00:00.000Z",
|
||||||
|
creatorId: "u1",
|
||||||
|
content: body("resolved root"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "r1",
|
||||||
|
pageId: "page-1",
|
||||||
|
parentCommentId: "r",
|
||||||
|
resolvedAt: null,
|
||||||
|
createdAt: "2026-01-02T01:00:00.000Z",
|
||||||
|
creatorId: "u1",
|
||||||
|
content: body("resolved reply"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function commentsServer() {
|
||||||
|
return spawn(async (req, res) => {
|
||||||
|
await readBody(req);
|
||||||
|
if (req.url === "/api/auth/login") {
|
||||||
|
sendJson(res, 200, { success: true }, {
|
||||||
|
"Set-Cookie": "authToken=t; Path=/; HttpOnly",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.url === "/api/comments") {
|
||||||
|
// Single page, no cursor.
|
||||||
|
sendJson(res, 200, { data: { items: FEED, meta: { nextCursor: null } } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendJson(res, 404, { message: "not found" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("default hides the resolved thread (root + its reply) and counts it", async () => {
|
||||||
|
const { baseURL } = await commentsServer();
|
||||||
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
|
|
||||||
|
const result = await client.listComments("page-1");
|
||||||
|
assert.equal(Array.isArray(result.items), true, "returns { items, ... }");
|
||||||
|
const ids = result.items.map((c) => c.id).sort();
|
||||||
|
assert.deepEqual(ids, ["a", "a1"], "only the active thread remains");
|
||||||
|
assert.equal(result.resolvedThreadsHidden, 1, "one resolved thread hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("includeResolved:true returns EVERYTHING with zero hidden", async () => {
|
||||||
|
const { baseURL } = await commentsServer();
|
||||||
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
|
|
||||||
|
const result = await client.listComments("page-1", true);
|
||||||
|
const ids = result.items.map((c) => c.id).sort();
|
||||||
|
assert.deepEqual(ids, ["a", "a1", "r", "r1"], "all four comments returned");
|
||||||
|
assert.equal(result.resolvedThreadsHidden, 0, "nothing hidden with the flag");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the reply of a resolved thread is hidden with the thread", async () => {
|
||||||
|
const { baseURL } = await commentsServer();
|
||||||
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
|
|
||||||
|
const result = await client.listComments("page-1");
|
||||||
|
const ids = result.items.map((c) => c.id);
|
||||||
|
assert.equal(ids.includes("r1"), false, "the resolved thread's reply is gone");
|
||||||
|
assert.equal(ids.includes("r"), false, "the resolved root is gone");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an ACTIVE thread whose REPLY is resolved is NOT hidden", async () => {
|
||||||
|
// A thread is gated only by its ROOT's resolvedAt. `a1` is a resolved reply
|
||||||
|
// under the active root `a`, so both must survive the default filter and the
|
||||||
|
// thread must not be counted as hidden.
|
||||||
|
const { baseURL } = await commentsServer();
|
||||||
|
const client = new DocmostClient(baseURL, "user@example.com", "pw");
|
||||||
|
|
||||||
|
const result = await client.listComments("page-1");
|
||||||
|
const ids = result.items.map((c) => c.id).sort();
|
||||||
|
assert.equal(ids.includes("a"), true, "active root stays");
|
||||||
|
assert.equal(ids.includes("a1"), true, "its resolved reply stays with the thread");
|
||||||
|
assert.equal(result.resolvedThreadsHidden, 1, "only the resolved-root thread is hidden");
|
||||||
|
});
|
||||||
@@ -45,6 +45,7 @@ const HOST_CONTRACT_METHODS = [
|
|||||||
"getOutline",
|
"getOutline",
|
||||||
"getPageJson",
|
"getPageJson",
|
||||||
"getNode",
|
"getNode",
|
||||||
|
"searchInPage",
|
||||||
"getTable",
|
"getTable",
|
||||||
"listComments",
|
"listComments",
|
||||||
"getComment",
|
"getComment",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user