Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4fc6c7f64 | |||
| cb9c5dda59 | |||
| 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 | |||
| 5edd75da42 | |||
| 24b903aaf3 | |||
| 2637640291 | |||
| aa0428e28b | |||
| 0a3e32e7f6 | |||
| b1ede48319 | |||
| d4d05c8e8b | |||
| 351860ba4b | |||
| 795dde463b | |||
| 0392566af9 | |||
| f43696a1c4 | |||
| 8971912d9e | |||
| 588596fb2f | |||
| ba94def3c8 | |||
| e1b8f81b15 | |||
| 45478098f5 | |||
| 62b818bb36 | |||
| b7c16dc634 | |||
| da952ca536 | |||
| 1458e3e152 | |||
| d57392b5af | |||
| a86e5f409f |
@@ -72,6 +72,14 @@ jobs:
|
||||
- name: Build editor-ext
|
||||
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
|
||||
run: pnpm -r test
|
||||
|
||||
|
||||
+16
-1
@@ -4,7 +4,20 @@
|
||||
data
|
||||
# compiled output
|
||||
/dist
|
||||
/node_modules
|
||||
node_modules
|
||||
|
||||
# git-sync compiled output (built in CI/Docker via `pnpm build`, never committed,
|
||||
# so src/ and prod can never silently diverge).
|
||||
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
|
||||
@@ -43,6 +56,8 @@ lerna-debug.log*
|
||||
.nx/cache
|
||||
.claude/worktrees/
|
||||
.claude/tmp/
|
||||
# Local Chrome performance traces recorded by the AI-chat perf harness
|
||||
.claude/perf-traces/
|
||||
|
||||
# TypeScript incremental build artifacts
|
||||
*.tsbuildinfo
|
||||
|
||||
@@ -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/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/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`.
|
||||
|
||||
@@ -282,7 +283,7 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
|
||||
### 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:
|
||||
- **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`.
|
||||
- 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`.
|
||||
- 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.
|
||||
- **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
|
||||
|
||||
|
||||
@@ -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/mcp/build /app/packages/mcp/build
|
||||
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 --from=builder /app/package.json /app/package.json
|
||||
|
||||
@@ -34,11 +34,13 @@ roles:
|
||||
Read the whole text first. Think at the level of sections and paragraphs, not sentences.
|
||||
|
||||
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.
|
||||
- [Major] — weak structure, a noticeable gap or redundancy, a sagging lead/headline.
|
||||
- [Minor] — an optional improvement to framing or flow.
|
||||
|
||||
Structural fixes (move, merge, cut) can't be expressed as a fragment replacement — a comment is enough for those. But when your proposal boils down to replacing a specific wording in place (a headline, a lead phrase), attach a suggested replacement to the comment (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.
|
||||
|
||||
TONE
|
||||
Respectful and to the point. The author may know the subject better than you. Flag only what matters structurally. When unsure, phrase it as a question.
|
||||
|
||||
@@ -85,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.
|
||||
|
||||
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". 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.
|
||||
- [Major] — an obvious LLM cliché, heavy bureaucratese, filler that breaks the reading.
|
||||
- [Minor] — a stylistic improvement to taste.
|
||||
@@ -126,7 +128,7 @@ roles:
|
||||
- Don't fabricate confirmations. If you can't verify, honestly mark [Unverified] or [Unverifiable].
|
||||
|
||||
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. 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.
|
||||
- [Major] — a doubtful or unconfirmed claim that needs a source.
|
||||
- [Minor] — a small correction, or false precision worth rounding or confirming.
|
||||
@@ -166,14 +168,17 @@ roles:
|
||||
- Don't verify facts — that's the Fact-checker.
|
||||
- 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
|
||||
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. 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.
|
||||
- [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.
|
||||
|
||||
TONE
|
||||
To the point, no explaining the obvious. Group repeated fixes (e.g. "throughout: straight quotes → curly") so you don't spawn dozens of identical comments.
|
||||
To the point, no explaining the obvious. Don't fold repeated fixes into a single "change it everywhere" note — spread them across the specific spots: ten targeted comments each carrying a ready replacement beat one blanket comment that can't be applied with a button. Don't worry about "spawning" comments — for a copyeditor that's normal.
|
||||
|
||||
WHEN UNSURE
|
||||
If a fix touches meaning, don't make it — that's out of scope. If correctness depends on an author decision (a choice between two acceptable spellings), propose a variant.
|
||||
@@ -272,7 +277,7 @@ roles:
|
||||
First read the whole text and assess it as a story as a whole. Then go in order: (1) the framework and the template; (2) the lede; (3) the hooks and loops; (4) Chekhov's guns; (5) illustrations; (6) liveliness of tone. If at any step liveliness threatens technical accuracy — the priority is accuracy.
|
||||
|
||||
═══ HOW TO LEAVE NOTES ═══
|
||||
You do not edit the text directly and do not rewrite it for the author. Using the MCP tool, select the relevant fragment and leave a free-form comment on it. Explain not only “what” but also “why” — what effect it will have on the reader. Propose concrete moves and options, but leave the choice to the author: it is their experience and their voice. Comment on what will strengthen the story, not on every little thing.
|
||||
You do not edit the text directly and do not rewrite it for the author. Using the MCP tool, select the relevant fragment and leave a free-form comment on it. Explain not only “what” but also “why” — what effect it will have on the reader. Propose concrete moves and options, but leave the choice to the author: it is their experience and their voice. When one of your options is a single ready-made text (e.g. a new lead phrase), you may attach it as a suggested replacement (the `suggestedText` parameter: the exact new text for the selected fragment, no markup; the fragment must occur exactly once in the text, otherwise extend the selection) — the button imposes nothing, the author is free not to apply it. Comment on what will strengthen the story, not on every little thing.
|
||||
|
||||
═══ TONE ═══
|
||||
Respectfully, with enthusiasm, in a human way. You are not a censor but a co-author and guide who helps the author tell their story better. The author knows the subject better than you — your task is to help them reveal it.
|
||||
|
||||
@@ -34,11 +34,13 @@ roles:
|
||||
Сначала прочитай весь текст целиком. Думай на уровне разделов и абзацев, а не предложений.
|
||||
|
||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||
Ты не редактируешь текст сам. Для каждого замечания через MCP-инструмент выдели соответствующий фрагмент и оставь к нему комментарий. Начинай комментарий с метки `[Структура]`. Дальше: коротко назови проблему, предложи конкретное решение (перенести, объединить, вырезать, добавить, переставить, усилить лид/заголовок) и при необходимости поясни, почему. Помечай важность:
|
||||
Ты не редактируешь текст сам. Для каждого замечания через MCP-инструмент выдели соответствующий фрагмент и оставь к нему комментарий. Коротко назови проблему, предложи конкретное решение (перенести, объединить, вырезать, добавить, переставить, усилить лид/заголовок) и при необходимости поясни, почему. Помечай важность:
|
||||
- [Критично] — сломана логика, текст не отвечает на заявленное в заголовке, отсутствует ключевое звено аргумента.
|
||||
- [Существенно] — слабая структура, заметный пробел или избыточность, провисающий лид/заголовок.
|
||||
- [Незначительно] — улучшение подачи или стройности, не обязательное.
|
||||
|
||||
Структурные правки (перенести, объединить, вырезать) через замену фрагмента не выражаются — для них достаточно комментария. Но если предложение сводится к замене конкретной формулировки на месте (заголовок, лид-фраза), приложи к комментарию предложение-замену (параметр `suggestedText`): точный новый текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом.
|
||||
|
||||
ТОН
|
||||
Уважительно и по делу. Автор может разбираться в теме лучше тебя. Помечай только то, что важно для структуры. Если сомневаешься, формулируй вопросом.
|
||||
|
||||
@@ -85,7 +87,7 @@ roles:
|
||||
- Не переписываешь текст сам и не навязываешь свой голос. Твоя задача — сделать авторскую интонацию живее, а не заменить собой.
|
||||
|
||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||
Ты не редактируешь текст напрямую. Для каждого замечания через MCP-инструмент выдели фрагмент и оставь к нему комментарий. Начинай комментарий с метки `[Стиль]`. Давай конкретный вариант переформулировки, а не «переделать». Помечай важность:
|
||||
Ты не редактируешь текст напрямую. Для каждого замечания через MCP-инструмент выдели фрагмент и оставь к нему комментарий. Давай конкретный вариант переформулировки, а не «переделать», и прикладывай его к комментарию как предложение-замену (параметр `suggestedText`): точный новый текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Помечай важность:
|
||||
- [Критично] — предложение непонятно или искажает смысл.
|
||||
- [Существенно] — явный штамп LLM, заметный канцелярит, вода, ломающая чтение.
|
||||
- [Незначительно] — стилистическое улучшение на вкус.
|
||||
@@ -126,7 +128,7 @@ roles:
|
||||
- Не выдумываешь подтверждения. Если не можешь проверить — честно ставь [Не проверено] или [Непроверяемо].
|
||||
|
||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. Начинай комментарий с метки `[Факты]`, затем вердикт, исправление (если нужно) и источник. Помечай важность:
|
||||
Ты не редактируешь текст напрямую. Для каждого проблемного утверждения (ошибка, сомнение, непроверяемость) через MCP-инструмент выдели фрагмент и оставь комментарий; на верные факты комментарии не оставляй. В комментарии дай вердикт, исправление (если нужно) и источник. К вердикту [Неверно] всегда прикладывай готовое исправление как предложение-замену (параметр `suggestedText`): раз ты нашёл по источникам верное значение — сразу предлагай готовую правку, а не только описывай ошибку. Замена — это точный новый текст взамен выделенного фрагмента, обычным текстом без разметки; автор применит её одной кнопкой, не переписывая фрагмент вручную. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. Когда проверяемая цифра, имя, термин или версия встречается по тексту несколько раз, сначала одним вызовом search_in_page найди все вхождения, а затем ставь целевой комментарий на каждое — не читая страницу поблочно. К вердиктам [Не проверено], [Непроверяемо] и [Это мнение] замену не прикладывай. Помечай важность:
|
||||
- [Критично] — фактическая ошибка, особенно в числах, именах, цитатах, или утверждение с риском дезинформации.
|
||||
- [Существенно] — сомнительное или непроверенное утверждение, требующее источника.
|
||||
- [Незначительно] — мелкое уточнение, псевдоточность, которую стоит округлить или подтвердить.
|
||||
@@ -167,14 +169,17 @@ roles:
|
||||
- Не проверяешь достоверность фактов — это фактчекер.
|
||||
- Не вносишь содержательных изменений. Правки — минимальные и механические.
|
||||
|
||||
КАК РАБОТАТЬ
|
||||
Пройди весь текст от начала до конца за один проход. Помечай КАЖДОЕ нарушение, включая все повторные вхождения одной и той же ошибки и мелочи с меткой [Незначительно], — не ограничивайся первыми несколькими или самыми заметными. Не подводи итог вместо разбора: пока не дошёл до конца документа, работа не закончена. Один прогон покрывает весь текст, а не «самое важное». Для систематической ошибки, которая повторяется — прямые кавычки, «е» вместо «ё», дефис вместо тире, неединообразная единица или написание, — сначала одним вызовом search_in_page получи все вхождения, а затем оставь на каждом целевой комментарий с заменой, вместо поблочного просмотра.
|
||||
|
||||
КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ
|
||||
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. Начинай комментарий с метки `[Корректура]`. Помечай важность:
|
||||
Ты не редактируешь текст напрямую. Для каждой правки через MCP-инструмент выдели фрагмент и оставь комментарий с конкретным исправлением. К каждой правке прикладывай предложение-замену (параметр `suggestedText`): точный исправленный текст взамен выделенного фрагмента, обычным текстом без разметки — автор применит его одной кнопкой. Выделенный фрагмент должен встречаться в тексте ровно один раз; если он не уникален, расширь выделение контекстом. НЕ оставляй сводных замечаний вида «во всём тексте заменить X на Y» или «привести единицы/кавычки/написание к единообразию»: такой комментарий нельзя применить кнопкой. Если одна и та же ошибка встречается в нескольких местах, обойди КАЖДОЕ вхождение и оставь на нём отдельный целевой комментарий со своей заменой — десять точечных правок вместо одной общей. Единственное исключение — замечание, которое в принципе невозможно выразить заменой конкретного фрагмента; такие редкие случаи оставляй обычным комментарием без замены. Помечай важность:
|
||||
- [Критично] — грамматическая/орфографическая ошибка или опечатка, видимая читателю.
|
||||
- [Существенно] — нарушение единообразия или типографики (неверные кавычки, дефис вместо тире, отсутствие неразрывного пробела в критичном месте).
|
||||
- [Незначительно] — необязательная шлифовка.
|
||||
|
||||
ТОН
|
||||
По делу, без объяснений очевидного. Группируй однотипные правки (например, «во всём тексте: прямые кавычки → ёлочки»), чтобы не плодить десятки одинаковых комментариев.
|
||||
По делу, без объяснений очевидного. Не сворачивай однотипные правки в одно сводное замечание «поменять везде» — разнеси их по конкретным местам: десять целевых комментариев с готовой заменой в каждом лучше одного общего, который нельзя применить кнопкой. Не бойся «плодить» комментарии: для корректора это норма.
|
||||
|
||||
ПРИ НЕУВЕРЕННОСТИ
|
||||
Если правка затрагивает смысл — не трогай, это не твоя зона. Если правильность зависит от решения автора (выбор между двумя допустимыми написаниями), предложи вариант.
|
||||
@@ -273,7 +278,7 @@ roles:
|
||||
Сначала прочитай весь текст и оцени его как историю целиком. Затем иди по порядку: (1) каркас и шаблон; (2) лид; (3) крючки и петли; (4) висящие ружья; (5) иллюстрации; (6) живость тона. Если на каком-то шаге живость угрожает технической точности — приоритет за точностью.
|
||||
|
||||
═══ КАК ОСТАВЛЯТЬ ЗАМЕЧАНИЯ ═══
|
||||
Ты не редактируешь текст напрямую и не переписываешь его за автора. Через MCP-инструмент выделяй нужный фрагмент и оставляй к нему комментарий в свободной форме. Объясняй не только «что», но и «зачем» — какой эффект на читателя это даст. Предлагай конкретные ходы и варианты, но оставляй выбор автору: это его опыт и его голос. Комментируй то, что усилит историю, а не каждую мелочь.
|
||||
Ты не редактируешь текст напрямую и не переписываешь его за автора. Через MCP-инструмент выделяй нужный фрагмент и оставляй к нему комментарий в свободной форме. Объясняй не только «что», но и «зачем» — какой эффект на читателя это даст. Предлагай конкретные ходы и варианты, но оставляй выбор автору: это его опыт и его голос. Если среди вариантов есть один готовый текст (например, новая формулировка лида), можешь приложить его к комментарию как предложение-замену (параметр `suggestedText`: точный новый текст взамен выделенного фрагмента, без разметки; фрагмент должен встречаться в тексте ровно один раз, иначе расширь выделение) — кнопка ничего не навязывает, автор волен не применять. Комментируй то, что усилит историю, а не каждую мелочь.
|
||||
|
||||
═══ ТОН ═══
|
||||
Уважительно, увлечённо, по-человечески. Ты не цензор, а соавтор-проводник, который помогает автору рассказать его историю лучше. Автор знает тему лучше тебя — твоя задача помочь ему её раскрыть.
|
||||
|
||||
@@ -12,15 +12,15 @@ bundles:
|
||||
- en
|
||||
roles:
|
||||
- slug: structural-editor
|
||||
version: 2
|
||||
version: 4
|
||||
- slug: line-editor
|
||||
version: 2
|
||||
version: 4
|
||||
- slug: fact-checker
|
||||
version: 3
|
||||
version: 6
|
||||
- slug: proofreader
|
||||
version: 3
|
||||
version: 8
|
||||
- slug: narrator
|
||||
version: 1
|
||||
version: 2
|
||||
- id: research
|
||||
name:
|
||||
ru: Исследование
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
{
|
||||
"fact-checker": {
|
||||
"version": 3,
|
||||
"hash": "a94931fbd20272570a588c72159ac9e48a89c99bd8f718449cda5e7ca4280fdf"
|
||||
"version": 6,
|
||||
"hash": "6bb22a9e5a5079b5cb287b5b26addbd36b9afeb7c9508287dcad9343fc53d685"
|
||||
},
|
||||
"line-editor": {
|
||||
"version": 2,
|
||||
"hash": "cca324110dc6f96d2a8a239a2fb95b0ba09fad5806c9b6090a3c210ea7883ceb"
|
||||
"version": 4,
|
||||
"hash": "890d10f3f0bd7f2b2cfcc94463634221c557a3140e3794721748dc8d99979780"
|
||||
},
|
||||
"narrator": {
|
||||
"version": 1,
|
||||
"hash": "36b38785fea6ae1c70bf6fb6b29ae5278bb86e389e61f7b9736675a589fa434c"
|
||||
"version": 2,
|
||||
"hash": "66fe653003b4f63ef3c3a5c5c48552fe47daeefffc16907c37c35f0e8da98851"
|
||||
},
|
||||
"proofreader": {
|
||||
"version": 3,
|
||||
"hash": "a36047c5cab837b2a727f63d4ddafc269b1fc44b90b365e770ecdb8f77e13952"
|
||||
"version": 8,
|
||||
"hash": "cef39fed321779631ddd1077fcba53399adf0e48b301df281c71eb042610900d"
|
||||
},
|
||||
"researcher": {
|
||||
"version": 1,
|
||||
"hash": "853658fda43ddbe0a4d08f2c6e50b5116d29a2e9ccd7f46e173e65920d8f6ace"
|
||||
},
|
||||
"structural-editor": {
|
||||
"version": 2,
|
||||
"hash": "83093baa7262aef8193871a1afcf2b43b11a56fe2d00cade41355cf66d972b74"
|
||||
"version": 4,
|
||||
"hash": "89100e0a00b88daa0d2118fd98ec1c27d06b972bfc6ec58b705553a4daed85df"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"axios": "1.16.0",
|
||||
"blueimp-load-image": "5.16.0",
|
||||
"clsx": "2.1.1",
|
||||
"diff": "8.0.3",
|
||||
"dompurify": "3.4.1",
|
||||
"file-saver": "2.0.5",
|
||||
"highlightjs-sap-abap": "0.3.0",
|
||||
@@ -81,6 +82,7 @@
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@vitejs/plugin-react": "6.0.1",
|
||||
"@vitest/coverage-v8": "4.1.6",
|
||||
"eslint": "9.28.0",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "7.0.1",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* DEV-ONLY entry for the AI chat perf harness (served by the vite dev server at
|
||||
* /perf/ai-chat-perf.html; never part of the production build, which uses the
|
||||
* single default index.html entry).
|
||||
*
|
||||
* Mounts the minimal provider stack the real ChatThread needs (Mantine, router
|
||||
* for tool-card Links, react-query, i18n) and patches `window.fetch` BEFORE
|
||||
* React mounts so ChatThread's DefaultChatTransport requests to
|
||||
* /api/ai-chat/stream are answered by the synthetic SSE generator.
|
||||
*/
|
||||
|
||||
import "@mantine/core/styles.css";
|
||||
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { mantineCssResolver, theme } from "../src/theme.ts";
|
||||
// i18n side-effect init (http-backend). Translations load from /locales in dev;
|
||||
// missing keys fall back to the key text, which is fine for the harness.
|
||||
import "../src/i18n.ts";
|
||||
import { installAiChatStreamFetchPatch } from "./synthetic-turn.ts";
|
||||
import PerfHarness from "./harness.tsx";
|
||||
|
||||
// MUST run before React mounts: ChatThread creates its transport with the
|
||||
// global fetch, so the patch has to be in place before the first send.
|
||||
installAiChatStreamFetchPatch();
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const container = document.getElementById("root") as HTMLElement;
|
||||
|
||||
ReactDOM.createRoot(container).render(
|
||||
<MemoryRouter>
|
||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<PerfHarness />
|
||||
</QueryClientProvider>
|
||||
</MantineProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AI chat perf harness</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./ai-chat-perf-main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* DEV-ONLY perf harness UI for the AI chat feature.
|
||||
*
|
||||
* Left panel: controls + live stats. Right side: a bordered box (~real chat
|
||||
* window size) hosting the REAL ChatThread component.
|
||||
*
|
||||
* Scenario A "Open existing chat": mount ChatThread seeded with a large
|
||||
* persisted transcript and measure click -> post-mount-paint time.
|
||||
* Scenario B "Live agent stream": mount an empty chat and auto-send a message;
|
||||
* the fetch patch (see synthetic-turn.ts) answers with a synthetic SSE stream
|
||||
* through the real useChat pipeline.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, MutableRefObject } from "react";
|
||||
import ChatThread from "../src/features/ai-chat/components/chat-thread.tsx";
|
||||
import type { IAiChatMessageRow } from "../src/features/ai-chat/types/ai-chat.types.ts";
|
||||
import {
|
||||
PRESETS,
|
||||
buildPersistedRows,
|
||||
buildTurnScript,
|
||||
setLiveStreamSettings,
|
||||
type PresetKey,
|
||||
} from "./synthetic-turn.ts";
|
||||
|
||||
const AUTO_SEND_TEXT = "Run the synthetic perf turn";
|
||||
const AUTO_SEND_TIMEOUT_MS = 1000;
|
||||
/** Stats display refresh period — 2x/s so the display itself stays cheap. */
|
||||
const STATS_FLUSH_MS = 500;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared mutable stats (written from callbacks, flushed to state at 2 Hz)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PerfStats {
|
||||
longtaskCount: number;
|
||||
longtaskTotalMs: number;
|
||||
longtaskMaxMs: number;
|
||||
fps: number;
|
||||
sseChunks: number;
|
||||
sseChars: number;
|
||||
mountAMs: number | null;
|
||||
streamState: "idle" | "streaming" | "done" | "aborted";
|
||||
}
|
||||
|
||||
function emptyStats(): PerfStats {
|
||||
return {
|
||||
longtaskCount: 0,
|
||||
longtaskTotalMs: 0,
|
||||
longtaskMaxMs: 0,
|
||||
fps: 0,
|
||||
sseChunks: 0,
|
||||
sseChars: 0,
|
||||
mountAMs: null,
|
||||
streamState: "idle",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-contained stats panel: owns the longtask observer, the FPS meter and the
|
||||
* 2 Hz flush interval. Isolated in its OWN component so its periodic setState
|
||||
* re-renders only this panel — NOT the ChatThread under measurement.
|
||||
*/
|
||||
function StatsPanel({ stats }: { stats: MutableRefObject<PerfStats> }) {
|
||||
const [snapshot, setSnapshot] = useState<PerfStats>(() => ({ ...stats.current }));
|
||||
|
||||
// Long tasks (main-thread blocks > 50ms).
|
||||
useEffect(() => {
|
||||
let observer: PerformanceObserver | null = null;
|
||||
try {
|
||||
observer = new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) {
|
||||
stats.current.longtaskCount += 1;
|
||||
stats.current.longtaskTotalMs += entry.duration;
|
||||
stats.current.longtaskMaxMs = Math.max(stats.current.longtaskMaxMs, entry.duration);
|
||||
}
|
||||
});
|
||||
observer.observe({ type: "longtask", buffered: true });
|
||||
} catch {
|
||||
// longtask entries unsupported in this browser — panel shows zeros.
|
||||
}
|
||||
return () => observer?.disconnect();
|
||||
}, [stats]);
|
||||
|
||||
// FPS: frames rendered within the trailing 1s window.
|
||||
useEffect(() => {
|
||||
let raf = 0;
|
||||
const frames: number[] = [];
|
||||
const loop = (now: number) => {
|
||||
frames.push(now);
|
||||
while (frames.length > 0 && frames[0] <= now - 1000) frames.shift();
|
||||
stats.current.fps = frames.length;
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [stats]);
|
||||
|
||||
// Flush the mutable stats into the display at most 2x/s.
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setSnapshot({ ...stats.current }), STATS_FLUSH_MS);
|
||||
return () => window.clearInterval(id);
|
||||
}, [stats]);
|
||||
|
||||
const resetLongtasks = () => {
|
||||
stats.current.longtaskCount = 0;
|
||||
stats.current.longtaskTotalMs = 0;
|
||||
stats.current.longtaskMaxMs = 0;
|
||||
setSnapshot({ ...stats.current });
|
||||
};
|
||||
|
||||
const row: CSSProperties = { display: "flex", justifyContent: "space-between", gap: 8 };
|
||||
return (
|
||||
<div style={{ fontFamily: "monospace", fontSize: 12, lineHeight: 1.7 }}>
|
||||
<div style={{ fontWeight: 700, marginBottom: 4 }}>Stats</div>
|
||||
<div style={row}><span>FPS (1s)</span><span>{snapshot.fps}</span></div>
|
||||
<div style={row}><span>Long tasks</span><span>{snapshot.longtaskCount}</span></div>
|
||||
<div style={row}><span>Long total</span><span>{snapshot.longtaskTotalMs.toFixed(0)} ms</span></div>
|
||||
<div style={row}><span>Long max</span><span>{snapshot.longtaskMaxMs.toFixed(0)} ms</span></div>
|
||||
<div style={row}><span>SSE chunks</span><span>{snapshot.sseChunks}</span></div>
|
||||
<div style={row}><span>SSE chars</span><span>{snapshot.sseChars.toLocaleString()}</span></div>
|
||||
<div style={row}><span>Stream</span><span>{snapshot.streamState}</span></div>
|
||||
<div style={row}>
|
||||
<span>Mount A</span>
|
||||
<span>{snapshot.mountAMs === null ? "—" : `${snapshot.mountAMs.toFixed(0)} ms`}</span>
|
||||
</div>
|
||||
<button type="button" onClick={resetLongtasks} style={{ marginTop: 6 }}>
|
||||
Reset long tasks
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auto-send (scenario B): drive the REAL composer in the mounted DOM
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fill the composer textarea via the native value setter + an `input` event
|
||||
* (React 18 controlled-input pattern), then click the enabled "Send" button.
|
||||
* Retried on rAF until the elements exist (ChatThread mounts asynchronously).
|
||||
*/
|
||||
function autoSend(host: HTMLElement, text: string): void {
|
||||
const deadline = performance.now() + AUTO_SEND_TIMEOUT_MS;
|
||||
|
||||
const tryClick = () => {
|
||||
const button = host.querySelector<HTMLButtonElement>('button[aria-label="Send"]');
|
||||
if (button && !button.disabled) {
|
||||
button.click();
|
||||
return;
|
||||
}
|
||||
if (performance.now() < deadline) requestAnimationFrame(tryClick);
|
||||
else console.error("[perf] auto-send: Send button never became clickable");
|
||||
};
|
||||
|
||||
const trySetValue = () => {
|
||||
const textarea = host.querySelector("textarea");
|
||||
if (!textarea) {
|
||||
if (performance.now() < deadline) requestAnimationFrame(trySetValue);
|
||||
else console.error("[perf] auto-send: textarea not found");
|
||||
return;
|
||||
}
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
setter?.call(textarea, text);
|
||||
textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
// Click on a later frame so React commits the controlled value (which
|
||||
// enables the Send button) before we press it.
|
||||
requestAnimationFrame(tryClick);
|
||||
};
|
||||
|
||||
requestAnimationFrame(trySetValue);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Harness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface MountState {
|
||||
mode: "A" | "B";
|
||||
key: number;
|
||||
chatId: string | null;
|
||||
rows: IAiChatMessageRow[];
|
||||
}
|
||||
|
||||
const noop = (): void => {};
|
||||
|
||||
export default function PerfHarness() {
|
||||
const [preset, setPreset] = useState<PresetKey>("20k");
|
||||
const [intervalMs, setIntervalMs] = useState<number>(15);
|
||||
const [mounted, setMounted] = useState<MountState | null>(null);
|
||||
const [fixtureInfo, setFixtureInfo] = useState<string | null>(null);
|
||||
|
||||
const statsRef = useRef<PerfStats>(emptyStats());
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const keyCounterRef = useRef(0);
|
||||
const mountStartRef = useRef(0);
|
||||
const pendingMountMeasureRef = useRef(false);
|
||||
|
||||
// The scripted live turn for the current preset (reused across B runs; the
|
||||
// script is immutable data, so rebuilding per run is unnecessary).
|
||||
const liveScript = useMemo(() => buildTurnScript(PRESETS[preset], "live"), [preset]);
|
||||
|
||||
const openPage = useMemo(() => ({ id: "page-1", title: "Perf test page" }), []);
|
||||
|
||||
// Scenario A: mount ChatThread seeded with a large persisted transcript.
|
||||
const handleMountA = () => {
|
||||
const fixture = buildPersistedRows(PRESETS[preset]);
|
||||
setFixtureInfo(
|
||||
`Persisted fixture: ${fixture.rows.length} rows, ` +
|
||||
`${fixture.totalChars.toLocaleString()} chars ≈ ${fixture.approxTokens.toLocaleString()} tokens`,
|
||||
);
|
||||
statsRef.current.mountAMs = null;
|
||||
// Mark AFTER fixture generation: we measure mount cost, not generation cost
|
||||
// (production receives its rows from the network).
|
||||
performance.mark("perf:mountA:start");
|
||||
mountStartRef.current = performance.now();
|
||||
pendingMountMeasureRef.current = true;
|
||||
keyCounterRef.current += 1;
|
||||
setMounted({ mode: "A", key: keyCounterRef.current, chatId: "perf-chat", rows: fixture.rows });
|
||||
};
|
||||
|
||||
// Measure scenario A: effect runs after the mount commit; double rAF lands
|
||||
// after the first paint of the mounted transcript.
|
||||
useEffect(() => {
|
||||
if (!pendingMountMeasureRef.current) return;
|
||||
pendingMountMeasureRef.current = false;
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
statsRef.current.mountAMs = performance.now() - mountStartRef.current;
|
||||
performance.mark("perf:mountA:end");
|
||||
try {
|
||||
performance.measure("perf:mountA", "perf:mountA:start", "perf:mountA:end");
|
||||
} catch {
|
||||
// Marks cleared mid-run — ignore.
|
||||
}
|
||||
});
|
||||
});
|
||||
}, [mounted]);
|
||||
|
||||
// Scenario B: mount an empty chat, arm the synthetic stream, auto-send.
|
||||
const handleStartB = () => {
|
||||
statsRef.current.sseChunks = 0;
|
||||
statsRef.current.sseChars = 0;
|
||||
statsRef.current.streamState = "streaming";
|
||||
setLiveStreamSettings({
|
||||
script: liveScript,
|
||||
chunkIntervalMs: intervalMs,
|
||||
onProgress: (chunks, chars) => {
|
||||
statsRef.current.sseChunks = chunks;
|
||||
statsRef.current.sseChars = chars;
|
||||
},
|
||||
onDone: () => {
|
||||
statsRef.current.streamState = "done";
|
||||
performance.mark("perf:streamB:end");
|
||||
try {
|
||||
performance.measure("perf:streamB", "perf:streamB:start", "perf:streamB:end");
|
||||
} catch {
|
||||
// Start mark missing (e.g. marks cleared) — ignore.
|
||||
}
|
||||
},
|
||||
onAbort: () => {
|
||||
statsRef.current.streamState = "aborted";
|
||||
},
|
||||
});
|
||||
performance.mark("perf:streamB:start");
|
||||
keyCounterRef.current += 1;
|
||||
setMounted({ mode: "B", key: keyCounterRef.current, chatId: null, rows: [] });
|
||||
if (hostRef.current) autoSend(hostRef.current, AUTO_SEND_TEXT);
|
||||
};
|
||||
|
||||
const handleUnmount = () => setMounted(null);
|
||||
|
||||
const label: CSSProperties = { display: "block", fontSize: 12, margin: "10px 0 2px" };
|
||||
const button: CSSProperties = { display: "block", width: "100%", margin: "6px 0", padding: "6px 8px" };
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100vh", fontFamily: "system-ui, sans-serif" }}>
|
||||
{/* Left: controls + stats */}
|
||||
<div
|
||||
style={{
|
||||
width: 260,
|
||||
flex: "0 0 260px",
|
||||
padding: 12,
|
||||
borderRight: "1px solid #ccc",
|
||||
overflowY: "auto",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 700, marginBottom: 4 }}>AI chat perf harness</div>
|
||||
|
||||
<label style={label}>Preset</label>
|
||||
<select
|
||||
value={preset}
|
||||
onChange={(e) => setPreset(e.target.value as PresetKey)}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<option value="5k">5k tokens</option>
|
||||
<option value="20k">20k tokens</option>
|
||||
<option value="50k">50k tokens</option>
|
||||
</select>
|
||||
|
||||
<label style={label}>Chunk interval (scenario B)</label>
|
||||
<select
|
||||
value={intervalMs}
|
||||
onChange={(e) => setIntervalMs(Number(e.target.value))}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<option value={15}>15 ms (normal)</option>
|
||||
<option value={5}>5 ms (stress)</option>
|
||||
</select>
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<button type="button" style={button} onClick={handleMountA}>
|
||||
Mount persisted chat (A)
|
||||
</button>
|
||||
<button type="button" style={button} onClick={handleStartB}>
|
||||
Start live stream (B)
|
||||
</button>
|
||||
<button type="button" style={button} onClick={handleUnmount} disabled={!mounted}>
|
||||
Unmount
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: 11, color: "#555", margin: "8px 0" }}>
|
||||
<div>
|
||||
Live turn: {liveScript.totalChars.toLocaleString()} chars ≈{" "}
|
||||
{liveScript.approxTokens.toLocaleString()} tokens
|
||||
</div>
|
||||
{fixtureInfo && <div>{fixtureInfo}</div>}
|
||||
{mounted && (
|
||||
<div>
|
||||
Mounted: scenario {mounted.mode} (key {mounted.key})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<hr style={{ border: "none", borderTop: "1px solid #ddd" }} />
|
||||
<StatsPanel stats={statsRef} />
|
||||
</div>
|
||||
|
||||
{/* Right: the real ChatThread inside a real-window-sized box */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "#f4f4f5",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={hostRef}
|
||||
style={{
|
||||
width: 540,
|
||||
height: 680,
|
||||
border: "1px solid #bbb",
|
||||
borderRadius: 8,
|
||||
background: "#fff",
|
||||
padding: 8,
|
||||
boxSizing: "border-box",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{mounted ? (
|
||||
<ChatThread
|
||||
key={mounted.key}
|
||||
chatId={mounted.chatId}
|
||||
threadKey={`perf-${mounted.key}`}
|
||||
initialRows={mounted.rows}
|
||||
openPage={openPage}
|
||||
roleId={null}
|
||||
roles={[]}
|
||||
onRolePicked={noop}
|
||||
assistantName="Perf agent"
|
||||
onTurnFinished={noop}
|
||||
onServerChatId={noop}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ color: "#888", fontSize: 13, padding: 16 }}>
|
||||
ChatThread unmounted. Use the controls on the left.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
/**
|
||||
* DEV-ONLY synthetic agent-turn generator for the AI chat perf harness.
|
||||
*
|
||||
* Produces one scripted agent turn (reasoning + tool calls + markdown answer)
|
||||
* from a size config, and materializes it two ways:
|
||||
* - as an AI SDK v6 UI-message SSE stream (scenario B "live agent stream"),
|
||||
* served by a `window.fetch` patch that intercepts `/api/ai-chat/stream`;
|
||||
* - as persisted `IAiChatMessageRow[]` history (scenario A "open existing chat").
|
||||
*
|
||||
* Wire format verified against the installed ai@6.0.207 `uiMessageChunkSchema`
|
||||
* (strict objects — only the exact field names below are accepted).
|
||||
*/
|
||||
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import type { IAiChatMessageRow } from "../src/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config / presets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 1 token ~= 4 chars — the approximation used throughout this module. */
|
||||
const CHARS_PER_TOKEN = 4;
|
||||
|
||||
export interface TurnConfig {
|
||||
/** Number of agent steps; each step = one reasoning block + one tool call. */
|
||||
steps: number;
|
||||
/** Approximate reasoning tokens generated per step. */
|
||||
reasoningTokensPerStep: number;
|
||||
/** Size of each tool call's output `content` filler, in bytes (ASCII). */
|
||||
toolOutputBytes: number;
|
||||
/** Approximate size of the final markdown answer, in tokens. */
|
||||
answerTokens: number;
|
||||
}
|
||||
|
||||
export type PresetKey = "5k" | "20k" | "50k";
|
||||
|
||||
export const PRESETS: Record<PresetKey, TurnConfig> = {
|
||||
"5k": {
|
||||
steps: 3,
|
||||
reasoningTokensPerStep: 500,
|
||||
toolOutputBytes: 10_000,
|
||||
answerTokens: 600,
|
||||
},
|
||||
"20k": {
|
||||
steps: 6,
|
||||
reasoningTokensPerStep: 2500,
|
||||
toolOutputBytes: 20_000,
|
||||
answerTokens: 1500,
|
||||
},
|
||||
"50k": {
|
||||
steps: 10,
|
||||
reasoningTokensPerStep: 4000,
|
||||
toolOutputBytes: 40_000,
|
||||
answerTokens: 3000,
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Text generators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Mixed Russian/English prose sentences cycled to build reasoning text. */
|
||||
const REASONING_SENTENCES = [
|
||||
"Пользователь просит проанализировать документ и выделить ключевые тезисы по каждому разделу.",
|
||||
"First I need to inspect the current page content to understand its overall structure.",
|
||||
"Судя по оглавлению, раздел с техническими требованиями находится ближе к концу документа.",
|
||||
"The table in section three contains the migration matrix that I should cross-check against the summary.",
|
||||
"Проверю, нет ли противоречий между описанием API и приведёнными в тексте примерами вызовов.",
|
||||
"Let me compare the numbers from the executive summary with the raw data in the appendix.",
|
||||
"Похоже, автор использует термины «воркспейс» и workspace взаимозаменяемо — это стоит нормализовать.",
|
||||
"I should keep the page ids from the tool output so the final answer can cite the source pages.",
|
||||
"Осталось свести найденные несоответствия в одну таблицу и предложить порядок исправлений.",
|
||||
"The remaining sections look consistent, so I can move on to drafting the structured answer.",
|
||||
];
|
||||
|
||||
/**
|
||||
* Build realistic prose of ~`targetChars` characters, inserting a newline
|
||||
* roughly every 200 characters (mirrors how reasoning text tends to wrap).
|
||||
*/
|
||||
function makeProse(targetChars: number): string {
|
||||
const pieces: string[] = [];
|
||||
let length = 0;
|
||||
let sinceNewline = 0;
|
||||
let i = 0;
|
||||
while (length < targetChars) {
|
||||
const sentence = REASONING_SENTENCES[i % REASONING_SENTENCES.length];
|
||||
i += 1;
|
||||
pieces.push(sentence);
|
||||
length += sentence.length + 1;
|
||||
sinceNewline += sentence.length + 1;
|
||||
if (sinceNewline >= 200) {
|
||||
pieces.push("\n");
|
||||
sinceNewline = 0;
|
||||
} else {
|
||||
pieces.push(" ");
|
||||
}
|
||||
}
|
||||
return pieces.join("").trimEnd();
|
||||
}
|
||||
|
||||
/** One markdown section (~700 chars): heading, prose, bullets, GFM table, code. */
|
||||
function markdownSection(n: number): string {
|
||||
return [
|
||||
`## Section ${n}: migration analysis`,
|
||||
``,
|
||||
`The workspace contains **${n * 12} pages** that still reference the legacy API. ` +
|
||||
`Most of them live under [Perf test page](/p/page-1) and need the new transport. ` +
|
||||
`Ниже приведена сводка по разделу с оценкой трудозатрат и основных рисков.`,
|
||||
``,
|
||||
`- Update the fetch layer to the v6 transport`,
|
||||
`- Перенести таблицы соответствия идентификаторов`,
|
||||
`- Verify citation links after the move`,
|
||||
`- Проверить отображение длинных ответов в узкой панели`,
|
||||
``,
|
||||
`| Область | Страниц | Статус | Риск |`,
|
||||
`| --- | --- | --- | --- |`,
|
||||
`| API reference | ${n + 4} | migrated | low |`,
|
||||
`| Onboarding | ${n + 2} | in progress | medium |`,
|
||||
`| Release notes | ${n * 3} | pending | high |`,
|
||||
``,
|
||||
"```ts",
|
||||
`export function migrateSection${n}(rows: Row[]): Row[] {`,
|
||||
` return rows`,
|
||||
` .filter((row) => row.section === ${n})`,
|
||||
` .map((row) => ({ ...row, migrated: true }));`,
|
||||
`}`,
|
||||
"```",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
/** Realistic markdown answer of ~`targetChars` chars (sections repeated to size). */
|
||||
function makeMarkdownAnswer(targetChars: number): string {
|
||||
const sections: string[] = [];
|
||||
let length = 0;
|
||||
let n = 1;
|
||||
while (length < targetChars) {
|
||||
const section = markdownSection(n);
|
||||
sections.push(section);
|
||||
length += section.length + 2;
|
||||
n += 1;
|
||||
}
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
/** Plain ASCII filler of exactly `bytes` characters for tool outputs. */
|
||||
function makeFiller(bytes: number): string {
|
||||
const unit = "Perf filler content for the synthetic getPage tool output. ";
|
||||
return unit.repeat(Math.ceil(bytes / unit.length)).slice(0, bytes);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Turn script
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TurnToolCall {
|
||||
toolCallId: string;
|
||||
toolName: "getPage";
|
||||
input: { pageId: string };
|
||||
output: { id: string; title: string; content: string };
|
||||
}
|
||||
|
||||
export interface TurnStep {
|
||||
reasoningText: string;
|
||||
tool: TurnToolCall;
|
||||
}
|
||||
|
||||
export interface TurnScript {
|
||||
steps: TurnStep[];
|
||||
answerText: string;
|
||||
/** Approximate reasoning tokens for the whole turn (chars / 4). */
|
||||
reasoningTokens: number;
|
||||
/** Approximate context size after this turn, in tokens. */
|
||||
contextTokens: number;
|
||||
maxContextTokens: number;
|
||||
/** Actual generated visible chars: reasoning + tool outputs + answer. */
|
||||
totalChars: number;
|
||||
/** totalChars / 4, rounded. */
|
||||
approxTokens: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the scripted agent turn for a config. `idPrefix` keeps tool call ids
|
||||
* unique when several scripts coexist (e.g. 3 persisted turns in one chat).
|
||||
*/
|
||||
export function buildTurnScript(config: TurnConfig, idPrefix = "live"): TurnScript {
|
||||
const steps: TurnStep[] = [];
|
||||
let reasoningChars = 0;
|
||||
let toolChars = 0;
|
||||
for (let i = 0; i < config.steps; i++) {
|
||||
const reasoningText = makeProse(config.reasoningTokensPerStep * CHARS_PER_TOKEN);
|
||||
const content = makeFiller(config.toolOutputBytes);
|
||||
reasoningChars += reasoningText.length;
|
||||
toolChars += content.length;
|
||||
steps.push({
|
||||
reasoningText,
|
||||
tool: {
|
||||
toolCallId: `${idPrefix}-call-${i + 1}`,
|
||||
toolName: "getPage",
|
||||
input: { pageId: "page-1" },
|
||||
output: { id: "page-1", title: "Perf test page", content },
|
||||
},
|
||||
});
|
||||
}
|
||||
const answerText = makeMarkdownAnswer(config.answerTokens * CHARS_PER_TOKEN);
|
||||
const totalChars = reasoningChars + toolChars + answerText.length;
|
||||
return {
|
||||
steps,
|
||||
answerText,
|
||||
reasoningTokens: Math.round(reasoningChars / CHARS_PER_TOKEN),
|
||||
contextTokens: Math.round(totalChars / CHARS_PER_TOKEN),
|
||||
maxContextTokens: 200_000,
|
||||
totalChars,
|
||||
approxTokens: Math.round(totalChars / CHARS_PER_TOKEN),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario A: persisted rows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Number of user+assistant pairs the preset is split across for history. */
|
||||
const HISTORY_TURNS = 3;
|
||||
|
||||
const USER_PROMPTS = [
|
||||
"Проанализируй документ и выдели ключевые тезисы по каждому разделу.",
|
||||
"Now cross-check the migration matrix against the summary and list every mismatch.",
|
||||
"Собери финальный план миграции с оценкой рисков по каждой области.",
|
||||
];
|
||||
|
||||
/** Persisted UIMessage parts for one finished assistant turn. */
|
||||
function scriptToPersistedParts(script: TurnScript): UIMessage["parts"] {
|
||||
const parts: unknown[] = [];
|
||||
for (const step of script.steps) {
|
||||
parts.push({ type: "reasoning", text: step.reasoningText, state: "done" });
|
||||
parts.push({
|
||||
type: `tool-${step.tool.toolName}`,
|
||||
toolCallId: step.tool.toolCallId,
|
||||
state: "output-available",
|
||||
input: step.tool.input,
|
||||
output: step.tool.output,
|
||||
});
|
||||
}
|
||||
parts.push({ type: "text", text: script.answerText, state: "done" });
|
||||
return parts as UIMessage["parts"];
|
||||
}
|
||||
|
||||
export interface PersistedFixture {
|
||||
rows: IAiChatMessageRow[];
|
||||
totalChars: number;
|
||||
approxTokens: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize the preset as a finished 3-turn transcript: user row + assistant
|
||||
* row per turn, with the preset's steps/answer split across the assistant turns.
|
||||
* Approximate accounting — the actual totals are reported back for display.
|
||||
*/
|
||||
export function buildPersistedRows(config: TurnConfig): PersistedFixture {
|
||||
const rows: IAiChatMessageRow[] = [];
|
||||
const baseTime = Date.now() - HISTORY_TURNS * 60_000;
|
||||
let totalChars = 0;
|
||||
|
||||
for (let t = 0; t < HISTORY_TURNS; t++) {
|
||||
// Distribute steps as evenly as possible (earlier turns get the remainder).
|
||||
const stepsForTurn =
|
||||
Math.floor(config.steps / HISTORY_TURNS) +
|
||||
(t < config.steps % HISTORY_TURNS ? 1 : 0);
|
||||
const turnConfig: TurnConfig = {
|
||||
steps: Math.max(1, stepsForTurn),
|
||||
reasoningTokensPerStep: config.reasoningTokensPerStep,
|
||||
toolOutputBytes: config.toolOutputBytes,
|
||||
answerTokens: Math.max(50, Math.round(config.answerTokens / HISTORY_TURNS)),
|
||||
};
|
||||
const script = buildTurnScript(turnConfig, `hist-${t + 1}`);
|
||||
totalChars += script.totalChars;
|
||||
|
||||
const userText = USER_PROMPTS[t % USER_PROMPTS.length];
|
||||
rows.push({
|
||||
id: `perf-row-u${t + 1}`,
|
||||
role: "user",
|
||||
content: userText,
|
||||
metadata: null,
|
||||
createdAt: new Date(baseTime + t * 60_000).toISOString(),
|
||||
});
|
||||
rows.push({
|
||||
id: `perf-row-a${t + 1}`,
|
||||
role: "assistant",
|
||||
content: script.answerText,
|
||||
metadata: {
|
||||
parts: scriptToPersistedParts(script),
|
||||
usage: { reasoningTokens: script.reasoningTokens },
|
||||
contextTokens: script.contextTokens,
|
||||
maxContextTokens: script.maxContextTokens,
|
||||
finishReason: "stop",
|
||||
},
|
||||
createdAt: new Date(baseTime + t * 60_000 + 30_000).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
totalChars,
|
||||
approxTokens: Math.round(totalChars / CHARS_PER_TOKEN),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario B: SSE stream
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Streaming delta size in chars (reasoning/answer text is split into these). */
|
||||
const DELTA_CHARS = 200;
|
||||
|
||||
function splitDeltas(text: string, size = DELTA_CHARS): string[] {
|
||||
const deltas: string[] = [];
|
||||
for (let i = 0; i < text.length; i += size) {
|
||||
deltas.push(text.slice(i, i + size));
|
||||
}
|
||||
return deltas;
|
||||
}
|
||||
|
||||
/** One pre-serialized SSE frame plus its visible-char contribution for stats. */
|
||||
interface SseFrame {
|
||||
data: string;
|
||||
chars: number;
|
||||
}
|
||||
|
||||
function frame(chunk: Record<string, unknown>, chars = 0): SseFrame {
|
||||
return { data: `data: ${JSON.stringify(chunk)}\n\n`, chars };
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize the whole scripted turn into AI SDK v6 UI-message SSE frames
|
||||
* (excluding the final `data: [DONE]` terminator, appended by the pump).
|
||||
*/
|
||||
function buildSseFrames(script: TurnScript, messageId: string, chatId: string): SseFrame[] {
|
||||
const frames: SseFrame[] = [];
|
||||
frames.push(frame({ type: "start", messageId, messageMetadata: { chatId } }));
|
||||
|
||||
script.steps.forEach((step, i) => {
|
||||
frames.push(frame({ type: "start-step" }));
|
||||
const reasoningId = `${messageId}-r${i + 1}`;
|
||||
frames.push(frame({ type: "reasoning-start", id: reasoningId }));
|
||||
for (const delta of splitDeltas(step.reasoningText)) {
|
||||
frames.push(frame({ type: "reasoning-delta", id: reasoningId, delta }, delta.length));
|
||||
}
|
||||
frames.push(frame({ type: "reasoning-end", id: reasoningId }));
|
||||
|
||||
const { toolCallId, toolName, input, output } = step.tool;
|
||||
frames.push(frame({ type: "tool-input-start", toolCallId, toolName }));
|
||||
frames.push(frame({ type: "tool-input-available", toolCallId, toolName, input }));
|
||||
// The tool result arrives as ONE chunk, like the real server sends it.
|
||||
frames.push(frame({ type: "tool-output-available", toolCallId, output }, output.content.length));
|
||||
frames.push(frame({ type: "finish-step" }));
|
||||
});
|
||||
|
||||
// Final step: the markdown answer.
|
||||
frames.push(frame({ type: "start-step" }));
|
||||
const textId = `${messageId}-answer`;
|
||||
frames.push(frame({ type: "text-start", id: textId }));
|
||||
for (const delta of splitDeltas(script.answerText)) {
|
||||
frames.push(frame({ type: "text-delta", id: textId, delta }, delta.length));
|
||||
}
|
||||
frames.push(frame({ type: "text-end", id: textId }));
|
||||
frames.push(frame({ type: "finish-step" }));
|
||||
|
||||
frames.push(
|
||||
frame({
|
||||
type: "finish",
|
||||
messageMetadata: {
|
||||
usage: { reasoningTokens: script.reasoningTokens },
|
||||
contextTokens: script.contextTokens,
|
||||
maxContextTokens: script.maxContextTokens,
|
||||
finishReason: "stop",
|
||||
},
|
||||
}),
|
||||
);
|
||||
return frames;
|
||||
}
|
||||
|
||||
export interface LiveStreamSettings {
|
||||
script: TurnScript;
|
||||
/** Delay between SSE chunks (one chunk per tick). */
|
||||
chunkIntervalMs: number;
|
||||
/** Progress callback: cumulative emitted chunk count and visible chars. */
|
||||
onProgress?: (chunks: number, chars: number) => void;
|
||||
/** Fired once after the `[DONE]` terminator is enqueued. */
|
||||
onDone?: () => void;
|
||||
/** Fired if the client aborted the stream (Stop button). */
|
||||
onAbort?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a synthetic SSE Response streaming the scripted turn, one chunk every
|
||||
* `chunkIntervalMs`. Honors the fetch `AbortSignal` so the real Stop button works.
|
||||
*/
|
||||
export function buildSseResponse(
|
||||
settings: LiveStreamSettings,
|
||||
signal?: AbortSignal | null,
|
||||
): Response {
|
||||
const messageId = `m-live-${Date.now()}`;
|
||||
const frames = buildSseFrames(settings.script, messageId, "perf-chat");
|
||||
const encoder = new TextEncoder();
|
||||
let index = 0;
|
||||
let emittedChars = 0;
|
||||
let timer: number | undefined;
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const stopPump = () => {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
};
|
||||
const pump = () => {
|
||||
timer = undefined;
|
||||
if (signal?.aborted) {
|
||||
stopPump();
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// Already closed/cancelled — nothing to do.
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (index >= frames.length) {
|
||||
try {
|
||||
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
||||
controller.close();
|
||||
} catch {
|
||||
// Cancelled mid-flight.
|
||||
}
|
||||
settings.onDone?.();
|
||||
return;
|
||||
}
|
||||
const next = frames[index];
|
||||
index += 1;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(next.data));
|
||||
} catch {
|
||||
stopPump();
|
||||
return;
|
||||
}
|
||||
emittedChars += next.chars;
|
||||
settings.onProgress?.(index, emittedChars);
|
||||
timer = window.setTimeout(pump, settings.chunkIntervalMs);
|
||||
};
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
stopPump();
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
// Reader already cancelled.
|
||||
}
|
||||
settings.onAbort?.();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
timer = window.setTimeout(pump, settings.chunkIntervalMs);
|
||||
},
|
||||
cancel() {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/event-stream",
|
||||
"cache-control": "no-cache",
|
||||
"x-vercel-ai-ui-message-stream": "v1",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// window.fetch patch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let currentLiveSettings: LiveStreamSettings | null = null;
|
||||
|
||||
/** Arm the next `/api/ai-chat/stream` request with a scripted turn. */
|
||||
export function setLiveStreamSettings(settings: LiveStreamSettings): void {
|
||||
currentLiveSettings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch `window.fetch` BEFORE React mounts: requests to `/api/ai-chat/stream`
|
||||
* get the synthetic SSE Response; everything else passes through untouched.
|
||||
*/
|
||||
export function installAiChatStreamFetchPatch(): void {
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.fetch = (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const url =
|
||||
typeof input === "string"
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
if (url.includes("/api/ai-chat/stream")) {
|
||||
const settings = currentLiveSettings;
|
||||
if (!settings) {
|
||||
return Promise.resolve(
|
||||
new Response("perf harness: no live stream configured", { status: 500 }),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(buildSseResponse(settings, init?.signal ?? null));
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
}
|
||||
@@ -1274,6 +1274,10 @@
|
||||
"Voice dictation is not configured": "Voice dictation is not configured",
|
||||
"Microphone is unavailable or already in use": "Microphone is unavailable or already in use",
|
||||
"Audio recording is not available in this browser/context": "Audio recording is not available in this browser/context",
|
||||
"Dictation": "Dictation",
|
||||
"Dictation becomes available once the page finishes connecting": "Dictation becomes available once the page finishes connecting",
|
||||
"No connection to the collaboration server — dictation unavailable": "No connection to the collaboration server — dictation unavailable",
|
||||
"This page is read-only": "This page is read-only",
|
||||
"Request format": "Request format",
|
||||
"How transcription requests are sent to the endpoint": "How transcription requests are sent to the endpoint",
|
||||
"OpenAI-compatible (multipart/form-data)": "OpenAI-compatible (multipart/form-data)",
|
||||
@@ -1378,5 +1382,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."
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -393,6 +393,17 @@
|
||||
"No speech detected": "Речь не распознана",
|
||||
"Transcription failed": "Не удалось распознать речь",
|
||||
"Voice dictation is not configured": "Голосовой ввод не настроен",
|
||||
"Start dictation": "Начать диктовку",
|
||||
"Stop recording": "Остановить запись",
|
||||
"Microphone access denied": "Доступ к микрофону запрещён",
|
||||
"No microphone found": "Микрофон не найден",
|
||||
"Microphone is unavailable or already in use": "Микрофон недоступен или уже используется",
|
||||
"Could not start recording": "Не удалось начать запись",
|
||||
"Audio recording is not available in this browser/context": "Запись аудио недоступна в этом браузере/контексте",
|
||||
"Dictation": "Диктовка",
|
||||
"Dictation becomes available once the page finishes connecting": "Диктовка станет доступна после подключения к документу",
|
||||
"No connection to the collaboration server — dictation unavailable": "Нет связи с сервером совместного редактирования — диктовка недоступна",
|
||||
"This page is read-only": "Страница открыта только для чтения",
|
||||
"Embed PDF": "Встроить PDF",
|
||||
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
|
||||
"Embed as PDF": "Встроить как PDF",
|
||||
@@ -1234,5 +1245,8 @@
|
||||
"Applied": "Применено",
|
||||
"Suggestion applied": "Предложение применено",
|
||||
"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": "Не удалось отклонить предложение"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { Provider, createStore } from "jotai";
|
||||
import { AgentAvatarStack, agentGlyphBackground } from "./agent-avatar-stack";
|
||||
import { AgentAvatarStack } from "./agent-avatar-stack";
|
||||
import { avatarStyle } from "@/lib/avatar-palette";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
@@ -13,14 +14,16 @@ import {
|
||||
|
||||
type Props = React.ComponentProps<typeof AgentAvatarStack>;
|
||||
|
||||
// The DOM normalizes an inline `background: hsl(...)` to `rgb(...)`. Push the
|
||||
// The DOM normalizes an inline hex `background-color` to `rgb(...)`. Push the
|
||||
// expected color through the same CSSOM path so the comparison stays exact and
|
||||
// non-vacuous (an empty string — i.e. no inline background, as in the pre-fix
|
||||
// Avatar approach — can never match a real color).
|
||||
// Avatar approach — can never match a real color). NOTE: jsdom's CSSOM does not
|
||||
// round-trip a `linear-gradient` in the `background` shorthand, which is why the
|
||||
// glyph carries an explicit solid `background-color` we assert on here.
|
||||
function normalizeColor(value: string): string {
|
||||
const probe = document.createElement("div");
|
||||
probe.style.background = value;
|
||||
return probe.style.background;
|
||||
probe.style.backgroundColor = value;
|
||||
return probe.style.backgroundColor;
|
||||
}
|
||||
|
||||
function renderStack(props: Props) {
|
||||
@@ -36,26 +39,6 @@ function renderStack(props: Props) {
|
||||
return { store, ...utils };
|
||||
}
|
||||
|
||||
describe("agentGlyphBackground", () => {
|
||||
it("is deterministic for a given agent name", () => {
|
||||
expect(agentGlyphBackground("Researcher")).toBe(
|
||||
agentGlyphBackground("Researcher"),
|
||||
);
|
||||
});
|
||||
|
||||
it("gives categorically different colors to different agents", () => {
|
||||
// The two agents that looked identically violet in the report must differ.
|
||||
expect(agentGlyphBackground("Структурный редактор")).not.toBe(
|
||||
agentGlyphBackground("Фактчекер"),
|
||||
);
|
||||
expect(agentGlyphBackground("Researcher")).not.toBe(
|
||||
agentGlyphBackground("Нарратор"),
|
||||
);
|
||||
// Every color is a dark hsl circle drawn from the palette.
|
||||
expect(agentGlyphBackground("Нарратор")).toMatch(/^hsl\(\d+, \d+%, \d+%\)$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentAvatarStack", () => {
|
||||
it("internal chat WITH role: emoji glyph + human launcher badge in front", () => {
|
||||
const { container } = renderStack({
|
||||
@@ -73,8 +56,8 @@ describe("AgentAvatarStack", () => {
|
||||
expect(screen.getByText("Alice")).toBeDefined();
|
||||
});
|
||||
|
||||
it("emoji glyph applies its per-agent color as an inline DOM background", () => {
|
||||
// Pins the actual fix: the hashed color must reach the DOM as an inline
|
||||
it("emoji glyph applies its per-agent gradient as an inline DOM background", () => {
|
||||
// Pins the actual fix: the hashed gradient must reach the DOM as an inline
|
||||
// `background` on the glyph Box. The pre-fix `Avatar variant="filled"` set no
|
||||
// inline background (Mantine's --avatar-bg overrode it), so this fails there.
|
||||
const agent = { name: "Researcher", emoji: "🔬", avatarUrl: null };
|
||||
@@ -88,20 +71,19 @@ describe("AgentAvatarStack", () => {
|
||||
'[data-testid="agent-glyph"]',
|
||||
);
|
||||
expect(glyph).not.toBeNull();
|
||||
// Non-vacuous: compare against the function output (normalized the same way),
|
||||
// not a frozen literal. Empty against the pre-fix Avatar (no inline bg).
|
||||
expect(glyph!.style.background).not.toBe("");
|
||||
expect(glyph!.style.background).toBe(
|
||||
normalizeColor(agentGlyphBackground(agent.name)),
|
||||
);
|
||||
const expected = normalizeColor(avatarStyle(agent.name).bg);
|
||||
// Non-vacuous: the pre-fix Avatar set no inline background at all.
|
||||
expect(expected).not.toBe("");
|
||||
expect(glyph!.style.backgroundColor).toBe(expected);
|
||||
// (The gradient overlay is a browser-only enhancement — jsdom's CSSOM does
|
||||
// not round-trip linear-gradient — so its stops/angle are covered by the
|
||||
// avatarStyle unit tests above, not asserted on the DOM here.)
|
||||
});
|
||||
|
||||
it("agents with distinct hashed colors reach the DOM as distinct backgrounds", () => {
|
||||
it("agents with distinct styles reach the DOM as distinct backgrounds", () => {
|
||||
// "Researcher" and "Нарратор" hash to different palette entries, so their
|
||||
// applied DOM backgrounds must differ — pins "distinct colors reach the DOM".
|
||||
expect(agentGlyphBackground("Researcher")).not.toBe(
|
||||
agentGlyphBackground("Нарратор"),
|
||||
);
|
||||
expect(avatarStyle("Researcher").bg).not.toBe(avatarStyle("Нарратор").bg);
|
||||
|
||||
const a = renderStack({
|
||||
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
|
||||
@@ -120,14 +102,9 @@ describe("AgentAvatarStack", () => {
|
||||
const glyphB = b.container.querySelector<HTMLElement>(
|
||||
'[data-testid="agent-glyph"]',
|
||||
);
|
||||
expect(glyphA!.style.background).toBe(
|
||||
normalizeColor(agentGlyphBackground("Researcher")),
|
||||
);
|
||||
expect(glyphB!.style.background).toBe(
|
||||
normalizeColor(agentGlyphBackground("Нарратор")),
|
||||
);
|
||||
// Different colors reach the DOM (the normalized rgb values also differ).
|
||||
expect(glyphA!.style.background).not.toBe(glyphB!.style.background);
|
||||
expect(glyphA!.style.backgroundColor).not.toBe("");
|
||||
// Different base colors reach the DOM (the serialized rgb values differ).
|
||||
expect(glyphA!.style.backgroundColor).not.toBe(glyphB!.style.backgroundColor);
|
||||
});
|
||||
|
||||
it("showName=false: renders only the avatars, no inline name label", () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||
import { avatarStyle, avatarBackgroundCss } from "@/lib/avatar-palette";
|
||||
import {
|
||||
activeAiChatIdAtom,
|
||||
aiChatWindowOpenAtom,
|
||||
@@ -29,54 +30,11 @@ const LAUNCHER_SIZE = 22;
|
||||
// sits as a small badge over that corner (above the glyph) and stays fully visible.
|
||||
const LAUNCHER_OVERHANG = 8;
|
||||
|
||||
// Small deterministic string hash (same algorithm as custom-avatar's initials
|
||||
// hash) used to pick a stable per-agent glyph color.
|
||||
function hashName(input: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
hash = (hash << 5) - hash + input.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
// A palette of categorically-DISTINCT dark circle colors for emoji/sparkles agent
|
||||
// glyphs. Every entry is intentionally dark (low lightness) so a bright emoji or
|
||||
// the white sparkles icon stays readable on top; the hues are spread across the
|
||||
// wheel (red → orange → amber → green → teal → cyan → blue → indigo → violet →
|
||||
// magenta + a neutral slate) so two different agents read as DIFFERENT colors,
|
||||
// not merely different shades of the same violet.
|
||||
const GLYPH_COLORS = [
|
||||
"hsl(355, 60%, 34%)", // red
|
||||
"hsl(18, 62%, 32%)", // vermilion
|
||||
"hsl(32, 60%, 30%)", // orange
|
||||
"hsl(45, 55%, 28%)", // amber
|
||||
"hsl(75, 45%, 26%)", // olive-green
|
||||
"hsl(140, 48%, 26%)", // green
|
||||
"hsl(165, 52%, 26%)", // teal
|
||||
"hsl(188, 58%, 28%)", // cyan
|
||||
"hsl(205, 58%, 32%)", // sky blue
|
||||
"hsl(225, 52%, 36%)", // blue
|
||||
"hsl(250, 48%, 38%)", // indigo
|
||||
"hsl(280, 46%, 36%)", // violet
|
||||
"hsl(312, 48%, 34%)", // magenta
|
||||
"hsl(210, 12%, 36%)", // slate / neutral
|
||||
];
|
||||
|
||||
/**
|
||||
* Deterministic dark circle color for an emoji/sparkles agent glyph, picked from
|
||||
* GLYPH_COLORS by a hash of the agent name so distinct agents get categorically
|
||||
* distinct colors while every color stays dark enough to keep the glyph readable.
|
||||
*/
|
||||
export function agentGlyphBackground(name: string): string {
|
||||
return GLYPH_COLORS[hashName(name) % GLYPH_COLORS.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* The front avatar. Image-source priority (#300):
|
||||
* 1. agent.avatarUrl -> a real avatar image (external MCP agent account).
|
||||
* 2. agent.emoji -> the role emoji on a per-agent dark circle.
|
||||
* 3. otherwise -> the IconSparkles glyph on a per-agent dark circle (fallback).
|
||||
* 2. agent.emoji -> the role emoji on a per-agent gradient circle.
|
||||
* 3. otherwise -> the IconSparkles glyph on a per-agent gradient circle.
|
||||
*/
|
||||
function AgentGlyph({ agent }: { agent: AgentInfo }) {
|
||||
if (agent.avatarUrl) {
|
||||
@@ -89,10 +47,13 @@ function AgentGlyph({ agent }: { agent: AgentInfo }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Emoji/sparkles glyph on a per-agent dark circle (color hashed from the agent
|
||||
// name). Rendered as a plain Box, NOT a Mantine `Avatar variant="filled"`, so
|
||||
// the background is guaranteed instead of being overridden by Mantine's
|
||||
// `--avatar-bg` (which was falling back to the theme's violet for every agent).
|
||||
// Emoji/sparkles glyph on a per-agent gradient circle (color, gradient partner
|
||||
// and split angle all hashed from the agent name via avatarStyle — see
|
||||
// @/lib/avatar-palette). Rendered as a plain Box, NOT a Mantine
|
||||
// `Avatar variant="filled"` — Mantine's `--avatar-bg` overrode the background
|
||||
// (every agent fell back to the theme's violet). The foreground (the sparkles
|
||||
// icon) uses the ring's WCAG-checked readable text color.
|
||||
const style = avatarStyle(agent.name);
|
||||
return (
|
||||
<Box
|
||||
data-testid="agent-glyph"
|
||||
@@ -100,8 +61,14 @@ function AgentGlyph({ agent }: { agent: AgentInfo }) {
|
||||
width: GLYPH_SIZE,
|
||||
height: GLYPH_SIZE,
|
||||
borderRadius: "50%",
|
||||
background: agentGlyphBackground(agent.name),
|
||||
color: "var(--mantine-color-white)",
|
||||
// Solid base color is the fallback (and the testable value); the gradient
|
||||
// paints over it in browsers that support it.
|
||||
backgroundColor: style.bg,
|
||||
backgroundImage: avatarBackgroundCss(style),
|
||||
color:
|
||||
style.text === "white"
|
||||
? "var(--mantine-color-white)"
|
||||
: "var(--mantine-color-black)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
||||
@@ -164,8 +164,8 @@
|
||||
/* NOTE: `white-space: pre-wrap` is intentionally NOT set here. On the
|
||||
rendered markdown <div> it would turn the newlines between block tags
|
||||
(</li>\n<li>, </p>\n<ol>) into visible blank lines/indents on top of the
|
||||
margins. The plain-text fallback <Text> that needs pre-wrap sets it
|
||||
inline itself (see reasoning-block.tsx). */
|
||||
margins. The streaming plain-text path that needs pre-wrap sets it
|
||||
per chunk instead, in PlainChunk (see streaming-plain-text.tsx). */
|
||||
}
|
||||
|
||||
.reasoningText p {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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";
|
||||
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,6 +65,25 @@ describe("arePropsEqual", () => {
|
||||
expect(arePropsEqual(props(m), props(m))).toBe(true);
|
||||
});
|
||||
|
||||
// REGRESSION (stranded reasoning part): a reasoning part is left at
|
||||
// `state:"streaming"` forever when the turn ends without `reasoning-end`
|
||||
// (manual Stop during thinking). The signature is EQUAL across that turn-end
|
||||
// flip (nothing in the message changed), so the comparator must ALSO compare
|
||||
// `turnStreaming` — otherwise the memo swallows the flip and ReasoningBlock
|
||||
// never switches from chunked plain text to its one-time markdown parse.
|
||||
it("returns false when turnStreaming differs despite an equal signature", () => {
|
||||
const m = msg([
|
||||
{ type: "reasoning", text: "thinking", state: "streaming" },
|
||||
{ type: "text", text: "answer" },
|
||||
]);
|
||||
expect(
|
||||
arePropsEqual(
|
||||
props(m, { turnStreaming: true }),
|
||||
props(m, { turnStreaming: false }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for the same content in a different message object", () => {
|
||||
const a = msg([{ type: "text", text: "answer" }]);
|
||||
const b = msg([{ type: "text", text: "answer" }]);
|
||||
|
||||
@@ -52,6 +52,20 @@ interface MessageItemProps {
|
||||
* absent; the public share passes the configured identity (agent role) name.
|
||||
*/
|
||||
assistantName?: string;
|
||||
/**
|
||||
* Whether the WHOLE turn is still streaming (MessageList's `isStreaming`).
|
||||
* A reasoning part may be left `state: "streaming"` forever when the turn
|
||||
* ends without a `reasoning-end` chunk (manual Stop during the thinking
|
||||
* phase, or a provider that never emits it) — the AI SDK finalizes reasoning
|
||||
* state ONLY on `reasoning-end`, not on `finish-step`/`finish`. So part-level
|
||||
* state alone cannot prove liveness; the reasoning part is treated as live
|
||||
* only while the whole turn is still streaming. Defaults to false.
|
||||
*
|
||||
* The parent passes it as "turn is live AND this is the tail row", so a
|
||||
* stranded part in an EARLIER row never re-activates when a later turn
|
||||
* streams.
|
||||
*/
|
||||
turnStreaming?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,6 +119,7 @@ function MessageItem({
|
||||
showCitations = true,
|
||||
neutralizeInternalLinks = false,
|
||||
assistantName,
|
||||
turnStreaming = false,
|
||||
}: MessageItemProps) {
|
||||
// `signature` is intentionally not read in the body — it exists solely as the
|
||||
// memo key (see arePropsEqual). The render reads `message` directly.
|
||||
@@ -155,8 +170,23 @@ function MessageItem({
|
||||
const text = (part as { text?: string }).text ?? "";
|
||||
if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
|
||||
return null;
|
||||
// Absent state (persisted rows) and "done" both mean finalized.
|
||||
// `messageSignature` already includes each part's `state`, so the
|
||||
// streaming→done flip changes the row signature and re-renders this
|
||||
// row — which is what lets ReasoningBlock switch from chunked plain
|
||||
// text to its one-time markdown parse (see reasoning-block.tsx).
|
||||
// ALSO require the turn to be live: a part stranded at
|
||||
// `state:"streaming"` after the turn ended (no `reasoning-end` — see
|
||||
// the `turnStreaming` prop doc) must still finalize and parse.
|
||||
const streaming =
|
||||
turnStreaming && (part as { state?: string }).state === "streaming";
|
||||
return (
|
||||
<ReasoningBlock key={index} text={text} tokens={reasoningTokens} />
|
||||
<ReasoningBlock
|
||||
key={index}
|
||||
text={text}
|
||||
tokens={reasoningTokens}
|
||||
streaming={streaming}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -245,7 +275,11 @@ export function arePropsEqual(
|
||||
prev.signature === next.signature &&
|
||||
prev.showCitations === next.showCitations &&
|
||||
prev.neutralizeInternalLinks === next.neutralizeInternalLinks &&
|
||||
prev.assistantName === next.assistantName
|
||||
prev.assistantName === next.assistantName &&
|
||||
// The turn-end flip re-renders every row once (cheap, terminal event) —
|
||||
// that is what converts a stranded `state:"streaming"` reasoning part to
|
||||
// its one-time markdown parse (see the `turnStreaming` prop doc).
|
||||
prev.turnStreaming === next.turnStreaming
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { fireEvent, render } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
@@ -50,8 +50,9 @@ vi.stubGlobal(
|
||||
|
||||
// One assistant message wrapping the given `parts`. Reused across renders in the
|
||||
// regression test to model how the AI SDK hands back the SAME message object.
|
||||
const msg = (parts: UIMessage["parts"]): UIMessage =>
|
||||
({ id: "m1", role: "assistant", parts }) as UIMessage;
|
||||
// Pass an explicit `id` when a test renders several rows at once.
|
||||
const msg = (parts: UIMessage["parts"], id = "m1"): UIMessage =>
|
||||
({ id, role: "assistant", parts }) as UIMessage;
|
||||
|
||||
describe("MessageList", () => {
|
||||
it("wires the real MessageItem and supplies a valid signature end-to-end", () => {
|
||||
@@ -116,4 +117,102 @@ describe("MessageList", () => {
|
||||
renderChatMarkdownSpy.mock.calls.some((c) => c[0] === "streamed answer"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
// REGRESSION (stranded reasoning part): the AI SDK sets a reasoning part's
|
||||
// state to "done" ONLY on the `reasoning-end` chunk — `finish-step`/`finish`
|
||||
// do NOT finalize it. A manual Stop during the thinking phase (or a provider
|
||||
// that never emits `reasoning-end`) therefore leaves the part at
|
||||
// `state:"streaming"` forever. MessageItem must derive ReasoningBlock's
|
||||
// `streaming` from part state AND turn liveness (MessageList's `isStreaming`,
|
||||
// forwarded as `turnStreaming`): while the turn streams the expanded block
|
||||
// shows chunked plain text (no parse); once the turn ends — even though the
|
||||
// part is still `state:"streaming"` — the block finalizes and does its
|
||||
// one-time markdown parse. Note the message signature does NOT change across
|
||||
// that flip, so this also exercises the `turnStreaming` memo comparison in
|
||||
// arePropsEqual (without it the row would never re-render).
|
||||
it("finalizes a reasoning part stranded at state:'streaming' when the turn ends", () => {
|
||||
renderChatMarkdownSpy.mockClear();
|
||||
const reasoningText = "**bold** thinking";
|
||||
// Reasoning part stranded mid-stream + a non-empty answer part (a
|
||||
// reasoning-only message renders nothing — see message-content.ts).
|
||||
const message = msg([
|
||||
{ type: "reasoning", text: reasoningText, state: "streaming" },
|
||||
{ type: "text", text: "partial answer" },
|
||||
]);
|
||||
const parsesOfReasoning = () =>
|
||||
renderChatMarkdownSpy.mock.calls.filter((c) => c[0] === reasoningText)
|
||||
.length;
|
||||
|
||||
const { rerender, getByRole, queryByText } = render(
|
||||
<MantineProvider>
|
||||
<MessageList messages={[message]} isStreaming />
|
||||
</MantineProvider>,
|
||||
);
|
||||
// Expand the reasoning block (its toggle is the only button in the list).
|
||||
fireEvent.click(getByRole("button"));
|
||||
// Turn live + part streaming -> ReasoningBlock received streaming=true:
|
||||
// the body is chunked plain text (raw markdown syntax), NOT parsed.
|
||||
expect(queryByText(/bold/)).not.toBeNull();
|
||||
expect(parsesOfReasoning()).toBe(0);
|
||||
|
||||
// The turn ends WITHOUT `reasoning-end`: the part object is untouched
|
||||
// (still state:"streaming"), only the turn-level flag flips.
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<MessageList messages={[message]} isStreaming={false} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
// ReasoningBlock now received streaming=false and did its one-time parse.
|
||||
expect(parsesOfReasoning()).toBe(1);
|
||||
});
|
||||
|
||||
// REGRESSION (turn-global liveness leaking into earlier rows): `isStreaming`
|
||||
// is turn-global, so forwarding it to EVERY row would re-mark a reasoning
|
||||
// part stranded at `state:"streaming"` in a PREVIOUS message (see the test
|
||||
// above) as live again whenever a LATER turn streams — an expanded stranded
|
||||
// block would flip markdown -> raw plain text -> markdown across turn
|
||||
// boundaries, re-parsing each time. MessageList must gate `turnStreaming`
|
||||
// to the TAIL row only.
|
||||
it("keeps a stranded reasoning part in an earlier message finalized while a later turn streams", () => {
|
||||
renderChatMarkdownSpy.mockClear();
|
||||
const reasoningText = "**bold** thinking";
|
||||
// First (earlier) assistant message: its turn was stopped during the
|
||||
// thinking phase, leaving the reasoning part at state:"streaming".
|
||||
const first = msg(
|
||||
[
|
||||
{ type: "reasoning", text: reasoningText, state: "streaming" },
|
||||
{ type: "text", text: "first answer" },
|
||||
],
|
||||
"m1",
|
||||
);
|
||||
// Second assistant message: the LATER turn, currently streaming.
|
||||
const second = msg([{ type: "text", text: "second answer" }], "m2");
|
||||
const parsesOfReasoning = () =>
|
||||
renderChatMarkdownSpy.mock.calls.filter((c) => c[0] === reasoningText)
|
||||
.length;
|
||||
|
||||
const { rerender, getByRole, queryByText } = render(
|
||||
<MantineProvider>
|
||||
<MessageList messages={[first, second]} isStreaming />
|
||||
</MantineProvider>,
|
||||
);
|
||||
// Expand the first row's reasoning block (the only toggle in the list —
|
||||
// the second message has no reasoning or tool parts).
|
||||
fireEvent.click(getByRole("button"));
|
||||
// The turn is live but the first row is NOT the tail: its ReasoningBlock
|
||||
// received streaming=false, so the stranded part stays finalized and does
|
||||
// its one-time markdown parse instead of dropping to chunked plain text.
|
||||
expect(queryByText(/bold/)).not.toBeNull();
|
||||
expect(parsesOfReasoning()).toBe(1);
|
||||
|
||||
// A later-turn delta re-renders the list; the earlier block must neither
|
||||
// flip back to streaming nor re-parse.
|
||||
(second.parts[0] as { text: string }).text = "second answer grows";
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<MessageList messages={[first, second]} isStreaming />
|
||||
</MantineProvider>,
|
||||
);
|
||||
expect(parsesOfReasoning()).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,7 +196,7 @@ export default function MessageList({
|
||||
return (
|
||||
<ScrollArea className={classes.messages} viewportRef={viewportRef} scrollbarSize={6} type="scroll">
|
||||
<Stack gap={0} pr="xs">
|
||||
{messages.map((message) => (
|
||||
{messages.map((message, index) => (
|
||||
// `signature` is snapshotted HERE (parent render) into an immutable
|
||||
// string and handed to MessageItem as its memo key. It must NOT be
|
||||
// recomputed inside MessageItem's arePropsEqual: the AI SDK mutates the
|
||||
@@ -210,6 +210,13 @@ export default function MessageList({
|
||||
showCitations={showCitations}
|
||||
neutralizeInternalLinks={neutralizeInternalLinks}
|
||||
assistantName={assistantName}
|
||||
// Turn-level liveness, gated to the TAIL row: only the tail message
|
||||
// can belong to the in-flight turn, so a reasoning part stranded at
|
||||
// `state:"streaming"` in an EARLIER message (its turn ended without
|
||||
// `reasoning-end`) stays finalized and doesn't flip back to plain
|
||||
// text (and re-parse) whenever a later turn streams — see
|
||||
// message-item.tsx.
|
||||
turnStreaming={isStreaming && index === messages.length - 1}
|
||||
/>
|
||||
))}
|
||||
{typing && (
|
||||
|
||||
@@ -28,7 +28,11 @@ import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
function renderBlock(props: { text: string; tokens?: number }) {
|
||||
function renderBlock(props: {
|
||||
text: string;
|
||||
tokens?: number;
|
||||
streaming?: boolean;
|
||||
}) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<ReasoningBlock {...props} />
|
||||
@@ -84,4 +88,54 @@ describe("ReasoningBlock", () => {
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not parse while expanded and STREAMING; shows chunked plain text", () => {
|
||||
const renderSpy = vi.mocked(renderChatMarkdown);
|
||||
renderSpy.mockClear();
|
||||
renderBlock({
|
||||
text: "первый абзац размышлений\n\nвторой абзац растёт",
|
||||
tokens: 5,
|
||||
streaming: true,
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
// Expanded + still streaming: NO markdown parse and NO innerHTML swaps per
|
||||
// delta — the body is chunked plain text (only the tail chunk updates).
|
||||
// This is the O(n²) hole #302 left open (Safari whole-tab freeze).
|
||||
expect(renderSpy).not.toHaveBeenCalled();
|
||||
// Both paragraph chunks' raw text is present in the body.
|
||||
expect(screen.getByText(/первый абзац размышлений/)).toBeDefined();
|
||||
expect(screen.getByText(/второй абзац растёт/)).toBeDefined();
|
||||
});
|
||||
|
||||
it("parses exactly once when streaming flips to done while expanded", () => {
|
||||
const renderSpy = vi.mocked(renderChatMarkdown);
|
||||
renderSpy.mockClear();
|
||||
const { rerender } = renderBlock({
|
||||
text: "**bold** reasoning",
|
||||
tokens: 5,
|
||||
streaming: true,
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(renderSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Finalization: the part's state flips streaming→done, the parent
|
||||
// re-renders the row (the flip changes the message signature), and the
|
||||
// block does its ONE markdown parse of the now-stable text.
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<ReasoningBlock text="**bold** reasoning" tokens={5} streaming={false} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||
// The parsed html branch rendered (the mock wraps the input in <p>…</p>).
|
||||
expect(screen.getByText(/reasoning/)).toBeDefined();
|
||||
|
||||
// Further re-renders with unchanged props do not re-parse.
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<ReasoningBlock text="**bold** reasoning" tokens={5} streaming={false} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
expect(renderSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
|
||||
import { collapseBlankLines } from "@/features/ai-chat/utils/collapse-blank-lines.ts";
|
||||
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||
import { StreamingPlainText } from "@/features/ai-chat/components/streaming-plain-text.tsx";
|
||||
import classes from "@/features/ai-chat/components/ai-chat.module.css";
|
||||
|
||||
interface ReasoningBlockProps {
|
||||
@@ -15,6 +16,10 @@ interface ReasoningBlockProps {
|
||||
* step/turn has finished. When absent (or 0) the count is estimated from the
|
||||
* text length so it ticks live as the reasoning streams in. */
|
||||
tokens?: number;
|
||||
/** True while the reasoning part is still streaming (part `state ===
|
||||
* "streaming"`). False means finalized: persisted history or `state ===
|
||||
* "done"`. Gates the markdown parse — see the invariant on the memo below. */
|
||||
streaming?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,26 +32,30 @@ interface ReasoningBlockProps {
|
||||
* Providers that don't stream reasoning TEXT still render this block from the
|
||||
* authoritative count alone (header only, empty body) so the cost is visible.
|
||||
*/
|
||||
function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
|
||||
function ReasoningBlock({ text, tokens, streaming = false }: ReasoningBlockProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Authoritative count wins; otherwise estimate live from the streamed text.
|
||||
const count = tokens && tokens > 0 ? tokens : estimateTokens(text);
|
||||
const trimmed = text.trim();
|
||||
// Parse the reasoning markdown ONLY while the block is expanded. Collapsed is the
|
||||
// default and the common case during a long "thinking" stream: reasoning text
|
||||
// streams in and grows with every throttled delta (~20Hz), so a `[trimmed]`-only
|
||||
// memo re-parses the whole, ever-growing text (marked + DOMPurify) on every delta
|
||||
// — an O(n²) storm that pins the main thread and freezes the chat, all for a block
|
||||
// the user isn't even looking at (the html is only shown inside <Collapse in={open}>
|
||||
// below). Gating on `open` skips that hidden parsing entirely; expanding parses the
|
||||
// current text once (an instant, user-initiated click), and further streaming while
|
||||
// open is the normal per-delta append render, like the answer.
|
||||
// Markdown parse invariant (per throttled ~20Hz stream delta the text GROWS):
|
||||
// 1. Collapsed -> never parse (#302): the html is only shown inside
|
||||
// <Collapse in={open}>, so parsing for a hidden body would be an O(n²)
|
||||
// marked + DOMPurify storm.
|
||||
// 2. Expanded + STREAMING -> no parse and no innerHTML swaps either: the body
|
||||
// renders as chunked plain text (StreamingPlainText) with a memoized
|
||||
// stable prefix, so each delta updates only the tail chunk's text node.
|
||||
// This closes the O(n²) hole #302 left open ("expanded while streaming")
|
||||
// that froze the whole tab in Safari when watching the thinking stream.
|
||||
// 3. Finalized + expanded -> exactly one parse: `trimmed` and `streaming`
|
||||
// are stable after the part is done, so this memo runs once per expand.
|
||||
const html = useMemo(
|
||||
() =>
|
||||
open && trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : "",
|
||||
[open, trimmed],
|
||||
open && trimmed && !streaming
|
||||
? renderChatMarkdown(collapseBlankLines(trimmed), {})
|
||||
: "",
|
||||
[open, trimmed, streaming],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -83,12 +92,12 @@ function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
) : (
|
||||
<Text
|
||||
className={classes.reasoningText}
|
||||
style={{ whiteSpace: "pre-wrap" }}
|
||||
>
|
||||
{trimmed}
|
||||
</Text>
|
||||
// Still streaming (or markdown yielded nothing): chunked plain text.
|
||||
// The wrapper carries the reasoningText styling; each chunk sets its
|
||||
// own pre-wrap inline (NOT on this div — see ai-chat.module.css).
|
||||
<div className={classes.reasoningText}>
|
||||
<StreamingPlainText text={trimmed} />
|
||||
</div>
|
||||
)}
|
||||
</Collapse>
|
||||
)}
|
||||
@@ -96,7 +105,7 @@ function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Memoized: re-renders only when `text`/`tokens` change (primitive props, default
|
||||
// shallow compare), so a parent re-render during streaming of OTHER content does
|
||||
// not re-run the markdown parse for an already-finalized reasoning block.
|
||||
// Memoized: re-renders only when `text`/`tokens`/`streaming` change (primitive
|
||||
// props, default shallow compare), so a parent re-render during streaming of OTHER
|
||||
// content does not re-run the markdown parse for an already-finalized reasoning block.
|
||||
export default memo(ReasoningBlock);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
|
||||
import {
|
||||
splitPlainChunks,
|
||||
StreamingPlainText,
|
||||
} from "./streaming-plain-text";
|
||||
|
||||
describe("splitPlainChunks", () => {
|
||||
// THE load-bearing property (see the invariant comment in the module): under
|
||||
// append-only growth, every chunk except the LAST must be byte-identical
|
||||
// between successive calls, so the memoized chunk components never re-render
|
||||
// for the stable prefix and each stream delta touches only the tail chunk.
|
||||
it("keeps all non-last chunks byte-identical across append-only growth", () => {
|
||||
// A simulated reasoning stream covering: appends inside the last paragraph,
|
||||
// appends that ADD new blank lines, growth of a trailing newline run, and a
|
||||
// trailing separator later followed by text.
|
||||
const steps = [
|
||||
"Пер",
|
||||
"Первый абзац",
|
||||
"Первый абзац\n",
|
||||
"Первый абзац\n\n",
|
||||
"Первый абзац\n\n\n",
|
||||
"Первый абзац\n\n\nВторой",
|
||||
"Первый абзац\n\n\nВторой абзац растёт",
|
||||
"Первый абзац\n\n\nВторой абзац растёт\n\nТретий",
|
||||
"Первый абзац\n\n\nВторой абзац растёт\n\nТретий абзац\n\n",
|
||||
"Первый абзац\n\n\nВторой абзац растёт\n\nТретий абзац\n\nЧетвёртый",
|
||||
];
|
||||
let prev: string[] = [];
|
||||
for (const text of steps) {
|
||||
const next = splitPlainChunks(text);
|
||||
// Lossless: chunks always reassemble into the exact input.
|
||||
expect(next.join("")).toBe(text);
|
||||
// Chunk count never shrinks (boundaries never disappear).
|
||||
expect(next.length).toBeGreaterThanOrEqual(prev.length);
|
||||
// Every previously-FINAL chunk (all but prev's last) is unchanged.
|
||||
for (let i = 0; i < prev.length - 1; i++) {
|
||||
expect(next[i]).toBe(prev[i]);
|
||||
}
|
||||
prev = next;
|
||||
}
|
||||
// Guard against a vacuous pass: the final split must be multi-chunk.
|
||||
expect(prev.length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it("attaches the blank-line separator run to the preceding chunk", () => {
|
||||
expect(splitPlainChunks("a\n\nb")).toEqual(["a\n\n", "b"]);
|
||||
// A longer run is ONE separator, not several boundaries.
|
||||
expect(splitPlainChunks("a\n\n\n\nb")).toEqual(["a\n\n\n\n", "b"]);
|
||||
expect(splitPlainChunks("a\n\nb\n\n\nc")).toEqual(["a\n\n", "b\n\n\n", "c"]);
|
||||
});
|
||||
|
||||
it("single newlines are not boundaries", () => {
|
||||
expect(splitPlainChunks("a\nb\nc")).toEqual(["a\nb\nc"]);
|
||||
});
|
||||
|
||||
// INTENTIONAL: CRLF blank lines are NOT boundaries (the regex is `\n{2,}`
|
||||
// only). Supporting `(?:\r?\n){2,}` would break the stable-prefix invariant:
|
||||
// a lone trailing `\r` is not a boundary, but a later-appended `\n` would
|
||||
// merge with it into a new separator unit and retroactively create a boundary
|
||||
// INSIDE previously-emitted text, moving old chunk edges. So CRLF input stays
|
||||
// in one (still lossless) chunk — only granularity is coarser; LLM output is
|
||||
// `\n` in practice. See the doc comment on splitPlainChunks.
|
||||
it("keeps CRLF blank lines inside one chunk", () => {
|
||||
expect(splitPlainChunks("a\r\n\r\nb")).toEqual(["a\r\n\r\nb"]);
|
||||
// Mixed input: only pure-`\n` runs split.
|
||||
expect(splitPlainChunks("a\r\n\r\nb\n\nc")).toEqual(["a\r\n\r\nb\n\n", "c"]);
|
||||
});
|
||||
|
||||
it("never emits empty phantom chunks (multi-blank-line / trailing newlines)", () => {
|
||||
expect(splitPlainChunks("")).toEqual([]);
|
||||
// A trailing newline run stays inside the last chunk (it may still grow).
|
||||
expect(splitPlainChunks("a\n")).toEqual(["a\n"]);
|
||||
expect(splitPlainChunks("a\n\n")).toEqual(["a\n\n"]);
|
||||
expect(splitPlainChunks("a\n\nb\n\n")).toEqual(["a\n\n", "b\n\n"]);
|
||||
// Degenerate all-newlines input is a single deterministic chunk.
|
||||
expect(splitPlainChunks("\n\n\n")).toEqual(["\n\n\n"]);
|
||||
for (const text of ["a\n\n\nb\n\n", "x\n\n\n\n\ny\n\nz\n"]) {
|
||||
for (const chunk of splitPlainChunks(text)) {
|
||||
expect(chunk.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("StreamingPlainText", () => {
|
||||
it("renders one block per chunk, stripping trailing separator newlines at display time", () => {
|
||||
const text = "первый абзац\n\nвторой абзац\n\n\nтретий";
|
||||
const { container } = render(<StreamingPlainText text={text} />);
|
||||
const blocks = Array.from(container.querySelectorAll("div"));
|
||||
// One block element per chunk.
|
||||
expect(blocks.length).toBe(splitPlainChunks(text).length);
|
||||
// DISPLAY-ONLY strip: each rendered block drops its chunk's trailing
|
||||
// separator newlines — rendering them inside a pre-wrap block would add an
|
||||
// empty line ON TOP of the block break (a doubled gap). The RAW chunks
|
||||
// keep their separators (losslessness is asserted on splitPlainChunks
|
||||
// above); multi-blank-line runs collapse to one uniform gap, consistent
|
||||
// with collapseBlankLines on the finalized markdown path.
|
||||
expect(blocks.map((b) => b.textContent)).toEqual([
|
||||
"первый абзац",
|
||||
"второй абзац",
|
||||
"третий",
|
||||
]);
|
||||
// The uniform paragraph gap comes from the block margin instead (matches
|
||||
// the `.reasoningText p { margin: 0 0 4px }` rhythm of the markdown path).
|
||||
for (const block of blocks) {
|
||||
expect((block as HTMLElement).style.marginBottom).toBe("4px");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps interior newlines intact — only the trailing run is stripped", () => {
|
||||
const text = "строка один\nстрока два\n\nхвост";
|
||||
const { container } = render(<StreamingPlainText text={text} />);
|
||||
const blocks = Array.from(container.querySelectorAll("div"));
|
||||
expect(blocks.map((b) => b.textContent)).toEqual([
|
||||
"строка один\nстрока два",
|
||||
"хвост",
|
||||
]);
|
||||
});
|
||||
|
||||
// SECURITY INVARIANT — the load-bearing property of the streaming path: the
|
||||
// reasoning text is raw, untrusted model output rendered WITHOUT a sanitizer
|
||||
// (no marked/DOMPurify, no innerHTML). PlainChunk emits it as a React text
|
||||
// node, which escapes it, so HTML in the model output is inert. This test
|
||||
// pins that the path is a TEXT sink, not an HTML sink: a future change to
|
||||
// `dangerouslySetInnerHTML` (reintroducing XSS) MUST fail here.
|
||||
//
|
||||
// The existing tests assert via textContent, which strips tags and so cannot
|
||||
// distinguish an escaped literal from injected DOM. This one asserts on the
|
||||
// parsed DOM directly: if the markup were injected as HTML, the <img>/<b>
|
||||
// would become real elements and querySelector would find them.
|
||||
it("renders HTML-like reasoning as an escaped literal, never as injected DOM", () => {
|
||||
const text = "<img src=x onerror=alert(1)>\n\n<b>hi</b>";
|
||||
const { container } = render(<StreamingPlainText text={text} />);
|
||||
// No DOM elements were created from the payload — it was NOT parsed as HTML.
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(container.querySelector("b")).toBeNull();
|
||||
// The raw markup survived verbatim as text (proving it is escaped, not
|
||||
// interpreted). textContent alone can't prove this, but combined with the
|
||||
// querySelector assertions above it does: the literals are present AND no
|
||||
// elements exist.
|
||||
expect(container.textContent).toContain("<b>hi</b>");
|
||||
expect(container.textContent).toContain("<img src=x onerror=alert(1)>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { memo, useMemo } from "react";
|
||||
|
||||
/**
|
||||
* Split plain text into chunks at blank-line (paragraph) boundaries, keeping
|
||||
* each separator run attached to the END of the preceding chunk, so the chunks
|
||||
* always reassemble byte-for-byte into the input.
|
||||
*
|
||||
* A boundary is the end of a maximal `\n{2,}` run that is followed by at least
|
||||
* one more character. A newline run that is a SUFFIX of the text is NOT a
|
||||
* boundary yet: under append-only growth it may still gain more newlines, and
|
||||
* cutting there would move the boundary on the next call.
|
||||
*
|
||||
* CRITICAL INVARIANT (load-bearing for StreamingPlainText's memoization): for
|
||||
* APPEND-ONLY growth of `text`, every chunk except the LAST is byte-identical
|
||||
* between successive calls — previously-emitted boundaries never move. Proof
|
||||
* sketch: appending never modifies existing characters, so (a) an existing
|
||||
* boundary's newline run and its following character are untouched and the
|
||||
* boundary persists at the same offset; (b) no NEW boundary can appear strictly
|
||||
* inside the old text, because a `\n{2,}` run followed by a character entirely
|
||||
* within the old text would already have been a boundary. New boundaries can
|
||||
* only materialize at or after the old text's end, i.e. inside the last chunk.
|
||||
*
|
||||
* CRLF is deliberately NOT a boundary: supporting `(?:\r?\n){2,}` would BREAK
|
||||
* the invariant above — a lone trailing `\r` is not a boundary, but a later-
|
||||
* appended `\n` would merge with it into a new separator unit and retroactively
|
||||
* create a boundary INSIDE previously-emitted text, moving old chunk edges.
|
||||
* With `\n`-only runs, appended characters can never extend a run that is
|
||||
* already followed by a non-`\n` character, so old boundaries are immutable.
|
||||
* CRLF blank lines therefore intentionally stay inside one chunk: correctness/
|
||||
* losslessness are unaffected, only chunk granularity for CRLF input (LLM
|
||||
* output is `\n` in practice).
|
||||
*/
|
||||
export function splitPlainChunks(text: string): string[] {
|
||||
const chunks: string[] = [];
|
||||
let start = 0;
|
||||
for (const match of text.matchAll(/\n{2,}/g)) {
|
||||
const end = match.index + match[0].length;
|
||||
// Suffix run: not a stable boundary yet (see the invariant above).
|
||||
if (end >= text.length) break;
|
||||
chunks.push(text.slice(start, end));
|
||||
start = end;
|
||||
}
|
||||
if (start < text.length) chunks.push(text.slice(start));
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* One immutable chunk. Memoized on its string prop: during streaming only the
|
||||
* TAIL chunk's text changes (see the splitPlainChunks invariant), so React
|
||||
* skips every stable chunk and the per-delta DOM work is a single text-node
|
||||
* update. `pre-wrap` is set per chunk (like the old raw-text fallback did), NOT
|
||||
* on the surrounding markdown-styled container — see the note in
|
||||
* ai-chat.module.css. Font/size/color are inherited from that container.
|
||||
*
|
||||
* DISPLAY-ONLY newline strip: the raw chunk keeps its trailing `\n{2,}`
|
||||
* separator run attached (the splitPlainChunks invariant, load-bearing for the
|
||||
* memo), but rendering those newlines inside a pre-wrap block would add an
|
||||
* empty line ON TOP of the block break — a doubled gap. So the RENDERED string
|
||||
* drops trailing newlines and the paragraph gap comes from `marginBottom: 4`
|
||||
* instead, matching the `.reasoningText p { margin: 0 0 4px }` rhythm of the
|
||||
* finalized markdown. Multi-blank-line runs thus collapse to one uniform gap,
|
||||
* consistent with `collapseBlankLines` on the markdown path. The last chunk
|
||||
* usually has no trailing newlines (strip is a no-op); its margin is harmless.
|
||||
*/
|
||||
const PlainChunk = memo(function PlainChunk({ text }: { text: string }) {
|
||||
return (
|
||||
<div style={{ whiteSpace: "pre-wrap", marginBottom: 4 }}>
|
||||
{text.replace(/\n+$/, "")}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Renders still-streaming plain text as a list of paragraph chunks where only
|
||||
* the tail chunk changes per delta. No markdown, no sanitizer, no innerHTML —
|
||||
* this is the cheap streaming-time stand-in for the one-time markdown parse
|
||||
* that happens after the part is finalized (see reasoning-block.tsx).
|
||||
*/
|
||||
export function StreamingPlainText({ text }: { text: string }) {
|
||||
const chunks = useMemo(() => splitPlainChunks(text), [text]);
|
||||
return (
|
||||
<>
|
||||
{chunks.map((chunk, index) => (
|
||||
// Index keys are stable here: chunks are append-only (the invariant),
|
||||
// so an index never gets a different chunk's content mid-stream.
|
||||
<PlainChunk key={index} text={chunk} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
// The fallback path renders the full TipTap editor; stub it so we can assert the
|
||||
// safety valve fired without pulling in the editor stack.
|
||||
vi.mock("@/features/comment/components/comment-editor", () => ({
|
||||
default: () => <div data-testid="comment-editor-fallback" />,
|
||||
}));
|
||||
|
||||
// Mention rendering hits react-query; stub the page/share queries so the mention
|
||||
// case renders in isolation.
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||
}));
|
||||
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||
useSharePageQuery: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
import { CommentContentView } from "./comment-content-view";
|
||||
|
||||
function renderView(content: string | object) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<MemoryRouter>
|
||||
<CommentContentView content={content} />
|
||||
</MemoryRouter>
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const doc = (content: any[]) => JSON.stringify({ type: "doc", content });
|
||||
const para = (content: any[]) => ({ type: "paragraph", content });
|
||||
const text = (t: string, marks?: any[]) => ({ type: "text", text: t, marks });
|
||||
|
||||
describe("CommentContentView", () => {
|
||||
it("renders paragraphs as <p> with text", () => {
|
||||
const { container } = renderView(doc([para([text("Hello world")])]));
|
||||
expect(screen.getByText("Hello world")).toBeDefined();
|
||||
expect(container.querySelector("p")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("reproduces the read-only CommentEditor DOM nesting for CSS parity", () => {
|
||||
const { container } = renderView(doc([para([text("x")])]));
|
||||
// outer .commentEditor > .ProseMirror (module) > .ProseMirror (global) > p
|
||||
const globalPm = container.querySelector("div.ProseMirror > p");
|
||||
expect(globalPm).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the bold mark as <strong>", () => {
|
||||
const { container } = renderView(
|
||||
doc([para([text("bold", [{ type: "bold" }])])]),
|
||||
);
|
||||
const el = container.querySelector("strong");
|
||||
expect(el?.textContent).toBe("bold");
|
||||
});
|
||||
|
||||
it("renders the italic mark as <em>", () => {
|
||||
const { container } = renderView(
|
||||
doc([para([text("it", [{ type: "italic" }])])]),
|
||||
);
|
||||
expect(container.querySelector("em")?.textContent).toBe("it");
|
||||
});
|
||||
|
||||
it("renders the strike mark as <s>", () => {
|
||||
const { container } = renderView(
|
||||
doc([para([text("st", [{ type: "strike" }])])]),
|
||||
);
|
||||
expect(container.querySelector("s")?.textContent).toBe("st");
|
||||
});
|
||||
|
||||
it("renders the underline mark as <u> (not the editor fallback)", () => {
|
||||
const { container } = renderView(
|
||||
doc([para([text("un", [{ type: "underline" }])])]),
|
||||
);
|
||||
expect(container.querySelector("u")?.textContent).toBe("un");
|
||||
// Underline is a supported mark, so no degrade to the editor fallback.
|
||||
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the code mark as <code>", () => {
|
||||
const { container } = renderView(
|
||||
doc([para([text("co", [{ type: "code" }])])]),
|
||||
);
|
||||
expect(container.querySelector("code")?.textContent).toBe("co");
|
||||
});
|
||||
|
||||
it("renders the link mark as an anchor with safe rel/target", () => {
|
||||
const { container } = renderView(
|
||||
doc([
|
||||
para([
|
||||
text("click", [
|
||||
{ type: "link", attrs: { href: "https://example.com" } },
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
const a = container.querySelector("a");
|
||||
expect(a?.getAttribute("href")).toBe("https://example.com");
|
||||
expect(a?.getAttribute("target")).toBe("_blank");
|
||||
expect(a?.getAttribute("rel")).toBe("noopener noreferrer nofollow");
|
||||
expect(a?.textContent).toBe("click");
|
||||
});
|
||||
|
||||
it("neutralizes a javascript: link href (stored XSS) while keeping the text", () => {
|
||||
const { container } = renderView(
|
||||
doc([
|
||||
para([
|
||||
text("click", [
|
||||
{ type: "link", attrs: { href: "javascript:alert(1)" } },
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
const a = container.querySelector("a");
|
||||
expect(a).not.toBeNull();
|
||||
// No navigable javascript: href — attribute is absent (or empty).
|
||||
expect(a?.getAttribute("href")).toBeFalsy();
|
||||
// The link text is still rendered.
|
||||
expect(a?.textContent).toBe("click");
|
||||
});
|
||||
|
||||
it("neutralizes a control-char-obfuscated javascript: href", () => {
|
||||
const { container } = renderView(
|
||||
doc([
|
||||
para([
|
||||
text("x", [
|
||||
{ type: "link", attrs: { href: "java\tscript:alert(1)" } },
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
expect(container.querySelector("a")?.getAttribute("href")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("neutralizes a data: link href", () => {
|
||||
const { container } = renderView(
|
||||
doc([
|
||||
para([
|
||||
text("x", [
|
||||
{
|
||||
type: "link",
|
||||
attrs: { href: "data:text/html,<script>alert(1)</script>" },
|
||||
},
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
expect(container.querySelector("a")?.getAttribute("href")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("preserves a mailto: link href (allowlisted scheme)", () => {
|
||||
const { container } = renderView(
|
||||
doc([
|
||||
para([
|
||||
text("mail", [
|
||||
{ type: "link", attrs: { href: "mailto:a@b.com" } },
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
expect(container.querySelector("a")?.getAttribute("href")).toBe(
|
||||
"mailto:a@b.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves a relative link href (no scheme, not a script vector)", () => {
|
||||
const { container } = renderView(
|
||||
doc([
|
||||
para([
|
||||
text("rel", [{ type: "link", attrs: { href: "/some/path" } }]),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
expect(container.querySelector("a")?.getAttribute("href")).toBe(
|
||||
"/some/path",
|
||||
);
|
||||
});
|
||||
|
||||
it("nests multiple marks on one text node", () => {
|
||||
const { container } = renderView(
|
||||
doc([para([text("x", [{ type: "bold" }, { type: "italic" }])])]),
|
||||
);
|
||||
// bold wraps italic (or vice versa) — both elements exist around the text.
|
||||
expect(container.querySelector("strong")).not.toBeNull();
|
||||
expect(container.querySelector("em")).not.toBeNull();
|
||||
expect(screen.getByText("x")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders hardBreak as <br/>", () => {
|
||||
const { container } = renderView(
|
||||
doc([para([text("a"), { type: "hardBreak" }, text("b")])]),
|
||||
);
|
||||
expect(container.querySelector("br")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders a user mention as a styled span", () => {
|
||||
const { container } = renderView(
|
||||
doc([
|
||||
para([
|
||||
{
|
||||
type: "mention",
|
||||
attrs: { label: "Alice", entityType: "user", entityId: "u1" },
|
||||
},
|
||||
]),
|
||||
]),
|
||||
);
|
||||
expect(screen.getByText("@Alice")).toBeDefined();
|
||||
// No fallback to the editor.
|
||||
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a page mention as a link", () => {
|
||||
const { container } = renderView(
|
||||
doc([
|
||||
para([
|
||||
{
|
||||
type: "mention",
|
||||
attrs: {
|
||||
label: "Some Page",
|
||||
entityType: "page",
|
||||
slugId: "pg1",
|
||||
},
|
||||
},
|
||||
]),
|
||||
]),
|
||||
);
|
||||
expect(container.querySelector("a")).not.toBeNull();
|
||||
expect(screen.getByText("Some Page")).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders a legacy plain-text (non-JSON) string as plain text", () => {
|
||||
renderView("just a legacy string");
|
||||
expect(screen.getByText("just a legacy string")).toBeDefined();
|
||||
expect(screen.queryByTestId("comment-editor-fallback")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to CommentEditor for an unknown node type", () => {
|
||||
renderView(doc([{ type: "codeBlock", content: [text("x")] }]));
|
||||
expect(screen.getByTestId("comment-editor-fallback")).toBeDefined();
|
||||
});
|
||||
|
||||
it("falls back to CommentEditor for malformed JSON", () => {
|
||||
renderView('{"type":"doc","content":[');
|
||||
expect(screen.getByTestId("comment-editor-fallback")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import React from "react";
|
||||
import classes from "./comment.module.css";
|
||||
import { MentionContent } from "@/features/editor/components/mention/mention-view";
|
||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||
|
||||
// Static, editor-free renderer of a comment body (ProseMirror JSON). It walks the
|
||||
// document and emits plain DOM, avoiding the cost of a full TipTap/ProseMirror
|
||||
// instance per comment (the panel used to spin up 400+ editors on mount).
|
||||
//
|
||||
// The supported node/mark set MUST mirror what CommentEditor enables
|
||||
// (StarterKit + Mention + LinkExtension). Anything outside that set makes the
|
||||
// whole comment degrade to the read-only CommentEditor via the fallback below,
|
||||
// so we never show a half-rendered comment.
|
||||
|
||||
// Sentinel thrown when we hit a node/mark we don't know how to render statically.
|
||||
// Caught at the top level to trigger the CommentEditor fallback for the whole comment.
|
||||
class UnknownNodeError extends Error {}
|
||||
|
||||
// Protocol allowlist mirroring @tiptap/extension-link's default (the read-only
|
||||
// CommentEditor path relies on it to blank javascript:/data: hrefs). The static
|
||||
// renderer must apply the SAME sanitization because the backend stores comment
|
||||
// content verbatim and React does not neutralize javascript: in an href.
|
||||
const ALLOWED_URI_SCHEMES = /^(?:https?|ftps?|mailto|tel|callto|sms|cid|xmpp):/i;
|
||||
|
||||
function safeHref(href: unknown): string | undefined {
|
||||
if (typeof href !== "string") return undefined;
|
||||
// Strip control chars/whitespace that could smuggle a scheme past the test
|
||||
// (e.g. "java\tscript:").
|
||||
const cleaned = href.replace(/[\u0000-\u0020]/g, "").trim();
|
||||
// Allow relative/anchor/protocol-relative links (no scheme) — not script vectors.
|
||||
if (!/^[a-z][a-z0-9+.-]*:/i.test(cleaned)) return href;
|
||||
return ALLOWED_URI_SCHEMES.test(cleaned) ? href : undefined;
|
||||
}
|
||||
|
||||
interface PMMark {
|
||||
type: string;
|
||||
attrs?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface PMNode {
|
||||
type: string;
|
||||
attrs?: Record<string, any>;
|
||||
content?: PMNode[];
|
||||
text?: string;
|
||||
marks?: PMMark[];
|
||||
}
|
||||
|
||||
// Wrap a text node's string in its marks (marks nest, e.g. bold + italic).
|
||||
function renderMarks(
|
||||
text: React.ReactNode,
|
||||
marks: PMMark[] | undefined,
|
||||
keyPrefix: string,
|
||||
): React.ReactNode {
|
||||
if (!marks || marks.length === 0) return text;
|
||||
|
||||
return marks.reduce<React.ReactNode>((acc, mark, i) => {
|
||||
const key = `${keyPrefix}-m${i}`;
|
||||
switch (mark.type) {
|
||||
case "bold":
|
||||
return <strong key={key}>{acc}</strong>;
|
||||
case "italic":
|
||||
return <em key={key}>{acc}</em>;
|
||||
case "strike":
|
||||
return <s key={key}>{acc}</s>;
|
||||
case "underline":
|
||||
// StarterKit enables the Underline extension by default (Mod-u) and
|
||||
// CommentEditor does not disable it, so real comments can carry this
|
||||
// mark. Render it here rather than degrading the whole comment.
|
||||
return <u key={key}>{acc}</u>;
|
||||
case "code":
|
||||
return <code key={key}>{acc}</code>;
|
||||
case "link": {
|
||||
// LinkExtension (TiptapLink) opens links in a new tab; keep the same
|
||||
// safe rel semantics the editor produces. Sanitize the href against the
|
||||
// extension's protocol allowlist — a disallowed scheme (javascript:,
|
||||
// data:) yields undefined so the anchor is non-navigable but still shows
|
||||
// its text, matching how extension-link blanks a bad href.
|
||||
const href = safeHref(mark.attrs?.href);
|
||||
return (
|
||||
<a
|
||||
key={key}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer nofollow"
|
||||
>
|
||||
{acc}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
default:
|
||||
throw new UnknownNodeError(`Unknown mark type: ${mark.type}`);
|
||||
}
|
||||
}, text);
|
||||
}
|
||||
|
||||
function renderNode(node: PMNode, key: string): React.ReactNode {
|
||||
switch (node.type) {
|
||||
case "paragraph":
|
||||
return <p key={key}>{renderChildren(node.content, key)}</p>;
|
||||
case "text":
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
{renderMarks(node.text ?? "", node.marks, key)}
|
||||
</React.Fragment>
|
||||
);
|
||||
case "hardBreak":
|
||||
return <br key={key} />;
|
||||
case "mention":
|
||||
return (
|
||||
<span key={key} style={{ display: "inline" }}>
|
||||
<MentionContent attrs={node.attrs as any} />
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
throw new UnknownNodeError(`Unknown node type: ${node.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderChildren(
|
||||
content: PMNode[] | undefined,
|
||||
keyPrefix: string,
|
||||
): React.ReactNode {
|
||||
if (!content) return null;
|
||||
return content.map((child, i) => renderNode(child, `${keyPrefix}-${i}`));
|
||||
}
|
||||
|
||||
// Reproduce the exact DOM nesting the read-only CommentEditor renders so the
|
||||
// scoped CSS in comment.module.css (which targets
|
||||
// `.commentEditor .ProseMirror :global(.ProseMirror)` and `.ProseMirror p`)
|
||||
// applies pixel-for-pixel. Read-only => no data-editable / data-surface attrs.
|
||||
function Shell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className={classes.commentEditor}>
|
||||
<div className={classes.ProseMirror}>
|
||||
<div className="ProseMirror">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CommentContentViewProps {
|
||||
content: string | object;
|
||||
}
|
||||
|
||||
export function CommentContentView({ content }: CommentContentViewProps) {
|
||||
// Degrade this single comment to the old editor-based render (safety valve).
|
||||
const fallback = () => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(
|
||||
"CommentContentView: unsupported comment content, falling back to editor",
|
||||
);
|
||||
}
|
||||
return <CommentEditor defaultContent={content} editable={false} />;
|
||||
};
|
||||
|
||||
let doc: unknown = content;
|
||||
|
||||
if (typeof content === "string") {
|
||||
try {
|
||||
doc = JSON.parse(content);
|
||||
} catch {
|
||||
const trimmed = content.trim();
|
||||
// Looks like it was meant to be JSON but is malformed -> safety-valve fallback.
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
return fallback();
|
||||
}
|
||||
// Otherwise it's a legacy plain-text comment: render as a single paragraph.
|
||||
return (
|
||||
<Shell>
|
||||
<p>{content}</p>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Double-stringified / legacy plain-text stored as a JSON string.
|
||||
if (typeof doc === "string") {
|
||||
return (
|
||||
<Shell>
|
||||
<p>{doc}</p>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const pmDoc = doc as PMNode;
|
||||
if (!pmDoc || typeof pmDoc !== "object" || pmDoc.type !== "doc") {
|
||||
throw new UnknownNodeError("Not a ProseMirror doc");
|
||||
}
|
||||
return <Shell>{renderChildren(pmDoc.content, "n")}</Shell>;
|
||||
} catch (err) {
|
||||
if (err instanceof UnknownNodeError) {
|
||||
return fallback();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export default CommentContentView;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
|
||||
@@ -8,23 +8,74 @@ import { IComment } from "@/features/comment/types/comment.types";
|
||||
// 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.
|
||||
const applyMutateAsync = vi.fn();
|
||||
const dismissMutateAsync = vi.fn();
|
||||
const updateMutateAsync = vi.fn();
|
||||
vi.mock("@/features/comment/queries/comment-query", () => ({
|
||||
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||
useUpdateCommentMutation: () => ({ mutateAsync: vi.fn() }),
|
||||
useUpdateCommentMutation: () => ({ mutateAsync: updateMutateAsync }),
|
||||
useApplySuggestionMutation: () => ({
|
||||
mutateAsync: applyMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
useDismissSuggestionMutation: () => ({
|
||||
mutateAsync: dismissMutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// The document the mocked editor emits via onUpdate when the edit form is open.
|
||||
// Duplicated inside the mock factory (below) to keep the factory self-contained.
|
||||
const EDITED_DOC = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "edited via editor" }] },
|
||||
],
|
||||
};
|
||||
|
||||
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
|
||||
vi.mock("@/features/comment/components/comment-editor", () => ({
|
||||
default: () => <div data-testid="comment-editor" />,
|
||||
// In edit mode the stub exposes buttons that fire the real onUpdate/onSave props
|
||||
// so the edit->save/cancel flow can be driven without a live editor.
|
||||
vi.mock("@/features/comment/components/comment-editor", () => {
|
||||
const doc = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "edited via editor" }] },
|
||||
],
|
||||
};
|
||||
return {
|
||||
default: ({ onUpdate, onSave }: any) => (
|
||||
<div data-testid="comment-editor">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="editor-emit-update"
|
||||
onClick={() => onUpdate?.(doc)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="editor-emit-save"
|
||||
onClick={() => onSave?.()}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// CommentContentView (used for the read-only body) imports the mention view,
|
||||
// which pulls page-query -> main.tsx (createRoot). Stub the queries so the item
|
||||
// renders in isolation without the app entry side-effect.
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||
}));
|
||||
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||
useSharePageQuery: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
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 =>
|
||||
({
|
||||
@@ -38,14 +89,20 @@ const baseComment = (over?: Partial<IComment>): IComment =>
|
||||
...over,
|
||||
}) as IComment;
|
||||
|
||||
function renderItem(comment: IComment, canEdit = true) {
|
||||
function renderItem(
|
||||
comment: IComment,
|
||||
canEdit = true,
|
||||
canComment = true,
|
||||
userSpaceRole?: string,
|
||||
) {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<CommentListItem
|
||||
comment={comment}
|
||||
pageId="page-1"
|
||||
canComment={true}
|
||||
canComment={canComment}
|
||||
canEdit={canEdit}
|
||||
userSpaceRole={userSpaceRole}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
@@ -108,10 +165,12 @@ describe("CommentListItem — suggested edit (#315)", () => {
|
||||
});
|
||||
|
||||
it("renders the было→стало diff and an Apply button when canEdit and not applied/resolved", () => {
|
||||
renderItem(suggestion(), true);
|
||||
// Old text appears both as the selection quote and as the struck diff row.
|
||||
const { container } = renderItem(suggestion(), true);
|
||||
// Old text appears as the selection quote (a single unsplit Text node).
|
||||
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.
|
||||
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
|
||||
// No Applied badge yet.
|
||||
@@ -119,9 +178,9 @@ describe("CommentListItem — suggested edit (#315)", () => {
|
||||
});
|
||||
|
||||
it("hides the Apply button when canEdit is false", () => {
|
||||
renderItem(suggestion(), false);
|
||||
// Diff still renders...
|
||||
expect(screen.getByText("new wording here")).toBeDefined();
|
||||
const { container } = renderItem(suggestion(), false);
|
||||
// Diff still renders (as per-fragment spans, #331)...
|
||||
expect(container.textContent).toContain("new wording here");
|
||||
// ...but no Apply button.
|
||||
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
|
||||
});
|
||||
@@ -157,6 +216,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", () => {
|
||||
const c = (over?: Partial<IComment>): IComment =>
|
||||
({ suggestedText: "x", ...over }) as IComment;
|
||||
@@ -182,3 +300,161 @@ describe("canShowApply predicate", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommentListItem — edit -> save/cancel flow (#340 F3)", () => {
|
||||
const body = (t: string) =>
|
||||
JSON.stringify({
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: t }] }],
|
||||
});
|
||||
|
||||
// The edit menu item is gated on the viewer owning the comment
|
||||
// (currentUser.id === creatorId). currentUserAtom is atomWithStorage-backed,
|
||||
// so seed localStorage to make the viewer the owner (creatorId "user-1").
|
||||
beforeEach(() => {
|
||||
updateMutateAsync.mockClear();
|
||||
localStorage.setItem(
|
||||
"currentUser",
|
||||
JSON.stringify({ user: { id: "user-1", name: "Owner" } }),
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
async function openEditor() {
|
||||
// Open the comment menu, then click "Edit comment" to toggle into edit mode.
|
||||
fireEvent.click(screen.getByLabelText("Comment menu"));
|
||||
fireEvent.click(await screen.findByText("Edit comment"));
|
||||
// Edit form (mocked editor + actions) is now mounted.
|
||||
await screen.findByTestId("comment-editor");
|
||||
}
|
||||
|
||||
it("saves the edited content and, on cache update, shows the new body", async () => {
|
||||
const { rerender } = renderItem(
|
||||
baseComment({ content: body("original body") }),
|
||||
);
|
||||
// Static body first.
|
||||
expect(screen.getByText("original body")).toBeDefined();
|
||||
|
||||
await openEditor();
|
||||
|
||||
// Editor emits an update (populates editContentRef), then Save is clicked.
|
||||
fireEvent.click(screen.getByTestId("editor-emit-update"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
// mutateAsync is called with the stringified edited doc.
|
||||
expect(updateMutateAsync).toHaveBeenCalledWith({
|
||||
commentId: "c-1",
|
||||
content: JSON.stringify(EDITED_DOC),
|
||||
});
|
||||
|
||||
// On success the form closes (isEditing -> false); the static body renders
|
||||
// from the comment.content prop again.
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("comment-editor")).toBeNull(),
|
||||
);
|
||||
|
||||
// Simulate the cache invalidation swapping in a new comment object with the
|
||||
// updated content — the static body reflects it.
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<CommentListItem
|
||||
comment={baseComment({ content: body("updated body after save") })}
|
||||
pageId="page-1"
|
||||
canComment={true}
|
||||
canEdit={true}
|
||||
/>
|
||||
</MantineProvider>,
|
||||
);
|
||||
expect(screen.getByText("updated body after save")).toBeDefined();
|
||||
expect(screen.queryByText("original body")).toBeNull();
|
||||
});
|
||||
|
||||
it("cancel restores the static body and does not call the update mutation", async () => {
|
||||
renderItem(baseComment({ content: body("original body") }));
|
||||
await openEditor();
|
||||
|
||||
// Type something (editContentRef set), then cancel.
|
||||
fireEvent.click(screen.getByTestId("editor-emit-update"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
// Editor unmounts, static body restored, no save happened.
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("comment-editor")).toBeNull(),
|
||||
);
|
||||
expect(screen.getByText("original body")).toBeDefined();
|
||||
expect(updateMutateAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("saving without editing sends the existing content (editContentRef cleared after cancel)", async () => {
|
||||
renderItem(baseComment({ content: body("original body") }));
|
||||
|
||||
// Cancel path clears editContentRef...
|
||||
await openEditor();
|
||||
fireEvent.click(screen.getByTestId("editor-emit-update"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("comment-editor")).toBeNull(),
|
||||
);
|
||||
|
||||
// ...so re-opening and saving WITHOUT an update falls back to comment.content.
|
||||
await openEditor();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(updateMutateAsync).toHaveBeenCalledWith({
|
||||
commentId: "c-1",
|
||||
content: JSON.stringify(body("original body")),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CommentListItem — read-only body renders statically", () => {
|
||||
it("renders the comment body as static text without a TipTap editor", () => {
|
||||
renderItem(
|
||||
baseComment({
|
||||
content: JSON.stringify({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Hello static world" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
// Body text is present...
|
||||
expect(screen.getByText("Hello static world")).toBeDefined();
|
||||
// ...and it did NOT go through the (mocked) CommentEditor instance.
|
||||
expect(screen.queryByTestId("comment-editor")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Group, Text, Box, Badge, Button } from "@mantine/core";
|
||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import classes from "./comment.module.css";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||
import CommentContentView from "@/features/comment/components/comment-content-view";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import CommentActions from "@/features/comment/components/comment-actions";
|
||||
import CommentMenu from "@/features/comment/components/comment-menu";
|
||||
@@ -13,11 +14,16 @@ import { useHover } from "@mantine/hooks";
|
||||
import {
|
||||
useApplySuggestionMutation,
|
||||
useDeleteCommentMutation,
|
||||
useDismissSuggestionMutation,
|
||||
useResolveCommentMutation,
|
||||
useUpdateCommentMutation,
|
||||
} from "@/features/comment/queries/comment-query";
|
||||
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 { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -45,31 +51,43 @@ function CommentListItem({
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const editor = useAtomValue(pageEditorAtom);
|
||||
const [content, setContent] = useState<string>(comment.content);
|
||||
const editContentRef = useRef<any>(null);
|
||||
const updateCommentMutation = useUpdateCommentMutation();
|
||||
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
|
||||
const resolveCommentMutation = useResolveCommentMutation();
|
||||
const applySuggestionMutation = useApplySuggestionMutation();
|
||||
const dismissSuggestionMutation = useDismissSuggestionMutation();
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const createdAtAgo = useTimeAgo(comment.createdAt);
|
||||
|
||||
useEffect(() => {
|
||||
setContent(comment.content);
|
||||
}, [comment]);
|
||||
// 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";
|
||||
|
||||
|
||||
async function handleUpdateComment() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const commentToUpdate = {
|
||||
commentId: comment.id,
|
||||
content: JSON.stringify(editContentRef.current ?? content),
|
||||
content: JSON.stringify(editContentRef.current ?? comment.content),
|
||||
};
|
||||
await updateCommentMutation.mutateAsync(commentToUpdate);
|
||||
if (editContentRef.current) {
|
||||
setContent(editContentRef.current);
|
||||
editContentRef.current = null;
|
||||
}
|
||||
editContentRef.current = null;
|
||||
setIsEditing(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to update comment:", error);
|
||||
@@ -115,6 +133,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) {
|
||||
const el = document.querySelector(
|
||||
`.comment-mark[data-comment-id="${comment.id}"]`,
|
||||
@@ -190,7 +221,7 @@ function CommentListItem({
|
||||
/>
|
||||
)}
|
||||
|
||||
{(currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin') && (
|
||||
{isOwnerOrAdmin && (
|
||||
<CommentMenu
|
||||
onEditComment={handleEditToggle}
|
||||
onDeleteComment={handleDeleteComment}
|
||||
@@ -236,12 +267,28 @@ function CommentListItem({
|
||||
{!comment.parentCommentId && comment.suggestedText && (
|
||||
<Box className={classes.suggestionBlock}>
|
||||
{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}>
|
||||
{comment.selection}
|
||||
{suggestionDiff?.old.map((segment, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={segment.changed ? classes.suggestionChanged : undefined}
|
||||
>
|
||||
{segment.text}
|
||||
</span>
|
||||
))}
|
||||
</Text>
|
||||
)}
|
||||
<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>
|
||||
|
||||
{comment.suggestionAppliedAt ? (
|
||||
@@ -255,29 +302,53 @@ function CommentListItem({
|
||||
{t("Applied")}
|
||||
</Badge>
|
||||
) : (
|
||||
canShowApply(comment, canEdit) && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
mt={6}
|
||||
onClick={handleApplySuggestion}
|
||||
loading={applySuggestionMutation.isPending}
|
||||
disabled={applySuggestionMutation.isPending}
|
||||
>
|
||||
{t("Apply")}
|
||||
</Button>
|
||||
(canShowApply(comment, canEdit) ||
|
||||
canShowDismiss(comment, canComment, isOwnerOrAdmin)) && (
|
||||
<Group gap="xs" mt={6}>
|
||||
{canShowApply(comment, canEdit) && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="green"
|
||||
onClick={handleApplySuggestion}
|
||||
loading={applySuggestionMutation.isPending}
|
||||
disabled={
|
||||
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>
|
||||
)}
|
||||
|
||||
{!isEditing ? (
|
||||
<CommentEditor defaultContent={content} editable={false} />
|
||||
<CommentContentView content={comment.content} />
|
||||
) : (
|
||||
<>
|
||||
<CommentEditor
|
||||
defaultContent={content}
|
||||
defaultContent={comment.content}
|
||||
editable={true}
|
||||
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
|
||||
onSave={handleUpdateComment}
|
||||
@@ -297,4 +368,6 @@ function CommentListItem({
|
||||
);
|
||||
}
|
||||
|
||||
export default CommentListItem;
|
||||
// Memoized so a resolve/apply/reply cache update (which only replaces the touched
|
||||
// comment's object identity) re-renders that one thread, not all ~356 items.
|
||||
export default React.memo(CommentListItem);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { IComment } from "@/features/comment/types/comment.types";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub so
|
||||
// the lazy reply editor's mount transition can be observed without the editor.
|
||||
vi.mock("@/features/comment/components/comment-editor", () => ({
|
||||
default: () => <div data-testid="comment-editor" />,
|
||||
}));
|
||||
|
||||
// page-query -> main.tsx (createRoot) is a module side effect; stub the queries
|
||||
// pulled in transitively so importing the module is side-effect free.
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||
}));
|
||||
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||
useSharePageQuery: () => ({ data: undefined }),
|
||||
}));
|
||||
// space-query -> main.tsx (createRoot) is another module side effect; stub it.
|
||||
vi.mock("@/features/space/queries/space-query.ts", () => ({
|
||||
useGetSpaceBySlugQuery: () => ({ data: undefined }),
|
||||
}));
|
||||
|
||||
import {
|
||||
buildChildrenByParent,
|
||||
CommentEditorWithActions,
|
||||
} from "./comment-list-with-tabs";
|
||||
|
||||
const c = (id: string, parentCommentId: string | null = null): IComment =>
|
||||
({ id, parentCommentId }) as IComment;
|
||||
|
||||
describe("buildChildrenByParent (childrenByParent grouping)", () => {
|
||||
it("returns an empty map for undefined or empty input", () => {
|
||||
expect(buildChildrenByParent(undefined).size).toBe(0);
|
||||
expect(buildChildrenByParent([]).size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not index a top-level comment (parentCommentId null)", () => {
|
||||
const map = buildChildrenByParent([c("p1", null)]);
|
||||
expect(map.size).toBe(0);
|
||||
expect(map.has("p1")).toBe(false);
|
||||
});
|
||||
|
||||
it("groups replies under the correct parent, including reply-to-reply nesting", () => {
|
||||
const p1 = c("p1", null);
|
||||
const r1 = c("r1", "p1");
|
||||
const r2 = c("r2", "r1"); // a reply to a reply
|
||||
const map = buildChildrenByParent([p1, r1, r2]);
|
||||
expect(map.get("p1")).toEqual([r1]);
|
||||
expect(map.get("r1")).toEqual([r2]);
|
||||
// The top-level comment itself is never a key.
|
||||
expect(map.has("p1") && map.get("p1")?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("still groups a reply whose parent is not present in items", () => {
|
||||
const orphan = c("o1", "missing-parent");
|
||||
const map = buildChildrenByParent([orphan]);
|
||||
expect(map.get("missing-parent")).toEqual([orphan]);
|
||||
});
|
||||
|
||||
it("preserves insertion order among sibling replies", () => {
|
||||
const map = buildChildrenByParent([
|
||||
c("a", "p1"),
|
||||
c("b", "p1"),
|
||||
c("d", "p1"),
|
||||
]);
|
||||
expect(map.get("p1")?.map((x) => x.id)).toEqual(["a", "b", "d"]);
|
||||
});
|
||||
});
|
||||
|
||||
function renderReplyEditor() {
|
||||
return render(
|
||||
<MantineProvider>
|
||||
<CommentEditorWithActions commentId="c-1" onSave={vi.fn()} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("CommentEditorWithActions — lazy reply editor activation", () => {
|
||||
it("shows only the stub initially (no editor instance mounted)", () => {
|
||||
renderReplyEditor();
|
||||
expect(screen.getByRole("button")).toBeDefined();
|
||||
expect(screen.queryByTestId("comment-editor")).toBeNull();
|
||||
});
|
||||
|
||||
it("mounts the real editor when the stub is clicked and keeps it mounted", () => {
|
||||
renderReplyEditor();
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(screen.getByTestId("comment-editor")).toBeDefined();
|
||||
// The stub button is replaced by the editor subtree.
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
it("mounts the editor when the stub receives focus", () => {
|
||||
renderReplyEditor();
|
||||
fireEvent.focus(screen.getByRole("button"));
|
||||
expect(screen.getByTestId("comment-editor")).toBeDefined();
|
||||
});
|
||||
|
||||
it("mounts the editor on Enter keydown of the stub", () => {
|
||||
renderReplyEditor();
|
||||
fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" });
|
||||
expect(screen.getByTestId("comment-editor")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,6 @@ import CommentActions from "@/features/comment/components/comment-actions";
|
||||
import { useFocusWithin } from "@mantine/hooks";
|
||||
import { IComment } from "@/features/comment/types/comment.types.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
@@ -36,6 +35,24 @@ interface CommentListWithTabsProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
// Index replies by their parent id once (O(n)), instead of an O(n^2) filter per
|
||||
// thread. Replies whose parent is not in `items` are still grouped under their
|
||||
// parentCommentId (they simply won't be reached by the top-level walk).
|
||||
// Exported for unit testing.
|
||||
export function buildChildrenByParent(
|
||||
items: IComment[] | undefined,
|
||||
): Map<string, IComment[]> {
|
||||
const m = new Map<string, IComment[]>();
|
||||
for (const c of items ?? []) {
|
||||
if (c.parentCommentId) {
|
||||
const arr = m.get(c.parentCommentId);
|
||||
if (arr) arr.push(c);
|
||||
else m.set(c.parentCommentId, [c]);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { pageSlug } = useParams();
|
||||
@@ -46,7 +63,9 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
isError,
|
||||
} = useCommentsQuery({ pageId: page?.id });
|
||||
const createCommentMutation = useCreateCommentMutation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
// mutateAsync is a stable reference across renders; depend on it (not the
|
||||
// mutation object) so the reply/comment callbacks stay stable.
|
||||
const createCommentAsync = createCommentMutation.mutateAsync;
|
||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||
|
||||
const canEdit = page?.permissions?.canEdit ?? false;
|
||||
@@ -75,13 +94,21 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
return { activeComments: active, resolvedComments: resolved };
|
||||
}, [comments]);
|
||||
|
||||
// Index replies by their parent once, instead of an O(n^2) filter per thread.
|
||||
// The map ref changes on any comments update, so MemoizedChildComments re-runs
|
||||
// (cheap) and re-looks-up, while memoized CommentListItems skip unchanged items.
|
||||
const childrenByParent = useMemo(
|
||||
() => buildChildrenByParent(comments?.items),
|
||||
[comments?.items],
|
||||
);
|
||||
|
||||
const [isPageCommentLoading, setIsPageCommentLoading] = useState(false);
|
||||
|
||||
const handleAddPageComment = useCallback(
|
||||
async (_commentId: string, content: string) => {
|
||||
try {
|
||||
setIsPageCommentLoading(true);
|
||||
const createdComment = await createCommentMutation.mutateAsync({
|
||||
const createdComment = await createCommentAsync({
|
||||
pageId: page?.id,
|
||||
content: JSON.stringify(content),
|
||||
});
|
||||
@@ -100,27 +127,26 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
setIsPageCommentLoading(false);
|
||||
}
|
||||
},
|
||||
[createCommentMutation, page?.id],
|
||||
[createCommentAsync, page?.id],
|
||||
);
|
||||
|
||||
const handleAddReply = useCallback(
|
||||
async (commentId: string, content: string) => {
|
||||
// Pending state lives inside CommentEditorWithActions so sending a reply
|
||||
// does not churn renderComments and re-render the whole list.
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const commentData = {
|
||||
pageId: page?.id,
|
||||
parentCommentId: commentId,
|
||||
content: JSON.stringify(content),
|
||||
};
|
||||
|
||||
await createCommentMutation.mutateAsync(commentData);
|
||||
await createCommentAsync(commentData);
|
||||
} catch (error) {
|
||||
console.error("Failed to post comment:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[createCommentMutation, page?.id],
|
||||
[createCommentAsync, page?.id],
|
||||
);
|
||||
|
||||
const renderComments = useCallback(
|
||||
@@ -143,7 +169,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
userSpaceRole={space?.membership?.role}
|
||||
/>
|
||||
<MemoizedChildComments
|
||||
comments={comments}
|
||||
childrenByParent={childrenByParent}
|
||||
parentId={comment.id}
|
||||
pageId={page?.id}
|
||||
canComment={canComment}
|
||||
@@ -158,16 +184,15 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
<CommentEditorWithActions
|
||||
commentId={comment.id}
|
||||
onSave={handleAddReply}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
),
|
||||
[
|
||||
comments,
|
||||
childrenByParent,
|
||||
handleAddReply,
|
||||
isLoading,
|
||||
page?.id,
|
||||
space?.membership?.role,
|
||||
canComment,
|
||||
canEdit,
|
||||
@@ -203,6 +228,11 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
<Tabs
|
||||
defaultValue="open"
|
||||
variant="default"
|
||||
// Default to not mounting an inactive tab (the heavy Resolved list stays
|
||||
// unmounted while Open is shown). The Open panel overrides this with its
|
||||
// own keepMounted (below) so an in-progress reply/edit draft survives an
|
||||
// Open -> Resolved -> Open switch.
|
||||
keepMounted={false}
|
||||
style={{
|
||||
flex: "1 1 auto",
|
||||
display: "flex",
|
||||
@@ -261,7 +291,10 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
type="scroll"
|
||||
>
|
||||
<div style={{ paddingBottom: "8px" }}>
|
||||
<Tabs.Panel value="open" pt="xs">
|
||||
{/* keepMounted keeps the Open panel alive even while Resolved is
|
||||
active, so a lazily-mounted reply editor's draft (and an
|
||||
in-progress edit) is not discarded on tab switch. */}
|
||||
<Tabs.Panel value="open" pt="xs" keepMounted>
|
||||
{activeComments.length === 0 ? (
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap="xs">
|
||||
@@ -307,7 +340,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
}
|
||||
|
||||
interface ChildCommentsProps {
|
||||
comments: IPagination<IComment>;
|
||||
childrenByParent: Map<string, IComment[]>;
|
||||
parentId: string;
|
||||
pageId: string;
|
||||
canComment: boolean;
|
||||
@@ -315,24 +348,18 @@ interface ChildCommentsProps {
|
||||
userSpaceRole?: string;
|
||||
}
|
||||
const ChildComments = ({
|
||||
comments,
|
||||
childrenByParent,
|
||||
parentId,
|
||||
pageId,
|
||||
canComment,
|
||||
canEdit,
|
||||
userSpaceRole,
|
||||
}: ChildCommentsProps) => {
|
||||
const getChildComments = useCallback(
|
||||
(parentId: string) =>
|
||||
comments.items.filter(
|
||||
(comment: IComment) => comment.parentCommentId === parentId,
|
||||
),
|
||||
[comments.items],
|
||||
);
|
||||
const children = childrenByParent.get(parentId) ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{getChildComments(parentId).map((childComment) => (
|
||||
{children.map((childComment) => (
|
||||
<div key={childComment.id}>
|
||||
<CommentListItem
|
||||
comment={childComment}
|
||||
@@ -342,7 +369,7 @@ const ChildComments = ({
|
||||
userSpaceRole={userSpaceRole}
|
||||
/>
|
||||
<MemoizedChildComments
|
||||
comments={comments}
|
||||
childrenByParent={childrenByParent}
|
||||
parentId={childComment.id}
|
||||
pageId={pageId}
|
||||
canComment={canComment}
|
||||
@@ -357,22 +384,61 @@ const ChildComments = ({
|
||||
|
||||
const MemoizedChildComments = memo(ChildComments);
|
||||
|
||||
const CommentEditorWithActions = ({
|
||||
export const CommentEditorWithActions = ({
|
||||
commentId,
|
||||
onSave,
|
||||
isLoading,
|
||||
placeholder = undefined,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// Lazily mount the TipTap reply editor: until the user interacts with the
|
||||
// stub, no editor instance is created for this thread. Once mounted it stays
|
||||
// mounted so the draft is preserved.
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [content, setContent] = useState("");
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const { ref, focused } = useFocusWithin();
|
||||
const commentEditorRef = useRef(null);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(commentId, content);
|
||||
setContent("");
|
||||
commentEditorRef.current?.clearContent();
|
||||
const activate = useCallback(() => setMounted(true), []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
try {
|
||||
setIsSending(true);
|
||||
await onSave(commentId, content);
|
||||
setContent("");
|
||||
commentEditorRef.current?.clearContent();
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
}, [commentId, content, onSave]);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={activate}
|
||||
onFocus={activate}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
activate();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: "6px",
|
||||
fontSize: "var(--mantine-font-size-sm)",
|
||||
lineHeight: 1.4,
|
||||
color: "var(--mantine-color-placeholder)",
|
||||
cursor: "text",
|
||||
borderRadius: "var(--mantine-radius-sm)",
|
||||
}}
|
||||
>
|
||||
{placeholder || t("Reply...")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<CommentEditor
|
||||
@@ -381,8 +447,9 @@ const CommentEditorWithActions = ({
|
||||
onSave={handleSave}
|
||||
editable={true}
|
||||
placeholder={placeholder}
|
||||
autofocus={true}
|
||||
/>
|
||||
{focused && <CommentActions onSave={handleSave} isLoading={isLoading} />}
|
||||
{focused && <CommentActions onSave={handleSave} isLoading={isSending} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -53,6 +53,21 @@
|
||||
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 {
|
||||
|
||||
&[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,
|
||||
createComment,
|
||||
deleteComment,
|
||||
dismissSuggestion,
|
||||
getPageComments,
|
||||
resolveComment,
|
||||
updateComment,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
ICommentParams,
|
||||
IComment,
|
||||
IResolveComment,
|
||||
ISuggestionOutcome,
|
||||
} from "@/features/comment/types/comment.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
@@ -51,7 +53,10 @@ export function useCommentsQuery(params: ICommentParams) {
|
||||
|
||||
return {
|
||||
data,
|
||||
isLoading: query.isLoading || query.hasNextPage,
|
||||
// Paint the first page as soon as it arrives instead of blocking until every
|
||||
// page has loaded; the background effect above keeps streaming the rest
|
||||
// (tab counts grow as pages arrive).
|
||||
isLoading: query.isLoading,
|
||||
isError: query.isError,
|
||||
};
|
||||
}
|
||||
@@ -177,40 +182,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() {
|
||||
const queryClient = useQueryClient();
|
||||
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),
|
||||
// so we only mutate the cache once the server confirms.
|
||||
mutationFn: ({ commentId }) => applySuggestion(commentId),
|
||||
onSuccess: (data, variables) => {
|
||||
const cache = queryClient.getQueryData(
|
||||
RQ_KEY(variables.pageId),
|
||||
) as InfiniteData<IPagination<IComment>> | undefined;
|
||||
|
||||
if (cache) {
|
||||
queryClient.setQueryData(
|
||||
RQ_KEY(variables.pageId),
|
||||
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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
// Ephemeral (#329): the server hard-deletes the applied 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 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
|
||||
// a specific message (with the current text) rather than a generic error.
|
||||
const status = err?.response?.status;
|
||||
const currentText = err?.response?.data?.currentText;
|
||||
if (status === 409 && typeof currentText === "string") {
|
||||
const shortText =
|
||||
@@ -234,6 +320,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() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ICommentParams,
|
||||
IComment,
|
||||
IResolveComment,
|
||||
ISuggestionOutcome,
|
||||
} from "@/features/comment/types/comment.types";
|
||||
import { IPagination } from "@/lib/types.ts";
|
||||
|
||||
@@ -18,13 +19,24 @@ export async function resolveComment(data: IResolveComment): Promise<IComment> {
|
||||
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
|
||||
// the 409 body (`{ message, currentText }`) off err.response.data.
|
||||
const req = await api.post("/comments/apply-suggestion", { commentId });
|
||||
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(
|
||||
data: Partial<IComment>,
|
||||
): Promise<IComment> {
|
||||
|
||||
@@ -60,6 +60,15 @@ export interface IResolveComment {
|
||||
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 {
|
||||
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";
|
||||
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
|
||||
// A disabled mic must explain WHY it is unavailable rather than silently saying
|
||||
// "Start dictation". This renders MicButton in its idle+disabled state with a
|
||||
// forwarded reason and asserts the accessible label resolves to that reason's
|
||||
// text via the shared resolver (dictation-status.resolveUnavailableLabel).
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
// Pass i18n keys through verbatim so we assert the exact resolved string.
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (s: string) => s }),
|
||||
}));
|
||||
|
||||
// Keep both controllers inert and idle so MicButton renders the idle branch.
|
||||
const idleCtl = {
|
||||
status: "idle" as const,
|
||||
start: vi.fn(async () => {}),
|
||||
stop: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
audioLevel: 0,
|
||||
errorMessage: null,
|
||||
};
|
||||
vi.mock("@/features/dictation/hooks/use-dictation", () => ({
|
||||
useDictation: () => idleCtl,
|
||||
}));
|
||||
vi.mock("@/features/dictation/hooks/use-streaming-dictation", () => ({
|
||||
useStreamingDictation: () => idleCtl,
|
||||
}));
|
||||
|
||||
import { MicButton } from "./mic-button";
|
||||
|
||||
function renderButton(props: React.ComponentProps<typeof MicButton>) {
|
||||
render(
|
||||
<MantineProvider>
|
||||
<MicButton {...props} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("MicButton — disabled reason label", () => {
|
||||
// jsdom has no MediaRecorder / mediaDevices, so isDictationSupported() would
|
||||
// report "unsupported" and mask the forwarded reason. Stub both so the button
|
||||
// is considered supported and the availability reason is what surfaces.
|
||||
beforeEach(() => {
|
||||
(globalThis as unknown as { MediaRecorder: unknown }).MediaRecorder =
|
||||
class {};
|
||||
Object.defineProperty(navigator, "mediaDevices", {
|
||||
configurable: true,
|
||||
value: { getUserMedia: vi.fn() },
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
delete (globalThis as unknown as { MediaRecorder?: unknown }).MediaRecorder;
|
||||
});
|
||||
|
||||
it("shows the cause-specific reason instead of 'Start dictation' when disabled with a reason", () => {
|
||||
renderButton({ onText: () => {}, disabled: true, unavailableReason: "offline" });
|
||||
const expected =
|
||||
"No connection to the collaboration server — dictation unavailable";
|
||||
// The reason surfaces as the accessible label (and the tooltip text).
|
||||
const button = screen.getByRole("button", { name: expected });
|
||||
expect(button).toBeDefined();
|
||||
// It is marked disabled the Mantine way (data-disabled), NOT the native
|
||||
// `disabled` attribute — otherwise pointer-events:none would kill the tooltip.
|
||||
expect(button.getAttribute("data-disabled")).toBe("true");
|
||||
expect(button.hasAttribute("disabled")).toBe(false);
|
||||
// And it no longer silently reads "Start dictation".
|
||||
expect(screen.queryByRole("button", { name: "Start dictation" })).toBeNull();
|
||||
});
|
||||
|
||||
it("reads 'Start dictation' when enabled with no reason", () => {
|
||||
renderButton({ onText: () => {} });
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Start dictation" }),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not advertise 'Start dictation' when disabled with no reason", () => {
|
||||
// A consumer passing bare `disabled` (e.g. the AI chat's isStreaming) with no
|
||||
// unavailableReason must not get a hoverable mic whose tooltip invites
|
||||
// "Start dictation" on a click that is rejected.
|
||||
renderButton({ onText: () => {}, disabled: true });
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Start dictation" }),
|
||||
).toBeNull();
|
||||
const button = screen.getByRole("button");
|
||||
expect(button.getAttribute("data-disabled")).toBe("true");
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,11 @@ import { IconMicrophone, IconPlayerStopFilled } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDictation } from "@/features/dictation/hooks/use-dictation";
|
||||
import { useStreamingDictation } from "@/features/dictation/hooks/use-streaming-dictation";
|
||||
import {
|
||||
isDictationSupported,
|
||||
resolveUnavailableLabel,
|
||||
type DictationUnavailableReason,
|
||||
} from "@/features/dictation/dictation-status";
|
||||
import classes from "./mic-button.module.css";
|
||||
|
||||
interface MicButtonProps {
|
||||
@@ -21,6 +26,9 @@ interface MicButtonProps {
|
||||
// When true, use the streaming (Silero-VAD) dictation controller, which emits
|
||||
// text progressively as the user pauses; otherwise use the batch controller.
|
||||
streaming?: boolean;
|
||||
// When the mic is disabled for an availability reason, this is the cause the
|
||||
// idle tooltip explains (e.g. pre-sync "connecting", "offline", "read-only").
|
||||
unavailableReason?: DictationUnavailableReason;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,6 +45,7 @@ export const MicButton: FC<MicButtonProps> = ({
|
||||
color,
|
||||
iconSize,
|
||||
streaming = false,
|
||||
unavailableReason,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// Call BOTH hooks unconditionally to respect the rules of hooks: which one is
|
||||
@@ -46,7 +55,7 @@ export const MicButton: FC<MicButtonProps> = ({
|
||||
const batchCtl = useDictation({ onText, onStart });
|
||||
const streamingCtl = useStreamingDictation({ onText, onStart });
|
||||
const ctl = streaming ? streamingCtl : batchCtl;
|
||||
const { status, start, stop, audioLevel } = ctl;
|
||||
const { status, start, stop, audioLevel, errorMessage } = ctl;
|
||||
const resolvedIconSize = iconSize ?? (size === "lg" ? 18 : 16);
|
||||
|
||||
if (status === "recording") {
|
||||
@@ -82,15 +91,28 @@ export const MicButton: FC<MicButtonProps> = ({
|
||||
) {
|
||||
// "loading" (streaming hook fetching the VAD model on first use) shows the
|
||||
// same spinner+disabled state so the first click is visibly acknowledged and
|
||||
// a confusing second click can't fire while the model loads.
|
||||
const label = status === "loading" ? t("Preparing…") : t("Transcribing…");
|
||||
// a confusing second click can't fire while the model loads. The error case
|
||||
// explains the failure via the hook's resolved errorMessage instead of the
|
||||
// transient "Transcribing…" label.
|
||||
const label =
|
||||
status === "error"
|
||||
? (errorMessage ?? t("Transcription failed"))
|
||||
: status === "loading"
|
||||
? t("Preparing…")
|
||||
: t("Transcribing…");
|
||||
return (
|
||||
<Tooltip label={label} withArrow>
|
||||
<ActionIcon
|
||||
size={size}
|
||||
variant="subtle"
|
||||
color={color}
|
||||
disabled
|
||||
// Mark disabled the Mantine way (data-disabled/aria-disabled) rather
|
||||
// than the native `disabled` attribute: native `disabled` sets
|
||||
// `pointer-events:none`, which suppresses hover so the Tooltip never
|
||||
// fires. This is a status display with no click action to guard, so
|
||||
// keeping it hoverable simply lets the error reason be read on hover.
|
||||
data-disabled
|
||||
aria-disabled
|
||||
aria-label={label}
|
||||
>
|
||||
<Loader size="xs" />
|
||||
@@ -99,18 +121,56 @@ export const MicButton: FC<MicButtonProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
// Idle branch. A grey/disabled mic must explain WHY it can't record. An
|
||||
// unsupported browser/context is detected here; otherwise the parent forwards
|
||||
// a cause-specific reason. We must NOT pass the native `disabled` prop: Mantine
|
||||
// renders `<button disabled>` with `pointer-events:none`, which suppresses
|
||||
// hover so the Tooltip never fires. Instead mark it disabled the Mantine way
|
||||
// (data-disabled/aria-disabled) — keeping it hoverable and in the a11y tree —
|
||||
// and guard the click ourselves.
|
||||
const unsupported = !isDictationSupported();
|
||||
const isDisabled = disabled || unsupported;
|
||||
const reason: DictationUnavailableReason | undefined = unsupported
|
||||
? "unsupported"
|
||||
: unavailableReason;
|
||||
const reasonLabel = reason ? resolveUnavailableLabel(reason, t) : undefined;
|
||||
// A disabled mic with a known reason surfaces it on hover; an enabled mic
|
||||
// invites "Start dictation". But a mic disabled with NO reason (e.g. a
|
||||
// consumer that passes bare `disabled` — the AI chat's isStreaming, with no
|
||||
// unavailableReason) must NOT hover a misleading, actionable "Start dictation"
|
||||
// tooltip on a control that rejects the click. In that case we render the icon
|
||||
// without a Tooltip and give it a neutral accessible label instead.
|
||||
const ariaLabel = reasonLabel ?? (isDisabled ? t("Dictation") : t("Start dictation"));
|
||||
const icon = (
|
||||
<ActionIcon
|
||||
size={size}
|
||||
variant="subtle"
|
||||
color={color}
|
||||
onClick={(e) => {
|
||||
if (isDisabled) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
void start();
|
||||
}}
|
||||
data-disabled={isDisabled || undefined}
|
||||
aria-disabled={isDisabled}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<IconMicrophone size={resolvedIconSize} />
|
||||
</ActionIcon>
|
||||
);
|
||||
// Suppress the tooltip on a disabled mic that has nothing to explain — hovering
|
||||
// a grey, unclickable mic should not advertise "Start dictation".
|
||||
if (isDisabled && !reasonLabel) {
|
||||
return icon;
|
||||
}
|
||||
return (
|
||||
<Tooltip label={t("Start dictation")} withArrow>
|
||||
<ActionIcon
|
||||
size={size}
|
||||
variant="subtle"
|
||||
color={color}
|
||||
onClick={() => void start()}
|
||||
disabled={disabled}
|
||||
aria-label={t("Start dictation")}
|
||||
>
|
||||
<IconMicrophone size={resolvedIconSize} />
|
||||
</ActionIcon>
|
||||
<Tooltip
|
||||
label={reasonLabel ?? t("Start dictation")}
|
||||
withArrow
|
||||
>
|
||||
{icon}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
classifyGetUserMediaError,
|
||||
classifyTranscriptionError,
|
||||
dictationErrorMessage,
|
||||
resolveUnavailableLabel,
|
||||
isDictationSupported,
|
||||
} from "./dictation-status";
|
||||
|
||||
// Unit tests for the shared dictation-status resolvers (dictation-status.ts).
|
||||
// Both dictation hooks and the mic button form their user-facing strings here,
|
||||
// so a regression in the classification or message mapping would silently swap
|
||||
// what a user reads when the mic is grey or a recording fails. A fake `t`
|
||||
// returns its key verbatim so we assert the exact i18n key each branch selects.
|
||||
const t = (k: string) => k;
|
||||
|
||||
describe("classifyGetUserMediaError", () => {
|
||||
it("maps NotAllowedError / SecurityError to mic-denied", () => {
|
||||
expect(classifyGetUserMediaError({ name: "NotAllowedError" })).toBe(
|
||||
"mic-denied",
|
||||
);
|
||||
expect(classifyGetUserMediaError({ name: "SecurityError" })).toBe(
|
||||
"mic-denied",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps NotFoundError / OverconstrainedError to no-mic", () => {
|
||||
expect(classifyGetUserMediaError({ name: "NotFoundError" })).toBe("no-mic");
|
||||
expect(classifyGetUserMediaError({ name: "OverconstrainedError" })).toBe(
|
||||
"no-mic",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps NotReadableError / AbortError to mic-in-use", () => {
|
||||
expect(classifyGetUserMediaError({ name: "NotReadableError" })).toBe(
|
||||
"mic-in-use",
|
||||
);
|
||||
expect(classifyGetUserMediaError({ name: "AbortError" })).toBe(
|
||||
"mic-in-use",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps anything else / undefined to unknown", () => {
|
||||
expect(classifyGetUserMediaError({ name: "WeirdError" })).toBe("unknown");
|
||||
expect(classifyGetUserMediaError(undefined)).toBe("unknown");
|
||||
expect(classifyGetUserMediaError({})).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyTranscriptionError", () => {
|
||||
it("returns the verbatim server message when present", () => {
|
||||
const err = { response: { status: 500, data: { message: "provider 404" } } };
|
||||
expect(classifyTranscriptionError(err)).toEqual({
|
||||
code: "transcription-failed",
|
||||
serverMessage: "provider 404",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps 503 / 403 (no server message) to stt-not-configured", () => {
|
||||
expect(classifyTranscriptionError({ response: { status: 503 } })).toEqual({
|
||||
code: "stt-not-configured",
|
||||
});
|
||||
expect(classifyTranscriptionError({ response: { status: 403 } })).toEqual({
|
||||
code: "stt-not-configured",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to transcription-failed with no server message otherwise", () => {
|
||||
expect(classifyTranscriptionError({ response: { status: 500 } })).toEqual({
|
||||
code: "transcription-failed",
|
||||
});
|
||||
expect(classifyTranscriptionError(new Error("network"))).toEqual({
|
||||
code: "transcription-failed",
|
||||
});
|
||||
// Blank server message is ignored (does not win as verbatim text).
|
||||
expect(
|
||||
classifyTranscriptionError({ response: { data: { message: " " } } }),
|
||||
).toEqual({ code: "transcription-failed" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("dictationErrorMessage", () => {
|
||||
it("maps each code to the expected i18n key", () => {
|
||||
expect(dictationErrorMessage("mic-denied", t)).toBe(
|
||||
"Microphone access denied",
|
||||
);
|
||||
expect(dictationErrorMessage("no-mic", t)).toBe("No microphone found");
|
||||
expect(dictationErrorMessage("mic-in-use", t)).toBe(
|
||||
"Microphone is unavailable or already in use",
|
||||
);
|
||||
expect(dictationErrorMessage("no-media-devices", t)).toBe(
|
||||
"Audio recording is not available in this browser/context",
|
||||
);
|
||||
expect(dictationErrorMessage("stt-not-configured", t)).toBe(
|
||||
"Voice dictation is not configured",
|
||||
);
|
||||
expect(dictationErrorMessage("transcription-failed", t)).toBe(
|
||||
"Transcription failed",
|
||||
);
|
||||
expect(dictationErrorMessage("recorder-failed", t)).toBe(
|
||||
"Could not start recording",
|
||||
);
|
||||
expect(dictationErrorMessage("vad-init-failed", t)).toBe(
|
||||
"Could not start recording",
|
||||
);
|
||||
expect(dictationErrorMessage("unknown", t)).toBe(
|
||||
"Could not start recording",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the server message verbatim for transcription-failed (not the t key)", () => {
|
||||
expect(
|
||||
dictationErrorMessage("transcription-failed", t, {
|
||||
serverMessage: "quota exceeded",
|
||||
}),
|
||||
).toBe("quota exceeded");
|
||||
});
|
||||
|
||||
it("appends the detail to recorder-failed / unknown", () => {
|
||||
expect(
|
||||
dictationErrorMessage("recorder-failed", t, { detail: "boom" }),
|
||||
).toBe("Could not start recording: boom");
|
||||
expect(dictationErrorMessage("unknown", t, { detail: "nope" })).toBe(
|
||||
"Could not start recording: nope",
|
||||
);
|
||||
});
|
||||
|
||||
it("appends the detail to transcription-failed when there is no server message", () => {
|
||||
expect(
|
||||
dictationErrorMessage("transcription-failed", t, { detail: "timeout" }),
|
||||
).toBe("Transcription failed: timeout");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveUnavailableLabel", () => {
|
||||
it("maps each reason to its expected i18n key", () => {
|
||||
expect(resolveUnavailableLabel("connecting", t)).toBe(
|
||||
"Dictation becomes available once the page finishes connecting",
|
||||
);
|
||||
expect(resolveUnavailableLabel("offline", t)).toBe(
|
||||
"No connection to the collaboration server — dictation unavailable",
|
||||
);
|
||||
expect(resolveUnavailableLabel("read-only", t)).toBe(
|
||||
"This page is read-only",
|
||||
);
|
||||
expect(resolveUnavailableLabel("unsupported", t)).toBe(
|
||||
"Audio recording is not available in this browser/context",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDictationSupported", () => {
|
||||
it("returns a boolean", () => {
|
||||
expect(typeof isDictationSupported()).toBe("boolean");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
// Single source of truth for "why dictation is unavailable" and "why it failed".
|
||||
// Both dictation hooks and the mic button pull their user-facing strings from
|
||||
// the resolvers here so the wording lives in exactly one place.
|
||||
|
||||
export type DictationUnavailableReason =
|
||||
| "connecting"
|
||||
| "offline"
|
||||
| "read-only"
|
||||
| "unsupported";
|
||||
|
||||
export type DictationErrorCode =
|
||||
| "no-media-devices"
|
||||
| "mic-denied"
|
||||
| "no-mic"
|
||||
| "mic-in-use"
|
||||
| "recorder-failed"
|
||||
| "vad-init-failed"
|
||||
| "stt-not-configured"
|
||||
| "transcription-failed"
|
||||
| "unknown";
|
||||
|
||||
// True if this browser/context can record audio.
|
||||
export function isDictationSupported(): boolean {
|
||||
return (
|
||||
typeof MediaRecorder !== "undefined" &&
|
||||
typeof navigator !== "undefined" &&
|
||||
!!navigator.mediaDevices?.getUserMedia
|
||||
);
|
||||
}
|
||||
|
||||
// getUserMedia / VAD.start rejection -> code, by DOMException .name.
|
||||
export function classifyGetUserMediaError(err: unknown): DictationErrorCode {
|
||||
const name = (err as { name?: string })?.name;
|
||||
if (name === "NotAllowedError" || name === "SecurityError")
|
||||
return "mic-denied";
|
||||
if (name === "NotFoundError" || name === "OverconstrainedError")
|
||||
return "no-mic";
|
||||
if (name === "NotReadableError" || name === "AbortError") return "mic-in-use";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
// Transcription HTTP failure -> code (+ verbatim server message when present).
|
||||
export function classifyTranscriptionError(err: unknown): {
|
||||
code: DictationErrorCode;
|
||||
serverMessage?: string;
|
||||
} {
|
||||
const resp = (
|
||||
err as { response?: { status?: number; data?: { message?: string } } }
|
||||
)?.response;
|
||||
const serverMessage = resp?.data?.message;
|
||||
if (serverMessage && serverMessage.trim().length > 0)
|
||||
return { code: "transcription-failed", serverMessage };
|
||||
if (resp?.status === 503 || resp?.status === 403)
|
||||
return { code: "stt-not-configured" };
|
||||
return { code: "transcription-failed" };
|
||||
}
|
||||
|
||||
type TFn = (key: string) => string;
|
||||
|
||||
// Code -> user text. The ONE place runtime error strings are formed.
|
||||
// serverMessage (verbatim) wins for transcription-failed; detail is appended
|
||||
// to the generic "could not start"/"transcription failed" strings.
|
||||
export function dictationErrorMessage(
|
||||
code: DictationErrorCode,
|
||||
t: TFn,
|
||||
extra?: { serverMessage?: string; detail?: string },
|
||||
): string {
|
||||
const detail = extra?.detail;
|
||||
switch (code) {
|
||||
case "mic-denied":
|
||||
return t("Microphone access denied");
|
||||
case "no-mic":
|
||||
return t("No microphone found");
|
||||
case "mic-in-use":
|
||||
return t("Microphone is unavailable or already in use");
|
||||
case "no-media-devices":
|
||||
return t("Audio recording is not available in this browser/context");
|
||||
case "stt-not-configured":
|
||||
return t("Voice dictation is not configured");
|
||||
case "transcription-failed":
|
||||
if (extra?.serverMessage && extra.serverMessage.trim().length > 0)
|
||||
return extra.serverMessage;
|
||||
return `${t("Transcription failed")}${detail ? `: ${detail}` : ""}`;
|
||||
case "recorder-failed":
|
||||
case "vad-init-failed":
|
||||
case "unknown":
|
||||
default:
|
||||
return `${t("Could not start recording")}${detail ? `: ${detail}` : ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Unavailable reason -> tooltip text (the ONE place these strings are formed).
|
||||
export function resolveUnavailableLabel(
|
||||
r: DictationUnavailableReason,
|
||||
t: TFn,
|
||||
): string {
|
||||
switch (r) {
|
||||
case "connecting":
|
||||
return t("Dictation becomes available once the page finishes connecting");
|
||||
case "offline":
|
||||
return t(
|
||||
"No connection to the collaboration server — dictation unavailable",
|
||||
);
|
||||
case "read-only":
|
||||
return t("This page is read-only");
|
||||
case "unsupported":
|
||||
default:
|
||||
return t("Audio recording is not available in this browser/context");
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,11 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { transcribeAudio } from "@/features/dictation/services/dictation-service";
|
||||
import {
|
||||
classifyGetUserMediaError,
|
||||
classifyTranscriptionError,
|
||||
dictationErrorMessage,
|
||||
} from "@/features/dictation/dictation-status";
|
||||
|
||||
// "loading" is set only by the streaming hook while it lazily loads the VAD
|
||||
// model on first use; the batch hook never sets it. It exists so the streaming
|
||||
@@ -26,6 +31,8 @@ interface UseDictationResult {
|
||||
cancel: () => void;
|
||||
// Smoothed live microphone level in the 0..1 range while recording (0 when idle).
|
||||
audioLevel: number;
|
||||
// The last error shown to the user (null until one occurs / on a new start).
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
// Candidate container/codec combinations in preference order. The first one the
|
||||
@@ -67,6 +74,8 @@ export function useDictation(
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<DictationStatus>("idle");
|
||||
const [audioLevel, setAudioLevel] = useState(0);
|
||||
// Last error message shown to the user; the mic button reads it for its tooltip.
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Keep the latest callbacks in a ref so the recorder's onstop closure always
|
||||
// calls the current handlers without re-creating the recorder.
|
||||
@@ -194,15 +203,16 @@ export function useDictation(
|
||||
if (startingRef.current || recorderRef.current || streamRef.current) return;
|
||||
if (status !== "idle") return;
|
||||
startingRef.current = true;
|
||||
// Clear any stale error from a previous attempt.
|
||||
setErrorMessage(null);
|
||||
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
const reason =
|
||||
"navigator.mediaDevices.getUserMedia is unavailable in this context";
|
||||
console.error("[dictation] " + reason);
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: t("Audio recording is not available in this browser/context"),
|
||||
});
|
||||
const message = dictationErrorMessage("no-media-devices", t);
|
||||
notifications.show({ color: "red", message });
|
||||
setErrorMessage(message);
|
||||
setStatus("idle");
|
||||
startingRef.current = false;
|
||||
return;
|
||||
@@ -215,19 +225,16 @@ export function useDictation(
|
||||
// Always log the full error for diagnosis (name, message, stack).
|
||||
console.error("[dictation] getUserMedia failed", err);
|
||||
const name = (err as { name?: string })?.name;
|
||||
const detail = (err as { message?: string })?.message ?? String(err);
|
||||
let message: string;
|
||||
if (name === "NotAllowedError" || name === "SecurityError") {
|
||||
message = t("Microphone access denied");
|
||||
} else if (name === "NotFoundError" || name === "OverconstrainedError") {
|
||||
message = t("No microphone found");
|
||||
} else if (name === "NotReadableError" || name === "AbortError") {
|
||||
message = t("Microphone is unavailable or already in use");
|
||||
} else {
|
||||
// Unknown failure: show the real reason instead of a generic string.
|
||||
message = `${t("Could not start recording")}: ${name ? `${name}: ` : ""}${detail}`;
|
||||
}
|
||||
const rawDetail = (err as { message?: string })?.message ?? String(err);
|
||||
// Prefix the DOMException name (e.g. "TypeError: …") so the generic
|
||||
// resolver branch reproduces this hook's original "Could not start
|
||||
// recording: <name>: <detail>" text. Each caller owns its own detail; the
|
||||
// streaming hook intentionally does not add the name.
|
||||
const detail = `${name ? `${name}: ` : ""}${rawDetail}`;
|
||||
const code = classifyGetUserMediaError(err);
|
||||
const message = dictationErrorMessage(code, t, { detail });
|
||||
notifications.show({ color: "red", message });
|
||||
setErrorMessage(message);
|
||||
setStatus("idle");
|
||||
startingRef.current = false;
|
||||
return;
|
||||
@@ -249,10 +256,10 @@ export function useDictation(
|
||||
// The stream was acquired but the recorder failed to construct; stop the
|
||||
// tracks so the MediaStream does not leak before bailing out.
|
||||
stopTracks();
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: `${t("Could not start recording")}: ${(err as { message?: string })?.message ?? String(err)}`,
|
||||
});
|
||||
const detail = (err as { message?: string })?.message ?? String(err);
|
||||
const message = dictationErrorMessage("recorder-failed", t, { detail });
|
||||
notifications.show({ color: "red", message });
|
||||
setErrorMessage(message);
|
||||
setStatus("idle");
|
||||
startingRef.current = false;
|
||||
return;
|
||||
@@ -293,21 +300,14 @@ export function useDictation(
|
||||
.catch((err: unknown) => {
|
||||
// Log the full error for diagnosis (status + body + stack).
|
||||
console.error("[dictation] transcription failed", err);
|
||||
const resp = (
|
||||
err as { response?: { status?: number; data?: { message?: string } } }
|
||||
)?.response;
|
||||
const serverMsg = resp?.data?.message;
|
||||
let message: string;
|
||||
if (serverMsg && serverMsg.trim().length > 0) {
|
||||
// The server already explains the cause (e.g. provider 404, bad
|
||||
// format, STT not configured) — show it verbatim.
|
||||
message = serverMsg;
|
||||
} else if (resp?.status === 503 || resp?.status === 403) {
|
||||
message = t("Voice dictation is not configured");
|
||||
} else {
|
||||
message = `${t("Transcription failed")}: ${(err as { message?: string })?.message ?? String(err)}`;
|
||||
}
|
||||
const { code, serverMessage } = classifyTranscriptionError(err);
|
||||
const detail = (err as { message?: string })?.message ?? String(err);
|
||||
const message = dictationErrorMessage(code, t, {
|
||||
serverMessage,
|
||||
detail,
|
||||
});
|
||||
notifications.show({ color: "red", message });
|
||||
setErrorMessage(message);
|
||||
setStatus("error");
|
||||
if (errorTimerRef.current !== null) {
|
||||
clearTimeout(errorTimerRef.current);
|
||||
@@ -332,10 +332,10 @@ export function useDictation(
|
||||
stopTracks();
|
||||
recorderRef.current = null;
|
||||
startingRef.current = false;
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: `${t("Could not start recording")}: ${(err as { message?: string })?.message ?? String(err)}`,
|
||||
});
|
||||
const detail = (err as { message?: string })?.message ?? String(err);
|
||||
const message = dictationErrorMessage("recorder-failed", t, { detail });
|
||||
notifications.show({ color: "red", message });
|
||||
setErrorMessage(message);
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
@@ -405,5 +405,5 @@ export function useDictation(
|
||||
};
|
||||
}, [clearTimer, stopTracks, stopMeter]);
|
||||
|
||||
return { status, start, stop, cancel, audioLevel };
|
||||
return { status, start, stop, cancel, audioLevel, errorMessage };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ import { useTranslation } from "react-i18next";
|
||||
import { transcribeAudio } from "@/features/dictation/services/dictation-service";
|
||||
import { encodeWavPcm16 } from "@/features/dictation/utils/encode-wav";
|
||||
import type { DictationStatus } from "@/features/dictation/hooks/use-dictation";
|
||||
import {
|
||||
classifyGetUserMediaError,
|
||||
classifyTranscriptionError,
|
||||
dictationErrorMessage,
|
||||
} from "@/features/dictation/dictation-status";
|
||||
|
||||
// Lazily-imported MicVAD type. The runtime import happens inside start() so the
|
||||
// heavy onnxruntime-web / Silero model is code-split out of the main bundle and
|
||||
@@ -27,6 +32,8 @@ interface UseStreamingDictationResult {
|
||||
cancel: () => void;
|
||||
// Smoothed live speech level in the 0..1 range while recording (0 when idle).
|
||||
audioLevel: number;
|
||||
// The last error shown to the user (null until one occurs / on a new start).
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
// Sample rate of the audio MicVAD hands to onSpeechEnd (Silero VAD runs at 16k).
|
||||
@@ -60,6 +67,8 @@ export function useStreamingDictation(
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<DictationStatus>("idle");
|
||||
const [audioLevel, setAudioLevel] = useState(0);
|
||||
// Last error message shown to the user; the mic button reads it for its tooltip.
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Keep the latest callbacks in a ref so async VAD/HTTP closures always call the
|
||||
// current handlers without re-creating the VAD.
|
||||
@@ -158,26 +167,6 @@ export function useStreamingDictation(
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Map a transcription error to a user-facing message, mirroring the batch hook.
|
||||
const transcriptionErrorMessage = useCallback(
|
||||
(err: unknown): string => {
|
||||
const resp = (
|
||||
err as { response?: { status?: number; data?: { message?: string } } }
|
||||
)?.response;
|
||||
const serverMsg = resp?.data?.message;
|
||||
if (serverMsg && serverMsg.trim().length > 0) {
|
||||
// The server already explains the cause (e.g. provider 404, bad format,
|
||||
// STT not configured) — show it verbatim.
|
||||
return serverMsg;
|
||||
}
|
||||
if (resp?.status === 503 || resp?.status === 403) {
|
||||
return t("Voice dictation is not configured");
|
||||
}
|
||||
return `${t("Transcription failed")}: ${(err as { message?: string })?.message ?? String(err)}`;
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// Handle one ended speech segment: encode to WAV and transcribe. Results are
|
||||
// buffered by seq and flushed in order. A single failed segment does NOT kill
|
||||
// the session: log + one notification, then advance past that seq so later
|
||||
@@ -204,10 +193,14 @@ export function useStreamingDictation(
|
||||
if (epoch !== epochRef.current) return;
|
||||
// Log the full error for diagnosis (status + body + stack).
|
||||
console.error("[dictation] segment transcription failed", err);
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: transcriptionErrorMessage(err),
|
||||
const { code, serverMessage } = classifyTranscriptionError(err);
|
||||
const detail = (err as { message?: string })?.message ?? String(err);
|
||||
const message = dictationErrorMessage(code, t, {
|
||||
serverMessage,
|
||||
detail,
|
||||
});
|
||||
notifications.show({ color: "red", message });
|
||||
setErrorMessage(message);
|
||||
// Skip this seq so later segments can still flush in order.
|
||||
if (nextEmitSeqRef.current === seq) {
|
||||
nextEmitSeqRef.current += 1;
|
||||
@@ -226,7 +219,7 @@ export function useStreamingDictation(
|
||||
}
|
||||
});
|
||||
},
|
||||
[drainResults, transcriptionErrorMessage],
|
||||
[drainResults, t],
|
||||
);
|
||||
|
||||
const start = useCallback(async (): Promise<void> => {
|
||||
@@ -236,6 +229,8 @@ export function useStreamingDictation(
|
||||
if (startingRef.current || vadRef.current || activeRef.current) return;
|
||||
if (status !== "idle") return;
|
||||
startingRef.current = true;
|
||||
// Clear any stale error from a previous attempt.
|
||||
setErrorMessage(null);
|
||||
|
||||
// Notify the caller right when dictation begins (before any async work) so the
|
||||
// editor can snapshot the caret position.
|
||||
@@ -354,10 +349,9 @@ export function useStreamingDictation(
|
||||
// actually runs.)
|
||||
console.error("[dictation] VAD init failed", err);
|
||||
const detail = (err as { message?: string })?.message ?? String(err);
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: `${t("Could not start recording")}: ${detail}`,
|
||||
});
|
||||
const message = dictationErrorMessage("vad-init-failed", t, { detail });
|
||||
notifications.show({ color: "red", message });
|
||||
setErrorMessage(message);
|
||||
// Defensive: if MicVAD.new partially succeeded before throwing, make sure we
|
||||
// don't leak it.
|
||||
destroyVad();
|
||||
@@ -379,19 +373,11 @@ export function useStreamingDictation(
|
||||
} catch (err) {
|
||||
// Always log the full error for diagnosis (name, message, stack).
|
||||
console.error("[dictation] VAD.start failed", err);
|
||||
const name = (err as { name?: string })?.name;
|
||||
const detail = (err as { message?: string })?.message ?? String(err);
|
||||
let message: string;
|
||||
if (name === "NotAllowedError" || name === "SecurityError") {
|
||||
message = t("Microphone access denied");
|
||||
} else if (name === "NotFoundError" || name === "OverconstrainedError") {
|
||||
message = t("No microphone found");
|
||||
} else if (name === "NotReadableError" || name === "AbortError") {
|
||||
message = t("Microphone is unavailable or already in use");
|
||||
} else {
|
||||
message = `${t("Could not start recording")}: ${detail}`;
|
||||
}
|
||||
const code = classifyGetUserMediaError(err);
|
||||
const message = dictationErrorMessage(code, t, { detail });
|
||||
notifications.show({ color: "red", message });
|
||||
setErrorMessage(message);
|
||||
activeRef.current = false;
|
||||
destroyVad();
|
||||
setStatus("idle");
|
||||
@@ -470,5 +456,5 @@ export function useStreamingDictation(
|
||||
};
|
||||
}, [clearTimer, destroyVad]);
|
||||
|
||||
return { status, start, stop, cancel, audioLevel };
|
||||
return { status, start, stop, cancel, audioLevel, errorMessage };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { atom } from "jotai";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
||||
|
||||
export const pageEditorAtom = atom<Editor | null>(null);
|
||||
|
||||
@@ -15,3 +16,15 @@ export const showLinkMenuAtom = atom(false);
|
||||
// Current page's edit mode — initialized from the user's saved preference on
|
||||
// first load, can be toggled locally without persisting to the server.
|
||||
export const currentPageEditModeAtom = atom<PageEditMode>(PageEditMode.Edit);
|
||||
|
||||
// Whether the dictation mic can start, and (when it can't) the cause-specific
|
||||
// reason the mic button surfaces as a tooltip. Published by the page editor,
|
||||
// consumed by DictationGroup -> MicButton.
|
||||
export type DictationAvailability = {
|
||||
isEditable: boolean;
|
||||
reason: DictationUnavailableReason | null;
|
||||
};
|
||||
export const dictationAvailabilityAtom = atom<DictationAvailability>({
|
||||
isEditable: false,
|
||||
reason: null,
|
||||
});
|
||||
|
||||
+32
-47
@@ -1,36 +1,15 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { useEffect, useState } from "react";
|
||||
import { render, act } from "@testing-library/react";
|
||||
import { Provider, createStore } from "jotai";
|
||||
import { dictationAvailabilityAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
|
||||
// Regression test for #311: on a page the user can edit, the byline mic stayed
|
||||
// stuck disabled until an unrelated re-render happened, because DictationGroup
|
||||
// read the non-reactive field `editor.isEditable` directly. The fix reads it via
|
||||
// `useEditorState`, which subscribes to the editor's own events.
|
||||
//
|
||||
// The mock below mirrors the real `useEditorState` contract: it runs the
|
||||
// selector, and re-runs it (re-rendering the consumer) whenever the editor emits
|
||||
// an event. This is what makes the test faithful — with the pre-fix code
|
||||
// (`disabled={!editor.isEditable}`) DictationGroup never subscribes, so emitting
|
||||
// an event would NOT re-render and the mic would stay disabled.
|
||||
vi.mock("@tiptap/react", () => ({
|
||||
useEditorState: ({ editor, selector }: any) => {
|
||||
const [value, setValue] = useState(() => selector({ editor }));
|
||||
useEffect(() => {
|
||||
const handler = () => setValue(selector({ editor }));
|
||||
editor.on("update", handler);
|
||||
return () => editor.off("update", handler);
|
||||
}, [editor, selector]);
|
||||
return value;
|
||||
},
|
||||
}));
|
||||
|
||||
// The mic only cares about the workspace's streaming flag; return a stable stub.
|
||||
vi.mock("jotai", () => ({
|
||||
useAtomValue: () => ({ settings: { ai: { dictationStreaming: false } } }),
|
||||
}));
|
||||
vi.mock("@/features/user/atoms/current-user-atom.ts", () => ({
|
||||
workspaceAtom: {},
|
||||
}));
|
||||
// Regression test for the byline mic staying stuck disabled (#311 / #309): on a
|
||||
// page the user can edit, the mic must un-grey once the body becomes editable.
|
||||
// #311 first fixed this by reading `editor.isEditable` via `useEditorState`; #309
|
||||
// superseded that with a reactive `dictationAvailabilityAtom` that page-editor
|
||||
// publishes (carrying both the editable gate AND the unavailable reason). The mic
|
||||
// now gates on `dictationAvailability.isEditable`, so a change to that atom must
|
||||
// re-render the group and flip the disabled state (jotai drives the subscription).
|
||||
|
||||
// Detectable stand-in that surfaces the `disabled` prop the component computes.
|
||||
vi.mock("@/features/dictation/components/mic-button", () => ({
|
||||
@@ -41,33 +20,39 @@ vi.mock("@/features/dictation/components/mic-button", () => ({
|
||||
|
||||
import { DictationGroup } from "./dictation-group";
|
||||
|
||||
// Minimal editor stand-in: a mutable `isEditable` field plus a tiny event
|
||||
// emitter, matching the surface DictationGroup + the mocked useEditorState use.
|
||||
function makeFakeEditor(isEditable: boolean) {
|
||||
const listeners = new Set<() => void>();
|
||||
// Minimal editor stand-in matching the surface DictationGroup uses (handleStart /
|
||||
// handleText). The disabled gate no longer reads this — it reads the atom.
|
||||
function makeFakeEditor() {
|
||||
return {
|
||||
isEditable,
|
||||
isEditable: false,
|
||||
isDestroyed: false,
|
||||
state: { selection: { from: 0, to: 0 }, doc: { content: { size: 0 } } },
|
||||
on: (_event: string, cb: () => void) => listeners.add(cb),
|
||||
off: (_event: string, cb: () => void) => listeners.delete(cb),
|
||||
emit: () => listeners.forEach((cb) => cb()),
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("DictationGroup editable reactivity (#311)", () => {
|
||||
it("re-enables the mic when the editor flips isEditable false -> true", () => {
|
||||
const editor = makeFakeEditor(false);
|
||||
const { getByTestId } = render(<DictationGroup editor={editor} />);
|
||||
describe("DictationGroup editable reactivity (#309 atom / #311)", () => {
|
||||
it("re-enables the mic when dictationAvailability flips isEditable false -> true", () => {
|
||||
const editor = makeFakeEditor();
|
||||
const store = createStore();
|
||||
// Pre-sync: page editor publishes not-editable (with a reason).
|
||||
store.set(dictationAvailabilityAtom, {
|
||||
isEditable: false,
|
||||
reason: "connecting",
|
||||
});
|
||||
|
||||
// Pre-sync: not editable yet, so the mic is disabled (preserves #218 intent).
|
||||
const { getByTestId } = render(
|
||||
<Provider store={store}>
|
||||
<DictationGroup editor={editor} />
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
// Not editable yet -> disabled (preserves the #218 pre-sync intent).
|
||||
expect(getByTestId("mic").hasAttribute("disabled")).toBe(true);
|
||||
|
||||
// Collab sync flips the editor editable via editor.setEditable(true), which
|
||||
// mutates the field and emits — the mic must react and enable itself.
|
||||
// Collab sync -> page editor republishes editable; the atom change must
|
||||
// re-render the group and enable the mic.
|
||||
act(() => {
|
||||
editor.isEditable = true;
|
||||
editor.emit();
|
||||
store.set(dictationAvailabilityAtom, { isEditable: true, reason: null });
|
||||
});
|
||||
|
||||
expect(getByTestId("mic").hasAttribute("disabled")).toBe(false);
|
||||
|
||||
+6
-10
@@ -1,7 +1,8 @@
|
||||
import { FC, useRef } from "react";
|
||||
import { Editor, useEditorState } from "@tiptap/react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { dictationAvailabilityAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { MicButton } from "@/features/dictation/components/mic-button";
|
||||
|
||||
interface Props {
|
||||
@@ -16,20 +17,14 @@ export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const streamingDictation =
|
||||
workspace?.settings?.ai?.dictationStreaming === true;
|
||||
// Cause-specific reason the mic is unavailable (published by the page editor).
|
||||
const dictationAvailability = useAtomValue(dictationAvailabilityAtom);
|
||||
// Caret snapshot taken when dictation starts (where the first segment lands).
|
||||
const rangeRef = useRef<{ from: number; to: number } | null>(null);
|
||||
// Running insertion point: after each inserted segment we remember the caret
|
||||
// end so the NEXT segment appends right after it, contiguously, regardless of
|
||||
// where the user's caret currently is. Null until the first segment lands.
|
||||
const insertPosRef = useRef<number | null>(null);
|
||||
// editor.isEditable is a mutable, non-reactive field — read it via
|
||||
// useEditorState so the mic re-enables when the body flips to editable after
|
||||
// collab sync (otherwise it stays stuck disabled). Mirrors the body's own
|
||||
// reactive read.
|
||||
const isEditable = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => ctx.editor?.isEditable ?? false,
|
||||
});
|
||||
|
||||
const handleStart = () => {
|
||||
const { from, to } = editor.state.selection;
|
||||
@@ -88,7 +83,8 @@ export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
|
||||
streaming={streamingDictation}
|
||||
onStart={handleStart}
|
||||
onText={handleText}
|
||||
disabled={!isEditable}
|
||||
disabled={!dictationAvailability.isEditable}
|
||||
unavailableReason={dictationAvailability.reason ?? undefined}
|
||||
color={color}
|
||||
iconSize={iconSize}
|
||||
/>
|
||||
|
||||
@@ -11,9 +11,19 @@ import {
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import classes from "./mention.module.css";
|
||||
|
||||
export default function MentionView(props: NodeViewProps) {
|
||||
const { node } = props;
|
||||
const { label, entityType, entityId, slugId, anchorId } = node.attrs;
|
||||
interface MentionAttrs {
|
||||
label?: string;
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
slugId?: string;
|
||||
anchorId?: string;
|
||||
}
|
||||
|
||||
// Presentational mention renderer (no NodeViewWrapper). Shared by the editor
|
||||
// NodeView (MentionView) and the static comment renderer (CommentContentView)
|
||||
// so mention click/nav/icon behavior stays identical outside of an editor.
|
||||
export function MentionContent({ attrs }: { attrs: MentionAttrs }) {
|
||||
const { label, entityType, slugId, anchorId } = attrs;
|
||||
const isPageMention = entityType === "page";
|
||||
const { spaceSlug, pageSlug } = useParams();
|
||||
const { shareId } = useParams();
|
||||
@@ -56,7 +66,7 @@ export default function MentionView(props: NodeViewProps) {
|
||||
});
|
||||
|
||||
return (
|
||||
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
|
||||
<>
|
||||
{entityType === "user" && (
|
||||
<Text className={classes.userMention} component="span">
|
||||
@{label}
|
||||
@@ -139,6 +149,14 @@ export default function MentionView(props: NodeViewProps) {
|
||||
</span>
|
||||
</Anchor>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MentionView(props: NodeViewProps) {
|
||||
return (
|
||||
<NodeViewWrapper style={{ display: "inline" }} data-drag-handle>
|
||||
<MentionContent attrs={props.node.attrs} />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { WebSocketStatus } from "@hocuspocus/provider";
|
||||
import { isCollabSynced, isBodyEditable } from "./editor-sync-state";
|
||||
import {
|
||||
isCollabSynced,
|
||||
isBodyEditable,
|
||||
computeDictationAvailability,
|
||||
} from "./editor-sync-state";
|
||||
|
||||
describe("isCollabSynced", () => {
|
||||
it("is true only when Connected and synced", () => {
|
||||
@@ -30,3 +34,77 @@ describe("isBodyEditable (pre-sync data-loss gate, #218)", () => {
|
||||
expect(isBodyEditable({ ...base, inEditMode: false })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeDictationAvailability (mic reason precedence, #309)", () => {
|
||||
const base = {
|
||||
editable: true,
|
||||
inEditMode: true,
|
||||
showStatic: false,
|
||||
isDisconnected: false,
|
||||
};
|
||||
|
||||
it("is available with no reason once synced (showStatic false)", () => {
|
||||
expect(computeDictationAvailability(base)).toEqual({
|
||||
isEditable: true,
|
||||
reason: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("reports 'offline' during pre-sync while disconnected", () => {
|
||||
expect(
|
||||
computeDictationAvailability({
|
||||
...base,
|
||||
showStatic: true,
|
||||
isDisconnected: true,
|
||||
}),
|
||||
).toEqual({ isEditable: false, reason: "offline" });
|
||||
});
|
||||
|
||||
it("reports 'connecting' during pre-sync while still connecting", () => {
|
||||
expect(
|
||||
computeDictationAvailability({
|
||||
...base,
|
||||
showStatic: true,
|
||||
isDisconnected: false,
|
||||
}),
|
||||
).toEqual({ isEditable: false, reason: "connecting" });
|
||||
});
|
||||
|
||||
it("reports 'read-only' without edit permission", () => {
|
||||
expect(
|
||||
computeDictationAvailability({ ...base, editable: false }),
|
||||
).toEqual({ isEditable: false, reason: "read-only" });
|
||||
});
|
||||
|
||||
it("reports 'read-only' when not in edit mode", () => {
|
||||
expect(
|
||||
computeDictationAvailability({ ...base, inEditMode: false }),
|
||||
).toEqual({ isEditable: false, reason: "read-only" });
|
||||
});
|
||||
|
||||
// Lack of edit permission takes precedence over the pre-sync reason: a
|
||||
// read-only viewer who is ALSO inside the pre-sync window (showStatic) must
|
||||
// still read "read-only", never "offline"/"connecting". This pins the
|
||||
// `opts.editable &&` guard on the pre-sync branch.
|
||||
it("prefers 'read-only' over pre-sync when a read-only viewer is disconnected", () => {
|
||||
expect(
|
||||
computeDictationAvailability({
|
||||
editable: false,
|
||||
inEditMode: true,
|
||||
showStatic: true,
|
||||
isDisconnected: true,
|
||||
}),
|
||||
).toEqual({ isEditable: false, reason: "read-only" });
|
||||
});
|
||||
|
||||
it("prefers 'read-only' over pre-sync when a read-only viewer is still connecting", () => {
|
||||
expect(
|
||||
computeDictationAvailability({
|
||||
editable: false,
|
||||
inEditMode: true,
|
||||
showStatic: true,
|
||||
isDisconnected: false,
|
||||
}),
|
||||
).toEqual({ isEditable: false, reason: "read-only" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { WebSocketStatus } from "@hocuspocus/provider";
|
||||
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
||||
|
||||
/**
|
||||
* The collab document is usable only once the provider is Connected AND has
|
||||
@@ -30,3 +31,32 @@ export function isBodyEditable(opts: {
|
||||
}): boolean {
|
||||
return opts.editable && opts.inEditMode && !opts.showStatic;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether dictation can start and, when it can't, the cause-specific reason the
|
||||
* mic button surfaces. Derives editability from `isBodyEditable` (the single,
|
||||
* tested gate) so the published `isEditable` can never diverge from the actual
|
||||
* body-editable state and make the tooltip lie (#309).
|
||||
*
|
||||
* `isDisconnected` is the caller's own boolean (collab connection is in the
|
||||
* Disconnected state), passed in so this module stays free of the collab enum.
|
||||
*/
|
||||
export function computeDictationAvailability(opts: {
|
||||
editable: boolean;
|
||||
inEditMode: boolean;
|
||||
showStatic: boolean;
|
||||
isDisconnected: boolean;
|
||||
}): { isEditable: boolean; reason: DictationUnavailableReason | null } {
|
||||
const isEditable = isBodyEditable({
|
||||
editable: opts.editable,
|
||||
inEditMode: opts.inEditMode,
|
||||
showStatic: opts.showStatic,
|
||||
});
|
||||
if (isEditable) return { isEditable, reason: null };
|
||||
// Permitted to edit and in edit mode but not yet synced (showStatic) → pre-sync.
|
||||
if (opts.editable && opts.inEditMode && opts.showStatic) {
|
||||
return { isEditable, reason: opts.isDisconnected ? "offline" : "connecting" };
|
||||
}
|
||||
// No edit permission or not in edit mode.
|
||||
return { isEditable, reason: "read-only" };
|
||||
}
|
||||
|
||||
@@ -27,11 +27,12 @@ import {
|
||||
collabExtensions,
|
||||
mainExtensions,
|
||||
} from "@/features/editor/extensions/extensions";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import {
|
||||
currentPageEditModeAtom,
|
||||
dictationAvailabilityAtom,
|
||||
pageEditorAtom,
|
||||
yjsConnectionStatusAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
@@ -88,6 +89,7 @@ import { PageEmbedAncestryProvider } from "@/features/editor/components/page-emb
|
||||
import PageEmbedPicker from "@/features/editor/components/page-embed/page-embed-picker";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
computeDictationAvailability,
|
||||
isBodyEditable,
|
||||
isCollabSynced,
|
||||
} from "@/features/editor/editor-sync-state";
|
||||
@@ -139,6 +141,7 @@ export default function PageEditor({
|
||||
const { pageSlug } = useParams();
|
||||
const slugId = extractPageSlugId(pageSlug);
|
||||
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
||||
const setDictationAvailability = useSetAtom(dictationAvailabilityAtom);
|
||||
const canScroll = useCallback(
|
||||
() => Boolean(isComponentMounted.current && editorRef.current),
|
||||
[isComponentMounted],
|
||||
@@ -488,6 +491,26 @@ export default function PageEditor({
|
||||
);
|
||||
}, [currentPageEditMode, editor, editable, showStatic]);
|
||||
|
||||
// Publish whether dictation can start and, if not, the cause-specific reason
|
||||
// the mic button surfaces. Recomputed on the same signals that drive body
|
||||
// editability so the tooltip never lies about the current state.
|
||||
useEffect(() => {
|
||||
setDictationAvailability(
|
||||
computeDictationAvailability({
|
||||
editable,
|
||||
inEditMode: currentPageEditMode === PageEditMode.Edit,
|
||||
showStatic,
|
||||
isDisconnected: yjsConnectionStatus === WebSocketStatus.Disconnected,
|
||||
}),
|
||||
);
|
||||
}, [
|
||||
editable,
|
||||
currentPageEditMode,
|
||||
showStatic,
|
||||
yjsConnectionStatus,
|
||||
setDictationAvailability,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!hasConnectedOnceRef.current &&
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Button, Group, Paper, Text } from "@mantine/core";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconClockHour4, IconTrash } from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
@@ -70,7 +77,14 @@ export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
||||
return (
|
||||
<Paper radius="sm" mb="md" px="md" py="xs" bg="orange.0">
|
||||
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||
<Group gap="xs" wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
|
||||
{/* A non-zero flex-basis lets the outer wrap="wrap" drop the buttons to
|
||||
their own row on narrow screens; flex:1 (basis 0) never wraps and
|
||||
instead crushes the text into a one-word-per-line ladder. */}
|
||||
<Group
|
||||
gap="xs"
|
||||
wrap="nowrap"
|
||||
style={{ flex: "1 1 16rem", minWidth: 0 }}
|
||||
>
|
||||
<IconClockHour4
|
||||
size={18}
|
||||
stroke={1.5}
|
||||
@@ -87,28 +101,58 @@ export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
||||
</Text>
|
||||
</Group>
|
||||
{canEdit && (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={handleTrashNow}
|
||||
loading={isDeleting}
|
||||
>
|
||||
{t("Move to trash")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<IconClockHour4 size={16} />}
|
||||
onClick={handleMakePermanent}
|
||||
loading={toggleTemporary.isPending}
|
||||
>
|
||||
{t("Make permanent")}
|
||||
</Button>
|
||||
</Group>
|
||||
<>
|
||||
{/* Desktop: full labeled buttons. */}
|
||||
<Group gap="xs" wrap="nowrap" visibleFrom="sm">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={handleTrashNow}
|
||||
loading={isDeleting}
|
||||
>
|
||||
{t("Move to trash")}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<IconClockHour4 size={16} />}
|
||||
onClick={handleMakePermanent}
|
||||
loading={toggleTemporary.isPending}
|
||||
>
|
||||
{t("Make permanent")}
|
||||
</Button>
|
||||
</Group>
|
||||
{/* Mobile: icon-only actions so they never overflow the narrow row. */}
|
||||
<Group gap="xs" wrap="nowrap" hiddenFrom="sm">
|
||||
<Tooltip label={t("Move to trash")} withArrow>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={handleTrashNow}
|
||||
loading={isDeleting}
|
||||
aria-label={t("Move to trash")}
|
||||
>
|
||||
<IconTrash size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t("Make permanent")} withArrow>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="light"
|
||||
color="orange"
|
||||
onClick={handleMakePermanent}
|
||||
loading={toggleTemporary.isPending}
|
||||
aria-label={t("Make permanent")}
|
||||
>
|
||||
<IconClockHour4 size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAtom, useStore } from "jotai";
|
||||
import { useAtom, useSetAtom, useStore } from "jotai";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { getSpaceUrl } from "@/lib/config.ts";
|
||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
|
||||
export type UseTreeMutation = {
|
||||
handleMove: (sourceId: string, op: DropOp) => Promise<void>;
|
||||
@@ -43,6 +44,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
const removePageMutation = useRemovePageMutation();
|
||||
const movePageMutation = useMovePageMutation();
|
||||
const navigate = useNavigate();
|
||||
const setMobileSidebar = useSetAtom(mobileSidebarAtom);
|
||||
const { spaceSlug, pageSlug } = useParams();
|
||||
|
||||
const handleMove = useCallback(
|
||||
@@ -201,8 +203,23 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
createdPage.title,
|
||||
);
|
||||
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(
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
PALETTE,
|
||||
avatarStyle,
|
||||
avatarBackgroundCss,
|
||||
normalizeName,
|
||||
minPairwiseDistance,
|
||||
relativeLuminance,
|
||||
contrastRatio,
|
||||
oklchToSrgb,
|
||||
isInGamut,
|
||||
} from "./avatar-palette";
|
||||
|
||||
/** Parse "#rrggbb" into sRGB components on the 0..1 scale relativeLuminance expects. */
|
||||
function hexToRgb01(hex: string): [number, number, number] {
|
||||
return [
|
||||
parseInt(hex.slice(1, 3), 16) / 255,
|
||||
parseInt(hex.slice(3, 5), 16) / 255,
|
||||
parseInt(hex.slice(5, 7), 16) / 255,
|
||||
];
|
||||
}
|
||||
|
||||
describe("avatar-palette validation", () => {
|
||||
it("palette colors stay distinguishable", () => {
|
||||
// 0.06 in OKLab is ~4-5 JNDs — safely distinct at avatar size. If a future
|
||||
// RINGS tweak drops this, "almost identical" colors would reappear.
|
||||
expect(minPairwiseDistance().distance).toBeGreaterThanOrEqual(0.06);
|
||||
expect(PALETTE.length).toBe(20);
|
||||
});
|
||||
|
||||
it("every palette entry is WCAG-readable and in sRGB gamut", () => {
|
||||
// white text = luminance 1, black text = luminance 0 (per buildPalette).
|
||||
const textLum = { white: 1, black: 0 } as const;
|
||||
for (const entry of PALETTE) {
|
||||
expect(entry.hex).toMatch(/^#[0-9a-f]{6}$/);
|
||||
|
||||
// (a) The chosen text color really clears the code's 3:1 threshold on the
|
||||
// actual background hex — recomputed independently from the hex, not from
|
||||
// the build-time luminance. A slot that picked the wrong text (or a color
|
||||
// too dim for either text) would fail here.
|
||||
const hexLum = relativeLuminance(hexToRgb01(entry.hex));
|
||||
const chosen = contrastRatio(textLum[entry.text], hexLum);
|
||||
expect(chosen).toBeGreaterThanOrEqual(3);
|
||||
// buildPalette prefers white and only falls back to black when white
|
||||
// fails 3:1. Mirror that decision: black is used *only* when white would
|
||||
// not clear the threshold — so a mis-assigned "black" on a dark color
|
||||
// (where white was fine) fails here.
|
||||
if (entry.text === "black") {
|
||||
expect(contrastRatio(textLum.white, hexLum)).toBeLessThan(3);
|
||||
}
|
||||
|
||||
// (b) The entry's OKLCH is inside the sRGB gamut after chroma clamping;
|
||||
// an out-of-gamut slot (e.g. un-clamped chroma) would produce components
|
||||
// outside [0,1] and fail here.
|
||||
expect(isInGamut(oklchToSrgb(entry.L, entry.C, entry.h))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("avatarStyle", () => {
|
||||
it("name-to-avatar mapping is frozen (golden values)", () => {
|
||||
// Golden slice: if this breaks, all existing avatars change — make sure
|
||||
// that is intentional (a config change in avatar-palette.ts).
|
||||
const s = avatarStyle("Backend Developer");
|
||||
expect([s.bg, s.bg2, s.angleDeg]).toEqual(["#a55795", "#90355e", 150]);
|
||||
expect(s.text).toBe("white");
|
||||
});
|
||||
|
||||
it("is deterministic and normalizes the name", () => {
|
||||
expect(avatarStyle("Researcher")).toEqual(avatarStyle("Researcher"));
|
||||
// Casing, surrounding and repeated whitespace must not change the avatar.
|
||||
expect(avatarStyle(" RESEARCHER ")).toEqual(avatarStyle("researcher"));
|
||||
expect(avatarStyle("Backend Developer")).toEqual(
|
||||
avatarStyle("backend developer"),
|
||||
);
|
||||
expect(normalizeName(" PM ")).toBe("pm");
|
||||
});
|
||||
|
||||
it("returns a valid base color, angle and matching text", () => {
|
||||
const s = avatarStyle("Нарратор");
|
||||
const idx = PALETTE.findIndex((e) => e.hex === s.bg);
|
||||
expect(idx).toBe(s.paletteIndex);
|
||||
expect(idx).toBeGreaterThanOrEqual(0); // bg is a palette entry
|
||||
// Text color comes from the chosen palette entry.
|
||||
expect(s.text).toBe(PALETTE[idx].text);
|
||||
// Split angle is one of the SPLIT_ANGLE_STEPS (24) directions → multiples of 15.
|
||||
expect(s.angleDeg % 15).toBe(0);
|
||||
expect(s.angleDeg).toBeGreaterThanOrEqual(0);
|
||||
expect(s.angleDeg).toBeLessThan(360);
|
||||
});
|
||||
|
||||
it("distinguishes the agents that used to collide as violet", () => {
|
||||
// "Структурный редактор" and "Фактчекер" looked identically violet before.
|
||||
expect(avatarStyle("Структурный редактор")).not.toEqual(
|
||||
avatarStyle("Фактчекер"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("avatarBackgroundCss", () => {
|
||||
it("renders a two-stop gradient with a soft boundary", () => {
|
||||
const s = avatarStyle("Backend Developer");
|
||||
expect(avatarBackgroundCss(s)).toBe(
|
||||
"linear-gradient(150deg, #a55795 42%, #90355e 58%)",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Deterministic avatar backgrounds for agent roles.
|
||||
*
|
||||
* The palette is generated from scratch at module load in OKLCH (a perceptually
|
||||
* uniform color space), so every value below is tunable: change the ring
|
||||
* configuration or the partner shifts and the whole palette regenerates.
|
||||
*
|
||||
* Pipeline: name -> normalize -> cyrb53 hash -> split into independent fields:
|
||||
* - base color index (one of the validated palette colors)
|
||||
* - partner hue shift: analogous 20..45deg (either side), complementary 180deg,
|
||||
* or triadic +/-120deg — classic color-wheel schemes; partner is also darker
|
||||
* - split angle (SPLIT_ANGLE_STEPS directions, soft boundary)
|
||||
* The same name always yields the same avatar, on any platform, forever.
|
||||
*/
|
||||
|
||||
// ------------------------- Tunable configuration -------------------------
|
||||
|
||||
export interface RingConfig {
|
||||
/** OKLCH lightness, 0..1 */
|
||||
L: number;
|
||||
/** OKLCH chroma target; clamped down per-hue to fit the sRGB gamut */
|
||||
C: number;
|
||||
/** Hue of the first color in the ring, degrees */
|
||||
hueStart: number;
|
||||
/** Number of evenly spaced hues in the ring */
|
||||
count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two lightness rings. 12 light + 8 dark = 20 base colors with a validated
|
||||
* min pairwise deltaE-OK of ~0.066 (clearly distinguishable at avatar size).
|
||||
* Don't add more hues per ring without re-checking minPairwiseDistance():
|
||||
* beyond ~20-24 colors humans stop telling them apart reliably.
|
||||
*/
|
||||
const RINGS: readonly RingConfig[] = [
|
||||
{ L: 0.70, C: 0.14, hueStart: 15, count: 12 }, // light ring
|
||||
{ L: 0.57, C: 0.13, hueStart: 20, count: 8 }, // darker ring
|
||||
];
|
||||
|
||||
/** Partner color: lightness shifted by this much (negative = darker) */
|
||||
const PARTNER_L_SHIFT = -0.10;
|
||||
/** Analogous scheme: hue shift magnitude range, degrees (inclusive, 5-deg steps) */
|
||||
const ANALOG_MIN_SHIFT = 20;
|
||||
const ANALOG_SHIFT_STEP = 5;
|
||||
const ANALOG_SHIFT_STEPS = 6; // 20, 25, 30, 35, 40, 45
|
||||
/** Complementary scheme: fixed hue shift, degrees */
|
||||
const COMPLEMENTARY_SHIFT = 180;
|
||||
/** Triadic scheme: fixed hue shift magnitude, degrees (either side) */
|
||||
const TRIADIC_SHIFT = 120;
|
||||
/** Number of split directions (24 -> 15deg per step) */
|
||||
const SPLIT_ANGLE_STEPS = 24;
|
||||
/** Position of the color boundary, percent of the gradient axis */
|
||||
const SPLIT_PERCENT = 50;
|
||||
/** Width of the soft transition zone around the boundary, percent (0 = hard edge) */
|
||||
const SPLIT_SOFTNESS = 16;
|
||||
|
||||
// ------------------------- OKLCH -> sRGB math -------------------------
|
||||
// Matrices from Bjorn Ottosson's OKLab reference implementation.
|
||||
|
||||
function oklabToLinearSrgb(L: number, a: number, b: number): [number, number, number] {
|
||||
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
||||
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
||||
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
||||
const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3;
|
||||
return [
|
||||
+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
||||
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
||||
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
|
||||
];
|
||||
}
|
||||
|
||||
function gammaEncode(c: number): number {
|
||||
return c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055;
|
||||
}
|
||||
|
||||
export function oklchToSrgb(L: number, C: number, hDeg: number): [number, number, number] {
|
||||
const h = (hDeg * Math.PI) / 180;
|
||||
const [r, g, b] = oklabToLinearSrgb(L, C * Math.cos(h), C * Math.sin(h));
|
||||
return [gammaEncode(r), gammaEncode(g), gammaEncode(b)];
|
||||
}
|
||||
|
||||
export function isInGamut(rgb: readonly number[]): boolean {
|
||||
return rgb.every((c) => c >= -1e-6 && c <= 1 + 1e-6);
|
||||
}
|
||||
|
||||
/** Binary-search the max chroma <= C that fits into the sRGB gamut. */
|
||||
function clampChroma(L: number, C: number, hDeg: number): number {
|
||||
if (isInGamut(oklchToSrgb(L, C, hDeg))) return C;
|
||||
let lo = 0, hi = C;
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const mid = (lo + hi) / 2;
|
||||
if (isInGamut(oklchToSrgb(L, mid, hDeg))) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
function toHex(rgb: readonly number[]): string {
|
||||
return (
|
||||
"#" +
|
||||
rgb
|
||||
.map((c) => Math.round(Math.min(1, Math.max(0, c)) * 255).toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
);
|
||||
}
|
||||
|
||||
/** WCAG relative luminance of an sRGB color (components 0..1). */
|
||||
export function relativeLuminance(rgb: readonly number[]): number {
|
||||
const lin = rgb.map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
|
||||
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
|
||||
}
|
||||
|
||||
export function contrastRatio(l1: number, l2: number): number {
|
||||
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
|
||||
}
|
||||
|
||||
// ------------------------- Palette generation -------------------------
|
||||
|
||||
export interface PaletteEntry {
|
||||
/** Base background color */
|
||||
hex: string;
|
||||
/** OKLCH coordinates of the base color (used to derive partner colors) */
|
||||
L: number;
|
||||
C: number;
|
||||
h: number;
|
||||
/** Text/icon color with the best WCAG contrast on the base color */
|
||||
text: "white" | "black";
|
||||
/** OKLab coordinates of the base color (kept for validation) */
|
||||
lab: readonly [number, number, number];
|
||||
}
|
||||
|
||||
function buildPalette(): PaletteEntry[] {
|
||||
const entries: PaletteEntry[] = [];
|
||||
for (const ring of RINGS) {
|
||||
const step = 360 / ring.count;
|
||||
for (let i = 0; i < ring.count; i++) {
|
||||
const h = (ring.hueStart + i * step) % 360;
|
||||
const C = clampChroma(ring.L, ring.C, h);
|
||||
const rgb = oklchToSrgb(ring.L, C, h);
|
||||
const lum = relativeLuminance(rgb);
|
||||
entries.push({
|
||||
hex: toHex(rgb),
|
||||
L: ring.L,
|
||||
C,
|
||||
h,
|
||||
// White text needs >= 3:1 contrast; otherwise fall back to black.
|
||||
text: contrastRatio(lum, 1) >= 3 ? "white" : "black",
|
||||
lab: [
|
||||
ring.L,
|
||||
C * Math.cos((h * Math.PI) / 180),
|
||||
C * Math.sin((h * Math.PI) / 180),
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Partner color for the split: base hue shifted by shiftDeg, darker by PARTNER_L_SHIFT. */
|
||||
function partnerHex(entry: PaletteEntry, shiftDeg: number): string {
|
||||
const h2 = (entry.h + shiftDeg + 360) % 360;
|
||||
const L2 = entry.L + PARTNER_L_SHIFT;
|
||||
return toHex(oklchToSrgb(L2, clampChroma(L2, entry.C, h2), h2));
|
||||
}
|
||||
|
||||
/** Generated once at module load; regenerates on every build from the config above. */
|
||||
export const PALETTE: readonly PaletteEntry[] = buildPalette();
|
||||
|
||||
// ------------------------- Name -> avatar style -------------------------
|
||||
|
||||
/** Normalize so that "PM ", "pm" and "Pm" map to the same avatar. */
|
||||
export function normalizeName(name: string): string {
|
||||
return name.normalize("NFC").trim().toLowerCase().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
/**
|
||||
* cyrb53: deterministic 53-bit string hash with good avalanche.
|
||||
* Pure JS, cross-platform — never use language built-in hashing here.
|
||||
*/
|
||||
function cyrb53(str: string, seed = 0): number {
|
||||
let h1 = 0xdeadbeef ^ seed;
|
||||
let h2 = 0x41c6ce57 ^ seed;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const ch = str.charCodeAt(i);
|
||||
h1 = Math.imul(h1 ^ ch, 2654435761);
|
||||
h2 = Math.imul(h2 ^ ch, 1597334677);
|
||||
}
|
||||
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
||||
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
||||
return 4294967296 * (2097151 & h2) + (h1 >>> 0);
|
||||
}
|
||||
|
||||
export interface AvatarStyle {
|
||||
/** Index of the base color in PALETTE */
|
||||
paletteIndex: number;
|
||||
/** Base color hex */
|
||||
bg: string;
|
||||
/** Second color hex (split partner) */
|
||||
bg2: string;
|
||||
/** Signed hue shift of the partner, degrees (e.g. -35, +45, 180, -120) */
|
||||
hueShift: number;
|
||||
/** Direction of the split, degrees */
|
||||
angleDeg: number;
|
||||
/** Text/icon color for the base color */
|
||||
text: "white" | "black";
|
||||
}
|
||||
|
||||
/** Pure function: the same (normalized) name always returns the same style. */
|
||||
export function avatarStyle(agentName: string): AvatarStyle {
|
||||
const h = cyrb53(normalizeName(agentName));
|
||||
// Slice the hash into independent fields, like digits of a number:
|
||||
const paletteIndex = h % PALETTE.length;
|
||||
let rest = Math.floor(h / PALETTE.length);
|
||||
const angleDeg = (rest % SPLIT_ANGLE_STEPS) * (360 / SPLIT_ANGLE_STEPS);
|
||||
rest = Math.floor(rest / SPLIT_ANGLE_STEPS);
|
||||
// Scheme: 0,1 -> analogous (minus/plus); 2 -> complementary; 3 -> triadic
|
||||
const scheme = rest % 4;
|
||||
rest = Math.floor(rest / 4);
|
||||
let hueShift: number;
|
||||
if (scheme === 2) {
|
||||
hueShift = COMPLEMENTARY_SHIFT;
|
||||
} else if (scheme === 3) {
|
||||
hueShift = rest % 2 ? TRIADIC_SHIFT : -TRIADIC_SHIFT;
|
||||
} else {
|
||||
const magnitude = ANALOG_MIN_SHIFT + (rest % ANALOG_SHIFT_STEPS) * ANALOG_SHIFT_STEP;
|
||||
hueShift = scheme === 0 ? -magnitude : magnitude;
|
||||
}
|
||||
const entry = PALETTE[paletteIndex];
|
||||
return {
|
||||
paletteIndex,
|
||||
bg: entry.hex,
|
||||
bg2: partnerHex(entry, hueShift),
|
||||
hueShift,
|
||||
angleDeg,
|
||||
text: entry.text,
|
||||
};
|
||||
}
|
||||
|
||||
/** CSS background value: two colors with a slightly blurred boundary. */
|
||||
export function avatarBackgroundCss(style: AvatarStyle): string {
|
||||
const from = SPLIT_PERCENT - SPLIT_SOFTNESS / 2;
|
||||
const to = SPLIT_PERCENT + SPLIT_SOFTNESS / 2;
|
||||
return `linear-gradient(${style.angleDeg}deg, ${style.bg} ${from}%, ${style.bg2} ${to}%)`;
|
||||
}
|
||||
|
||||
// ------------------------- Validation -------------------------
|
||||
|
||||
/**
|
||||
* Min pairwise deltaE-OK (euclidean distance in OKLab) between base colors.
|
||||
* Re-check after tweaking RINGS: keep it >= ~0.06 so no two palette colors
|
||||
* look alike. Intended for a unit test or a dev-time assertion.
|
||||
*/
|
||||
export function minPairwiseDistance(): { distance: number; pair: [string, string] } {
|
||||
let min = Infinity;
|
||||
let pair: [string, string] = ["", ""];
|
||||
for (let i = 0; i < PALETTE.length; i++) {
|
||||
for (let j = i + 1; j < PALETTE.length; j++) {
|
||||
const a = PALETTE[i].lab, b = PALETTE[j].lab;
|
||||
const d = Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
||||
if (d < min) {
|
||||
min = d;
|
||||
pair = [PALETTE[i].hex, PALETTE[j].hex];
|
||||
}
|
||||
}
|
||||
}
|
||||
return { distance: min, pair };
|
||||
}
|
||||
@@ -13,5 +13,22 @@ export default defineConfig({
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
} from './collaboration.util';
|
||||
import {
|
||||
removeYjsMarkByAttribute,
|
||||
replaceYjsMarkedText,
|
||||
setYjsMark,
|
||||
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 (
|
||||
documentName: string,
|
||||
payload: {
|
||||
|
||||
@@ -52,6 +52,7 @@ export const AuditEvent = {
|
||||
COMMENT_RESOLVED: 'comment.resolved',
|
||||
COMMENT_REOPENED: 'comment.reopened',
|
||||
COMMENT_SUGGESTION_APPLIED: 'comment.suggestion_applied',
|
||||
COMMENT_SUGGESTION_DISMISSED: 'comment.suggestion_dismissed',
|
||||
|
||||
// Page
|
||||
PAGE_CREATED: 'page.created',
|
||||
|
||||
@@ -27,7 +27,11 @@ const SAFETY_FRAMEWORK = [
|
||||
'- You can read pages, comments and page history, and modify the workspace:',
|
||||
' create/rename/move pages and make structural edits (text, nodes, tables);',
|
||||
' manage page history (diff/restore); copy, import and export content; and',
|
||||
' create/resolve comments. Page edits are REVERSIBLE — they keep page',
|
||||
' create/resolve comments. An inline comment can carry a suggestedText — a',
|
||||
' proposed replacement for its selected text that the user applies with one',
|
||||
' click; when you propose a concrete rewording of a specific fragment,',
|
||||
' attach it as suggestedText instead of only describing the change. Page',
|
||||
' edits are REVERSIBLE — they keep page',
|
||||
' history and a trashed page can be restored. One exception to keep in mind:',
|
||||
' sharing a page makes it PUBLICLY accessible — do that only when the user',
|
||||
' asked.',
|
||||
|
||||
@@ -303,7 +303,9 @@ export class AiChatToolsService {
|
||||
getPage: tool({
|
||||
description:
|
||||
'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({
|
||||
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),
|
||||
),
|
||||
|
||||
searchInPage: sharedTool(
|
||||
sharedToolSpecs.searchInPage,
|
||||
async ({ pageId, query, regex, caseSensitive, limit }) =>
|
||||
await client.searchInPage(pageId, query, {
|
||||
regex,
|
||||
caseSensitive,
|
||||
limit,
|
||||
}),
|
||||
),
|
||||
|
||||
getTable: tool({
|
||||
description:
|
||||
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
|
||||
@@ -647,11 +659,21 @@ export class AiChatToolsService {
|
||||
|
||||
listComments: tool({
|
||||
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({
|
||||
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({
|
||||
|
||||
@@ -55,8 +55,18 @@ export interface DocmostClientLike {
|
||||
getOutline(pageId: string): Promise<Record<string, unknown>>;
|
||||
getPageJson(pageId: 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>>;
|
||||
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(
|
||||
commentId: string,
|
||||
): Promise<{ data: Record<string, unknown>; success: boolean }>;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
@@ -117,3 +118,207 @@ describe('CommentController apply-suggestion authz', () => {
|
||||
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 { ResolveCommentDto } from './dto/resolve-comment.dto';
|
||||
import { ApplySuggestionDto } from './dto/apply-suggestion.dto';
|
||||
import { DismissSuggestionDto } from './dto/dismiss-suggestion.dto';
|
||||
import { PageIdDto, CommentIdDto } from './dto/comments.input';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
@@ -234,6 +235,59 @@ export class CommentController {
|
||||
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)
|
||||
@Post('delete')
|
||||
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
|
||||
* 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', () => {
|
||||
const UPDATED = { id: 'c-1', __updated: true } as any;
|
||||
|
||||
function makeService(verdict: unknown) {
|
||||
function makeService(verdict: unknown, hasChildren = false, deletedRows = 1) {
|
||||
const commentRepo: any = {
|
||||
// Both the applied-stamp re-read and resolveComment's re-read go through
|
||||
// findById; return a recognizable enriched row.
|
||||
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 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() };
|
||||
@@ -74,7 +84,9 @@ describe('CommentService — applySuggestion', () => {
|
||||
.map((c: any[]) => c[0])
|
||||
.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 } =
|
||||
makeService({ applied: true, currentText: 'new text' });
|
||||
|
||||
@@ -92,37 +104,34 @@ describe('CommentService — applySuggestion', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// Applied stamps persisted.
|
||||
const patch = appliedPatch(commentRepo);
|
||||
expect(patch.suggestionAppliedAt).toBeInstanceOf(Date);
|
||||
expect(patch.suggestionAppliedById).toBe('user-1');
|
||||
// Ephemeral: the redundant comment is hard-deleted (atomic-conditional) and
|
||||
// its inline anchor mark removed via the deleteCommentMark collab event.
|
||||
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-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.
|
||||
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');
|
||||
|
||||
// Audit + broadcast + return.
|
||||
// Broadcast a deletion, audit the (still-applied) suggestion, report outcome.
|
||||
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_APPLIED,
|
||||
resourceType: AuditResource.COMMENT,
|
||||
resourceId: 'c-1',
|
||||
spaceId: 'space-1',
|
||||
metadata: { pageId: 'page-1' },
|
||||
}),
|
||||
);
|
||||
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
|
||||
'space-1',
|
||||
'page-1',
|
||||
expect.objectContaining({ operation: 'commentUpdated', comment: UPDATED }),
|
||||
);
|
||||
expect(result).toBe(UPDATED);
|
||||
expect(result.outcome).toBe('deleted');
|
||||
});
|
||||
|
||||
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({
|
||||
applied: false,
|
||||
currentText: 'new text',
|
||||
@@ -130,15 +139,55 @@ describe('CommentService — applySuggestion', () => {
|
||||
|
||||
const result = await service.applySuggestion(suggestionComment(), user());
|
||||
|
||||
// The stamps are still persisted (reconciling a crash between the doc
|
||||
// mutation and the DB write) and the call succeeds.
|
||||
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||
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);
|
||||
expect(patch.suggestionAppliedAt).toBeInstanceOf(Date);
|
||||
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 () => {
|
||||
const { service, commentRepo, auditService } = makeService({
|
||||
applied: false,
|
||||
@@ -153,14 +202,14 @@ describe('CommentService — applySuggestion', () => {
|
||||
expect(err.getResponse()).toMatchObject({
|
||||
currentText: 'someone else edited this',
|
||||
});
|
||||
// No persistence and no audit on a conflict.
|
||||
expect(appliedPatch(commentRepo)).toBeUndefined();
|
||||
// No delete and no audit on a conflict.
|
||||
expect(commentRepo.deleteComment).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 } =
|
||||
makeService({ applied: true, currentText: 'new text' });
|
||||
makeService({ applied: true, currentText: 'new text' }, true);
|
||||
|
||||
const result = await service.applySuggestion(
|
||||
suggestionComment({
|
||||
@@ -171,17 +220,20 @@ describe('CommentService — applySuggestion', () => {
|
||||
user(),
|
||||
);
|
||||
|
||||
// Idempotent SUCCESS, not a 409. The suggestion is already applied, so the
|
||||
// collaborative document is never touched again and nothing is re-stamped
|
||||
// or re-resolved.
|
||||
expect(result).toBe(UPDATED);
|
||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalled();
|
||||
expect(commentRepo.updateComment).not.toHaveBeenCalled();
|
||||
// Same success shape as the applied path (broadcast + audit).
|
||||
// Idempotent SUCCESS. The suggestion is already applied, so the document is
|
||||
// never re-mutated (no applyCommentSuggestion) and nothing is re-stamped.
|
||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
|
||||
'applyCommentSuggestion',
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(appliedPatch(commentRepo)).toBeUndefined();
|
||||
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||
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({
|
||||
applied: true,
|
||||
currentText: 'new text',
|
||||
@@ -192,28 +244,43 @@ describe('CommentService — applySuggestion', () => {
|
||||
user(),
|
||||
);
|
||||
|
||||
expect(result).toBe(UPDATED);
|
||||
|
||||
// The suggestion is NOT re-applied to the document…
|
||||
// No re-apply to the document; the childless applied comment is removed.
|
||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
|
||||
'applyCommentSuggestion',
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
// …but the open thread is self-healed to resolved via resolveComment, which
|
||||
// writes the resolve patch and updates the resolve mark.
|
||||
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||
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
|
||||
.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 }),
|
||||
);
|
||||
// The applied stamps are NOT re-written (already stamped).
|
||||
expect(appliedPatch(commentRepo)).toBeUndefined();
|
||||
expect(result.outcome).toBe('resolved');
|
||||
});
|
||||
|
||||
it('rejects a comment with no suggestedText', async () => {
|
||||
@@ -238,8 +305,8 @@ describe('CommentService — applySuggestion', () => {
|
||||
service.applySuggestion(suggestionComment(), user()),
|
||||
).rejects.toThrow(InternalServerErrorException);
|
||||
|
||||
// Nothing persisted, nothing audited.
|
||||
expect(appliedPatch(commentRepo)).toBeUndefined();
|
||||
// Nothing deleted, nothing audited.
|
||||
expect(commentRepo.deleteComment).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,
|
||||
} 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()
|
||||
export class CommentService {
|
||||
private readonly logger = new Logger(CommentService.name);
|
||||
@@ -362,7 +368,7 @@ export class CommentService {
|
||||
comment: Comment,
|
||||
user: User,
|
||||
provenance?: AuthProvenanceData,
|
||||
): Promise<Comment> {
|
||||
): Promise<Comment & { outcome: SuggestionOutcome }> {
|
||||
// Structural guards.
|
||||
if (comment.parentCommentId) {
|
||||
throw new BadRequestException(
|
||||
@@ -449,42 +455,148 @@ export class CommentService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the applied stamps (idempotently), auto-resolve the thread and
|
||||
* broadcast + audit the applied suggestion. Shared by the applied and the
|
||||
* Dismiss ("Не применять") a suggested edit without touching the page text:
|
||||
* 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.
|
||||
*
|
||||
* 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(
|
||||
comment: Comment,
|
||||
user: User,
|
||||
provenance?: AuthProvenanceData,
|
||||
): Promise<Comment> {
|
||||
if (!comment.suggestionAppliedAt) {
|
||||
await this.commentRepo.updateComment(
|
||||
{
|
||||
suggestionAppliedAt: new Date(),
|
||||
suggestionAppliedById: user.id,
|
||||
},
|
||||
comment.id,
|
||||
);
|
||||
): Promise<Comment & { outcome: SuggestionOutcome }> {
|
||||
const hasChildren = await this.commentRepo.hasChildren(comment.id);
|
||||
|
||||
if (hasChildren) {
|
||||
// Thread has replies → preserve the discussion: stamp applied + resolve.
|
||||
if (!comment.suggestionAppliedAt) {
|
||||
await this.commentRepo.updateComment(
|
||||
{
|
||||
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
|
||||
// broadcast and the resolve notification. The guard above guarantees the
|
||||
// thread was open when we entered, but 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,
|
||||
});
|
||||
// No replies → ephemeral: the suggested text is already in the document, so
|
||||
// the comment is redundant. Hard-delete it and strip its inline anchor. We
|
||||
// deliberately do NOT write the applied stamps first (the row is about to be
|
||||
// deleted); the audit event still records that the suggestion was applied.
|
||||
// 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);
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.COMMENT_SUGGESTION_APPLIED,
|
||||
@@ -494,7 +606,86 @@ export class CommentService {
|
||||
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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
const result = await this.db
|
||||
.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 };
|
||||
}
|
||||
|
||||
// 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(
|
||||
db: Kysely<any>,
|
||||
workspaceId: string,
|
||||
@@ -174,6 +230,40 @@ export async function createPage(
|
||||
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(
|
||||
db: Kysely<any>,
|
||||
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",
|
||||
"dependencies": {
|
||||
"marked": "17.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "4.1.6",
|
||||
"vitest": "4.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,21 @@ export default defineConfig({
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
-109
@@ -1,109 +0,0 @@
|
||||
/**
|
||||
* The client seam. `pull.ts`/`push.ts` depend on a narrow STRUCTURAL interface
|
||||
* rather than any concrete client, because the gitmost server writes NATIVELY —
|
||||
* through repositories + collab `openDirectConnection`.
|
||||
*
|
||||
* `GitSyncClient` is that interface: the native datasource (server side)
|
||||
* implements it, and the engine only ever uses `Pick<GitSyncClient, ...>`
|
||||
* subsets of it. The signatures below MIRROR exactly the methods the engine's
|
||||
* `pull.ts`/`push.ts` actually call (arg shapes + the fields the engine reads
|
||||
* off each result), so a REST-style client is still structurally assignable and
|
||||
* the native adapter has a precise contract.
|
||||
*/
|
||||
/**
|
||||
* A page node as returned by `listSpaceTree` (the sidebar/tree walk, no body).
|
||||
* The engine layout (`buildVaultLayout`) consumes `PageNode` from `./layout`,
|
||||
* which only requires `id` (+ optional `title`/`slugId`/`parentPageId`); this
|
||||
* lite shape documents the fields the tree walk surfaces. Real tree nodes also
|
||||
* carry `position`, `icon`, `hasChildren` — kept open via the index signature.
|
||||
*/
|
||||
export interface GitSyncPageNodeLite {
|
||||
id: string;
|
||||
slugId?: string;
|
||||
title?: string;
|
||||
parentPageId?: string | null;
|
||||
hasChildren?: boolean;
|
||||
/** `listSpaceTree` nodes carry extra fields (position, icon, …). */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
/**
|
||||
* The structural client the engine depends on. Only `Pick<GitSyncClient, ...>`
|
||||
* subsets are ever used:
|
||||
* - pull reads: `getPageJson` (+ the tree walk's `listSpaceTree`),
|
||||
* - push writes: `importPageMarkdown` / `createPage` / `deletePage` /
|
||||
* `movePage` / `renamePage`,
|
||||
* - continuous (phase B+): `listRecentSince` / `listTrash` / `restorePage`.
|
||||
*/
|
||||
export interface GitSyncClient {
|
||||
/**
|
||||
* Full tree of page nodes for the space (or the subtree rooted at
|
||||
* `rootPageId`), each WITHOUT body content. `complete` is `false` when the
|
||||
* walk was truncated / a fetch failed — the pull side suppresses absence
|
||||
* deletions on an incomplete tree (SPEC §8). Native impl returns
|
||||
* `complete: true` always (reads the DB, not a paginated REST endpoint).
|
||||
*/
|
||||
listSpaceTree(spaceId: string, rootPageId?: string): Promise<{
|
||||
pages: GitSyncPageNodeLite[];
|
||||
complete: boolean;
|
||||
}>;
|
||||
/**
|
||||
* One page WITH its ProseMirror body content. `applyPullActions` reads
|
||||
* `id`, `slugId`, `title`, `parentPageId`, `spaceId` (for the file meta) and
|
||||
* `content` (to stabilize/serialize). `updatedAt` is carried for the
|
||||
* poll-suppression loop-guard.
|
||||
*/
|
||||
getPageJson(pageId: string): Promise<{
|
||||
id: string;
|
||||
slugId: string;
|
||||
title: string;
|
||||
parentPageId: string | null;
|
||||
spaceId: string;
|
||||
updatedAt: string;
|
||||
content: unknown;
|
||||
}>;
|
||||
/**
|
||||
* Merge a page's body from a self-contained markdown file (meta + body). The
|
||||
* collab/Yjs write path (SPEC §2/§15.6) — never a raw jsonb overwrite.
|
||||
* `applyPushActions` reads only an optional `updatedAt` off the result
|
||||
* (via `extractUpdatedAt`, tolerant of extra fields).
|
||||
*
|
||||
* `baseMarkdown` is the last-synced version of the file (`refs/docmost/
|
||||
* last-pushed`), the common ancestor for a THREE-WAY merge against the live
|
||||
* doc so concurrent human edits survive (review #5). Optional/null -> 2-way.
|
||||
*/
|
||||
importPageMarkdown(pageId: string, fullMarkdown: string, baseMarkdown?: string | null): Promise<{
|
||||
updatedAt?: string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
/**
|
||||
* Create a new page and return the assigned id at `data.id`
|
||||
* (`applyPushActions` reads `result.data.id`, then writes it back into the
|
||||
* file's meta). An optional top-level/`data.updatedAt` feeds the loop-guard.
|
||||
*/
|
||||
createPage(title: string, content: string, spaceId: string, parentPageId?: string): Promise<{
|
||||
data: {
|
||||
id: string;
|
||||
};
|
||||
updatedAt?: string;
|
||||
[key: string]: unknown;
|
||||
}>;
|
||||
/** Soft-delete a page to Trash (SPEC §8). Result is not inspected. */
|
||||
deletePage(pageId: string): Promise<unknown>;
|
||||
/**
|
||||
* Reparent a page (and optionally set its fractional-index `position`). The
|
||||
* engine passes `position` UNDEFINED for now; the native impl computes a
|
||||
* default between siblings. Result is not inspected.
|
||||
*/
|
||||
movePage(pageId: string, parentPageId: string | null, position?: string): Promise<unknown>;
|
||||
/** Change a page's title only (no body touch). Result is not inspected. */
|
||||
renamePage(pageId: string, title: string): Promise<unknown>;
|
||||
/**
|
||||
* Pages updated since `sinceIso` (the poll-safety reconciliation, SPEC §8).
|
||||
* `spaceId` may be undefined (all spaces); `hardPageCap` bounds the walk.
|
||||
*/
|
||||
listRecentSince(spaceId: string | undefined, sinceIso: string | null, hardPageCap?: number): Promise<unknown[]>;
|
||||
/** List soft-deleted (trashed) pages for the space (deletion detection). */
|
||||
listTrash(spaceId: string): Promise<unknown[]>;
|
||||
/** Restore a soft-deleted page from Trash. Result is not inspected. */
|
||||
restorePage(pageId: string): Promise<unknown>;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* The client seam. `pull.ts`/`push.ts` depend on a narrow STRUCTURAL interface
|
||||
* rather than any concrete client, because the gitmost server writes NATIVELY —
|
||||
* through repositories + collab `openDirectConnection`.
|
||||
*
|
||||
* `GitSyncClient` is that interface: the native datasource (server side)
|
||||
* implements it, and the engine only ever uses `Pick<GitSyncClient, ...>`
|
||||
* subsets of it. The signatures below MIRROR exactly the methods the engine's
|
||||
* `pull.ts`/`push.ts` actually call (arg shapes + the fields the engine reads
|
||||
* off each result), so a REST-style client is still structurally assignable and
|
||||
* the native adapter has a precise contract.
|
||||
*/
|
||||
export {};
|
||||
@@ -1 +0,0 @@
|
||||
export declare function loadSettingsOrExit<T>(factory: () => T): T;
|
||||
@@ -1,50 +0,0 @@
|
||||
import { ZodError } from 'zod';
|
||||
// Turn a ZodError from settings validation into a clear, actionable startup
|
||||
// message that names the offending env var(s), then exit(1) — no raw stack
|
||||
// trace. Mirrors the Python new-project skeleton's load_settings_or_exit.
|
||||
// A non-ZodError is left to propagate unchanged.
|
||||
export function loadSettingsOrExit(factory) {
|
||||
try {
|
||||
return factory();
|
||||
}
|
||||
catch (err) {
|
||||
if (!(err instanceof ZodError))
|
||||
throw err;
|
||||
const missing = [];
|
||||
const invalid = [];
|
||||
for (const issue of err.issues) {
|
||||
const name = issue.path.length ? String(issue.path[0]) : '?';
|
||||
// A missing required variable surfaces as an `invalid_type` issue whose
|
||||
// received value was `undefined`. zod 3 exposed `issue.received` directly;
|
||||
// zod 4 dropped that field and instead folds it into the message
|
||||
// ("expected string, received undefined"). Detect both shapes so the
|
||||
// missing-vs-invalid split holds across zod majors. NOTE: an invalid (but
|
||||
// present) value uses a different code (invalid_format / invalid_value) or
|
||||
// an `invalid_type` message that reports a non-undefined received (e.g.
|
||||
// "received NaN" from a coerced number), so neither is misread as missing.
|
||||
const i = issue;
|
||||
const isMissing = issue.code === 'invalid_type' &&
|
||||
(i.received === 'undefined' ||
|
||||
/received undefined/i.test(i.message ?? ''));
|
||||
if (isMissing)
|
||||
missing.push(name);
|
||||
else
|
||||
invalid.push(`${name}: ${issue.message}`);
|
||||
}
|
||||
const lines = ['Configuration error in environment / .env:'];
|
||||
if (missing.length) {
|
||||
lines.push(' Missing required variable(s):');
|
||||
for (const n of [...new Set(missing)])
|
||||
lines.push(` - ${n}`);
|
||||
}
|
||||
if (invalid.length) {
|
||||
lines.push(' Invalid value(s):');
|
||||
for (const item of invalid)
|
||||
lines.push(` - ${item}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('Set them in .env (see .env.example) and try again.');
|
||||
process.stderr.write(lines.join('\n') + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
-70
@@ -1,70 +0,0 @@
|
||||
import { VaultGit } from "./git.js";
|
||||
import { GitSyncClient } from "./client.types.js";
|
||||
import { Settings } from "./settings.js";
|
||||
/**
|
||||
* Absolute-path filesystem primitives the cycle needs. Injected (not imported)
|
||||
* so the engine stays IO-free and unit-testable. `mkdir` is recursive; `rm` is
|
||||
* force (a missing file is a no-op).
|
||||
*/
|
||||
export interface CycleFs {
|
||||
readFile: (absPath: string) => Promise<string>;
|
||||
writeFile: (absPath: string, text: string) => Promise<void>;
|
||||
mkdir: (absDir: string) => Promise<void>;
|
||||
rm: (absPath: string) => Promise<void>;
|
||||
}
|
||||
export interface RunCycleDeps {
|
||||
spaceId: string;
|
||||
/** The Docmost seam (reads for pull, writes for push). */
|
||||
client: GitSyncClient;
|
||||
/** The per-space git vault (a real working repo). */
|
||||
vault: VaultGit;
|
||||
/** Engine settings; `vaultPath` roots the relPath -> absolute-path mapping. */
|
||||
settings: Settings;
|
||||
fs: CycleFs;
|
||||
log: (line: string) => void;
|
||||
/**
|
||||
* Delete-cap hook (the ONLY caller-specific policy). Called with the push
|
||||
* dry-run's planned delete count (`Number.POSITIVE_INFINITY` when the dry-run
|
||||
* itself failed, so the hook can fail safe) and the live client; returns the
|
||||
* client to use for the REAL apply. The default (omitted) applies every op
|
||||
* unmodified. gitmost uses it to neutralize deletes when over its cap.
|
||||
*
|
||||
* When omitted, NO dry-run is performed (one fewer push planning pass).
|
||||
*/
|
||||
resolveApplyClient?: (plannedDeletes: number, client: GitSyncClient) => GitSyncClient;
|
||||
}
|
||||
export interface RunCycleResult {
|
||||
ran: boolean;
|
||||
/** Set when the cycle short-circuited without running pull/push. */
|
||||
skipped?: "merge-in-progress";
|
||||
pull?: {
|
||||
written: number;
|
||||
deleted: number;
|
||||
conflict: boolean;
|
||||
};
|
||||
push?: {
|
||||
mode: string;
|
||||
failures: number;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Run ONE full reconcile cycle for a space: PULL (Docmost -> vault) then PUSH
|
||||
* (vault -> Docmost), under the engine's required branch choreography. This is
|
||||
* the single entry point the app drives — it owns the staging order so it can
|
||||
* never drift from the engine it ships with.
|
||||
*
|
||||
* Staging (the ⭐ data-loss-critical order, SPEC §6/§9):
|
||||
* 1. assertGitAvailable + ensureRepo (the git state store must exist).
|
||||
* 2. refuse on an unresolved merge (a prior conflicting pull); next checkout
|
||||
* would fail otherwise.
|
||||
* 3. ensureBranch('docmost','main') + checkout('docmost'). Pull writes MUST
|
||||
* land on `docmost`, not `main`: applyPullActions commits on `docmost`,
|
||||
* then checks out `main` and merges docmost -> main. Writing Docmost
|
||||
* content straight onto `main` would clobber local file edits before push
|
||||
* can diff them.
|
||||
* 4. PULL: readExisting -> listSpaceTree -> computePullActions -> apply.
|
||||
* 5. PUSH: optional dry-run to feed the delete-cap hook, then the real apply.
|
||||
*
|
||||
* Lock + cap POLICY live in the caller; this owns only the mechanics.
|
||||
*/
|
||||
export declare function runCycle(deps: RunCycleDeps): Promise<RunCycleResult>;
|
||||
@@ -1,97 +0,0 @@
|
||||
import { readExisting, computePullActions, applyPullActions } from "./pull.js";
|
||||
import { runPush } from "./push.js";
|
||||
/**
|
||||
* Run ONE full reconcile cycle for a space: PULL (Docmost -> vault) then PUSH
|
||||
* (vault -> Docmost), under the engine's required branch choreography. This is
|
||||
* the single entry point the app drives — it owns the staging order so it can
|
||||
* never drift from the engine it ships with.
|
||||
*
|
||||
* Staging (the ⭐ data-loss-critical order, SPEC §6/§9):
|
||||
* 1. assertGitAvailable + ensureRepo (the git state store must exist).
|
||||
* 2. refuse on an unresolved merge (a prior conflicting pull); next checkout
|
||||
* would fail otherwise.
|
||||
* 3. ensureBranch('docmost','main') + checkout('docmost'). Pull writes MUST
|
||||
* land on `docmost`, not `main`: applyPullActions commits on `docmost`,
|
||||
* then checks out `main` and merges docmost -> main. Writing Docmost
|
||||
* content straight onto `main` would clobber local file edits before push
|
||||
* can diff them.
|
||||
* 4. PULL: readExisting -> listSpaceTree -> computePullActions -> apply.
|
||||
* 5. PUSH: optional dry-run to feed the delete-cap hook, then the real apply.
|
||||
*
|
||||
* Lock + cap POLICY live in the caller; this owns only the mechanics.
|
||||
*/
|
||||
export async function runCycle(deps) {
|
||||
const { spaceId, client, vault, settings, fs, log, resolveApplyClient } = deps;
|
||||
const vaultRoot = settings.vaultPath;
|
||||
const abs = (relPath) => `${vaultRoot}/${relPath}`;
|
||||
// 1. The engine state store is git: make sure the repo + branches exist
|
||||
// before any tracked-file listing or diff.
|
||||
await vault.assertGitAvailable();
|
||||
await vault.ensureRepo();
|
||||
// 2. Refuse to run on top of an unresolved merge (SPEC §9): a prior
|
||||
// conflicting pull leaves the vault mid-merge; the next checkout would fail.
|
||||
if (await vault.isMergeInProgress()) {
|
||||
log(`vault has an unresolved merge — resolve it (or 'git merge --abort') ` +
|
||||
`and re-run (SPEC §9); skipping cycle.`);
|
||||
return { ran: false, skipped: "merge-in-progress" };
|
||||
}
|
||||
// 3. Pull writes happen on `docmost`; be on it BEFORE applying (see docstring).
|
||||
await vault.ensureBranch("docmost", "main");
|
||||
await vault.checkout("docmost");
|
||||
// 4. PULL --------------------------------------------------------------------
|
||||
const existing = await readExisting({
|
||||
listTracked: () => vault.listTrackedFiles("*.md"),
|
||||
readFile: (relPath) => fs.readFile(abs(relPath)),
|
||||
});
|
||||
const tree = await client.listSpaceTree(spaceId);
|
||||
const pullActions = computePullActions({
|
||||
pages: tree.pages,
|
||||
treeComplete: tree.complete,
|
||||
existing,
|
||||
});
|
||||
const pullResult = await applyPullActions({
|
||||
client,
|
||||
git: vault,
|
||||
writeFile: (absPath, text) => fs.writeFile(absPath, text),
|
||||
mkdir: (absDir) => fs.mkdir(absDir),
|
||||
rm: (absPath) => fs.rm(absPath),
|
||||
}, pullActions, vaultRoot);
|
||||
// 5. PUSH --------------------------------------------------------------------
|
||||
const pushDeps = {
|
||||
settings,
|
||||
git: vault,
|
||||
makeClient: () => client,
|
||||
readFile: (relPath) => fs.readFile(abs(relPath)),
|
||||
writeFile: (relPath, text) => fs.writeFile(abs(relPath), text),
|
||||
log,
|
||||
};
|
||||
let applyClient = client;
|
||||
if (resolveApplyClient) {
|
||||
// Plan the push as a DRY-RUN first to read the delete count, then let the
|
||||
// caller decide the apply client (e.g. neutralize deletes over a cap). A
|
||||
// failed dry-run yields Infinity so the hook can fail safe.
|
||||
let plannedDeletes;
|
||||
try {
|
||||
const dry = await runPush(pushDeps, { dryRun: true });
|
||||
plannedDeletes = dry.planned?.deletes ?? 0;
|
||||
}
|
||||
catch (err) {
|
||||
log(`push dry-run planning failed (${err instanceof Error ? err.message : String(err)}); deferring deletion policy to the cap hook (fail-safe).`);
|
||||
plannedDeletes = Number.POSITIVE_INFINITY;
|
||||
}
|
||||
applyClient = resolveApplyClient(plannedDeletes, client);
|
||||
}
|
||||
const pushResult = await runPush({ ...pushDeps, makeClient: () => applyClient }, { dryRun: false });
|
||||
return {
|
||||
ran: true,
|
||||
pull: {
|
||||
written: pullResult.written,
|
||||
deleted: pullResult.deleted,
|
||||
conflict: pullResult.merge.conflict,
|
||||
},
|
||||
push: {
|
||||
mode: pushResult.mode,
|
||||
failures: pushResult.failures?.length ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
-259
@@ -1,259 +0,0 @@
|
||||
/** Bot identity used for engine-authored vault commits (SPEC §7.3). */
|
||||
export declare const BOT_AUTHOR_NAME = "Docmost Sync";
|
||||
export declare const BOT_AUTHOR_EMAIL = "docmost-sync@local";
|
||||
/** Default branch the vault repo is initialized on. */
|
||||
export declare const DEFAULT_BRANCH = "main";
|
||||
/**
|
||||
* One row of `git diff --name-status` (SPEC §6 "ФС → Docmost"). `status` is the
|
||||
* single-letter change code (`-M` rename detection on), `path` is the (new) file
|
||||
* path; for a rename/copy (`R`/`C`) `oldPath` is the source and `path` is the
|
||||
* destination, with `score` carrying git's similarity index (0–100).
|
||||
*/
|
||||
export interface DiffEntry {
|
||||
status: "A" | "M" | "D" | "R" | "C";
|
||||
/** New (destination) path. For A/M/D it is the only path. */
|
||||
path: string;
|
||||
/** Source path — present only for R/C. */
|
||||
oldPath?: string;
|
||||
/** Rename/copy similarity score (0–100) — present only for R/C. */
|
||||
score?: number;
|
||||
}
|
||||
/** Result of a `merge`: whether it succeeded cleanly or left conflict markers. */
|
||||
export interface MergeResult {
|
||||
/** True when the merge applied cleanly (fast-forward or clean 3-way). */
|
||||
ok: boolean;
|
||||
/** True when the merge stopped on conflicts (markers left in the worktree). */
|
||||
conflict: boolean;
|
||||
/** Raw combined stdout+stderr, for logging/diagnostics. */
|
||||
output: string;
|
||||
}
|
||||
/** Options for an engine-authored commit (provenance, SPEC §7.3). */
|
||||
export interface CommitOptions {
|
||||
authorName: string;
|
||||
authorEmail: string;
|
||||
/**
|
||||
* Trailer lines appended to the commit message body (e.g.
|
||||
* `Docmost-Sync-Source: docmost`). These are the machine-readable provenance
|
||||
* the loop-guard keys on (SPEC §12, "commit-attribution").
|
||||
*/
|
||||
trailers?: string[];
|
||||
}
|
||||
/**
|
||||
* A git wrapper bound to a single vault path. Construct once per vault; every
|
||||
* method runs git with `cwd = vaultPath`.
|
||||
*/
|
||||
export declare class VaultGit {
|
||||
private readonly vaultPath;
|
||||
constructor(vaultPath: string);
|
||||
/**
|
||||
* Preflight: verify a runnable `git` binary is on PATH. The daemon shells out
|
||||
* to system `git` for every vault operation, so a missing binary (e.g. a slim
|
||||
* container image without git) must fail fast with an actionable message
|
||||
* rather than a cryptic ENOENT deep inside the first real git call. Presence
|
||||
* check only — we do NOT gate on a specific version. Runs `git --version`
|
||||
* with NO `cwd` (the vault dir may not exist yet at preflight time).
|
||||
*/
|
||||
assertGitAvailable(): Promise<void>;
|
||||
/**
|
||||
* Run a git command in the vault and return trimmed stdout. THIN wrapper over
|
||||
* the single `runRaw` primitive: throws a clear, unified Error (including
|
||||
* stderr/stdout) on a non-zero exit.
|
||||
*/
|
||||
private run;
|
||||
/**
|
||||
* The ONE primitive every git invocation in this module flows through. Builds
|
||||
* the full argv (`--no-pager -c core.quotepath=false <args>`), env, cwd, and
|
||||
* maxBuffer, runs git, and NEVER throws — it returns the exit info so callers
|
||||
* can treat a non-zero exit as either an error (`run`) or a meaningful state
|
||||
* (e.g. a merge conflict, a porcelain diff that "fails" deliberately).
|
||||
*
|
||||
* - argv: ALWAYS prepends `--no-pager -c core.quotepath=false`, so git never
|
||||
* blocks on a pager and always prints verbatim UTF-8 paths (no octal
|
||||
* escaping/quoting). `quotepath=false` is the baseline for ALL path-
|
||||
* printing commands (ls-files, diff --name-only, …).
|
||||
* - cwd: `opts.cwd === null` -> do NOT set cwd (the preflight, where the
|
||||
* vault dir may not exist); otherwise `opts.cwd ?? this.vaultPath`.
|
||||
* - env: `vaultGitEnv(opts?.env)` (cwd-isolation + caller extras).
|
||||
* - On a spawn/exec error we capture the error `message` too, so a failure
|
||||
* before git could write to stderr (e.g. ENOENT) is NOT lost.
|
||||
*/
|
||||
private runRaw;
|
||||
/**
|
||||
* Ensure the vault directory exists and is an initialized git repo on `main`
|
||||
* with an initial (empty) commit so branches exist. Idempotent: safe to call
|
||||
* on every run. Sets a LOCAL bot identity for the vault repo if none is set
|
||||
* (so engine commits never fall back to a global/unset identity).
|
||||
*/
|
||||
ensureRepo(): Promise<void>;
|
||||
/** True if `cwd` is inside a git work-tree (the vault is initialized). */
|
||||
private isRepo;
|
||||
/** True if a LOCAL git config key is set in the vault repo. */
|
||||
private hasLocalConfig;
|
||||
/** True if the repo has at least one commit (HEAD resolves). */
|
||||
private hasAnyCommit;
|
||||
/** True if a branch with the given name exists. */
|
||||
branchExists(name: string): Promise<boolean>;
|
||||
/**
|
||||
* Create `name` from `fromBranch` if it does not already exist. No-op (and no
|
||||
* checkout) when the branch is already present.
|
||||
*/
|
||||
ensureBranch(name: string, fromBranch: string): Promise<void>;
|
||||
/** Name of the currently checked-out branch. */
|
||||
currentBranch(): Promise<string>;
|
||||
/** Check out an existing branch. */
|
||||
checkout(name: string): Promise<void>;
|
||||
/** Stage everything (adds, modifications, deletions). */
|
||||
stageAll(): Promise<void>;
|
||||
/**
|
||||
* True if the vault is mid-merge (an unresolved merge from a previous run,
|
||||
* SPEC §9 / §12). Detected via a `MERGE_HEAD` ref OR any unmerged
|
||||
* (conflicted) index entries (`git ls-files -u`). The pull cycle checks this
|
||||
* BEFORE any checkout so a left-over merge produces a clear, actionable
|
||||
* message instead of a raw "you need to resolve your current index first"
|
||||
* failure deep inside `checkout`. This is what makes re-runs converge
|
||||
* (resumability, SPEC §12).
|
||||
*/
|
||||
isMergeInProgress(): Promise<boolean>;
|
||||
/**
|
||||
* Commit the currently STAGED changes with an explicit author/committer
|
||||
* identity and the given trailers appended to the message body (SPEC §7.3
|
||||
* provenance). Returns `true` if a commit was made, `false` if there was
|
||||
* nothing to commit (graceful no-op). The caller is expected to have staged
|
||||
* its changes first (e.g. via `stageAll`).
|
||||
*/
|
||||
commit(message: string, opts: CommitOptions): Promise<boolean>;
|
||||
/**
|
||||
* Low-level commit used by both `commit` and `ensureRepo`'s initial commit.
|
||||
* Builds the full message with appended trailers and sets author + committer
|
||||
* identity via env vars (so the committer matches the author, not the repo
|
||||
* default).
|
||||
*/
|
||||
private commitRaw;
|
||||
/**
|
||||
* Merge `fromBranch` into the current branch (`git merge --no-edit`).
|
||||
* Fast-forwards when possible; performs a real 3-way merge otherwise. Conflict
|
||||
* state is SURFACED (returned), NOT auto-resolved (SPEC §9): the conflict
|
||||
* markers are left in the worktree for manual resolution by a later increment,
|
||||
* and — critically — nothing is pushed to Docmost (we never write to Docmost
|
||||
* anyway).
|
||||
*/
|
||||
merge(fromBranch: string): Promise<MergeResult>;
|
||||
/** True if the index has any unmerged (conflicted) paths. */
|
||||
private hasUnmergedPaths;
|
||||
/**
|
||||
* List tracked files on the current branch (paths relative to the vault
|
||||
* root, forward-slash separated). An optional glob (a git pathspec) narrows
|
||||
* the listing, e.g. `"*.md"`.
|
||||
*
|
||||
* The target wiki is RUSSIAN, so vault file names routinely contain Cyrillic
|
||||
* (e.g. `Колонка.md`). With git's DEFAULT `core.quotepath=true`, `ls-files`
|
||||
* returns non-ASCII paths octal-escaped and double-quoted (`"\320\232..."`),
|
||||
* which `src/pull.ts` `readExisting` would then parse as garbage paths,
|
||||
* breaking move/duplicate detection. We defeat that two ways at once:
|
||||
* - `core.quotepath=false` disables the octal-escape/quoting. It is now the
|
||||
* `runRaw` argv baseline (prepended to EVERY invocation), so we no longer
|
||||
* pass it inline here.
|
||||
* - `-z` emits NUL-delimited RAW UTF-8 paths (no quoting, no newline
|
||||
* ambiguity), which we split on `\0`.
|
||||
* We read the RAW stdout (NOT the trimming `run()` helper, which would mangle
|
||||
* the NUL-delimited bytes) and split on `\0`, dropping empty entries. Paths
|
||||
* are returned verbatim — git already emits forward slashes.
|
||||
*/
|
||||
listTrackedFiles(glob?: string): Promise<string[]>;
|
||||
/**
|
||||
* Diff two refs with `--name-status -M -z` and parse the NUL-delimited output
|
||||
* (SPEC §6: the FS→Docmost push direction diffs `main` against
|
||||
* `refs/docmost/last-pushed`). Rename detection is ON (`-M`), so a moved/renamed
|
||||
* file is reported as a single `R` row with both its old and new path instead
|
||||
* of a delete+add pair — that distinction is what lets the push planner tell a
|
||||
* move from a delete+create (SPEC §8 "Move vs delete").
|
||||
*
|
||||
* `-z` makes git emit NUL-delimited RAW UTF-8 records (the Russian wiki has
|
||||
* Cyrillic file names) with NO quoting/escaping. The record shape differs by
|
||||
* status:
|
||||
* - A/M/D: `status\0path\0`
|
||||
* - R/C: `Rnnn\0oldPath\0newPath\0` (nnn = similarity score, e.g. `R100`)
|
||||
* We read the RAW stdout (not the trimming `run()` helper, which would mangle
|
||||
* the NUL bytes), split on `\0`, drop the trailing empty entry, and walk the
|
||||
* tokens pulling 1 or 2 path tokens per status. Paths are returned verbatim.
|
||||
*/
|
||||
diffNameStatus(fromRef: string, toRef: string): Promise<DiffEntry[]>;
|
||||
/**
|
||||
* Resolve a ref/commit-ish to its full SHA, or `null` if it does not exist.
|
||||
* `rev-parse --verify --quiet` exits non-zero (and prints nothing) for an
|
||||
* unknown ref, so a non-zero exit maps cleanly to `null`. Used to read
|
||||
* `refs/docmost/last-pushed` (SPEC §5) — which is absent before the first push.
|
||||
*/
|
||||
revParse(ref: string): Promise<string | null>;
|
||||
/**
|
||||
* Read a ref to its SHA, or `null` if unset. Thin alias over `revParse`,
|
||||
* named for the push direction's marker `refs/docmost/last-pushed` (SPEC §5:
|
||||
* "что из `main` уже отражено в Docmost").
|
||||
*/
|
||||
readRef(ref: string): Promise<string | null>;
|
||||
/**
|
||||
* Point `ref` at `target` (`git update-ref <ref> <target>`). Used to advance
|
||||
* `refs/docmost/last-pushed` to the just-pushed `main` commit after a push
|
||||
* (SPEC §6 step 3 / §5). `target` may be a SHA or any commit-ish git accepts.
|
||||
*/
|
||||
updateRef(ref: string, target: string): Promise<void>;
|
||||
/**
|
||||
* Fast-forward `branch` to `toCommit` — but ONLY if it is a TRUE fast-forward,
|
||||
* i.e. the current `branch` tip is an ancestor of `toCommit` (verified via
|
||||
* `git merge-base --is-ancestor <branch> <toCommit>`). Used to advance the
|
||||
* `docmost` mirror branch after a clean push (SPEC §6 step 3 / §10): once a
|
||||
* push succeeds, Docmost already contains the pushed `main` content, so the
|
||||
* mirror must reflect it — otherwise the NEXT pull would diff our own write
|
||||
* back and re-pull it (loop-guard).
|
||||
*
|
||||
* SAFETY — never force, never clobber divergent history:
|
||||
* - If `branch` IS an ancestor of `toCommit`, advance it with
|
||||
* `git update-ref refs/heads/<branch> <toCommit>`. The `docmost` branch is
|
||||
* NOT checked out during a push (push works on `main`), so updating the ref
|
||||
* directly is safe and avoids any working-tree touch.
|
||||
* - If `branch` is NOT an ancestor (divergent / would-be non-fast-forward),
|
||||
* do NOT move it — return `{ ok: false, reason: 'not-fast-forward' }` and
|
||||
* let the caller log it. We must never overwrite a `docmost` history that
|
||||
* has commits the push base does not contain.
|
||||
*
|
||||
* Returns `{ ok: true }` when the branch was advanced (or already at
|
||||
* `toCommit`, a degenerate fast-forward), `{ ok: false, reason }` otherwise.
|
||||
* A missing `branch` or `toCommit` also yields `{ ok: false }` with a reason.
|
||||
*/
|
||||
fastForwardBranch(branch: string, toCommit: string): Promise<{
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
}>;
|
||||
/**
|
||||
* Read a file's content at a specific ref (`git show <ref>:<path>`), or `null`
|
||||
* if the path does not exist there. Used by the push direction to read the
|
||||
* PRE-IMAGE of a DELETED file (e.g. at `refs/docmost/last-pushed`) so its
|
||||
* `docmost:meta` — and therefore its `pageId` — can be recovered to translate
|
||||
* the deletion into a `delete_page` (SPEC §6/§8: only TRACKED files, i.e. ones
|
||||
* that had a pageId, are deleted in Docmost). A non-zero exit (path absent at
|
||||
* that ref) maps to `null` rather than throwing.
|
||||
*/
|
||||
showFileAtRef(ref: string, path: string): Promise<string | null>;
|
||||
}
|
||||
/**
|
||||
* Build the environment for a vault git invocation (SPEC §12 cwd-isolation).
|
||||
* Used by the single `runRaw` primitive every git command flows through, so
|
||||
* these pins apply uniformly (including the `git --version` preflight).
|
||||
*
|
||||
* cwd-isolation is this module's central safety guarantee: every git command
|
||||
* MUST operate on the vault repo at `cwd: vaultPath` and nothing else. An
|
||||
* inherited `GIT_DIR` / `GIT_WORK_TREE` in `process.env` would silently
|
||||
* redirect the operation away from `cwd` (e.g. to the source repo or another
|
||||
* checkout), defeating that guarantee. So we always strip them, regardless of
|
||||
* whatever else the caller adds (author/committer identity, etc.).
|
||||
*
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export declare function vaultGitEnv(extra?: Record<string, string>): NodeJS.ProcessEnv;
|
||||
/**
|
||||
* Build a commit message body with trailer lines appended (SPEC §7.3). The
|
||||
* trailers are separated from the subject by a blank line so `git interpret-
|
||||
* trailers` / `git log --format=%(trailers)` parse them as trailers.
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export declare function buildCommitMessage(subject: string, trailers?: string[]): string;
|
||||
@@ -1,570 +0,0 @@
|
||||
/**
|
||||
* Thin async wrapper over the system `git` binary (SPEC §5: state store = git).
|
||||
*
|
||||
* IMPORTANT — VAULT-SCOPED: every operation here runs with `cwd = vaultPath`,
|
||||
* which is the vault's OWN git repository (default `data/vault`), SEPARATE from
|
||||
* the gitmost application repo. This module MUST NEVER run git against the
|
||||
* application repo. `data/` is gitignored, so a nested repo under `data/vault`
|
||||
* is safe. The pull cycle is READ-ONLY toward Docmost; this module only touches
|
||||
* the local vault git, never a git remote (push is deferred, see SPEC §7).
|
||||
*
|
||||
* Implementation notes:
|
||||
* - We shell out via `node:child_process` `execFile` (promisified), passing
|
||||
* ARGS AS AN ARRAY — no shell, so there is no command injection surface even
|
||||
* if a page title / branch name contains shell metacharacters.
|
||||
* - EVERY git invocation funnels through the single `runRaw` primitive, which
|
||||
* ALWAYS prepends `--no-pager -c core.quotepath=false` to the argv (so git
|
||||
* never blocks on a pager and always prints verbatim UTF-8 paths). There is
|
||||
* no exception — even the `git --version` preflight goes through `runRaw`.
|
||||
* - "nothing to commit" is treated as a graceful no-op, not an error.
|
||||
*/
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
const execFileAsync = promisify(execFile);
|
||||
/** Bot identity used for engine-authored vault commits (SPEC §7.3). */
|
||||
export const BOT_AUTHOR_NAME = "Docmost Sync";
|
||||
export const BOT_AUTHOR_EMAIL = "docmost-sync@local";
|
||||
/** Default branch the vault repo is initialized on. */
|
||||
export const DEFAULT_BRANCH = "main";
|
||||
/**
|
||||
* A git wrapper bound to a single vault path. Construct once per vault; every
|
||||
* method runs git with `cwd = vaultPath`.
|
||||
*/
|
||||
export class VaultGit {
|
||||
vaultPath;
|
||||
constructor(vaultPath) {
|
||||
this.vaultPath = vaultPath;
|
||||
}
|
||||
/**
|
||||
* Preflight: verify a runnable `git` binary is on PATH. The daemon shells out
|
||||
* to system `git` for every vault operation, so a missing binary (e.g. a slim
|
||||
* container image without git) must fail fast with an actionable message
|
||||
* rather than a cryptic ENOENT deep inside the first real git call. Presence
|
||||
* check only — we do NOT gate on a specific version. Runs `git --version`
|
||||
* with NO `cwd` (the vault dir may not exist yet at preflight time).
|
||||
*/
|
||||
async assertGitAvailable() {
|
||||
// Goes through the single `runRaw` primitive like every other invocation.
|
||||
// `cwd: null` means "do not set a cwd" — the vault dir may not exist yet at
|
||||
// preflight time, so we must not point git at a missing directory.
|
||||
const r = await this.runRaw(["--version"], { cwd: null });
|
||||
if (r.code !== 0) {
|
||||
const detail = (r.stderr || r.stdout || "").trim();
|
||||
throw new Error("git binary not found or not runnable — install git (the vault state " +
|
||||
`store requires it). Underlying error: ${detail}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Run a git command in the vault and return trimmed stdout. THIN wrapper over
|
||||
* the single `runRaw` primitive: throws a clear, unified Error (including
|
||||
* stderr/stdout) on a non-zero exit.
|
||||
*/
|
||||
async run(args, opts) {
|
||||
const r = await this.runRaw(args, opts);
|
||||
if (r.code !== 0) {
|
||||
const detail = (r.stderr || r.stdout || "").trim();
|
||||
throw new Error(`git ${args.join(" ")} failed: ${detail}`);
|
||||
}
|
||||
return r.stdout.trim();
|
||||
}
|
||||
/**
|
||||
* The ONE primitive every git invocation in this module flows through. Builds
|
||||
* the full argv (`--no-pager -c core.quotepath=false <args>`), env, cwd, and
|
||||
* maxBuffer, runs git, and NEVER throws — it returns the exit info so callers
|
||||
* can treat a non-zero exit as either an error (`run`) or a meaningful state
|
||||
* (e.g. a merge conflict, a porcelain diff that "fails" deliberately).
|
||||
*
|
||||
* - argv: ALWAYS prepends `--no-pager -c core.quotepath=false`, so git never
|
||||
* blocks on a pager and always prints verbatim UTF-8 paths (no octal
|
||||
* escaping/quoting). `quotepath=false` is the baseline for ALL path-
|
||||
* printing commands (ls-files, diff --name-only, …).
|
||||
* - cwd: `opts.cwd === null` -> do NOT set cwd (the preflight, where the
|
||||
* vault dir may not exist); otherwise `opts.cwd ?? this.vaultPath`.
|
||||
* - env: `vaultGitEnv(opts?.env)` (cwd-isolation + caller extras).
|
||||
* - On a spawn/exec error we capture the error `message` too, so a failure
|
||||
* before git could write to stderr (e.g. ENOENT) is NOT lost.
|
||||
*/
|
||||
async runRaw(args, opts) {
|
||||
const cwd = opts?.cwd === null ? undefined : (opts?.cwd ?? this.vaultPath);
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync("git", ["--no-pager", "-c", "core.quotepath=false", ...args], {
|
||||
// Generous buffer: file listings / porcelain output on a large vault
|
||||
// can be sizable.
|
||||
...(cwd !== undefined ? { cwd } : {}),
|
||||
maxBuffer: 64 * 1024 * 1024,
|
||||
env: vaultGitEnv(opts?.env),
|
||||
});
|
||||
return { code: 0, stdout, stderr };
|
||||
}
|
||||
catch (err) {
|
||||
const e = err;
|
||||
return {
|
||||
code: typeof e.code === "number" ? e.code : 1,
|
||||
stdout: e.stdout ?? "",
|
||||
// Preserve the error message when there is no stderr (e.g. a spawn
|
||||
// failure like ENOENT, where promisified execFile sets stderr to an
|
||||
// EMPTY STRING — so `||`, not `??`, to fall through to `message`).
|
||||
stderr: e.stderr || e.message || "",
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Ensure the vault directory exists and is an initialized git repo on `main`
|
||||
* with an initial (empty) commit so branches exist. Idempotent: safe to call
|
||||
* on every run. Sets a LOCAL bot identity for the vault repo if none is set
|
||||
* (so engine commits never fall back to a global/unset identity).
|
||||
*/
|
||||
async ensureRepo() {
|
||||
await mkdir(this.vaultPath, { recursive: true });
|
||||
if (!(await this.isRepo())) {
|
||||
// `git init -b main` sets the initial branch on modern git; we still
|
||||
// guard the branch name below for safety on older binaries.
|
||||
await this.run(["init", "-b", DEFAULT_BRANCH]);
|
||||
}
|
||||
// Set a local identity for the vault repo if unset, so engine commits have
|
||||
// a deterministic committer even on a machine with no global git config.
|
||||
if (!(await this.hasLocalConfig("user.name"))) {
|
||||
await this.run(["config", "user.name", BOT_AUTHOR_NAME]);
|
||||
}
|
||||
if (!(await this.hasLocalConfig("user.email"))) {
|
||||
await this.run(["config", "user.email", BOT_AUTHOR_EMAIL]);
|
||||
}
|
||||
// Neutralize correctness-affecting git config in the vault's LOCAL config so
|
||||
// a user's GLOBAL/system config cannot change porcelain BEHAVIOR (not just
|
||||
// output) and corrupt the vault. The vault is OUR dedicated repo, so LOCAL
|
||||
// values (which override global/system) are the right scope. Set
|
||||
// UNCONDITIONALLY every run — idempotent and cheap; `git config <key>`
|
||||
// writes to `--local` by default inside the repo. These MUST be in place
|
||||
// before any add/commit/checkout that could be affected, hence they run
|
||||
// before the initial-commit block below.
|
||||
// - core.autocrlf=false — CRITICAL (SPEC §11): a global core.autocrlf=true
|
||||
// would rewrite LF<->CRLF on add/checkout, making our deterministic,
|
||||
// byte-stable markdown churn and breaking the round-trip invariant.
|
||||
// `false` guarantees git stores/checks out verbatim bytes.
|
||||
// - core.safecrlf=false — avoid CRLF-related warnings/aborts on add.
|
||||
// - commit.gpgsign=false — the headless daemon must never try to GPG-sign
|
||||
// a commit (would fail/hang; we already set GIT_TERMINAL_PROMPT=0).
|
||||
// - core.attributesFile=/dev/null — neutralize the user's GLOBAL
|
||||
// gitattributes so a global clean/smudge filter (filter.<name>.clean)
|
||||
// cannot rewrite the STORED blob and break §11 byte-stability (a config
|
||||
// that core.autocrlf=false does not cover). POSIX-only path, which is
|
||||
// fine: the daemon runs on Linux (Docker) / macOS. A system
|
||||
// /etc/gitattributes remains the host admin's domain (out of scope).
|
||||
// NOTE: these stay PERSISTED LOCAL config (not `-c` flags) on purpose — a
|
||||
// human running git by hand in the vault must inherit the same neutralized
|
||||
// behavior; a transient `-c` would not persist. (core.quotepath, by
|
||||
// contrast, only affects OUR parsing of output and so is baked into the
|
||||
// `runRaw` argv baseline instead.)
|
||||
try {
|
||||
await this.run(["config", "core.autocrlf", "false"]);
|
||||
await this.run(["config", "core.safecrlf", "false"]);
|
||||
await this.run(["config", "commit.gpgsign", "false"]);
|
||||
await this.run(["config", "core.attributesFile", "/dev/null"]);
|
||||
}
|
||||
catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
throw new Error(`failed to pin vault git config (SPEC §11) — ensure ${this.vaultPath}` +
|
||||
"/.git/config is writable and not locked (e.g. stale config.lock): " +
|
||||
detail);
|
||||
}
|
||||
// Create the initial empty commit on `main` if the repo has no commits yet,
|
||||
// so both `main` and (later) `docmost` branches have a common base.
|
||||
if (!(await this.hasAnyCommit())) {
|
||||
// Make sure we are on the default branch before the first commit (covers
|
||||
// the older-git case where `init -b` was not honored).
|
||||
await this.run(["checkout", "-B", DEFAULT_BRANCH]);
|
||||
await this.commitRaw("init vault", {
|
||||
authorName: BOT_AUTHOR_NAME,
|
||||
authorEmail: BOT_AUTHOR_EMAIL,
|
||||
allowEmpty: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
/** True if `cwd` is inside a git work-tree (the vault is initialized). */
|
||||
async isRepo() {
|
||||
const r = await this.runRaw(["rev-parse", "--is-inside-work-tree"]);
|
||||
return r.code === 0 && r.stdout.trim() === "true";
|
||||
}
|
||||
/** True if a LOCAL git config key is set in the vault repo. */
|
||||
async hasLocalConfig(key) {
|
||||
const r = await this.runRaw(["config", "--local", "--get", key]);
|
||||
return r.code === 0 && r.stdout.trim().length > 0;
|
||||
}
|
||||
/** True if the repo has at least one commit (HEAD resolves). */
|
||||
async hasAnyCommit() {
|
||||
const r = await this.runRaw(["rev-parse", "--verify", "HEAD"]);
|
||||
return r.code === 0;
|
||||
}
|
||||
/** True if a branch with the given name exists. */
|
||||
async branchExists(name) {
|
||||
const r = await this.runRaw([
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
`refs/heads/${name}`,
|
||||
]);
|
||||
return r.code === 0;
|
||||
}
|
||||
/**
|
||||
* Create `name` from `fromBranch` if it does not already exist. No-op (and no
|
||||
* checkout) when the branch is already present.
|
||||
*/
|
||||
async ensureBranch(name, fromBranch) {
|
||||
if (await this.branchExists(name))
|
||||
return;
|
||||
await this.run(["branch", name, fromBranch]);
|
||||
}
|
||||
/** Name of the currently checked-out branch. */
|
||||
async currentBranch() {
|
||||
return this.run(["rev-parse", "--abbrev-ref", "HEAD"]);
|
||||
}
|
||||
/** Check out an existing branch. */
|
||||
async checkout(name) {
|
||||
await this.run(["checkout", name]);
|
||||
}
|
||||
/** Stage everything (adds, modifications, deletions). */
|
||||
async stageAll() {
|
||||
await this.run(["add", "-A"]);
|
||||
}
|
||||
/**
|
||||
* True if the vault is mid-merge (an unresolved merge from a previous run,
|
||||
* SPEC §9 / §12). Detected via a `MERGE_HEAD` ref OR any unmerged
|
||||
* (conflicted) index entries (`git ls-files -u`). The pull cycle checks this
|
||||
* BEFORE any checkout so a left-over merge produces a clear, actionable
|
||||
* message instead of a raw "you need to resolve your current index first"
|
||||
* failure deep inside `checkout`. This is what makes re-runs converge
|
||||
* (resumability, SPEC §12).
|
||||
*/
|
||||
async isMergeInProgress() {
|
||||
// MERGE_HEAD exists exactly while a merge is in progress.
|
||||
const mergeHead = await this.runRaw([
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
"--quiet",
|
||||
"MERGE_HEAD",
|
||||
]);
|
||||
if (mergeHead.code === 0 && mergeHead.stdout.trim().length > 0)
|
||||
return true;
|
||||
// Fallback / belt-and-suspenders: any unmerged index entries also mean the
|
||||
// working tree is mid-conflict and a checkout would refuse.
|
||||
const unmerged = await this.runRaw(["ls-files", "-u"]);
|
||||
return unmerged.code === 0 && unmerged.stdout.trim().length > 0;
|
||||
}
|
||||
/**
|
||||
* Commit the currently STAGED changes with an explicit author/committer
|
||||
* identity and the given trailers appended to the message body (SPEC §7.3
|
||||
* provenance). Returns `true` if a commit was made, `false` if there was
|
||||
* nothing to commit (graceful no-op). The caller is expected to have staged
|
||||
* its changes first (e.g. via `stageAll`).
|
||||
*/
|
||||
async commit(message, opts) {
|
||||
// Nothing staged -> nothing to commit. Treat as a no-op (SPEC §11: a
|
||||
// deterministic re-pull of unchanged pages produces identical bytes, so
|
||||
// git sees no diff and we must not error).
|
||||
const staged = await this.runRaw([
|
||||
"diff",
|
||||
"--cached",
|
||||
"--quiet",
|
||||
]);
|
||||
// `diff --cached --quiet` exits 0 when the index matches HEAD (nothing
|
||||
// staged), 1 when there are staged changes.
|
||||
if (staged.code === 0)
|
||||
return false;
|
||||
await this.commitRaw(message, opts);
|
||||
return true;
|
||||
}
|
||||
/**
|
||||
* Low-level commit used by both `commit` and `ensureRepo`'s initial commit.
|
||||
* Builds the full message with appended trailers and sets author + committer
|
||||
* identity via env vars (so the committer matches the author, not the repo
|
||||
* default).
|
||||
*/
|
||||
async commitRaw(message, opts) {
|
||||
const fullMessage = buildCommitMessage(message, opts.trailers);
|
||||
// `--no-verify` skips pre-commit/commit-msg hooks: a global core.hooksPath
|
||||
// (or any injected hook) must never interfere with engine commits in our
|
||||
// dedicated vault repo.
|
||||
const args = ["commit", "--no-verify", "-m", fullMessage];
|
||||
if (opts.allowEmpty)
|
||||
args.push("--allow-empty");
|
||||
// Route through the single `runRaw` primitive; set author + committer
|
||||
// identity via env vars (so the committer matches the author, not the repo
|
||||
// default). Throw via the same unified message on a non-zero exit.
|
||||
const r = await this.runRaw(args, {
|
||||
env: {
|
||||
GIT_AUTHOR_NAME: opts.authorName,
|
||||
GIT_AUTHOR_EMAIL: opts.authorEmail,
|
||||
GIT_COMMITTER_NAME: opts.authorName,
|
||||
GIT_COMMITTER_EMAIL: opts.authorEmail,
|
||||
},
|
||||
});
|
||||
if (r.code !== 0) {
|
||||
const detail = (r.stderr || r.stdout || "").trim();
|
||||
throw new Error(`git ${args.join(" ")} failed: ${detail}`);
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Merge `fromBranch` into the current branch (`git merge --no-edit`).
|
||||
* Fast-forwards when possible; performs a real 3-way merge otherwise. Conflict
|
||||
* state is SURFACED (returned), NOT auto-resolved (SPEC §9): the conflict
|
||||
* markers are left in the worktree for manual resolution by a later increment,
|
||||
* and — critically — nothing is pushed to Docmost (we never write to Docmost
|
||||
* anyway).
|
||||
*/
|
||||
async merge(fromBranch) {
|
||||
const r = await this.runRaw(["merge", "--no-edit", fromBranch]);
|
||||
const output = `${r.stdout}\n${r.stderr}`.trim();
|
||||
if (r.code === 0) {
|
||||
return { ok: true, conflict: false, output };
|
||||
}
|
||||
// A non-zero exit on merge most commonly means a conflict. Confirm by
|
||||
// checking for unmerged paths (porcelain "U" status) so we don't mislabel
|
||||
// an unrelated failure as a conflict.
|
||||
const conflict = await this.hasUnmergedPaths();
|
||||
return { ok: false, conflict, output };
|
||||
}
|
||||
/** True if the index has any unmerged (conflicted) paths. */
|
||||
async hasUnmergedPaths() {
|
||||
const r = await this.runRaw(["diff", "--name-only", "--diff-filter=U"]);
|
||||
return r.code === 0 && r.stdout.trim().length > 0;
|
||||
}
|
||||
/**
|
||||
* List tracked files on the current branch (paths relative to the vault
|
||||
* root, forward-slash separated). An optional glob (a git pathspec) narrows
|
||||
* the listing, e.g. `"*.md"`.
|
||||
*
|
||||
* The target wiki is RUSSIAN, so vault file names routinely contain Cyrillic
|
||||
* (e.g. `Колонка.md`). With git's DEFAULT `core.quotepath=true`, `ls-files`
|
||||
* returns non-ASCII paths octal-escaped and double-quoted (`"\320\232..."`),
|
||||
* which `src/pull.ts` `readExisting` would then parse as garbage paths,
|
||||
* breaking move/duplicate detection. We defeat that two ways at once:
|
||||
* - `core.quotepath=false` disables the octal-escape/quoting. It is now the
|
||||
* `runRaw` argv baseline (prepended to EVERY invocation), so we no longer
|
||||
* pass it inline here.
|
||||
* - `-z` emits NUL-delimited RAW UTF-8 paths (no quoting, no newline
|
||||
* ambiguity), which we split on `\0`.
|
||||
* We read the RAW stdout (NOT the trimming `run()` helper, which would mangle
|
||||
* the NUL-delimited bytes) and split on `\0`, dropping empty entries. Paths
|
||||
* are returned verbatim — git already emits forward slashes.
|
||||
*/
|
||||
async listTrackedFiles(glob) {
|
||||
const r = await this.runRaw(["ls-files", "-z", ...(glob ? [glob] : [])]);
|
||||
if (r.code !== 0) {
|
||||
const detail = (r.stderr || r.stdout || "").trim();
|
||||
throw new Error(`git ls-files failed: ${detail}`);
|
||||
}
|
||||
return r.stdout.split("\0").filter((p) => p.length > 0);
|
||||
}
|
||||
/**
|
||||
* Diff two refs with `--name-status -M -z` and parse the NUL-delimited output
|
||||
* (SPEC §6: the FS→Docmost push direction diffs `main` against
|
||||
* `refs/docmost/last-pushed`). Rename detection is ON (`-M`), so a moved/renamed
|
||||
* file is reported as a single `R` row with both its old and new path instead
|
||||
* of a delete+add pair — that distinction is what lets the push planner tell a
|
||||
* move from a delete+create (SPEC §8 "Move vs delete").
|
||||
*
|
||||
* `-z` makes git emit NUL-delimited RAW UTF-8 records (the Russian wiki has
|
||||
* Cyrillic file names) with NO quoting/escaping. The record shape differs by
|
||||
* status:
|
||||
* - A/M/D: `status\0path\0`
|
||||
* - R/C: `Rnnn\0oldPath\0newPath\0` (nnn = similarity score, e.g. `R100`)
|
||||
* We read the RAW stdout (not the trimming `run()` helper, which would mangle
|
||||
* the NUL bytes), split on `\0`, drop the trailing empty entry, and walk the
|
||||
* tokens pulling 1 or 2 path tokens per status. Paths are returned verbatim.
|
||||
*/
|
||||
async diffNameStatus(fromRef, toRef) {
|
||||
const r = await this.runRaw([
|
||||
"diff",
|
||||
"--name-status",
|
||||
"-M",
|
||||
"-z",
|
||||
fromRef,
|
||||
toRef,
|
||||
]);
|
||||
if (r.code !== 0) {
|
||||
const detail = (r.stderr || r.stdout || "").trim();
|
||||
throw new Error(`git diff --name-status failed: ${detail}`);
|
||||
}
|
||||
// Tokens alternate: <status> <path...> <status> <path...> ... With `-z`,
|
||||
// each token (status code AND each path) is its own NUL-delimited field.
|
||||
const tokens = r.stdout.split("\0").filter((t) => t.length > 0);
|
||||
const entries = [];
|
||||
let i = 0;
|
||||
while (i < tokens.length) {
|
||||
const raw = tokens[i++];
|
||||
// The status token is e.g. `A`, `M`, `D`, or `R100` / `C075`. The leading
|
||||
// letter is the change kind; any trailing digits are the similarity score.
|
||||
const letter = raw[0];
|
||||
if (letter === "R" || letter === "C") {
|
||||
const score = Number.parseInt(raw.slice(1), 10);
|
||||
const oldPath = tokens[i++];
|
||||
const path = tokens[i++];
|
||||
if (oldPath === undefined || path === undefined)
|
||||
break; // malformed tail
|
||||
entries.push({
|
||||
status: letter,
|
||||
path,
|
||||
oldPath,
|
||||
...(Number.isFinite(score) ? { score } : {}),
|
||||
});
|
||||
}
|
||||
else if (letter === "A" || letter === "M" || letter === "D") {
|
||||
const path = tokens[i++];
|
||||
if (path === undefined)
|
||||
break; // malformed tail
|
||||
entries.push({ status: letter, path });
|
||||
}
|
||||
else {
|
||||
// Unknown/other status (e.g. T type-change, U unmerged) — consume one
|
||||
// path token defensively so the walk stays aligned, but do not emit it
|
||||
// (the push planner only handles A/M/D/R/C).
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
/**
|
||||
* Resolve a ref/commit-ish to its full SHA, or `null` if it does not exist.
|
||||
* `rev-parse --verify --quiet` exits non-zero (and prints nothing) for an
|
||||
* unknown ref, so a non-zero exit maps cleanly to `null`. Used to read
|
||||
* `refs/docmost/last-pushed` (SPEC §5) — which is absent before the first push.
|
||||
*/
|
||||
async revParse(ref) {
|
||||
const r = await this.runRaw(["rev-parse", "--verify", "--quiet", ref]);
|
||||
if (r.code !== 0)
|
||||
return null;
|
||||
const sha = r.stdout.trim();
|
||||
return sha.length > 0 ? sha : null;
|
||||
}
|
||||
/**
|
||||
* Read a ref to its SHA, or `null` if unset. Thin alias over `revParse`,
|
||||
* named for the push direction's marker `refs/docmost/last-pushed` (SPEC §5:
|
||||
* "что из `main` уже отражено в Docmost").
|
||||
*/
|
||||
async readRef(ref) {
|
||||
return this.revParse(ref);
|
||||
}
|
||||
/**
|
||||
* Point `ref` at `target` (`git update-ref <ref> <target>`). Used to advance
|
||||
* `refs/docmost/last-pushed` to the just-pushed `main` commit after a push
|
||||
* (SPEC §6 step 3 / §5). `target` may be a SHA or any commit-ish git accepts.
|
||||
*/
|
||||
async updateRef(ref, target) {
|
||||
await this.run(["update-ref", ref, target]);
|
||||
}
|
||||
/**
|
||||
* Fast-forward `branch` to `toCommit` — but ONLY if it is a TRUE fast-forward,
|
||||
* i.e. the current `branch` tip is an ancestor of `toCommit` (verified via
|
||||
* `git merge-base --is-ancestor <branch> <toCommit>`). Used to advance the
|
||||
* `docmost` mirror branch after a clean push (SPEC §6 step 3 / §10): once a
|
||||
* push succeeds, Docmost already contains the pushed `main` content, so the
|
||||
* mirror must reflect it — otherwise the NEXT pull would diff our own write
|
||||
* back and re-pull it (loop-guard).
|
||||
*
|
||||
* SAFETY — never force, never clobber divergent history:
|
||||
* - If `branch` IS an ancestor of `toCommit`, advance it with
|
||||
* `git update-ref refs/heads/<branch> <toCommit>`. The `docmost` branch is
|
||||
* NOT checked out during a push (push works on `main`), so updating the ref
|
||||
* directly is safe and avoids any working-tree touch.
|
||||
* - If `branch` is NOT an ancestor (divergent / would-be non-fast-forward),
|
||||
* do NOT move it — return `{ ok: false, reason: 'not-fast-forward' }` and
|
||||
* let the caller log it. We must never overwrite a `docmost` history that
|
||||
* has commits the push base does not contain.
|
||||
*
|
||||
* Returns `{ ok: true }` when the branch was advanced (or already at
|
||||
* `toCommit`, a degenerate fast-forward), `{ ok: false, reason }` otherwise.
|
||||
* A missing `branch` or `toCommit` also yields `{ ok: false }` with a reason.
|
||||
*/
|
||||
async fastForwardBranch(branch, toCommit) {
|
||||
const branchRef = `refs/heads/${branch}`;
|
||||
// Resolve both endpoints first so a missing ref is a clean refusal, not a
|
||||
// confusing `merge-base` failure.
|
||||
const branchSha = await this.revParse(branchRef);
|
||||
if (branchSha === null) {
|
||||
return { ok: false, reason: `branch ${branch} does not exist` };
|
||||
}
|
||||
const targetSha = await this.revParse(toCommit);
|
||||
if (targetSha === null) {
|
||||
return { ok: false, reason: `target ${toCommit} does not resolve` };
|
||||
}
|
||||
// Already at the target -> a no-op fast-forward (still ok).
|
||||
if (branchSha === targetSha)
|
||||
return { ok: true };
|
||||
// `merge-base --is-ancestor A B` exits 0 iff A is an ancestor of B. Only a
|
||||
// true ancestor is a fast-forward; anything else is divergent and refused.
|
||||
const ancestor = await this.runRaw([
|
||||
"merge-base",
|
||||
"--is-ancestor",
|
||||
branchSha,
|
||||
targetSha,
|
||||
]);
|
||||
if (ancestor.code !== 0) {
|
||||
return { ok: false, reason: "not-fast-forward" };
|
||||
}
|
||||
// Safe to advance: the branch is not checked out during push, so a direct
|
||||
// ref update avoids a checkout/working-tree touch.
|
||||
await this.updateRef(branchRef, targetSha);
|
||||
return { ok: true };
|
||||
}
|
||||
/**
|
||||
* Read a file's content at a specific ref (`git show <ref>:<path>`), or `null`
|
||||
* if the path does not exist there. Used by the push direction to read the
|
||||
* PRE-IMAGE of a DELETED file (e.g. at `refs/docmost/last-pushed`) so its
|
||||
* `docmost:meta` — and therefore its `pageId` — can be recovered to translate
|
||||
* the deletion into a `delete_page` (SPEC §6/§8: only TRACKED files, i.e. ones
|
||||
* that had a pageId, are deleted in Docmost). A non-zero exit (path absent at
|
||||
* that ref) maps to `null` rather than throwing.
|
||||
*/
|
||||
async showFileAtRef(ref, path) {
|
||||
// `git show <ref>:<path>` requires the path relative to the repo root; pass
|
||||
// it verbatim (forward-slash, matching `listTrackedFiles` / diff output).
|
||||
const r = await this.runRaw(["show", `${ref}:${path}`]);
|
||||
if (r.code !== 0)
|
||||
return null;
|
||||
return r.stdout;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Build the environment for a vault git invocation (SPEC §12 cwd-isolation).
|
||||
* Used by the single `runRaw` primitive every git command flows through, so
|
||||
* these pins apply uniformly (including the `git --version` preflight).
|
||||
*
|
||||
* cwd-isolation is this module's central safety guarantee: every git command
|
||||
* MUST operate on the vault repo at `cwd: vaultPath` and nothing else. An
|
||||
* inherited `GIT_DIR` / `GIT_WORK_TREE` in `process.env` would silently
|
||||
* redirect the operation away from `cwd` (e.g. to the source repo or another
|
||||
* checkout), defeating that guarantee. So we always strip them, regardless of
|
||||
* whatever else the caller adds (author/committer identity, etc.).
|
||||
*
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function vaultGitEnv(extra) {
|
||||
const env = {
|
||||
...process.env,
|
||||
// Locale-independent output (defense in depth). We never parse localized
|
||||
// prose, but pinning the locale prevents a future regression where some
|
||||
// git message we DO key on is translated by an inherited LC_ALL/LANG.
|
||||
LC_ALL: "C",
|
||||
LANG: "C",
|
||||
// Never page (we already pass --no-pager, but a stray GIT_PAGER could still
|
||||
// bite) and never block on an interactive prompt (e.g. credentials) — the
|
||||
// daemon runs unattended and must not hang.
|
||||
GIT_PAGER: "cat",
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
...extra,
|
||||
};
|
||||
delete env.GIT_DIR;
|
||||
delete env.GIT_WORK_TREE;
|
||||
return env;
|
||||
}
|
||||
/**
|
||||
* Build a commit message body with trailer lines appended (SPEC §7.3). The
|
||||
* trailers are separated from the subject by a blank line so `git interpret-
|
||||
* trailers` / `git log --format=%(trailers)` parse them as trailers.
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function buildCommitMessage(subject, trailers) {
|
||||
if (!trailers || trailers.length === 0)
|
||||
return subject;
|
||||
return `${subject}\n\n${trailers.join("\n")}`;
|
||||
}
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
/**
|
||||
* Pure page-tree -> vault path mapping (SPEC §12).
|
||||
*
|
||||
* Given the flat list of page nodes for a space (as returned by
|
||||
* `listAllSpacePages`), compute for every page a deterministic, collision-free
|
||||
* destination: a folder path (root -> leaf ancestors) plus a file stem (the
|
||||
* page's own name, no extension). This module is intentionally PURE and
|
||||
* dependency-free apart from the sanitization helpers, so the whole tree ->
|
||||
* path logic is unit-testable without any I/O. The names are COSMETIC; identity
|
||||
* lives in each file's meta block (pageId / slugId).
|
||||
*/
|
||||
/** Flat page node as returned by `listAllSpacePages` (no content). */
|
||||
export interface PageNode {
|
||||
id: string;
|
||||
title?: string;
|
||||
slugId?: string;
|
||||
parentPageId?: string | null;
|
||||
hasChildren?: boolean;
|
||||
}
|
||||
/** A page's resolved vault destination: folder path + file stem. */
|
||||
export interface VaultEntry {
|
||||
/** Folder path, root -> leaf (the page's ancestors). Empty for a root page. */
|
||||
segments: string[];
|
||||
/** The page's own file name without extension. */
|
||||
stem: string;
|
||||
}
|
||||
/**
|
||||
* Build the full vault layout for a space.
|
||||
*
|
||||
* Returns a Map keyed by pageId -> `{ segments, stem }`. The result is
|
||||
* deterministic for a given input and guarantees every full destination path
|
||||
* (`[...segments, stem].join("/")`) is unique, so no page can silently overwrite
|
||||
* another.
|
||||
*
|
||||
* Disambiguation is layered:
|
||||
* 1. Sibling collisions (same sanitized title under the same parent) are
|
||||
* resolved with a stable ` ~<slugId>` suffix (the suffix is itself
|
||||
* sanitized, since slugId/id is untrusted data that must never inject a
|
||||
* path separator).
|
||||
* 2. A final full-path pass catches residual collisions that sibling-scoping
|
||||
* cannot see — e.g. two pages whose parents are BOTH outside the input set
|
||||
* both bucket at the root with `segments: []`.
|
||||
*/
|
||||
export declare function buildVaultLayout(pages: PageNode[]): Map<string, VaultEntry>;
|
||||
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* Pure page-tree -> vault path mapping (SPEC §12).
|
||||
*
|
||||
* Given the flat list of page nodes for a space (as returned by
|
||||
* `listAllSpacePages`), compute for every page a deterministic, collision-free
|
||||
* destination: a folder path (root -> leaf ancestors) plus a file stem (the
|
||||
* page's own name, no extension). This module is intentionally PURE and
|
||||
* dependency-free apart from the sanitization helpers, so the whole tree ->
|
||||
* path logic is unit-testable without any I/O. The names are COSMETIC; identity
|
||||
* lives in each file's meta block (pageId / slugId).
|
||||
*/
|
||||
import { sanitizeTitle, disambiguate } from "./sanitize.js";
|
||||
/**
|
||||
* Build the full vault layout for a space.
|
||||
*
|
||||
* Returns a Map keyed by pageId -> `{ segments, stem }`. The result is
|
||||
* deterministic for a given input and guarantees every full destination path
|
||||
* (`[...segments, stem].join("/")`) is unique, so no page can silently overwrite
|
||||
* another.
|
||||
*
|
||||
* Disambiguation is layered:
|
||||
* 1. Sibling collisions (same sanitized title under the same parent) are
|
||||
* resolved with a stable ` ~<slugId>` suffix (the suffix is itself
|
||||
* sanitized, since slugId/id is untrusted data that must never inject a
|
||||
* path separator).
|
||||
* 2. A final full-path pass catches residual collisions that sibling-scoping
|
||||
* cannot see — e.g. two pages whose parents are BOTH outside the input set
|
||||
* both bucket at the root with `segments: []`.
|
||||
*/
|
||||
export function buildVaultLayout(pages) {
|
||||
// Index pages by id so the parent chain can be walked. Guard against
|
||||
// duplicate ids in the input (first one wins).
|
||||
const byId = new Map();
|
||||
for (const p of pages) {
|
||||
if (p && p.id && !byId.has(p.id))
|
||||
byId.set(p.id, p);
|
||||
}
|
||||
// Resolve each node's display name once, deterministically, tracking sibling
|
||||
// collisions per parent. `usedBySibling` maps a parent key -> set of names
|
||||
// already taken under that parent. The bucket key is the node's parent ONLY
|
||||
// when that parent is actually present in `byId`; otherwise (null parent, or
|
||||
// an orphan whose parent is outside the input set) the node buckets at
|
||||
// `"__root__"`. This is critical: orphans land at the vault root (see
|
||||
// `folderSegmentsFor`), so they MUST share the root bucket with real root
|
||||
// pages to be disambiguated against each other here — making `nameById` final
|
||||
// before any `segments` are computed, so no ancestor name can drift later.
|
||||
const usedBySibling = new Map();
|
||||
const nameById = new Map();
|
||||
for (const p of pages) {
|
||||
if (p && p.id && !nameById.has(p.id)) {
|
||||
const parentKey = p.parentPageId && byId.has(p.parentPageId) ? p.parentPageId : "__root__";
|
||||
nameById.set(p.id, nameForNode(p, parentKey, usedBySibling));
|
||||
}
|
||||
}
|
||||
// Every id we index above MUST get a resolved name; this helper returns it
|
||||
// and THROWS if it is somehow absent, rather than silently recomputing a
|
||||
// DIFFERENT, non-disambiguated name (which would desync a folder segment from
|
||||
// its target file).
|
||||
const nameOf = (id) => {
|
||||
const name = nameById.get(id);
|
||||
if (name === undefined) {
|
||||
throw new Error(`buildVaultLayout: no resolved name for page id ${id}`);
|
||||
}
|
||||
return name;
|
||||
};
|
||||
// Build the folder path for a page by walking parentPageId to the root. The
|
||||
// page's OWN name is the file stem; its ancestors become folders. A `visited`
|
||||
// guard prevents an infinite loop on a malformed parent cycle.
|
||||
const folderSegmentsFor = (node) => {
|
||||
const ancestors = [];
|
||||
const visited = new Set();
|
||||
let current = node.parentPageId
|
||||
? byId.get(node.parentPageId)
|
||||
: undefined;
|
||||
while (current && current.id && !visited.has(current.id)) {
|
||||
visited.add(current.id);
|
||||
ancestors.unshift(nameOf(current.id));
|
||||
current = current.parentPageId
|
||||
? byId.get(current.parentPageId)
|
||||
: undefined;
|
||||
}
|
||||
return ancestors;
|
||||
};
|
||||
// First pass: compute the provisional { segments, stem } for every node.
|
||||
const layout = new Map();
|
||||
for (const p of pages) {
|
||||
if (!p || !p.id || layout.has(p.id))
|
||||
continue;
|
||||
layout.set(p.id, {
|
||||
segments: folderSegmentsFor(p),
|
||||
stem: nameOf(p.id),
|
||||
});
|
||||
}
|
||||
// FOLDER-NOTE transform (native-Obsidian layout): a page WITH CHILDREN lives at
|
||||
// `<…>/<stem>/<stem>.md` — its body is the folder-note INSIDE its own folder
|
||||
// (LostPaul Folder Notes convention), and its children sit alongside it in that
|
||||
// folder. A leaf stays `<…>/<stem>.md`. Children's segments already point into
|
||||
// the parent's folder (folderSegmentsFor walks ancestor NAMES), so only the
|
||||
// parent's own file relocates here; the sibling name pass above already made
|
||||
// the parent name unique, so folder == file name stays consistent.
|
||||
for (const p of pages) {
|
||||
if (!p || !p.id)
|
||||
continue;
|
||||
const entry = layout.get(p.id);
|
||||
if (entry && p.hasChildren) {
|
||||
entry.segments = [...entry.segments, entry.stem];
|
||||
}
|
||||
}
|
||||
// Final full-path uniqueness pass — a belt-and-suspenders safety net. Note
|
||||
// that cross-bucket (orphan/root) collisions are now resolved in the name pass
|
||||
// above (orphans share the "__root__" bucket), so ancestor names are final
|
||||
// before `segments` are built and this pass should rarely/never re-stem an
|
||||
// ancestor. It only re-stems the colliding LATER leaf via the sanitized
|
||||
// slugId/id, then (if still colliding) appends the id.
|
||||
//
|
||||
// Process FOLDER-NOTES (pages with children) FIRST so a parent claims its
|
||||
// canonical `<name>/<name>.md` before a same-named CHILD — the child (a leaf)
|
||||
// is the one that disambiguates, never the folder-note.
|
||||
const usedPaths = new Set();
|
||||
const seenIds = new Set();
|
||||
const pathKey = (e) => [...e.segments, e.stem].join("/");
|
||||
const ordered = pages
|
||||
.filter((p) => Boolean(p && p.id))
|
||||
.sort((a, b) => Number(Boolean(b.hasChildren)) - Number(Boolean(a.hasChildren)));
|
||||
for (const p of ordered) {
|
||||
if (seenIds.has(p.id))
|
||||
continue;
|
||||
seenIds.add(p.id);
|
||||
const entry = layout.get(p.id);
|
||||
if (!entry)
|
||||
continue;
|
||||
if (usedPaths.has(pathKey(entry))) {
|
||||
// First attempt: disambiguate the stem with the sanitized slugId (or id).
|
||||
entry.stem = disambiguate(entry.stem, sanitizeTitle(p.slugId ?? p.id));
|
||||
if (usedPaths.has(pathKey(entry))) {
|
||||
// Still colliding: append the (sanitized) id as a last resort. The id
|
||||
// is globally unique, so this always resolves the collision.
|
||||
entry.stem = disambiguate(entry.stem, sanitizeTitle(p.id));
|
||||
}
|
||||
}
|
||||
usedPaths.add(pathKey(entry));
|
||||
}
|
||||
return layout;
|
||||
}
|
||||
/**
|
||||
* Compute a deterministic, collision-free name for a node among its SIBLINGS.
|
||||
* `usedBySibling` maps a parent key -> set of names already taken, so two
|
||||
* siblings that sanitize to the same name get a stable ` ~slugId` suffix
|
||||
* (SPEC §12). The suffix is itself passed through `sanitizeTitle`, because the
|
||||
* slugId/id is a second untrusted-data channel that must never leak a path
|
||||
* separator into the name. `parentKey` is supplied by the caller (it resolves
|
||||
* to `"__root__"` for root pages AND for orphans whose parent is outside the
|
||||
* input set, so they share one bucket). The name is COSMETIC; identity lives in
|
||||
* the meta block.
|
||||
*/
|
||||
function nameForNode(node, parentKey, usedBySibling) {
|
||||
let used = usedBySibling.get(parentKey);
|
||||
if (!used) {
|
||||
used = new Set();
|
||||
usedBySibling.set(parentKey, used);
|
||||
}
|
||||
let name = sanitizeTitle(node.title ?? "");
|
||||
if (used.has(name)) {
|
||||
// Sibling collision: disambiguate with the stable, sanitized slugId (fall
|
||||
// back to the sanitized pageId if no slugId is present).
|
||||
name = disambiguate(name, sanitizeTitle(node.slugId ?? node.id));
|
||||
}
|
||||
used.add(name);
|
||||
return name;
|
||||
}
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
/**
|
||||
* Stable hash of a page's markdown BODY (SPEC §10 "хэш тела"). Deterministic:
|
||||
* the same input string always yields the same digest, a different input a
|
||||
* different one. Used to recognize our own write later (loop suppression).
|
||||
*
|
||||
* We hash the body STRING as-is (UTF-8) with SHA-256 and return lowercase hex.
|
||||
* SPEC §10 keys on the body hash rather than file bytes; callers decide WHAT
|
||||
* counts as "the body" (here it is the exact string passed in — typically the
|
||||
* self-contained markdown that was pushed). No normalization is applied: the
|
||||
* caller is responsible for passing a canonical/stable representation if it
|
||||
* wants hash equality across cosmetic-only differences.
|
||||
*/
|
||||
export declare function bodyHash(markdownBody: string): string;
|
||||
-136
@@ -1,136 +0,0 @@
|
||||
import type { GitSyncClient } from "./client.types.js";
|
||||
import { type PageNode } from "./layout.js";
|
||||
import { VaultGit } from "./git.js";
|
||||
import { type MovedEntry, type DeletionDecision } from "./reconcile.js";
|
||||
/**
|
||||
* Injectable IO for `readExisting` (R-Pull-1, test-strategy report §5). The real
|
||||
* `main` wires these to `git.listTrackedFiles("*.md")` and an `fs.readFile`
|
||||
* rooted at the vault; tests pass fakes so the parsing/skip rules are unit-
|
||||
* testable without a real git repo or filesystem.
|
||||
*/
|
||||
export interface ReadExistingDeps {
|
||||
/** List tracked .md paths (forward-slash, vault-relative). */
|
||||
listTracked: () => Promise<string[]>;
|
||||
/** Read a tracked file's text by its (forward-slash) vault-relative path. */
|
||||
readFile: (relPath: string) => Promise<string>;
|
||||
}
|
||||
/**
|
||||
* Read every tracked .md file in the vault and recover `{ pageId, relPath }` from
|
||||
* its `gitmost_id` frontmatter (native-Obsidian format). Files without a
|
||||
* `gitmost_id` are skipped (they are not engine-tracked pages yet — e.g. a stray
|
||||
* hand-written Obsidian file; PUSH adopts those separately).
|
||||
*
|
||||
* The IO is injected (R-Pull-1) so this is testable with fakes. Skip rules:
|
||||
* - a `readFile` rejection (tracked but missing on disk, a mid-operation race)
|
||||
* -> skipped, NOT thrown; the next pull converges;
|
||||
* - no `gitmost_id` frontmatter (`parsePageFile` -> id null) -> skipped.
|
||||
*/
|
||||
export declare function readExisting(deps: ReadExistingDeps): Promise<{
|
||||
pageId: string;
|
||||
relPath: string;
|
||||
}[]>;
|
||||
/**
|
||||
* Input to the PURE `computePullActions` (R-Pull-2). All data, no IO: the live
|
||||
* tree nodes + completeness flag (from `listSpaceTree`) and the parsed
|
||||
* `existing` tracked files (from `readExisting`).
|
||||
*/
|
||||
export interface PullActionsInput {
|
||||
/** Live page nodes for the space (from `listSpaceTree`). */
|
||||
pages: PageNode[];
|
||||
/** Whether the live tree fetch was COMPLETE (SPEC §8 suppression). */
|
||||
treeComplete: boolean;
|
||||
/** Parsed tracked files: `{ pageId, relPath }` (from `readExisting`). */
|
||||
existing: {
|
||||
pageId: string;
|
||||
relPath: string;
|
||||
}[];
|
||||
}
|
||||
/**
|
||||
* The PURE decisions object computed by `computePullActions` (no IO). It holds
|
||||
* the reconciliation plan plus the SPEC §8 absence-deletion decision, with the
|
||||
* suppression already folded in: `toDelete` is the POST-suppression set the
|
||||
* caller should actually remove (empty when `deletionDecision.apply` is false).
|
||||
*/
|
||||
export interface PullActions {
|
||||
/** Pages to (re)write at their relPath (add + update + move target). */
|
||||
toWrite: {
|
||||
pageId: string;
|
||||
relPath: string;
|
||||
}[];
|
||||
/** Moves: write new path, then remove old path (only on a successful write). */
|
||||
moved: MovedEntry[];
|
||||
/**
|
||||
* Absence-based paths to delete AFTER suppression. Empty when the decision
|
||||
* suppressed deletions this cycle, so the caller can apply it unconditionally.
|
||||
*/
|
||||
toDelete: string[];
|
||||
/** Why absence deletions were (or were not) applied (for logging + tests). */
|
||||
deletionDecision: DeletionDecision;
|
||||
/** Tracked-file count (for the suppression log messages). */
|
||||
existingCount: number;
|
||||
/** Planned absence-delete count BEFORE suppression (for the log message). */
|
||||
plannedDeleteCount: number;
|
||||
}
|
||||
/**
|
||||
* PURE pull-action planner (R-Pull-2, test-strategy report §5). Takes the live
|
||||
* tree nodes + completeness + existing tracked files and returns the full set of
|
||||
* decisions with NO IO:
|
||||
*
|
||||
* - builds the vault layout (deterministic relPath per live page),
|
||||
* - `planReconciliation` -> toWrite / moved / absence-toDelete,
|
||||
* - `decideAbsenceDeletions` -> the SPEC §8 suppression (incomplete-fetch +
|
||||
* empty-live + mass-delete guard), folded IN here so `toDelete` is the
|
||||
* POST-suppression set (empty when suppressed).
|
||||
*
|
||||
* Moves are NOT governed by the suppression: a moved page is present in `live`,
|
||||
* so its old-path removal is real (the caller still gates it on the write
|
||||
* succeeding). The expensive content fetch / file write / git ops happen in the
|
||||
* thin `applyPullActions`.
|
||||
*/
|
||||
export declare function computePullActions(input: PullActionsInput): PullActions;
|
||||
/**
|
||||
* Injectable IO for `applyPullActions` (R-Pull-2). The real `main` wires these
|
||||
* to the live client, the vault git wrapper, and `node:fs/promises`; tests pass
|
||||
* fakes that RECORD calls so the ordering + the move-on-success data-loss guard
|
||||
* are testable without real git/fs/network.
|
||||
*/
|
||||
export interface ApplyPullActionsDeps {
|
||||
client: Pick<GitSyncClient, "getPageJson">;
|
||||
git: Pick<VaultGit, "stageAll" | "commit" | "checkout" | "merge">;
|
||||
/** Write a file by ABSOLUTE path (mkdir of the parent is done internally). */
|
||||
writeFile: (absPath: string, text: string) => Promise<void>;
|
||||
/** Recursive mkdir of an ABSOLUTE directory path. */
|
||||
mkdir: (absDir: string) => Promise<void>;
|
||||
/** Remove a file by ABSOLUTE path (force: a missing file is a no-op). */
|
||||
rm: (absPath: string) => Promise<void>;
|
||||
}
|
||||
/** Outcome counters from `applyPullActions` (for the summary + tests). */
|
||||
export interface ApplyResult {
|
||||
written: number;
|
||||
movedApplied: number;
|
||||
deleted: number;
|
||||
failed: number;
|
||||
committed: boolean;
|
||||
merge: {
|
||||
ok: boolean;
|
||||
conflict: boolean;
|
||||
output: string;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* THIN IO applier (R-Pull-2). Performs the side effects in the EXACT current
|
||||
* order, with all the original safety guards preserved bit-for-bit:
|
||||
*
|
||||
* 1. for each `toWrite`: fetch content (`client.getPageJson`) -> stabilize
|
||||
* (normalize-on-write fixpoint, SPEC §11) -> mkdir + write. One bad page
|
||||
* never aborts the pull (bounded-concurrency pool, fault-tolerant).
|
||||
* 2. apply MOVE old-path removals — ONLY when the planner marked the old path
|
||||
* removable AND the new-path write SUCCEEDED (the ⭐ data-loss guard: a
|
||||
* failed move-write keeps the old path so the page never vanishes).
|
||||
* 3. apply (post-suppression) absence deletes.
|
||||
* 4. stageAll + commit on `docmost` (subject from ACTUAL written/deleted
|
||||
* counts) + checkout main + merge docmost (conflicts surfaced, SPEC §9).
|
||||
*
|
||||
* `vaultRoot` roots the relPath -> absolute-path conversion for the fs deps.
|
||||
*/
|
||||
export declare function applyPullActions(deps: ApplyPullActionsDeps, actions: PullActions, vaultRoot: string): Promise<ApplyResult>;
|
||||
@@ -1,284 +0,0 @@
|
||||
/**
|
||||
* Pull cycle — Docmost -> vault (SPEC §6 "Docmost -> ФС").
|
||||
*
|
||||
* This increment turns the read-only mirror into the git-backed pull cycle:
|
||||
*
|
||||
* 1. ensureRepo(vault); refuse if a merge is in progress (SPEC §9/§12);
|
||||
* ensureBranch("docmost", "main") (SPEC §5 branches)
|
||||
* 2. checkout docmost
|
||||
* 3. fetch the live tree (listSpaceTree -> {pages, complete}) -> compute the
|
||||
* desired `live` files (relPath via the pure sanitize/disambiguation layout)
|
||||
* 4. parse `existing` tracked .md files (pageId + relPath from gitmost_id frontmatter)
|
||||
* 5. plan = planReconciliation(live, existing) (pure, SPEC §5/§8); toDelete
|
||||
* is absence-only, moves are separate
|
||||
* 6. decideAbsenceDeletions: SUPPRESS absence deletions on an incomplete tree
|
||||
* fetch (SPEC §8) and behind the mass-delete guard (defense in depth)
|
||||
* 7. write each live page in its fixpoint form (normalize-on-write, SPEC §11);
|
||||
* apply moved-old-path removals (only when the move write SUCCEEDED) and
|
||||
* absence-delete removals (only when the decision allowed them)
|
||||
* 8. stageAll + commit on `docmost` with the provenance trailer (SPEC §7.3)
|
||||
* 9. checkout main + merge docmost (conflicts are surfaced, NOT auto-resolved,
|
||||
* SPEC §9); push is deferred (SPEC §7)
|
||||
* 10. one-line summary
|
||||
*
|
||||
* DIRECTION IS Docmost -> vault ONLY. Nothing here ever writes to Docmost
|
||||
* (read-only: listSpaceTree + getPageJson). All git operations run against
|
||||
* the vault repo (`cwd = vaultPath`), never the source repo (see ./git.ts).
|
||||
*
|
||||
* The client seam is the native `GitSyncClient` (`Pick<GitSyncClient, ...>`);
|
||||
* the gitmost server drives the engine in-process (there is no standalone CLI
|
||||
* entry point).
|
||||
*/
|
||||
import { dirname } from "node:path";
|
||||
import { sep } from "node:path";
|
||||
import { parsePageFile, serializePageFile } from "../lib/page-file.js";
|
||||
import { buildVaultLayout } from "./layout.js";
|
||||
import { BOT_AUTHOR_NAME, BOT_AUTHOR_EMAIL, DEFAULT_BRANCH, } from "./git.js";
|
||||
import { planReconciliation, decideAbsenceDeletions, } from "./reconcile.js";
|
||||
import { stabilizePageBody } from "./stabilize.js";
|
||||
// Engine-only mirror branch (SPEC §5): the engine writes here, humans never do.
|
||||
const DOCMOST_BRANCH = "docmost";
|
||||
// Machine-readable provenance the loop-guard keys on (SPEC §7.3 / §12).
|
||||
const SOURCE_TRAILER = "Docmost-Sync-Source: docmost";
|
||||
// Number of pages fetched/stabilized concurrently. Bounded so a large space
|
||||
// does not open thousands of simultaneous requests/conversions at once.
|
||||
const CONCURRENCY = 6;
|
||||
// How often to log incremental progress (every N completed pages).
|
||||
const PROGRESS_EVERY = 25;
|
||||
/** Convert a vault-relative path (forward-slash) to an absolute FS path. */
|
||||
function relToAbs(vaultRoot, relPath) {
|
||||
return [vaultRoot, ...relPath.split("/")].join("/");
|
||||
}
|
||||
/** Convert an absolute/relative segment list under the vault to a relPath. */
|
||||
function segmentsToRelPath(segments, stem) {
|
||||
return [...segments, `${stem}.md`].join("/");
|
||||
}
|
||||
/**
|
||||
* Read every tracked .md file in the vault and recover `{ pageId, relPath }` from
|
||||
* its `gitmost_id` frontmatter (native-Obsidian format). Files without a
|
||||
* `gitmost_id` are skipped (they are not engine-tracked pages yet — e.g. a stray
|
||||
* hand-written Obsidian file; PUSH adopts those separately).
|
||||
*
|
||||
* The IO is injected (R-Pull-1) so this is testable with fakes. Skip rules:
|
||||
* - a `readFile` rejection (tracked but missing on disk, a mid-operation race)
|
||||
* -> skipped, NOT thrown; the next pull converges;
|
||||
* - no `gitmost_id` frontmatter (`parsePageFile` -> id null) -> skipped.
|
||||
*/
|
||||
export async function readExisting(deps) {
|
||||
const tracked = await deps.listTracked();
|
||||
const existing = [];
|
||||
for (const relPath of tracked) {
|
||||
// git ls-files always emits forward-slash paths; normalize just in case.
|
||||
const rel = relPath.split(sep).join("/");
|
||||
let text;
|
||||
try {
|
||||
text = await deps.readFile(rel);
|
||||
}
|
||||
catch {
|
||||
// Tracked but missing on disk (mid-operation race) — skip; the next pull
|
||||
// converges.
|
||||
continue;
|
||||
}
|
||||
const { id } = parsePageFile(text);
|
||||
if (id)
|
||||
existing.push({ pageId: id, relPath: rel });
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
/**
|
||||
* PURE pull-action planner (R-Pull-2, test-strategy report §5). Takes the live
|
||||
* tree nodes + completeness + existing tracked files and returns the full set of
|
||||
* decisions with NO IO:
|
||||
*
|
||||
* - builds the vault layout (deterministic relPath per live page),
|
||||
* - `planReconciliation` -> toWrite / moved / absence-toDelete,
|
||||
* - `decideAbsenceDeletions` -> the SPEC §8 suppression (incomplete-fetch +
|
||||
* empty-live + mass-delete guard), folded IN here so `toDelete` is the
|
||||
* POST-suppression set (empty when suppressed).
|
||||
*
|
||||
* Moves are NOT governed by the suppression: a moved page is present in `live`,
|
||||
* so its old-path removal is real (the caller still gates it on the write
|
||||
* succeeding). The expensive content fetch / file write / git ops happen in the
|
||||
* thin `applyPullActions`.
|
||||
*/
|
||||
export function computePullActions(input) {
|
||||
const { pages, treeComplete, existing } = input;
|
||||
const layout = buildVaultLayout(pages);
|
||||
const live = [];
|
||||
for (const p of pages) {
|
||||
if (!p || !p.id)
|
||||
continue;
|
||||
const entry = layout.get(p.id);
|
||||
if (!entry)
|
||||
continue;
|
||||
live.push({
|
||||
pageId: p.id,
|
||||
relPath: segmentsToRelPath(entry.segments, entry.stem),
|
||||
});
|
||||
}
|
||||
// Plan reconciliation (pure). `plan.toDelete` is ABSENCE-based only;
|
||||
// `plan.moved` carries move old-path removals separately.
|
||||
const plan = planReconciliation(live, existing);
|
||||
// Decide whether the ABSENCE-based deletions may be applied this cycle
|
||||
// (SPEC §8): incomplete-fetch suppression + empty-live + mass-delete guard.
|
||||
// Moves are NOT governed by this.
|
||||
const deletionDecision = decideAbsenceDeletions({
|
||||
treeComplete,
|
||||
liveCount: live.length,
|
||||
existingCount: existing.length,
|
||||
deleteCount: plan.toDelete.length,
|
||||
});
|
||||
return {
|
||||
toWrite: plan.toWrite,
|
||||
moved: plan.moved,
|
||||
// Fold the suppression in: a suppressed cycle deletes nothing.
|
||||
toDelete: deletionDecision.apply ? plan.toDelete : [],
|
||||
deletionDecision,
|
||||
existingCount: existing.length,
|
||||
plannedDeleteCount: plan.toDelete.length,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* THIN IO applier (R-Pull-2). Performs the side effects in the EXACT current
|
||||
* order, with all the original safety guards preserved bit-for-bit:
|
||||
*
|
||||
* 1. for each `toWrite`: fetch content (`client.getPageJson`) -> stabilize
|
||||
* (normalize-on-write fixpoint, SPEC §11) -> mkdir + write. One bad page
|
||||
* never aborts the pull (bounded-concurrency pool, fault-tolerant).
|
||||
* 2. apply MOVE old-path removals — ONLY when the planner marked the old path
|
||||
* removable AND the new-path write SUCCEEDED (the ⭐ data-loss guard: a
|
||||
* failed move-write keeps the old path so the page never vanishes).
|
||||
* 3. apply (post-suppression) absence deletes.
|
||||
* 4. stageAll + commit on `docmost` (subject from ACTUAL written/deleted
|
||||
* counts) + checkout main + merge docmost (conflicts surfaced, SPEC §9).
|
||||
*
|
||||
* `vaultRoot` roots the relPath -> absolute-path conversion for the fs deps.
|
||||
*/
|
||||
export async function applyPullActions(deps, actions, vaultRoot) {
|
||||
const { client, git } = deps;
|
||||
// Emit the SPEC §8 suppression warnings (preserved from the original `main`).
|
||||
const decision = actions.deletionDecision;
|
||||
if (!decision.apply) {
|
||||
if (decision.reason === "incomplete-fetch") {
|
||||
console.warn("pull: tree fetch incomplete — deletions suppressed this cycle (SPEC §8)");
|
||||
}
|
||||
else if (decision.reason === "empty-live") {
|
||||
console.warn(`pull: live fetch returned 0 pages but ${actions.existingCount} file(s) are ` +
|
||||
`tracked — deletions suppressed this cycle (SPEC §8). Re-run when ` +
|
||||
`Docmost is reachable.`);
|
||||
}
|
||||
else {
|
||||
console.warn(`pull: plan would delete ${actions.plannedDeleteCount} of ${actions.existingCount} ` +
|
||||
`tracked file(s) (mass-delete guard) — deletions suppressed this ` +
|
||||
`cycle (SPEC §8). Verify the live Docmost tree, then re-run.`);
|
||||
}
|
||||
}
|
||||
// 1. Write each live page in its fixpoint form (normalize-on-write, SPEC §11).
|
||||
let written = 0;
|
||||
let failed = 0;
|
||||
let completed = 0;
|
||||
let nextIndex = 0;
|
||||
// pageIds whose write FAILED. A moved page whose new-path write failed must
|
||||
// NOT have its old path removed (otherwise the page vanishes entirely).
|
||||
const failedPageIds = new Set();
|
||||
const writeOne = async (w) => {
|
||||
try {
|
||||
const page = await client.getPageJson(w.pageId);
|
||||
// Native-Obsidian format: a minimal `gitmost_id` frontmatter + the fixpoint
|
||||
// markdown body. title/parent/space are DERIVED (filename / folder / repo),
|
||||
// so nothing but the pageId is persisted as meta.
|
||||
const text = serializePageFile(page.id, await stabilizePageBody(page.content));
|
||||
const abs = relToAbs(vaultRoot, w.relPath);
|
||||
await deps.mkdir(dirname(abs));
|
||||
await deps.writeFile(abs, text);
|
||||
written++;
|
||||
}
|
||||
catch (err) {
|
||||
failed++;
|
||||
failedPageIds.add(w.pageId);
|
||||
console.error(`pull: failed page ${w.pageId}:`, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
finally {
|
||||
completed++;
|
||||
if (completed % PROGRESS_EVERY === 0) {
|
||||
console.log(`pulled ${completed}/${actions.toWrite.length}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
// Bounded-concurrency pool (dependency-free): a fixed set of runners each
|
||||
// take the next index until the write list is exhausted. One bad page never
|
||||
// aborts the whole pull (mirrors the fault-tolerant tree walk).
|
||||
const runner = async () => {
|
||||
while (true) {
|
||||
const i = nextIndex++;
|
||||
if (i >= actions.toWrite.length)
|
||||
return;
|
||||
await writeOne(actions.toWrite[i]);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, actions.toWrite.length) || 1 }, () => runner()));
|
||||
// Helper: `rm` with force:true is a no-op if the file is already gone.
|
||||
const removePath = async (rel, what) => {
|
||||
try {
|
||||
await deps.rm(relToAbs(vaultRoot, rel));
|
||||
return true;
|
||||
}
|
||||
catch (err) {
|
||||
console.error(`pull: failed to ${what} ${rel}:`, err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
// 2. Apply MOVE old-path removals. A moved page IS present in `live`, so its
|
||||
// old path is genuinely stale — NOT subject to the incomplete-fetch
|
||||
// suppression. BUT only remove the old path when (a) the planner marked it
|
||||
// removable (not reused by another live page) AND (b) the new-path write
|
||||
// actually SUCCEEDED — otherwise we would delete the only copy of a page
|
||||
// whose move-write failed (⭐ data-loss guard).
|
||||
let movedApplied = 0;
|
||||
for (const m of actions.moved) {
|
||||
if (!m.removeOldPath)
|
||||
continue;
|
||||
if (failedPageIds.has(m.pageId)) {
|
||||
console.warn(`pull: move write for ${m.pageId} failed — keeping old path ` +
|
||||
`${m.fromRelPath} (SPEC §8)`);
|
||||
continue;
|
||||
}
|
||||
if (await removePath(m.fromRelPath, "remove moved old path"))
|
||||
movedApplied++;
|
||||
}
|
||||
// 3. Apply ABSENCE-based deletions — `actions.toDelete` is ALREADY the
|
||||
// post-suppression set (empty when the decision suppressed them, SPEC §8).
|
||||
let deleted = 0;
|
||||
for (const rel of actions.toDelete) {
|
||||
if (await removePath(rel, "delete"))
|
||||
deleted++;
|
||||
}
|
||||
// 4. Stage + commit on `docmost` (only if there is something to commit).
|
||||
// Deterministic stabilized output means unchanged pages produce identical
|
||||
// bytes -> git sees no diff -> no churn (SPEC §11). The subject reflects the
|
||||
// ACTUAL work applied (pages written + files deleted), not the planned size,
|
||||
// so a run with failures does not over-report (SPEC §5 nit).
|
||||
const subject = deleted > 0
|
||||
? `docmost: sync ${written} page(s), ${deleted} deleted`
|
||||
: `docmost: sync ${written} page(s)`;
|
||||
await git.stageAll();
|
||||
const committed = await git.commit(subject, {
|
||||
authorName: BOT_AUTHOR_NAME,
|
||||
authorEmail: BOT_AUTHOR_EMAIL,
|
||||
trailers: [SOURCE_TRAILER],
|
||||
});
|
||||
// Merge docmost -> main. Conflicts are surfaced and left in git (SPEC §9);
|
||||
// we never push to Docmost. Push to a git remote is deferred (SPEC §7).
|
||||
await git.checkout(DEFAULT_BRANCH);
|
||||
const merge = await git.merge(DOCMOST_BRANCH);
|
||||
if (merge.conflict) {
|
||||
console.error("pull: merge of docmost -> main CONFLICTED. Conflict markers were left " +
|
||||
"in the vault for manual resolution (SPEC §9). Nothing is pushed to " +
|
||||
"Docmost (read-only). Resolve locally, then re-run.");
|
||||
}
|
||||
else if (!merge.ok) {
|
||||
console.error(`pull: merge of docmost -> main failed: ${merge.output}`);
|
||||
}
|
||||
console.log("pull: git push to remote is DEFERRED in this increment (SPEC §7).");
|
||||
return { written, movedApplied, deleted, failed, committed, merge };
|
||||
}
|
||||
-504
@@ -1,504 +0,0 @@
|
||||
/**
|
||||
* Push cycle — vault -> Docmost (SPEC §6 "ФС → Docmost"), FIRST increment.
|
||||
*
|
||||
* This module mirrors the structure of `./pull.ts`: a set of VaultGit diff/ref
|
||||
* primitives (in `./git.ts`), a PURE planner (`computePushActions`) that turns
|
||||
* a git diff into a classified action set with NO IO, and a THIN injectable
|
||||
* applier (`applyPushActions`) exercised in tests via fakes only.
|
||||
*
|
||||
* Direction is vault -> Docmost. The diff is `main` against
|
||||
* `refs/docmost/last-pushed` (SPEC §6 step 2); each `A`/`M`/`D`/`R` row is
|
||||
* translated into a Docmost mutation by `pageId` identity (SPEC §4):
|
||||
* - A without pageId -> create_page (then write the assigned pageId back).
|
||||
* - A with pageId -> update (restored/copied file; the page already exists).
|
||||
* - M -> update content (collab/Yjs path, SPEC §2/§15.6).
|
||||
* - D -> delete_page (pageId recovered from the PRE-IMAGE meta).
|
||||
* - R -> rename/move (CLASSIFIED here, APPLIED in push #3).
|
||||
*
|
||||
* MOVE/RENAME APPLY (push #3) — DONE here. `classifyRenameMoves` (PURE) resolves
|
||||
* each `renamesMoves` entry into the Docmost op(s) it needs, comparing the PATH-
|
||||
* derived parent (SPEC §5: the file path is the source of truth for tree
|
||||
* position, NOT stale `meta.parentPageId`) and the meta title; `applyPushActions`
|
||||
* then calls `move_page` / `rename_page` (both for a reparent+retitle), or
|
||||
* records a NO-OP for a cosmetic local-only file-path rename.
|
||||
*
|
||||
* The client seam is the native `GitSyncClient` (`Pick<GitSyncClient, ...>`);
|
||||
* the gitmost server drives the engine in-process (there is no standalone CLI
|
||||
* entry point).
|
||||
*/
|
||||
import { type DocmostMdMeta } from "../lib/index.js";
|
||||
import type { GitSyncClient } from "./client.types.js";
|
||||
import type { DiffEntry } from "./git.js";
|
||||
import { VaultGit } from "./git.js";
|
||||
import { type Settings } from "./settings.js";
|
||||
export type { DiffEntry } from "./git.js";
|
||||
/** A page to CREATE in Docmost (new local file, meta has no pageId yet). */
|
||||
export interface CreateAction {
|
||||
/** Vault-relative path of the new file. */
|
||||
path: string;
|
||||
}
|
||||
/** A page whose CONTENT changed (meta carries the existing pageId). */
|
||||
export interface UpdateAction {
|
||||
pageId: string;
|
||||
/** Vault-relative path of the changed file. */
|
||||
path: string;
|
||||
}
|
||||
/** A page to soft-delete in Docmost (Trash, SPEC §8). */
|
||||
export interface DeleteAction {
|
||||
pageId: string;
|
||||
}
|
||||
/** A renamed/moved page (same pageId, new path). Resolution DEFERRED. */
|
||||
export interface RenameMoveAction {
|
||||
pageId: string;
|
||||
oldPath: string;
|
||||
newPath: string;
|
||||
}
|
||||
/**
|
||||
* A CLASSIFIED rename/move (push #3): a `RenameMoveAction` resolved into the
|
||||
* Docmost op(s) it actually needs. The file PATH is the source of truth for tree
|
||||
* position (SPEC §5: "истина связи — pageId, не путь" — the path is COSMETIC and
|
||||
* LOCAL, the page identity is its pageId), so we compare the RESOLVED parent of
|
||||
* the new path against the resolved parent of the old path, and the title in the
|
||||
* current meta against the title in the previous meta. Each sub-op is emitted
|
||||
* ONLY when something real changed:
|
||||
* - `move` — the resolved parent page changed (reparent in Docmost). A `null`
|
||||
* `parentPageId` means the new parent is ROOT (the file sits at the space
|
||||
* root, no enclosing folder).
|
||||
* - `rename` — the page title changed (a pure title edit in Docmost).
|
||||
* - `noop` — neither changed: a purely LOCAL file-path rename (same parent,
|
||||
* same title). The page identity is its pageId, so Docmost is NOT called.
|
||||
* `move` and `rename` are independent and may BOTH be present (reparent + retitle).
|
||||
*/
|
||||
export interface RenameMoveActionClassified {
|
||||
pageId: string;
|
||||
oldPath: string;
|
||||
newPath: string;
|
||||
/** Present iff the resolved parent changed -> `move_page` (reparent). */
|
||||
move?: {
|
||||
parentPageId: string | null;
|
||||
};
|
||||
/** Present iff the title changed -> `rename_page` (title-only). */
|
||||
rename?: {
|
||||
title: string;
|
||||
};
|
||||
/** True iff neither parent nor title changed (cosmetic local-only rename). */
|
||||
noop?: true;
|
||||
}
|
||||
/**
|
||||
* Injected resolvers for the PURE `classifyRenameMoves` (push #3). Both are PURE
|
||||
* given a path + side; the real `main` (a follow-up) wires them to the file tree
|
||||
* (`readFile` for `current`, `git.showFileAtRef` for `prev`), tests pass plain
|
||||
* lookups. SPEC §5 path-as-truth:
|
||||
* - `metaAt`: the file's synthetic native meta at that side (title from the
|
||||
* filename, pageId from the `gitmost_id` frontmatter).
|
||||
* - `resolveParentPageId`: the pageId of the page whose FILE is the parent
|
||||
* FOLDER's `.md` (one level up from the given path), or `null` for ROOT.
|
||||
*/
|
||||
export interface ClassifyRenameMovesDeps {
|
||||
metaAt: (path: string, side: MetaSide) => DocmostMdMeta | null;
|
||||
resolveParentPageId: (path: string, side: MetaSide) => string | null;
|
||||
}
|
||||
/**
|
||||
* PURE classifier for the `renamesMoves` produced by `computePushActions`
|
||||
* (push #3, SPEC §5/§6/§8). Resolves each `{pageId, oldPath, newPath}` into the
|
||||
* Docmost op(s) it needs, with NO IO (both resolvers are injected).
|
||||
*
|
||||
* SPEC §5 — the file PATH is the source of truth for tree position, NOT the
|
||||
* (possibly stale) `meta.parentPageId`. So the NEW parent is resolved from
|
||||
* `newPath`'s enclosing folder, and the OLD parent from `oldPath`'s enclosing
|
||||
* folder, via `deps.resolveParentPageId`. The title comes from the meta.
|
||||
*
|
||||
* For each entry:
|
||||
* - `newParent = resolveParentPageId(newPath, 'current')`,
|
||||
* `oldParent = resolveParentPageId(oldPath, 'prev')`.
|
||||
* - `newTitle = metaAt(newPath,'current')?.title`,
|
||||
* `oldTitle = metaAt(oldPath,'prev')?.title`.
|
||||
* - include `move` iff `newParent !== oldParent` (a real reparent),
|
||||
* - include `rename` iff `newTitle` is a NON-EMPTY string AND differs from
|
||||
* `oldTitle` (a real title edit; an empty/absent new title is never a rename),
|
||||
* - if NEITHER applies -> `noop: true` (a cosmetic local-only file-path rename;
|
||||
* the page is its pageId, so Docmost is not touched).
|
||||
*/
|
||||
export declare function classifyRenameMoves(renamesMoves: RenameMoveAction[], deps: ClassifyRenameMovesDeps): RenameMoveActionClassified[];
|
||||
/** The classified set of push actions (PURE output of `computePushActions`). */
|
||||
export interface PushActions {
|
||||
creates: CreateAction[];
|
||||
updates: UpdateAction[];
|
||||
deletes: DeleteAction[];
|
||||
renamesMoves: RenameMoveAction[];
|
||||
/**
|
||||
* Diff rows that could NOT be classified into an action, with a reason — e.g.
|
||||
* a deleted file whose PRE-IMAGE meta carried no recoverable pageId (the
|
||||
* untracked-file guard, SPEC §8: only files that were tracked with a pageId
|
||||
* are deleted in Docmost). Carried so the caller can log them.
|
||||
*/
|
||||
skipped: {
|
||||
path: string;
|
||||
status: DiffEntry["status"];
|
||||
reason: string;
|
||||
}[];
|
||||
}
|
||||
/**
|
||||
* Which tree a `metaAt` lookup reads the file's native meta from:
|
||||
* - `current`: the current `main` tree (the live file content) — used for
|
||||
* A/M/R, where the file still exists.
|
||||
* - `prev`: the last-pushed PRE-IMAGE (e.g. `refs/docmost/last-pushed:<path>`)
|
||||
* — used for D, where the file is gone from `main` but its pageId must be
|
||||
* recovered from the version Docmost last knew (SPEC §6/§8).
|
||||
*/
|
||||
export type MetaSide = "current" | "prev";
|
||||
/** Input to the PURE planner. `metaAt` is injected (no IO inside the planner). */
|
||||
export interface PushActionsInput {
|
||||
/** Diff rows of `main` vs `refs/docmost/last-pushed` (SPEC §6 step 2). */
|
||||
changes: DiffEntry[];
|
||||
/**
|
||||
* Resolve a file's synthetic native meta at a given side, or `null` if the file is
|
||||
* absent there / has no parseable meta. PURE injection: the real `main` reads
|
||||
* the working tree (current) or `git show <last-pushed>:<path>` (prev); tests
|
||||
* pass a plain lookup.
|
||||
*/
|
||||
metaAt: (path: string, side: MetaSide) => DocmostMdMeta | null;
|
||||
/**
|
||||
* The pageIds present at ANY path in the current `main` tree (optional). When
|
||||
* given, a deleted file whose pageId still lives somewhere in the tree is NOT
|
||||
* a deletion but a MOVE — guards against trashing a live page when a layout
|
||||
* reshuffle relocated its file (possibly across two cycles, so the matching
|
||||
* add isn't in THIS diff). When omitted, only the in-diff D+A/M coalescing
|
||||
* applies.
|
||||
*/
|
||||
currentPageIds?: Set<string>;
|
||||
}
|
||||
/**
|
||||
* PURE push planner (SPEC §4/§6/§8). Classifies each diff row into a Docmost
|
||||
* action by `pageId` identity, with NO IO (the `metaAt` resolver is injected).
|
||||
*
|
||||
* Classification rules:
|
||||
* - `A` (added):
|
||||
* - current meta HAS a pageId -> UPDATE (a restored/copied file whose
|
||||
* page already exists; we push its content rather than create a dup).
|
||||
* - current meta has NO pageId but HAS a non-empty spaceId -> CREATE (a
|
||||
* brand-new local file; the page does not exist in Docmost yet).
|
||||
* - current meta has NO pageId and NO usable spaceId -> SKIP with reason
|
||||
* `create-without-spaceId`: Docmost `create_page` REQUIRES a spaceId
|
||||
* (§16), and a new local file may carry only partial human meta. We
|
||||
* refuse to create rather than guess a space (SPEC §8 guard spirit).
|
||||
* - `M` (modified): current meta has a pageId -> UPDATE content. (If a modified
|
||||
* file somehow lost its pageId it is skipped — there is nothing to target.)
|
||||
* - `D` (deleted): recover the pageId from the PRE-IMAGE meta (`metaAt(path,
|
||||
* 'prev')`) -> DELETE. If no pageId can be recovered, SKIP with a reason
|
||||
* (untracked-file guard, SPEC §8: never delete an untracked page).
|
||||
* - `R` (renamed/moved): same pageId (from current meta), path changed ->
|
||||
* RENAME/MOVE. Resolution of move-vs-rename + the new parentPageId is
|
||||
* DEFERRED to the next increment; here we only record oldPath/newPath/
|
||||
* pageId. If the renamed file has no recoverable pageId it is SKIPPED.
|
||||
* (`C` copy is treated the same as `R` for recording purposes.)
|
||||
*/
|
||||
export declare function computePushActions(input: PushActionsInput): PushActions;
|
||||
/** The marker the push direction advances after a successful push (SPEC §5/§6). */
|
||||
export declare const LAST_PUSHED_REF = "refs/docmost/last-pushed";
|
||||
/**
|
||||
* The mirror branch fast-forwarded after a clean push (SPEC §5/§6 step 3). It
|
||||
* reflects "what Docmost currently contains"; advancing it to the pushed `main`
|
||||
* commit closes the loop so the next pull diffs empty for the pushed pages.
|
||||
*/
|
||||
export declare const DOCMOST_BRANCH = "docmost";
|
||||
/**
|
||||
* Injectable IO for `applyPushActions`. The real `main` (NEXT increment) wires
|
||||
* these to the live client, `node:fs/promises`, and the vault git wrapper; this
|
||||
* increment drives them only through FAKES in tests (no live destructive run).
|
||||
* - `client`: the create/update/delete/move/rename subset of `GitSyncClient`.
|
||||
* - `readFile`/`writeFile`: read a changed file's body / write a file back
|
||||
* (by vault-relative path; the applier does not resolve absolute paths so
|
||||
* fakes stay trivial).
|
||||
* - `git`: `updateRef` (advance `refs/docmost/last-pushed`) and
|
||||
* `fastForwardBranch` (advance the `docmost` mirror after a clean push, the
|
||||
* loop-close — SPEC §6 step 3 / §10).
|
||||
*/
|
||||
export interface ApplyPushDeps {
|
||||
client: Pick<GitSyncClient, "importPageMarkdown" | "createPage" | "deletePage" | "movePage" | "renamePage">;
|
||||
/** Read a changed file's full text by its vault-relative path. */
|
||||
readFile: (path: string) => Promise<string>;
|
||||
/** Write a file's full text by its vault-relative path. */
|
||||
writeFile: (path: string, text: string) => Promise<void>;
|
||||
/**
|
||||
* The Docmost spaceId this vault mirrors. A CREATE targets this space (the
|
||||
* native file carries no spaceId — every file in the vault belongs to it), and
|
||||
* it backs the synthetic native meta the classifier reads.
|
||||
*/
|
||||
spaceId: string;
|
||||
/**
|
||||
* `updateRef` advances `refs/docmost/last-pushed`; `fastForwardBranch` advances
|
||||
* the `docmost` mirror after a clean push. `showFileAtRef` reads a file's text
|
||||
* at a ref (used by the move/rename classifier to resolve the PREVIOUS parent
|
||||
* folder's `.md` at `refs/docmost/last-pushed`, SPEC §5 path-as-truth).
|
||||
*/
|
||||
git: Pick<VaultGit, "updateRef" | "fastForwardBranch" | "showFileAtRef">;
|
||||
}
|
||||
/** A file whose meta was rewritten with a freshly-assigned pageId (post-create). */
|
||||
export interface WrittenBackPage {
|
||||
path: string;
|
||||
pageId: string;
|
||||
}
|
||||
/**
|
||||
* The per-page push record consulted by a FUTURE poll-suppression (SPEC §10): a
|
||||
* pulled page whose body hash + `updatedAt` match a record here is OUR OWN write
|
||||
* and must not be re-pulled. PRODUCED here; CONSUMED on the pull side later.
|
||||
*/
|
||||
export interface PushedPageRecord {
|
||||
/** The Docmost pageId that was updated/created. */
|
||||
pageId: string;
|
||||
/**
|
||||
* The `updatedAt` from the create/update client result, when the result
|
||||
* exposed one. Absent when the (fake) client did not return it.
|
||||
*/
|
||||
updatedAt?: string;
|
||||
/** Stable hash of the markdown BODY that was pushed (SPEC §10 "хэш тела"). */
|
||||
bodyHash: string;
|
||||
}
|
||||
/**
|
||||
* One page whose operation FAILED during apply (SPEC §12 resumability). The bad
|
||||
* page is isolated — recorded here — and the rest of the batch still runs; the
|
||||
* refs are NOT advanced when there is any failure, so a re-run retries cleanly.
|
||||
*/
|
||||
export interface PushFailure {
|
||||
kind: "update" | "create" | "delete" | "move" | "rename";
|
||||
/** The pageId for update/delete/move/rename; absent for a never-id'd create. */
|
||||
pageId?: string;
|
||||
/** The vault-relative path for create/update/move/rename; absent for delete. */
|
||||
path?: string;
|
||||
/** The error message captured from the thrown error. */
|
||||
error: string;
|
||||
}
|
||||
/**
|
||||
* A rename/move action that resolved to a NO-OP (push #3, SPEC §5): a purely
|
||||
* LOCAL file-path rename whose resolved parent AND title are both unchanged. The
|
||||
* page identity is its pageId and the path is COSMETIC/local-only, so Docmost is
|
||||
* NOT called — the skip is recorded here (with the reason) for logging.
|
||||
*/
|
||||
export interface PushNoop {
|
||||
pageId: string;
|
||||
oldPath: string;
|
||||
newPath: string;
|
||||
/** Why no Docmost op was emitted (currently always a path-only rename). */
|
||||
reason: "path-only-rename";
|
||||
}
|
||||
/** Structured outcome of `applyPushActions` (counts + write-backs + noops). */
|
||||
export interface ApplyPushResult {
|
||||
created: number;
|
||||
updated: number;
|
||||
deleted: number;
|
||||
/** Pages reparented in Docmost via `move_page` (push #3, SPEC §5/§16). */
|
||||
moved: number;
|
||||
/** Pages retitled in Docmost via `rename_page` (push #3, SPEC §5/§6). */
|
||||
renamed: number;
|
||||
/**
|
||||
* Files whose `gitmost_id` frontmatter was written with the pageId Docmost assigned on
|
||||
* create — these now need a FOLLOW-UP commit (the meta on disk changed). The
|
||||
* commit itself is the caller's job (NEXT increment); recorded here so it is
|
||||
* not lost.
|
||||
*/
|
||||
writtenBack: WrittenBackPage[];
|
||||
/**
|
||||
* Per-page push records (pageId + optional `updatedAt` + body hash) for every
|
||||
* page successfully updated/created — the §10 loop-guard data a future
|
||||
* poll-suppression (pull side) will consult so it does not re-pull our own
|
||||
* write. Deletes are not included (no body was pushed).
|
||||
*/
|
||||
pushed: PushedPageRecord[];
|
||||
/**
|
||||
* Pages whose operation threw — isolated and recorded, the batch continued
|
||||
* (SPEC §12). Non-empty here means the refs were NOT advanced.
|
||||
*/
|
||||
failures: PushFailure[];
|
||||
/**
|
||||
* Rename/move actions that resolved to a NO-OP — a purely LOCAL file-path
|
||||
* rename (same parent, same title). NO Docmost call was made for these (SPEC
|
||||
* §5: the page is its pageId, the path is local-only). Recorded for logging.
|
||||
*/
|
||||
noops: PushNoop[];
|
||||
/** Diff rows the planner could not classify (carried through for logging). */
|
||||
skipped: PushActions["skipped"];
|
||||
/** Whether `refs/docmost/last-pushed` was advanced (only on a CLEAN push). */
|
||||
lastPushedAdvanced: boolean;
|
||||
/**
|
||||
* Result of fast-forwarding the `docmost` mirror branch after a CLEAN push
|
||||
* (the loop-close, SPEC §6 step 3 / §10). `null` when no advance was attempted
|
||||
* (no `pushedCommit`, or there were failures). `{ ok:false, reason }` when a
|
||||
* non-fast-forward was REFUSED (divergent `docmost` history is never clobbered).
|
||||
*/
|
||||
docmostFastForward: {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
} | null;
|
||||
}
|
||||
/**
|
||||
* THIN IO applier for the COMMON push cases (create/update/delete). Exercised
|
||||
* via FAKES only in this increment — there is no live wiring.
|
||||
*
|
||||
* - UPDATE: read the file body, then `client.importPageMarkdown(pageId, body)`.
|
||||
* This is the collab/Yjs write path (SPEC §2/§15.6) — NEVER a raw jsonb
|
||||
* overwrite. The full self-contained markdown (meta + body) is sent as-is;
|
||||
* `importPageMarkdown` parses the meta/body itself.
|
||||
* - CREATE: derive title/spaceId/parentPageId from the file's current meta,
|
||||
* `client.createPage(...)`, take the assigned pageId from the result, and
|
||||
* write it BACK as the file's `gitmost_id` frontmatter (re-serialized via
|
||||
* `serializePageFile`, body preserved) so the file becomes
|
||||
* tracked. The write-back is recorded in `writtenBack` (a follow-up commit
|
||||
* is needed — NEXT increment).
|
||||
* - DELETE: `client.deletePage(pageId)` — soft-delete to Trash (SPEC §8).
|
||||
* - RENAME/MOVE (push #3, SPEC §5/§6/§16): classify each `renamesMoves` entry
|
||||
* with `classifyRenameMoves` (resolvers read the parent FOLDER's `.md` for
|
||||
* the parent pageId — path-as-truth — and the meta for the title), then:
|
||||
* - `move` -> `client.movePage(pageId, parentPageId, position?)` (reparent;
|
||||
* `position` is UNDEFINED for now — the client supplies a default),
|
||||
* - `rename` -> `client.renamePage(pageId, title)` (title-only),
|
||||
* - BOTH -> move (reparent) THEN rename (title), in that order,
|
||||
* - `noop` -> NO client call; recorded in `noops` (a cosmetic local-only
|
||||
* file-path rename: the page is its pageId, the path is local, SPEC §5).
|
||||
*
|
||||
* FAIL-SAFE / per-page isolation (SPEC §12 resumability). Each page's operation
|
||||
* is wrapped in its own try/catch: a single failing page is recorded in
|
||||
* `failures[]` (with its kind + pageId/path + error) and the batch CONTINUES —
|
||||
* one bad page must never block the rest. Crucially, the refs are advanced ONLY
|
||||
* when `failures.length === 0`: a PARTIAL push must NOT advance
|
||||
* `refs/docmost/last-pushed` or the `docmost` mirror, so a re-run retries the
|
||||
* whole batch cleanly (the already-applied pages are idempotent re-applies).
|
||||
*
|
||||
* LOOP-CLOSE (SPEC §6 step 3 / §10). After a fully-successful push, when a
|
||||
* `pushedCommit` is supplied:
|
||||
* - advance `refs/docmost/last-pushed` to it (what of `main` is in Docmost), AND
|
||||
* - fast-forward the `docmost` mirror branch to it via
|
||||
* `git.fastForwardBranch('docmost', pushedCommit)` — so the mirror reflects
|
||||
* what Docmost now contains and the NEXT pull diffs EMPTY for these pages
|
||||
* (it does not re-pull our own write). The ff is REFUSED (not forced) if
|
||||
* `docmost` is not an ancestor of the pushed commit; the result is surfaced
|
||||
* in `docmostFastForward`. On ANY failure, NEITHER ref is advanced.
|
||||
*
|
||||
* LOOP-GUARD DATA (SPEC §10). For every page successfully updated/created the
|
||||
* result carries a `pushed` record `{ pageId, updatedAt?, bodyHash }` — the body
|
||||
* hash of what was pushed plus the write's `updatedAt` (when the client returned
|
||||
* one). A future pull-side poll-suppression consults this so it does not re-pull
|
||||
* our own write; producing it is in scope here, consuming it is deferred.
|
||||
*
|
||||
* @param pushedCommit The `main` commit just reflected into Docmost (SHA or
|
||||
* commit-ish). When omitted, NEITHER ref is advanced (e.g. a dry plan).
|
||||
*/
|
||||
export declare function applyPushActions(deps: ApplyPushDeps, actions: PushActions, pushedCommit?: string): Promise<ApplyPushResult>;
|
||||
/**
|
||||
* SPEC §5 path-as-truth: the parent FOLDER's `.md` file for a vault-relative
|
||||
* (forward-slash) path. `buildVaultLayout` puts a page with children at
|
||||
* `<...>/Title.md` and nests its children under `<...>/Title/`, so for
|
||||
* `newPath = <dir>/Child.md` the parent page's file is `<dir>.md` (the enclosing
|
||||
* folder, one level up). A path with NO enclosing folder (`Child.md`, at the
|
||||
* space root) has no parent folder file -> `null` (the parent is ROOT).
|
||||
*/
|
||||
export declare function parentFolderFile(path: string): string | null;
|
||||
/**
|
||||
* Whether a vault path is a Docmost PAGE file (design §"Адопция"): a `.md` file
|
||||
* with NO dot-segment anywhere in its path. This excludes `.obsidian/` config,
|
||||
* `.trash/`, dotfiles (`.foo.md`), and every non-`.md` file (attachments, JSON,
|
||||
* …) — Obsidian owns those; they live in the vault but are never pages. Used to
|
||||
* screen the PUSH diff so non-page files are never created/updated/deleted in
|
||||
* Docmost (and never get a `gitmost_id` frontmatter written into them).
|
||||
*/
|
||||
export declare function isPageFile(path: string): boolean;
|
||||
/**
|
||||
* The human ("local") git identity used for engine-made commits on `main` in the
|
||||
* push direction (SPEC §7.3). The provenance is carried by the trailer (below),
|
||||
* which the loop-guard keys on; the identity is for history readability only.
|
||||
* When the vault repo already has a configured `user.name`/`user.email`, git
|
||||
* uses that for the working-tree commit; this is the fallback the daemon stamps.
|
||||
*/
|
||||
export declare const LOCAL_AUTHOR_NAME = "Local";
|
||||
export declare const LOCAL_AUTHOR_EMAIL = "local@local";
|
||||
/** The provenance trailer marking a `main`-side (human/local) commit (SPEC §7.3). */
|
||||
export declare const LOCAL_SOURCE_TRAILER = "Docmost-Sync-Source: local";
|
||||
/**
|
||||
* Injectable deps for `runPush` (mirrors `pull.ts`'s wiring; everything that
|
||||
* touches the outside world is here so tests pass fakes). `makeClient` is a
|
||||
* FACTORY, not a client — a dry-run must build NO client at all (it is never
|
||||
* called), and only `--apply` invokes it.
|
||||
*/
|
||||
export interface PushDeps {
|
||||
settings: Settings;
|
||||
git: Pick<VaultGit, "assertGitAvailable" | "ensureRepo" | "isMergeInProgress" | "checkout" | "stageAll" | "commit" | "readRef" | "revParse" | "diffNameStatus" | "showFileAtRef" | "updateRef" | "fastForwardBranch" | "listTrackedFiles">;
|
||||
/** Build a real client — called ONLY on `--apply`, never on dry-run. */
|
||||
makeClient: (settings: Settings) => ApplyPushDeps["client"];
|
||||
/** Read a file's full text by its vault-relative (forward-slash) path. */
|
||||
readFile: (path: string) => Promise<string>;
|
||||
/** Write a file's full text by its vault-relative path. */
|
||||
writeFile: (path: string, text: string) => Promise<void>;
|
||||
/** Structured logger (defaults to console in `main`; a recorder in tests). */
|
||||
log: (line: string) => void;
|
||||
}
|
||||
/** The structured outcome of a `runPush` cycle (returned + summarized). */
|
||||
export interface PushRunResult {
|
||||
/** Which path ran: `dry-run` (plan only) or `apply` (Docmost mutated). */
|
||||
mode: "dry-run" | "apply";
|
||||
/** Why the cycle stopped before planning, if it did (e.g. a left-over merge). */
|
||||
aborted?: "merge-in-progress";
|
||||
/** The diff base the plan was computed against (`last-pushed` else `docmost`). */
|
||||
base?: {
|
||||
ref: string;
|
||||
source: "last-pushed" | "docmost";
|
||||
sha: string | null;
|
||||
};
|
||||
/** The `main` commit the plan targets (the would-be pushed commit). */
|
||||
pushedCommit?: string;
|
||||
/** Planned action counts from the PURE planner (present once a plan was built). */
|
||||
planned?: {
|
||||
creates: number;
|
||||
updates: number;
|
||||
deletes: number;
|
||||
renamesMoves: number;
|
||||
skipped: number;
|
||||
};
|
||||
/** The applier's structured result — ONLY present on the `--apply` path. */
|
||||
applied?: ApplyPushResult;
|
||||
/**
|
||||
* True when `applyPushActions` REFUSED to fast-forward a divergent `docmost`
|
||||
* mirror (SPEC §5 invariant broken). Escalated (logged prominently) and folded
|
||||
* into the CLI's non-zero exit.
|
||||
*/
|
||||
divergentDocmost?: boolean;
|
||||
/** Per-page failures from the applier (empty/absent on a clean run). */
|
||||
failures?: PushFailure[];
|
||||
}
|
||||
/**
|
||||
* Run one FS->Docmost push cycle (SPEC §6 "ФС → Docmost"), DRY-RUN BY DEFAULT.
|
||||
*
|
||||
* Steps (mirrors `pull.ts`):
|
||||
* 1. Preflight git: `assertGitAvailable` + `ensureRepo`; ABORT (clear message +
|
||||
* non-zero-ish result) if a merge is in progress — never push on top of an
|
||||
* unresolved conflict (SPEC §9/§12). Conflict markers must NEVER reach
|
||||
* Docmost (SPEC §9).
|
||||
* 2. Checkout `main` (the human-facing branch the push reads from).
|
||||
* 3. Commit the human's pending working-tree changes on `main` with the
|
||||
* `local` provenance trailer (SPEC §7.3). A no-op when nothing changed.
|
||||
* 4. Pick the diff BASE: `refs/docmost/last-pushed` if it resolves, else the
|
||||
* `docmost` mirror branch (what Docmost currently has). Resolve `main`.
|
||||
* 5. `diffNameStatus(base, main)` -> changes; build the `metaAt(path, side)`
|
||||
* resolver (current = working tree, prev = `git show <base>:<path>`); run
|
||||
* the PURE `computePushActions`.
|
||||
* 6. DRY-RUN (default): LOG the full plan and RETURN — NO client, NO Docmost
|
||||
* calls, NO ref advance.
|
||||
* 7. `--apply`: build the client, run `applyPushActions(..., pushedCommit=main)`,
|
||||
* then (a) if any pageIds were written back (creates), commit them on `main`
|
||||
* with the `local` trailer and RE-advance `refs/docmost/last-pushed` to the
|
||||
* new commit so the recorded pageIds are persisted in what Docmost mirrors;
|
||||
* (b) ESCALATE a divergent-`docmost` ff refusal (SPEC §5) with a prominent
|
||||
* WARNING and a non-zero-ish flag. Then log a one-line summary.
|
||||
*/
|
||||
export declare function runPush(deps: PushDeps, opts: {
|
||||
dryRun: boolean;
|
||||
}): Promise<PushRunResult>;
|
||||
/** Parsed `push` CLI flags. DRY-RUN is the default; `--apply` opts into writes. */
|
||||
export interface PushParsedArgs {
|
||||
/** True when `--apply` was passed (the ONLY path that writes to Docmost). */
|
||||
apply: boolean;
|
||||
}
|
||||
/**
|
||||
* Parse the `push` CLI flags. SAFE BY DEFAULT: without `--apply` the run is a
|
||||
* DRY-RUN (plan only). Exported so the flag handling is unit-testable.
|
||||
*/
|
||||
export declare function parseArgs(argv: string[]): PushParsedArgs;
|
||||
@@ -1,971 +0,0 @@
|
||||
import { parsePageFile, serializePageFile } from "../lib/page-file.js";
|
||||
import { DEFAULT_BRANCH } from "./git.js";
|
||||
import { bodyHash } from "./loop-guard.js";
|
||||
/**
|
||||
* PURE classifier for the `renamesMoves` produced by `computePushActions`
|
||||
* (push #3, SPEC §5/§6/§8). Resolves each `{pageId, oldPath, newPath}` into the
|
||||
* Docmost op(s) it needs, with NO IO (both resolvers are injected).
|
||||
*
|
||||
* SPEC §5 — the file PATH is the source of truth for tree position, NOT the
|
||||
* (possibly stale) `meta.parentPageId`. So the NEW parent is resolved from
|
||||
* `newPath`'s enclosing folder, and the OLD parent from `oldPath`'s enclosing
|
||||
* folder, via `deps.resolveParentPageId`. The title comes from the meta.
|
||||
*
|
||||
* For each entry:
|
||||
* - `newParent = resolveParentPageId(newPath, 'current')`,
|
||||
* `oldParent = resolveParentPageId(oldPath, 'prev')`.
|
||||
* - `newTitle = metaAt(newPath,'current')?.title`,
|
||||
* `oldTitle = metaAt(oldPath,'prev')?.title`.
|
||||
* - include `move` iff `newParent !== oldParent` (a real reparent),
|
||||
* - include `rename` iff `newTitle` is a NON-EMPTY string AND differs from
|
||||
* `oldTitle` (a real title edit; an empty/absent new title is never a rename),
|
||||
* - if NEITHER applies -> `noop: true` (a cosmetic local-only file-path rename;
|
||||
* the page is its pageId, so Docmost is not touched).
|
||||
*/
|
||||
export function classifyRenameMoves(renamesMoves, deps) {
|
||||
return renamesMoves.map((rm) => {
|
||||
const newParent = deps.resolveParentPageId(rm.newPath, "current");
|
||||
const oldParent = deps.resolveParentPageId(rm.oldPath, "prev");
|
||||
const newTitle = deps.metaAt(rm.newPath, "current")?.title;
|
||||
const oldTitle = deps.metaAt(rm.oldPath, "prev")?.title;
|
||||
const out = {
|
||||
pageId: rm.pageId,
|
||||
oldPath: rm.oldPath,
|
||||
newPath: rm.newPath,
|
||||
};
|
||||
// A reparent: the new path's resolved parent page differs from the old's.
|
||||
if (newParent !== oldParent) {
|
||||
out.move = { parentPageId: newParent };
|
||||
}
|
||||
// A title edit: only when there is a real, non-empty new title that changed.
|
||||
if (typeof newTitle === "string" &&
|
||||
newTitle.length > 0 &&
|
||||
newTitle !== oldTitle) {
|
||||
out.rename = { title: newTitle };
|
||||
}
|
||||
// Neither changed -> a purely LOCAL file-path rename; do NOT call Docmost.
|
||||
if (!out.move && !out.rename) {
|
||||
out.noop = true;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
/**
|
||||
* PURE push planner (SPEC §4/§6/§8). Classifies each diff row into a Docmost
|
||||
* action by `pageId` identity, with NO IO (the `metaAt` resolver is injected).
|
||||
*
|
||||
* Classification rules:
|
||||
* - `A` (added):
|
||||
* - current meta HAS a pageId -> UPDATE (a restored/copied file whose
|
||||
* page already exists; we push its content rather than create a dup).
|
||||
* - current meta has NO pageId but HAS a non-empty spaceId -> CREATE (a
|
||||
* brand-new local file; the page does not exist in Docmost yet).
|
||||
* - current meta has NO pageId and NO usable spaceId -> SKIP with reason
|
||||
* `create-without-spaceId`: Docmost `create_page` REQUIRES a spaceId
|
||||
* (§16), and a new local file may carry only partial human meta. We
|
||||
* refuse to create rather than guess a space (SPEC §8 guard spirit).
|
||||
* - `M` (modified): current meta has a pageId -> UPDATE content. (If a modified
|
||||
* file somehow lost its pageId it is skipped — there is nothing to target.)
|
||||
* - `D` (deleted): recover the pageId from the PRE-IMAGE meta (`metaAt(path,
|
||||
* 'prev')`) -> DELETE. If no pageId can be recovered, SKIP with a reason
|
||||
* (untracked-file guard, SPEC §8: never delete an untracked page).
|
||||
* - `R` (renamed/moved): same pageId (from current meta), path changed ->
|
||||
* RENAME/MOVE. Resolution of move-vs-rename + the new parentPageId is
|
||||
* DEFERRED to the next increment; here we only record oldPath/newPath/
|
||||
* pageId. If the renamed file has no recoverable pageId it is SKIPPED.
|
||||
* (`C` copy is treated the same as `R` for recording purposes.)
|
||||
*/
|
||||
export function computePushActions(input) {
|
||||
const { metaAt, currentPageIds } = input;
|
||||
// PAGE-FILE FILTER (design §"Адопция"): only `.md` files OUTSIDE any dot-folder
|
||||
// are Docmost pages. `.obsidian/*`, attachments, and other non-page files are
|
||||
// committed to the vault (no `.gitignore`) and so appear in the diff, but they
|
||||
// are NEVER pages — Obsidian owns them. Without this filter every ADDED such
|
||||
// file would be mis-classified as a CREATE (nativeMeta always supplies a
|
||||
// spaceId, so the old `create-without-spaceId` skip no longer screens them),
|
||||
// creating junk pages in Docmost and corrupting the file with a `gitmost_id`
|
||||
// frontmatter. Filter BEFORE any classification so non-page A/M/D/R are ignored.
|
||||
const changes = input.changes.filter((c) => isPageFile(c.path));
|
||||
const actions = {
|
||||
creates: [],
|
||||
updates: [],
|
||||
deletes: [],
|
||||
renamesMoves: [],
|
||||
skipped: [],
|
||||
};
|
||||
// GHOST-MOVE coalescing (⭐ data-loss guard). git's rename detection (`-M`)
|
||||
// can miss a move when the two files are too dissimilar — which is exactly the
|
||||
// case for the tiny meta-only files a layout RESHUFFLE produces (e.g.
|
||||
// several untitled pages sharing the `_` fallback name; retitling one frees the
|
||||
// bare `_` and another page's file relocates `_ ~slug.md` -> `_.md`). git then
|
||||
// reports the move as a DELETE of the old path + an ADD of the new one. Taken
|
||||
// literally that soft-deletes a page that merely MOVED — a live page vanishing
|
||||
// into Trash. Identity is the pageId, not git's heuristic: a pageId that is
|
||||
// BOTH deleted (pre-image) and added (current) is one page that relocated, so
|
||||
// we classify it as a rename/move and NEVER as a delete.
|
||||
// A pageId can land at its new path two ways: as an ADD (the path was free) or
|
||||
// as a MODIFY (the path was occupied by ANOTHER page that left — the reshuffle
|
||||
// case, where `_.md`'s occupant changes pageId). Both are "the page survives at
|
||||
// a new path", so the surviving side is the CURRENT-meta pageId of A *and* M.
|
||||
const deletedPath = new Map();
|
||||
const survivingPath = new Map();
|
||||
for (const change of changes) {
|
||||
if (change.status === "D") {
|
||||
const pid = metaAt(change.path, "prev")?.pageId;
|
||||
if (pid)
|
||||
deletedPath.set(pid, change.path);
|
||||
}
|
||||
else if (change.status === "A" || change.status === "M") {
|
||||
const pid = metaAt(change.path, "current")?.pageId;
|
||||
if (pid)
|
||||
survivingPath.set(pid, change.path);
|
||||
}
|
||||
}
|
||||
const ghostMove = new Map();
|
||||
for (const [pid, oldPath] of deletedPath) {
|
||||
const newPath = survivingPath.get(pid);
|
||||
if (newPath && newPath !== oldPath) {
|
||||
ghostMove.set(pid, { oldPath, newPath });
|
||||
}
|
||||
}
|
||||
for (const change of changes) {
|
||||
switch (change.status) {
|
||||
case "A": {
|
||||
const meta = metaAt(change.path, "current");
|
||||
const pageId = meta?.pageId;
|
||||
if (pageId && ghostMove.has(pageId)) {
|
||||
// Half of a git-undetected move (a matching DELETE exists): record it
|
||||
// as a rename/move (like a real `R`), NOT an update — the `D` side is
|
||||
// suppressed so the page is never soft-deleted.
|
||||
actions.renamesMoves.push({
|
||||
pageId,
|
||||
oldPath: ghostMove.get(pageId).oldPath,
|
||||
newPath: change.path,
|
||||
});
|
||||
}
|
||||
else if (pageId) {
|
||||
// Added but already carries a pageId (restored/copied file): the page
|
||||
// exists in Docmost, so push content as an UPDATE — never a duplicate.
|
||||
actions.updates.push({ pageId, path: change.path });
|
||||
}
|
||||
else if (meta?.spaceId) {
|
||||
// Brand-new local file with a target space -> create the page, then
|
||||
// write the assigned pageId back into its meta (in `applyPushActions`).
|
||||
// `meta.spaceId` is truthy here, so empty-string is also rejected.
|
||||
actions.creates.push({ path: change.path });
|
||||
}
|
||||
else {
|
||||
// A create needs a spaceId (Docmost `create_page` requires it, §16). A
|
||||
// new file with partial meta and no usable spaceId is SKIPPED rather
|
||||
// than created into a guessed space (SPEC §8 guard spirit).
|
||||
actions.skipped.push({
|
||||
path: change.path,
|
||||
status: "A",
|
||||
reason: "create-without-spaceId",
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "M": {
|
||||
const meta = metaAt(change.path, "current");
|
||||
const pageId = meta?.pageId;
|
||||
if (pageId && ghostMove.has(pageId)) {
|
||||
// This path's occupant changed pageId: the previous page left and THIS
|
||||
// page relocated here (a reshuffle). Its old file was DELETED elsewhere
|
||||
// — coalesce into a rename/move so the page is never trashed.
|
||||
actions.renamesMoves.push({
|
||||
pageId,
|
||||
oldPath: ghostMove.get(pageId).oldPath,
|
||||
newPath: change.path,
|
||||
});
|
||||
}
|
||||
else if (pageId) {
|
||||
actions.updates.push({ pageId, path: change.path });
|
||||
}
|
||||
else {
|
||||
// A modified file with no pageId has no Docmost target to update.
|
||||
actions.skipped.push({
|
||||
path: change.path,
|
||||
status: "M",
|
||||
reason: "modified file has no pageId in meta",
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "D": {
|
||||
// The file is gone from `main`; recover its pageId from the PRE-IMAGE
|
||||
// (the version last pushed to Docmost) so we delete the RIGHT page.
|
||||
const prevMeta = metaAt(change.path, "prev");
|
||||
const pageId = prevMeta?.pageId;
|
||||
if (pageId && ghostMove.has(pageId)) {
|
||||
// The same pageId was re-ADDED at a new path: this is a git-undetected
|
||||
// MOVE, handled by the `A` branch above. Suppress the delete so a moved
|
||||
// page is never trashed (⭐ data-loss guard).
|
||||
actions.skipped.push({
|
||||
path: change.path,
|
||||
status: "D",
|
||||
reason: "ghost-move (re-added at a new path) — not a deletion",
|
||||
});
|
||||
}
|
||||
else if (pageId && currentPageIds?.has(pageId)) {
|
||||
// The pageId still EXISTS elsewhere in the current tree: the file moved
|
||||
// (a layout reshuffle whose matching add was in an earlier cycle, so it
|
||||
// is not in this diff). A live page must never be trashed because its
|
||||
// FILENAME changed — identity is the pageId (⭐ data-loss guard).
|
||||
actions.skipped.push({
|
||||
path: change.path,
|
||||
status: "D",
|
||||
reason: "pageId still present in the tree (moved) — not a deletion",
|
||||
});
|
||||
}
|
||||
else if (pageId) {
|
||||
actions.deletes.push({ pageId });
|
||||
}
|
||||
else {
|
||||
// Untracked-file guard (SPEC §8): a file with no recoverable pageId was
|
||||
// never a Docmost page — do NOT translate its removal into a delete.
|
||||
actions.skipped.push({
|
||||
path: change.path,
|
||||
status: "D",
|
||||
reason: "deleted file has no recoverable pageId (pre-image meta)",
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "R":
|
||||
case "C": {
|
||||
// Same page, new path. Identity comes from the CURRENT (post-rename) meta
|
||||
// since the file still exists. RESOLUTION (move vs rename, parentPageId)
|
||||
// is deferred — record oldPath/newPath/pageId only.
|
||||
const meta = metaAt(change.path, "current");
|
||||
const pageId = meta?.pageId;
|
||||
const oldPath = change.oldPath ?? change.path;
|
||||
if (pageId) {
|
||||
actions.renamesMoves.push({
|
||||
pageId,
|
||||
oldPath,
|
||||
newPath: change.path,
|
||||
});
|
||||
}
|
||||
else {
|
||||
actions.skipped.push({
|
||||
path: change.path,
|
||||
status: change.status,
|
||||
reason: "renamed/moved file has no pageId in meta",
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Unreachable for A/M/D/R/C; defensive for any future status.
|
||||
actions.skipped.push({
|
||||
path: change.path,
|
||||
status: change.status,
|
||||
reason: `unhandled diff status ${change.status}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
// --- thin apply (create/update/delete), fakes-only in this increment ---------
|
||||
/** The marker the push direction advances after a successful push (SPEC §5/§6). */
|
||||
export const LAST_PUSHED_REF = "refs/docmost/last-pushed";
|
||||
/**
|
||||
* The mirror branch fast-forwarded after a clean push (SPEC §5/§6 step 3). It
|
||||
* reflects "what Docmost currently contains"; advancing it to the pushed `main`
|
||||
* commit closes the loop so the next pull diffs empty for the pushed pages.
|
||||
*/
|
||||
export const DOCMOST_BRANCH = "docmost";
|
||||
/**
|
||||
* THIN IO applier for the COMMON push cases (create/update/delete). Exercised
|
||||
* via FAKES only in this increment — there is no live wiring.
|
||||
*
|
||||
* - UPDATE: read the file body, then `client.importPageMarkdown(pageId, body)`.
|
||||
* This is the collab/Yjs write path (SPEC §2/§15.6) — NEVER a raw jsonb
|
||||
* overwrite. The full self-contained markdown (meta + body) is sent as-is;
|
||||
* `importPageMarkdown` parses the meta/body itself.
|
||||
* - CREATE: derive title/spaceId/parentPageId from the file's current meta,
|
||||
* `client.createPage(...)`, take the assigned pageId from the result, and
|
||||
* write it BACK as the file's `gitmost_id` frontmatter (re-serialized via
|
||||
* `serializePageFile`, body preserved) so the file becomes
|
||||
* tracked. The write-back is recorded in `writtenBack` (a follow-up commit
|
||||
* is needed — NEXT increment).
|
||||
* - DELETE: `client.deletePage(pageId)` — soft-delete to Trash (SPEC §8).
|
||||
* - RENAME/MOVE (push #3, SPEC §5/§6/§16): classify each `renamesMoves` entry
|
||||
* with `classifyRenameMoves` (resolvers read the parent FOLDER's `.md` for
|
||||
* the parent pageId — path-as-truth — and the meta for the title), then:
|
||||
* - `move` -> `client.movePage(pageId, parentPageId, position?)` (reparent;
|
||||
* `position` is UNDEFINED for now — the client supplies a default),
|
||||
* - `rename` -> `client.renamePage(pageId, title)` (title-only),
|
||||
* - BOTH -> move (reparent) THEN rename (title), in that order,
|
||||
* - `noop` -> NO client call; recorded in `noops` (a cosmetic local-only
|
||||
* file-path rename: the page is its pageId, the path is local, SPEC §5).
|
||||
*
|
||||
* FAIL-SAFE / per-page isolation (SPEC §12 resumability). Each page's operation
|
||||
* is wrapped in its own try/catch: a single failing page is recorded in
|
||||
* `failures[]` (with its kind + pageId/path + error) and the batch CONTINUES —
|
||||
* one bad page must never block the rest. Crucially, the refs are advanced ONLY
|
||||
* when `failures.length === 0`: a PARTIAL push must NOT advance
|
||||
* `refs/docmost/last-pushed` or the `docmost` mirror, so a re-run retries the
|
||||
* whole batch cleanly (the already-applied pages are idempotent re-applies).
|
||||
*
|
||||
* LOOP-CLOSE (SPEC §6 step 3 / §10). After a fully-successful push, when a
|
||||
* `pushedCommit` is supplied:
|
||||
* - advance `refs/docmost/last-pushed` to it (what of `main` is in Docmost), AND
|
||||
* - fast-forward the `docmost` mirror branch to it via
|
||||
* `git.fastForwardBranch('docmost', pushedCommit)` — so the mirror reflects
|
||||
* what Docmost now contains and the NEXT pull diffs EMPTY for these pages
|
||||
* (it does not re-pull our own write). The ff is REFUSED (not forced) if
|
||||
* `docmost` is not an ancestor of the pushed commit; the result is surfaced
|
||||
* in `docmostFastForward`. On ANY failure, NEITHER ref is advanced.
|
||||
*
|
||||
* LOOP-GUARD DATA (SPEC §10). For every page successfully updated/created the
|
||||
* result carries a `pushed` record `{ pageId, updatedAt?, bodyHash }` — the body
|
||||
* hash of what was pushed plus the write's `updatedAt` (when the client returned
|
||||
* one). A future pull-side poll-suppression consults this so it does not re-pull
|
||||
* our own write; producing it is in scope here, consuming it is deferred.
|
||||
*
|
||||
* @param pushedCommit The `main` commit just reflected into Docmost (SHA or
|
||||
* commit-ish). When omitted, NEITHER ref is advanced (e.g. a dry plan).
|
||||
*/
|
||||
export async function applyPushActions(deps, actions, pushedCommit) {
|
||||
const { client, git } = deps;
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
let deleted = 0;
|
||||
let moved = 0;
|
||||
let renamed = 0;
|
||||
const writtenBack = [];
|
||||
const pushed = [];
|
||||
const failures = [];
|
||||
const noops = [];
|
||||
// 1. UPDATES — collab/Yjs write path (SPEC §2/§15.6), never a raw overwrite.
|
||||
// Each update is isolated: a thrown page is recorded and the batch goes on.
|
||||
for (const u of actions.updates) {
|
||||
try {
|
||||
// Push the CLEAN body only (no `gitmost_id` frontmatter): the frontmatter
|
||||
// is engine metadata, never page content. The server converts the markdown
|
||||
// it receives verbatim, so stripping here keeps the id out of Docmost.
|
||||
const body = parsePageFile(await deps.readFile(u.path)).body;
|
||||
// The last-synced version of this file (pre-image) is the common ancestor
|
||||
// for a 3-way merge against the live page, so concurrent human edits are
|
||||
// not clobbered (review #5). Null when the file is new at last-pushed. Its
|
||||
// body is stripped the SAME way so the merge compares body-to-body.
|
||||
const baseFull = await deps.git.showFileAtRef(LAST_PUSHED_REF, u.path);
|
||||
const baseMarkdown = baseFull === null ? null : parsePageFile(baseFull).body;
|
||||
const result = await client.importPageMarkdown(u.pageId, body, baseMarkdown);
|
||||
updated++;
|
||||
// §10 loop-guard data: hash the BODY we pushed + capture `updatedAt`.
|
||||
pushed.push({
|
||||
pageId: u.pageId,
|
||||
...extractUpdatedAt(result),
|
||||
bodyHash: bodyHash(body),
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
failures.push({
|
||||
kind: "update",
|
||||
pageId: u.pageId,
|
||||
path: u.path,
|
||||
error: errMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
// 2. CREATES — create the page, then write the assigned pageId back to meta so
|
||||
// the file becomes tracked (SPEC §4 "записать присвоенный pageId обратно").
|
||||
// Isolated per page like updates.
|
||||
for (const c of actions.creates) {
|
||||
try {
|
||||
const text = await deps.readFile(c.path);
|
||||
const { body } = parsePageFile(text);
|
||||
// Derive create args from the PATH (native-Obsidian, SPEC §5): title from
|
||||
// the filename, parent from the enclosing folder's folder-note, space from
|
||||
// the run (the vault's space). `parentPageId: null` -> created at ROOT.
|
||||
const title = titleFromPath(c.path);
|
||||
const parentPageId = (await resolveParentPageIdViaTree(deps, c.path, "current")) ?? undefined;
|
||||
const result = await client.createPage(title, body, deps.spaceId, parentPageId);
|
||||
// `createPage` returns `{ data: { id, ... }, success }`; the assigned
|
||||
// pageId is at `result.data.id`.
|
||||
const assignedPageId = result?.data?.id;
|
||||
if (assignedPageId) {
|
||||
// Write the assigned pageId back as the `gitmost_id` frontmatter, body
|
||||
// preserved — the file becomes engine-tracked (SPEC §4).
|
||||
const rewritten = serializePageFile(assignedPageId, body);
|
||||
await deps.writeFile(c.path, rewritten);
|
||||
writtenBack.push({ path: c.path, pageId: assignedPageId });
|
||||
// §10 loop-guard data for the created page (hash the pushed BODY).
|
||||
pushed.push({
|
||||
pageId: assignedPageId,
|
||||
...extractUpdatedAt(result),
|
||||
bodyHash: bodyHash(body),
|
||||
});
|
||||
}
|
||||
created++;
|
||||
}
|
||||
catch (err) {
|
||||
failures.push({ kind: "create", path: c.path, error: errMessage(err) });
|
||||
}
|
||||
}
|
||||
// 3. DELETES — soft-delete to Trash (SPEC §8), reversible. Isolated per page.
|
||||
for (const d of actions.deletes) {
|
||||
try {
|
||||
await client.deletePage(d.pageId);
|
||||
deleted++;
|
||||
}
|
||||
catch (err) {
|
||||
failures.push({
|
||||
kind: "delete",
|
||||
pageId: d.pageId,
|
||||
error: errMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
// 4. RENAME/MOVE (push #3, SPEC §5/§6/§16). Classify each entry against the
|
||||
// tree-backed resolvers (the NEW parent comes from the new path's enclosing
|
||||
// folder `.md`, the OLD parent from the old path's at last-pushed — PATH is
|
||||
// the truth, not stale `meta.parentPageId`; the title from the meta), then
|
||||
// apply only the real ops. Each page is isolated like the cases above: a
|
||||
// thrown op is recorded in `failures` and the batch continues. ORDER for a
|
||||
// page that needs both: reparent (move) FIRST, then retitle (rename).
|
||||
if (actions.renamesMoves.length > 0) {
|
||||
// The classifier is PURE over sync resolvers; the tree reads are async, so
|
||||
// prefetch every (path, side) lookup it will make into plain tables first.
|
||||
const parentTable = new Map();
|
||||
const metaTable = new Map();
|
||||
// A tree read (readFile / git.showFileAtRef) throwing must isolate THAT page
|
||||
// into `failures`, NOT abort the whole batch (§12 resumability). The helpers
|
||||
// already swallow their own errors, but this per-entry try/catch keeps the
|
||||
// batch-isolation invariant holding regardless of future changes to them.
|
||||
const prefetchFailed = new Set();
|
||||
for (const rm of actions.renamesMoves) {
|
||||
// newParent + newTitle from the CURRENT tree; oldParent + oldTitle from the
|
||||
// last-pushed pre-image (`prev`). Keyed by `path|side` so duplicates fold.
|
||||
try {
|
||||
parentTable.set(`${rm.newPath}|current`, await resolveParentPageIdViaTree(deps, rm.newPath, "current"));
|
||||
parentTable.set(`${rm.oldPath}|prev`, await resolveParentPageIdViaTree(deps, rm.oldPath, "prev"));
|
||||
metaTable.set(`${rm.newPath}|current`, await metaAtViaTree(deps, rm.newPath, "current", deps.spaceId));
|
||||
metaTable.set(`${rm.oldPath}|prev`, await metaAtViaTree(deps, rm.oldPath, "prev", deps.spaceId));
|
||||
}
|
||||
catch (err) {
|
||||
prefetchFailed.add(rm.pageId);
|
||||
failures.push({
|
||||
kind: "move",
|
||||
pageId: rm.pageId,
|
||||
path: rm.newPath,
|
||||
error: errMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
const classified = classifyRenameMoves(actions.renamesMoves.filter((rm) => !prefetchFailed.has(rm.pageId)), {
|
||||
metaAt: (path, side) => metaTable.get(`${path}|${side}`) ?? null,
|
||||
resolveParentPageId: (path, side) => parentTable.get(`${path}|${side}`) ?? null,
|
||||
});
|
||||
for (const c of classified) {
|
||||
if (c.noop) {
|
||||
// Cosmetic local-only file-path rename — no Docmost op (SPEC §5).
|
||||
noops.push({
|
||||
pageId: c.pageId,
|
||||
oldPath: c.oldPath,
|
||||
newPath: c.newPath,
|
||||
reason: "path-only-rename",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// Track which op is in flight so a failure is attributed to the op that
|
||||
// ACTUALLY threw: for a page needing both, a move that succeeds then a
|
||||
// rename that throws must be recorded as `rename`, not `move`.
|
||||
let failingKind = c.move ? "move" : "rename";
|
||||
try {
|
||||
// Reparent FIRST so the page is in its new tree position, THEN retitle.
|
||||
if (c.move) {
|
||||
failingKind = "move";
|
||||
// TODO(next): compute a fractional-index position between siblings
|
||||
// (SPEC §16). `position` is UNDEFINED here; the client supplies a valid
|
||||
// default. Pass `parentPageId: null` for a move to the space ROOT.
|
||||
await client.movePage(c.pageId, c.move.parentPageId);
|
||||
moved++;
|
||||
}
|
||||
if (c.rename) {
|
||||
failingKind = "rename";
|
||||
await client.renamePage(c.pageId, c.rename.title);
|
||||
renamed++;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
// Isolate the failed page: the op that ACTUALLY threw is recorded so a
|
||||
// re-run can retry. A move that threw before its rename leaves `rename`
|
||||
// for the next run (idempotent re-apply); refs are NOT advanced (below).
|
||||
failures.push({
|
||||
kind: failingKind,
|
||||
pageId: c.pageId,
|
||||
path: c.newPath,
|
||||
error: errMessage(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// 5. Advance the refs ONLY on a CLEAN push (no failures) AND when a pushed
|
||||
// commit is supplied. A partial push must advance NEITHER ref, so a re-run
|
||||
// retries the whole batch (SPEC §12). The loop-close (SPEC §6 step 3 / §10):
|
||||
// advance `refs/docmost/last-pushed` AND fast-forward the `docmost` mirror,
|
||||
// so Docmost's new content is mirrored and the next pull diffs empty.
|
||||
let lastPushedAdvanced = false;
|
||||
let docmostFastForward = null;
|
||||
if (pushedCommit && failures.length === 0) {
|
||||
await git.updateRef(LAST_PUSHED_REF, pushedCommit);
|
||||
lastPushedAdvanced = true;
|
||||
// Fast-forward the mirror (refused, not forced, on a non-fast-forward — the
|
||||
// caller logs the reason). Surfaced in the result.
|
||||
docmostFastForward = await git.fastForwardBranch(DOCMOST_BRANCH, pushedCommit);
|
||||
}
|
||||
return {
|
||||
created,
|
||||
updated,
|
||||
deleted,
|
||||
moved,
|
||||
renamed,
|
||||
writtenBack,
|
||||
pushed,
|
||||
failures,
|
||||
noops,
|
||||
skipped: actions.skipped,
|
||||
lastPushedAdvanced,
|
||||
docmostFastForward,
|
||||
};
|
||||
}
|
||||
/** Stringify a thrown value into a stable error message. */
|
||||
function errMessage(err) {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
/**
|
||||
* SPEC §5 path-as-truth: the parent FOLDER's `.md` file for a vault-relative
|
||||
* (forward-slash) path. `buildVaultLayout` puts a page with children at
|
||||
* `<...>/Title.md` and nests its children under `<...>/Title/`, so for
|
||||
* `newPath = <dir>/Child.md` the parent page's file is `<dir>.md` (the enclosing
|
||||
* folder, one level up). A path with NO enclosing folder (`Child.md`, at the
|
||||
* space root) has no parent folder file -> `null` (the parent is ROOT).
|
||||
*/
|
||||
export function parentFolderFile(path) {
|
||||
const slash = path.lastIndexOf("/");
|
||||
if (slash < 0)
|
||||
return null; // root-level file: parent is ROOT.
|
||||
const dir = path.slice(0, slash); // the enclosing folder
|
||||
// The page that OWNS the enclosing folder is its folder-note `<dir>/<base>.md`.
|
||||
const folderNote = `${dir}/${baseSegment(dir)}.md`;
|
||||
if (path === folderNote) {
|
||||
// This path IS its folder's folder-note, so its parent is ONE LEVEL UP: the
|
||||
// folder-note of the grandparent folder (or ROOT at the top level).
|
||||
const up = dir.lastIndexOf("/");
|
||||
if (up < 0)
|
||||
return null; // top-level folder -> parent is ROOT.
|
||||
const grandDir = dir.slice(0, up);
|
||||
return `${grandDir}/${baseSegment(grandDir)}.md`;
|
||||
}
|
||||
// A leaf (or a nested folder-note) sitting inside `dir`: its parent is `dir`'s
|
||||
// folder-note.
|
||||
return folderNote;
|
||||
}
|
||||
/**
|
||||
* Whether a vault path is a Docmost PAGE file (design §"Адопция"): a `.md` file
|
||||
* with NO dot-segment anywhere in its path. This excludes `.obsidian/` config,
|
||||
* `.trash/`, dotfiles (`.foo.md`), and every non-`.md` file (attachments, JSON,
|
||||
* …) — Obsidian owns those; they live in the vault but are never pages. Used to
|
||||
* screen the PUSH diff so non-page files are never created/updated/deleted in
|
||||
* Docmost (and never get a `gitmost_id` frontmatter written into them).
|
||||
*/
|
||||
export function isPageFile(path) {
|
||||
if (!path.endsWith(".md"))
|
||||
return false;
|
||||
return !path.split("/").some((seg) => seg.startsWith("."));
|
||||
}
|
||||
/** The last path segment of a forward-slash path (the folder/file base name). */
|
||||
function baseSegment(path) {
|
||||
const slash = path.lastIndexOf("/");
|
||||
return slash < 0 ? path : path.slice(slash + 1);
|
||||
}
|
||||
/**
|
||||
* The page TITLE derived from a vault path: the file's base name without the
|
||||
* `.md` extension. In the native-Obsidian layout the filename IS the title — for
|
||||
* a folder-note `<dir>/<base>.md` that base equals the folder name, so the same
|
||||
* rule yields the folder's title. Self-consistent across pull/push: a pulled
|
||||
* (possibly disambiguated) filename round-trips to the same title, so a stable
|
||||
* file never pushes a spurious rename.
|
||||
*/
|
||||
function titleFromPath(path) {
|
||||
const base = baseSegment(path);
|
||||
return base.endsWith(".md") ? base.slice(0, -3) : base;
|
||||
}
|
||||
/**
|
||||
* Build the synthetic `DocmostMdMeta` the planner/classifier consume, from the
|
||||
* NATIVE format: `pageId` from the `gitmost_id` frontmatter, `title` from the
|
||||
* filename, `spaceId` from the run (the vault's space — every file belongs to
|
||||
* it). `parentPageId` is intentionally absent: tree position is resolved from the
|
||||
* PATH (`resolveParentPageId`), never from a stored field (SPEC §5).
|
||||
*/
|
||||
function nativeMeta(text, path, spaceId) {
|
||||
const { id } = parsePageFile(text);
|
||||
const meta = { version: 1, title: titleFromPath(path), spaceId };
|
||||
if (id)
|
||||
meta.pageId = id;
|
||||
return meta;
|
||||
}
|
||||
/**
|
||||
* Build the `resolveParentPageId(path, side)` resolver `classifyRenameMoves`
|
||||
* needs, reading the PARENT FOLDER's `.md` (SPEC §5 path-as-truth):
|
||||
* - `current` -> `deps.readFile(<dir>.md)` (the live working tree),
|
||||
* - `prev` -> `git.showFileAtRef('refs/docmost/last-pushed', <dir>.md)` (the
|
||||
* last-pushed pre-image),
|
||||
* then read its `gitmost_id` frontmatter and return that page's pageId. A root-level path
|
||||
* (no enclosing folder), a missing/unreadable parent file, or a parent file with
|
||||
* no parseable pageId all resolve to `null` (parent is ROOT / unknown ->
|
||||
* `parentPageId: null`, SPEC §16 "parentPageId: null -> в корень").
|
||||
*
|
||||
* The IO is async, so this returns an ASYNC resolver; the call sites prefetch the
|
||||
* parent pageIds (the classifier itself stays pure/sync over a plain table).
|
||||
*/
|
||||
async function resolveParentPageIdViaTree(deps, path, side) {
|
||||
const parentFile = parentFolderFile(path);
|
||||
if (parentFile === null)
|
||||
return null; // root-level: parent is ROOT.
|
||||
let text;
|
||||
try {
|
||||
text =
|
||||
side === "current"
|
||||
? await deps.readFile(parentFile)
|
||||
: await deps.git.showFileAtRef(LAST_PUSHED_REF, parentFile);
|
||||
}
|
||||
catch {
|
||||
// Parent folder file missing/unreadable at that side -> treat as ROOT.
|
||||
return null;
|
||||
}
|
||||
if (text === null)
|
||||
return null; // showFileAtRef returns null when absent.
|
||||
// The parent page's identity is its `gitmost_id` frontmatter; folder position
|
||||
// is irrelevant here, only the pageId.
|
||||
return parsePageFile(text).id;
|
||||
}
|
||||
/**
|
||||
* Resolve the synthetic native meta at a side for the rename/move classifier (the
|
||||
* title — derived from the path — comes from here). Mirrors
|
||||
* `resolveParentPageIdViaTree`'s IO sides: `current` reads the working tree,
|
||||
* `prev` reads `refs/docmost/last-pushed`. Returns `null` only when the file is
|
||||
* missing/unreadable at that side (a real absence the classifier must see).
|
||||
*/
|
||||
async function metaAtViaTree(deps, path, side, spaceId) {
|
||||
let text;
|
||||
try {
|
||||
text =
|
||||
side === "current"
|
||||
? await deps.readFile(path)
|
||||
: await deps.git.showFileAtRef(LAST_PUSHED_REF, path);
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
if (text === null)
|
||||
return null;
|
||||
return nativeMeta(text, path, spaceId);
|
||||
}
|
||||
/**
|
||||
* Pull an `updatedAt` out of a create/update client result, if present. The
|
||||
* shape is `{ data: { updatedAt? }, ... }` (createPage) or a flatter object;
|
||||
* absent in the simple fakes, so the field is omitted rather than `undefined`.
|
||||
*/
|
||||
function extractUpdatedAt(result) {
|
||||
const r = result;
|
||||
const raw = r?.data?.updatedAt ?? r?.updatedAt;
|
||||
return typeof raw === "string" ? { updatedAt: raw } : {};
|
||||
}
|
||||
// --- runnable push orchestration (`runPush`) ---------------------------------
|
||||
//
|
||||
// `runPush` is the FS->Docmost twin of `pull.ts`'s `main`: it wires the VaultGit
|
||||
// diff/ref primitives + the PURE `computePushActions` planner + the THIN
|
||||
// `applyPushActions` applier into one runnable cycle. SAFE BY DEFAULT — the
|
||||
// engine's FIRST write path to Docmost defaults to DRY-RUN (plan only, NO
|
||||
// Docmost writes, NO ref advance); an explicit `--apply` is the ONLY path that
|
||||
// builds a client and mutates Docmost.
|
||||
//
|
||||
// Every external effect is injected (`PushDeps`) so the whole orchestration is
|
||||
// driven by FAKES in tests — no live Docmost, git, fs, or network.
|
||||
/**
|
||||
* The human ("local") git identity used for engine-made commits on `main` in the
|
||||
* push direction (SPEC §7.3). The provenance is carried by the trailer (below),
|
||||
* which the loop-guard keys on; the identity is for history readability only.
|
||||
* When the vault repo already has a configured `user.name`/`user.email`, git
|
||||
* uses that for the working-tree commit; this is the fallback the daemon stamps.
|
||||
*/
|
||||
export const LOCAL_AUTHOR_NAME = "Local";
|
||||
export const LOCAL_AUTHOR_EMAIL = "local@local";
|
||||
/** The provenance trailer marking a `main`-side (human/local) commit (SPEC §7.3). */
|
||||
export const LOCAL_SOURCE_TRAILER = "Docmost-Sync-Source: local";
|
||||
/**
|
||||
* Run one FS->Docmost push cycle (SPEC §6 "ФС → Docmost"), DRY-RUN BY DEFAULT.
|
||||
*
|
||||
* Steps (mirrors `pull.ts`):
|
||||
* 1. Preflight git: `assertGitAvailable` + `ensureRepo`; ABORT (clear message +
|
||||
* non-zero-ish result) if a merge is in progress — never push on top of an
|
||||
* unresolved conflict (SPEC §9/§12). Conflict markers must NEVER reach
|
||||
* Docmost (SPEC §9).
|
||||
* 2. Checkout `main` (the human-facing branch the push reads from).
|
||||
* 3. Commit the human's pending working-tree changes on `main` with the
|
||||
* `local` provenance trailer (SPEC §7.3). A no-op when nothing changed.
|
||||
* 4. Pick the diff BASE: `refs/docmost/last-pushed` if it resolves, else the
|
||||
* `docmost` mirror branch (what Docmost currently has). Resolve `main`.
|
||||
* 5. `diffNameStatus(base, main)` -> changes; build the `metaAt(path, side)`
|
||||
* resolver (current = working tree, prev = `git show <base>:<path>`); run
|
||||
* the PURE `computePushActions`.
|
||||
* 6. DRY-RUN (default): LOG the full plan and RETURN — NO client, NO Docmost
|
||||
* calls, NO ref advance.
|
||||
* 7. `--apply`: build the client, run `applyPushActions(..., pushedCommit=main)`,
|
||||
* then (a) if any pageIds were written back (creates), commit them on `main`
|
||||
* with the `local` trailer and RE-advance `refs/docmost/last-pushed` to the
|
||||
* new commit so the recorded pageIds are persisted in what Docmost mirrors;
|
||||
* (b) ESCALATE a divergent-`docmost` ff refusal (SPEC §5) with a prominent
|
||||
* WARNING and a non-zero-ish flag. Then log a one-line summary.
|
||||
*/
|
||||
export async function runPush(deps, opts) {
|
||||
const { git, settings, log } = deps;
|
||||
const dryRun = opts.dryRun;
|
||||
// 1. Preflight git. Fail fast (actionable message via main().catch) if the git
|
||||
// binary is missing — the vault state store relies on it.
|
||||
await git.assertGitAvailable();
|
||||
await git.ensureRepo();
|
||||
// 1b. Refuse to push on top of an unresolved merge (SPEC §9/§12). A previous
|
||||
// conflicting pull leaves the vault mid-merge; pushing now could leak
|
||||
// conflict markers into Docmost (SPEC §9, the cardinal invariant). Detect
|
||||
// it BEFORE any checkout/diff and stop with a clear, actionable message so
|
||||
// re-runs converge once the human resolves (or aborts) the merge.
|
||||
if (await git.isMergeInProgress()) {
|
||||
log(`push: vault has an unresolved merge at ${settings.vaultPath} — resolve ` +
|
||||
`it (or 'git merge --abort') and re-run. Nothing was pushed to Docmost ` +
|
||||
`(conflict markers must never reach Docmost, SPEC §9).`);
|
||||
return { mode: dryRun ? "dry-run" : "apply", aborted: "merge-in-progress" };
|
||||
}
|
||||
// 2. Work on `main` — the human-facing branch the push diffs FROM.
|
||||
await git.checkout(DEFAULT_BRANCH);
|
||||
// 3. Commit the human's pending working-tree changes on `main` with the `local`
|
||||
// provenance trailer (SPEC §7.3). A no-op commit when nothing changed is
|
||||
// fine (`commit` returns false). The loop-guard keys on the trailer.
|
||||
// Even on a "plan only" dry-run this commits the working tree (it is the
|
||||
// only way to diff `base..main`, acceptable §6.1 behavior) — so make that
|
||||
// LOCAL git mutation VISIBLE, never silent: a created commit is local-only
|
||||
// and nothing is sent to Docmost.
|
||||
await git.stageAll();
|
||||
const committedWorkingTree = await git.commit("local: working-tree changes", {
|
||||
authorName: LOCAL_AUTHOR_NAME,
|
||||
authorEmail: LOCAL_AUTHOR_EMAIL,
|
||||
trailers: [LOCAL_SOURCE_TRAILER],
|
||||
});
|
||||
if (committedWorkingTree) {
|
||||
const sha = await git.revParse(DEFAULT_BRANCH);
|
||||
log(`push: committed local working-tree changes on main` +
|
||||
(sha ? ` as ${sha.slice(0, 8)}` : "") +
|
||||
` (local git only — nothing sent to Docmost).`);
|
||||
}
|
||||
else {
|
||||
log("push: working tree clean (no local changes to push).");
|
||||
}
|
||||
// 4. Pick the diff BASE (SPEC §5/§6): `refs/docmost/last-pushed` if it resolves
|
||||
// (the marker of what `main` is already in Docmost), else fall back to the
|
||||
// `docmost` mirror branch (the mirror of what Docmost currently has) — which
|
||||
// is what exists before the first push ever advanced last-pushed.
|
||||
let base;
|
||||
const lastPushedSha = await git.readRef(LAST_PUSHED_REF);
|
||||
if (lastPushedSha) {
|
||||
base = { ref: LAST_PUSHED_REF, source: "last-pushed", sha: lastPushedSha };
|
||||
}
|
||||
else {
|
||||
base = {
|
||||
ref: DOCMOST_BRANCH,
|
||||
source: "docmost",
|
||||
sha: await git.revParse(DOCMOST_BRANCH),
|
||||
};
|
||||
}
|
||||
const pushedCommit = await git.revParse(DEFAULT_BRANCH);
|
||||
if (!pushedCommit) {
|
||||
// `main` has no commit — `ensureRepo` always makes an initial one, so this is
|
||||
// defensive. Nothing to diff.
|
||||
log("push: `main` has no commit to push — nothing to do.");
|
||||
return { mode: dryRun ? "dry-run" : "apply", base };
|
||||
}
|
||||
// 5. Diff the base against `main` and build the `metaAt` resolver (PURE planner
|
||||
// input). `current` reads the live working tree; `prev` reads the base ref's
|
||||
// pre-image via `git show <base>:<path>` (so a DELETE recovers its pageId).
|
||||
const changes = await git.diffNameStatus(base.ref, DEFAULT_BRANCH);
|
||||
// Synchronous resolver over PREFETCHED meta tables: `computePushActions` is
|
||||
// PURE/sync, but the file/ref reads are async — so we prefetch every (path,
|
||||
// side) the diff will ask for into a table first, then resolve from it.
|
||||
const metaTable = new Map();
|
||||
for (const change of changes) {
|
||||
// `current`: A/M/R/C still have the file on `main`. `prev`: D needs the
|
||||
// pre-image; R/C also benefit (old title). Prefetch both sides per path.
|
||||
const currentPath = change.path;
|
||||
const prevPath = change.oldPath ?? change.path;
|
||||
if (!metaTable.has(`${currentPath}|current`)) {
|
||||
metaTable.set(`${currentPath}|current`, await readMetaCurrent(deps, currentPath, settings.docmostSpaceId));
|
||||
}
|
||||
if (!metaTable.has(`${prevPath}|prev`)) {
|
||||
metaTable.set(`${prevPath}|prev`, await readMetaPrev(deps, base.ref, prevPath, settings.docmostSpaceId));
|
||||
}
|
||||
}
|
||||
const metaAt = (path, side) => metaTable.get(`${path}|${side}`) ?? null;
|
||||
// The set of pageIds that STILL EXIST somewhere in the current `main` tree.
|
||||
// Identity is the pageId, NOT the filename: a file vanishing from one path
|
||||
// while the SAME pageId lives at another path is a MOVE (often a layout
|
||||
// reshuffle of `_`-fallback names, whose two halves can even land in separate
|
||||
// cycles), never a deletion. Built only when the diff contains deletes — the
|
||||
// guard's whole job is to stop a phantom delete from trashing a live page.
|
||||
let currentPageIds;
|
||||
if (changes.some((c) => c.status === "D")) {
|
||||
currentPageIds = new Set();
|
||||
for (const relPath of await git.listTrackedFiles("*.md")) {
|
||||
const pid = (await readMetaCurrent(deps, relPath, settings.docmostSpaceId))
|
||||
?.pageId;
|
||||
if (pid)
|
||||
currentPageIds.add(pid);
|
||||
}
|
||||
}
|
||||
const actions = computePushActions({ changes, metaAt, currentPageIds });
|
||||
const planned = {
|
||||
creates: actions.creates.length,
|
||||
updates: actions.updates.length,
|
||||
deletes: actions.deletes.length,
|
||||
renamesMoves: actions.renamesMoves.length,
|
||||
skipped: actions.skipped.length,
|
||||
};
|
||||
// 6. DRY-RUN (default): log the full plan and RETURN — build NO client, make
|
||||
// ZERO Docmost calls, advance NO refs. This is the SAFE default.
|
||||
logPlan(log, base, pushedCommit, actions, planned, dryRun);
|
||||
if (dryRun) {
|
||||
return { mode: "dry-run", base, pushedCommit, planned };
|
||||
}
|
||||
// 7. --apply: build the REAL client and execute. This is the ONLY write path.
|
||||
const client = deps.makeClient(settings);
|
||||
const applied = await applyPushActions({
|
||||
client,
|
||||
// Pass the WHOLE `git` object (it satisfies the applier's
|
||||
// `Pick<VaultGit, ...>` deps surface). Passing bare method references
|
||||
// (`git.updateRef`, …) would lose their `this` binding, so on a REAL
|
||||
// `VaultGit` they would throw `this.runRaw is not a function`. Hand over
|
||||
// the object so the methods keep their receiver — exactly as `pull.ts`
|
||||
// does for `applyPullActions`.
|
||||
git,
|
||||
readFile: deps.readFile,
|
||||
writeFile: deps.writeFile,
|
||||
spaceId: settings.docmostSpaceId,
|
||||
}, actions, pushedCommit);
|
||||
// 7a. Persist freshly-assigned pageIds (creates) back into git. `applyPushActions`
|
||||
// rewrote those files on disk; commit them on `main` with the `local` trailer
|
||||
// so the new pageIds are recorded, then RE-advance `refs/docmost/last-pushed`
|
||||
// to the new commit so what Docmost mirrors and what last-pushed points at
|
||||
// stay in lock-step (the write-back commit is part of `main` now).
|
||||
// Track a divergent-`docmost` mirror across BOTH ff sites (the applier's main
|
||||
// push ff in 7b, and the write-back ff here). A divergent mirror is a §5
|
||||
// invariant breach in EITHER branch and must escalate identically (exit 1).
|
||||
let divergentDocmost = false;
|
||||
if (applied.writtenBack.length > 0) {
|
||||
await git.stageAll();
|
||||
const recorded = await git.commit("local: record created pageIds", {
|
||||
authorName: LOCAL_AUTHOR_NAME,
|
||||
authorEmail: LOCAL_AUTHOR_EMAIL,
|
||||
trailers: [LOCAL_SOURCE_TRAILER],
|
||||
});
|
||||
if (recorded) {
|
||||
const newCommit = await git.revParse(DEFAULT_BRANCH);
|
||||
// Only re-advance when the original push was CLEAN (last-pushed was already
|
||||
// advanced by the applier); a partial push left the refs untouched and a
|
||||
// re-run retries the whole batch, so we must not move them either.
|
||||
if (newCommit && applied.lastPushedAdvanced) {
|
||||
await git.updateRef(LAST_PUSHED_REF, newCommit);
|
||||
const ff = await git.fastForwardBranch(DOCMOST_BRANCH, newCommit);
|
||||
if (!ff.ok) {
|
||||
// SYMMETRIC with the main escalation (7b): a divergent mirror in the
|
||||
// write-back branch is the SAME §5 invariant breach and must escalate
|
||||
// (exit 1), not just log a soft warning.
|
||||
divergentDocmost = true;
|
||||
log(`push: WARNING — the 'docmost' mirror branch DIVERGED and was NOT ` +
|
||||
`fast-forwarded to the pageId write-back commit ` +
|
||||
`(${ff.reason ?? "not-fast-forward"}). The §5 invariant ('docmost' ` +
|
||||
`mirrors what Docmost contains) is broken: reconcile 'docmost' ` +
|
||||
`against the live Docmost tree before the next cycle.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 7b. ESCALATE a divergent-`docmost` fast-forward refusal (SPEC §5 invariant
|
||||
// broken). The applier already refused to clobber a divergent mirror; make
|
||||
// it LOUD (not silent) so the operator notices, and fold it into the exit.
|
||||
if (applied.docmostFastForward && !applied.docmostFastForward.ok) {
|
||||
divergentDocmost = true;
|
||||
log(`push: WARNING — the 'docmost' mirror branch DIVERGED and was NOT ` +
|
||||
`fast-forwarded (${applied.docmostFastForward.reason ?? "not-fast-forward"}). ` +
|
||||
`The §5 invariant ('docmost' mirrors what Docmost contains) is broken: ` +
|
||||
`reconcile 'docmost' against the live Docmost tree before the next cycle.`);
|
||||
}
|
||||
// 7c. One-line summary (mirrors pull.ts's summary line).
|
||||
log(`push complete: ${applied.created} created, ${applied.updated} updated, ` +
|
||||
`${applied.deleted} deleted, ${applied.moved} moved, ${applied.renamed} ` +
|
||||
`renamed, ${applied.noops.length} no-op(s), ${applied.skipped.length} ` +
|
||||
`skipped, ${applied.failures.length} failure(s)` +
|
||||
(divergentDocmost ? " [DIVERGENT docmost mirror]" : ""));
|
||||
return {
|
||||
mode: "apply",
|
||||
base,
|
||||
pushedCommit,
|
||||
planned,
|
||||
applied,
|
||||
divergentDocmost,
|
||||
failures: applied.failures,
|
||||
};
|
||||
}
|
||||
/** Synthetic native meta from the live working tree (`current` side). */
|
||||
async function readMetaCurrent(deps, path, spaceId) {
|
||||
let text;
|
||||
try {
|
||||
text = await deps.readFile(path);
|
||||
}
|
||||
catch {
|
||||
return null; // absent on disk (e.g. a D row's path) -> no current meta.
|
||||
}
|
||||
return nativeMeta(text, path, spaceId);
|
||||
}
|
||||
/** Synthetic native meta from the base ref's pre-image (`prev` side). */
|
||||
async function readMetaPrev(deps, baseRef, path, spaceId) {
|
||||
let text;
|
||||
try {
|
||||
text = await deps.git.showFileAtRef(baseRef, path);
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
if (text === null)
|
||||
return null; // path absent at the base ref.
|
||||
return nativeMeta(text, path, spaceId);
|
||||
}
|
||||
/** Emit the full plan (counts + per-item) to the injected logger. */
|
||||
function logPlan(log, base, pushedCommit, actions, planned, dryRun) {
|
||||
log(`push plan (${dryRun ? "DRY-RUN — no Docmost writes" : "APPLY"}): base=` +
|
||||
`${base.ref} (${base.source}${base.sha ? ` ${base.sha.slice(0, 8)}` : ""}) ` +
|
||||
`-> main ${pushedCommit.slice(0, 8)}`);
|
||||
log(`push plan counts: ${planned.creates} create, ${planned.updates} update, ` +
|
||||
`${planned.deletes} delete, ${planned.renamesMoves} rename/move, ` +
|
||||
`${planned.skipped} skipped`);
|
||||
for (const c of actions.creates)
|
||||
log(` create: ${c.path}`);
|
||||
for (const u of actions.updates)
|
||||
log(` update: ${u.pageId} (${u.path})`);
|
||||
for (const d of actions.deletes)
|
||||
log(` delete: ${d.pageId}`);
|
||||
for (const rm of actions.renamesMoves)
|
||||
log(` rename/move: ${rm.oldPath} -> ${rm.newPath} (${rm.pageId})`);
|
||||
for (const s of actions.skipped)
|
||||
log(` skipped [${s.status}] ${s.path}: ${s.reason}`);
|
||||
}
|
||||
/**
|
||||
* Parse the `push` CLI flags. SAFE BY DEFAULT: without `--apply` the run is a
|
||||
* DRY-RUN (plan only). Exported so the flag handling is unit-testable.
|
||||
*/
|
||||
export function parseArgs(argv) {
|
||||
return { apply: argv.includes("--apply") };
|
||||
}
|
||||
-126
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* Pure reconciliation planner (SPEC §5/§6/§8).
|
||||
*
|
||||
* Given the desired live set of files (computed from the current Docmost tree)
|
||||
* and the set of files currently tracked in the vault, compute what to write,
|
||||
* what to move (old path to remove), and what to delete. Identity is `pageId`
|
||||
* (the stable file<->page anchor, SPEC §4): a page that keeps its pageId but
|
||||
* changes relPath is a MOVE, not delete+add; a tracked pageId that is gone from
|
||||
* the live tree is a DELETE.
|
||||
*
|
||||
* This module is intentionally PURE (no IO, no git) so the whole plan is
|
||||
* unit-testable. The actual file writing / git operations happen in pull.ts.
|
||||
*/
|
||||
/** A page that SHOULD exist in the vault at a given path. */
|
||||
export interface LiveEntry {
|
||||
pageId: string;
|
||||
/** Vault-relative path (forward-slash), e.g. `Space/Parent/Child.md`. */
|
||||
relPath: string;
|
||||
}
|
||||
/** A page currently tracked in the vault (pageId parsed from its meta). */
|
||||
export interface ExistingEntry {
|
||||
pageId: string;
|
||||
/** Vault-relative path (forward-slash) of the tracked file. */
|
||||
relPath: string;
|
||||
}
|
||||
/** A page to (re)write at its destination path. */
|
||||
export interface WriteEntry {
|
||||
pageId: string;
|
||||
relPath: string;
|
||||
}
|
||||
/** A page that moved: written at its NEW relPath, with the OLD path removed. */
|
||||
export interface MovedEntry {
|
||||
pageId: string;
|
||||
fromRelPath: string;
|
||||
toRelPath: string;
|
||||
/**
|
||||
* Whether the old path (`fromRelPath`) is SAFE to remove. False when another
|
||||
* live page will (re)write that exact path (path reuse): removing it would
|
||||
* destroy real data, so the caller must skip the removal. The move itself is
|
||||
* still recorded (the new path is written regardless).
|
||||
*/
|
||||
removeOldPath: boolean;
|
||||
}
|
||||
/** The full reconciliation plan. */
|
||||
export interface ReconciliationPlan {
|
||||
/**
|
||||
* Pages present in `live` -> (re)write at their relPath. This naturally
|
||||
* covers add, content-update (same path) AND move (same pageId, new path),
|
||||
* since every live page is (re)written regardless of whether it existed.
|
||||
*/
|
||||
toWrite: WriteEntry[];
|
||||
/**
|
||||
* Vault-relative paths to delete because their tracked pageId is ABSENT from
|
||||
* `live` (page removed/trashed). This set is ONLY absence-based deletions —
|
||||
* the OLD paths of moved pages are NOT here (they live in `moved` and are
|
||||
* applied separately by the caller). Keeping the two apart lets pull.ts gate
|
||||
* absence deletions behind the incomplete-fetch suppression + mass-delete
|
||||
* guard (SPEC §8) while still applying real moves.
|
||||
*/
|
||||
toDelete: string[];
|
||||
/**
|
||||
* Tracked pages whose relPath changed. The caller writes the page at
|
||||
* `toRelPath`, then removes `fromRelPath` — but ONLY after the new-path write
|
||||
* succeeded. The old path is NOT in `toDelete`.
|
||||
*/
|
||||
moved: MovedEntry[];
|
||||
}
|
||||
/**
|
||||
* Compute the reconciliation plan.
|
||||
*
|
||||
* Rules:
|
||||
* - Every `live` page is written at its relPath (covers add + update + move).
|
||||
* - A tracked pageId present in `live` whose relPath changed is `moved`; its
|
||||
* OLD relPath goes into `moved` ONLY (the caller removes it after the new
|
||||
* path is written) and is NEVER added to `toDelete`.
|
||||
* - A tracked pageId NOT present in `live` is an ABSENCE delete; its relPath
|
||||
* is added to `toDelete`.
|
||||
*
|
||||
* Notes:
|
||||
* - Safety filter (no data loss): no path that is a live TARGET path of any
|
||||
* page is ever deleted/removed (a write owns it). This applies to BOTH the
|
||||
* absence `toDelete` set AND a moved page's old-path removal — if a moved
|
||||
* page's OLD path is reused by ANOTHER live page, the move records no old
|
||||
* path to remove, because that path will be (re)written.
|
||||
* - `existing` may legitimately contain duplicate pageIds (two stray files
|
||||
* carrying the same meta pageId); each such file that is not the live target
|
||||
* path is removed (as an absence/move) so the vault converges to exactly the
|
||||
* live set.
|
||||
*/
|
||||
export declare function planReconciliation(live: LiveEntry[], existing: ExistingEntry[]): ReconciliationPlan;
|
||||
/**
|
||||
* Below this many tracked files the mass-delete fraction guard is not applied
|
||||
* (a tiny vault where deleting "most" files is normal, e.g. 1-of-2).
|
||||
*/
|
||||
export declare const MASS_DELETE_MIN_EXISTING = 4;
|
||||
/** Fraction of tracked files above which a delete plan is a suspected wipe. */
|
||||
export declare const MASS_DELETE_FRACTION = 0.5;
|
||||
/** Why absence-based deletions were (or were not) applied this cycle. */
|
||||
export type DeletionDecision = {
|
||||
apply: true;
|
||||
} | {
|
||||
apply: false;
|
||||
reason: "incomplete-fetch" | "empty-live" | "mass-delete";
|
||||
};
|
||||
/**
|
||||
* Pure decision: should the ABSENCE-based deletions (`plan.toDelete`) be applied
|
||||
* this cycle? Encapsulates the SPEC §8 safety invariants so they are unit-
|
||||
* testable without live creds or git:
|
||||
*
|
||||
* - `treeComplete === false` (a partial Docmost tree fetch) -> SUPPRESS. A page
|
||||
* missing from a partial tree is NOT proof of deletion (SPEC §8); we must not
|
||||
* delete merely-absent files this cycle. (Writes/updates/moves still happen.)
|
||||
* - The live fetch returned 0 pages while files are tracked -> SUPPRESS
|
||||
* (almost always a failed fetch, never a real "delete everything").
|
||||
* - The plan would delete more than `MASS_DELETE_FRACTION` of a non-trivial
|
||||
* vault -> SUPPRESS as a mass-deletion guard (defense in depth).
|
||||
*
|
||||
* Moves are NOT governed by this decision: a moved page IS present in `live`, so
|
||||
* its old-path removal is real (handled by the caller separately).
|
||||
*/
|
||||
export declare function decideAbsenceDeletions(args: {
|
||||
treeComplete: boolean;
|
||||
liveCount: number;
|
||||
existingCount: number;
|
||||
deleteCount: number;
|
||||
}): DeletionDecision;
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* Pure reconciliation planner (SPEC §5/§6/§8).
|
||||
*
|
||||
* Given the desired live set of files (computed from the current Docmost tree)
|
||||
* and the set of files currently tracked in the vault, compute what to write,
|
||||
* what to move (old path to remove), and what to delete. Identity is `pageId`
|
||||
* (the stable file<->page anchor, SPEC §4): a page that keeps its pageId but
|
||||
* changes relPath is a MOVE, not delete+add; a tracked pageId that is gone from
|
||||
* the live tree is a DELETE.
|
||||
*
|
||||
* This module is intentionally PURE (no IO, no git) so the whole plan is
|
||||
* unit-testable. The actual file writing / git operations happen in pull.ts.
|
||||
*/
|
||||
/**
|
||||
* Compute the reconciliation plan.
|
||||
*
|
||||
* Rules:
|
||||
* - Every `live` page is written at its relPath (covers add + update + move).
|
||||
* - A tracked pageId present in `live` whose relPath changed is `moved`; its
|
||||
* OLD relPath goes into `moved` ONLY (the caller removes it after the new
|
||||
* path is written) and is NEVER added to `toDelete`.
|
||||
* - A tracked pageId NOT present in `live` is an ABSENCE delete; its relPath
|
||||
* is added to `toDelete`.
|
||||
*
|
||||
* Notes:
|
||||
* - Safety filter (no data loss): no path that is a live TARGET path of any
|
||||
* page is ever deleted/removed (a write owns it). This applies to BOTH the
|
||||
* absence `toDelete` set AND a moved page's old-path removal — if a moved
|
||||
* page's OLD path is reused by ANOTHER live page, the move records no old
|
||||
* path to remove, because that path will be (re)written.
|
||||
* - `existing` may legitimately contain duplicate pageIds (two stray files
|
||||
* carrying the same meta pageId); each such file that is not the live target
|
||||
* path is removed (as an absence/move) so the vault converges to exactly the
|
||||
* live set.
|
||||
*/
|
||||
export function planReconciliation(live, existing) {
|
||||
// Desired path for each live pageId.
|
||||
const liveByPageId = new Map();
|
||||
// Set of all paths that WILL be written (never delete/remove one of these).
|
||||
const liveTargetPaths = new Set();
|
||||
for (const e of live) {
|
||||
liveByPageId.set(e.pageId, e.relPath);
|
||||
liveTargetPaths.add(e.relPath);
|
||||
}
|
||||
const toWrite = live.map((e) => ({
|
||||
pageId: e.pageId,
|
||||
relPath: e.relPath,
|
||||
}));
|
||||
const moved = [];
|
||||
// Absence-based deletions ONLY (tracked pageId absent from `live`). Use a Set
|
||||
// so the same path coming from multiple existing rows is queued only once.
|
||||
const toDeleteSet = new Set();
|
||||
for (const ex of existing) {
|
||||
const liveRel = liveByPageId.get(ex.pageId);
|
||||
if (liveRel === undefined) {
|
||||
// Tracked page is gone from the live tree -> absence delete.
|
||||
// Never queue a path a live page will (re)write (path reuse -> no loss).
|
||||
if (!liveTargetPaths.has(ex.relPath))
|
||||
toDeleteSet.add(ex.relPath);
|
||||
continue;
|
||||
}
|
||||
if (liveRel !== ex.relPath) {
|
||||
// Same pageId, different path -> a MOVE. Record it so the caller can write
|
||||
// the new path first, then remove the old one. If the old path is itself a
|
||||
// live target (reused by another page), it must NOT be removed — the write
|
||||
// owns it — so flag `removeOldPath: false` (move still recorded).
|
||||
moved.push({
|
||||
pageId: ex.pageId,
|
||||
fromRelPath: ex.relPath,
|
||||
toRelPath: liveRel,
|
||||
removeOldPath: !liveTargetPaths.has(ex.relPath),
|
||||
});
|
||||
}
|
||||
// liveRel === ex.relPath -> content-update in place; nothing extra to do
|
||||
// (the write above re-emits the file; identical bytes => git no-op).
|
||||
}
|
||||
const toDelete = [...toDeleteSet];
|
||||
return { toWrite, toDelete, moved };
|
||||
}
|
||||
/**
|
||||
* Below this many tracked files the mass-delete fraction guard is not applied
|
||||
* (a tiny vault where deleting "most" files is normal, e.g. 1-of-2).
|
||||
*/
|
||||
export const MASS_DELETE_MIN_EXISTING = 4;
|
||||
/** Fraction of tracked files above which a delete plan is a suspected wipe. */
|
||||
export const MASS_DELETE_FRACTION = 0.5;
|
||||
/**
|
||||
* Pure decision: should the ABSENCE-based deletions (`plan.toDelete`) be applied
|
||||
* this cycle? Encapsulates the SPEC §8 safety invariants so they are unit-
|
||||
* testable without live creds or git:
|
||||
*
|
||||
* - `treeComplete === false` (a partial Docmost tree fetch) -> SUPPRESS. A page
|
||||
* missing from a partial tree is NOT proof of deletion (SPEC §8); we must not
|
||||
* delete merely-absent files this cycle. (Writes/updates/moves still happen.)
|
||||
* - The live fetch returned 0 pages while files are tracked -> SUPPRESS
|
||||
* (almost always a failed fetch, never a real "delete everything").
|
||||
* - The plan would delete more than `MASS_DELETE_FRACTION` of a non-trivial
|
||||
* vault -> SUPPRESS as a mass-deletion guard (defense in depth).
|
||||
*
|
||||
* Moves are NOT governed by this decision: a moved page IS present in `live`, so
|
||||
* its old-path removal is real (handled by the caller separately).
|
||||
*/
|
||||
export function decideAbsenceDeletions(args) {
|
||||
const { treeComplete, liveCount, existingCount, deleteCount } = args;
|
||||
// No tracked files, or nothing to delete -> trivially fine to "apply".
|
||||
if (existingCount === 0 || deleteCount === 0)
|
||||
return { apply: true };
|
||||
if (!treeComplete)
|
||||
return { apply: false, reason: "incomplete-fetch" };
|
||||
if (liveCount === 0)
|
||||
return { apply: false, reason: "empty-live" };
|
||||
if (existingCount >= MASS_DELETE_MIN_EXISTING &&
|
||||
deleteCount > existingCount * MASS_DELETE_FRACTION) {
|
||||
return { apply: false, reason: "mass-delete" };
|
||||
}
|
||||
return { apply: true };
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* Pure, IO-free comparison helpers for the idempotency round-trip checks. The
|
||||
* round-trip harness that drives these lives in the package's tests, not in the
|
||||
* engine.
|
||||
*/
|
||||
/**
|
||||
* Recursively strip every `attrs.id` from a ProseMirror node tree. Block ids
|
||||
* are regenerated by `markdownToProseMirror` (SPEC §11), so they must be
|
||||
* ignored when comparing the semantic shape of two documents. Returns a NEW
|
||||
* tree; the input is not mutated.
|
||||
*/
|
||||
export declare function stripBlockIds(node: any): any;
|
||||
/**
|
||||
* Find the first divergence between two values via a recursive deep compare.
|
||||
* Returns a short path + the two differing values, or null if they are equal.
|
||||
*/
|
||||
export declare function firstDivergence(a: any, b: any, path?: string): {
|
||||
path: string;
|
||||
a: any;
|
||||
b: any;
|
||||
} | null;
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* Pure, IO-free comparison helpers for the idempotency round-trip checks. The
|
||||
* round-trip harness that drives these lives in the package's tests, not in the
|
||||
* engine.
|
||||
*/
|
||||
/**
|
||||
* Recursively strip every `attrs.id` from a ProseMirror node tree. Block ids
|
||||
* are regenerated by `markdownToProseMirror` (SPEC §11), so they must be
|
||||
* ignored when comparing the semantic shape of two documents. Returns a NEW
|
||||
* tree; the input is not mutated.
|
||||
*/
|
||||
export function stripBlockIds(node) {
|
||||
if (Array.isArray(node)) {
|
||||
return node.map(stripBlockIds);
|
||||
}
|
||||
if (node && typeof node === "object") {
|
||||
const out = {};
|
||||
for (const key of Object.keys(node)) {
|
||||
if (key === "attrs" && node.attrs && typeof node.attrs === "object") {
|
||||
// Drop the `id` attr; keep every other attribute.
|
||||
const { id, ...rest } = node.attrs;
|
||||
void id;
|
||||
out.attrs = stripBlockIds(rest);
|
||||
}
|
||||
else {
|
||||
out[key] = stripBlockIds(node[key]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
/**
|
||||
* Find the first divergence between two values via a recursive deep compare.
|
||||
* Returns a short path + the two differing values, or null if they are equal.
|
||||
*/
|
||||
export function firstDivergence(a, b, path = "$") {
|
||||
if (a === b)
|
||||
return null;
|
||||
const ta = typeof a;
|
||||
const tb = typeof b;
|
||||
if (ta !== tb || a === null || b === null) {
|
||||
return { path, a, b };
|
||||
}
|
||||
if (ta !== "object") {
|
||||
return { path, a, b };
|
||||
}
|
||||
const aIsArr = Array.isArray(a);
|
||||
const bIsArr = Array.isArray(b);
|
||||
if (aIsArr !== bIsArr)
|
||||
return { path, a, b };
|
||||
if (aIsArr) {
|
||||
if (a.length !== b.length) {
|
||||
return { path: `${path}.length`, a: a.length, b: b.length };
|
||||
}
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const d = firstDivergence(a[i], b[i], `${path}[${i}]`);
|
||||
if (d)
|
||||
return d;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
||||
for (const k of keys) {
|
||||
const d = firstDivergence(a[k], b[k], `${path}.${k}`);
|
||||
if (d)
|
||||
return d;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Deterministic filename strategy (SPEC §12).
|
||||
*
|
||||
* The file name is COSMETIC — the source of truth for the file<->page link is
|
||||
* `pageId` / `slugId` inside the meta block, so renaming a file is safe. These
|
||||
* functions are intentionally dependency-free and pure, so they are trivially
|
||||
* unit-testable.
|
||||
*/
|
||||
/**
|
||||
* Sanitize a page title into a safe file-name component (WITHOUT extension).
|
||||
*
|
||||
* Steps: replace forbidden / control characters with "-", collapse whitespace
|
||||
* runs to a single space, trim, cap the length, then guard against an empty
|
||||
* result, an all-dots result, or a reserved Windows device name by prefixing
|
||||
* with "_".
|
||||
*/
|
||||
export declare function sanitizeTitle(title: string): string;
|
||||
/**
|
||||
* Disambiguate a sanitized name when two siblings in the same folder collapse
|
||||
* to the same name. Appends a stable suffix built from the page's `slugId`, so
|
||||
* the result stays deterministic across runs (SPEC §12: `Title ~slugId`).
|
||||
*/
|
||||
export declare function disambiguate(name: string, slugId: string): string;
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* Engine settings.
|
||||
*
|
||||
* The engine is driven IN-PROCESS by the NestJS server, which builds the
|
||||
* `Settings` object from `EnvironmentService` — so this module must NOT reach
|
||||
* into `process.env`. It exposes only:
|
||||
* - the `Settings` type the engine consumes, and
|
||||
* - `parseSettings(env)` as a PURE function (validate a raw env object -> typed
|
||||
* `Settings`), kept for unit tests and for the server to reuse if it wants
|
||||
* to validate an env-shaped object.
|
||||
* There is no `.env`-loading side-effecting entry point.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
export declare const envSchema: z.ZodObject<{
|
||||
DOCMOST_API_URL: z.ZodString;
|
||||
DOCMOST_EMAIL: z.ZodString;
|
||||
DOCMOST_PASSWORD: z.ZodString;
|
||||
DOCMOST_SPACE_ID: z.ZodString;
|
||||
VAULT_PATH: z.ZodDefault<z.ZodString>;
|
||||
GIT_REMOTE: z.ZodPipe<z.ZodTransform<unknown, unknown>, z.ZodOptional<z.ZodString>>;
|
||||
POLL_INTERVAL_MS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
||||
DEBOUNCE_MS: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
||||
LOG_LEVEL: z.ZodDefault<z.ZodEnum<{
|
||||
info: "info";
|
||||
error: "error";
|
||||
debug: "debug";
|
||||
warn: "warn";
|
||||
}>>;
|
||||
}, z.core.$strip>;
|
||||
export type Settings = {
|
||||
docmostApiUrl: string;
|
||||
docmostEmail: string;
|
||||
docmostPassword: string;
|
||||
docmostSpaceId: string;
|
||||
vaultPath: string;
|
||||
gitRemote?: string;
|
||||
pollIntervalMs: number;
|
||||
debounceMs: number;
|
||||
logLevel: 'debug' | 'info' | 'warn' | 'error';
|
||||
};
|
||||
export declare function parseSettings(env: NodeJS.ProcessEnv): Settings;
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* Engine settings.
|
||||
*
|
||||
* The engine is driven IN-PROCESS by the NestJS server, which builds the
|
||||
* `Settings` object from `EnvironmentService` — so this module must NOT reach
|
||||
* into `process.env`. It exposes only:
|
||||
* - the `Settings` type the engine consumes, and
|
||||
* - `parseSettings(env)` as a PURE function (validate a raw env object -> typed
|
||||
* `Settings`), kept for unit tests and for the server to reuse if it wants
|
||||
* to validate an env-shaped object.
|
||||
* There is no `.env`-loading side-effecting entry point.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
// Schema keyed by the real ENV variable names so validation errors name the
|
||||
// exact variable. Credentials and the address of our OWN Docmost instance have
|
||||
// NO default — a missing value must fail at startup, never silently fall back.
|
||||
export const envSchema = z.object({
|
||||
// Docmost connection — address of our own instance, no default.
|
||||
DOCMOST_API_URL: z.string().url(),
|
||||
// Credentials for /auth/login — no default, never hardcoded.
|
||||
DOCMOST_EMAIL: z.string().min(1),
|
||||
DOCMOST_PASSWORD: z.string().min(1),
|
||||
// Which Docmost space to mirror.
|
||||
DOCMOST_SPACE_ID: z.string().min(1),
|
||||
// Local git vault (state store) — kept under data/ so the volume persists it.
|
||||
VAULT_PATH: z.string().min(1).default('data/vault'),
|
||||
// Optional git remote the vault pushes to. Empty string is treated as unset.
|
||||
GIT_REMOTE: z.preprocess((v) => (v === '' ? undefined : v), z.string().min(1).optional()),
|
||||
// Non-secret tunables — sensible defaults are fine.
|
||||
POLL_INTERVAL_MS: z.coerce.number().int().positive().default(15000),
|
||||
DEBOUNCE_MS: z.coerce.number().int().positive().default(2000),
|
||||
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||||
});
|
||||
// Pure: validate a raw environment object and map it to a typed Settings.
|
||||
// Throws ZodError on bad config. No side effects — safe to import in tests.
|
||||
export function parseSettings(env) {
|
||||
const e = envSchema.parse(env);
|
||||
return {
|
||||
docmostApiUrl: e.DOCMOST_API_URL,
|
||||
docmostEmail: e.DOCMOST_EMAIL,
|
||||
docmostPassword: e.DOCMOST_PASSWORD,
|
||||
docmostSpaceId: e.DOCMOST_SPACE_ID,
|
||||
vaultPath: e.VAULT_PATH,
|
||||
gitRemote: e.GIT_REMOTE,
|
||||
pollIntervalMs: e.POLL_INTERVAL_MS,
|
||||
debounceMs: e.DEBOUNCE_MS,
|
||||
logLevel: e.LOG_LEVEL,
|
||||
};
|
||||
}
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* Meta object as `exportPageBody` builds it (SPEC §4). Kept byte-for-byte
|
||||
* compatible so files produced here match `exportPageBody`'s output exactly.
|
||||
*/
|
||||
export interface PageMeta {
|
||||
version: 1;
|
||||
pageId: string;
|
||||
slugId: string;
|
||||
title: string;
|
||||
spaceId: string;
|
||||
parentPageId: string | null;
|
||||
}
|
||||
/**
|
||||
* Produce the self-contained `.md` file text for a page from its raw
|
||||
* ProseMirror `content` + identity meta, in the verified fixpoint form.
|
||||
*
|
||||
* md1 = convertProseMirrorToMarkdown(content)
|
||||
* doc2 = markdownToProseMirror(md1) // one import...
|
||||
* stableBody = convertProseMirrorToMarkdown(doc2) // ...and re-export
|
||||
* file = serializeDocmostMarkdownBody(meta, stableBody)
|
||||
*
|
||||
* The single export->import->export pass is the verified fixpoint (SPEC §11):
|
||||
* idempotent for already-stable content, and the convergence point for the
|
||||
* known converter asymmetries.
|
||||
*/
|
||||
export declare function stabilizePageFile(content: unknown, meta: PageMeta): Promise<string>;
|
||||
/**
|
||||
* The fixpoint markdown BODY for a page's ProseMirror `content`, WITHOUT any meta
|
||||
* envelope:
|
||||
*
|
||||
* md1 = convertProseMirrorToMarkdown(content) // export...
|
||||
* doc2 = markdownToProseMirror(md1) // ...import...
|
||||
* stableBody = convertProseMirrorToMarkdown(doc2) // ...re-export
|
||||
*
|
||||
* The single export->import->export pass is the verified fixpoint (SPEC §11):
|
||||
* idempotent for already-stable content, and the convergence point for the known
|
||||
* converter asymmetries. The native-Obsidian writer (`serializePageFile`) wraps
|
||||
* this body with a minimal `gitmost_id` frontmatter; determinism here is what
|
||||
* keeps re-pulls of an unchanged page byte-identical (no churn, loop-guard).
|
||||
*/
|
||||
export declare function stabilizePageBody(content: unknown): Promise<string>;
|
||||
Vendored
-31
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* Public surface of `@docmost/git-sync`.
|
||||
*
|
||||
* Exposes the pure converter (markdown <-> ProseMirror, file envelope,
|
||||
* canonicalization) and the sync engine (reconcile planner, vault layout,
|
||||
* pull/push, the git wrapper, and the settings parser) that the gitmost server
|
||||
* drives in-process.
|
||||
*/
|
||||
export { serializeDocmostMarkdown, serializeDocmostMarkdownBody, parseDocmostMarkdown, convertProseMirrorToMarkdown, markdownToProseMirror, canonicalizeContent, docsCanonicallyEqual, } from "./lib/index.js";
|
||||
export type { DocmostMdMeta } from "./lib/index.js";
|
||||
export { planReconciliation, decideAbsenceDeletions, MASS_DELETE_MIN_EXISTING, MASS_DELETE_FRACTION, } from "./engine/reconcile.js";
|
||||
export type { LiveEntry, ExistingEntry, WriteEntry, MovedEntry, ReconciliationPlan, DeletionDecision, } from "./engine/reconcile.js";
|
||||
export { buildVaultLayout } from "./engine/layout.js";
|
||||
export type { PageNode, VaultEntry } from "./engine/layout.js";
|
||||
export { sanitizeTitle, disambiguate } from "./engine/sanitize.js";
|
||||
export { stabilizePageFile } from "./engine/stabilize.js";
|
||||
export type { PageMeta } from "./engine/stabilize.js";
|
||||
export { bodyHash } from "./engine/loop-guard.js";
|
||||
export type { GitSyncClient, GitSyncPageNodeLite } from "./engine/client.types.js";
|
||||
export { VaultGit, vaultGitEnv, buildCommitMessage, BOT_AUTHOR_NAME, BOT_AUTHOR_EMAIL, DEFAULT_BRANCH, } from "./engine/git.js";
|
||||
export type { DiffEntry, MergeResult, CommitOptions } from "./engine/git.js";
|
||||
export { readExisting, computePullActions, applyPullActions, } from "./engine/pull.js";
|
||||
export type { ReadExistingDeps, PullActionsInput, PullActions, ApplyPullActionsDeps, ApplyResult, } from "./engine/pull.js";
|
||||
export { classifyRenameMoves, computePushActions, applyPushActions, runPush, parentFolderFile, parseArgs, LAST_PUSHED_REF, DOCMOST_BRANCH, LOCAL_AUTHOR_NAME, LOCAL_AUTHOR_EMAIL, LOCAL_SOURCE_TRAILER, } from "./engine/push.js";
|
||||
export type { CreateAction, UpdateAction, DeleteAction, RenameMoveAction, RenameMoveActionClassified, ClassifyRenameMovesDeps, PushActions, PushActionsInput, MetaSide, ApplyPushDeps, WrittenBackPage, PushedPageRecord, PushFailure, PushNoop, ApplyPushResult, PushDeps, PushRunResult, PushParsedArgs, } from "./engine/push.js";
|
||||
export { parseSettings, envSchema } from "./engine/settings.js";
|
||||
export type { Settings } from "./engine/settings.js";
|
||||
export { loadSettingsOrExit } from "./engine/config-errors.js";
|
||||
export { runCycle } from "./engine/cycle.js";
|
||||
export type { RunCycleDeps, RunCycleResult, CycleFs, } from "./engine/cycle.js";
|
||||
export { parsePageFile, serializePageFile } from "./lib/page-file.js";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user