Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| babc42c2ff | |||
| 6ee814b7f3 | |||
| 3085ec1b50 | |||
| 05ec9feaf9 | |||
| 8e12579925 | |||
| 20703d06c2 | |||
| dab2660999 | |||
| 751d55e9db | |||
| 02308012a6 | |||
| 0665fcb630 | |||
| 97bd554cb5 | |||
| f77a6b42de | |||
| 134b627806 | |||
| 3267512ed9 | |||
| 48bd27b83c | |||
| 265b81c93d | |||
| ed808876be | |||
| a72ddbbe86 | |||
| d8fc724d90 | |||
| e4bfbcabaa | |||
| b8cce4f814 | |||
| c5bff2d84a | |||
| a325ddbabd | |||
| 80fc30633b | |||
| e17d5bc060 | |||
| 2c2d60a5dc | |||
| 1417209915 | |||
| f555fc87da | |||
| d6d1195abd | |||
| 36b940fdb8 | |||
| 0050ad7ebb | |||
| 43b11d92ab | |||
| ce70fab1df | |||
| 7b4617db70 | |||
| b51dae16a6 | |||
| 39735afd73 | |||
| 9b4b38a611 | |||
| eebbe6717c | |||
| e348433a39 | |||
| f759084f41 | |||
| 459d636ffb |
@@ -151,6 +151,12 @@ jobs:
|
|||||||
- name: Build editor-ext
|
- name: Build editor-ext
|
||||||
run: pnpm --filter @docmost/editor-ext build
|
run: pnpm --filter @docmost/editor-ext build
|
||||||
|
|
||||||
|
# @docmost/prosemirror-markdown is an ESM workspace package the server
|
||||||
|
# imports at runtime; its build/ is gitignored and test:e2e has no pretest
|
||||||
|
# hook, so build it before the e2e run (mirrors the test.yml job).
|
||||||
|
- name: Build prosemirror-markdown
|
||||||
|
run: pnpm --filter @docmost/prosemirror-markdown build
|
||||||
|
|
||||||
- name: Run migrations
|
- name: Run migrations
|
||||||
run: pnpm --filter ./apps/server migration:latest
|
run: pnpm --filter ./apps/server migration:latest
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,49 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Guard against a long-lived branch adding a migration whose timestamped
|
||||||
|
# filename sorts BEFORE migrations already applied on the target branch (and
|
||||||
|
# thus in prod). The Kysely startup migrator rejects that as "corrupted
|
||||||
|
# migrations" and crash-loops the app on boot (incident #361). This gate fails
|
||||||
|
# the PR so the migration is renamed to a current timestamp before merge. Only
|
||||||
|
# runs for pull_request events (needs a base branch to diff against).
|
||||||
|
migration-order:
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- name: Checkout (full history for the base-branch diff)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Added migrations must sort after the newest on the base branch
|
||||||
|
env:
|
||||||
|
TARGET_BRANCH: ${{ github.base_ref }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
MIG_DIR="apps/server/src/database/migrations"
|
||||||
|
# checkout above already did fetch-depth:0 (full history). Fetch the base
|
||||||
|
# WITHOUT --depth (a shallow graft would truncate the base history and
|
||||||
|
# break the merge-base when the base has moved ahead of the PR merge —
|
||||||
|
# exactly the long-branch-vs-moving-base case this gate guards, #361).
|
||||||
|
git fetch --no-tags origin "$TARGET_BRANCH"
|
||||||
|
newest_on_target=$(git ls-tree -r --name-only "origin/${TARGET_BRANCH}" "$MIG_DIR" | sort | tail -1)
|
||||||
|
# NO `|| true`: a diff failure (e.g. an unresolved merge-base) must fail
|
||||||
|
# the job CLOSED — a gate whose job is to BLOCK must never pass on error.
|
||||||
|
# `set -e` above already aborts on a non-zero diff exit.
|
||||||
|
added=$(git diff --diff-filter=A --name-only "origin/${TARGET_BRANCH}...HEAD" -- "$MIG_DIR")
|
||||||
|
bad=0
|
||||||
|
for f in $added; do
|
||||||
|
if [[ "$f" < "$newest_on_target" || "$f" == "$newest_on_target" ]]; then
|
||||||
|
echo "::error::Migration $f sorts at or before the newest on ${TARGET_BRANCH} ($newest_on_target) — rename it with a CURRENT timestamp before merge (do not change its contents). See incident #361."
|
||||||
|
bad=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$bad" -eq 0 ]; then
|
||||||
|
echo "Migration order OK (added migrations all sort after $newest_on_target)."
|
||||||
|
fi
|
||||||
|
exit $bad
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ pnpm workspace (`pnpm@10.4.0`) orchestrated by **Nx**. Four workspace packages:
|
|||||||
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend |
|
| `apps/client` | `client` | React 18 + Vite + Mantine 8 + TanStack Query + Jotai | SPA frontend |
|
||||||
| `packages/editor-ext` | `@docmost/editor-ext` | Tiptap/ProseMirror | Shared Tiptap node/mark extensions, imported by both the client and the server |
|
| `packages/editor-ext` | `@docmost/editor-ext` | Tiptap/ProseMirror | Shared Tiptap node/mark extensions, imported by both the client and the server |
|
||||||
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Consumes the shared converter/schema from `@docmost/prosemirror-markdown` (#293) — it no longer carries its own vendored converter/schema copy |
|
| `packages/mcp` | `@docmost/mcp` | MCP SDK, Tiptap, Yjs | Standalone MCP server, also bundled into the server at `/mcp`. Consumes the shared converter/schema from `@docmost/prosemirror-markdown` (#293) — it no longer carries its own vendored converter/schema copy |
|
||||||
| `packages/prosemirror-markdown` | `@docmost/prosemirror-markdown` | Tiptap, marked, jsdom | The single, canonical ProseMirror↔Markdown converter + Docmost schema mirror (#293). Consumed by `mcp` and `git-sync`; there is exactly ONE copy of the converter now |
|
| `packages/prosemirror-markdown` | `@docmost/prosemirror-markdown` | Tiptap, marked, jsdom | The single, canonical ProseMirror↔Markdown converter + Docmost schema mirror (#293). Consumed by `mcp`, `git-sync`, AND `apps/server` (server-side markdown import/export, #345); there is exactly ONE copy of the converter now |
|
||||||
|
|
||||||
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
`build` targets are Nx-cached and dependency-ordered (`dependsOn: ["^build"]`), so `editor-ext` builds before the apps. `nx.json` sets `affected.defaultBase: main`.
|
||||||
|
|
||||||
@@ -214,6 +214,12 @@ Run from the repo root unless noted. The dev workflow needs **Postgres (with the
|
|||||||
> server, `APP_SECRET` mismatch between processes, a stale `editor-ext` white-
|
> server, `APP_SECRET` mismatch between processes, a stale `editor-ext` white-
|
||||||
> screening the client, LAN exposure. See **[docs/dev-stand.md](docs/dev-stand.md)**
|
> screening the client, LAN exposure. See **[docs/dev-stand.md](docs/dev-stand.md)**
|
||||||
> for the step-by-step and the traps.
|
> for the step-by-step and the traps.
|
||||||
|
>
|
||||||
|
> **Testing the app against a stand** (browser E2E + out-of-band verification) has
|
||||||
|
> its own non-obvious traps — the page has two ProseMirror editors (only the body is
|
||||||
|
> collab-bound), a ~10s store debounce, and API-seeding the thing under test is a
|
||||||
|
> silent no-test. See **[docs/how-to-test.md](docs/how-to-test.md)** before writing
|
||||||
|
> UI tests.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install # install all workspaces (uses pnpm patches; see package.json `pnpm.patchedDependencies`)
|
pnpm install # install all workspaces (uses pnpm patches; see package.json `pnpm.patchedDependencies`)
|
||||||
@@ -250,7 +256,10 @@ pnpm --filter server migration:codegen # regenerate src/databa
|
|||||||
```
|
```
|
||||||
Migration files live in `apps/server/src/database/migrations/` and are named `YYYYMMDDThhmmss-description.ts`. Fork-specific migrations only **add** tables (`page_embeddings`, `ai_chats`, `ai_chat_messages`, `ai_provider_credentials`, `ai_mcp_servers`, `page_template_references`) and columns (e.g. `pages.is_template`, a `NOT NULL DEFAULT false` boolean) — never drop/rewrite Docmost data.
|
Migration files live in `apps/server/src/database/migrations/` and are named `YYYYMMDDThhmmss-description.ts`. Fork-specific migrations only **add** tables (`page_embeddings`, `ai_chats`, `ai_chat_messages`, `ai_provider_credentials`, `ai_mcp_servers`, `page_template_references`) and columns (e.g. `pages.is_template`, a `NOT NULL DEFAULT false` boolean) — never drop/rewrite Docmost data.
|
||||||
|
|
||||||
**Migration ordering — always check when merging branches/features.** Kysely runs migrations in **alphabetical (= timestamp) order** and refuses to start if a *new* migration sorts **before** one already applied to the DB (`corrupted migrations: ... must always have a name that comes alphabetically after the last executed migration`). When you merge a branch or land a feature, verify your migration's timestamp still sorts **after every migration that may already be applied on the target** (`/bin/ls -1 apps/server/src/database/migrations | sort | tail`). Branches developed in parallel routinely break this: a feature branch adds `…T130000-…`, `main` meanwhile ships and deploys `…T150000-…`, and after the merge the older-timestamped file is rejected at boot. **Fix = rename your migration to a timestamp after the latest one already in the target** (content unchanged — the filename is the ordering key), then rebuild so the compiled `dist/database/migrations/` picks up the new name.
|
**Migration ordering — always check when merging branches/features.** Kysely runs migrations in **alphabetical (= timestamp) order**. A *new* migration that sorts **before** one already applied to the DB is a "back-dated" migration, which branches developed in parallel routinely produce: a feature branch adds `…T130000-…`, `develop` meanwhile ships and deploys `…T150000-…`, and after the merge the older-timestamped file has been skipped. Two layers guard this (both added for incident #361, where a back-dated migration crash-looped prod for ~11 min):
|
||||||
|
|
||||||
|
- **CI gate (primary):** the `migration-order` job in `.github/workflows/test.yml` fails a PR whose added migration sorts at/before the newest on the base branch. **So the fix is to rename your migration to a timestamp after the latest one already in the target** (`/bin/ls -1 apps/server/src/database/migrations | sort | tail`; content unchanged — the filename is the ordering key), then rebuild so the compiled `dist/database/migrations/` picks up the new name.
|
||||||
|
- **Runtime safety net:** both Migrators (`migration.service.ts` startup auto-migrate + `migrate.ts` CLI) set `allowUnorderedMigrations: true`, so the app does **not** refuse to start on an out-of-order migration — it applies the skipped older one instead of crash-looping. Kysely's `#ensureNoMissingMigrations` guard is still on (a *removed* applied migration is still an error). Because apply order can then differ from lexicographic across instances, migrations must stay **independent** (each creates its own objects) — the CI gate remains the primary line; this net only covers a gate bypass (manual push / hotfix branch).
|
||||||
|
|
||||||
## Architecture — the big picture
|
## Architecture — the big picture
|
||||||
|
|
||||||
@@ -284,7 +293,7 @@ The API server is a Fastify app with a global `/api` prefix (`main.ts` excludes
|
|||||||
### Client structure
|
### Client structure
|
||||||
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirrors the server domains: `page`, `space`, `comment`, `ai-chat`, `editor`, …). Conventions:
|
||||||
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
|
- **TanStack Query** for server state (one `queries/` file per feature), **Jotai** atoms for local/shared UI state, **Mantine 8** + CSS modules (`*.module.css`) + `postcss-preset-mantine` for UI.
|
||||||
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, import/export) — editor schema changes often need to be made in `editor-ext`, not just the client. 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.
|
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, and `apps/server` (#345) — do NOT reintroduce a per-package copy. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence.
|
||||||
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
|
||||||
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
|
||||||
|
|
||||||
@@ -294,7 +303,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
|
|||||||
- **Errors must never be swallowed or shown as generic messages.** Every caught error MUST (1) be logged in full to the console/logger — error name, message, stack, `cause`, and (for HTTP/provider failures) the status code and response body — and (2) be surfaced to the user with a *specific, human-readable explanation of what actually went wrong*, never a bare generic string like "Something went wrong" / "Could not start recording" / "Transcription failed". Include the real reason (the underlying error/provider message) in the user-facing text. On the server, wrap third-party/provider failures with `describeProviderError` (or equivalent) and rethrow as a meaningful HTTP status + message — never let them collapse into an opaque 500. On the client, `console.error(<context>, err)` the raw error AND show the extracted reason (e.g. `err.response?.data?.message`, or the error `name: message`) in the notification.
|
- **Errors must never be swallowed or shown as generic messages.** Every caught error MUST (1) be logged in full to the console/logger — error name, message, stack, `cause`, and (for HTTP/provider failures) the status code and response body — and (2) be surfaced to the user with a *specific, human-readable explanation of what actually went wrong*, never a bare generic string like "Something went wrong" / "Could not start recording" / "Transcription failed". Include the real reason (the underlying error/provider message) in the user-facing text. On the server, wrap third-party/provider failures with `describeProviderError` (or equivalent) and rethrow as a meaningful HTTP status + message — never let them collapse into an opaque 500. On the client, `console.error(<context>, err)` the raw error AND show the extracted reason (e.g. `err.response?.data?.message`, or the error `name: message`) in the notification.
|
||||||
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
|
||||||
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
|
||||||
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons.
|
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`, `ai`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons. The `ai@6.0.134` patch disables the SDK's O(n²) cumulative `partialOutput` accumulation when no output strategy is requested (server heap OOM on long agent runs, #184; tripwire test: `apps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts`) — it MUST be re-created via `pnpm patch` when bumping `ai`.
|
||||||
- **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.
|
- **Adding/renaming/removing an MCP tool requires updating `SERVER_INSTRUCTIONS`** in `packages/mcp/src/index.ts` — the intent-routing guide MCP clients receive on initialize. This applies both to inline `server.registerTool(...)` calls in `index.ts` and to specs in `packages/mcp/src/tool-specs.ts`. Enforced by `packages/mcp/test/unit/server-instructions.test.mjs`, which fails when a registered tool is not mentioned in the guide (deliberate opt-outs go into its `EXCEPTIONS` list). `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
|
||||||
|
|
||||||
## CI / release
|
## CI / release
|
||||||
|
|||||||
@@ -169,6 +169,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **The server no longer runs out of heap during long autonomous agent runs.** A
|
||||||
|
new pnpm patch on `ai@6.0.134` stops the SDK from building a cumulative
|
||||||
|
snapshot of the ENTIRE turn text on every streamed text-delta when no output
|
||||||
|
strategy was requested (our server never requests one). Unpatched, those
|
||||||
|
O(n²) `partialOutput` snapshots piled up in a never-consumed internal
|
||||||
|
`tee()` branch of the stream result — a ~20-step, ~28k-chunk agent run
|
||||||
|
retained ~1.7 GB and OOM'd the 2 GB JS heap. Streaming granularity is
|
||||||
|
unchanged; the patch must be re-created if `ai` is ever bumped. (#184)
|
||||||
- **Internal links in exported Markdown no longer lose their visible text.** A
|
- **Internal links in exported Markdown no longer lose their visible text.** A
|
||||||
link whose target page name had no file extension (e.g. a bare title) was
|
link whose target page name had no file extension (e.g. a bare title) was
|
||||||
collapsed to empty text during export, producing an unclickable, label-less
|
collapsed to empty text during export, producing an unclickable, label-less
|
||||||
|
|||||||
@@ -98,6 +98,7 @@
|
|||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.57.1",
|
"typescript-eslint": "8.57.1",
|
||||||
"vite": "8.0.5",
|
"vite": "8.0.5",
|
||||||
|
"vite-plugin-compression2": "2.5.3",
|
||||||
"vitest": "4.1.6"
|
"vitest": "4.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1373,6 +1373,39 @@
|
|||||||
"The role catalog is unavailable": "The role catalog is unavailable",
|
"The role catalog is unavailable": "The role catalog is unavailable",
|
||||||
"Please try again later.": "Please try again later.",
|
"Please try again later.": "Please try again later.",
|
||||||
"No bundles available": "No bundles available",
|
"No bundles available": "No bundles available",
|
||||||
|
"Content": "Content",
|
||||||
|
"Content language of the roles": "Content language of the roles",
|
||||||
|
"{{count}} updates available in {{bundles}} bundles": "{{count}} updates available in {{bundles}} bundles",
|
||||||
|
"Update all ({{count}})": "Update all ({{count}})",
|
||||||
|
"Updating {{current}}/{{total}}…": "Updating {{current}}/{{total}}…",
|
||||||
|
"{{count}} roles are installed in another language. A different language installs separately and appears as new.": "{{count}} roles are installed in another language. A different language installs separately and appears as new.",
|
||||||
|
"{{count}} roles": "{{count}} roles",
|
||||||
|
"{{count}} new — none installed": "{{count}} new — none installed",
|
||||||
|
"All installed · up to date": "All installed · up to date",
|
||||||
|
"{{count}} updates · {{installed}} up to date": "{{count}} updates · {{installed}} up to date",
|
||||||
|
"{{count}} new": "{{count}} new",
|
||||||
|
"{{count}} installed": "{{count}} installed",
|
||||||
|
"{{count}} updates": "{{count}} updates",
|
||||||
|
"Install bundle": "Install bundle",
|
||||||
|
"Install {{count}} selected": "Install {{count}} selected",
|
||||||
|
"Install bundle ({{count}})": "Install bundle ({{count}})",
|
||||||
|
"{{selected}} of {{total}} selected": "{{selected}} of {{total}} selected",
|
||||||
|
"Select all": "Select all",
|
||||||
|
"Deselect all": "Deselect all",
|
||||||
|
"Skipped": "Skipped",
|
||||||
|
"v{{version}}": "v{{version}}",
|
||||||
|
"{{count}} roles installed": "{{count}} roles installed",
|
||||||
|
"{{count}} roles installed · {{renamed}} renamed": "{{count}} roles installed · {{renamed}} renamed",
|
||||||
|
"{{count}} roles updated": "{{count}} roles updated",
|
||||||
|
"Installed {{installed}} · {{skipped}} skipped": "Installed {{installed}} · {{skipped}} skipped",
|
||||||
|
"A role named \"{{name}}\" already exists in this workspace.": "A role named \"{{name}}\" already exists in this workspace.",
|
||||||
|
"\"{{name}}\" is already installed.": "\"{{name}}\" is already installed.",
|
||||||
|
"Rename & install": "Rename & install",
|
||||||
|
"Couldn’t load the catalog": "Couldn’t load the catalog",
|
||||||
|
"Check your connection and try again. Installed roles are not affected.": "Check your connection and try again. Installed roles are not affected.",
|
||||||
|
"Retry": "Retry",
|
||||||
|
"The catalog is empty": "The catalog is empty",
|
||||||
|
"No role bundles are published for this language yet. Try switching the content language.": "No role bundles are published for this language yet. Try switching the content language.",
|
||||||
"Already up to date": "Already up to date",
|
"Already up to date": "Already up to date",
|
||||||
"Updated to the latest version": "Updated to the latest version",
|
"Updated to the latest version": "Updated to the latest version",
|
||||||
"This role is no longer in the catalog": "This role is no longer in the catalog",
|
"This role is no longer in the catalog": "This role is no longer in the catalog",
|
||||||
|
|||||||
@@ -1235,6 +1235,39 @@
|
|||||||
"The role catalog is unavailable": "Каталог ролей недоступен",
|
"The role catalog is unavailable": "Каталог ролей недоступен",
|
||||||
"Please try again later.": "Попробуйте позже.",
|
"Please try again later.": "Попробуйте позже.",
|
||||||
"No bundles available": "Наборы недоступны",
|
"No bundles available": "Наборы недоступны",
|
||||||
|
"Content": "Язык контента",
|
||||||
|
"Content language of the roles": "Язык контента ролей",
|
||||||
|
"{{count}} updates available in {{bundles}} bundles": "Доступно обновлений: {{count}} в наборах: {{bundles}}",
|
||||||
|
"Update all ({{count}})": "Обновить все ({{count}})",
|
||||||
|
"Updating {{current}}/{{total}}…": "Обновление {{current}}/{{total}}…",
|
||||||
|
"{{count}} roles are installed in another language. A different language installs separately and appears as new.": "Ролей установлено на другом языке: {{count}}. Другой язык устанавливается отдельно и отображается как новый.",
|
||||||
|
"{{count}} roles": "ролей: {{count}}",
|
||||||
|
"{{count}} new — none installed": "новых: {{count}} — ничего не установлено",
|
||||||
|
"All installed · up to date": "Все установлены · актуальны",
|
||||||
|
"{{count}} updates · {{installed}} up to date": "обновлений: {{count}} · актуальны: {{installed}}",
|
||||||
|
"{{count}} new": "новых: {{count}}",
|
||||||
|
"{{count}} installed": "установлено: {{count}}",
|
||||||
|
"{{count}} updates": "обновлений: {{count}}",
|
||||||
|
"Install bundle": "Установить набор",
|
||||||
|
"Install {{count}} selected": "Установить выбранные ({{count}})",
|
||||||
|
"Install bundle ({{count}})": "Установить набор ({{count}})",
|
||||||
|
"{{selected}} of {{total}} selected": "выбрано {{selected}} из {{total}}",
|
||||||
|
"Select all": "Выбрать все",
|
||||||
|
"Deselect all": "Снять выбор",
|
||||||
|
"Skipped": "Пропущено",
|
||||||
|
"v{{version}}": "v{{version}}",
|
||||||
|
"{{count}} roles installed": "Установлено ролей: {{count}}",
|
||||||
|
"{{count}} roles installed · {{renamed}} renamed": "Установлено ролей: {{count}} · переименовано: {{renamed}}",
|
||||||
|
"{{count}} roles updated": "Обновлено ролей: {{count}}",
|
||||||
|
"Installed {{installed}} · {{skipped}} skipped": "Установлено: {{installed}} · пропущено: {{skipped}}",
|
||||||
|
"A role named \"{{name}}\" already exists in this workspace.": "Роль с именем «{{name}}» уже существует в этом рабочем пространстве.",
|
||||||
|
"\"{{name}}\" is already installed.": "«{{name}}» уже установлена.",
|
||||||
|
"Rename & install": "Переименовать и установить",
|
||||||
|
"Couldn’t load the catalog": "Не удалось загрузить каталог",
|
||||||
|
"Check your connection and try again. Installed roles are not affected.": "Проверьте подключение и попробуйте снова. Установленные роли не затронуты.",
|
||||||
|
"Retry": "Повторить",
|
||||||
|
"The catalog is empty": "Каталог пуст",
|
||||||
|
"No role bundles are published for this language yet. Try switching the content language.": "Для этого языка ещё не опубликовано ни одного набора ролей. Попробуйте сменить язык контента.",
|
||||||
"No roles configured": "Роли не настроены",
|
"No roles configured": "Роли не настроены",
|
||||||
"Already up to date": "Уже актуальна",
|
"Already up to date": "Уже актуальна",
|
||||||
"Updated to the latest version": "Обновлено до последней версии",
|
"Updated to the latest version": "Обновлено до последней версии",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
useMutation,
|
useMutation,
|
||||||
|
useQueries,
|
||||||
useQuery,
|
useQuery,
|
||||||
useQueryClient,
|
useQueryClient,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
@@ -307,6 +308,29 @@ export function useAiRoleCatalogBundleQuery(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eagerly open EVERY listed bundle's content in parallel for one language. The
|
||||||
|
* redesigned catalog shows each bundle's status summary in its COLLAPSED header,
|
||||||
|
* which needs every role's install state up front — so contents can no longer be
|
||||||
|
* lazy-loaded on expand. The catalog is small, so a fan-out of `useQueries` (one
|
||||||
|
* cached read per bundle, sharing the same cache keys as
|
||||||
|
* `useAiRoleCatalogBundleQuery`) is cheap. Gated by `enabled` (modal open + a
|
||||||
|
* resolved language) so nothing fetches while the modal is closed.
|
||||||
|
*/
|
||||||
|
export function useAiRoleCatalogBundlesQueries(
|
||||||
|
bundleIds: string[],
|
||||||
|
language: string,
|
||||||
|
enabled: boolean,
|
||||||
|
) {
|
||||||
|
return useQueries({
|
||||||
|
queries: bundleIds.map((bundleId) => ({
|
||||||
|
queryKey: AI_ROLE_CATALOG_BUNDLE_RQ_KEY(bundleId, language),
|
||||||
|
queryFn: () => getAiRoleCatalogBundle(bundleId, language),
|
||||||
|
enabled: enabled && !!language,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useImportAiRolesFromCatalogMutation() {
|
export function useImportAiRolesFromCatalogMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|||||||
@@ -77,7 +77,14 @@ describe("useImportAiRolesFromCatalogMutation — success notifications", () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("errors:[] -> only the summary notification (counts interpolated)", async () => {
|
it("errors:[] -> only the summary notification (counts interpolated)", async () => {
|
||||||
await runMutation({ created: 3, renamed: 1, skipped: 2, errors: [] });
|
await runMutation({
|
||||||
|
created: 3,
|
||||||
|
renamed: 1,
|
||||||
|
skipped: 2,
|
||||||
|
errors: [],
|
||||||
|
createdRoles: [],
|
||||||
|
skippedRoles: [],
|
||||||
|
});
|
||||||
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
|
expect(notificationsShowMock).toHaveBeenCalledTimes(1);
|
||||||
expect(notificationsShowMock).toHaveBeenCalledWith({
|
expect(notificationsShowMock).toHaveBeenCalledWith({
|
||||||
message: "Imported 3, renamed 1, skipped 2",
|
message: "Imported 3, renamed 1, skipped 2",
|
||||||
@@ -93,6 +100,8 @@ describe("useImportAiRolesFromCatalogMutation — success notifications", () =>
|
|||||||
{ slug: "a", message: "name taken" },
|
{ slug: "a", message: "name taken" },
|
||||||
{ slug: "b", message: "name taken" },
|
{ slug: "b", message: "name taken" },
|
||||||
],
|
],
|
||||||
|
createdRoles: [{ slug: "ok", name: "Ok" }],
|
||||||
|
skippedRoles: [],
|
||||||
});
|
});
|
||||||
expect(notificationsShowMock).toHaveBeenCalledTimes(2);
|
expect(notificationsShowMock).toHaveBeenCalledTimes(2);
|
||||||
expect(notificationsShowMock).toHaveBeenNthCalledWith(1, {
|
expect(notificationsShowMock).toHaveBeenNthCalledWith(1, {
|
||||||
|
|||||||
@@ -108,12 +108,25 @@ export interface IAiRoleImportPayload {
|
|||||||
conflict: "skip" | "rename";
|
conflict: "skip" | "rename";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Import result counts (mirrors `importFromCatalog()`). */
|
/**
|
||||||
|
* Import result (mirrors `importFromCatalog()`). The counters (`created`,
|
||||||
|
* `skipped`, `renamed`) drive the summary notification; the per-role lists
|
||||||
|
* (`createdRoles`, `skippedRoles`) drive the redesigned catalog modal's inline
|
||||||
|
* result plaque — which roles were installed (and any rename) and which were
|
||||||
|
* skipped and why (so the plaque can name the conflicting role and offer
|
||||||
|
* "Rename & install").
|
||||||
|
*/
|
||||||
export interface IAiRoleImportResult {
|
export interface IAiRoleImportResult {
|
||||||
created: number;
|
created: number;
|
||||||
skipped: number;
|
skipped: number;
|
||||||
renamed: number;
|
renamed: number;
|
||||||
errors: { slug: string; message: string }[];
|
errors: { slug: string; message: string }[];
|
||||||
|
createdRoles: { slug: string; name: string; renamedTo?: string }[];
|
||||||
|
skippedRoles: {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
reason: "name-conflict" | "already-installed";
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
bundleCounts,
|
||||||
|
bundlePhase,
|
||||||
|
installedLangForRole,
|
||||||
|
mapBundleRolesToView,
|
||||||
|
mapCatalogRoleToView,
|
||||||
|
nameConflictSlugs,
|
||||||
|
partialOffersRename,
|
||||||
|
type CatalogViewRole,
|
||||||
|
} from "./catalog-bundle-model.ts";
|
||||||
|
import type {
|
||||||
|
IAiRole,
|
||||||
|
IAiRoleCatalogRole,
|
||||||
|
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
|
||||||
|
function installedRole(
|
||||||
|
source: { slug: string; language: string; version: number },
|
||||||
|
overrides: Partial<IAiRole> = {},
|
||||||
|
): IAiRole {
|
||||||
|
return {
|
||||||
|
id: `role-${source.slug}-${source.language}`,
|
||||||
|
name: source.slug,
|
||||||
|
emoji: null,
|
||||||
|
description: null,
|
||||||
|
enabled: true,
|
||||||
|
autoStart: true,
|
||||||
|
launchMessage: null,
|
||||||
|
source,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function catalogRole(
|
||||||
|
overrides: Partial<IAiRoleCatalogRole> = {},
|
||||||
|
): IAiRoleCatalogRole {
|
||||||
|
return {
|
||||||
|
slug: "writer",
|
||||||
|
emoji: "✍️",
|
||||||
|
name: "Writer",
|
||||||
|
description: "Drafts copy.",
|
||||||
|
instructions: "be a writer",
|
||||||
|
autoStart: true,
|
||||||
|
launchMessage: null,
|
||||||
|
version: 3,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build a minimal view role for bundlePhase tests.
|
||||||
|
function viewRole(status: CatalogViewRole["status"]): CatalogViewRole {
|
||||||
|
return { slug: `s-${status}`, name: status, description: "", version: 1, status };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("bundlePhase", () => {
|
||||||
|
it("empty bundle -> empty", () => {
|
||||||
|
expect(bundlePhase([])).toBe("empty");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all importable, none installed -> allNew", () => {
|
||||||
|
expect(bundlePhase([viewRole("import"), viewRole("import")])).toBe(
|
||||||
|
"allNew",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nothing to import or update -> allInstalled", () => {
|
||||||
|
expect(bundlePhase([viewRole("installed"), viewRole("installed")])).toBe(
|
||||||
|
"allInstalled",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates present, nothing to import -> updates", () => {
|
||||||
|
expect(bundlePhase([viewRole("update"), viewRole("installed")])).toBe(
|
||||||
|
"updates",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("import + installed (no updates) -> mixed", () => {
|
||||||
|
expect(bundlePhase([viewRole("import"), viewRole("installed")])).toBe(
|
||||||
|
"mixed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("import + update -> mixed", () => {
|
||||||
|
expect(bundlePhase([viewRole("import"), viewRole("update")])).toBe("mixed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a skipped role with nothing installed -> mixed (NOT allInstalled)", () => {
|
||||||
|
// F1: a bundle whose only non-installed role was skipped has 0 installed for
|
||||||
|
// it, so the collapsed 'All installed · up to date' header would contradict
|
||||||
|
// the open 'Installed 0 · 1 skipped' plaque. It must be mixed until resolved.
|
||||||
|
expect(bundlePhase([viewRole("skipped")])).toBe("mixed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("installed + a skipped role -> mixed (partial success is not allInstalled)", () => {
|
||||||
|
expect(bundlePhase([viewRole("installed"), viewRole("skipped")])).toBe(
|
||||||
|
"mixed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bundleCounts", () => {
|
||||||
|
it("tallies each status once", () => {
|
||||||
|
expect(
|
||||||
|
bundleCounts([
|
||||||
|
viewRole("import"),
|
||||||
|
viewRole("import"),
|
||||||
|
viewRole("installed"),
|
||||||
|
viewRole("update"),
|
||||||
|
viewRole("skipped"),
|
||||||
|
]),
|
||||||
|
).toEqual({ importable: 2, installed: 1, update: 1, skipped: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("nameConflictSlugs / partialOffersRename (reason -> action)", () => {
|
||||||
|
it("only name-conflict skips become the transient overlay / offer rename", () => {
|
||||||
|
const skipped = [
|
||||||
|
{ slug: "writer", name: "Writer", reason: "name-conflict" as const },
|
||||||
|
{ slug: "editor", name: "Editor", reason: "already-installed" as const },
|
||||||
|
];
|
||||||
|
expect(nameConflictSlugs(skipped)).toEqual(["writer"]);
|
||||||
|
expect(partialOffersRename(skipped)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an already-installed-only skip is informational: no overlay, no rename", () => {
|
||||||
|
const skipped = [
|
||||||
|
{ slug: "editor", name: "Editor", reason: "already-installed" as const },
|
||||||
|
];
|
||||||
|
expect(nameConflictSlugs(skipped)).toEqual([]);
|
||||||
|
expect(partialOffersRename(skipped)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("installedLangForRole", () => {
|
||||||
|
it("returns the other language when the same slug is installed elsewhere", () => {
|
||||||
|
const roles = [installedRole({ slug: "writer", language: "ru", version: 2 })];
|
||||||
|
expect(installedLangForRole("writer", roles, "en")).toBe("ru");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when the same slug is installed in the SAME language", () => {
|
||||||
|
const roles = [installedRole({ slug: "writer", language: "en", version: 2 })];
|
||||||
|
expect(installedLangForRole("writer", roles, "en")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined when no install of the slug exists", () => {
|
||||||
|
expect(installedLangForRole("writer", [], "en")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores manually-created roles (no source)", () => {
|
||||||
|
const roles = [
|
||||||
|
installedRole({ slug: "writer", language: "ru", version: 2 }, {
|
||||||
|
source: null,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
expect(installedLangForRole("writer", roles, "en")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapCatalogRoleToView", () => {
|
||||||
|
it("no install -> import status, catalog version, emoji preserved", () => {
|
||||||
|
const view = mapCatalogRoleToView(catalogRole(), [], "en");
|
||||||
|
expect(view).toMatchObject({
|
||||||
|
slug: "writer",
|
||||||
|
emoji: "✍️",
|
||||||
|
name: "Writer",
|
||||||
|
description: "Drafts copy.",
|
||||||
|
status: "import",
|
||||||
|
version: 3,
|
||||||
|
});
|
||||||
|
expect(view.installedRoleId).toBeUndefined();
|
||||||
|
expect(view.installedLang).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("import with the slug installed in another language -> installedLang set", () => {
|
||||||
|
const roles = [installedRole({ slug: "writer", language: "ru", version: 9 })];
|
||||||
|
const view = mapCatalogRoleToView(catalogRole(), roles, "en");
|
||||||
|
expect(view.status).toBe("import");
|
||||||
|
expect(view.installedLang).toBe("ru");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("installed (up to date) -> installed status, catalog version, installedRoleId", () => {
|
||||||
|
const installed = installedRole({
|
||||||
|
slug: "writer",
|
||||||
|
language: "en",
|
||||||
|
version: 3,
|
||||||
|
});
|
||||||
|
const view = mapCatalogRoleToView(catalogRole(), [installed], "en");
|
||||||
|
expect(view).toMatchObject({
|
||||||
|
status: "installed",
|
||||||
|
version: 3,
|
||||||
|
installedRoleId: installed.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("update -> version=from, newVersion=to, installedRoleId", () => {
|
||||||
|
const installed = installedRole({
|
||||||
|
slug: "writer",
|
||||||
|
language: "en",
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
const view = mapCatalogRoleToView(catalogRole(), [installed], "en");
|
||||||
|
expect(view).toMatchObject({
|
||||||
|
status: "update",
|
||||||
|
version: 1,
|
||||||
|
newVersion: 3,
|
||||||
|
installedRoleId: installed.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("missing emoji -> emoji undefined; null description -> empty string", () => {
|
||||||
|
const view = mapCatalogRoleToView(
|
||||||
|
catalogRole({ emoji: null, description: null }),
|
||||||
|
[],
|
||||||
|
"en",
|
||||||
|
);
|
||||||
|
expect(view.emoji).toBeUndefined();
|
||||||
|
expect(view.description).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mapBundleRolesToView", () => {
|
||||||
|
it("maps a bundle's roles preserving order", () => {
|
||||||
|
const roles = [
|
||||||
|
catalogRole({ slug: "a", name: "A", version: 1 }),
|
||||||
|
catalogRole({ slug: "b", name: "B", version: 1 }),
|
||||||
|
];
|
||||||
|
const installed = [installedRole({ slug: "a", language: "en", version: 1 })];
|
||||||
|
const view = mapBundleRolesToView(roles, installed, "en");
|
||||||
|
expect(view.map((r) => r.slug)).toEqual(["a", "b"]);
|
||||||
|
expect(view[0].status).toBe("installed");
|
||||||
|
expect(view[1].status).toBe("import");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import type {
|
||||||
|
IAiRole,
|
||||||
|
IAiRoleCatalogRole,
|
||||||
|
} from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||||
|
import { catalogRoleInstallState } from "@/features/ai-chat/utils/catalog-role-install-state.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The redesigned catalog modal renders bundles as cards with a summary status
|
||||||
|
* (readable without expanding) and a single primary action. The per-role and
|
||||||
|
* per-bundle view model that drives that UI is derived here as PURE functions so
|
||||||
|
* the mapping, the "installed in another language" hint, and the bundle-phase
|
||||||
|
* computation are unit-testable without mounting the component (mirrors the
|
||||||
|
* `catalogRoleInstallState` precedent).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A role's status in the catalog view model.
|
||||||
|
* - `import` — not installed in the current content language.
|
||||||
|
* - `installed` — installed and up to date.
|
||||||
|
* - `update` — installed, but the catalog ships a newer version.
|
||||||
|
* - `skipped` — TRANSIENT client-only status set after a conflicted import
|
||||||
|
* (a name collision under `conflict:'skip'`); never from the
|
||||||
|
* backend.
|
||||||
|
*/
|
||||||
|
export type RoleStatus = "import" | "installed" | "update" | "skipped";
|
||||||
|
|
||||||
|
/** A catalog role mapped into the modal's view model. */
|
||||||
|
export interface CatalogViewRole {
|
||||||
|
// Slug is the stable identity within a bundle; used as the row key and as the
|
||||||
|
// `slugs[]` payload for import.
|
||||||
|
slug: string;
|
||||||
|
// Optional in the catalog — the row reserves space and renders nothing when
|
||||||
|
// absent.
|
||||||
|
emoji?: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
// For `installed`/`import`: the catalog version. For `update`: the installed
|
||||||
|
// (from) version, with `newVersion` holding the catalog (to) version.
|
||||||
|
version: number;
|
||||||
|
newVersion?: number;
|
||||||
|
status: RoleStatus;
|
||||||
|
// The language a same-slug role is installed under, when it differs from the
|
||||||
|
// current content language (drives the Р5 hint). Only set for `import` roles.
|
||||||
|
installedLang?: string;
|
||||||
|
// The workspace role id, present for `installed`/`update` — needed to call the
|
||||||
|
// update-from-catalog mutation.
|
||||||
|
installedRoleId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The summary phase of a bundle, derived from its roles' statuses. Determines
|
||||||
|
* the collapsed-header summary and the bundle's single primary action.
|
||||||
|
* - `empty` — the bundle has no roles.
|
||||||
|
* - `allNew` — everything is importable, nothing installed.
|
||||||
|
* - `allInstalled` — everything installed & up to date; nothing else pending.
|
||||||
|
* - `updates` — updates available and nothing left to import.
|
||||||
|
* - `mixed` — any other combination.
|
||||||
|
*/
|
||||||
|
export type BundlePhase =
|
||||||
|
| "empty"
|
||||||
|
| "allNew"
|
||||||
|
| "allInstalled"
|
||||||
|
| "updates"
|
||||||
|
| "mixed";
|
||||||
|
|
||||||
|
/** Per-status tallies for a bundle's roles (the single source of truth). */
|
||||||
|
export interface BundleCounts {
|
||||||
|
importable: number;
|
||||||
|
installed: number;
|
||||||
|
update: number;
|
||||||
|
skipped: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count a bundle's roles by status ONCE. Both `bundlePhase` and the panel derive
|
||||||
|
* from this, so the tally logic lives in exactly one place (no rescans / drift).
|
||||||
|
*/
|
||||||
|
export function bundleCounts(roles: CatalogViewRole[]): BundleCounts {
|
||||||
|
const counts: BundleCounts = {
|
||||||
|
importable: 0,
|
||||||
|
installed: 0,
|
||||||
|
update: 0,
|
||||||
|
skipped: 0,
|
||||||
|
};
|
||||||
|
for (const r of roles) {
|
||||||
|
if (r.status === "import") counts.importable += 1;
|
||||||
|
else if (r.status === "installed") counts.installed += 1;
|
||||||
|
else if (r.status === "update") counts.update += 1;
|
||||||
|
else if (r.status === "skipped") counts.skipped += 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bundlePhase(roles: CatalogViewRole[]): BundlePhase {
|
||||||
|
if (roles.length === 0) return "empty";
|
||||||
|
const { importable, installed, update, skipped } = bundleCounts(roles);
|
||||||
|
// A `skipped` role is a pending post-import conflict (0 installed for it), so a
|
||||||
|
// bundle that has ANY skipped role is NOT "all installed & up to date" — that
|
||||||
|
// would make the collapsed green "up to date" header contradict the open
|
||||||
|
// panel's "Installed 0 · 1 skipped" plaque. It is `mixed` until resolved.
|
||||||
|
if (importable === 0 && update === 0 && skipped === 0) return "allInstalled";
|
||||||
|
if (update > 0 && importable === 0 && skipped === 0) return "updates";
|
||||||
|
if (importable > 0 && installed === 0 && update === 0 && skipped === 0)
|
||||||
|
return "allNew";
|
||||||
|
return "mixed";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subset of a skip result that should be shown as a TRANSIENT `skipped`
|
||||||
|
* overlay in the bundle (so the row offers a re-import path). Only NAME-CONFLICT
|
||||||
|
* skips qualify: an `already-installed` skip (a concurrent-import race) has
|
||||||
|
* nothing to act on — re-importing the same slug would just skip again — so it
|
||||||
|
* must NOT be overlaid (else the row shows a misleading "Rename & install" that
|
||||||
|
* self-heals into a false "installed"). Pure so both reason branches are tested.
|
||||||
|
*/
|
||||||
|
export function nameConflictSlugs(
|
||||||
|
skipped: { slug: string; reason: "name-conflict" | "already-installed" }[],
|
||||||
|
): string[] {
|
||||||
|
return skipped
|
||||||
|
.filter((s) => s.reason === "name-conflict")
|
||||||
|
.map((s) => s.slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a partial-import result should offer the "Rename & install" action:
|
||||||
|
* only when at least one skip is a name conflict (renameable). An
|
||||||
|
* `already-installed`-only partial is informational.
|
||||||
|
*/
|
||||||
|
export function partialOffersRename(
|
||||||
|
skipped: { reason: "name-conflict" | "already-installed" }[],
|
||||||
|
): boolean {
|
||||||
|
return skipped.some((s) => s.reason === "name-conflict");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For a role NOT installed in the current `language`, find a workspace role with
|
||||||
|
* the same catalog `slug` installed under a DIFFERENT language, and return that
|
||||||
|
* language. Drives the "installed in another language" hint (Р5): a different
|
||||||
|
* language of the same slug is a separate install and appears as `import`.
|
||||||
|
*/
|
||||||
|
export function installedLangForRole(
|
||||||
|
slug: string,
|
||||||
|
workspaceRoles: IAiRole[],
|
||||||
|
language: string,
|
||||||
|
): string | undefined {
|
||||||
|
const other = workspaceRoles.find(
|
||||||
|
(r) =>
|
||||||
|
r.source?.slug === slug &&
|
||||||
|
!!r.source?.language &&
|
||||||
|
r.source.language !== language,
|
||||||
|
);
|
||||||
|
return other?.source?.language;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map one catalog role to the view model, computing its install status against
|
||||||
|
* the workspace roles (via `catalogRoleInstallState`) and, for importable roles,
|
||||||
|
* the other-language hint.
|
||||||
|
*/
|
||||||
|
export function mapCatalogRoleToView(
|
||||||
|
role: IAiRoleCatalogRole,
|
||||||
|
workspaceRoles: IAiRole[],
|
||||||
|
language: string,
|
||||||
|
): CatalogViewRole {
|
||||||
|
const state = catalogRoleInstallState(role, workspaceRoles, language);
|
||||||
|
const base = {
|
||||||
|
slug: role.slug,
|
||||||
|
emoji: role.emoji ?? undefined,
|
||||||
|
name: role.name,
|
||||||
|
description: role.description ?? "",
|
||||||
|
};
|
||||||
|
if (state.state === "update") {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: "update",
|
||||||
|
version: state.fromVersion,
|
||||||
|
newVersion: state.toVersion,
|
||||||
|
installedRoleId: state.installed.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (state.state === "installed") {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: "installed",
|
||||||
|
version: role.version,
|
||||||
|
installedRoleId: state.installed.id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
status: "import",
|
||||||
|
version: role.version,
|
||||||
|
installedLang: installedLangForRole(role.slug, workspaceRoles, language),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a whole bundle's catalog roles to the view model, preserving order.
|
||||||
|
*/
|
||||||
|
export function mapBundleRolesToView(
|
||||||
|
roles: IAiRoleCatalogRole[],
|
||||||
|
workspaceRoles: IAiRole[],
|
||||||
|
language: string,
|
||||||
|
): CatalogViewRole[] {
|
||||||
|
return roles.map((r) => mapCatalogRoleToView(r, workspaceRoles, language));
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
GitmostListPagesResult,
|
GitmostListPagesResult,
|
||||||
GitmostListSpacesResult,
|
GitmostListSpacesResult,
|
||||||
gitmostDecodePayloadToFile,
|
gitmostDecodePayloadToFile,
|
||||||
|
gitmostInsertTranscriptIntoEditor,
|
||||||
gitmostUploadFileToEditor,
|
gitmostUploadFileToEditor,
|
||||||
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
||||||
|
|
||||||
@@ -281,6 +282,18 @@ export default function GitmostGlobalBridge() {
|
|||||||
pageId: page.id,
|
pageId: page.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Best-effort: append the transcript (heading + one paragraph per line)
|
||||||
|
// below the just-inserted audio node. The audio insert already
|
||||||
|
// succeeded, so a transcript failure must NOT turn this into an error —
|
||||||
|
// wrap it and, on any throw, log and still return ok. A missing/empty/
|
||||||
|
// non-string transcript is a no-op inside the helper (audio only).
|
||||||
|
try {
|
||||||
|
gitmostInsertTranscriptIntoEditor(editor, payload?.transcript);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[gitmost] transcript insert failed", err);
|
||||||
|
}
|
||||||
|
|
||||||
return { ok: true, pageId: page.id };
|
return { ok: true, pageId: page.id };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("[gitmost] createPageWithRecording failed", err);
|
console.error("[gitmost] createPageWithRecording failed", err);
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { Editor } from "@tiptap/core";
|
||||||
|
import { Document } from "@tiptap/extension-document";
|
||||||
|
import { Paragraph } from "@tiptap/extension-paragraph";
|
||||||
|
import { Text } from "@tiptap/extension-text";
|
||||||
|
import { Heading } from "@tiptap/extension-heading";
|
||||||
|
import { Bold } from "@tiptap/extension-bold";
|
||||||
|
import { Italic } from "@tiptap/extension-italic";
|
||||||
|
import { Link } from "@tiptap/extension-link";
|
||||||
|
import { gitmostInsertTranscriptIntoEditor } from "./gitmost-recording.ts";
|
||||||
|
|
||||||
|
const ZWSP = ""; // U+200B, the helper's block-trigger neutralizer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #377 — the web-side bridge must append the native host's transcript below the
|
||||||
|
* recording. These exercise the pure insert helper through a REAL Tiptap editor
|
||||||
|
* (Document/Paragraph/Text/Heading + Bold/Italic/Link marks so an HTML-parsing
|
||||||
|
* regression would be caught), asserting the resulting document rather than
|
||||||
|
* mocking the editor: transcript present -> "Transcript" heading + one paragraph
|
||||||
|
* per non-empty line; content is inserted as LITERAL TEXT (no HTML/markdown
|
||||||
|
* parsing); col-0 markdown block triggers are neutralized so git-sync keeps them
|
||||||
|
* paragraphs; absent/empty/non-string -> no-op.
|
||||||
|
*/
|
||||||
|
describe("gitmostInsertTranscriptIntoEditor", () => {
|
||||||
|
const makeEditor = () =>
|
||||||
|
new Editor({
|
||||||
|
// Bold/Italic/Link are registered specifically so that IF the helper ever
|
||||||
|
// regressed to inserting an HTML/markdown string (instead of a text node),
|
||||||
|
// TipTap would parse `<b>`/`*..*`/`[..](..)` into marks and the literal-
|
||||||
|
// text assertions below would fail.
|
||||||
|
extensions: [Document, Paragraph, Text, Heading, Bold, Italic, Link],
|
||||||
|
// Start from a single empty paragraph (a fresh page's baseline). The
|
||||||
|
// helper appends at the end of the doc, i.e. below existing content.
|
||||||
|
content: { type: "doc", content: [{ type: "paragraph" }] },
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts a Transcript heading + one paragraph per non-empty line, verbatim", () => {
|
||||||
|
const editor = makeEditor();
|
||||||
|
|
||||||
|
const inserted = gitmostInsertTranscriptIntoEditor(
|
||||||
|
editor,
|
||||||
|
"You: hello there\nSpeaker 1: hi\n\nYou: bye",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(inserted).toBe(true);
|
||||||
|
|
||||||
|
const nodes = (editor.getJSON().content ?? []) as any[];
|
||||||
|
// A level-2 "Transcript" heading is present.
|
||||||
|
const heading = nodes.find((n) => n.type === "heading");
|
||||||
|
expect(heading?.attrs?.level).toBe(2);
|
||||||
|
expect(heading?.content?.[0]?.text).toBe("Transcript");
|
||||||
|
|
||||||
|
// Every non-empty transcript line becomes a paragraph, in order, verbatim;
|
||||||
|
// the blank line between them is dropped.
|
||||||
|
const texts = nodes
|
||||||
|
.filter((n) => n.type === "paragraph")
|
||||||
|
.map((n) => n.content?.[0]?.text)
|
||||||
|
.filter((t) => typeof t === "string");
|
||||||
|
expect(texts).toEqual(["You: hello there", "Speaker 1: hi", "You: bye"]);
|
||||||
|
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inserts HTML + markdown metacharacters as LITERAL text (no injection / no mark parsing)", () => {
|
||||||
|
const editor = makeEditor();
|
||||||
|
const line =
|
||||||
|
"You: <b>bold</b> <script>alert(1)</script> and *stars* and [link](x)";
|
||||||
|
|
||||||
|
const inserted = gitmostInsertTranscriptIntoEditor(editor, line);
|
||||||
|
expect(inserted).toBe(true);
|
||||||
|
|
||||||
|
const paras = (editor.getJSON().content ?? []).filter(
|
||||||
|
(n: any) => n.type === "paragraph",
|
||||||
|
) as any[];
|
||||||
|
// The transcript line is exactly ONE paragraph holding a SINGLE text node
|
||||||
|
// whose text is the verbatim string — not split into bold/link/other nodes,
|
||||||
|
// not carrying any marks, not raw HTML. This FAILS if the helper switched to
|
||||||
|
// insertContent(htmlString): TipTap would then parse <b>/[link](x)/*stars*.
|
||||||
|
const content = paras[paras.length - 1].content;
|
||||||
|
expect(content).toHaveLength(1);
|
||||||
|
expect(content[0].type).toBe("text");
|
||||||
|
expect(content[0].marks ?? []).toEqual([]);
|
||||||
|
expect(content[0].text).toBe(line);
|
||||||
|
// And no bold/italic/link mark exists anywhere in the document.
|
||||||
|
const html = editor.getHTML();
|
||||||
|
expect(html).not.toMatch(/<(strong|b|em|i|a)\b/);
|
||||||
|
// The angle brackets survived as escaped entities (literal text), not a live
|
||||||
|
// <script>/<b> element.
|
||||||
|
expect(html).not.toMatch(/<script/i);
|
||||||
|
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("neutralizes col-0 markdown block triggers with a leading ZWSP (git-sync safety)", () => {
|
||||||
|
const editor = makeEditor();
|
||||||
|
// Trigger lines (some with a leaked indent) + a normal prefixed line.
|
||||||
|
const inserted = gitmostInsertTranscriptIntoEditor(
|
||||||
|
editor,
|
||||||
|
[
|
||||||
|
"- dash",
|
||||||
|
" > quote", // leading indent must be trimmed then neutralized
|
||||||
|
"# hash",
|
||||||
|
"1. one",
|
||||||
|
"> [!info] note",
|
||||||
|
"```js",
|
||||||
|
"---", // solid thematic break -> horizontalRule (text-losing) if unneutralized
|
||||||
|
"***",
|
||||||
|
"___",
|
||||||
|
"You: normal line",
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
expect(inserted).toBe(true);
|
||||||
|
|
||||||
|
const texts = (editor.getJSON().content ?? [])
|
||||||
|
.filter((n: any) => n.type === "paragraph")
|
||||||
|
.map((n: any) => n.content?.[0]?.text)
|
||||||
|
.filter((t: any) => typeof t === "string") as string[];
|
||||||
|
|
||||||
|
// Every block-trigger line is prefixed with the invisible ZWSP (indent
|
||||||
|
// trimmed first); the normal `You:` line is left byte-exact.
|
||||||
|
expect(texts).toEqual([
|
||||||
|
ZWSP + "- dash",
|
||||||
|
ZWSP + "> quote",
|
||||||
|
ZWSP + "# hash",
|
||||||
|
ZWSP + "1. one",
|
||||||
|
ZWSP + "> [!info] note",
|
||||||
|
ZWSP + "```js",
|
||||||
|
ZWSP + "---",
|
||||||
|
ZWSP + "***",
|
||||||
|
ZWSP + "___",
|
||||||
|
"You: normal line",
|
||||||
|
]);
|
||||||
|
|
||||||
|
editor.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is a no-op for undefined / empty / whitespace-only / non-string transcripts", () => {
|
||||||
|
for (const value of [undefined, "", " \n \n", 42, {}, null]) {
|
||||||
|
const editor = makeEditor();
|
||||||
|
const before = JSON.stringify(editor.getJSON());
|
||||||
|
|
||||||
|
const inserted = gitmostInsertTranscriptIntoEditor(editor, value as any);
|
||||||
|
|
||||||
|
expect(inserted).toBe(false);
|
||||||
|
// Document is untouched (audio-only behavior preserved).
|
||||||
|
expect(JSON.stringify(editor.getJSON())).toBe(before);
|
||||||
|
editor.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -65,6 +65,11 @@ export interface GitmostCreatePagePayload {
|
|||||||
base64: string;
|
base64: string;
|
||||||
filename: string;
|
filename: string;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
|
// Optional transcript for the recording: plain text, `\n`-separated, each
|
||||||
|
// line already formatted as `You: ...` / `Speaker N: ...` by the native host
|
||||||
|
// (ready to insert, no parsing needed). Omitted (no speech / no models) ->
|
||||||
|
// audio only.
|
||||||
|
transcript?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitmostCreatePageResult {
|
export interface GitmostCreatePageResult {
|
||||||
@@ -235,6 +240,83 @@ export async function gitmostUploadFileToEditor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zero-width space (U+200B). Prepended to a transcript line that begins with a
|
||||||
|
// markdown BLOCK trigger: it is invisible in the rendered doc but shifts the
|
||||||
|
// trigger off column 0, so the git-sync doc->markdown->doc round-trip keeps the
|
||||||
|
// line a plain paragraph (see GITMOST_MD_BLOCK_TRIGGER_RE).
|
||||||
|
const GITMOST_ZWSP = "";
|
||||||
|
|
||||||
|
// A markdown BLOCK-level construct that, sitting at column 0 of a paragraph
|
||||||
|
// line, the git-sync markdown serializer (packages/prosemirror-markdown
|
||||||
|
// markdown-converter.ts, `case "paragraph"`) would re-parse into a NON-paragraph
|
||||||
|
// block on the doc->markdown->doc cycle. That serializer emits paragraph text
|
||||||
|
// verbatim with NO block-escape (the pre-existing root cause), so a leading
|
||||||
|
// `#`/`-`/`*`/`+`/`>`, an ordered-list `N.`/`N)`, a code fence ```/~~~, a table
|
||||||
|
// `|`, or a `> [!info]` callout opener would silently become a heading / list /
|
||||||
|
// quote / code block / table / callout. The final alternative matches a WHOLE-
|
||||||
|
// LINE thematic break — solid `---`/`***`/`___` or spaced `- - -`/`_ _ _` (3+ of
|
||||||
|
// the same `-`/`*`/`_`) — which round-trips into a `horizontalRule`; because
|
||||||
|
// that node carries NO text, an un-neutralized separator line would LOSE its
|
||||||
|
// text entirely (worse than the list/quote case). This matches a TRIMMED line's
|
||||||
|
// start; the transcript's own `You:` / `Speaker N:` prefix begins with a letter
|
||||||
|
// and never matches, so prefixed lines are left byte-exact.
|
||||||
|
const GITMOST_MD_BLOCK_TRIGGER_RE =
|
||||||
|
/^(?:#{1,6}(?:\s|$)|[-*+](?:\s|$)|>|\d+[.)](?:\s|$)|```|~~~|\||([-*_])(?:\s*\1){2,}\s*$)/;
|
||||||
|
|
||||||
|
// Append a transcript block BELOW the recording's audio node in a live editor:
|
||||||
|
// a "Transcript" heading followed by one paragraph per non-empty transcript
|
||||||
|
// line. The transcript is plain text, `\n`-separated, each line already
|
||||||
|
// formatted as `You: ...` / `Speaker N: ...` by the native host — line text is
|
||||||
|
// inserted as a TEXT node (never HTML/markdown), so there is no injection or
|
||||||
|
// mark-parsing surface. Each kept line is trimmed (drops an indent that would
|
||||||
|
// both leak into the display and, at col 0, form a markdown block trigger) and,
|
||||||
|
// if it still begins with a col-0 markdown block trigger, gets an invisible
|
||||||
|
// zero-width space prepended so the git-sync round-trip cannot turn it into a
|
||||||
|
// list/quote/heading/callout/code/table (defensive boundary against the
|
||||||
|
// serializer's missing block-escape). This is best-effort and meant to run
|
||||||
|
// AFTER the audio has already been inserted; the caller must guard against a
|
||||||
|
// throw so a transcript failure never fails the (already successful) recording.
|
||||||
|
// Returns true when a block was inserted, false when there was nothing to
|
||||||
|
// insert (transcript undefined/empty/not-a-string). A non-string value is a
|
||||||
|
// no-op, not an error.
|
||||||
|
export function gitmostInsertTranscriptIntoEditor(
|
||||||
|
editor: Editor,
|
||||||
|
transcript: unknown,
|
||||||
|
): boolean {
|
||||||
|
if (typeof transcript !== "string") return false;
|
||||||
|
const lines = transcript
|
||||||
|
.split("\n")
|
||||||
|
// Trim each line and drop blank (whitespace-only) ones.
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
// Neutralize a col-0 markdown block trigger with an invisible ZWSP so the
|
||||||
|
// git-sync round-trip keeps the line a paragraph. Host lines (`You:` /
|
||||||
|
// `Speaker N:`) never match and stay byte-exact.
|
||||||
|
.map((line) =>
|
||||||
|
GITMOST_MD_BLOCK_TRIGGER_RE.test(line) ? GITMOST_ZWSP + line : line,
|
||||||
|
);
|
||||||
|
if (lines.length === 0) return false;
|
||||||
|
|
||||||
|
const content = [
|
||||||
|
{
|
||||||
|
type: "heading",
|
||||||
|
attrs: { level: 2 },
|
||||||
|
content: [{ type: "text", text: "Transcript" }],
|
||||||
|
},
|
||||||
|
...lines.map((line) => ({
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: line }],
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Append at the end of the document. On a freshly-created recording page the
|
||||||
|
// audio node is the last block, so the end position places the transcript
|
||||||
|
// directly below it.
|
||||||
|
const endPos = editor.state.doc.content.size;
|
||||||
|
editor.chain().focus().insertContentAt(endPos, content).run();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Full insert path used by the open-page bridge (insertRecording): guard the
|
// Full insert path used by the open-page bridge (insertRecording): guard the
|
||||||
// editor, validate/decode the payload, then upload. Never throws — resolves to
|
// editor, validate/decode the payload, then upload. Never throws — resolves to
|
||||||
// a result code.
|
// a result code.
|
||||||
|
|||||||
+976
-275
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
import { defineConfig, loadEnv } from "vite";
|
import { defineConfig, loadEnv } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { compression } from "vite-plugin-compression2";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
|
|
||||||
@@ -53,7 +54,25 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
|
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
|
||||||
},
|
},
|
||||||
plugins: [react()],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
// Emit .br and .gz next to every built asset so the server can serve the
|
||||||
|
// precompressed copy (see @fastify/static preCompressed in static.module.ts).
|
||||||
|
compression({
|
||||||
|
algorithms: ["brotliCompress", "gzip"],
|
||||||
|
// vite-plugin-compression2's default `include` only covers text-ish
|
||||||
|
// bundle output (js/mjs/json/css/html/svg/…). Extend it with the large
|
||||||
|
// VAD binaries copied from public/vad (.wasm ~26MB, .onnx ~2.3MB) so
|
||||||
|
// they are brotli/gzip'd once at build time and served via
|
||||||
|
// @fastify/static preCompressed — otherwise @fastify/compress would
|
||||||
|
// re-brotli them on EVERY request. The default types are repeated here
|
||||||
|
// because setting `include` replaces (does not extend) the default.
|
||||||
|
include: /\.(html|xml|css|json|js|mjs|svg|yaml|yml|toml|wasm|onnx)$/,
|
||||||
|
// index.html is rewritten at server boot (window.CONFIG injection); a
|
||||||
|
// precompressed copy would go stale — NEVER precompress it.
|
||||||
|
exclude: [/index\.html$/],
|
||||||
|
}),
|
||||||
|
],
|
||||||
build: {
|
build: {
|
||||||
rolldownOptions: {
|
rolldownOptions: {
|
||||||
output: {
|
output: {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
"migration:reset": "tsx src/database/migrate.ts down-to NO_MIGRATIONS",
|
||||||
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
"migration:codegen": "kysely-codegen --dialect=postgres --camel-case --env-file=../../.env --out-file=./src/database/types/db.d.ts",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
"pretest": "pnpm --filter @docmost/editor-ext build",
|
"pretest": "pnpm --filter @docmost/editor-ext build && pnpm --filter @docmost/prosemirror-markdown build",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:int": "jest --config test/jest-integration.json",
|
"test:int": "jest --config test/jest-integration.json",
|
||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
@@ -43,6 +43,8 @@
|
|||||||
"@clickhouse/client": "^1.18.2",
|
"@clickhouse/client": "^1.18.2",
|
||||||
"@docmost/mcp": "workspace:*",
|
"@docmost/mcp": "workspace:*",
|
||||||
"@docmost/pdf-inspector": "1.9.6",
|
"@docmost/pdf-inspector": "1.9.6",
|
||||||
|
"@docmost/prosemirror-markdown": "workspace:*",
|
||||||
|
"@fastify/compress": "^9.0.0",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/multipart": "^10.0.0",
|
"@fastify/multipart": "^10.0.0",
|
||||||
"@fastify/static": "^9.1.3",
|
"@fastify/static": "^9.1.3",
|
||||||
@@ -175,7 +177,7 @@
|
|||||||
"/node_modules/"
|
"/node_modules/"
|
||||||
],
|
],
|
||||||
"transform": {
|
"transform": {
|
||||||
"happy-dom.+\\.js$": [
|
"(happy-dom.+|prosemirror-markdown/build/.+)\\.js$": [
|
||||||
"babel-jest",
|
"babel-jest",
|
||||||
{
|
{
|
||||||
"presets": [
|
"presets": [
|
||||||
@@ -193,7 +195,7 @@
|
|||||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||||
},
|
},
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0)(@|/))"
|
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0|@docmost/prosemirror-markdown)(@|/))"
|
||||||
],
|
],
|
||||||
"collectCoverageFrom": [
|
"collectCoverageFrom": [
|
||||||
"**/*.(t|j)s"
|
"**/*.(t|j)s"
|
||||||
@@ -204,7 +206,8 @@
|
|||||||
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
"^@docmost/db/(.*)$": "<rootDir>/database/$1",
|
||||||
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
"^@docmost/transactional/(.*)$": "<rootDir>/integrations/transactional/$1",
|
||||||
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
|
"^@docmost/ee/(.*)$": "<rootDir>/ee/$1",
|
||||||
"^src/(.*)$": "<rootDir>/$1"
|
"^src/(.*)$": "<rootDir>/$1",
|
||||||
|
"^@tiptap/react$": "<rootDir>/../test/stubs/tiptap-react.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ import {
|
|||||||
Column,
|
Column,
|
||||||
Status,
|
Status,
|
||||||
addUniqueIdsToDoc,
|
addUniqueIdsToDoc,
|
||||||
htmlToMarkdown,
|
|
||||||
TransclusionSource,
|
TransclusionSource,
|
||||||
TransclusionReference,
|
TransclusionReference,
|
||||||
FootnoteReference,
|
FootnoteReference,
|
||||||
@@ -51,6 +50,7 @@ import {
|
|||||||
FootnoteDefinition,
|
FootnoteDefinition,
|
||||||
PageEmbed,
|
PageEmbed,
|
||||||
} from '@docmost/editor-ext';
|
} from '@docmost/editor-ext';
|
||||||
|
import { convertProseMirrorToMarkdown } from '@docmost/prosemirror-markdown';
|
||||||
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
||||||
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
||||||
// @tiptap/html library works best for generating prosemirror json state but not HTML
|
// @tiptap/html library works best for generating prosemirror json state but not HTML
|
||||||
@@ -239,6 +239,10 @@ export function prosemirrorNodeToYElement(node: any): Y.XmlElement | Y.XmlText {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function jsonToMarkdown(tiptapJson: any): string {
|
export function jsonToMarkdown(tiptapJson: any): string {
|
||||||
const html = jsonToHtml(tiptapJson);
|
// Direct ProseMirror JSON -> Markdown via the canonical converter
|
||||||
return htmlToMarkdown(html);
|
// (`@docmost/prosemirror-markdown`) — no HTML intermediate, no second
|
||||||
|
// editor-ext markdown layer. Same serializer as the page/space export and the
|
||||||
|
// git-sync vault writer, so every server PM->MD path emits identical canonical
|
||||||
|
// markdown (issue #345).
|
||||||
|
return convertProseMirrorToMarkdown(tiptapJson);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -610,6 +610,63 @@ describe('AiAgentRolesService guards', () => {
|
|||||||
expect(repo.insert.mock.calls[0][0].name).toBe('Researcher (2)');
|
expect(repo.insert.mock.calls[0][0].name).toBe('Researcher (2)');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('createdRoles lists the installed role (no renamedTo when not renamed)', async () => {
|
||||||
|
const { service } = makeImportService({});
|
||||||
|
const res = await service.importFromCatalog('ws-1', 'u1', dto());
|
||||||
|
expect(res.createdRoles).toEqual([
|
||||||
|
{ slug: 'researcher', name: 'Researcher' },
|
||||||
|
]);
|
||||||
|
expect(res.skippedRoles).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('createdRoles carries renamedTo on a rename', async () => {
|
||||||
|
const existing = [makeRow({ id: 'r-x', name: 'Researcher' })];
|
||||||
|
const { service } = makeImportService({ existing });
|
||||||
|
const res = await service.importFromCatalog(
|
||||||
|
'ws-1',
|
||||||
|
'u1',
|
||||||
|
dto({ conflict: 'rename' }),
|
||||||
|
);
|
||||||
|
expect(res.createdRoles).toEqual([
|
||||||
|
{ slug: 'researcher', name: 'Researcher', renamedTo: 'Researcher (2)' },
|
||||||
|
]);
|
||||||
|
expect(res.skippedRoles).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skippedRoles: already-installed slug carries reason "already-installed"', async () => {
|
||||||
|
const existing = [
|
||||||
|
makeRow({
|
||||||
|
id: 'r-existing',
|
||||||
|
name: 'Old researcher',
|
||||||
|
source: { slug: 'researcher', language: 'en', version: 1 } as never,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const { service } = makeImportService({ existing });
|
||||||
|
const res = await service.importFromCatalog('ws-1', 'u1', dto());
|
||||||
|
expect(res.skippedRoles).toEqual([
|
||||||
|
{
|
||||||
|
slug: 'researcher',
|
||||||
|
name: 'Researcher',
|
||||||
|
reason: 'already-installed',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(res.createdRoles).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skippedRoles: a name collision under conflict:skip carries reason "name-conflict"', async () => {
|
||||||
|
const existing = [makeRow({ id: 'r-x', name: 'Researcher' })];
|
||||||
|
const { service } = makeImportService({ existing });
|
||||||
|
const res = await service.importFromCatalog(
|
||||||
|
'ws-1',
|
||||||
|
'u1',
|
||||||
|
dto({ conflict: 'skip' }),
|
||||||
|
);
|
||||||
|
expect(res.skippedRoles).toEqual([
|
||||||
|
{ slug: 'researcher', name: 'Researcher', reason: 'name-conflict' },
|
||||||
|
]);
|
||||||
|
expect(res.createdRoles).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it('dto.slugs filters; an unknown slug becomes an error entry', async () => {
|
it('dto.slugs filters; an unknown slug becomes an error entry', async () => {
|
||||||
const { service, repo } = makeImportService({
|
const { service, repo } = makeImportService({
|
||||||
bundleRoles: [catalogRole()],
|
bundleRoles: [catalogRole()],
|
||||||
@@ -677,6 +734,15 @@ describe('AiAgentRolesService guards', () => {
|
|||||||
// 'a' converged on the concurrent install (skip); 'b' imported; no errors.
|
// 'a' converged on the concurrent install (skip); 'b' imported; no errors.
|
||||||
expect(res).toMatchObject({ created: 1, skipped: 1, renamed: 0 });
|
expect(res).toMatchObject({ created: 1, skipped: 1, renamed: 0 });
|
||||||
expect(res.errors).toEqual([]);
|
expect(res.errors).toEqual([]);
|
||||||
|
// The per-role list records 'a' as an already-installed skip (the UI reads
|
||||||
|
// skippedRoles, not the counter, to render its plaque — assert the array,
|
||||||
|
// not just the count).
|
||||||
|
expect(res.skippedRoles).toContainEqual({
|
||||||
|
slug: 'a',
|
||||||
|
name: 'A',
|
||||||
|
reason: 'already-installed',
|
||||||
|
});
|
||||||
|
expect(res.createdRoles.map((r) => r.slug)).toEqual(['b']);
|
||||||
// Both inserts were attempted (the batch did not abort on the 23505).
|
// Both inserts were attempted (the batch did not abort on the 23505).
|
||||||
expect(repo.insert).toHaveBeenCalledTimes(2);
|
expect(repo.insert).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -305,6 +305,16 @@ export class AiAgentRolesService {
|
|||||||
skipped: number;
|
skipped: number;
|
||||||
renamed: number;
|
renamed: number;
|
||||||
errors: { slug: string; message: string }[];
|
errors: { slug: string; message: string }[];
|
||||||
|
// Per-role lists alongside the counters (kept for back-compat). The redesigned
|
||||||
|
// catalog UI needs the actual roles — which were created (and any rename) and
|
||||||
|
// which were skipped and why — to render an inline result plaque with the
|
||||||
|
// conflicting role's name and a "Rename & install" affordance.
|
||||||
|
createdRoles: { slug: string; name: string; renamedTo?: string }[];
|
||||||
|
skippedRoles: {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
reason: 'name-conflict' | 'already-installed';
|
||||||
|
}[];
|
||||||
}> {
|
}> {
|
||||||
const { file, versions } = await this.loadBundleById(
|
const { file, versions } = await this.loadBundleById(
|
||||||
dto.bundleId,
|
dto.bundleId,
|
||||||
@@ -312,6 +322,13 @@ export class AiAgentRolesService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const errors: { slug: string; message: string }[] = [];
|
const errors: { slug: string; message: string }[] = [];
|
||||||
|
const createdRoles: { slug: string; name: string; renamedTo?: string }[] =
|
||||||
|
[];
|
||||||
|
const skippedRoles: {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
reason: 'name-conflict' | 'already-installed';
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
// Resolve the selected catalog roles (honor dto.slugs; flag unknown ones).
|
// Resolve the selected catalog roles (honor dto.slugs; flag unknown ones).
|
||||||
let selected = file.roles;
|
let selected = file.roles;
|
||||||
@@ -351,16 +368,27 @@ export class AiAgentRolesService {
|
|||||||
// Already installed from the catalog in THIS language => skip (use
|
// Already installed from the catalog in THIS language => skip (use
|
||||||
// update-from-catalog). A different language of the same slug still imports.
|
// update-from-catalog). A different language of the same slug still imports.
|
||||||
const installKey = `${role.slug}:${dto.language}`;
|
const installKey = `${role.slug}:${dto.language}`;
|
||||||
|
const originalName = role.name.trim();
|
||||||
if (installedKeys.has(installKey)) {
|
if (installedKeys.has(installKey)) {
|
||||||
skipped++;
|
skipped++;
|
||||||
|
skippedRoles.push({
|
||||||
|
slug: role.slug,
|
||||||
|
name: originalName,
|
||||||
|
reason: 'already-installed',
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let name = role.name.trim();
|
let name = originalName;
|
||||||
let didRename = false;
|
let didRename = false;
|
||||||
if (takenNames.has(name.toLowerCase())) {
|
if (takenNames.has(name.toLowerCase())) {
|
||||||
if (dto.conflict === 'skip') {
|
if (dto.conflict === 'skip') {
|
||||||
skipped++;
|
skipped++;
|
||||||
|
skippedRoles.push({
|
||||||
|
slug: role.slug,
|
||||||
|
name: originalName,
|
||||||
|
reason: 'name-conflict',
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// conflict === 'rename': find a free " (N)" suffix.
|
// conflict === 'rename': find a free " (N)" suffix.
|
||||||
@@ -380,6 +408,11 @@ export class AiAgentRolesService {
|
|||||||
});
|
});
|
||||||
created++;
|
created++;
|
||||||
if (didRename) renamed++;
|
if (didRename) renamed++;
|
||||||
|
createdRoles.push({
|
||||||
|
slug: role.slug,
|
||||||
|
name: originalName,
|
||||||
|
...(didRename ? { renamedTo: name } : {}),
|
||||||
|
});
|
||||||
takenNames.add(name.toLowerCase());
|
takenNames.add(name.toLowerCase());
|
||||||
installedKeys.add(installKey);
|
installedKeys.add(installKey);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -391,6 +424,11 @@ export class AiAgentRolesService {
|
|||||||
// skipped (already installed) and continue; do NOT abort or error.
|
// skipped (already installed) and continue; do NOT abort or error.
|
||||||
if (isSourceUniqueViolation(err)) {
|
if (isSourceUniqueViolation(err)) {
|
||||||
skipped++;
|
skipped++;
|
||||||
|
skippedRoles.push({
|
||||||
|
slug: role.slug,
|
||||||
|
name: originalName,
|
||||||
|
reason: 'already-installed',
|
||||||
|
});
|
||||||
installedKeys.add(installKey);
|
installedKeys.add(installKey);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -407,7 +445,7 @@ export class AiAgentRolesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { created, skipped, renamed, errors };
|
return { created, skipped, renamed, errors, createdRoles, skippedRoles };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -539,3 +539,115 @@ describe('AiChatToolsService model-friendly input validation (#190)', () => {
|
|||||||
expect(result.error?.message).toContain('parameter "pageId": missing (required)');
|
expect(result.error?.message).toContain('parameter "pageId": missing (required)');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* #294 F1 — the contract-parity test introspects only the ADVERTISED schema keys
|
||||||
|
* (buildShape), not the execute bodies. Most execs are unchanged pass-throughs,
|
||||||
|
* but two wirings actually CHANGED in the migration and are otherwise untested:
|
||||||
|
* - movePage now forwards the newly-added optional `position` field to the
|
||||||
|
* client (client.movePage(pageId, parentPageId, position));
|
||||||
|
* - the table trio unified its `tableRef` param to `table` and must forward it
|
||||||
|
* positionally. A field destructured under the wrong name would silently pass
|
||||||
|
* `undefined` to the client (execute is `any`-cast, so tsc won't catch it).
|
||||||
|
*/
|
||||||
|
describe('AiChatToolsService #294 changed execute wirings', () => {
|
||||||
|
const calls: Record<string, unknown[][]> = {
|
||||||
|
movePage: [],
|
||||||
|
tableInsertRow: [],
|
||||||
|
tableDeleteRow: [],
|
||||||
|
tableUpdateCell: [],
|
||||||
|
};
|
||||||
|
const fakeClient: Partial<DocmostClientLike> = {
|
||||||
|
movePage: (...args: unknown[]) => {
|
||||||
|
calls.movePage.push(args);
|
||||||
|
return Promise.resolve({ success: true });
|
||||||
|
},
|
||||||
|
tableInsertRow: (...args: unknown[]) => {
|
||||||
|
calls.tableInsertRow.push(args);
|
||||||
|
return Promise.resolve({ ok: true });
|
||||||
|
},
|
||||||
|
tableDeleteRow: (...args: unknown[]) => {
|
||||||
|
calls.tableDeleteRow.push(args);
|
||||||
|
return Promise.resolve({ ok: true });
|
||||||
|
},
|
||||||
|
tableUpdateCell: (...args: unknown[]) => {
|
||||||
|
calls.tableUpdateCell.push(args);
|
||||||
|
return Promise.resolve({ ok: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const tokenServiceStub = {
|
||||||
|
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
|
||||||
|
generateCollabToken: jest.fn().mockResolvedValue('collab-token'),
|
||||||
|
};
|
||||||
|
let service: AiChatToolsService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const k of Object.keys(calls)) calls[k].length = 0;
|
||||||
|
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
|
||||||
|
mockLoaded(function () {
|
||||||
|
return fakeClient as DocmostClientLike;
|
||||||
|
} as unknown as loader.DocmostClientCtor),
|
||||||
|
);
|
||||||
|
service = new AiChatToolsService(
|
||||||
|
tokenServiceStub as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{} as never,
|
||||||
|
{
|
||||||
|
asSink: () => ({ put: jest.fn(), has: jest.fn(), evict: jest.fn() }),
|
||||||
|
} as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
const buildTools = () =>
|
||||||
|
service.forUser(
|
||||||
|
{ id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never,
|
||||||
|
'session-1',
|
||||||
|
'ws-1',
|
||||||
|
'chat-1',
|
||||||
|
);
|
||||||
|
|
||||||
|
it('movePage forwards the optional position to the client', async () => {
|
||||||
|
const tools = await buildTools();
|
||||||
|
await tools.movePage.execute(
|
||||||
|
{ pageId: 'p1', parentPageId: 'parent1', position: 'a5' } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
expect(calls.movePage).toEqual([['p1', 'parent1', 'a5']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('movePage passes undefined position and null parent when omitted (unchanged behavior)', async () => {
|
||||||
|
const tools = await buildTools();
|
||||||
|
await tools.movePage.execute({ pageId: 'p2' } as never, {} as never);
|
||||||
|
expect(calls.movePage).toEqual([['p2', null, undefined]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tableInsertRow forwards the unified `table` param positionally', async () => {
|
||||||
|
const tools = await buildTools();
|
||||||
|
await tools.tableInsertRow.execute(
|
||||||
|
{ pageId: 'p1', table: '#0', cells: ['a', 'b'], index: 2 } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
expect(calls.tableInsertRow).toEqual([['p1', '#0', ['a', 'b'], 2]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tableDeleteRow forwards `table` positionally', async () => {
|
||||||
|
const tools = await buildTools();
|
||||||
|
await tools.tableDeleteRow.execute(
|
||||||
|
{ pageId: 'p1', table: '#0', index: 1 } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
expect(calls.tableDeleteRow).toEqual([['p1', '#0', 1]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tableUpdateCell forwards `table` positionally', async () => {
|
||||||
|
const tools = await buildTools();
|
||||||
|
await tools.tableUpdateCell.execute(
|
||||||
|
{ pageId: 'p1', table: '#0', row: 1, col: 2, text: 'x' } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
expect(calls.tableUpdateCell).toEqual([['p1', '#0', 1, 2, 'x']]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -316,50 +316,27 @@ export class AiChatToolsService {
|
|||||||
execute: async () => resolveCurrentPageResult(openedPage),
|
execute: async () => resolveCurrentPageResult(openedPage),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getPage: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
// The execute body keeps this layer's { title, markdown } projection.
|
||||||
'Fetch a single page as Markdown by its page id. Returns the page ' +
|
getPage: sharedTool(sharedToolSpecs.getPage, async ({ pageId }) => {
|
||||||
'title and its Markdown content. Inline <span data-comment-id> tags ' +
|
// getPage(pageId) -> { data: filterPage(page, markdown), success }.
|
||||||
'in the markdown are comment highlight anchors (also present for ' +
|
const result = await client.getPage(pageId);
|
||||||
'RESOLVED threads) — treat them as markup, not page text.',
|
const data = (result?.data ?? {}) as {
|
||||||
inputSchema: modelFriendlyInput({
|
title?: string;
|
||||||
pageId: z.string().describe('The id (or slugId) of the page.'),
|
content?: string;
|
||||||
}),
|
};
|
||||||
execute: async ({ pageId }) => {
|
return {
|
||||||
// getPage(pageId) -> { data: filterPage(page, markdown), success }.
|
title: data.title ?? '',
|
||||||
const result = await client.getPage(pageId);
|
markdown: typeof data.content === 'string' ? data.content : '',
|
||||||
const data = (result?.data ?? {}) as {
|
};
|
||||||
title?: string;
|
|
||||||
content?: string;
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
title: data.title ?? '',
|
|
||||||
markdown: typeof data.content === 'string' ? data.content : '',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// --- WRITE tools (all reversible — history/trash; §6.5 / D3) ---
|
// --- WRITE tools (all reversible — history/trash; §6.5 / D3) ---
|
||||||
|
|
||||||
createPage: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
createPage: sharedTool(
|
||||||
'Create a new page with a Markdown body in a space, optionally under ' +
|
sharedToolSpecs.createPage,
|
||||||
'a parent page. Returns the new page id and title. Reversible: a page ' +
|
async ({ title, content, spaceId, parentPageId }) => {
|
||||||
'can be moved to trash later.',
|
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
title: z.string().describe('The title of the new page.'),
|
|
||||||
content: z
|
|
||||||
.string()
|
|
||||||
.describe('The page body as Markdown (may be empty).'),
|
|
||||||
spaceId: z
|
|
||||||
.string()
|
|
||||||
.describe('The id of the space to create the page in.'),
|
|
||||||
parentPageId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe('Optional parent page id to nest the new page under.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ title, content, spaceId, parentPageId }) => {
|
|
||||||
// createPage(title, content, spaceId, parentPageId?) ->
|
// createPage(title, content, spaceId, parentPageId?) ->
|
||||||
// { data: filterPage(page, markdown), success }.
|
// { data: filterPage(page, markdown), success }.
|
||||||
const result = await client.createPage(
|
const result = await client.createPage(
|
||||||
@@ -375,7 +352,7 @@ export class AiChatToolsService {
|
|||||||
};
|
};
|
||||||
return { id: data.id ?? data.slugId, title: data.title ?? title };
|
return { id: data.id ?? data.slugId, title: data.title ?? title };
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
updatePageContent: tool({
|
updatePageContent: tool({
|
||||||
description:
|
description:
|
||||||
@@ -399,115 +376,46 @@ export class AiChatToolsService {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
renamePage: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
renamePage: sharedTool(
|
||||||
"Rename a page (change its title only; the body is untouched). " +
|
sharedToolSpecs.renamePage,
|
||||||
'Reversible: rename back at any time.',
|
async ({ pageId, title }) => {
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page to rename.'),
|
|
||||||
title: z.string().describe('The new title.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, title }) => {
|
|
||||||
// renamePage(pageId, title) -> { success, pageId, title }.
|
// renamePage(pageId, title) -> { success, pageId, title }.
|
||||||
await client.renamePage(pageId, title);
|
await client.renamePage(pageId, title);
|
||||||
return { pageId, title };
|
return { pageId, title };
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
movePage: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
// The shared schema adds the optional `position` field this layer lacked
|
||||||
'Move a page under a new parent page, or to the space root when no ' +
|
// before; the execute now forwards it (the client already accepted it).
|
||||||
'parent is given. Reversible: move it back at any time.',
|
movePage: sharedTool(
|
||||||
inputSchema: modelFriendlyInput({
|
sharedToolSpecs.movePage,
|
||||||
pageId: z.string().describe('The id of the page to move.'),
|
async ({ pageId, parentPageId, position }) => {
|
||||||
parentPageId: z
|
|
||||||
.string()
|
|
||||||
.nullable()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'Target parent page id. Null/omitted moves the page to the ' +
|
|
||||||
'space root.',
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, parentPageId }) => {
|
|
||||||
// movePage(pageId, parentPageId, position?) -> raw move response.
|
// movePage(pageId, parentPageId, position?) -> raw move response.
|
||||||
await client.movePage(pageId, parentPageId ?? null);
|
await client.movePage(pageId, parentPageId ?? null, position);
|
||||||
return { pageId, parentPageId: parentPageId ?? null, moved: true };
|
return { pageId, parentPageId: parentPageId ?? null, moved: true };
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
|
// GUARDRAIL (§14 H4) preserved: the shared schema exposes ONLY pageId, so
|
||||||
|
// permanentlyDelete/forceDelete are never part of the input and can never
|
||||||
|
// be forwarded — the agent physically cannot permanently delete a page.
|
||||||
|
deletePage: sharedTool(sharedToolSpecs.deletePage, async ({ pageId }) => {
|
||||||
|
// deletePage(pageId) hits POST /pages/delete with { pageId } only,
|
||||||
|
// which is the soft-delete (trash) path on the server.
|
||||||
|
await client.deletePage(pageId);
|
||||||
|
return { pageId, trashed: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
deletePage: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
// This layer keeps only its own execute-side guards (require a selection
|
||||||
'Move a page to the trash (SOFT delete only — fully reversible; the ' +
|
// for a top-level comment; reject suggestedText on a reply / without a
|
||||||
'page can be restored from trash). This NEVER permanently deletes.',
|
// selection) — the schema+description are shared.
|
||||||
inputSchema: modelFriendlyInput({
|
createComment: sharedTool(
|
||||||
pageId: z.string().describe('The id of the page to move to trash.'),
|
sharedToolSpecs.createComment,
|
||||||
}),
|
async ({
|
||||||
// GUARDRAIL (§14 H4): the only field ever passed to the client is
|
|
||||||
// pageId. permanentlyDelete/forceDelete are not part of the schema and
|
|
||||||
// are never forwarded, so the agent physically cannot permanently
|
|
||||||
// delete a page through this tool.
|
|
||||||
execute: async ({ pageId }) => {
|
|
||||||
// deletePage(pageId) hits POST /pages/delete with { pageId } only,
|
|
||||||
// which is the soft-delete (trash) path on the server.
|
|
||||||
await client.deletePage(pageId);
|
|
||||||
return { pageId, trashed: true };
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
// INTENTIONAL per-transport divergence (not shared): the description is
|
|
||||||
// tuned for the in-app agent (e.g. "retry with a corrected EXACT selection"
|
|
||||||
// and "Reversible via the comment UI"); the standalone MCP `create_comment`
|
|
||||||
// keeps its own wording. Kept per-layer.
|
|
||||||
createComment: tool({
|
|
||||||
description:
|
|
||||||
'Add an INLINE comment to a page, or reply to an existing top-level ' +
|
|
||||||
'comment (one level only — the backend rejects replies to replies). ' +
|
|
||||||
'The comment is anchored inline to the given exact `selection` text ' +
|
|
||||||
'(which gets highlighted); page-level comments are NOT supported. A ' +
|
|
||||||
"new top-level comment REQUIRES a `selection`. Replies inherit the " +
|
|
||||||
"parent's anchor and take no selection. If the call fails with a " +
|
|
||||||
'"selection not found" error, retry with a corrected EXACT selection ' +
|
|
||||||
'copied verbatim from a single paragraph/block. You may also attach a ' +
|
|
||||||
'`suggestedText` proposing a replacement for the `selection` (a human ' +
|
|
||||||
'applies it from the UI); when set, the `selection` must occur exactly ' +
|
|
||||||
'once in the page. Reversible via the comment UI.',
|
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page to comment on.'),
|
|
||||||
content: z.string().describe('The comment body as Markdown.'),
|
|
||||||
selection: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.max(250)
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'EXACT contiguous text from a SINGLE paragraph/block to anchor ' +
|
|
||||||
'(highlight) the comment on (<=250 chars, avoid spanning across ' +
|
|
||||||
'formatting boundaries). Required for a new top-level comment; ' +
|
|
||||||
'omit only when replying via parentCommentId.',
|
|
||||||
),
|
|
||||||
parentCommentId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'Optional id of a TOP-LEVEL comment to reply to (one level ' +
|
|
||||||
'of replies only).',
|
|
||||||
),
|
|
||||||
suggestedText: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.max(2000)
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'Optional proposed replacement (PLAIN TEXT) for the `selection`, ' +
|
|
||||||
'applied by a human via the UI (never auto-applied). REQUIRES a ' +
|
|
||||||
'`selection`; NOT allowed on a reply. When set, the `selection` ' +
|
|
||||||
'must be UNIQUE in the page — expand it with surrounding context ' +
|
|
||||||
'(still <=250 chars) if it occurs more than once, or the call is ' +
|
|
||||||
'refused.',
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
execute: async ({
|
|
||||||
pageId,
|
pageId,
|
||||||
content,
|
content,
|
||||||
selection,
|
selection,
|
||||||
@@ -548,26 +456,17 @@ export class AiChatToolsService {
|
|||||||
const data = (result?.data ?? {}) as { id?: string };
|
const data = (result?.data ?? {}) as { id?: string };
|
||||||
return { commentId: data.id, pageId };
|
return { commentId: data.id, pageId };
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
resolveComment: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
resolveComment: sharedTool(
|
||||||
'Resolve or reopen a top-level comment thread (reversible — toggle ' +
|
sharedToolSpecs.resolveComment,
|
||||||
'the resolved flag). Only top-level comments can be resolved.',
|
async ({ commentId, resolved }) => {
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
commentId: z
|
|
||||||
.string()
|
|
||||||
.describe('The id of the top-level comment to resolve/reopen.'),
|
|
||||||
resolved: z
|
|
||||||
.boolean()
|
|
||||||
.describe('true to resolve the thread, false to reopen it.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ commentId, resolved }) => {
|
|
||||||
// resolveComment(commentId, resolved) -> { success, commentId, resolved }.
|
// resolveComment(commentId, resolved) -> { success, commentId, resolved }.
|
||||||
await client.resolveComment(commentId, resolved);
|
await client.resolveComment(commentId, resolved);
|
||||||
return { commentId, resolved };
|
return { commentId, resolved };
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
// --- READ tools (added) ---
|
// --- READ tools (added) ---
|
||||||
|
|
||||||
@@ -585,33 +484,12 @@ export class AiChatToolsService {
|
|||||||
// hierarchy mode but is worded for the in-app agent; the standalone MCP
|
// hierarchy mode but is worded for the in-app agent; the standalone MCP
|
||||||
// `list_pages` carries its own wording. Kept per-layer so each side tunes
|
// `list_pages` carries its own wording. Kept per-layer so each side tunes
|
||||||
// its own guidance.
|
// its own guidance.
|
||||||
listPages: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
listPages: sharedTool(
|
||||||
'List the most recent pages, optionally scoped to a single space. ' +
|
sharedToolSpecs.listPages,
|
||||||
'Returns a bounded list (default 50, max 100). Pass tree:true (with ' +
|
async ({ spaceId, limit, tree }) =>
|
||||||
"spaceId) to instead get the space's full page hierarchy as a nested tree.",
|
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
spaceId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe('Optional space id to scope the listing to.'),
|
|
||||||
limit: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.min(1)
|
|
||||||
.max(100)
|
|
||||||
.optional()
|
|
||||||
.describe('Maximum number of pages (1-100).'),
|
|
||||||
tree: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'When true, return the full page hierarchy of the given space as a nested tree (children arrays) instead of the recent-pages flat list. Requires spaceId; ignores limit.',
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
execute: async ({ spaceId, limit, tree }) =>
|
|
||||||
await client.listPages(spaceId, limit, tree),
|
await client.listPages(spaceId, limit, tree),
|
||||||
}),
|
),
|
||||||
|
|
||||||
listSidebarPages: tool({
|
listSidebarPages: tool({
|
||||||
description:
|
description:
|
||||||
@@ -656,41 +534,34 @@ export class AiChatToolsService {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// NOT shared (kept inline): the MCP tool name `table_get` is noun-first
|
||||||
|
// while this key is `getTable` (verb-first), breaking the
|
||||||
|
// snake_case(inAppKey) convention the shared registry enforces. Its
|
||||||
|
// reference parameter is still named `table` (was `tableRef`) so it matches
|
||||||
|
// the migrated table row/cell tools below.
|
||||||
getTable: tool({
|
getTable: tool({
|
||||||
description:
|
description:
|
||||||
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
|
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
|
||||||
'matrix so cells can be addressed for rich edits).',
|
'matrix so cells can be addressed for rich edits).',
|
||||||
inputSchema: modelFriendlyInput({
|
inputSchema: modelFriendlyInput({
|
||||||
pageId: z.string().describe('The id of the page.'),
|
pageId: z.string().describe('The id of the page.'),
|
||||||
tableRef: z
|
table: z
|
||||||
.string()
|
.string()
|
||||||
.describe(
|
.describe(
|
||||||
'"#<index>" from getOutline, or a block id of any node inside ' +
|
'"#<index>" from the page outline, or a block id of any node ' +
|
||||||
'the table.',
|
'inside the table.',
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
execute: async ({ pageId, tableRef }) =>
|
execute: async ({ pageId, table }) =>
|
||||||
await client.getTable(pageId, tableRef),
|
await client.getTable(pageId, table),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
listComments: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
listComments: sharedTool(
|
||||||
'List comments on a page in one call. By DEFAULT only ACTIVE ' +
|
sharedToolSpecs.listComments,
|
||||||
'threads are returned; resolved threads (a resolved top-level ' +
|
async ({ pageId, includeResolved }) =>
|
||||||
'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, includeResolved }) =>
|
|
||||||
await client.listComments(pageId, includeResolved),
|
await client.listComments(pageId, includeResolved),
|
||||||
}),
|
),
|
||||||
|
|
||||||
getComment: tool({
|
getComment: tool({
|
||||||
description: 'Fetch a single comment by id (content as Markdown).',
|
description: 'Fetch a single comment by id (content as Markdown).',
|
||||||
@@ -700,26 +571,12 @@ export class AiChatToolsService {
|
|||||||
execute: async ({ commentId }) => await client.getComment(commentId),
|
execute: async ({ commentId }) => await client.getComment(commentId),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
checkNewComments: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
checkNewComments: sharedTool(
|
||||||
'Find new comments across a space (optionally scoped to a subtree) ' +
|
sharedToolSpecs.checkNewComments,
|
||||||
'created after a given timestamp.',
|
async ({ spaceId, since, parentPageId }) =>
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
spaceId: z.string().describe('The id of the space to scan.'),
|
|
||||||
since: z
|
|
||||||
.string()
|
|
||||||
.describe('An ISO-8601 timestamp; only comments created after it.'),
|
|
||||||
parentPageId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'Optional page id to scope the scan to that page and its ' +
|
|
||||||
'descendants.',
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
execute: async ({ spaceId, since, parentPageId }) =>
|
|
||||||
await client.checkNewComments(spaceId, since, parentPageId),
|
await client.checkNewComments(spaceId, since, parentPageId),
|
||||||
}),
|
),
|
||||||
|
|
||||||
listShares: sharedTool(
|
listShares: sharedTool(
|
||||||
sharedToolSpecs.listShares,
|
sharedToolSpecs.listShares,
|
||||||
@@ -749,19 +606,14 @@ export class AiChatToolsService {
|
|||||||
await client.diffPageVersions(pageId, from, to),
|
await client.diffPageVersions(pageId, from, to),
|
||||||
),
|
),
|
||||||
|
|
||||||
exportPageMarkdown: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
exportPageMarkdown: sharedTool(
|
||||||
'Export a page to a single self-contained Docmost-flavoured ' +
|
sharedToolSpecs.exportPageMarkdown,
|
||||||
'Markdown file (meta + body + comment threads). Lossless round-trip ' +
|
async ({ pageId }) => {
|
||||||
'with importPageMarkdown.',
|
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page to export.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId }) => {
|
|
||||||
const markdown = await client.exportPageMarkdown(pageId);
|
const markdown = await client.exportPageMarkdown(pageId);
|
||||||
return { markdown };
|
return { markdown };
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
// --- WRITE tools (added; reversible via page history/trash) ---
|
// --- WRITE tools (added; reversible via page history/trash) ---
|
||||||
|
|
||||||
@@ -811,28 +663,12 @@ export class AiChatToolsService {
|
|||||||
async ({ pageId, nodeId }) => await client.deleteNode(pageId, nodeId),
|
async ({ pageId, nodeId }) => await client.deleteNode(pageId, nodeId),
|
||||||
),
|
),
|
||||||
|
|
||||||
updatePageJson: tool({
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
description:
|
// The execute body keeps this layer's content normalization (parity with
|
||||||
"Replace a page's body with a full ProseMirror document — a full " +
|
// the standalone MCP server, index.ts update_page_json).
|
||||||
'overwrite — and/or update its title. Minimal example content: ' +
|
updatePageJson: sharedTool(
|
||||||
'{"type":"doc","content":[{"type":"paragraph","content":' +
|
sharedToolSpecs.updatePageJson,
|
||||||
'[{"type":"text","text":"Hi"}]}]}. The content arg may be a JSON ' +
|
async ({ pageId, content, title }) => {
|
||||||
'object or a JSON string (both accepted). Omit content for a ' +
|
|
||||||
'title-only update. Reversible: the previous version is kept in page ' +
|
|
||||||
'history.',
|
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page to update.'),
|
|
||||||
content: z
|
|
||||||
.any()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'Full ProseMirror doc {"type":"doc","content":[...]} (JSON ' +
|
|
||||||
'object or JSON string); omit for a title-only update.',
|
|
||||||
),
|
|
||||||
title: z.string().optional().describe('Optional new title.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, content, title }) => {
|
|
||||||
// Parity with the standalone MCP server (index.ts update_page_json):
|
|
||||||
// undefined/null pass through as undefined (title-only / no-op); any
|
// undefined/null pass through as undefined (title-only / no-op); any
|
||||||
// string is JSON.parsed (so an empty string "" throws, matching the
|
// string is JSON.parsed (so an empty string "" throws, matching the
|
||||||
// MCP server); an object is passed through unchanged.
|
// MCP server); an object is passed through unchanged.
|
||||||
@@ -845,66 +681,29 @@ export class AiChatToolsService {
|
|||||||
}
|
}
|
||||||
return await client.updatePageJson(pageId, doc, title);
|
return await client.updatePageJson(pageId, doc, title);
|
||||||
},
|
},
|
||||||
}),
|
),
|
||||||
|
|
||||||
// NOT in the shared registry: this layer names the table argument
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
// `tableRef`, while the standalone MCP tool names it `table` (index.ts).
|
// The table reference parameter was unified to `table` (was `tableRef`).
|
||||||
// Sharing one buildShape would rename a model-facing parameter on one
|
tableInsertRow: sharedTool(
|
||||||
// transport, so the table row/cell tools stay per-layer by design.
|
sharedToolSpecs.tableInsertRow,
|
||||||
tableInsertRow: tool({
|
async ({ pageId, table, cells, index }) =>
|
||||||
description:
|
await client.tableInsertRow(pageId, table, cells, index),
|
||||||
'Insert a row of plain-text cells into a table. Reversible via ' +
|
),
|
||||||
'page history.',
|
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page.'),
|
|
||||||
tableRef: z
|
|
||||||
.string()
|
|
||||||
.describe('"#<index>" from getOutline, or a block id in the table.'),
|
|
||||||
cells: z.array(z.string()).describe('The cell texts for the row.'),
|
|
||||||
index: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.optional()
|
|
||||||
.describe('0-based insert position (omit/out-of-range to append).'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, tableRef, cells, index }) =>
|
|
||||||
await client.tableInsertRow(pageId, tableRef, cells, index),
|
|
||||||
}),
|
|
||||||
|
|
||||||
// NOT shared — same `tableRef` (here) vs `table` (MCP) parameter-name
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
// divergence as tableInsertRow.
|
tableDeleteRow: sharedTool(
|
||||||
tableDeleteRow: tool({
|
sharedToolSpecs.tableDeleteRow,
|
||||||
description:
|
async ({ pageId, table, index }) =>
|
||||||
'Delete a table row at a 0-based index. Reversible via page history.',
|
await client.tableDeleteRow(pageId, table, index),
|
||||||
inputSchema: modelFriendlyInput({
|
),
|
||||||
pageId: z.string().describe('The id of the page.'),
|
|
||||||
tableRef: z
|
|
||||||
.string()
|
|
||||||
.describe('"#<index>" from getOutline, or a block id in the table.'),
|
|
||||||
index: z.number().int().describe('0-based row index to delete.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, tableRef, index }) =>
|
|
||||||
await client.tableDeleteRow(pageId, tableRef, index),
|
|
||||||
}),
|
|
||||||
|
|
||||||
// NOT shared — same `tableRef` (here) vs `table` (MCP) parameter-name
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
// divergence as tableInsertRow.
|
tableUpdateCell: sharedTool(
|
||||||
tableUpdateCell: tool({
|
sharedToolSpecs.tableUpdateCell,
|
||||||
description:
|
async ({ pageId, table, row, col, text }) =>
|
||||||
'Set the plain-text content of a table cell at [row, col] (0-based). ' +
|
await client.tableUpdateCell(pageId, table, row, col, text),
|
||||||
'Reversible via page history.',
|
),
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page.'),
|
|
||||||
tableRef: z
|
|
||||||
.string()
|
|
||||||
.describe('"#<index>" from getOutline, or a block id in the table.'),
|
|
||||||
row: z.number().int().describe('0-based row index.'),
|
|
||||||
col: z.number().int().describe('0-based column index.'),
|
|
||||||
text: z.string().describe('The new cell text.'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, tableRef, row, col, text }) =>
|
|
||||||
await client.tableUpdateCell(pageId, tableRef, row, col, text),
|
|
||||||
}),
|
|
||||||
|
|
||||||
copyPageContent: sharedTool(
|
copyPageContent: sharedTool(
|
||||||
sharedToolSpecs.copyPageContent,
|
sharedToolSpecs.copyPageContent,
|
||||||
@@ -918,25 +717,14 @@ export class AiChatToolsService {
|
|||||||
await client.importPageMarkdown(pageId, markdown),
|
await client.importPageMarkdown(pageId, markdown),
|
||||||
),
|
),
|
||||||
|
|
||||||
// INTENTIONAL per-transport divergence (not shared): adds a security
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||||
// confirmation framing ("Only share when the user explicitly asked, since
|
// Both layers already carried the security-confirmation framing, so there
|
||||||
// this exposes the page to anyone with the link") for the in-app agent; the
|
// was no real divergence to preserve — only wording drift.
|
||||||
// standalone MCP `share_page` keeps the plain public-URL wording.
|
sharePage: sharedTool(
|
||||||
sharePage: tool({
|
sharedToolSpecs.sharePage,
|
||||||
description:
|
async ({ pageId, searchIndexing }) =>
|
||||||
'Make a page PUBLICLY accessible and return its public URL. ' +
|
|
||||||
'Reversible via unsharePage. Only share when the user explicitly ' +
|
|
||||||
'asked, since this exposes the page to anyone with the link.',
|
|
||||||
inputSchema: modelFriendlyInput({
|
|
||||||
pageId: z.string().describe('The id of the page to share.'),
|
|
||||||
searchIndexing: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.describe('Allow public search engines to index it (default true).'),
|
|
||||||
}),
|
|
||||||
execute: async ({ pageId, searchIndexing }) =>
|
|
||||||
await client.sharePage(pageId, searchIndexing),
|
await client.sharePage(pageId, searchIndexing),
|
||||||
}),
|
),
|
||||||
|
|
||||||
unsharePage: sharedTool(
|
unsharePage: sharedTool(
|
||||||
sharedToolSpecs.unsharePage,
|
sharedToolSpecs.unsharePage,
|
||||||
|
|||||||
@@ -100,54 +100,26 @@ export const INLINE_TOOL_TIERS: Record<
|
|||||||
tier: 'core',
|
tier: 'core',
|
||||||
catalogLine: 'getCurrentPage — the page the user is currently viewing.',
|
catalogLine: 'getCurrentPage — the page the user is currently viewing.',
|
||||||
},
|
},
|
||||||
getPage: {
|
// NOTE: getPage and listPages moved to @docmost/mcp's SHARED_TOOL_SPECS
|
||||||
tier: 'core',
|
// (#294); they carry their own tier ('core') + catalogLine there.
|
||||||
catalogLine: 'getPage — fetch a page as Markdown by its id.',
|
// NOTE: createComment, listComments and resolveComment moved to
|
||||||
},
|
// @docmost/mcp's SHARED_TOOL_SPECS (#294); they carry their own tier +
|
||||||
listPages: {
|
// catalogLine there. getComment stays inline (MCP-only shape divergence is
|
||||||
tier: 'core',
|
// n/a — it simply has no shared spec).
|
||||||
catalogLine: "listPages — list recent pages, or a space's full page tree.",
|
|
||||||
},
|
|
||||||
listComments: {
|
|
||||||
tier: 'core',
|
|
||||||
catalogLine: 'listComments — list all comments on a page (including resolved).',
|
|
||||||
},
|
|
||||||
getComment: {
|
getComment: {
|
||||||
tier: 'core',
|
tier: 'core',
|
||||||
catalogLine: 'getComment — fetch a single comment by id.',
|
catalogLine: 'getComment — fetch a single comment by id.',
|
||||||
},
|
},
|
||||||
createComment: {
|
|
||||||
tier: 'core',
|
|
||||||
catalogLine:
|
|
||||||
'createComment — add an inline comment (optionally with a suggested edit).',
|
|
||||||
},
|
|
||||||
resolveComment: {
|
|
||||||
tier: 'core',
|
|
||||||
catalogLine: 'resolveComment — resolve or reopen a comment thread.',
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- deferred inline ---
|
// --- deferred inline ---
|
||||||
createPage: {
|
// NOTE: createPage, renamePage, movePage, deletePage, updatePageJson and
|
||||||
tier: 'deferred',
|
// exportPageMarkdown moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); they
|
||||||
catalogLine: 'createPage — create a new page with a Markdown body in a space.',
|
// carry their own deferred tier + catalogLine there.
|
||||||
},
|
|
||||||
updatePageContent: {
|
updatePageContent: {
|
||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine:
|
catalogLine:
|
||||||
"updatePageContent — replace a page's body (and optionally title) with new Markdown.",
|
"updatePageContent — replace a page's body (and optionally title) with new Markdown.",
|
||||||
},
|
},
|
||||||
renamePage: {
|
|
||||||
tier: 'deferred',
|
|
||||||
catalogLine: "renamePage — change a page's title only (body untouched).",
|
|
||||||
},
|
|
||||||
movePage: {
|
|
||||||
tier: 'deferred',
|
|
||||||
catalogLine: 'movePage — move a page under a new parent or to the space root.',
|
|
||||||
},
|
|
||||||
deletePage: {
|
|
||||||
tier: 'deferred',
|
|
||||||
catalogLine: 'deletePage — move a page to trash (soft delete, reversible).',
|
|
||||||
},
|
|
||||||
listSidebarPages: {
|
listSidebarPages: {
|
||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine:
|
catalogLine:
|
||||||
@@ -157,42 +129,21 @@ export const INLINE_TOOL_TIERS: Record<
|
|||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine: 'getTable — read a table as a matrix of cell texts and cell ids.',
|
catalogLine: 'getTable — read a table as a matrix of cell texts and cell ids.',
|
||||||
},
|
},
|
||||||
checkNewComments: {
|
// NOTE: tableInsertRow, tableDeleteRow and tableUpdateCell moved to
|
||||||
tier: 'deferred',
|
// @docmost/mcp's SHARED_TOOL_SPECS (#294); they carry their own deferred tier +
|
||||||
catalogLine:
|
// catalogLine there. getTable stays inline (its MCP name table_get breaks the
|
||||||
'checkNewComments — find comments in a space created after a timestamp.',
|
// snake_case(inAppKey) convention, so it has no shared spec).
|
||||||
},
|
// NOTE: checkNewComments moved to @docmost/mcp's SHARED_TOOL_SPECS (#294);
|
||||||
|
// it carries its own deferred tier + catalogLine there.
|
||||||
getPageHistory: {
|
getPageHistory: {
|
||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine:
|
catalogLine:
|
||||||
'getPageHistory — fetch one page-history version with its ProseMirror content.',
|
'getPageHistory — fetch one page-history version with its ProseMirror content.',
|
||||||
},
|
},
|
||||||
exportPageMarkdown: {
|
// NOTE: sharePage moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); it carries
|
||||||
tier: 'deferred',
|
// its own deferred tier + catalogLine there. transformPage stays inline (its
|
||||||
catalogLine:
|
// schema deliberately diverges — it omits the deleteComments field the MCP
|
||||||
'exportPageMarkdown — export a page to self-contained Markdown (body + comments).',
|
// docmost_transform exposes, a comment-deletion guardrail).
|
||||||
},
|
|
||||||
updatePageJson: {
|
|
||||||
tier: 'deferred',
|
|
||||||
catalogLine:
|
|
||||||
"updatePageJson — overwrite a page's body with a full ProseMirror document.",
|
|
||||||
},
|
|
||||||
tableInsertRow: {
|
|
||||||
tier: 'deferred',
|
|
||||||
catalogLine: 'tableInsertRow — insert a row of plain-text cells into a table.',
|
|
||||||
},
|
|
||||||
tableDeleteRow: {
|
|
||||||
tier: 'deferred',
|
|
||||||
catalogLine: 'tableDeleteRow — delete a table row at a 0-based index.',
|
|
||||||
},
|
|
||||||
tableUpdateCell: {
|
|
||||||
tier: 'deferred',
|
|
||||||
catalogLine: 'tableUpdateCell — set the text of a table cell at [row, col].',
|
|
||||||
},
|
|
||||||
sharePage: {
|
|
||||||
tier: 'deferred',
|
|
||||||
catalogLine: 'sharePage — make a page publicly accessible and return its URL.',
|
|
||||||
},
|
|
||||||
transformPage: {
|
transformPage: {
|
||||||
tier: 'deferred',
|
tier: 'deferred',
|
||||||
catalogLine: "transformPage — run a sandboxed JS transform over a page's document.",
|
catalogLine: "transformPage — run a sandboxed JS transform over a page's document.",
|
||||||
|
|||||||
@@ -474,6 +474,19 @@ export class AttachmentController {
|
|||||||
const fileSize = Number(attachment.fileSize);
|
const fileSize = Number(attachment.fileSize);
|
||||||
const rangeHeader = req.headers.range;
|
const rangeHeader = req.headers.range;
|
||||||
|
|
||||||
|
// Opt this download route out of the global @fastify/compress hook.
|
||||||
|
// Attachment bytes are final and mostly binary, so on-the-fly compression
|
||||||
|
// only burns CPU — and on the 206/Range branch it is actively corrupting:
|
||||||
|
// compress decides purely by Content-Type, so for a compressible mime
|
||||||
|
// (application/octet-stream fallback, image/svg+xml, text/*) it would gzip
|
||||||
|
// the byte slice and drop Content-Length while Content-Range still
|
||||||
|
// describes the RAW offsets and the status stays 206. A resuming client
|
||||||
|
// (`curl -C -`, download managers) then appends the encoded bytes as if
|
||||||
|
// raw and ends up with a broken file. @fastify/compress skips whenever the
|
||||||
|
// request carries `x-no-compression` (see its onSend hook), so setting it
|
||||||
|
// here covers both the 200 (full file) and 206 (range) responses.
|
||||||
|
req.headers['x-no-compression'] = 'true';
|
||||||
|
|
||||||
res.header('Accept-Ranges', 'bytes');
|
res.header('Accept-Ranges', 'bytes');
|
||||||
res.header(
|
res.header(
|
||||||
'Content-Security-Policy',
|
'Content-Security-Policy',
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ import {
|
|||||||
INTERNAL_LINK_REGEX,
|
INTERNAL_LINK_REGEX,
|
||||||
extractPageSlugId,
|
extractPageSlugId,
|
||||||
} from '../../../integrations/export/utils';
|
} from '../../../integrations/export/utils';
|
||||||
import { markdownToHtml, canonicalizeFootnotes } from '@docmost/editor-ext';
|
import { canonicalizeFootnotes } from '@docmost/editor-ext';
|
||||||
|
import { markdownToProseMirror } from '@docmost/prosemirror-markdown';
|
||||||
|
import { normalizeForeignMarkdown } from '../../../integrations/import/utils/foreign-markdown';
|
||||||
import { WatcherService } from '../../watcher/watcher.service';
|
import { WatcherService } from '../../watcher/watcher.service';
|
||||||
import { sql } from 'kysely';
|
import { sql } from 'kysely';
|
||||||
import { TransclusionService } from '../transclusion/transclusion.service';
|
import { TransclusionService } from '../transclusion/transclusion.service';
|
||||||
@@ -1301,8 +1303,14 @@ export class PageService {
|
|||||||
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case 'markdown': {
|
case 'markdown': {
|
||||||
const html = await markdownToHtml(content as string);
|
// Canonical markdown -> ProseMirror JSON directly via
|
||||||
prosemirrorJson = htmlToJson(html as string);
|
// `@docmost/prosemirror-markdown` (issue #345) — no HTML intermediate,
|
||||||
|
// no editor-ext markdown layer. Foreign markdown surfaces the strict
|
||||||
|
// parser rejects (GFM `[^id]` reference footnotes) are normalized to the
|
||||||
|
// canonical inline form first.
|
||||||
|
prosemirrorJson = await markdownToProseMirror(
|
||||||
|
normalizeForeignMarkdown(content as string),
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'html': {
|
case 'html': {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { EventName } from '../../common/events/event.contants';
|
|||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thin snapshot of a page node carried inside domain events so the WebSocket
|
* Thin snapshot of a page node carried inside domain events so the WebSocket
|
||||||
@@ -112,48 +111,24 @@ export class PageListener {
|
|||||||
private readonly logger = new Logger(PageListener.name);
|
private readonly logger = new Logger(PageListener.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly environmentService: EnvironmentService,
|
|
||||||
@InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue,
|
|
||||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@OnEvent(EventName.PAGE_CREATED)
|
@OnEvent(EventName.PAGE_CREATED)
|
||||||
async handlePageCreated(event: PageEvent) {
|
async handlePageCreated(event: PageEvent) {
|
||||||
const { pageIds, workspaceId } = event;
|
const { pageIds, workspaceId } = event;
|
||||||
if (this.isTypesense()) {
|
|
||||||
await this.searchQueue.add(QueueJob.PAGE_CREATED, {
|
|
||||||
pageIds,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.aiQueue.add(QueueJob.PAGE_CREATED, { pageIds, workspaceId });
|
await this.aiQueue.add(QueueJob.PAGE_CREATED, { pageIds, workspaceId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent(EventName.PAGE_UPDATED)
|
|
||||||
async handlePageUpdated(event: PageEvent) {
|
|
||||||
const { pageIds } = event;
|
|
||||||
|
|
||||||
await this.searchQueue.add(QueueJob.PAGE_UPDATED, { pageIds });
|
|
||||||
}
|
|
||||||
|
|
||||||
@OnEvent(EventName.PAGE_DELETED)
|
@OnEvent(EventName.PAGE_DELETED)
|
||||||
async handlePageDeleted(event: PageEvent) {
|
async handlePageDeleted(event: PageEvent) {
|
||||||
const { pageIds, workspaceId } = event;
|
const { pageIds, workspaceId } = event;
|
||||||
if (this.isTypesense()) {
|
|
||||||
await this.searchQueue.add(QueueJob.PAGE_DELETED, { pageIds });
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.aiQueue.add(QueueJob.PAGE_DELETED, { pageIds, workspaceId });
|
await this.aiQueue.add(QueueJob.PAGE_DELETED, { pageIds, workspaceId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent(EventName.PAGE_SOFT_DELETED)
|
@OnEvent(EventName.PAGE_SOFT_DELETED)
|
||||||
async handlePageSoftDeleted(event: PageEvent) {
|
async handlePageSoftDeleted(event: PageEvent) {
|
||||||
const { pageIds, workspaceId } = event;
|
const { pageIds, workspaceId } = event;
|
||||||
|
|
||||||
if (this.isTypesense()) {
|
|
||||||
await this.searchQueue.add(QueueJob.PAGE_SOFT_DELETED, { pageIds });
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.aiQueue.add(QueueJob.PAGE_SOFT_DELETED, {
|
await this.aiQueue.add(QueueJob.PAGE_SOFT_DELETED, {
|
||||||
pageIds,
|
pageIds,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -163,14 +138,6 @@ export class PageListener {
|
|||||||
@OnEvent(EventName.PAGE_RESTORED)
|
@OnEvent(EventName.PAGE_RESTORED)
|
||||||
async handlePageRestored(event: PageEvent) {
|
async handlePageRestored(event: PageEvent) {
|
||||||
const { pageIds, workspaceId } = event;
|
const { pageIds, workspaceId } = event;
|
||||||
if (this.isTypesense()) {
|
|
||||||
await this.searchQueue.add(QueueJob.PAGE_RESTORED, { pageIds });
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.aiQueue.add(QueueJob.PAGE_RESTORED, { pageIds, workspaceId });
|
await this.aiQueue.add(QueueJob.PAGE_RESTORED, { pageIds, workspaceId });
|
||||||
}
|
}
|
||||||
|
|
||||||
isTypesense(): boolean {
|
|
||||||
return this.environmentService.getSearchDriver() === 'typesense';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { EventName } from '../../common/events/event.contants';
|
|||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
|
||||||
|
|
||||||
export class SpaceEvent {
|
export class SpaceEvent {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@@ -15,22 +14,12 @@ export class SpaceListener {
|
|||||||
private readonly logger = new Logger(SpaceListener.name);
|
private readonly logger = new Logger(SpaceListener.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly environmentService: EnvironmentService,
|
|
||||||
@InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue,
|
|
||||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@OnEvent(EventName.SPACE_DELETED)
|
@OnEvent(EventName.SPACE_DELETED)
|
||||||
async handleSpaceDeleted(event: SpaceEvent) {
|
async handleSpaceDeleted(event: SpaceEvent) {
|
||||||
const { spaceId } = event;
|
const { spaceId } = event;
|
||||||
if (this.isTypesense()) {
|
|
||||||
await this.searchQueue.add(QueueJob.SPACE_DELETED, { spaceId });
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.aiQueue.add(QueueJob.SPACE_DELETED, { spaceId });
|
await this.aiQueue.add(QueueJob.SPACE_DELETED, { spaceId });
|
||||||
}
|
}
|
||||||
|
|
||||||
isTypesense(): boolean {
|
|
||||||
return this.environmentService.getSearchDriver() === 'typesense';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { EventName } from '../../common/events/event.contants';
|
|||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
|
||||||
|
|
||||||
export class WorkspaceEvent {
|
export class WorkspaceEvent {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
@@ -15,22 +14,12 @@ export class WorkspaceListener {
|
|||||||
private readonly logger = new Logger(WorkspaceListener.name);
|
private readonly logger = new Logger(WorkspaceListener.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly environmentService: EnvironmentService,
|
|
||||||
@InjectQueue(QueueName.SEARCH_QUEUE) private searchQueue: Queue,
|
|
||||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@OnEvent(EventName.WORKSPACE_DELETED)
|
@OnEvent(EventName.WORKSPACE_DELETED)
|
||||||
async handlePageDeleted(event: WorkspaceEvent) {
|
async handlePageDeleted(event: WorkspaceEvent) {
|
||||||
const { workspaceId } = event;
|
const { workspaceId } = event;
|
||||||
if (this.isTypesense()) {
|
|
||||||
await this.searchQueue.add(QueueJob.WORKSPACE_DELETED, { workspaceId });
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.aiQueue.add(QueueJob.WORKSPACE_DELETED, { workspaceId });
|
await this.aiQueue.add(QueueJob.WORKSPACE_DELETED, { workspaceId });
|
||||||
}
|
}
|
||||||
|
|
||||||
isTypesense(): boolean {
|
|
||||||
return this.environmentService.getSearchDriver() === 'typesense';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ const migrator = new Migrator({
|
|||||||
path,
|
path,
|
||||||
migrationFolder,
|
migrationFolder,
|
||||||
}),
|
}),
|
||||||
|
// Match the startup auto-migrator (migration.service.ts): a back-dated
|
||||||
|
// migration from a long-lived branch must be applied, not rejected as
|
||||||
|
// "corrupted migrations" (incident #361). See that file for the full rationale.
|
||||||
|
allowUnorderedMigrations: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
run(db, migrator, migrationFolder);
|
run(db, migrator, migrationFolder);
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ export class MigrationService {
|
|||||||
path,
|
path,
|
||||||
migrationFolder: path.join(__dirname, '..', 'migrations'),
|
migrationFolder: path.join(__dirname, '..', 'migrations'),
|
||||||
}),
|
}),
|
||||||
|
// A long-lived branch can add a migration whose timestamped filename sorts
|
||||||
|
// BEFORE migrations already applied in prod (e.g. #234's 20260627 landing
|
||||||
|
// after 20260704 was live). With the default (ordered) setting the startup
|
||||||
|
// migrator then sees "corrupted migrations" — the applied set is no longer a
|
||||||
|
// prefix of the sorted list — throws, and the app crash-loops on boot
|
||||||
|
// (incident #361: 502s for ~11 min). allowUnorderedMigrations runs any
|
||||||
|
// not-yet-applied migration regardless of filename order, so a back-dated
|
||||||
|
// migration is applied instead of bricking startup. A CI order-gate still
|
||||||
|
// discourages back-dating; this is the runtime safety net.
|
||||||
|
allowUnorderedMigrations: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { error, results } = await migrator.migrateToLatest();
|
const { error, results } = await migrator.migrateToLatest();
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { streamText, Output } from 'ai';
|
||||||
|
import { MockLanguageModelV3, simulateReadableStream } from 'ai/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for patches/ai@6.0.134.patch (server heap OOM on long
|
||||||
|
* autonomous agent runs, #184).
|
||||||
|
*
|
||||||
|
* Unpatched ai@6.0.134 substitutes the default text() output strategy even
|
||||||
|
* when the caller passes NO `output` option. Its createOutputTransformStream
|
||||||
|
* then accumulates the ENTIRE turn text and, on EVERY text-delta, enqueues a
|
||||||
|
* flat snapshot of all text so far as `partialOutput` (O(n^2) memory). Those
|
||||||
|
* snapshots pile up in the never-consumed leftover tee() branch of
|
||||||
|
* DefaultStreamTextResult.baseStream, which is what OOM'd production during a
|
||||||
|
* ~28k-chunk agent turn. The pnpm patch skips partialOutput production
|
||||||
|
* entirely when no output strategy was requested, while keeping per-delta
|
||||||
|
* streaming granularity.
|
||||||
|
*/
|
||||||
|
describe('ai@6.0.134 pnpm patch: no partialOutput accumulation without an output strategy', () => {
|
||||||
|
const makeModel = () =>
|
||||||
|
new MockLanguageModelV3({
|
||||||
|
doStream: async () => ({
|
||||||
|
stream: simulateReadableStream({
|
||||||
|
chunks: [
|
||||||
|
{ type: 'stream-start' as const, warnings: [] },
|
||||||
|
{ type: 'text-start' as const, id: '1' },
|
||||||
|
{ type: 'text-delta' as const, id: '1', delta: 'Hello' },
|
||||||
|
{ type: 'text-delta' as const, id: '1', delta: ', ' },
|
||||||
|
{ type: 'text-delta' as const, id: '1', delta: 'world!' },
|
||||||
|
{ type: 'text-end' as const, id: '1' },
|
||||||
|
{
|
||||||
|
type: 'finish' as const,
|
||||||
|
finishReason: { unified: 'stop' as const, raw: 'stop' },
|
||||||
|
usage: {
|
||||||
|
inputTokens: {
|
||||||
|
total: 1,
|
||||||
|
noCache: undefined,
|
||||||
|
cacheRead: undefined,
|
||||||
|
cacheWrite: undefined,
|
||||||
|
},
|
||||||
|
outputTokens: { total: 1, text: 1, reasoning: undefined },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves per-delta streaming granularity in textStream', async () => {
|
||||||
|
const result = streamText({ model: makeModel(), prompt: 'hi' });
|
||||||
|
|
||||||
|
const deltas: string[] = [];
|
||||||
|
for await (const delta of result.textStream) {
|
||||||
|
deltas.push(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The patch must NOT coalesce or drop deltas: three model deltas arrive
|
||||||
|
// as three separate textStream chunks.
|
||||||
|
expect(deltas).toEqual(['Hello', ', ', 'world!']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits NO partialOutput values when the caller did not request an output strategy', async () => {
|
||||||
|
const result = streamText({ model: makeModel(), prompt: 'hi' });
|
||||||
|
|
||||||
|
// Fully consume the primary stream first (mirrors production usage).
|
||||||
|
for await (const _ of result.textStream) {
|
||||||
|
// drain
|
||||||
|
}
|
||||||
|
|
||||||
|
const partials: unknown[] = [];
|
||||||
|
for await (const partial of result.experimental_partialOutputStream) {
|
||||||
|
partials.push(partial);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TRIPWIRE: on unpatched ai@6.0.134 the default text() output strategy
|
||||||
|
// yields one cumulative partial per text-delta here (['Hello', 'Hello, ',
|
||||||
|
// 'Hello, world!']). An empty stream proves the patch is applied and no
|
||||||
|
// cumulative snapshots are being produced (and thus none can pile up in
|
||||||
|
// the leftover internal tee branch).
|
||||||
|
expect(partials).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves cumulative partialOutput when the caller DOES request an output strategy', async () => {
|
||||||
|
// PRESERVE-BRANCH GUARD: the patch only short-circuits partialOutput when
|
||||||
|
// `output == null`. When an output strategy IS set (here Output.text()),
|
||||||
|
// createOutputTransformStream must fall through to the ORIGINAL code path
|
||||||
|
// and keep publishing cumulative snapshots, so object/text-output consumers
|
||||||
|
// behave byte-identically to unpatched ai. A careless re-port that routed
|
||||||
|
// output-set calls into the skip branch would leave partialOutput empty and
|
||||||
|
// silently break those consumers — this test is the tripwire for that.
|
||||||
|
const result = streamText({
|
||||||
|
model: makeModel(),
|
||||||
|
prompt: 'hi',
|
||||||
|
experimental_output: Output.text(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drain the primary stream fully and accumulate the complete output text.
|
||||||
|
let fullText = '';
|
||||||
|
for await (const delta of result.textStream) {
|
||||||
|
fullText += delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
const partials: string[] = [];
|
||||||
|
for await (const partial of result.experimental_partialOutputStream) {
|
||||||
|
partials.push(partial);
|
||||||
|
}
|
||||||
|
|
||||||
|
// With a strategy set, partialOutput must be PRESERVED (non-empty) and
|
||||||
|
// cumulative: the last emitted partial equals the full accumulated text.
|
||||||
|
expect(partials.length).toBeGreaterThan(0);
|
||||||
|
expect(partials[partials.length - 1]).toBe(fullText);
|
||||||
|
expect(fullText).toBe('Hello, world!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('both installed dist builds (CJS and ESM) carry the patch marker', () => {
|
||||||
|
// Secondary guard: pins the patch to BOTH bundles the SDK ships, since
|
||||||
|
// the NestJS server consumes CJS while other tooling may load ESM.
|
||||||
|
const cjsPath = require.resolve('ai');
|
||||||
|
const mjsPath = cjsPath.replace(/index\.js$/, 'index.mjs');
|
||||||
|
expect(cjsPath).toMatch(/index\.js$/);
|
||||||
|
expect(readFileSync(cjsPath, 'utf8')).toContain('PATCH(docmost');
|
||||||
|
expect(readFileSync(mjsPath, 'utf8')).toContain('PATCH(docmost');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// export.service.ts imports the ESM-only @sindresorhus/slugify (not in jest's
|
||||||
|
// transform allowlist). It is irrelevant to the markdown-serialization path under
|
||||||
|
// test (only used for page-mention link slugs on the DB path), so it is mocked
|
||||||
|
// out to keep the module graph loadable under ts-jest (mirrors the import specs).
|
||||||
|
jest.mock('@sindresorhus/slugify', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: (input: string) => String(input),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { convertProseMirrorToMarkdown } from '@docmost/prosemirror-markdown';
|
||||||
|
import { ExportService } from './export.service';
|
||||||
|
import { ExportFormat } from './dto/export-dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STEP 1 golden test for issue #345: server MARKDOWN export runs DIRECTLY through
|
||||||
|
* the canonical converter (`convertProseMirrorToMarkdown`) — no HTML intermediate
|
||||||
|
* and no `@docmost/editor-ext` markdown layer — so the emitted markdown is in the
|
||||||
|
* canonical package forms and is byte-identical to the git-sync vault body.
|
||||||
|
*
|
||||||
|
* These are the goldens the swap has to satisfy: they assert the CANONICAL
|
||||||
|
* surface (callout `> [!type]`, inline footnote `^[…]`, lossless image
|
||||||
|
* `<!--img …-->`) rather than the old editor-ext forms (`:::type`, `[^id]`,
|
||||||
|
* lossy ``).
|
||||||
|
*
|
||||||
|
* `exportPage(..., singlePage=false)` takes no DB path (no mention rewriting), so
|
||||||
|
* the service is constructed with null collaborators and only the pure
|
||||||
|
* PM -> Markdown path is exercised.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function makeService(): ExportService {
|
||||||
|
return new ExportService(
|
||||||
|
null as any, // pageRepo
|
||||||
|
null as any, // pagePermissionRepo
|
||||||
|
null as any, // db
|
||||||
|
null as any, // storageService
|
||||||
|
null as any, // environmentService
|
||||||
|
null as any, // domainService
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A representative page exercising the node types whose canonical markdown form
|
||||||
|
// changed with the move off the editor-ext layer: callout, inline footnote, and a
|
||||||
|
// lossless image carrying width/align attrs that the old layer dropped.
|
||||||
|
const REPRESENTATIVE_DOC = {
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [
|
||||||
|
{ type: 'text', text: 'Body ' },
|
||||||
|
{ type: 'footnoteReference', attrs: { id: 'fn-1' } },
|
||||||
|
{ type: 'text', text: ' end.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'callout',
|
||||||
|
attrs: { type: 'info', icon: null },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [{ type: 'text', text: 'Heads up' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'image',
|
||||||
|
attrs: {
|
||||||
|
src: '/files/pic.png',
|
||||||
|
alt: 'Pic',
|
||||||
|
width: 320,
|
||||||
|
align: 'left',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'footnotesList',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'footnoteDefinition',
|
||||||
|
attrs: { id: 'fn-1' },
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [{ type: 'text', text: 'the note' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ExportService — markdown export via the canonical converter (#345)', () => {
|
||||||
|
it('emits canonical callout, inline footnote and lossless image forms', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
const md = (await service.exportPage(ExportFormat.Markdown, {
|
||||||
|
title: '',
|
||||||
|
content: REPRESENTATIVE_DOC,
|
||||||
|
} as any)) as string;
|
||||||
|
|
||||||
|
// Callout: Obsidian `> [!type]`, NOT the legacy `:::type`.
|
||||||
|
expect(md).toContain('> [!info]');
|
||||||
|
expect(md).not.toContain(':::');
|
||||||
|
|
||||||
|
// Inline footnote: `^[…]`, NOT the reference `[^id]` form.
|
||||||
|
expect(md).toContain('^[the note]');
|
||||||
|
expect(md).not.toMatch(/\[\^/);
|
||||||
|
|
||||||
|
// Lossless image: trailing `<!--img …-->` carrying the dropped attrs.
|
||||||
|
expect(md).toContain('');
|
||||||
|
expect(md).toContain('<!--img');
|
||||||
|
expect(md).toContain('"width":"320"');
|
||||||
|
expect(md).toContain('"align":"left"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('export body is byte-identical to the git-sync vault serializer (export == vault)', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
// A title-less page: exportPage prepends NO heading, so the whole output is
|
||||||
|
// the page BODY — exactly what git-sync serializes (git-sync stores the title
|
||||||
|
// in frontmatter / the filename, never as an in-body H1).
|
||||||
|
const exported = (await service.exportPage(ExportFormat.Markdown, {
|
||||||
|
title: '',
|
||||||
|
content: REPRESENTATIVE_DOC,
|
||||||
|
} as any)) as string;
|
||||||
|
|
||||||
|
// The git-sync vault writer feeds this SAME converter (git-sync
|
||||||
|
// `stabilizePageBody` = convertProseMirrorToMarkdown(content) at the
|
||||||
|
// fixpoint). For an already-stable doc the single pass IS the fixpoint, so
|
||||||
|
// the two are byte-identical by construction — assert it.
|
||||||
|
const vaultBody = convertProseMirrorToMarkdown(REPRESENTATIVE_DOC);
|
||||||
|
expect(exported).toBe(vaultBody);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prepends the page title as an H1 heading (the one documented export/vault delta)', async () => {
|
||||||
|
const service = makeService();
|
||||||
|
const md = (await service.exportPage(ExportFormat.Markdown, {
|
||||||
|
title: 'My Page',
|
||||||
|
content: { type: 'doc', content: [] },
|
||||||
|
} as any)) as string;
|
||||||
|
|
||||||
|
// Export makes standalone files, so it prepends the title as an H1. This is
|
||||||
|
// the ONE deliberate difference from the vault body (which carries the title
|
||||||
|
// in frontmatter). The body below the heading still serializes canonically.
|
||||||
|
expect(md.startsWith('# My Page')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
getAttachmentIds,
|
getAttachmentIds,
|
||||||
getProsemirrorContent,
|
getProsemirrorContent,
|
||||||
} from '../../common/helpers/prosemirror/utils';
|
} from '../../common/helpers/prosemirror/utils';
|
||||||
import { htmlToMarkdown } from '@docmost/editor-ext';
|
import { convertProseMirrorToMarkdown } from '@docmost/prosemirror-markdown';
|
||||||
|
|
||||||
type AllowedAttachment = { id: string; fileName: string; filePath: string };
|
type AllowedAttachment = { id: string; fileName: string; filePath: string };
|
||||||
|
|
||||||
@@ -79,9 +79,8 @@ export class ExportService {
|
|||||||
prosemirrorJson.content.unshift(titleNode);
|
prosemirrorJson.content.unshift(titleNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageHtml = jsonToHtml(prosemirrorJson);
|
|
||||||
|
|
||||||
if (format === ExportFormat.HTML) {
|
if (format === ExportFormat.HTML) {
|
||||||
|
const pageHtml = jsonToHtml(prosemirrorJson);
|
||||||
return `<!DOCTYPE html>
|
return `<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -92,11 +91,14 @@ export class ExportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (format === ExportFormat.Markdown) {
|
if (format === ExportFormat.Markdown) {
|
||||||
const newPageHtml = pageHtml.replace(
|
// Direct ProseMirror JSON -> Markdown via the canonical converter
|
||||||
/<colgroup[^>]*>[\s\S]*?<\/colgroup>/gim,
|
// (`@docmost/prosemirror-markdown`). This is the SAME serializer the
|
||||||
'',
|
// git-sync vault writer feeds (see git-sync `stabilizePageBody`), so an
|
||||||
);
|
// exported page body is byte-identical to its vault representation — no
|
||||||
return htmlToMarkdown(newPageHtml);
|
// HTML intermediate, no second markdown layer, no format drift (issue
|
||||||
|
// #345). The old `<colgroup>` scrub is gone with the HTML step: the
|
||||||
|
// converter emits GFM tables directly and never produces `<colgroup>`.
|
||||||
|
return convertProseMirrorToMarkdown(prosemirrorJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
|||||||
+144
-77
@@ -17,6 +17,22 @@ jest.mock('image-dimensions', () => ({
|
|||||||
__esModule: true,
|
__esModule: true,
|
||||||
imageDimensionsFromData: () => undefined,
|
imageDimensionsFromData: () => undefined,
|
||||||
}));
|
}));
|
||||||
|
// FileImportTaskService -> PageService -> collaboration.gateway ->
|
||||||
|
// metrics.registry imports `prom-client`, which is not resolvable in this
|
||||||
|
// workspace's node_modules (types-only stub, no runtime entry). Metrics are
|
||||||
|
// disabled on this path, so a virtual no-op mock keeps the module graph loadable.
|
||||||
|
jest.mock(
|
||||||
|
'prom-client',
|
||||||
|
() => ({
|
||||||
|
collectDefaultMetrics: () => undefined,
|
||||||
|
Registry: class {},
|
||||||
|
Histogram: class {},
|
||||||
|
Gauge: class {},
|
||||||
|
Counter: class {},
|
||||||
|
Summary: class {},
|
||||||
|
}),
|
||||||
|
{ virtual: true },
|
||||||
|
);
|
||||||
|
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
@@ -26,14 +42,17 @@ import { ImportService } from './import.service';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Binding test for issue #228 / review #5: FileImportTaskService.processGenericImport
|
* Binding test for issue #228 / review #5: FileImportTaskService.processGenericImport
|
||||||
* is a NON-editor write path (markdownToHtml -> processHTML -> JSON, never runs
|
* is a NON-editor write path, so a zip-imported `.md` page ends up with canonical
|
||||||
* footnoteSyncPlugin), so it canonicalizes footnotes before persisting. This pins
|
* footnotes before persisting: ordered by first reference, reused refs deduped,
|
||||||
* that binding — the same one import.service has a spec for — which previously had
|
* orphan definitions dropped.
|
||||||
* NO spec at all.
|
|
||||||
*
|
*
|
||||||
* The markdown -> HTML -> ProseMirror conversion is REAL (a real ImportService,
|
* Since #345 the `.md` parse runs `normalizeForeignMarkdown` ->
|
||||||
* its createYdoc stubbed); the filesystem is a real temp dir with one .md file;
|
* `markdownToProseMirror` -> `jsonToHtml` (feeding the shared HTML attachment /
|
||||||
* the DB transaction is stubbed to capture the persisted page content.
|
* link pipeline) -> `processHTML` -> `canonicalizeFootnotes`. The parser assigns
|
||||||
|
* fresh `fn-*` ids, so we assert by definition BODY order rather than the source
|
||||||
|
* labels. The conversion is REAL (a real ImportService, its createYdoc stubbed);
|
||||||
|
* the filesystem is a real temp dir with one .md file; the DB transaction is
|
||||||
|
* stubbed to capture the persisted page content.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Out-of-order references (c, a, b), a REUSED reference ([^a] twice), and an
|
// Out-of-order references (c, a, b), a REUSED reference ([^a] twice), and an
|
||||||
@@ -49,13 +68,14 @@ const MARKDOWN = [
|
|||||||
'[^z]: orphan note',
|
'[^z]: orphan note',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
function footnoteListIds(content: any): string[] {
|
/** Definition body texts of the (single) footnotesList, in list order. */
|
||||||
|
function footnoteListBodies(content: any): string[] {
|
||||||
const list = (content?.content ?? []).find(
|
const list = (content?.content ?? []).find(
|
||||||
(n: any) => n.type === 'footnotesList',
|
(n: any) => n.type === 'footnotesList',
|
||||||
);
|
);
|
||||||
return (list?.content ?? [])
|
return (list?.content ?? [])
|
||||||
.filter((n: any) => n.type === 'footnoteDefinition')
|
.filter((n: any) => n.type === 'footnoteDefinition')
|
||||||
.map((n: any) => n.attrs?.id);
|
.map((n: any) => n.content?.[0]?.content?.[0]?.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A permissive chainable stub for the spaces lookup (selectFrom(...).select(...)
|
// A permissive chainable stub for the spaces lookup (selectFrom(...).select(...)
|
||||||
@@ -71,80 +91,127 @@ function chainable(result: any): any {
|
|||||||
return proxy;
|
return proxy;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one markdown file through the REAL zip-import pipeline
|
||||||
|
* (`processGenericImport` -> `markdownToProseMirror` -> `jsonToHtml` ->
|
||||||
|
* `processHTML`/`htmlToJson`) and return the persisted page `content`. This is
|
||||||
|
* the server-specific PM->HTML->PM hop that the package's own PM<->MD tests do
|
||||||
|
* NOT cover.
|
||||||
|
*/
|
||||||
|
async function runZipImport(markdown: string): Promise<any> {
|
||||||
|
const extractDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fit-canon-'));
|
||||||
|
await fs.writeFile(path.join(extractDir, 'note.md'), markdown, 'utf-8');
|
||||||
|
|
||||||
|
const importService = new ImportService(
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
);
|
||||||
|
jest
|
||||||
|
.spyOn(importService as any, 'createYdoc')
|
||||||
|
.mockResolvedValue(Buffer.from([]) as any);
|
||||||
|
|
||||||
|
let captured: any = null;
|
||||||
|
const trx = {
|
||||||
|
insertInto: (table: string) => ({
|
||||||
|
values: (v: any) => {
|
||||||
|
if (table === 'pages') captured = v;
|
||||||
|
return { execute: async () => {} };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const db: any = {
|
||||||
|
selectFrom: () => chainable({ slug: 'space-slug' }),
|
||||||
|
transaction: () => ({ execute: (fn: any) => fn(trx) }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const importAttachmentService = {
|
||||||
|
processAttachments: async ({ html }: any) => html,
|
||||||
|
};
|
||||||
|
const service = new FileImportTaskService(
|
||||||
|
{} as any, // storageService
|
||||||
|
importService as any,
|
||||||
|
{ nextPagePosition: async () => 'a0' } as any,
|
||||||
|
{ insertBacklink: jest.fn() } as any,
|
||||||
|
db,
|
||||||
|
importAttachmentService as any,
|
||||||
|
{ emit: jest.fn() } as any,
|
||||||
|
{ logBatchWithContext: jest.fn() } as any,
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileTask: any = {
|
||||||
|
id: 'task-1',
|
||||||
|
source: 'generic',
|
||||||
|
spaceId: 'space-1',
|
||||||
|
workspaceId: 'ws-1',
|
||||||
|
creatorId: 'user-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.processGenericImport({ extractDir, fileTask });
|
||||||
|
expect(captured).toBeTruthy();
|
||||||
|
return captured.content;
|
||||||
|
} finally {
|
||||||
|
await fs.rm(extractDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the first node of a given type anywhere in a PM content tree. */
|
||||||
|
function findFirst(node: any, type: string): any {
|
||||||
|
if (!node || typeof node !== 'object') return null;
|
||||||
|
if (node.type === type) return node;
|
||||||
|
for (const child of node.content ?? []) {
|
||||||
|
const hit = findFirst(child, type);
|
||||||
|
if (hit) return hit;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
describe('FileImportTaskService.processGenericImport — footnote canonicalization (#228)', () => {
|
describe('FileImportTaskService.processGenericImport — footnote canonicalization (#228)', () => {
|
||||||
it('orders footnotes by first reference, dedupes reuse, and drops orphans on zip import', async () => {
|
it('orders footnotes by first reference, dedupes reuse, and drops orphans on zip import', async () => {
|
||||||
const extractDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fit-canon-'));
|
const content = await runZipImport(MARKDOWN);
|
||||||
await fs.writeFile(path.join(extractDir, 'note.md'), MARKDOWN, 'utf-8');
|
// Definitions ordered by FIRST REFERENCE (C, A, B), NOT the markdown
|
||||||
|
// definition order (A, B, C). Ids are the parser's fresh `fn-*`, so pin
|
||||||
// Real ImportService for the html -> JSON conversion; stub the yjs encode.
|
// the BODIES.
|
||||||
const importService = new ImportService(
|
expect(footnoteListBodies(content)).toEqual(['note C', 'note A', 'note B']);
|
||||||
{} as any,
|
// Orphan [^z] dropped; reused [^a] collapses to one definition; one list.
|
||||||
{} as any,
|
expect(footnoteListBodies(content)).not.toContain('orphan note');
|
||||||
{} as any,
|
const lists = (content.content ?? []).filter(
|
||||||
{} as any,
|
(n: any) => n.type === 'footnotesList',
|
||||||
);
|
);
|
||||||
jest
|
expect(lists).toHaveLength(1);
|
||||||
.spyOn(importService as any, 'createYdoc')
|
expect(
|
||||||
.mockResolvedValue(Buffer.from([]) as any);
|
footnoteListBodies(content).filter((b) => b === 'note A'),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
let captured: any = null;
|
// #345 F4: the zip path routes markdown through jsonToHtml -> processHTML ->
|
||||||
const trx = {
|
// htmlToJson (the shared HTML attachment pipeline). #345's headline is LOSSLESS
|
||||||
insertInto: (table: string) => ({
|
// image width/align via the `<!--img {...}-->` comment; a callout carries its
|
||||||
values: (v: any) => {
|
// `type`. This asserts those survive the PM->HTML->PM hop — the one hop the
|
||||||
if (table === 'pages') captured = v;
|
// package's PM<->MD suite does not exercise.
|
||||||
return { execute: async () => {} };
|
it('preserves image width/align and callout type through the PM->HTML->PM hop', async () => {
|
||||||
},
|
const md = [
|
||||||
}),
|
'# Doc',
|
||||||
};
|
'',
|
||||||
const db: any = {
|
' <!--img {"width":"320","align":"left"}-->',
|
||||||
selectFrom: () => chainable({ slug: 'space-slug' }),
|
'',
|
||||||
transaction: () => ({ execute: (fn: any) => fn(trx) }),
|
':::warning',
|
||||||
};
|
'Careful now.',
|
||||||
|
':::',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
const importAttachmentService = {
|
const content = await runZipImport(md);
|
||||||
processAttachments: async ({ html }: any) => html,
|
|
||||||
};
|
|
||||||
const backlinkRepo = { insertBacklink: jest.fn() };
|
|
||||||
const eventEmitter = { emit: jest.fn() };
|
|
||||||
const auditService = { logBatchWithContext: jest.fn() };
|
|
||||||
|
|
||||||
const pageService = { nextPagePosition: async () => 'a0' };
|
const image = findFirst(content, 'image');
|
||||||
|
expect(image).toBeTruthy();
|
||||||
|
// The lossless sizing/alignment must survive the HTML hop.
|
||||||
|
expect(String(image.attrs?.width)).toBe('320');
|
||||||
|
expect(image.attrs?.align).toBe('left');
|
||||||
|
|
||||||
const service = new FileImportTaskService(
|
const callout = findFirst(content, 'callout');
|
||||||
{} as any, // storageService
|
expect(callout).toBeTruthy();
|
||||||
importService as any,
|
expect(callout.attrs?.type).toBe('warning');
|
||||||
pageService as any,
|
|
||||||
backlinkRepo as any,
|
|
||||||
db,
|
|
||||||
importAttachmentService as any,
|
|
||||||
eventEmitter as any,
|
|
||||||
auditService as any,
|
|
||||||
);
|
|
||||||
|
|
||||||
const fileTask: any = {
|
|
||||||
id: 'task-1',
|
|
||||||
source: 'generic',
|
|
||||||
spaceId: 'space-1',
|
|
||||||
workspaceId: 'ws-1',
|
|
||||||
creatorId: 'user-1',
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await service.processGenericImport({ extractDir, fileTask });
|
|
||||||
|
|
||||||
expect(captured).toBeTruthy();
|
|
||||||
const content = captured.content;
|
|
||||||
// Reference order is c, a, b (NOT the markdown definition order a, b, c).
|
|
||||||
expect(footnoteListIds(content)).toEqual(['c', 'a', 'b']);
|
|
||||||
// Orphan [^z] dropped; reused [^a] collapses to one definition; one list.
|
|
||||||
expect(footnoteListIds(content)).not.toContain('z');
|
|
||||||
const lists = (content.content ?? []).filter(
|
|
||||||
(n: any) => n.type === 'footnotesList',
|
|
||||||
);
|
|
||||||
expect(lists).toHaveLength(1);
|
|
||||||
expect(footnoteListIds(content).filter((id) => id === 'a')).toHaveLength(1);
|
|
||||||
} finally {
|
|
||||||
await fs.rm(extractDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { jsonToText } from '../../../collaboration/collaboration.util';
|
import {
|
||||||
|
jsonToHtml,
|
||||||
|
jsonToText,
|
||||||
|
} from '../../../collaboration/collaboration.util';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
import {
|
import {
|
||||||
@@ -18,9 +21,11 @@ import { generateSlugId } from '../../../common/helpers';
|
|||||||
import { v7 } from 'uuid';
|
import { v7 } from 'uuid';
|
||||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||||
import { FileTask, InsertablePage } from '@docmost/db/types/entity.types';
|
import { FileTask, InsertablePage } from '@docmost/db/types/entity.types';
|
||||||
import { markdownToHtml, canonicalizeFootnotes } from '@docmost/editor-ext';
|
import { canonicalizeFootnotes } from '@docmost/editor-ext';
|
||||||
|
import { markdownToProseMirror } from '@docmost/prosemirror-markdown';
|
||||||
import { getProsemirrorContent } from '../../../common/helpers/prosemirror/utils';
|
import { getProsemirrorContent } from '../../../common/helpers/prosemirror/utils';
|
||||||
import { formatImportHtml } from '../utils/import-formatter';
|
import { formatImportHtml } from '../utils/import-formatter';
|
||||||
|
import { normalizeForeignMarkdown } from '../utils/foreign-markdown';
|
||||||
import {
|
import {
|
||||||
buildAttachmentCandidates,
|
buildAttachmentCandidates,
|
||||||
collectMarkdownAndHtmlFiles,
|
collectMarkdownAndHtmlFiles,
|
||||||
@@ -461,7 +466,18 @@ export class FileImportTaskService {
|
|||||||
content = await fs.readFile(absPath, 'utf-8');
|
content = await fs.readFile(absPath, 'utf-8');
|
||||||
|
|
||||||
if (page.fileExtension.toLowerCase() === '.md') {
|
if (page.fileExtension.toLowerCase() === '.md') {
|
||||||
content = await markdownToHtml(content);
|
// Parse markdown with the single canonical converter
|
||||||
|
// (`@docmost/prosemirror-markdown`), after normalizing foreign
|
||||||
|
// reference footnotes, then serialize to HTML so the shared HTML
|
||||||
|
// pipeline below (processAttachments + formatImportHtml +
|
||||||
|
// processHTML) keeps handling `.md` and `.html` imports
|
||||||
|
// uniformly. The markdown PARSE no longer goes through the
|
||||||
|
// editor-ext markdown layer (issue #345) — the drift source is
|
||||||
|
// gone. The PM -> HTML -> PM hop that follows is lossless
|
||||||
|
// plumbing for attachment/link resolution, NOT a second parse.
|
||||||
|
content = jsonToHtml(
|
||||||
|
await markdownToProseMirror(normalizeForeignMarkdown(content)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.code === 'ENOENT') {
|
if (err?.code === 'ENOENT') {
|
||||||
@@ -500,10 +516,12 @@ export class FileImportTaskService {
|
|||||||
this.importService.extractTitleAndRemoveHeading(pmState);
|
this.importService.extractTitleAndRemoveHeading(pmState);
|
||||||
|
|
||||||
// Canonicalize footnote topology on this non-editor write path
|
// Canonicalize footnote topology on this non-editor write path
|
||||||
// (markdownToHtml/processHTML never runs footnoteSyncPlugin), so a
|
// (the HTML pipeline's processHTML never runs footnoteSyncPlugin), so
|
||||||
// zip-imported page's footnotes are reference-ordered, deduped, and
|
// a zip-imported page's footnotes are reference-ordered, deduped, and
|
||||||
// orphan-free like the editor's invariant (issue #228). Pure +
|
// orphan-free like the editor's invariant (issue #228). Pure +
|
||||||
// idempotent + shape-safe; a footnote-free doc is unchanged.
|
// idempotent + shape-safe; a footnote-free doc is unchanged. (For a
|
||||||
|
// `.md` file the package parser already yields canonical footnotes,
|
||||||
|
// so this is a no-op there.)
|
||||||
// (Future consolidation, architecture B: like import.service, this
|
// (Future consolidation, architecture B: like import.service, this
|
||||||
// path persists directly rather than via PageService — a shared
|
// path persists directly rather than via PageService — a shared
|
||||||
// "prepare JSON for persist" helper would centralize this call.)
|
// "prepare JSON for persist" helper would centralize this call.)
|
||||||
|
|||||||
+27
-31
@@ -12,13 +12,19 @@ import { canonicalizeFootnotes } from '@docmost/editor-ext';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Integration-ish test for the USER-FACING markdown import path
|
* Integration-ish test for the USER-FACING markdown import path
|
||||||
* (`ImportService.importPage`). It exercises the REAL markdown -> HTML -> JSON
|
* (`ImportService.importPage`). It exercises the REAL markdown -> ProseMirror
|
||||||
* conversion and asserts that the stored page content has its footnotes
|
* conversion and asserts the stored page's footnotes are canonical: ordered by
|
||||||
* canonicalized — the gap that issue #228 fixes: the import path builds
|
* FIRST REFERENCE (not markdown definition order), reused references deduped to a
|
||||||
* ProseMirror JSON directly (never running the editor's footnoteSyncPlugin), so
|
* single definition, and orphan definitions dropped.
|
||||||
* before this wiring the stored footnotes kept the markdown's physical
|
*
|
||||||
* definition order (out of order vs. references), retained orphan definitions,
|
* Since #345 the markdown parse runs through the canonical package
|
||||||
* and did not collapse reused references.
|
* (`normalizeForeignMarkdown` -> `markdownToProseMirror`), which owns this
|
||||||
|
* canonicalization: the input's GFM `[^id]` reference footnotes are normalized to
|
||||||
|
* inline `^[…]`, and the parser assigns fresh sequential ids (`fn-*`) in
|
||||||
|
* reference order while merging identical bodies — so we assert by definition
|
||||||
|
* BODY order, not by the source labels. `canonicalizeFootnotes` remains wired as
|
||||||
|
* an idempotent safety net (issue #228) and is a no-op on this already-canonical
|
||||||
|
* output.
|
||||||
*
|
*
|
||||||
* The DB/ydoc side-effects are stubbed: `getNewPagePosition` (DB query) and
|
* The DB/ydoc side-effects are stubbed: `getNewPagePosition` (DB query) and
|
||||||
* `createYdoc` (Yjs encode) are spied, and `pageRepo.insertPage` captures the
|
* `createYdoc` (Yjs encode) are spied, and `pageRepo.insertPage` captures the
|
||||||
@@ -67,24 +73,14 @@ function makeService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** List the footnote-definition ids of the (single) footnotesList, in order. */
|
/** List the footnote-definition ids of the (single) footnotesList, in order. */
|
||||||
function footnoteListIds(content: any): string[] {
|
/** Definition body texts of the (single) footnotesList, in list order. */
|
||||||
|
function footnoteListBodies(content: any): string[] {
|
||||||
const list = (content.content ?? []).find(
|
const list = (content.content ?? []).find(
|
||||||
(n: any) => n.type === 'footnotesList',
|
(n: any) => n.type === 'footnotesList',
|
||||||
);
|
);
|
||||||
if (!list) return [];
|
return (list?.content ?? [])
|
||||||
return (list.content ?? [])
|
|
||||||
.filter((n: any) => n.type === 'footnoteDefinition')
|
.filter((n: any) => n.type === 'footnoteDefinition')
|
||||||
.map((n: any) => n.attrs?.id);
|
.map((n: any) => n.content?.[0]?.content?.[0]?.text);
|
||||||
}
|
|
||||||
|
|
||||||
function definitionText(content: any, id: string): string | undefined {
|
|
||||||
const list = (content.content ?? []).find(
|
|
||||||
(n: any) => n.type === 'footnotesList',
|
|
||||||
);
|
|
||||||
const def = (list?.content ?? []).find(
|
|
||||||
(n: any) => n.type === 'footnoteDefinition' && n.attrs?.id === id,
|
|
||||||
);
|
|
||||||
return def?.content?.[0]?.content?.[0]?.text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ImportService.importPage — footnote canonicalization (#228)', () => {
|
describe('ImportService.importPage — footnote canonicalization (#228)', () => {
|
||||||
@@ -101,23 +97,23 @@ describe('ImportService.importPage — footnote canonicalization (#228)', () =>
|
|||||||
const content = getCaptured().content;
|
const content = getCaptured().content;
|
||||||
expect(content).toBeTruthy();
|
expect(content).toBeTruthy();
|
||||||
|
|
||||||
// Reference order is c, a, b (NOT the markdown definition order a, b, c).
|
// Definitions ordered by FIRST REFERENCE (C, A, B) — NOT the markdown
|
||||||
expect(footnoteListIds(content)).toEqual(['c', 'a', 'b']);
|
// definition order (A, B, C) — with the orphan [^z] dropped and the reused
|
||||||
|
// [^a] collapsed to a single definition. (Ids are the parser's fresh `fn-*`,
|
||||||
// Definitions preserved and attached to the right ids.
|
// so we pin the BODIES.)
|
||||||
expect(definitionText(content, 'c')).toBe('note C');
|
expect(footnoteListBodies(content)).toEqual(['note C', 'note A', 'note B']);
|
||||||
expect(definitionText(content, 'a')).toBe('note A');
|
|
||||||
expect(definitionText(content, 'b')).toBe('note B');
|
|
||||||
|
|
||||||
// Orphan definition [^z] is dropped.
|
// Orphan definition [^z] is dropped.
|
||||||
expect(footnoteListIds(content)).not.toContain('z');
|
expect(footnoteListBodies(content)).not.toContain('orphan note');
|
||||||
|
|
||||||
// Reused [^a] yields exactly ONE definition, and exactly one list.
|
// Reused [^a] yields exactly ONE definition, and exactly one list.
|
||||||
const lists = (content.content ?? []).filter(
|
const lists = (content.content ?? []).filter(
|
||||||
(n: any) => n.type === 'footnotesList',
|
(n: any) => n.type === 'footnotesList',
|
||||||
);
|
);
|
||||||
expect(lists).toHaveLength(1);
|
expect(lists).toHaveLength(1);
|
||||||
expect(footnoteListIds(content).filter((id) => id === 'a')).toHaveLength(1);
|
expect(
|
||||||
|
footnoteListBodies(content).filter((b) => b === 'note A'),
|
||||||
|
).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is idempotent: canonicalizing the stored output again is a no-op', async () => {
|
it('is idempotent: canonicalizing the stored output again is a no-op', async () => {
|
||||||
@@ -134,6 +130,6 @@ describe('ImportService.importPage — footnote canonicalization (#228)', () =>
|
|||||||
// time must not change it (safe to wire into every write path).
|
// time must not change it (safe to wire into every write path).
|
||||||
const second = canonicalizeFootnotes(stored);
|
const second = canonicalizeFootnotes(stored);
|
||||||
expect(second).toEqual(stored);
|
expect(second).toEqual(stored);
|
||||||
expect(footnoteListIds(second)).toEqual(['c', 'a', 'b']);
|
expect(footnoteListBodies(second)).toEqual(['note C', 'note A', 'note B']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ import {
|
|||||||
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
import { markdownToHtml, canonicalizeFootnotes } from '@docmost/editor-ext';
|
import { canonicalizeFootnotes } from '@docmost/editor-ext';
|
||||||
|
import { markdownToProseMirror } from '@docmost/prosemirror-markdown';
|
||||||
|
import { normalizeForeignMarkdown } from '../utils/foreign-markdown';
|
||||||
import {
|
import {
|
||||||
FileTaskStatus,
|
FileTaskStatus,
|
||||||
FileTaskType,
|
FileTaskType,
|
||||||
@@ -85,11 +87,13 @@ export class ImportService {
|
|||||||
|
|
||||||
const extracted = this.extractTitleAndRemoveHeading(prosemirrorState);
|
const extracted = this.extractTitleAndRemoveHeading(prosemirrorState);
|
||||||
const title = extracted.title;
|
const title = extracted.title;
|
||||||
// Imported markdown/HTML is built via markdownToHtml -> htmlToJson, which
|
// The markdown path now canonicalizes footnotes itself (the package parser),
|
||||||
// never runs the editor's footnoteSyncPlugin, so the footnote topology keeps
|
// but the HTML path (processHTML -> htmlToJson) does NOT run the editor's
|
||||||
// the source's PHYSICAL definition order (out of order vs. references),
|
// footnoteSyncPlugin, so an imported HTML doc can keep its source's PHYSICAL
|
||||||
// retains orphan definitions, and is not deduped. Canonicalize before
|
// definition order (out of order vs. references), retain orphan definitions,
|
||||||
// persisting so the stored page matches the editor's invariant (issue #228).
|
// and not be deduped. Canonicalize before persisting so the stored page
|
||||||
|
// matches the editor's invariant (issue #228); it is an idempotent no-op on
|
||||||
|
// the already-canonical markdown output.
|
||||||
// Pure + idempotent + shape-safe: a doc with no footnotes is unchanged.
|
// Pure + idempotent + shape-safe: a doc with no footnotes is unchanged.
|
||||||
// (Future consolidation, architecture B: this import path persists directly
|
// (Future consolidation, architecture B: this import path persists directly
|
||||||
// via pageRepo.insertPage rather than through PageService.createPage, so the
|
// via pageRepo.insertPage rather than through PageService.createPage, so the
|
||||||
@@ -133,12 +137,15 @@ export class ImportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async processMarkdown(markdownInput: string): Promise<any> {
|
async processMarkdown(markdownInput: string): Promise<any> {
|
||||||
try {
|
// Canonical markdown -> ProseMirror JSON directly via
|
||||||
const html = await markdownToHtml(markdownInput);
|
// `@docmost/prosemirror-markdown` (issue #345) — no HTML intermediate and no
|
||||||
return this.processHTML(html);
|
// second editor-ext markdown layer. Foreign markdown surfaces the strict
|
||||||
} catch (err) {
|
// canonical parser does not accept (GFM `[^id]` reference footnotes) are
|
||||||
throw err;
|
// rewritten to the canonical inline form by `normalizeForeignMarkdown` first.
|
||||||
}
|
// The HTML-cleanup pass (`normalizeImportHtml`) is intentionally skipped here:
|
||||||
|
// it targets foreign *HTML* (Notion/XWiki), which only ever arrives on the
|
||||||
|
// `.html` path (`processHTML`), never as canonical markdown.
|
||||||
|
return markdownToProseMirror(normalizeForeignMarkdown(markdownInput));
|
||||||
}
|
}
|
||||||
|
|
||||||
async processHTML(htmlInput: string): Promise<any> {
|
async processHTML(htmlInput: string): Promise<any> {
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import {
|
||||||
|
convertProseMirrorToMarkdown,
|
||||||
|
markdownToProseMirror,
|
||||||
|
} from '@docmost/prosemirror-markdown';
|
||||||
|
import { normalizeForeignMarkdown } from './foreign-markdown';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STEP 2 goldens for issue #345: the foreign-markdown normalizer that runs at the
|
||||||
|
* import boundary BEFORE the strict canonical parser (`markdownToProseMirror`).
|
||||||
|
*
|
||||||
|
* Two layers:
|
||||||
|
* 1. PURE string→string cases pinning the normalizer's own behavior (GFM
|
||||||
|
* reference footnotes → inline `^[…]`).
|
||||||
|
* 2. END-TO-END acceptance: for a foreign corpus, `normalizeForeignMarkdown`
|
||||||
|
* then `markdownToProseMirror` then `convertProseMirrorToMarkdown` must leave
|
||||||
|
* NO literal `[^id]` / `:::` garbage in the document and must re-export in the
|
||||||
|
* canonical forms.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('normalizeForeignMarkdown — GFM reference footnotes', () => {
|
||||||
|
it('inlines a single-line reference footnote and drops its definition', () => {
|
||||||
|
const out = normalizeForeignMarkdown(
|
||||||
|
'A note[^1] here.\n\n[^1]: The definition.',
|
||||||
|
);
|
||||||
|
expect(out).toBe('A note^[The definition.] here.\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inlines every reference to a reused id (downstream dedups)', () => {
|
||||||
|
const out = normalizeForeignMarkdown(
|
||||||
|
'X[^a] and Y[^a].\n\n[^a]: shared.',
|
||||||
|
);
|
||||||
|
expect(out).toBe('X^[shared.] and Y^[shared.].\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joins indented continuation lines of a definition with a space', () => {
|
||||||
|
const out = normalizeForeignMarkdown(
|
||||||
|
'See[^n].\n\n[^n]: line one\n line two',
|
||||||
|
);
|
||||||
|
expect(out).toBe('See^[line one line two].\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never rewrites a reference inside a fenced code block', () => {
|
||||||
|
const out = normalizeForeignMarkdown(
|
||||||
|
'```\ncode[^1] here\n```\n\n[^1]: def.',
|
||||||
|
);
|
||||||
|
expect(out).toContain('code[^1] here');
|
||||||
|
// The (now orphaned) definition line is still removed.
|
||||||
|
expect(out).not.toContain('[^1]: def.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never rewrites a reference inside an INLINE-code span (backticks)', () => {
|
||||||
|
// The `[^1]` inside backticks is literal code and must survive verbatim;
|
||||||
|
// the one outside is rewritten. (Bug #1: only fenced blocks were protected.)
|
||||||
|
const out = normalizeForeignMarkdown(
|
||||||
|
'Use `arr[^1]` in code but note[^1] in prose.\n\n[^1]: def.',
|
||||||
|
);
|
||||||
|
expect(out).toBe('Use `arr[^1]` in code but note^[def.] in prose.\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes brackets in a body so an unbalanced ] cannot truncate the footnote', () => {
|
||||||
|
// A foreign definition body with a stray `]` would, unescaped, close the
|
||||||
|
// canonical `^[...]` early and leak the tail as text (bug #2). The body's
|
||||||
|
// brackets are backslash-escaped so the footnote stays whole.
|
||||||
|
const out = normalizeForeignMarkdown(
|
||||||
|
'Ref[^1] here.\n\n[^1]: see item ] and [more] later',
|
||||||
|
);
|
||||||
|
expect(out).toBe('Ref^[see item \\] and \\[more\\] later] here.\n');
|
||||||
|
// The tokenizer must see exactly one unescaped closing bracket (our own).
|
||||||
|
expect(out.match(/(?<!\\)\]/g)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a reference with no matching definition literal (no body to inline)', () => {
|
||||||
|
const out = normalizeForeignMarkdown('Dangling[^x] ref.');
|
||||||
|
expect(out).toBe('Dangling[^x] ref.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the input unchanged when there are no reference footnotes', () => {
|
||||||
|
const md = '# Title\n\nJust text with `inline code` and a [link](/x).';
|
||||||
|
expect(normalizeForeignMarkdown(md)).toBe(md);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT touch callout surfaces — the canonical parser handles them', () => {
|
||||||
|
const callouts = ':::info\nHi\n:::\n\n> [!warning]\n> Careful';
|
||||||
|
expect(normalizeForeignMarkdown(callouts)).toBe(callouts);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips a leading YAML front-matter block (Obsidian/Hugo/git-sync files)', () => {
|
||||||
|
const out = normalizeForeignMarkdown(
|
||||||
|
'---\ntitle: My Page\ntags: [a, b]\n---\n\n# Heading\n\nBody.',
|
||||||
|
);
|
||||||
|
expect(out).toBe('# Heading\n\nBody.');
|
||||||
|
// The front-matter must not leak into the body as a setext heading.
|
||||||
|
expect(out).not.toContain('title: My Page');
|
||||||
|
expect(out).not.toContain('---');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not strip a horizontal rule that is not leading front-matter', () => {
|
||||||
|
const md = 'Intro paragraph.\n\n---\n\nAfter the rule.';
|
||||||
|
expect(normalizeForeignMarkdown(md)).toBe(md);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is linear on a document with thousands of definitions (no quadratic blowup)', () => {
|
||||||
|
// F2(a): the pass-2 rewrite must be O(text), not O(text × defs). Build a
|
||||||
|
// pathological doc (many defs + many plain text lines) and assert it
|
||||||
|
// completes well under a second — a quadratic implementation took ~14s.
|
||||||
|
const N = 4000;
|
||||||
|
const refs = Array.from({ length: N }, (_, i) => `line ${i} plain text`).join('\n');
|
||||||
|
const defs = Array.from({ length: N }, (_, i) => `[^n${i}]: def ${i}`).join('\n');
|
||||||
|
const doc = `start[^n0] and[^n${N - 1}] end\n\n${refs}\n\n${defs}`;
|
||||||
|
const t0 = Date.now();
|
||||||
|
const out = normalizeForeignMarkdown(doc);
|
||||||
|
const elapsed = Date.now() - t0;
|
||||||
|
expect(elapsed).toBeLessThan(2000);
|
||||||
|
// Sanity: the two real references were still inlined.
|
||||||
|
expect(out).toContain('^[def 0]');
|
||||||
|
expect(out).toContain(`^[def ${N - 1}]`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is bounded on a long unclosed backtick run (no inline-split ReDoS)', () => {
|
||||||
|
// F2(b): a huge unterminated backtick run must not cause quadratic
|
||||||
|
// backtracking in the inline-code split. Oversized lines skip the split
|
||||||
|
// entirely (left untouched), so this returns promptly.
|
||||||
|
const line = 'x' + '`'.repeat(200000);
|
||||||
|
const doc = `${line}\n\n[^1]: def`;
|
||||||
|
const t0 = Date.now();
|
||||||
|
normalizeForeignMarkdown(doc);
|
||||||
|
expect(Date.now() - t0).toBeLessThan(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not crash or slow down on thousands of prefix-chain definition ids', () => {
|
||||||
|
// F7: the rewrite must use a FIXED generic scanner, not an alternation built
|
||||||
|
// from the ids. A `(a|aa|aaa|…)` alternation over prefix-chain ids blows the
|
||||||
|
// V8 regex compiler (FATAL RegExpCompiler Allocation failed — uncatchable,
|
||||||
|
// kills the process). A fixed scanner has no id-dependent compilation cost.
|
||||||
|
const N = 4000;
|
||||||
|
const ids = Array.from({ length: N }, (_, i) => 'a'.repeat(i + 1));
|
||||||
|
const defs = ids.map((id) => `[^${id}]: body ${id.length}`).join('\n');
|
||||||
|
const doc = `ref[^${ids[0]}] and[^${ids[N - 1]}] end\n\n${defs}`;
|
||||||
|
const t0 = Date.now();
|
||||||
|
const out = normalizeForeignMarkdown(doc);
|
||||||
|
expect(Date.now() - t0).toBeLessThan(2000);
|
||||||
|
// Prefix disambiguation is correct: [^a] and [^aaaa...] inline their OWN body.
|
||||||
|
expect(out).toContain('^[body 1]');
|
||||||
|
expect(out).toContain(`^[body ${N}]`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips a CRLF (Windows) front-matter block, not just LF', () => {
|
||||||
|
// F9: the line-anchored regex needs LF after the opening `---`, so a Windows
|
||||||
|
// file (`---\r\n…`) would slip past the strip and leak the front-matter into
|
||||||
|
// the body. normalizeForeignMarkdown normalizes CRLF -> LF first.
|
||||||
|
const out = normalizeForeignMarkdown(
|
||||||
|
'---\r\ntitle: Foo\r\ntags: [a]\r\n---\r\n\r\n# Heading\r\n\r\nBody.',
|
||||||
|
);
|
||||||
|
expect(out).toBe('# Heading\n\nBody.');
|
||||||
|
expect(out).not.toContain('title: Foo');
|
||||||
|
expect(out).not.toContain('---');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips front-matter whose value contains a triple-dash (line-anchored)', () => {
|
||||||
|
// F8: the block must close only on a `\n---` LINE, not the first inline
|
||||||
|
// `---`. A value like `title: Q1 --- Q2` must not truncate the front-matter
|
||||||
|
// and leak the rest (author/closing ---) into the body.
|
||||||
|
const out = normalizeForeignMarkdown(
|
||||||
|
'---\ntitle: Q1 --- Q2 results\nauthor: bob\n---\n\nReal body.',
|
||||||
|
);
|
||||||
|
expect(out).toBe('Real body.');
|
||||||
|
expect(out).not.toContain('author: bob');
|
||||||
|
expect(out).not.toContain('Q2 results');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('foreign markdown import acceptance (normalizer + canonical parser)', () => {
|
||||||
|
const FOREIGN = [
|
||||||
|
'# Doc',
|
||||||
|
'',
|
||||||
|
'Body refs [^c] and [^a] and [^b] and again [^a].',
|
||||||
|
'',
|
||||||
|
':::info',
|
||||||
|
'A legacy callout.',
|
||||||
|
':::',
|
||||||
|
'',
|
||||||
|
'| h1 | h2 |',
|
||||||
|
'| --- | --- |',
|
||||||
|
'| 1 | 2 |',
|
||||||
|
'',
|
||||||
|
'[^a]: note A',
|
||||||
|
'[^b]: note B',
|
||||||
|
'[^c]: note C',
|
||||||
|
'[^z]: orphan note',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
it('leaves no literal [^id] or ::: in the imported doc and re-exports canonically', async () => {
|
||||||
|
const normalized = normalizeForeignMarkdown(FOREIGN);
|
||||||
|
const doc = await markdownToProseMirror(normalized);
|
||||||
|
const reexport = convertProseMirrorToMarkdown(doc);
|
||||||
|
|
||||||
|
// No foreign garbage leaks into the document.
|
||||||
|
expect(reexport).not.toMatch(/\[\^/); // no reference footnote refs/defs
|
||||||
|
expect(reexport).not.toContain(':::'); // no legacy callout fences
|
||||||
|
|
||||||
|
// Canonical forms are present.
|
||||||
|
expect(reexport).toContain('^[note C]');
|
||||||
|
expect(reexport).toContain('> [!info]');
|
||||||
|
expect(reexport).toContain('| h1 | h2 |');
|
||||||
|
|
||||||
|
// Footnotes: ordered by first reference (C, A, B), reused [^a] deduped to one,
|
||||||
|
// orphan [^z] dropped (it had no reference after normalization).
|
||||||
|
const list = doc.content.find((n: any) => n.type === 'footnotesList');
|
||||||
|
const bodies = list.content.map(
|
||||||
|
(d: any) => d.content[0].content[0].text,
|
||||||
|
);
|
||||||
|
expect(bodies).toEqual(['note C', 'note A', 'note B']);
|
||||||
|
expect(bodies).not.toContain('orphan note');
|
||||||
|
expect(
|
||||||
|
doc.content.filter((n: any) => n.type === 'footnotesList'),
|
||||||
|
).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
/**
|
||||||
|
* Foreign-markdown normalizer — an input-liberal / output-canonical adapter that
|
||||||
|
* runs at the IMPORT boundary, BEFORE the canonical parser
|
||||||
|
* (`markdownToProseMirror` from `@docmost/prosemirror-markdown`).
|
||||||
|
*
|
||||||
|
* The canonical parser is deliberately STRICT: it only understands Docmost's
|
||||||
|
* canonical markdown surface (Obsidian-style `> [!type]` callouts, Pandoc/Obsidian
|
||||||
|
* inline footnotes `^[body]`, lossless ` <!--img {...}-->` images, …).
|
||||||
|
* Import, however, ingests FOREIGN files (GitHub/GFM, Notion, old Docmost
|
||||||
|
* exports). Those use surfaces the canonical parser does not accept, most notably
|
||||||
|
* GitHub-flavoured *reference* footnotes:
|
||||||
|
*
|
||||||
|
* Text with a note[^1] and another[^long].
|
||||||
|
*
|
||||||
|
* [^1]: The first definition.
|
||||||
|
* [^long]: A second one.
|
||||||
|
*
|
||||||
|
* Left untouched, the parser does NOT recognise `[^id]` (it only parses `^[body]`),
|
||||||
|
* so the reference leaks as literal text — and worse, the trailing `[^id]: def`
|
||||||
|
* line is a valid CommonMark *link-reference definition*, so `[^id]` is silently
|
||||||
|
* rendered as a bogus link. This normalizer rewrites reference footnotes into the
|
||||||
|
* canonical inline form so the parser materialises real footnote nodes.
|
||||||
|
*
|
||||||
|
* This is a TEXT pre-pass, NOT a second parser fork: it does not re-implement any
|
||||||
|
* converter logic. Callout surfaces (`:::type` and `> [!type]`) are intentionally
|
||||||
|
* NOT touched here — the canonical parser already accepts BOTH natively (its
|
||||||
|
* `preprocessCallouts` pass), so normalizing them would be redundant and would
|
||||||
|
* only risk degrading the parser's nesting/code-fence-aware handling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Matches a fenced code block delimiter (``` or ~~~), capturing the marker run. */
|
||||||
|
const CODE_FENCE_RE = /^(\s*)(`{3,}|~{3,})/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches a GFM footnote DEFINITION line: `[^id]: body`. The id is any run of
|
||||||
|
* non-`]` characters; the body is the remainder of the line (possibly empty).
|
||||||
|
*/
|
||||||
|
const FOOTNOTE_DEF_RE = /^\[\^([^\]]+)\]:[ \t]?(.*)$/;
|
||||||
|
|
||||||
|
/** True when a line is a code-fence delimiter that toggles fenced-code state. */
|
||||||
|
function fenceMarker(line: string): string | null {
|
||||||
|
const m = line.match(CODE_FENCE_RE);
|
||||||
|
return m ? m[2] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a line is indented (leading space/tab) and not blank — a continuation. */
|
||||||
|
function isIndentedContinuation(line: string): boolean {
|
||||||
|
return /^[ \t]+\S/.test(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(value: string): string {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backslash-escape any square bracket in a footnote body before it is wrapped in
|
||||||
|
* `^[...]`. The canonical inline-footnote tokenizer scans the body with bracket
|
||||||
|
* balancing and closes on the first UNMATCHED `]`, so an unbalanced bracket in a
|
||||||
|
* foreign definition (e.g. `[^1]: see item ] later`) would otherwise truncate the
|
||||||
|
* footnote and leak the tail as literal text. Escaping every `[`/`]` makes the
|
||||||
|
* body an inert run of characters — the tokenizer then closes only on our own
|
||||||
|
* closing `]`. (A balanced `[link](url)` inside a body still round-trips because
|
||||||
|
* the escaped form renders the literal brackets, which is the safe reading for a
|
||||||
|
* footnote body; the alternative — brittle balance tracking — risks worse.)
|
||||||
|
*/
|
||||||
|
function escapeFootnoteBody(body: string): string {
|
||||||
|
return body.replace(/[[\]]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite every `[^id]` reference on a line to its `^[body]` form, but ONLY in the
|
||||||
|
* text OUTSIDE inline-code spans. A `[^id]` inside backticks is literal code
|
||||||
|
* content and must be preserved verbatim (a footnote ref never lives inside code).
|
||||||
|
* We split the line on inline-code spans (paired backtick runs) and rewrite only
|
||||||
|
* the non-code segments.
|
||||||
|
*/
|
||||||
|
// Above this length a single line is not split into inline-code spans (see
|
||||||
|
// below). A genuine markdown line carrying a footnote reference is never tens of
|
||||||
|
// KB; the cap only bypasses the inline-code protection for pathological lines.
|
||||||
|
const INLINE_SPLIT_MAX_LINE = 8192;
|
||||||
|
|
||||||
|
function rewriteRefsOutsideInlineCode(
|
||||||
|
line: string,
|
||||||
|
replace: (text: string) => string,
|
||||||
|
): string {
|
||||||
|
// The inline-code split alternation `(`+)(?:(?!\1)[\s\S])*\1` backtracks
|
||||||
|
// quadratically on a long UNCLOSED backtick run (its middle can consume the
|
||||||
|
// rest of the line, then fail to find a closing run and retry from each
|
||||||
|
// position). On an untrusted import this is a request-thread ReDoS. A real
|
||||||
|
// footnote line is short, so for an oversized line we skip the inline-code
|
||||||
|
// protection entirely and leave the line UNTOUCHED (rewriting it wholesale
|
||||||
|
// could corrupt a `[^id]` that legitimately lives inside inline code). This is
|
||||||
|
// a conservative bypass: an over-8KB line simply does not get its reference
|
||||||
|
// footnotes inlined — acceptable for a pathological input.
|
||||||
|
if (line.length > INLINE_SPLIT_MAX_LINE) return line;
|
||||||
|
|
||||||
|
// Alternation: an inline-code span (one or more backticks, then anything up to
|
||||||
|
// the SAME run of backticks) OR a run of non-backtick text. Unterminated
|
||||||
|
// backticks fall through as ordinary text (matched by the second branch on the
|
||||||
|
// leftover), so a stray backtick never swallows the rest of the line.
|
||||||
|
const parts = line.match(/(`+)(?:(?!\1)[\s\S])*\1|[^`]+|`+/g);
|
||||||
|
if (!parts) return line;
|
||||||
|
return parts
|
||||||
|
.map((seg) => (seg.startsWith('`') ? seg : replace(seg)))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert GFM reference footnotes (`[^id]` + `[^id]: def`) into canonical inline
|
||||||
|
* footnotes (`^[def]`).
|
||||||
|
*
|
||||||
|
* - Definitions are collected first (a leading `[^id]: text` line plus any
|
||||||
|
* immediately-following indented continuation lines, joined with a space) and
|
||||||
|
* removed from the output.
|
||||||
|
* - Each in-text reference `[^id]` for which a definition was found is replaced by
|
||||||
|
* `^[def]`. References with no matching definition are left literal (there is no
|
||||||
|
* body to inline; the parser fails them open the same way).
|
||||||
|
* - Code is respected on both passes: `[^id]` inside a fenced ``` / ~~~ block is
|
||||||
|
* never rewritten and a `[^id]:` line inside a fence is never a definition; and
|
||||||
|
* on the rewrite pass a `[^id]` inside an INLINE-code span (backticks) is left
|
||||||
|
* literal too.
|
||||||
|
* - The inlined body is bracket-escaped so an unbalanced `[`/`]` in a foreign
|
||||||
|
* definition cannot truncate the resulting `^[...]` footnote.
|
||||||
|
*
|
||||||
|
* Deduplication / reference-ordering / orphan-dropping of the resulting footnotes
|
||||||
|
* is handled downstream by the canonical parser (`assembleFootnotes`); this pass
|
||||||
|
* only changes the surface syntax.
|
||||||
|
*/
|
||||||
|
function convertReferenceFootnotes(markdown: string): string {
|
||||||
|
const lines = markdown.split('\n');
|
||||||
|
|
||||||
|
// Pass 1: collect definitions and mark their lines for removal.
|
||||||
|
const defs = new Map<string, string>();
|
||||||
|
const dropped = new Array<boolean>(lines.length).fill(false);
|
||||||
|
let inFence = false;
|
||||||
|
let fence = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
const marker = fenceMarker(line);
|
||||||
|
if (inFence) {
|
||||||
|
if (marker && marker[0] === fence[0] && marker.length >= fence.length) {
|
||||||
|
inFence = false;
|
||||||
|
fence = '';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (marker) {
|
||||||
|
inFence = true;
|
||||||
|
fence = marker;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const def = line.match(FOOTNOTE_DEF_RE);
|
||||||
|
if (!def) continue;
|
||||||
|
|
||||||
|
const id = def[1];
|
||||||
|
const body: string[] = [def[2].trim()];
|
||||||
|
dropped[i] = true;
|
||||||
|
|
||||||
|
// Consume immediately-following indented continuation lines (GFM lazy
|
||||||
|
// continuation is not supported by design — keep it simple and predictable).
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < lines.length && isIndentedContinuation(lines[j])) {
|
||||||
|
body.push(lines[j].trim());
|
||||||
|
dropped[j] = true;
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
i = j - 1;
|
||||||
|
|
||||||
|
// Last definition wins for a duplicated id (matches CommonMark link-ref
|
||||||
|
// semantics closely enough for a foreign-input adapter).
|
||||||
|
defs.set(id, body.filter((s) => s.length > 0).join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defs.size === 0) {
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ONE fixed, generic scanner regex — NOT one built from the definition ids.
|
||||||
|
// It matches ANY `[^id]` shape, and the replacer decides per match via a map
|
||||||
|
// lookup whether that id is a real definition (replace) or not (leave as-is).
|
||||||
|
// This is genuinely O(total text) with no per-document regex compilation.
|
||||||
|
//
|
||||||
|
// Do NOT rebuild this as an alternation over `[...defs.keys()]`: a giant
|
||||||
|
// `(id1|id2|...)` alternation over thousands of ids can blow the V8 regex
|
||||||
|
// compiler's stack — a fatal, UNCATCHABLE "RegExpCompiler Allocation failed"
|
||||||
|
// on prefix-chain ids (`a`, `aa`, `aaa`, ...) that kills the whole process
|
||||||
|
// (worse than the earlier per-def thread-hang). A fixed scanner has no
|
||||||
|
// id-dependent compilation cost and cannot blow up.
|
||||||
|
const refRe = /\[\^([^\]]+)\]/g;
|
||||||
|
const rewriteSegment = (segment: string): string =>
|
||||||
|
segment.replace(refRe, (whole, id: string) => {
|
||||||
|
const body = defs.get(id);
|
||||||
|
// Only real definitions are inlined; an unknown id is left literal (same as
|
||||||
|
// the old per-def loop, which simply never matched it).
|
||||||
|
return body === undefined ? whole : `^[${escapeFootnoteBody(body)}]`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pass 2: rewrite in-text references, skipping fenced code and dropped lines.
|
||||||
|
const out: string[] = [];
|
||||||
|
inFence = false;
|
||||||
|
fence = '';
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
if (dropped[i]) continue;
|
||||||
|
let line = lines[i];
|
||||||
|
|
||||||
|
const marker = fenceMarker(line);
|
||||||
|
if (inFence) {
|
||||||
|
out.push(line);
|
||||||
|
if (marker && marker[0] === fence[0] && marker.length >= fence.length) {
|
||||||
|
inFence = false;
|
||||||
|
fence = '';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (marker) {
|
||||||
|
inFence = true;
|
||||||
|
fence = marker;
|
||||||
|
out.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
line = rewriteRefsOutsideInlineCode(line, rewriteSegment);
|
||||||
|
out.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip a single leading YAML front-matter block (`---\n…\n---`). Foreign files
|
||||||
|
* from Obsidian / Hugo / Jekyll / Notion — and Docmost's OWN git-sync page files
|
||||||
|
* — open with front-matter that the canonical parser does not consume, so
|
||||||
|
* without this it leaks into the body (and `title: Foo` above the closing `---`
|
||||||
|
* renders as a setext `<h2>` that `extractTitleAndRemoveHeading` can hijack as
|
||||||
|
* the page title). It is a no-op for front-matter-free input.
|
||||||
|
*
|
||||||
|
* LINE-ANCHORED (the same shape the canonical parser uses in
|
||||||
|
* prosemirror-markdown/page-file.ts): the block opens only on `---\n` at the
|
||||||
|
* very start and closes only on a `\n---` line. The retired `markdownToHtml`
|
||||||
|
* strip closed on the FIRST `---` ANYWHERE (an unanchored close), so a value
|
||||||
|
* containing a triple-dash (e.g. `title: Q1 --- Q2`) truncated the front-matter
|
||||||
|
* and leaked the rest into the body. An optional leading BOM is tolerated.
|
||||||
|
*/
|
||||||
|
const YAML_FRONT_MATTER_RE = /^\uFEFF?---\n[\s\S]*?\n---\n?/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a foreign markdown string into Docmost's canonical markdown surface
|
||||||
|
* so the strict canonical parser accepts it losslessly: normalize line endings,
|
||||||
|
* strip a leading YAML front-matter block, then rewrite GFM reference footnotes
|
||||||
|
* into inline footnotes. Add further fixture-driven foreign-surface cases here as
|
||||||
|
* they are found.
|
||||||
|
*/
|
||||||
|
export function normalizeForeignMarkdown(markdown: string): string {
|
||||||
|
if (!markdown) return markdown;
|
||||||
|
// Normalize CRLF -> LF FIRST. The line-anchored front-matter regex requires a
|
||||||
|
// bare `\n` after the opening `---`, and convertReferenceFootnotes splits on
|
||||||
|
// `\n`; a Windows/CRLF foreign file (`---\r\n…`) would otherwise slip past the
|
||||||
|
// front-matter strip and leak into the body. The canonical parser
|
||||||
|
// (page-file.ts parsePageFile) normalizes the same way before its FRONTMATTER_RE.
|
||||||
|
const src = markdown.replace(/\r\n/g, '\n');
|
||||||
|
const withoutFrontMatter = src.replace(YAML_FRONT_MATTER_RE, '').trimStart();
|
||||||
|
return convertReferenceFootnotes(withoutFrontMatter);
|
||||||
|
}
|
||||||
@@ -2,15 +2,36 @@ import { FastifyReply, FastifyRequest } from 'fastify';
|
|||||||
import { isStreamingResponse } from './metrics.constants';
|
import { isStreamingResponse } from './metrics.constants';
|
||||||
import { observeHttp } from './metrics.registry';
|
import { observeHttp } from './metrics.registry';
|
||||||
|
|
||||||
|
// URL path prefixes served by @fastify/static (client build output under
|
||||||
|
// client/dist). `/assets/` holds the content-hashed bundle (index-*.js,
|
||||||
|
// chunk-*.js) — a NEW set of names every deploy, i.e. an UNBOUNDED label set
|
||||||
|
// (#362); the others (/vad/, /brand/, /locales/, /icons/ — copied verbatim from
|
||||||
|
// public/) have stable names, so they are merely repetitive per-file labels
|
||||||
|
// rather than unbounded. Either way none of these belong in the API-route
|
||||||
|
// histogram: collapse them all to one bounded `static` label. (Edge latency for
|
||||||
|
// static is already measured by Traefik's traefik_router_request_duration_*.)
|
||||||
|
const STATIC_PATH_PREFIXES = [
|
||||||
|
'/assets/',
|
||||||
|
'/vad/',
|
||||||
|
'/brand/',
|
||||||
|
'/locales/',
|
||||||
|
'/icons/',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the BOUNDED route label for an HTTP response.
|
* Resolve the BOUNDED route label for an HTTP response.
|
||||||
*
|
*
|
||||||
* HARD REQUIREMENT (#355): use the ROUTE TEMPLATE (`/pages/:id`), NEVER the raw
|
* HARD REQUIREMENT (#355): use the ROUTE TEMPLATE (`/pages/:id`), NEVER a raw
|
||||||
* URL (`/pages/abc-123`), so label cardinality stays finite. Fastify exposes the
|
* URL (`/pages/abc-123` or `/assets/index-CAbxDtto.js`), so label cardinality
|
||||||
* matched template on `req.routeOptions.url`. On 404s (no route matched) that is
|
* stays finite. Fastify exposes the matched template on `req.routeOptions.url`,
|
||||||
* missing → collapse to the literal `unknown`.
|
* BUT @fastify/static serves each file through a route whose matched url is the
|
||||||
|
* raw (hashed) file path — so for static assets that value is itself unbounded.
|
||||||
|
* Detect static requests by their path prefix FIRST and collapse to `static`;
|
||||||
|
* otherwise use the route template; on a 404 (no route matched) → `unknown`.
|
||||||
*/
|
*/
|
||||||
export function resolveRouteLabel(req: FastifyRequest): string {
|
export function resolveRouteLabel(req: FastifyRequest): string {
|
||||||
|
const path = (req.url ?? '').split('?', 1)[0];
|
||||||
|
if (STATIC_PATH_PREFIXES.some((p) => path.startsWith(p))) return 'static';
|
||||||
const url = req.routeOptions?.url;
|
const url = req.routeOptions?.url;
|
||||||
return typeof url === 'string' && url.length > 0 ? url : 'unknown';
|
return typeof url === 'string' && url.length > 0 ? url : 'unknown';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ export class MetricsBullService implements OnModuleInit, OnModuleDestroy {
|
|||||||
@InjectQueue(QueueName.GENERAL_QUEUE) generalQueue: Queue,
|
@InjectQueue(QueueName.GENERAL_QUEUE) generalQueue: Queue,
|
||||||
@InjectQueue(QueueName.BILLING_QUEUE) billingQueue: Queue,
|
@InjectQueue(QueueName.BILLING_QUEUE) billingQueue: Queue,
|
||||||
@InjectQueue(QueueName.FILE_TASK_QUEUE) fileTaskQueue: Queue,
|
@InjectQueue(QueueName.FILE_TASK_QUEUE) fileTaskQueue: Queue,
|
||||||
@InjectQueue(QueueName.SEARCH_QUEUE) searchQueue: Queue,
|
|
||||||
@InjectQueue(QueueName.AI_QUEUE) aiQueue: Queue,
|
@InjectQueue(QueueName.AI_QUEUE) aiQueue: Queue,
|
||||||
@InjectQueue(QueueName.HISTORY_QUEUE) historyQueue: Queue,
|
@InjectQueue(QueueName.HISTORY_QUEUE) historyQueue: Queue,
|
||||||
@InjectQueue(QueueName.NOTIFICATION_QUEUE) notificationQueue: Queue,
|
@InjectQueue(QueueName.NOTIFICATION_QUEUE) notificationQueue: Queue,
|
||||||
@@ -58,7 +57,6 @@ export class MetricsBullService implements OnModuleInit, OnModuleDestroy {
|
|||||||
{ label: 'general', queue: generalQueue },
|
{ label: 'general', queue: generalQueue },
|
||||||
{ label: 'billing', queue: billingQueue },
|
{ label: 'billing', queue: billingQueue },
|
||||||
{ label: 'file-task', queue: fileTaskQueue },
|
{ label: 'file-task', queue: fileTaskQueue },
|
||||||
{ label: 'search', queue: searchQueue },
|
|
||||||
{ label: 'ai', queue: aiQueue },
|
{ label: 'ai', queue: aiQueue },
|
||||||
{ label: 'history', queue: historyQueue },
|
{ label: 'history', queue: historyQueue },
|
||||||
{ label: 'notification', queue: notificationQueue },
|
{ label: 'notification', queue: notificationQueue },
|
||||||
|
|||||||
@@ -25,6 +25,61 @@ describe('resolveRouteLabel (histogram route label)', () => {
|
|||||||
const req = { url: '/x' } as unknown as FastifyRequest;
|
const req = { url: '/x' } as unknown as FastifyRequest;
|
||||||
expect(resolveRouteLabel(req)).toBe('unknown');
|
expect(resolveRouteLabel(req)).toBe('unknown');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'/assets/index-CAbxDtto.js',
|
||||||
|
'/assets/chunk-3OPIFGDE-CJOt9nr5.js',
|
||||||
|
'/assets/excalidraw-menu-DpsI0kFW.js',
|
||||||
|
'/vad/silero_vad_v5.onnx',
|
||||||
|
'/brand/logo.svg',
|
||||||
|
'/locales/en.json',
|
||||||
|
'/icons/app-icon-192x192.png',
|
||||||
|
])('collapses hashed/static asset %p to "static" (#362 cardinality)', (url) => {
|
||||||
|
// @fastify/static serves each file through a route whose matched url is the
|
||||||
|
// raw (hashed) file path, so routeOptions.url is itself unbounded here.
|
||||||
|
const req = {
|
||||||
|
url,
|
||||||
|
routeOptions: { url },
|
||||||
|
} as unknown as FastifyRequest;
|
||||||
|
const label = resolveRouteLabel(req);
|
||||||
|
expect(label).toBe('static');
|
||||||
|
expect(label).not.toContain('.js');
|
||||||
|
expect(label).not.toContain('index-');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips the query string before the static-prefix check', () => {
|
||||||
|
const req = {
|
||||||
|
url: '/assets/index-CAbxDtto.js?v=2',
|
||||||
|
routeOptions: { url: '/assets/index-CAbxDtto.js' },
|
||||||
|
} as unknown as FastifyRequest;
|
||||||
|
expect(resolveRouteLabel(req)).toBe('static');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT collapse a real API route that merely mentions assets', () => {
|
||||||
|
// A templated API route is kept as-is; only the static path PREFIXES collapse.
|
||||||
|
const req = {
|
||||||
|
url: '/api/pages/assets-guide',
|
||||||
|
routeOptions: { url: '/api/pages/:id' },
|
||||||
|
} as unknown as FastifyRequest;
|
||||||
|
expect(resolveRouteLabel(req)).toBe('/api/pages/:id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
// The TRAILING SLASH on the prefix is the anti-false-collapse guard: a path
|
||||||
|
// that is the prefix WITHOUT its slash, or merely shares the prefix as a
|
||||||
|
// substring of a longer segment, must NOT collapse. These would collapse
|
||||||
|
// under a buggy `includes('/assets/')` / slashless-prefix impl.
|
||||||
|
'/assets',
|
||||||
|
'/assetsx/foo.js',
|
||||||
|
'/iconset/x.png',
|
||||||
|
])('does NOT collapse the prefix-boundary case %p', (url) => {
|
||||||
|
const req = {
|
||||||
|
url,
|
||||||
|
routeOptions: { url: '/some/:route' },
|
||||||
|
} as unknown as FastifyRequest;
|
||||||
|
expect(resolveRouteLabel(req)).not.toBe('static');
|
||||||
|
expect(resolveRouteLabel(req)).toBe('/some/:route');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isStreamingResponse (SSE exclusion)', () => {
|
describe('isStreamingResponse (SSE exclusion)', () => {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ export enum QueueName {
|
|||||||
GENERAL_QUEUE = '{general-queue}',
|
GENERAL_QUEUE = '{general-queue}',
|
||||||
BILLING_QUEUE = '{billing-queue}',
|
BILLING_QUEUE = '{billing-queue}',
|
||||||
FILE_TASK_QUEUE = '{file-task-queue}',
|
FILE_TASK_QUEUE = '{file-task-queue}',
|
||||||
SEARCH_QUEUE = '{search-queue}',
|
|
||||||
AI_QUEUE = '{ai-queue}',
|
AI_QUEUE = '{ai-queue}',
|
||||||
HISTORY_QUEUE = '{history-queue}',
|
HISTORY_QUEUE = '{history-queue}',
|
||||||
NOTIFICATION_QUEUE = '{notification-queue}',
|
NOTIFICATION_QUEUE = '{notification-queue}',
|
||||||
@@ -32,12 +31,6 @@ export enum QueueJob {
|
|||||||
IMPORT_TASK = 'import-task',
|
IMPORT_TASK = 'import-task',
|
||||||
EXPORT_TASK = 'export-task',
|
EXPORT_TASK = 'export-task',
|
||||||
|
|
||||||
SEARCH_INDEX_PAGE = 'search-index-page',
|
|
||||||
SEARCH_INDEX_PAGES = 'search-index-pages',
|
|
||||||
SEARCH_INDEX_COMMENT = 'search-index-comment',
|
|
||||||
SEARCH_INDEX_COMMENTS = 'search-index-comments',
|
|
||||||
SEARCH_INDEX_ATTACHMENT = 'search-index-attachment',
|
|
||||||
SEARCH_INDEX_ATTACHMENTS = 'search-index-attachments',
|
|
||||||
SEARCH_REMOVE_PAGE = 'search-remove-page',
|
SEARCH_REMOVE_PAGE = 'search-remove-page',
|
||||||
SEARCH_REMOVE_ASSET = 'search-remove-attachment',
|
SEARCH_REMOVE_ASSET = 'search-remove-attachment',
|
||||||
SEARCH_REMOVE_FACE = 'search-remove-comment',
|
SEARCH_REMOVE_FACE = 'search-remove-comment',
|
||||||
|
|||||||
@@ -57,14 +57,6 @@ import { GeneralQueueProcessor } from './processors/general-queue.processor';
|
|||||||
attempts: 1,
|
attempts: 1,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
BullModule.registerQueue({
|
|
||||||
name: QueueName.SEARCH_QUEUE,
|
|
||||||
defaultJobOptions: {
|
|
||||||
removeOnComplete: true,
|
|
||||||
removeOnFail: true,
|
|
||||||
attempts: 2,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
BullModule.registerQueue({
|
BullModule.registerQueue({
|
||||||
name: QueueName.AI_QUEUE,
|
name: QueueName.AI_QUEUE,
|
||||||
defaultJobOptions: {
|
defaultJobOptions: {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { resolveStaticAssetHeaders } from './static.module';
|
||||||
|
|
||||||
|
// Unit tests for the static-asset cache classifier extracted from the
|
||||||
|
// @fastify/static setHeaders callback (precedent: sandbox.controller.spec.ts).
|
||||||
|
describe('resolveStaticAssetHeaders', () => {
|
||||||
|
it('marks a content-hashed /assets/ file immutable and sets Vary', () => {
|
||||||
|
const headers = resolveStaticAssetHeaders(
|
||||||
|
'/app/apps/client/dist/assets/index-a1b2c3.js',
|
||||||
|
);
|
||||||
|
expect(headers['cache-control']).toBe(
|
||||||
|
'public, max-age=31536000, immutable',
|
||||||
|
);
|
||||||
|
expect(headers['vary']).toBe('Accept-Encoding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes index.html always revalidate (never immutable)', () => {
|
||||||
|
const headers = resolveStaticAssetHeaders(
|
||||||
|
'/app/apps/client/dist/index.html',
|
||||||
|
);
|
||||||
|
expect(headers['cache-control']).toBe(
|
||||||
|
'no-cache, no-store, must-revalidate',
|
||||||
|
);
|
||||||
|
expect(headers['vary']).toBe('Accept-Encoding');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT mark a non-hashed asset immutable but still sets Vary', () => {
|
||||||
|
const headers = resolveStaticAssetHeaders(
|
||||||
|
'/app/apps/client/dist/locales/en.json',
|
||||||
|
);
|
||||||
|
// No immutable cache-control — this path keeps @fastify/static's default
|
||||||
|
// etag/last-modified revalidation.
|
||||||
|
expect(headers['cache-control']).toBeUndefined();
|
||||||
|
expect(headers['vary']).toBe('Accept-Encoding');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,46 @@ import * as fs from 'node:fs';
|
|||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import { EnvironmentService } from '../environment/environment.service';
|
import { EnvironmentService } from '../environment/environment.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the response headers for a statically served client asset.
|
||||||
|
*
|
||||||
|
* Extracted from the @fastify/static `setHeaders` callback so the cache
|
||||||
|
* classification stays a pure, unit-testable function (see
|
||||||
|
* static.module.spec.ts).
|
||||||
|
*
|
||||||
|
* `Vary: Accept-Encoding` is emitted for every static response because
|
||||||
|
* @fastify/static negotiates a precompressed .br/.gz neighbour by the client's
|
||||||
|
* Accept-Encoding but does NOT set Vary itself. Without it a shared/proxy cache
|
||||||
|
* keyed on the URL alone could store the brotli variant and later serve it to a
|
||||||
|
* client that only sent `Accept-Encoding: identity`/gzip → an undecodable body.
|
||||||
|
* This matters most for the immutable /assets/ files, which proxies may keep
|
||||||
|
* for a year.
|
||||||
|
*/
|
||||||
|
export function resolveStaticAssetHeaders(
|
||||||
|
filePath: string,
|
||||||
|
): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = { vary: 'Accept-Encoding' };
|
||||||
|
|
||||||
|
// Content-hashed files under /assets/ never change for a given URL, so they
|
||||||
|
// can be cached forever and skip revalidation entirely.
|
||||||
|
if (filePath.includes('/assets/')) {
|
||||||
|
headers['cache-control'] = 'public, max-age=31536000, immutable';
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// index.html is rewritten at boot (window.CONFIG injection) and on every
|
||||||
|
// deploy — it must be revalidated on every load.
|
||||||
|
if (filePath.endsWith('index.html')) {
|
||||||
|
headers['cache-control'] = 'no-cache, no-store, must-revalidate';
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else (locales, vad, icons, manifest) is NOT content-hashed and
|
||||||
|
// changes between deploys, so it keeps @fastify/static's default
|
||||||
|
// etag/last-modified revalidation — do NOT mark it immutable.
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
@Module({})
|
@Module({})
|
||||||
export class StaticModule implements OnModuleInit {
|
export class StaticModule implements OnModuleInit {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -72,6 +112,16 @@ export class StaticModule implements OnModuleInit {
|
|||||||
await app.register(fastifyStatic, {
|
await app.register(fastifyStatic, {
|
||||||
root: clientDistPath,
|
root: clientDistPath,
|
||||||
wildcard: false,
|
wildcard: false,
|
||||||
|
// Serve the build-time .br/.gz neighbour when the client accepts it
|
||||||
|
// (see vite-plugin-compression2 in apps/client/vite.config.ts).
|
||||||
|
preCompressed: true,
|
||||||
|
setHeaders: (res, filePath) => {
|
||||||
|
for (const [name, value] of Object.entries(
|
||||||
|
resolveStaticAssetHeaders(filePath),
|
||||||
|
)) {
|
||||||
|
res.setHeader(name, value);
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get(RENDER_PATH, (req: any, res: any) => {
|
app.get(RENDER_PATH, (req: any, res: any) => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { TransformHttpResponseInterceptor } from './common/interceptors/http-res
|
|||||||
import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter';
|
import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter';
|
||||||
import fastifyMultipart from '@fastify/multipart';
|
import fastifyMultipart from '@fastify/multipart';
|
||||||
import fastifyCookie from '@fastify/cookie';
|
import fastifyCookie from '@fastify/cookie';
|
||||||
|
import fastifyCompress from '@fastify/compress';
|
||||||
import fastifyIp from 'fastify-ip';
|
import fastifyIp from 'fastify-ip';
|
||||||
import { InternalLogFilter } from './common/logger/internal-log-filter';
|
import { InternalLogFilter } from './common/logger/internal-log-filter';
|
||||||
import { EnvironmentService } from './integrations/environment/environment.service';
|
import { EnvironmentService } from './integrations/environment/environment.service';
|
||||||
@@ -63,6 +64,17 @@ async function bootstrap() {
|
|||||||
await app.register(fastifyIp);
|
await app.register(fastifyIp);
|
||||||
await app.register(fastifyMultipart);
|
await app.register(fastifyMultipart);
|
||||||
await app.register(fastifyCookie);
|
await app.register(fastifyCookie);
|
||||||
|
// Compress dynamic responses (API JSON, the rewritten share-SEO HTML) when the
|
||||||
|
// client accepts br/gzip. @fastify/compress only compresses content-types that
|
||||||
|
// mime-db flags `compressible` (application/json, text/html, …); `text/event-stream`
|
||||||
|
// is not in mime-db, so SSE is never compressed by the allowlist. The AI-chat
|
||||||
|
// stream additionally hijacks the raw socket (pipeUIMessageStreamToResponse ->
|
||||||
|
// res.raw in ai-chat.service.ts), bypassing Fastify's reply/onSend lifecycle
|
||||||
|
// entirely, so this hook can never buffer that stream.
|
||||||
|
await app.register(fastifyCompress, {
|
||||||
|
// Skip tiny payloads where compression overhead outweighs the savings.
|
||||||
|
threshold: 1024,
|
||||||
|
});
|
||||||
|
|
||||||
const environmentService = app.get(EnvironmentService);
|
const environmentService = app.get(EnvironmentService);
|
||||||
const frameHeader = resolveFrameHeader(
|
const frameHeader = resolveFrameHeader(
|
||||||
|
|||||||
@@ -38,6 +38,24 @@ export const TEST_DATABASE_URL =
|
|||||||
process.env.TEST_DATABASE_URL ??
|
process.env.TEST_DATABASE_URL ??
|
||||||
'postgresql://docmost:docmost_dev_pw@localhost:5432/docmost_test';
|
'postgresql://docmost:docmost_dev_pw@localhost:5432/docmost_test';
|
||||||
|
|
||||||
|
// Build the raw postgres.js client (mirrors database.module.ts: max pool,
|
||||||
|
// silenced notices, bigint-as-number parsing). Kept separate so the singleton
|
||||||
|
// can hold a reference to bound its shutdown in destroyTestDb.
|
||||||
|
function buildTestSql(url: string = TEST_DATABASE_URL) {
|
||||||
|
return postgres(url, {
|
||||||
|
max: 5,
|
||||||
|
onnotice: () => {},
|
||||||
|
types: {
|
||||||
|
bigint: {
|
||||||
|
to: 20,
|
||||||
|
from: [20, 1700],
|
||||||
|
serialize: (value: number) => value.toString(),
|
||||||
|
parse: (value: string) => Number.parseInt(value),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a Kysely instance that MIRRORS the app's setup in database.module.ts:
|
* Build a Kysely instance that MIRRORS the app's setup in database.module.ts:
|
||||||
* PostgresJSDialect over postgres(), CamelCasePlugin, and the bigint type
|
* PostgresJSDialect over postgres(), CamelCasePlugin, and the bigint type
|
||||||
@@ -47,38 +65,40 @@ export const TEST_DATABASE_URL =
|
|||||||
*/
|
*/
|
||||||
export function buildTestDb(url: string = TEST_DATABASE_URL): Kysely<any> {
|
export function buildTestDb(url: string = TEST_DATABASE_URL): Kysely<any> {
|
||||||
return new Kysely<any>({
|
return new Kysely<any>({
|
||||||
dialect: new PostgresJSDialect({
|
dialect: new PostgresJSDialect({ postgres: buildTestSql(url) }),
|
||||||
postgres: postgres(url, {
|
|
||||||
max: 5,
|
|
||||||
onnotice: () => {},
|
|
||||||
types: {
|
|
||||||
bigint: {
|
|
||||||
to: 20,
|
|
||||||
from: [20, 1700],
|
|
||||||
serialize: (value: number) => value.toString(),
|
|
||||||
parse: (value: string) => Number.parseInt(value),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
plugins: [new CamelCasePlugin()],
|
plugins: [new CamelCasePlugin()],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let singleton: Kysely<any> | undefined;
|
let singleton: Kysely<any> | undefined;
|
||||||
|
let singletonSql: ReturnType<typeof buildTestSql> | undefined;
|
||||||
|
|
||||||
/** Lazily-built shared Kysely for the test suite (one per worker; maxWorkers=1). */
|
/** Lazily-built shared Kysely for the test suite (one per worker; maxWorkers=1). */
|
||||||
export function getTestDb(): Kysely<any> {
|
export function getTestDb(): Kysely<any> {
|
||||||
if (!singleton) {
|
if (!singleton) {
|
||||||
singleton = buildTestDb();
|
singletonSql = buildTestSql();
|
||||||
|
singleton = new Kysely<any>({
|
||||||
|
dialect: new PostgresJSDialect({ postgres: singletonSql }),
|
||||||
|
plugins: [new CamelCasePlugin()],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return singleton;
|
return singleton;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function destroyTestDb(): Promise<void> {
|
export async function destroyTestDb(): Promise<void> {
|
||||||
if (singleton) {
|
if (!singleton) return;
|
||||||
await singleton.destroy();
|
const sql = singletonSql;
|
||||||
singleton = undefined;
|
// Clear the refs first so a hung end() cannot leave a half-closed singleton.
|
||||||
|
singleton = undefined;
|
||||||
|
singletonSql = undefined;
|
||||||
|
// postgres.js .end() waits indefinitely for in-flight queries by default; a
|
||||||
|
// leaked/stuck pooled connection would hang the afterAll hook (a 60s hook
|
||||||
|
// timeout in CI). Bound the shutdown: the { timeout } grace period lets
|
||||||
|
// active queries drain, then force-closes lingering sockets so teardown
|
||||||
|
// always completes. We close the pool directly instead of Kysely.destroy()
|
||||||
|
// (which would call sql.end() again with no timeout).
|
||||||
|
if (sql) {
|
||||||
|
await sql.end({ timeout: 5 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,14 @@
|
|||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
"testRegex": ".e2e-spec.ts$",
|
"testRegex": ".e2e-spec.ts$",
|
||||||
"transform": {
|
"transform": {
|
||||||
|
"prosemirror-markdown/build/.+\\.js$": [
|
||||||
|
"babel-jest",
|
||||||
|
{ "presets": [["@babel/preset-env", { "targets": { "node": "current" } }]] }
|
||||||
|
],
|
||||||
"^.+\\.(t|j)sx?$": ["ts-jest", { "tsconfig": { "allowJs": true } }]
|
"^.+\\.(t|j)sx?$": ["ts-jest", { "tsconfig": { "allowJs": true } }]
|
||||||
},
|
},
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0|@sindresorhus[+/][a-z0-9-]+|escape-string-regexp|p-limit|yocto-queue)(@|/))"
|
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0|@sindresorhus[+/][a-z0-9-]+|escape-string-regexp|p-limit|yocto-queue|@docmost/prosemirror-markdown)(@|/))"
|
||||||
],
|
],
|
||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
"^@docmost/db/(.*)$": "<rootDir>/../src/database/$1",
|
"^@docmost/db/(.*)$": "<rootDir>/../src/database/$1",
|
||||||
|
|||||||
@@ -4,14 +4,19 @@
|
|||||||
"testRegex": ".*\\.int-spec\\.ts$",
|
"testRegex": ".*\\.int-spec\\.ts$",
|
||||||
"testPathIgnorePatterns": ["/node_modules/"],
|
"testPathIgnorePatterns": ["/node_modules/"],
|
||||||
"transform": {
|
"transform": {
|
||||||
|
"prosemirror-markdown/build/.+\\.js$": [
|
||||||
|
"babel-jest",
|
||||||
|
{ "presets": [["@babel/preset-env", { "targets": { "node": "current" } }]] }
|
||||||
|
],
|
||||||
"^.+\\.(t|j)sx?$": "ts-jest"
|
"^.+\\.(t|j)sx?$": "ts-jest"
|
||||||
},
|
},
|
||||||
"transformIgnorePatterns": [
|
"transformIgnorePatterns": [
|
||||||
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0)(@|/))"
|
"/node_modules/(?!(\\.pnpm/)?(nanoid|uuid|image-dimensions|marked|happy-dom|lib0|@docmost/prosemirror-markdown)(@|/))"
|
||||||
],
|
],
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
"testTimeout": 60000,
|
"testTimeout": 60000,
|
||||||
"maxWorkers": 1,
|
"maxWorkers": 1,
|
||||||
|
"forceExit": true,
|
||||||
"globalSetup": "<rootDir>/test/integration/global-setup.ts",
|
"globalSetup": "<rootDir>/test/integration/global-setup.ts",
|
||||||
"globalTeardown": "<rootDir>/test/integration/global-teardown.ts",
|
"globalTeardown": "<rootDir>/test/integration/global-teardown.ts",
|
||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Jest stub for @tiptap/react.
|
||||||
|
//
|
||||||
|
// The server export/import code paths transitively import editor-ext, whose node
|
||||||
|
// extensions import from `@tiptap/react`. The real module re-exports all of
|
||||||
|
// `@tiptap/core` (headless, safe under node) AND adds React view helpers
|
||||||
|
// (`ReactNodeViewRenderer`, …) that eagerly pull in react-dom — which throws
|
||||||
|
// `navigator is not defined` under jest's node environment.
|
||||||
|
//
|
||||||
|
// So this stub DELEGATES to the real `@tiptap/core` (keeping `mergeAttributes`,
|
||||||
|
// `Node`, `Mark`, `nodeInputRule`, … working — they are used by
|
||||||
|
// `jsonToHtml`/`htmlToJson` on the server) and overrides ONLY the React view
|
||||||
|
// helpers with no-ops. Those helpers are referenced solely inside `addNodeView()`
|
||||||
|
// — code that runs only in a live browser editor, never on the server; if any
|
||||||
|
// were actually invoked here it would (correctly) surface as a test failure.
|
||||||
|
const core = require('@tiptap/core');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
...core,
|
||||||
|
ReactNodeViewRenderer: () => () => ({}),
|
||||||
|
NodeViewWrapper: () => null,
|
||||||
|
NodeViewContent: () => null,
|
||||||
|
ReactRenderer: class {},
|
||||||
|
};
|
||||||
@@ -12,6 +12,11 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# The app already serves precompressed (brotli/gzip) static assets with
|
||||||
|
# long-lived cache headers and gzips dynamic API responses. For the best
|
||||||
|
# cold-load latency you can OPTIONALLY put a reverse proxy (caddy / nginx /
|
||||||
|
# traefik) in front with HTTP/2 (or HTTP/3) and brotli enabled — none is
|
||||||
|
# required for compression to work.
|
||||||
volumes:
|
volumes:
|
||||||
- docmost:/app/data/storage
|
- docmost:/app/data/storage
|
||||||
|
|
||||||
|
|||||||
@@ -131,5 +131,14 @@ const { Client } = require("pg");
|
|||||||
7. **Migrations don't auto-run in dev** — run `migration:latest` after every pull
|
7. **Migrations don't auto-run in dev** — run `migration:latest` after every pull
|
||||||
or branch switch.
|
or branch switch.
|
||||||
|
|
||||||
|
8. **Automation (Playwright): type into the BODY editor, not the title.** A page has
|
||||||
|
two `.ProseMirror` editors — `[aria-label='Page title']` (non-collab) and
|
||||||
|
`[aria-label='Page content']` (the collab body). `document.querySelector('.ProseMirror')`
|
||||||
|
returns the TITLE editor, so typing there never changes body content and `mod+S`
|
||||||
|
versions nothing. Target `[aria-label='Page content']`, confirm it's collab-bound
|
||||||
|
(`el.editor.extensionManager.extensions.some(e=>e.name==='collaboration')`), and
|
||||||
|
wait ~10-12s for the store debounce before asserting `pages.content` changed. Full
|
||||||
|
testing methodology + traps: **[how-to-test.md](how-to-test.md)**.
|
||||||
|
|
||||||
See also the **Commands** and **Architecture → Two server processes** sections in
|
See also the **Commands** and **Architecture → Two server processes** sections in
|
||||||
[`AGENTS.md`](../AGENTS.md).
|
[`AGENTS.md`](../AGENTS.md).
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# How to test the application (browser E2E + out-of-band)
|
||||||
|
|
||||||
|
How to actually verify a feature end-to-end against a running stand — driving the
|
||||||
|
**real app in a browser** and confirming results **out-of-band** in the DB/git, not
|
||||||
|
through the same API you're supposed to be testing. Written from real false-positives
|
||||||
|
that wasted hours (see **Traps** — read them before you write a test).
|
||||||
|
|
||||||
|
Prereq: a running stand — see **[dev-stand.md](dev-stand.md)**. Automation uses
|
||||||
|
Playwright (`pip install playwright && python -m playwright install chromium`).
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
1. **Drive the behaviour under test through the browser.** The stand exists so you
|
||||||
|
exercise the real UI + realtime-collab + server path. Using `POST /api/pages/*` to
|
||||||
|
perform the action you're validating tests the API, not the app — an e2e suite can
|
||||||
|
do that. API calls are fine ONLY for one-time setup/fixtures, never for the
|
||||||
|
interaction you're asserting on.
|
||||||
|
2. **Evidence before claim.** Nothing "passes" without an artifact: a DB row, a git
|
||||||
|
diff, a screenshot looked at as an image. If you can't show it, you didn't verify it.
|
||||||
|
3. **Verify out-of-band.** Judge results from a source independent of the UI: `psql`
|
||||||
|
against the DB, a fresh `git clone` of a synced repo, a hard reload. Optimistic UI
|
||||||
|
lies about persistence.
|
||||||
|
4. **Disconfirm by default.** For each feature, actively try to prove it's broken
|
||||||
|
before concluding it works. Reload after every create/edit/save.
|
||||||
|
5. **Recon actuatability FIRST.** Before building editor tests, confirm the
|
||||||
|
interaction even works in your harness (does a typed edit reach the DB?). Skipping
|
||||||
|
this is how you ship a pile of tests that all silently exercised the wrong thing.
|
||||||
|
|
||||||
|
## The editor: two ProseMirror instances (READ THIS)
|
||||||
|
|
||||||
|
A page has **two** `.ProseMirror` editors:
|
||||||
|
|
||||||
|
| index | selector | role | collab? |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0 | `[aria-label='Page title']` | title field | **NO** (16 exts, no `collaboration`) |
|
||||||
|
| 1 | `[aria-label='Page content']` | body | **YES** (95 exts, has `collaboration`) |
|
||||||
|
|
||||||
|
`document.querySelector('.ProseMirror')` returns the **title** editor (first match).
|
||||||
|
Type there and you edit the title only — body page content never changes, so `mod+S`
|
||||||
|
"versions" unchanged content and every content test silently no-ops.
|
||||||
|
|
||||||
|
**Always target the body editor** and confirm it's collab-bound before typing:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const el = document.querySelector("[aria-label='Page content']");
|
||||||
|
el.editor.extensionManager.extensions.some(e => e.name === 'collaboration'); // must be true
|
||||||
|
```
|
||||||
|
|
||||||
|
Body edits emit ~20 `/collab` websocket frames while typing and land in
|
||||||
|
`pages.content` after the **hocuspocus store debounce (~10s)** — so **wait ~12s**
|
||||||
|
before asserting persistence (checking at 6–8s is a false negative). `mod+S` (the
|
||||||
|
`save-version` stateless message) flushes immediately, so a version created right
|
||||||
|
after a settled body edit holds the typed text.
|
||||||
|
|
||||||
|
## A known-good browser flow
|
||||||
|
|
||||||
|
```
|
||||||
|
1. goto /s/<space-slug> # the "Create page" button lives in the space sidebar, not /home
|
||||||
|
2. click button[aria-label='Create page'] # fully UI-driven page creation
|
||||||
|
3. type into [aria-label='Page title'] # optional title
|
||||||
|
4. click [aria-label='Page content'] → type body text
|
||||||
|
5. wait ~12s (store debounce)
|
||||||
|
6. assert pages.content changed (psql) # out-of-band
|
||||||
|
7. mod+S / menu Save → assert page_history row (psql)
|
||||||
|
8. reload / fresh context → re-assert (persistence round-trip)
|
||||||
|
```
|
||||||
|
|
||||||
|
Auth: log in ONCE, save `storage_state.json`, reuse it across pages/agents (re-login
|
||||||
|
per run trips shared rate-limits). Cookie-based session authorizes both REST and the
|
||||||
|
collab websocket.
|
||||||
|
|
||||||
|
## Judging out-of-band
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# page content / history
|
||||||
|
docker exec <db> psql -U docmost -d docmost -tAc \
|
||||||
|
"select coalesce(kind,'null'), content::text from page_history where page_id='<id>' order by created_at;"
|
||||||
|
# git-sync round-trip: clone the space repo and diff against what you pushed
|
||||||
|
git clone http://<user>:<pass>@127.0.0.1:3000/git/<spaceId>.git /tmp/x
|
||||||
|
```
|
||||||
|
|
||||||
|
`page_history.content` is full JSON — parse it, don't truncate the snippet, or a
|
||||||
|
marker check misses. For sync/async features (autosave, git-sync, idle-flush) use an
|
||||||
|
active probe: write a unique marker, wait past the debounce/poll window, re-read
|
||||||
|
out-of-band, ≥2 iterations — never conclude "broken" from a single snapshot.
|
||||||
|
|
||||||
|
## Traps (each of these produced a false result in a real run)
|
||||||
|
|
||||||
|
- **Wrong editor.** Typed into `.ProseMirror` (= title). Edits never touched body
|
||||||
|
content. → target `[aria-label='Page content']`.
|
||||||
|
- **Checked persistence too early.** Store debounce ~10s; a 6–8s check reads stale.
|
||||||
|
- **Truncated the DB snapshot** below where the test marker sits → false "content
|
||||||
|
missing".
|
||||||
|
- **API-seeded the content under test**, then "verified" the feature — that validated
|
||||||
|
the API, not the app.
|
||||||
|
- **Reused a fixed marker on a non-rebooted stand** → title/row collisions inflate
|
||||||
|
counts (`count==2`). Use a unique per-run marker (timestamp).
|
||||||
|
- **Idle/async read once** and called it "permanently broken" — it was mid-debounce.
|
||||||
|
- **Concluded env-limitation without a cross-build control.** If unsure whether a
|
||||||
|
failure is your harness or the product, run the SAME harness against a known-good
|
||||||
|
build; a divergence localizes it.
|
||||||
|
|
||||||
|
## Scope note
|
||||||
|
|
||||||
|
Some paths genuinely need a human in a real browser (rich drag-drop, native file
|
||||||
|
pickers, clipboard, and anything the harness can't actuate). Label those UNTESTED in
|
||||||
|
the report — "handled gracefully" is not "works". Keep four states distinct:
|
||||||
|
verified-working, defect, untested, env-limitation.
|
||||||
+2
-1
@@ -96,7 +96,8 @@
|
|||||||
"pnpm": {
|
"pnpm": {
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"scimmy@1.3.5": "patches/scimmy@1.3.5.patch",
|
"scimmy@1.3.5": "patches/scimmy@1.3.5.patch",
|
||||||
"yjs@13.6.30": "patches/yjs@13.6.30.patch"
|
"yjs@13.6.30": "patches/yjs@13.6.30.patch",
|
||||||
|
"ai@6.0.134": "patches/ai@6.0.134.patch"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"prosemirror-changeset": "2.4.0",
|
"prosemirror-changeset": "2.4.0",
|
||||||
|
|||||||
+83
-355
@@ -118,56 +118,19 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
|
|||||||
// transport exposes a `tree:true` mode that returns the full nested hierarchy;
|
// transport exposes a `tree:true` mode that returns the full nested hierarchy;
|
||||||
// the in-app copy keeps the same tree option but is worded for the in-app agent.
|
// the in-app copy keeps the same tree option but is worded for the in-app agent.
|
||||||
// Kept per-layer so each side can tune its own guidance.
|
// Kept per-layer so each side can tune its own guidance.
|
||||||
server.registerTool(
|
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). This
|
||||||
"list_pages",
|
// transport keeps applying its own defaults (limit=50, tree=false) in execute.
|
||||||
{
|
registerShared(SHARED_TOOL_SPECS.listPages, async ({ spaceId, limit, tree }) => {
|
||||||
description:
|
const result = await docmostClient.listPages(spaceId, limit ?? 50, tree ?? false);
|
||||||
"List most recent pages in a space ordered by updatedAt (descending). " +
|
return jsonContent(result);
|
||||||
"Returns a bounded list (default 50, max 100) — use search for lookups " +
|
});
|
||||||
"in large spaces. Pass tree:true (with spaceId) to instead get the " +
|
|
||||||
"space's full page hierarchy as a nested tree.",
|
|
||||||
inputSchema: {
|
|
||||||
spaceId: z.string().optional(),
|
|
||||||
limit: z
|
|
||||||
.number()
|
|
||||||
.int()
|
|
||||||
.min(1)
|
|
||||||
.max(100)
|
|
||||||
.optional()
|
|
||||||
.describe("Max pages to return (default 50, max 100)"),
|
|
||||||
tree: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"When true, return the space's full page hierarchy as a nested tree (each node has a children array) instead of the recent-by-updatedAt flat list. Requires spaceId; ignores limit.",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ spaceId, limit, tree }) => {
|
|
||||||
const result = await docmostClient.listPages(spaceId, limit ?? 50, tree ?? false);
|
|
||||||
return jsonContent(result);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Tool: get_page
|
// Tool: get_page
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294).
|
||||||
"get_page",
|
registerShared(SHARED_TOOL_SPECS.getPage, async ({ pageId }) => {
|
||||||
{
|
const page = await docmostClient.getPage(pageId);
|
||||||
description:
|
return jsonContent(page);
|
||||||
"Get page details with content converted to Markdown. The conversion is " +
|
});
|
||||||
"LOSSY (block ids, exact table/callout structure are approximated); for a " +
|
|
||||||
"lossless representation use get_page_json. 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: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId }) => {
|
|
||||||
const page = await docmostClient.getPage(pageId);
|
|
||||||
return jsonContent(page);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Tool: get_page_json
|
// Tool: get_page_json
|
||||||
registerShared(SHARED_TOOL_SPECS.getPageJson, async ({ pageId }) => {
|
registerShared(SHARED_TOOL_SPECS.getPageJson, async ({ pageId }) => {
|
||||||
@@ -201,6 +164,10 @@ registerShared(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: table_get
|
// Tool: table_get
|
||||||
|
// NOT in the shared registry: the MCP tool name `table_get` is noun-first while
|
||||||
|
// the in-app key is `getTable` (verb-first), breaking the snake_case(inAppKey)
|
||||||
|
// convention the shared registry enforces (shared-tool-specs.contract.spec.ts).
|
||||||
|
// Renaming the public MCP tool would break external clients, so it stays inline.
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"table_get",
|
"table_get",
|
||||||
{
|
{
|
||||||
@@ -223,25 +190,10 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: table_insert_row
|
// Tool: table_insert_row
|
||||||
// NOT in the shared registry: this transport names the table argument `table`,
|
// Schema + description now live in the shared registry (#294); the `table`
|
||||||
// while the in-app tool names it `tableRef` (ai-chat-tools.service.ts). Sharing
|
// parameter name is the canonical one (the in-app layer was unified to it).
|
||||||
// one buildShape would rename a public MCP parameter, so the table row/cell
|
registerShared(
|
||||||
// tools stay per-transport by design.
|
SHARED_TOOL_SPECS.tableInsertRow,
|
||||||
server.registerTool(
|
|
||||||
"table_insert_row",
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Insert a row of plain-text cells into a table. `table` = `#<index>` or " +
|
|
||||||
"a block id inside it. `cells` = text per column (padded to the table's " +
|
|
||||||
"column count; error if more cells than columns). `index` = 0-based " +
|
|
||||||
"insert position (0 inserts before the header); omit to append at the end.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
table: z.string().min(1),
|
|
||||||
cells: z.array(z.string()),
|
|
||||||
index: z.number().int().optional(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, table, cells, index }) => {
|
async ({ pageId, table, cells, index }) => {
|
||||||
const result = await docmostClient.tableInsertRow(
|
const result = await docmostClient.tableInsertRow(
|
||||||
pageId,
|
pageId,
|
||||||
@@ -254,22 +206,9 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: table_delete_row
|
// Tool: table_delete_row
|
||||||
// NOT shared — same `table` (here) vs `tableRef` (in-app) parameter-name
|
// Schema + description now live in the shared registry (#294).
|
||||||
// divergence as table_insert_row.
|
registerShared(
|
||||||
server.registerTool(
|
SHARED_TOOL_SPECS.tableDeleteRow,
|
||||||
"table_delete_row",
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Delete the row at 0-based `index` from a table (`table` = `#<index>` or " +
|
|
||||||
"a block id inside it). Refuses to delete the table's only row. An " +
|
|
||||||
"out-of-range `index` throws. Deleting `index` 0 removes the header row, " +
|
|
||||||
"and the next row becomes the new header.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
table: z.string().min(1),
|
|
||||||
index: z.number().int(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, table, index }) => {
|
async ({ pageId, table, index }) => {
|
||||||
const result = await docmostClient.tableDeleteRow(pageId, table, index);
|
const result = await docmostClient.tableDeleteRow(pageId, table, index);
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
@@ -277,24 +216,9 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: table_update_cell
|
// Tool: table_update_cell
|
||||||
// NOT shared — same `table` (here) vs `tableRef` (in-app) parameter-name
|
// Schema + description now live in the shared registry (#294).
|
||||||
// divergence as table_insert_row.
|
registerShared(
|
||||||
server.registerTool(
|
SHARED_TOOL_SPECS.tableUpdateCell,
|
||||||
"table_update_cell",
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Set the plain-text content of cell [row,col] (0-based) in a table " +
|
|
||||||
"(`table` = `#<index>` or a block id inside it). Replaces the cell's " +
|
|
||||||
"content with a single text paragraph; for rich formatting use patch_node " +
|
|
||||||
"on the cell's paragraph id from table_get.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
table: z.string().min(1),
|
|
||||||
row: z.number().int(),
|
|
||||||
col: z.number().int(),
|
|
||||||
text: z.string(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, table, row, col, text }) => {
|
async ({ pageId, table, row, col, text }) => {
|
||||||
const result = await docmostClient.tableUpdateCell(
|
const result = await docmostClient.tableUpdateCell(
|
||||||
pageId,
|
pageId,
|
||||||
@@ -308,22 +232,9 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: create_page
|
// Tool: create_page
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294).
|
||||||
"create_page",
|
registerShared(
|
||||||
{
|
SHARED_TOOL_SPECS.createPage,
|
||||||
description:
|
|
||||||
"Create a new page from Markdown in a space. Pass parentPageId to nest " +
|
|
||||||
"it under a parent; omit it to create at the space root.",
|
|
||||||
inputSchema: {
|
|
||||||
title: z.string().min(1).describe("Title of the page"),
|
|
||||||
content: z.string().min(1).describe("Markdown content"),
|
|
||||||
spaceId: z.string().min(1),
|
|
||||||
parentPageId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("Optional parent page ID to nest under"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ title, content, spaceId, parentPageId }) => {
|
async ({ title, content, spaceId, parentPageId }) => {
|
||||||
const result = await docmostClient.createPage(
|
const result = await docmostClient.createPage(
|
||||||
title,
|
title,
|
||||||
@@ -336,32 +247,11 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: update_page_json
|
// Tool: update_page_json
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294). The execute body
|
||||||
"update_page_json",
|
// keeps this transport's content normalization (parse a JSON-string content,
|
||||||
{
|
// pass undefined/null through for a title-only/no-op update).
|
||||||
description:
|
registerShared(
|
||||||
"Replace a page's content with a raw ProseMirror JSON document " +
|
SHARED_TOOL_SPECS.updatePageJson,
|
||||||
"(lossless write: preserves the block ids, callouts, tables and " +
|
|
||||||
"attributes you pass in). Typical flow: get_page_json -> modify the " +
|
|
||||||
"JSON -> update_page_json. Keep existing node ids intact so heading " +
|
|
||||||
"anchors and history stay stable. Minimal full-doc example: " +
|
|
||||||
'{"type":"doc","content":[{"type":"paragraph","content":' +
|
|
||||||
'[{"type":"text","text":"Hi"}]}]}. `content` may be a JSON object or a ' +
|
|
||||||
"JSON string (both accepted), and is OPTIONAL: omit it to update only " +
|
|
||||||
"the title (though prefer rename_page for a title-only change). " +
|
|
||||||
"Supplying neither content nor title is an error.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1).describe("ID of the page to update"),
|
|
||||||
content: z
|
|
||||||
.any()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
'ProseMirror document {"type":"doc","content":[...]} (JSON object or ' +
|
|
||||||
"JSON string). Omit to rename only.",
|
|
||||||
),
|
|
||||||
title: z.string().optional().describe("Optional new title"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, content, title }) => {
|
async ({ pageId, content, title }) => {
|
||||||
// Only parse/validate the document when it was actually supplied; when it
|
// Only parse/validate the document when it was actually supplied; when it
|
||||||
// is omitted, pass it straight through so the client performs a title-only
|
// is omitted, pass it straight through so the client performs a title-only
|
||||||
@@ -379,26 +269,11 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: export_page_markdown
|
// Tool: export_page_markdown
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294).
|
||||||
"export_page_markdown",
|
registerShared(SHARED_TOOL_SPECS.exportPageMarkdown, async ({ pageId }) => {
|
||||||
{
|
const md = await docmostClient.exportPageMarkdown(pageId);
|
||||||
description:
|
return { content: [{ type: "text" as const, text: md }] };
|
||||||
"Export a page to a single self-contained, lossless Docmost-flavoured " +
|
});
|
||||||
"Markdown file (custom extensions): YAML-free meta header, body with " +
|
|
||||||
"inline comment anchors and diagrams, and a trailing comments-thread " +
|
|
||||||
"block. Designed for a download -> edit body -> import_page_markdown " +
|
|
||||||
"round-trip that preserves everything, including comment highlights. " +
|
|
||||||
"Comment THREADS are preserved in the file but are not re-pushed to the " +
|
|
||||||
"server on import.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId }) => {
|
|
||||||
const md = await docmostClient.exportPageMarkdown(pageId);
|
|
||||||
return { content: [{ type: "text" as const, text: md }] };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Tool: import_page_markdown
|
// Tool: import_page_markdown
|
||||||
registerShared(
|
registerShared(
|
||||||
@@ -422,22 +297,11 @@ registerShared(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: rename_page
|
// Tool: rename_page
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294).
|
||||||
"rename_page",
|
registerShared(SHARED_TOOL_SPECS.renamePage, async ({ pageId, title }) => {
|
||||||
{
|
const result = await docmostClient.renamePage(pageId, title);
|
||||||
description:
|
return jsonContent(result);
|
||||||
"Rename a page (change its title only) without touching or resending " +
|
});
|
||||||
"its content.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1).describe("ID of the page to rename"),
|
|
||||||
title: z.string().min(1).describe("New title"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, title }) => {
|
|
||||||
const result = await docmostClient.renamePage(pageId, title);
|
|
||||||
return jsonContent(result);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Tool: edit_page_text
|
// Tool: edit_page_text
|
||||||
registerShared(SHARED_TOOL_SPECS.editPageText, async ({ pageId, edits }) => {
|
registerShared(SHARED_TOOL_SPECS.editPageText, async ({ pageId, edits }) => {
|
||||||
@@ -516,6 +380,10 @@ registerShared(SHARED_TOOL_SPECS.deleteNode, async ({ pageId, nodeId }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Tool: insert_image
|
// Tool: insert_image
|
||||||
|
// MCP-only by design (NOT in the shared registry): the in-app AI-chat agent
|
||||||
|
// exposes no image tools (insert/replace), so there is no second layer to unify
|
||||||
|
// — a SHARED_TOOL_SPECS entry's tier/catalogLine are in-app metadata and the
|
||||||
|
// catalog-partition test forbids a spec without a live in-app tool (#294).
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"insert_image",
|
"insert_image",
|
||||||
{
|
{
|
||||||
@@ -561,6 +429,7 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: replace_image
|
// Tool: replace_image
|
||||||
|
// MCP-only by design (see insert_image): no in-app equivalent, stays inline.
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"replace_image",
|
"replace_image",
|
||||||
{
|
{
|
||||||
@@ -603,25 +472,10 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: share_page
|
// Tool: share_page
|
||||||
// INTENTIONAL per-transport divergence (not shared): the in-app copy adds a
|
// Schema + description now live in the shared registry (#294). The execute body
|
||||||
// security-confirmation framing ("only share when the user explicitly asked,
|
// keeps this transport's own `searchIndexing ?? true` default.
|
||||||
// since this exposes the page to anyone with the link") tuned for the in-app
|
registerShared(
|
||||||
// agent; this transport keeps the plain public-URL wording.
|
SHARED_TOOL_SPECS.sharePage,
|
||||||
server.registerTool(
|
|
||||||
"share_page",
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Make a page publicly accessible (idempotent) and return its public " +
|
|
||||||
"URL. The URL format is <app>/share/<key>/p/<slugId>. This exposes the " +
|
|
||||||
"page content to ANYONE with the URL — do it only when explicitly asked.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1).describe("ID of the page to share"),
|
|
||||||
searchIndexing: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.describe("Allow search engines to index the page (default true)"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, searchIndexing }) => {
|
async ({ pageId, searchIndexing }) => {
|
||||||
const result = await docmostClient.sharePage(pageId, searchIndexing ?? true);
|
const result = await docmostClient.sharePage(pageId, searchIndexing ?? true);
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
@@ -641,29 +495,11 @@ registerShared(SHARED_TOOL_SPECS.listShares, async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Tool: move_page
|
// Tool: move_page
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294). The execute body
|
||||||
"move_page",
|
// keeps this transport's cycle guard, its 'null'/'' -> null string coercion, and
|
||||||
{
|
// its positive-confirmation check on the move response.
|
||||||
description:
|
registerShared(
|
||||||
"Move a page under a new parent (nesting) or to the space root.",
|
SHARED_TOOL_SPECS.movePage,
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().min(1),
|
|
||||||
parentPageId: z
|
|
||||||
.string()
|
|
||||||
.nullable()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"Target parent page ID. Pass 'null' or empty string to move to root.",
|
|
||||||
),
|
|
||||||
position: z
|
|
||||||
.string()
|
|
||||||
.min(5)
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"fractional-index position key; min 5 chars; omit to append at the end.",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, parentPageId, position }) => {
|
async ({ pageId, parentPageId, position }) => {
|
||||||
const finalParentId =
|
const finalParentId =
|
||||||
parentPageId === "" || parentPageId === "null" ? null : parentPageId;
|
parentPageId === "" || parentPageId === "null" ? null : parentPageId;
|
||||||
@@ -698,49 +534,22 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: delete_page
|
// Tool: delete_page
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294). The shared schema
|
||||||
"delete_page",
|
// exposes ONLY pageId, so no permanent/force-delete flag can reach the client.
|
||||||
{
|
registerShared(SHARED_TOOL_SPECS.deletePage, async ({ pageId }) => {
|
||||||
description:
|
await docmostClient.deletePage(pageId);
|
||||||
"Delete a single page by ID. SOFT delete only: the page is moved to " +
|
return {
|
||||||
"trash and can be restored; nothing is permanently deleted.",
|
content: [
|
||||||
inputSchema: {
|
{ type: "text" as const, text: `Successfully deleted page ${pageId}` },
|
||||||
pageId: z.string().min(1),
|
],
|
||||||
},
|
};
|
||||||
},
|
});
|
||||||
async ({ pageId }) => {
|
|
||||||
await docmostClient.deletePage(pageId);
|
|
||||||
return {
|
|
||||||
content: [
|
|
||||||
{ type: "text" as const, text: `Successfully deleted page ${pageId}` },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Comment tools (ported from upstream PR #3 by Max Nikitin) ---
|
// --- Comment tools (ported from upstream PR #3 by Max Nikitin) ---
|
||||||
|
|
||||||
// Tool: list_comments
|
// Tool: list_comments
|
||||||
server.registerTool(
|
registerShared(
|
||||||
"list_comments",
|
SHARED_TOOL_SPECS.listComments,
|
||||||
{
|
|
||||||
description:
|
|
||||||
"List comments on a page in one call (pagination is handled " +
|
|
||||||
"internally). By DEFAULT only ACTIVE threads are returned; resolved " +
|
|
||||||
"threads (a resolved top-level comment and all its replies) are hidden " +
|
|
||||||
"and their count reported as `resolvedThreadsHidden` so you can re-query " +
|
|
||||||
"with `includeResolved: true` to see everything. Returns " +
|
|
||||||
"`{ items, resolvedThreadsHidden }`. Content is returned as Markdown.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().describe("ID of the page"),
|
|
||||||
includeResolved: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"default only active threads; true — include resolved",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, includeResolved }) => {
|
async ({ pageId, includeResolved }) => {
|
||||||
const comments = await docmostClient.listComments(pageId, includeResolved);
|
const comments = await docmostClient.listComments(pageId, includeResolved);
|
||||||
return jsonContent(comments);
|
return jsonContent(comments);
|
||||||
@@ -748,55 +557,11 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: create_comment
|
// Tool: create_comment
|
||||||
// INTENTIONAL per-transport divergence (not shared): the in-app copy tunes the
|
// Schema + description now live in the shared registry (#294). The execute body
|
||||||
// guidance for the in-app agent (e.g. "retry with a corrected EXACT selection"
|
// keeps this transport's own guards (require a selection for a top-level
|
||||||
// and "Reversible via the comment UI"); this transport keeps its own wording.
|
// comment; reject suggestedText on a reply / without a selection).
|
||||||
server.registerTool(
|
registerShared(
|
||||||
"create_comment",
|
SHARED_TOOL_SPECS.createComment,
|
||||||
{
|
|
||||||
description:
|
|
||||||
"Create a new comment on a page. The comment is ALWAYS inline and is " +
|
|
||||||
"anchored to (highlights) its `selection` text — there are no page-level " +
|
|
||||||
"comments. Content is provided as Markdown and automatically converted. " +
|
|
||||||
"A top-level comment REQUIRES an exact `selection`; if the selection " +
|
|
||||||
"cannot be found in the page the call fails (no orphan comment is left). " +
|
|
||||||
"Replies (parentCommentId set) inherit the parent's anchor and take no " +
|
|
||||||
"selection. You may also attach a `suggestedText` proposing a replacement " +
|
|
||||||
"for the `selection`; a human applies (or rejects) it from the UI. When " +
|
|
||||||
"`suggestedText` is set the `selection` MUST occur exactly once in the " +
|
|
||||||
"page — expand it with surrounding context if it is ambiguous.",
|
|
||||||
inputSchema: {
|
|
||||||
pageId: z.string().describe("ID of the page to comment on"),
|
|
||||||
content: z.string().min(1).describe("Comment content in Markdown format"),
|
|
||||||
selection: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
// Enforce the documented 250-char cap to match the description above.
|
|
||||||
.max(250)
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"EXACT contiguous text from a single paragraph/block to anchor the " +
|
|
||||||
"comment on (<=250 chars). Required for a top-level comment; omit " +
|
|
||||||
"only when replying via parentCommentId.",
|
|
||||||
),
|
|
||||||
parentCommentId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe("Parent comment ID to create a reply (max 2 nesting levels)"),
|
|
||||||
suggestedText: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.max(2000)
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"Optional proposed replacement (PLAIN TEXT) for the `selection`, " +
|
|
||||||
"applied by a human via the UI (never auto-applied). REQUIRES a " +
|
|
||||||
"`selection`; NOT allowed on a reply. When set, the `selection` must " +
|
|
||||||
"be UNIQUE in the page — expand it with surrounding context (still " +
|
|
||||||
"<=250 chars) if it occurs more than once, or the call is refused.",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ pageId, content, selection, parentCommentId, suggestedText }) => {
|
async ({ pageId, content, selection, parentCommentId, suggestedText }) => {
|
||||||
if (!parentCommentId && (!selection || !selection.trim())) {
|
if (!parentCommentId && (!selection || !selection.trim())) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -872,28 +637,9 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: resolve_comment
|
// Tool: resolve_comment
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294).
|
||||||
"resolve_comment",
|
registerShared(
|
||||||
{
|
SHARED_TOOL_SPECS.resolveComment,
|
||||||
description:
|
|
||||||
"Resolve (close) or reopen a comment thread. Only top-level comments can " +
|
|
||||||
"be resolved — the server rejects resolving a reply. Reversible: pass " +
|
|
||||||
"resolved=false to reopen. Resolving keeps the thread and its replies " +
|
|
||||||
"(unlike delete_comment, which permanently removes them).",
|
|
||||||
inputSchema: {
|
|
||||||
commentId: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.describe("ID of the top-level comment thread to resolve or reopen"),
|
|
||||||
resolved: z
|
|
||||||
.boolean()
|
|
||||||
.optional()
|
|
||||||
.default(true)
|
|
||||||
.describe(
|
|
||||||
"true (default) marks the thread resolved/closed; false reopens it",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ commentId, resolved }) => {
|
async ({ commentId, resolved }) => {
|
||||||
const result = await docmostClient.resolveComment(commentId, resolved);
|
const result = await docmostClient.resolveComment(commentId, resolved);
|
||||||
return jsonContent(result);
|
return jsonContent(result);
|
||||||
@@ -901,30 +647,10 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: check_new_comments
|
// Tool: check_new_comments
|
||||||
server.registerTool(
|
// Schema + description now live in the shared registry (#294). The execute body
|
||||||
"check_new_comments",
|
// keeps this transport's own guard rejecting an unparseable `since` timestamp.
|
||||||
{
|
registerShared(
|
||||||
description:
|
SHARED_TOOL_SPECS.checkNewComments,
|
||||||
"Check for new comments across pages in a space since a given timestamp. " +
|
|
||||||
"Optionally scope to a page subtree (folder). Returns only comments " +
|
|
||||||
"created after the specified time.",
|
|
||||||
inputSchema: {
|
|
||||||
spaceId: z.string().describe("Space ID to check for new comments"),
|
|
||||||
since: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.describe(
|
|
||||||
"ISO 8601 timestamp — only return comments created after this time (e.g. '2026-03-10T00:00:00Z')",
|
|
||||||
),
|
|
||||||
parentPageId: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.describe(
|
|
||||||
"Optional root page ID to scope the check to a subtree (folder). " +
|
|
||||||
"Only pages under this parent will be checked.",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
async ({ spaceId, since, parentPageId }) => {
|
async ({ spaceId, since, parentPageId }) => {
|
||||||
// Reject an unparseable timestamp up front: otherwise the comparison
|
// Reject an unparseable timestamp up front: otherwise the comparison
|
||||||
// against NaN silently treats every comment as "not new" and the tool
|
// against NaN silently treats every comment as "not new" and the tool
|
||||||
@@ -1053,6 +779,8 @@ server.registerTool(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Tool: insert_footnote
|
// Tool: insert_footnote
|
||||||
|
// MCP-only by design (see insert_image): the in-app AI-chat agent exposes no
|
||||||
|
// footnote tool, so there is no second layer to unify — stays inline (#294).
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
"insert_footnote",
|
"insert_footnote",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -316,6 +316,34 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
|
|
||||||
// --- share management ---
|
// --- share management ---
|
||||||
|
|
||||||
|
// Unified from the per-layer inline definitions (#294). Both layers already
|
||||||
|
// carried the "only share when explicitly asked" security framing (the
|
||||||
|
// "per-transport divergence" note on the old inline copies was stale), so
|
||||||
|
// there was no real behavioral divergence to preserve — only wording drift.
|
||||||
|
sharePage: {
|
||||||
|
mcpName: 'share_page',
|
||||||
|
inAppKey: 'sharePage',
|
||||||
|
// CANONICAL: merges the MCP copy's URL-format + idempotency detail with the
|
||||||
|
// in-app copy's reversibility note; keeps the security framing both had.
|
||||||
|
description:
|
||||||
|
'Make a page PUBLICLY accessible (idempotent) and return its public URL ' +
|
||||||
|
'(format: <app>/share/<key>/p/<slugId>). This exposes the page content ' +
|
||||||
|
'to ANYONE with the URL — only share when the user explicitly asked. ' +
|
||||||
|
'Reversible: unshare it later to revoke the public URL.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'sharePage — make a page publicly accessible and return its URL.',
|
||||||
|
// Reconciled: MCP's stricter .min(1) on pageId kept; field descriptions from
|
||||||
|
// the in-app copy. The MCP execute keeps its own `searchIndexing ?? true`
|
||||||
|
// default (a per-layer concern, not part of the shared schema).
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1).describe('The id of the page to share.'),
|
||||||
|
searchIndexing: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe('Allow public search engines to index it (default true).'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
unsharePage: {
|
unsharePage: {
|
||||||
mcpName: 'unshare_page',
|
mcpName: 'unshare_page',
|
||||||
inAppKey: 'unsharePage',
|
inAppKey: 'unsharePage',
|
||||||
@@ -509,4 +537,470 @@ export const SHARED_TOOL_SPECS = {
|
|||||||
pageId: z.string().min(1),
|
pageId: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- page tools (unified from the per-layer inline definitions, #294) ---
|
||||||
|
//
|
||||||
|
// Descriptions merge both layers (the MCP copy's richer structural notes + the
|
||||||
|
// in-app copy's "Reversible via history/trash" framing where it added one).
|
||||||
|
// Field constraints keep the MCP copy's stricter .min(1) EXCEPT where the
|
||||||
|
// in-app layer deliberately allowed a looser value (documented per field).
|
||||||
|
|
||||||
|
getPage: {
|
||||||
|
mcpName: 'get_page',
|
||||||
|
inAppKey: 'getPage',
|
||||||
|
description:
|
||||||
|
'Fetch a single page as Markdown by its id. Returns the page title and ' +
|
||||||
|
'its Markdown content. The Markdown conversion is LOSSY (block ids, exact ' +
|
||||||
|
'table/callout structure are approximated); for a lossless representation ' +
|
||||||
|
'use the lossless page-JSON read tool. Inline <span data-comment-id> tags in the markdown ' +
|
||||||
|
'are comment highlight anchors (also present for RESOLVED threads) — ' +
|
||||||
|
'treat them as markup, not page text.',
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'getPage — fetch a page as Markdown by its id.',
|
||||||
|
// Reconciled: MCP's stricter .min(1) kept; in-app's more-informative
|
||||||
|
// "(or slugId)" describe kept.
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1).describe('The id (or slugId) of the page.'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
listPages: {
|
||||||
|
mcpName: 'list_pages',
|
||||||
|
inAppKey: 'listPages',
|
||||||
|
description:
|
||||||
|
'List the most recent pages (ordered by updatedAt, descending), ' +
|
||||||
|
'optionally scoped to a single space. Returns a bounded list (default ' +
|
||||||
|
'50, max 100) — use search for lookups in large spaces. Pass tree:true ' +
|
||||||
|
"(with spaceId) to instead get the space's full page hierarchy as a " +
|
||||||
|
'nested tree.',
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: "listPages — list recent pages, or a space's full page tree.",
|
||||||
|
buildShape: (z) => ({
|
||||||
|
spaceId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Optional space id to scope the listing to.'),
|
||||||
|
limit: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.optional()
|
||||||
|
.describe('Maximum number of pages (default 50, max 100).'),
|
||||||
|
tree: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"When true, return the space's full page hierarchy as a nested tree " +
|
||||||
|
'(children arrays) instead of the recent-by-updatedAt flat list. ' +
|
||||||
|
'Requires spaceId; ignores limit.',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
createPage: {
|
||||||
|
mcpName: 'create_page',
|
||||||
|
inAppKey: 'createPage',
|
||||||
|
description:
|
||||||
|
'Create a new page with a Markdown body in a space, optionally under a ' +
|
||||||
|
'parent page (omit parentPageId to create at the space root). Returns ' +
|
||||||
|
'the new page id and title. Reversible: a page can be moved to trash ' +
|
||||||
|
'later.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'createPage — create a new page with a Markdown body in a space.',
|
||||||
|
// Reconciled schema DRIFT: the MCP copy pinned `content` to .min(1) while
|
||||||
|
// the in-app copy left it unbounded and DOCUMENTS an empty body as valid
|
||||||
|
// ("may be empty") — creating an empty page to fill in later is a real use
|
||||||
|
// case. The looser (no-min) form is kept, so create_page now also accepts an
|
||||||
|
// empty body (harmless — it creates an empty page) and no previously-valid
|
||||||
|
// in-app input is ever rejected. `title`/`spaceId` keep the MCP .min(1)
|
||||||
|
// (an empty title or space is never valid).
|
||||||
|
buildShape: (z) => ({
|
||||||
|
title: z.string().min(1).describe('The title of the new page.'),
|
||||||
|
content: z.string().describe('The page body as Markdown (may be empty).'),
|
||||||
|
spaceId: z.string().min(1).describe('The id of the space to create the page in.'),
|
||||||
|
parentPageId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Optional parent page id to nest the new page under.'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
movePage: {
|
||||||
|
mcpName: 'move_page',
|
||||||
|
inAppKey: 'movePage',
|
||||||
|
description:
|
||||||
|
'Move a page under a new parent page, or to the space root when no ' +
|
||||||
|
'parent is given. Reversible: move it back at any time.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'movePage — move a page under a new parent or to the space root.',
|
||||||
|
// Reconciled schema DRIFT: the MCP copy exposed a `position` field
|
||||||
|
// (fractional-index ordering) that the in-app copy lacked. Unified by
|
||||||
|
// KEEPING position (the in-app client already accepts an optional position
|
||||||
|
// arg, so the in-app execute now forwards it) — it is optional, so no
|
||||||
|
// previously-valid in-app call is rejected. `parentPageId` is `.nullable()`
|
||||||
|
// on both, so a real JSON null moves to root on either transport; the MCP
|
||||||
|
// execute additionally coerces the strings 'null'/'' to null as a robustness
|
||||||
|
// fallback (kept in its execute body, not in the shared schema).
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1).describe('The id of the page to move.'),
|
||||||
|
parentPageId: z
|
||||||
|
.string()
|
||||||
|
.nullable()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Target parent page id. Null or omitted moves the page to the space ' +
|
||||||
|
'root.',
|
||||||
|
),
|
||||||
|
position: z
|
||||||
|
.string()
|
||||||
|
.min(5)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Optional fractional-index position key (min 5 chars); omit to ' +
|
||||||
|
'append at the end.',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
renamePage: {
|
||||||
|
mcpName: 'rename_page',
|
||||||
|
inAppKey: 'renamePage',
|
||||||
|
description:
|
||||||
|
'Rename a page (change its title only; the body is untouched, never ' +
|
||||||
|
'resent). Reversible: rename back at any time.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: "renamePage — change a page's title only (body untouched).",
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1).describe('The id of the page to rename.'),
|
||||||
|
title: z.string().min(1).describe('The new title.'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
deletePage: {
|
||||||
|
mcpName: 'delete_page',
|
||||||
|
inAppKey: 'deletePage',
|
||||||
|
description:
|
||||||
|
'Move a page to the trash — SOFT delete only: the page can be restored ' +
|
||||||
|
'from trash and nothing is ever permanently deleted.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'deletePage — move a page to trash (soft delete, reversible).',
|
||||||
|
// GUARDRAIL preserved (§14 H4): the schema exposes ONLY pageId, so a
|
||||||
|
// permanentlyDelete/forceDelete flag can never reach the client through this
|
||||||
|
// tool (asserted by ai-chat-tools.service.spec.ts).
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1).describe('The id of the page to move to trash.'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
updatePageJson: {
|
||||||
|
mcpName: 'update_page_json',
|
||||||
|
inAppKey: 'updatePageJson',
|
||||||
|
description:
|
||||||
|
"Replace a page's content with a raw ProseMirror JSON document (lossless " +
|
||||||
|
'write: preserves the block ids, callouts, tables and attributes you pass ' +
|
||||||
|
'in). Typical flow: read the page-JSON view -> modify the JSON -> write it back. ' +
|
||||||
|
'Keep existing node ids intact so heading anchors and history stay ' +
|
||||||
|
'stable. Minimal full-doc example: {"type":"doc","content":[{"type":' +
|
||||||
|
'"paragraph","content":[{"type":"text","text":"Hi"}]}]}. `content` may be ' +
|
||||||
|
'a JSON object or a JSON string (both accepted), and is OPTIONAL: omit it ' +
|
||||||
|
'to update only the title (though prefer the rename-page tool for a title-only ' +
|
||||||
|
'change). Supplying neither content nor title is an error. Reversible: ' +
|
||||||
|
'the previous version is kept in page history.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
"updatePageJson — overwrite a page's body with a full ProseMirror document.",
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1).describe('ID of the page to update'),
|
||||||
|
content: z
|
||||||
|
.any()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'ProseMirror document {"type":"doc","content":[...]} (JSON object or ' +
|
||||||
|
'JSON string). Omit to update only the title.',
|
||||||
|
),
|
||||||
|
title: z.string().optional().describe('Optional new title'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
exportPageMarkdown: {
|
||||||
|
mcpName: 'export_page_markdown',
|
||||||
|
inAppKey: 'exportPageMarkdown',
|
||||||
|
// CANONICAL: the MCP copy (a strict superset of the terse in-app wording).
|
||||||
|
description:
|
||||||
|
'Export a page to a single self-contained, lossless Docmost-flavoured ' +
|
||||||
|
'Markdown file (custom extensions): YAML-free meta header, body with ' +
|
||||||
|
'inline comment anchors and diagrams, and a trailing comments-thread ' +
|
||||||
|
'block. Designed for a download -> edit body -> page-Markdown import ' +
|
||||||
|
'round-trip that preserves everything, including comment highlights. ' +
|
||||||
|
'Comment THREADS are preserved in the file but are not re-pushed to the ' +
|
||||||
|
'server on import.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'exportPageMarkdown — export a page to self-contained Markdown (body + comments).',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1).describe('The id of the page to export.'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- comment tools (unified from the per-layer inline definitions, #294) ---
|
||||||
|
//
|
||||||
|
// create_comment and resolve_comment previously carried a "per-transport
|
||||||
|
// divergence" note in BOTH layers; #294 unifies their schema + description
|
||||||
|
// here. Only the four tools that genuinely exist in BOTH layers live in the
|
||||||
|
// registry: create/list/resolve comment and check_new_comments.
|
||||||
|
//
|
||||||
|
// update_comment and delete_comment are intentionally NOT here: they exist
|
||||||
|
// ONLY on the standalone MCP server. The in-app agent deliberately exposes no
|
||||||
|
// hard comment edit/delete tool (comment edits are irreversible / not
|
||||||
|
// version-tracked; see the guardrail tests in ai-chat-tools.service.spec.ts),
|
||||||
|
// so there is nothing to unify — they stay inline in index.ts.
|
||||||
|
|
||||||
|
createComment: {
|
||||||
|
mcpName: 'create_comment',
|
||||||
|
inAppKey: 'createComment',
|
||||||
|
// CANONICAL: the in-app copy (the more-maintained one). It keeps the same
|
||||||
|
// rules as the MCP copy — inline-only, top-level requires a `selection`, no
|
||||||
|
// page-level comments, replies inherit the anchor, suggestedText must be
|
||||||
|
// unique — and adds the "retry with a corrected EXACT selection" and reply-
|
||||||
|
// to-reply-rejected guidance the MCP copy lacked. Execute-side validation
|
||||||
|
// (reject suggestedText on a reply, require a selection) stays per-layer.
|
||||||
|
description:
|
||||||
|
'Add an INLINE comment to a page, or reply to an existing top-level ' +
|
||||||
|
'comment (one level only — the backend rejects replies to replies). ' +
|
||||||
|
'The comment is anchored inline to the given exact `selection` text ' +
|
||||||
|
'(which gets highlighted); page-level comments are NOT supported. A ' +
|
||||||
|
'new top-level comment REQUIRES a `selection`. Replies inherit the ' +
|
||||||
|
"parent's anchor and take no selection. If the call fails with a " +
|
||||||
|
'"selection not found" error, retry with a corrected EXACT selection ' +
|
||||||
|
'copied verbatim from a single paragraph/block. You may also attach a ' +
|
||||||
|
'`suggestedText` proposing a replacement for the `selection` (a human ' +
|
||||||
|
'applies it from the UI); when set, the `selection` must occur exactly ' +
|
||||||
|
'once in the page. Reversible via the comment UI.',
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine:
|
||||||
|
'createComment — add an inline comment (optionally with a suggested edit).',
|
||||||
|
// Reconciled schema: the field set is identical across both layers; the
|
||||||
|
// only constraint drift is `content`, which the MCP copy pinned to
|
||||||
|
// .min(1) while the in-app copy left unbounded — the stricter MCP form is
|
||||||
|
// kept (an empty comment body is never valid).
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().describe('The id of the page to comment on.'),
|
||||||
|
content: z.string().min(1).describe('The comment body as Markdown.'),
|
||||||
|
selection: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(250)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'EXACT contiguous text from a SINGLE paragraph/block to anchor ' +
|
||||||
|
'(highlight) the comment on (<=250 chars, avoid spanning across ' +
|
||||||
|
'formatting boundaries). Required for a new top-level comment; ' +
|
||||||
|
'omit only when replying via parentCommentId.',
|
||||||
|
),
|
||||||
|
parentCommentId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Optional id of a TOP-LEVEL comment to reply to (one level ' +
|
||||||
|
'of replies only).',
|
||||||
|
),
|
||||||
|
suggestedText: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(2000)
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Optional proposed replacement (PLAIN TEXT) for the `selection`, ' +
|
||||||
|
'applied by a human via the UI (never auto-applied). REQUIRES a ' +
|
||||||
|
'`selection`; NOT allowed on a reply. When set, the `selection` ' +
|
||||||
|
'must be UNIQUE in the page — expand it with surrounding context ' +
|
||||||
|
'(still <=250 chars) if it occurs more than once, or the call is ' +
|
||||||
|
'refused.',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
listComments: {
|
||||||
|
mcpName: 'list_comments',
|
||||||
|
inAppKey: 'listComments',
|
||||||
|
// CANONICAL: the two copies are near-identical; the MCP copy is the
|
||||||
|
// superset (it keeps the "(pagination is handled internally)" note the
|
||||||
|
// in-app copy dropped), so it is used verbatim.
|
||||||
|
description:
|
||||||
|
'List comments on a page in one call (pagination is handled ' +
|
||||||
|
'internally). By DEFAULT only ACTIVE threads are returned; resolved ' +
|
||||||
|
'threads (a resolved top-level comment and all its replies) are hidden ' +
|
||||||
|
'and their count reported as `resolvedThreadsHidden` so you can re-query ' +
|
||||||
|
'with `includeResolved: true` to see everything. Returns ' +
|
||||||
|
'`{ items, resolvedThreadsHidden }`. Content is returned as Markdown.',
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine:
|
||||||
|
'listComments — list all comments on a page (including resolved).',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().describe('ID of the page'),
|
||||||
|
includeResolved: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe('default only active threads; true — include resolved'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
resolveComment: {
|
||||||
|
mcpName: 'resolve_comment',
|
||||||
|
inAppKey: 'resolveComment',
|
||||||
|
// CANONICAL: the MCP copy's richer wording, minus its snake_case reference
|
||||||
|
// to `delete_comment` (a sibling tool that does NOT exist in the in-app
|
||||||
|
// layer) — rephrased transport-neutrally per the registry convention.
|
||||||
|
description:
|
||||||
|
'Resolve (close) or reopen a top-level comment thread (reversible — ' +
|
||||||
|
'pass resolved=false to reopen). Only top-level comments can be ' +
|
||||||
|
'resolved; the server rejects resolving a reply. Resolving keeps the ' +
|
||||||
|
'thread and its replies intact (it is not a deletion).',
|
||||||
|
tier: 'core',
|
||||||
|
catalogLine: 'resolveComment — resolve or reopen a comment thread.',
|
||||||
|
// Reconciled schema: `resolved` drifted — the MCP copy made it optional
|
||||||
|
// with .default(true) (resolve is the common case, documented), the in-app
|
||||||
|
// copy made it required. The MCP form is kept (a strict superset: it never
|
||||||
|
// rejects a previously-valid input and adds a sensible default), and
|
||||||
|
// commentId keeps the MCP copy's stricter .min(1).
|
||||||
|
buildShape: (z) => ({
|
||||||
|
commentId: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe('ID of the top-level comment thread to resolve or reopen'),
|
||||||
|
resolved: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.default(true)
|
||||||
|
.describe(
|
||||||
|
'true (default) marks the thread resolved/closed; false reopens it',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
checkNewComments: {
|
||||||
|
mcpName: 'check_new_comments',
|
||||||
|
inAppKey: 'checkNewComments',
|
||||||
|
// CANONICAL: the MCP copy (the more detailed of the two). The MCP layer's
|
||||||
|
// execute-side guard that rejects an unparseable `since` timestamp stays in
|
||||||
|
// its execute body (per-layer logic), not in the shared schema.
|
||||||
|
description:
|
||||||
|
'Check for new comments across pages in a space since a given ' +
|
||||||
|
'timestamp. Optionally scope to a page subtree (folder). Returns only ' +
|
||||||
|
'comments created after the specified time.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine:
|
||||||
|
'checkNewComments — find comments in a space created after a timestamp.',
|
||||||
|
// Reconciled schema: `since` keeps the MCP copy's stricter .min(1) (the
|
||||||
|
// in-app copy left it unbounded); field descriptions use the MCP copy's
|
||||||
|
// more detailed wording (it carries an example timestamp).
|
||||||
|
buildShape: (z) => ({
|
||||||
|
spaceId: z.string().describe('Space ID to check for new comments'),
|
||||||
|
since: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe(
|
||||||
|
"ISO 8601 timestamp — only return comments created after this time " +
|
||||||
|
"(e.g. '2026-03-10T00:00:00Z')",
|
||||||
|
),
|
||||||
|
parentPageId: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'Optional root page ID to scope the check to a subtree (folder). ' +
|
||||||
|
'Only pages under this parent will be checked.',
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- table tools (unified from the per-layer inline definitions, #294) ---
|
||||||
|
//
|
||||||
|
// These tools carried a "NOT shared" note in BOTH layers because of a single
|
||||||
|
// parameter-NAME drift: the MCP layer named the table reference `table` while
|
||||||
|
// the in-app layer named it `tableRef`. #294 reconciles that drift by unifying
|
||||||
|
// on the MCP name `table` — renaming the MCP public parameter would break
|
||||||
|
// external MCP clients, whereas the in-app parameter is model-facing
|
||||||
|
// (prompt-only) and safe to rename. The in-app execute bodies now destructure
|
||||||
|
// `table` instead of `tableRef` (nothing else changes). Descriptions take the
|
||||||
|
// MCP copy's richer wording (it documented `#<index>`, padding, header-row
|
||||||
|
// behavior) plus the in-app copy's "Reversible via page history" note; sibling
|
||||||
|
// tool references are phrased transport-neutrally.
|
||||||
|
//
|
||||||
|
// NOT here (kept inline in index.ts): table_get / getTable. Its MCP tool name
|
||||||
|
// is noun-first (`table_get`) while the in-app key is verb-first (`getTable`),
|
||||||
|
// so it breaks the snake_case(inAppKey) naming convention the registry enforces
|
||||||
|
// (shared-tool-specs.contract.spec.ts). Renaming the public MCP tool would
|
||||||
|
// break external clients, so it stays per-transport (its in-app param was still
|
||||||
|
// aligned to `table` for consistency with the migrated trio below).
|
||||||
|
|
||||||
|
tableInsertRow: {
|
||||||
|
mcpName: 'table_insert_row',
|
||||||
|
inAppKey: 'tableInsertRow',
|
||||||
|
description:
|
||||||
|
'Insert a row of plain-text cells into a table. `table` is `#<index>` ' +
|
||||||
|
'from the page outline, or a block id inside it. `cells` is the text per ' +
|
||||||
|
"column (padded to the table's column count; an error if more cells than " +
|
||||||
|
'columns). `index` is the 0-based insert position (0 inserts before the ' +
|
||||||
|
'header); omit to append at the end. Reversible via page history.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'tableInsertRow — insert a row of plain-text cells into a table.',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1).describe('The id of the page.'),
|
||||||
|
table: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe('"#<index>" from the page outline, or a block id in the table.'),
|
||||||
|
cells: z.array(z.string()).describe('The cell texts for the row (one per column).'),
|
||||||
|
index: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.optional()
|
||||||
|
.describe('0-based insert position (0 inserts before the header); omit to append.'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
tableDeleteRow: {
|
||||||
|
mcpName: 'table_delete_row',
|
||||||
|
inAppKey: 'tableDeleteRow',
|
||||||
|
description:
|
||||||
|
'Delete the row at 0-based `index` from a table (`table` is `#<index>` ' +
|
||||||
|
'from the page outline, or a block id inside it). Refuses to delete the ' +
|
||||||
|
"table's only row; an out-of-range `index` throws. Deleting `index` 0 " +
|
||||||
|
'removes the header row, and the next row becomes the new header. ' +
|
||||||
|
'Reversible via page history.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'tableDeleteRow — delete a table row at a 0-based index.',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1).describe('The id of the page.'),
|
||||||
|
table: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe('"#<index>" from the page outline, or a block id in the table.'),
|
||||||
|
index: z.number().int().describe('0-based row index to delete.'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
tableUpdateCell: {
|
||||||
|
mcpName: 'table_update_cell',
|
||||||
|
inAppKey: 'tableUpdateCell',
|
||||||
|
description:
|
||||||
|
'Set the plain-text content of cell [row, col] (0-based) in a table ' +
|
||||||
|
'(`table` is `#<index>` from the page outline, or a block id inside it). ' +
|
||||||
|
"Replaces the cell's content with a single text paragraph; for rich " +
|
||||||
|
"formatting, patch the cell's paragraph id (obtained from reading the " +
|
||||||
|
'table) instead. Reversible via page history.',
|
||||||
|
tier: 'deferred',
|
||||||
|
catalogLine: 'tableUpdateCell — set the text of a table cell at [row, col].',
|
||||||
|
buildShape: (z) => ({
|
||||||
|
pageId: z.string().min(1).describe('The id of the page.'),
|
||||||
|
table: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.describe('"#<index>" from the page outline, or a block id in the table.'),
|
||||||
|
row: z.number().int().describe('0-based row index.'),
|
||||||
|
col: z.number().int().describe('0-based column index.'),
|
||||||
|
text: z.string().describe('The new cell text.'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
} satisfies Record<string, SharedToolSpec>;
|
} satisfies Record<string, SharedToolSpec>;
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
// Import DIRECTLY from src (NOT the docmost-client barrel, which pulls in
|
||||||
|
// collaboration.ts and mutates global DOM at import time).
|
||||||
|
import { convertProseMirrorToMarkdown } from "../src/lib/markdown-converter.js";
|
||||||
|
import { markdownToProseMirror } from "../src/lib/markdown-to-prosemirror.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gitmost #377 (round-1 review, finding #1) — proof, against the REAL
|
||||||
|
* converter, that the transcript-insert boundary defense survives git-sync.
|
||||||
|
*
|
||||||
|
* The web bridge (apps/client .../gitmost/gitmost-recording.ts,
|
||||||
|
* `gitmostInsertTranscriptIntoEditor`) appends each transcript line as a
|
||||||
|
* PARAGRAPH text node. The paragraph serializer here (`case "paragraph"`) emits
|
||||||
|
* that text VERBATIM with no block-escape, so a line whose text begins with a
|
||||||
|
* col-0 markdown block trigger would, on the doc -> markdown -> doc git-sync
|
||||||
|
* cycle, silently re-parse into a heading / list / quote / callout / code block.
|
||||||
|
* That missing block-escape is the pre-existing root cause; the bridge's
|
||||||
|
* boundary defense prepends an invisible zero-width space (U+200B) to a line
|
||||||
|
* that begins with such a trigger, shifting it off column 0.
|
||||||
|
*
|
||||||
|
* This test keeps a COPY of the bridge's trigger regex (the bridge is in a
|
||||||
|
* different package and can't be imported here) and asserts:
|
||||||
|
* 1. bare trigger lines DO corrupt (documents the root cause), and
|
||||||
|
* 2. the ZWSP-neutralized form round-trips as a single PARAGRAPH with the
|
||||||
|
* text byte-preserved.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ZWSP = ""; // U+200B
|
||||||
|
|
||||||
|
// MUST stay in sync with GITMOST_MD_BLOCK_TRIGGER_RE in the client bridge.
|
||||||
|
const MD_BLOCK_TRIGGER_RE =
|
||||||
|
/^(?:#{1,6}(?:\s|$)|[-*+](?:\s|$)|>|\d+[.)](?:\s|$)|```|~~~|\||([-*_])(?:\s*\1){2,}\s*$)/;
|
||||||
|
|
||||||
|
const doc = (...nodes: any[]) => ({ type: "doc", content: nodes });
|
||||||
|
const para = (t: string) => ({
|
||||||
|
type: "paragraph",
|
||||||
|
content: [{ type: "text", text: t }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const roundtrip = async (text: string) => {
|
||||||
|
const md = convertProseMirrorToMarkdown(doc(para(text)));
|
||||||
|
const back = await markdownToProseMirror(md);
|
||||||
|
return back.content as any[];
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("gitmost transcript neutralization (git-sync round-trip)", () => {
|
||||||
|
// Lines that, at column 0, the serializer's missing block-escape would let
|
||||||
|
// git-sync re-parse into a non-paragraph block.
|
||||||
|
const triggerLines = [
|
||||||
|
"- dash",
|
||||||
|
"* star",
|
||||||
|
"+ plus",
|
||||||
|
"> quote",
|
||||||
|
"# hash",
|
||||||
|
"1. one",
|
||||||
|
"1) one",
|
||||||
|
"> [!info] note",
|
||||||
|
"```js",
|
||||||
|
"~~~",
|
||||||
|
// Solid + spaced thematic breaks — these re-parse into a `horizontalRule`,
|
||||||
|
// which carries NO text, so a bare separator line LOSES its text entirely
|
||||||
|
// (round-2 finding). `_` also only forms a block via this construct.
|
||||||
|
"---",
|
||||||
|
"***",
|
||||||
|
"___",
|
||||||
|
"- - -", // spaced dash break (solid form is caught by [-*+]\s too, but this is the break)
|
||||||
|
"_ _ _",
|
||||||
|
];
|
||||||
|
|
||||||
|
it("BARE trigger lines corrupt into non-paragraph blocks (root cause)", async () => {
|
||||||
|
for (const line of triggerLines) {
|
||||||
|
const blocks = await roundtrip(line);
|
||||||
|
// At least one produced block is NOT a paragraph — i.e. corruption.
|
||||||
|
const allParagraphs = blocks.every((b) => b.type === "paragraph");
|
||||||
|
expect(
|
||||||
|
allParagraphs,
|
||||||
|
`expected "${line}" to corrupt when inserted bare`,
|
||||||
|
).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("BARE solid thematic breaks corrupt into a text-LOSING horizontalRule", async () => {
|
||||||
|
// The severe case: no text node survives. Documents why neutralization
|
||||||
|
// matters more here than for list/quote (where the text survived).
|
||||||
|
for (const line of ["---", "***", "___"]) {
|
||||||
|
const blocks = await roundtrip(line);
|
||||||
|
expect(blocks.map((b) => b.type)).toContain("horizontalRule");
|
||||||
|
// No block carries the original text anywhere.
|
||||||
|
const flat = JSON.stringify(blocks);
|
||||||
|
expect(flat).not.toContain(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ZWSP-neutralized trigger lines round-trip as a single paragraph, text preserved", async () => {
|
||||||
|
for (const line of triggerLines) {
|
||||||
|
// The regex must actually classify each as a trigger.
|
||||||
|
expect(MD_BLOCK_TRIGGER_RE.test(line), `regex missed "${line}"`).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const neutralized = ZWSP + line;
|
||||||
|
const blocks = await roundtrip(neutralized);
|
||||||
|
|
||||||
|
expect(blocks).toHaveLength(1);
|
||||||
|
expect(blocks[0].type).toBe("paragraph");
|
||||||
|
// Text is byte-preserved (ZWSP + original line), so the display is the
|
||||||
|
// original line with only an invisible leading character.
|
||||||
|
expect(blocks[0].content[0].text).toBe(neutralized);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normal host-prefixed lines never match the trigger regex and round-trip byte-exact", async () => {
|
||||||
|
for (const line of [
|
||||||
|
"You: hello there",
|
||||||
|
"Speaker 1: - and then a dash mid-line",
|
||||||
|
"Speaker 2: 1. not a list",
|
||||||
|
]) {
|
||||||
|
expect(MD_BLOCK_TRIGGER_RE.test(line)).toBe(false);
|
||||||
|
const blocks = await roundtrip(line);
|
||||||
|
expect(blocks).toHaveLength(1);
|
||||||
|
expect(blocks[0].type).toBe("paragraph");
|
||||||
|
expect(blocks[0].content[0].text).toBe(line);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
diff --git a/dist/index.js b/dist/index.js
|
||||||
|
index ae447a12f7823ec0a00837ee9f0eb809a610d5f8..a3402b2c2d021ef432cfa76e35d370073d525135 100644
|
||||||
|
--- a/dist/index.js
|
||||||
|
+++ b/dist/index.js
|
||||||
|
@@ -6578,9 +6578,19 @@ function createOutputTransformStream(output) {
|
||||||
|
controller.enqueue({ part: chunk, partialOutput: void 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
- text2 += chunk.text;
|
||||||
|
textChunk += chunk.text;
|
||||||
|
textProviderMetadata = (_a21 = chunk.providerMetadata) != null ? _a21 : textProviderMetadata;
|
||||||
|
+ if (output == null) {
|
||||||
|
+ // PATCH(docmost #OOM): no output strategy requested -> publish each
|
||||||
|
+ // text-delta immediately and do NOT build cumulative partialOutput
|
||||||
|
+ // snapshots. Unpatched, the default text() output snapshots the ENTIRE
|
||||||
|
+ // accumulated turn text on every delta (O(n^2) memory) and those
|
||||||
|
+ // snapshots pile up in the never-consumed leftover tee branch of
|
||||||
|
+ // DefaultStreamTextResult.baseStream -> heap OOM on long agent turns.
|
||||||
|
+ publishTextChunk({ controller });
|
||||||
|
+ return;
|
||||||
|
+ }
|
||||||
|
+ text2 += chunk.text;
|
||||||
|
const result = await output.parsePartialOutput({ text: text2 });
|
||||||
|
if (result !== void 0) {
|
||||||
|
const currentJson = JSON.stringify(result.partial);
|
||||||
|
@@ -6959,7 +6969,7 @@ var DefaultStreamTextResult = class {
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
- this.baseStream = stream.pipeThrough(createOutputTransformStream(output != null ? output : text())).pipeThrough(eventProcessor);
|
||||||
|
+ this.baseStream = stream.pipeThrough(createOutputTransformStream(output)).pipeThrough(eventProcessor);
|
||||||
|
const { maxRetries, retry } = prepareRetries({
|
||||||
|
maxRetries: maxRetriesArg,
|
||||||
|
abortSignal
|
||||||
|
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||||
|
index 663875332e3f9a9bd167c25583c515876f42951b..b840b0502c9894df983e0154805abb80e70e6331 100644
|
||||||
|
--- a/dist/index.mjs
|
||||||
|
+++ b/dist/index.mjs
|
||||||
|
@@ -6501,9 +6501,19 @@ function createOutputTransformStream(output) {
|
||||||
|
controller.enqueue({ part: chunk, partialOutput: void 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
- text2 += chunk.text;
|
||||||
|
textChunk += chunk.text;
|
||||||
|
textProviderMetadata = (_a21 = chunk.providerMetadata) != null ? _a21 : textProviderMetadata;
|
||||||
|
+ if (output == null) {
|
||||||
|
+ // PATCH(docmost #OOM): no output strategy requested -> publish each
|
||||||
|
+ // text-delta immediately and do NOT build cumulative partialOutput
|
||||||
|
+ // snapshots. Unpatched, the default text() output snapshots the ENTIRE
|
||||||
|
+ // accumulated turn text on every delta (O(n^2) memory) and those
|
||||||
|
+ // snapshots pile up in the never-consumed leftover tee branch of
|
||||||
|
+ // DefaultStreamTextResult.baseStream -> heap OOM on long agent turns.
|
||||||
|
+ publishTextChunk({ controller });
|
||||||
|
+ return;
|
||||||
|
+ }
|
||||||
|
+ text2 += chunk.text;
|
||||||
|
const result = await output.parsePartialOutput({ text: text2 });
|
||||||
|
if (result !== void 0) {
|
||||||
|
const currentJson = JSON.stringify(result.partial);
|
||||||
|
@@ -6882,7 +6892,7 @@ var DefaultStreamTextResult = class {
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
- this.baseStream = stream.pipeThrough(createOutputTransformStream(output != null ? output : text())).pipeThrough(eventProcessor);
|
||||||
|
+ this.baseStream = stream.pipeThrough(createOutputTransformStream(output)).pipeThrough(eventProcessor);
|
||||||
|
const { maxRetries, retry } = prepareRetries({
|
||||||
|
maxRetries: maxRetriesArg,
|
||||||
|
abortSignal
|
||||||
Generated
+137
-7
@@ -44,6 +44,9 @@ overrides:
|
|||||||
ip-address: 10.1.1
|
ip-address: 10.1.1
|
||||||
|
|
||||||
patchedDependencies:
|
patchedDependencies:
|
||||||
|
ai@6.0.134:
|
||||||
|
hash: f60bfc3357e01e1f3978c6c40fdd65aeb33fefaad7179cde8676465b6c5ff4d9
|
||||||
|
path: patches/ai@6.0.134.patch
|
||||||
scimmy@1.3.5:
|
scimmy@1.3.5:
|
||||||
hash: 775d80f86830b2c5dd1a250c9802c10f8fc3da3c7898373de5aa0c23993d1673
|
hash: 775d80f86830b2c5dd1a250c9802c10f8fc3da3c7898373de5aa0c23993d1673
|
||||||
path: patches/scimmy@1.3.5.patch
|
path: patches/scimmy@1.3.5.patch
|
||||||
@@ -504,6 +507,9 @@ importers:
|
|||||||
vite:
|
vite:
|
||||||
specifier: 8.0.5
|
specifier: 8.0.5
|
||||||
version: 8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)
|
version: 8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)
|
||||||
|
vite-plugin-compression2:
|
||||||
|
specifier: 2.5.3
|
||||||
|
version: 2.5.3
|
||||||
vitest:
|
vitest:
|
||||||
specifier: 4.1.6
|
specifier: 4.1.6
|
||||||
version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
|
version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
|
||||||
@@ -543,6 +549,12 @@ importers:
|
|||||||
'@docmost/pdf-inspector':
|
'@docmost/pdf-inspector':
|
||||||
specifier: 1.9.6
|
specifier: 1.9.6
|
||||||
version: 1.9.6
|
version: 1.9.6
|
||||||
|
'@docmost/prosemirror-markdown':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/prosemirror-markdown
|
||||||
|
'@fastify/compress':
|
||||||
|
specifier: ^9.0.0
|
||||||
|
version: 9.0.0
|
||||||
'@fastify/cookie':
|
'@fastify/cookie':
|
||||||
specifier: ^11.0.2
|
specifier: ^11.0.2
|
||||||
version: 11.0.2
|
version: 11.0.2
|
||||||
@@ -623,10 +635,10 @@ importers:
|
|||||||
version: 8.3.0(socket.io-adapter@2.5.4)
|
version: 8.3.0(socket.io-adapter@2.5.4)
|
||||||
ai:
|
ai:
|
||||||
specifier: ^6.0.134
|
specifier: ^6.0.134
|
||||||
version: 6.0.134(zod@4.3.6)
|
version: 6.0.134(patch_hash=f60bfc3357e01e1f3978c6c40fdd65aeb33fefaad7179cde8676465b6c5ff4d9)(zod@4.3.6)
|
||||||
ai-sdk-ollama:
|
ai-sdk-ollama:
|
||||||
specifier: ^3.8.1
|
specifier: ^3.8.1
|
||||||
version: 3.8.1(ai@6.0.134(zod@4.3.6))(zod@4.3.6)
|
version: 3.8.1(ai@6.0.134(patch_hash=f60bfc3357e01e1f3978c6c40fdd65aeb33fefaad7179cde8676465b6c5ff4d9)(zod@4.3.6))(zod@4.3.6)
|
||||||
bcrypt:
|
bcrypt:
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.0.0
|
version: 6.0.0
|
||||||
@@ -2700,6 +2712,9 @@ packages:
|
|||||||
'@fastify/busboy@3.1.1':
|
'@fastify/busboy@3.1.1':
|
||||||
resolution: {integrity: sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==}
|
resolution: {integrity: sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==}
|
||||||
|
|
||||||
|
'@fastify/compress@9.0.0':
|
||||||
|
resolution: {integrity: sha512-PZRg+ut5xd/ubsGPWfoPNryoCOtEdHboIWpDieTUHov1gKdLitF8mRmT3JbqNnRbelQXSNXUsIpakAEKR6AcTQ==}
|
||||||
|
|
||||||
'@fastify/cookie@11.0.2':
|
'@fastify/cookie@11.0.2':
|
||||||
resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==}
|
resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==}
|
||||||
|
|
||||||
@@ -4493,6 +4508,15 @@ packages:
|
|||||||
'@rolldown/pluginutils@1.0.0-rc.7':
|
'@rolldown/pluginutils@1.0.0-rc.7':
|
||||||
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
|
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
|
||||||
|
|
||||||
|
'@rollup/pluginutils@5.4.0':
|
||||||
|
resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
peerDependencies:
|
||||||
|
rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
rollup:
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@selderee/plugin-htmlparser2@0.11.0':
|
'@selderee/plugin-htmlparser2@0.11.0':
|
||||||
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
|
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
|
||||||
|
|
||||||
@@ -5682,6 +5706,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==}
|
resolution: {integrity: sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==}
|
||||||
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||||
|
|
||||||
|
abort-controller@3.0.0:
|
||||||
|
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||||
|
engines: {node: '>=6.5'}
|
||||||
|
|
||||||
abstract-logging@2.0.1:
|
abstract-logging@2.0.1:
|
||||||
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
|
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
|
||||||
|
|
||||||
@@ -6064,6 +6092,9 @@ packages:
|
|||||||
buffer@5.7.1:
|
buffer@5.7.1:
|
||||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||||
|
|
||||||
|
buffer@6.0.3:
|
||||||
|
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||||
|
|
||||||
bullmq@5.76.10:
|
bullmq@5.76.10:
|
||||||
resolution: {integrity: sha512-LWve7SpQjYSpCP2GEsWmoyzTz2H37L8HRmSTu3YihYsTOr5kJxrfEX6aEV7m6eskEMWXSHZYTMZepX6qNaH6CQ==}
|
resolution: {integrity: sha512-LWve7SpQjYSpCP2GEsWmoyzTz2H37L8HRmSTu3YihYsTOr5kJxrfEX6aEV7m6eskEMWXSHZYTMZepX6qNaH6CQ==}
|
||||||
engines: {node: '>=12.22.0'}
|
engines: {node: '>=12.22.0'}
|
||||||
@@ -6819,6 +6850,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
duplexify@3.7.1:
|
||||||
|
resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==}
|
||||||
|
|
||||||
ecdsa-sig-formatter@1.0.11:
|
ecdsa-sig-formatter@1.0.11:
|
||||||
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||||
|
|
||||||
@@ -7062,6 +7096,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
|
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
|
|
||||||
|
estree-walker@2.0.2:
|
||||||
|
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||||
|
|
||||||
estree-walker@3.0.3:
|
estree-walker@3.0.3:
|
||||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||||
|
|
||||||
@@ -7073,6 +7110,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
event-target-shim@5.0.1:
|
||||||
|
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
eventemitter2@6.4.9:
|
eventemitter2@6.4.9:
|
||||||
resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==}
|
resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==}
|
||||||
|
|
||||||
@@ -9082,6 +9123,9 @@ packages:
|
|||||||
peberminta@0.9.0:
|
peberminta@0.9.0:
|
||||||
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
|
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
|
||||||
|
|
||||||
|
peek-stream@1.1.3:
|
||||||
|
resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==}
|
||||||
|
|
||||||
pend@1.2.0:
|
pend@1.2.0:
|
||||||
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
|
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
|
||||||
|
|
||||||
@@ -9327,6 +9371,10 @@ packages:
|
|||||||
process-warning@5.0.0:
|
process-warning@5.0.0:
|
||||||
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
|
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
|
||||||
|
|
||||||
|
process@0.11.10:
|
||||||
|
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
||||||
|
engines: {node: '>= 0.6.0'}
|
||||||
|
|
||||||
prom-client@15.1.3:
|
prom-client@15.1.3:
|
||||||
resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==}
|
resolution: {integrity: sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==}
|
||||||
engines: {node: ^16 || ^18 || >=20}
|
engines: {node: ^16 || ^18 || >=20}
|
||||||
@@ -9622,6 +9670,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
readable-stream@4.7.0:
|
||||||
|
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
|
||||||
|
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||||
|
|
||||||
readdirp@3.6.0:
|
readdirp@3.6.0:
|
||||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||||
engines: {node: '>=8.10.0'}
|
engines: {node: '>=8.10.0'}
|
||||||
@@ -10010,6 +10062,9 @@ packages:
|
|||||||
stream-browserify@3.0.0:
|
stream-browserify@3.0.0:
|
||||||
resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==}
|
resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==}
|
||||||
|
|
||||||
|
stream-shift@1.0.3:
|
||||||
|
resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==}
|
||||||
|
|
||||||
strict-event-emitter-types@2.0.0:
|
strict-event-emitter-types@2.0.0:
|
||||||
resolution: {integrity: sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==}
|
resolution: {integrity: sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==}
|
||||||
|
|
||||||
@@ -10150,6 +10205,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
tar-mini@0.2.0:
|
||||||
|
resolution: {integrity: sha512-+qfUHz700DWnRutdUsxRRVZ38G1Qr27OetwaMYTdg8hcPxf46U0S1Zf76dQMWRBmusOt2ZCK5kbIaiLkoGO7WQ==}
|
||||||
|
|
||||||
tar-stream@2.2.0:
|
tar-stream@2.2.0:
|
||||||
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -10193,6 +10251,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==}
|
resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
through2@2.0.5:
|
||||||
|
resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==}
|
||||||
|
|
||||||
tiny-invariant@1.3.3:
|
tiny-invariant@1.3.3:
|
||||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||||
|
|
||||||
@@ -10598,6 +10659,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
vite-plugin-compression2@2.5.3:
|
||||||
|
resolution: {integrity: sha512-ItPgqQWkcnBbVw7is9OKwiZ8v6+ju9rYROl5Lp6QfQDEx/d55AwJQb/KLpsQqsU9HoigYBsZ8tK6I02UwJNvEw==}
|
||||||
|
|
||||||
vite@8.0.5:
|
vite@8.0.5:
|
||||||
resolution: {integrity: sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==}
|
resolution: {integrity: sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -12937,6 +13001,15 @@ snapshots:
|
|||||||
|
|
||||||
'@fastify/busboy@3.1.1': {}
|
'@fastify/busboy@3.1.1': {}
|
||||||
|
|
||||||
|
'@fastify/compress@9.0.0':
|
||||||
|
dependencies:
|
||||||
|
'@fastify/accept-negotiator': 2.0.1
|
||||||
|
fastify-plugin: 5.1.0
|
||||||
|
mime-db: 1.54.0
|
||||||
|
minipass: 7.1.3
|
||||||
|
peek-stream: 1.1.3
|
||||||
|
readable-stream: 4.7.0
|
||||||
|
|
||||||
'@fastify/cookie@11.0.2':
|
'@fastify/cookie@11.0.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
cookie: 1.1.1
|
cookie: 1.1.1
|
||||||
@@ -14952,6 +15025,12 @@ snapshots:
|
|||||||
|
|
||||||
'@rolldown/pluginutils@1.0.0-rc.7': {}
|
'@rolldown/pluginutils@1.0.0-rc.7': {}
|
||||||
|
|
||||||
|
'@rollup/pluginutils@5.4.0':
|
||||||
|
dependencies:
|
||||||
|
'@types/estree': 1.0.9
|
||||||
|
estree-walker: 2.0.2
|
||||||
|
picomatch: 4.0.4
|
||||||
|
|
||||||
'@selderee/plugin-htmlparser2@0.11.0':
|
'@selderee/plugin-htmlparser2@0.11.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
domhandler: 5.0.3
|
domhandler: 5.0.3
|
||||||
@@ -16325,6 +16404,10 @@ snapshots:
|
|||||||
|
|
||||||
abbrev@5.0.0: {}
|
abbrev@5.0.0: {}
|
||||||
|
|
||||||
|
abort-controller@3.0.0:
|
||||||
|
dependencies:
|
||||||
|
event-target-shim: 5.0.1
|
||||||
|
|
||||||
abstract-logging@2.0.1: {}
|
abstract-logging@2.0.1: {}
|
||||||
|
|
||||||
accepts@1.3.8:
|
accepts@1.3.8:
|
||||||
@@ -16355,17 +16438,17 @@ snapshots:
|
|||||||
|
|
||||||
agent-base@7.1.4: {}
|
agent-base@7.1.4: {}
|
||||||
|
|
||||||
ai-sdk-ollama@3.8.1(ai@6.0.134(zod@4.3.6))(zod@4.3.6):
|
ai-sdk-ollama@3.8.1(ai@6.0.134(patch_hash=f60bfc3357e01e1f3978c6c40fdd65aeb33fefaad7179cde8676465b6c5ff4d9)(zod@4.3.6))(zod@4.3.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ai-sdk/provider': 3.0.8
|
'@ai-sdk/provider': 3.0.8
|
||||||
'@ai-sdk/provider-utils': 4.0.21(zod@4.3.6)
|
'@ai-sdk/provider-utils': 4.0.21(zod@4.3.6)
|
||||||
ai: 6.0.134(zod@4.3.6)
|
ai: 6.0.134(patch_hash=f60bfc3357e01e1f3978c6c40fdd65aeb33fefaad7179cde8676465b6c5ff4d9)(zod@4.3.6)
|
||||||
jsonrepair: 3.13.3
|
jsonrepair: 3.13.3
|
||||||
ollama: 0.6.3
|
ollama: 0.6.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- zod
|
- zod
|
||||||
|
|
||||||
ai@6.0.134(zod@4.3.6):
|
ai@6.0.134(patch_hash=f60bfc3357e01e1f3978c6c40fdd65aeb33fefaad7179cde8676465b6c5ff4d9)(zod@4.3.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@ai-sdk/gateway': 3.0.77(zod@4.3.6)
|
'@ai-sdk/gateway': 3.0.77(zod@4.3.6)
|
||||||
'@ai-sdk/provider': 3.0.8
|
'@ai-sdk/provider': 3.0.8
|
||||||
@@ -16773,6 +16856,11 @@ snapshots:
|
|||||||
base64-js: 1.5.1
|
base64-js: 1.5.1
|
||||||
ieee754: 1.2.1
|
ieee754: 1.2.1
|
||||||
|
|
||||||
|
buffer@6.0.3:
|
||||||
|
dependencies:
|
||||||
|
base64-js: 1.5.1
|
||||||
|
ieee754: 1.2.1
|
||||||
|
|
||||||
bullmq@5.76.10:
|
bullmq@5.76.10:
|
||||||
dependencies:
|
dependencies:
|
||||||
cron-parser: 4.9.0
|
cron-parser: 4.9.0
|
||||||
@@ -17526,6 +17614,13 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
|
|
||||||
|
duplexify@3.7.1:
|
||||||
|
dependencies:
|
||||||
|
end-of-stream: 1.4.4
|
||||||
|
inherits: 2.0.4
|
||||||
|
readable-stream: 2.3.8
|
||||||
|
stream-shift: 1.0.3
|
||||||
|
|
||||||
ecdsa-sig-formatter@1.0.11:
|
ecdsa-sig-formatter@1.0.11:
|
||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
@@ -17952,6 +18047,8 @@ snapshots:
|
|||||||
|
|
||||||
estraverse@5.3.0: {}
|
estraverse@5.3.0: {}
|
||||||
|
|
||||||
|
estree-walker@2.0.2: {}
|
||||||
|
|
||||||
estree-walker@3.0.3:
|
estree-walker@3.0.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.9
|
'@types/estree': 1.0.9
|
||||||
@@ -17960,6 +18057,8 @@ snapshots:
|
|||||||
|
|
||||||
etag@1.8.1: {}
|
etag@1.8.1: {}
|
||||||
|
|
||||||
|
event-target-shim@5.0.1: {}
|
||||||
|
|
||||||
eventemitter2@6.4.9: {}
|
eventemitter2@6.4.9: {}
|
||||||
|
|
||||||
eventemitter3@4.0.7: {}
|
eventemitter3@4.0.7: {}
|
||||||
@@ -20223,6 +20322,12 @@ snapshots:
|
|||||||
|
|
||||||
peberminta@0.9.0: {}
|
peberminta@0.9.0: {}
|
||||||
|
|
||||||
|
peek-stream@1.1.3:
|
||||||
|
dependencies:
|
||||||
|
buffer-from: 1.1.2
|
||||||
|
duplexify: 3.7.1
|
||||||
|
through2: 2.0.5
|
||||||
|
|
||||||
pend@1.2.0: {}
|
pend@1.2.0: {}
|
||||||
|
|
||||||
perfect-freehand@1.2.0: {}
|
perfect-freehand@1.2.0: {}
|
||||||
@@ -20494,6 +20599,8 @@ snapshots:
|
|||||||
|
|
||||||
process-warning@5.0.0: {}
|
process-warning@5.0.0: {}
|
||||||
|
|
||||||
|
process@0.11.10: {}
|
||||||
|
|
||||||
prom-client@15.1.3:
|
prom-client@15.1.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@opentelemetry/api': 1.9.0
|
'@opentelemetry/api': 1.9.0
|
||||||
@@ -20915,6 +21022,14 @@ snapshots:
|
|||||||
string_decoder: 1.3.0
|
string_decoder: 1.3.0
|
||||||
util-deprecate: 1.0.2
|
util-deprecate: 1.0.2
|
||||||
|
|
||||||
|
readable-stream@4.7.0:
|
||||||
|
dependencies:
|
||||||
|
abort-controller: 3.0.0
|
||||||
|
buffer: 6.0.3
|
||||||
|
events: 3.3.0
|
||||||
|
process: 0.11.10
|
||||||
|
string_decoder: 1.3.0
|
||||||
|
|
||||||
readdirp@3.6.0:
|
readdirp@3.6.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
picomatch: 2.3.2
|
picomatch: 2.3.2
|
||||||
@@ -21368,6 +21483,8 @@ snapshots:
|
|||||||
inherits: 2.0.4
|
inherits: 2.0.4
|
||||||
readable-stream: 3.6.2
|
readable-stream: 3.6.2
|
||||||
|
|
||||||
|
stream-shift@1.0.3: {}
|
||||||
|
|
||||||
strict-event-emitter-types@2.0.0: {}
|
strict-event-emitter-types@2.0.0: {}
|
||||||
|
|
||||||
string-length@4.0.2:
|
string-length@4.0.2:
|
||||||
@@ -21528,6 +21645,8 @@ snapshots:
|
|||||||
|
|
||||||
tapable@2.3.0: {}
|
tapable@2.3.0: {}
|
||||||
|
|
||||||
|
tar-mini@0.2.0: {}
|
||||||
|
|
||||||
tar-stream@2.2.0:
|
tar-stream@2.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
bl: 4.1.0
|
bl: 4.1.0
|
||||||
@@ -21577,6 +21696,11 @@ snapshots:
|
|||||||
|
|
||||||
throttleit@2.1.0: {}
|
throttleit@2.1.0: {}
|
||||||
|
|
||||||
|
through2@2.0.5:
|
||||||
|
dependencies:
|
||||||
|
readable-stream: 2.3.8
|
||||||
|
xtend: 4.0.2
|
||||||
|
|
||||||
tiny-invariant@1.3.3: {}
|
tiny-invariant@1.3.3: {}
|
||||||
|
|
||||||
tinybench@2.9.0: {}
|
tinybench@2.9.0: {}
|
||||||
@@ -21981,6 +22105,13 @@ snapshots:
|
|||||||
|
|
||||||
vary@1.1.2: {}
|
vary@1.1.2: {}
|
||||||
|
|
||||||
|
vite-plugin-compression2@2.5.3:
|
||||||
|
dependencies:
|
||||||
|
'@rollup/pluginutils': 5.4.0
|
||||||
|
tar-mini: 0.2.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- rollup
|
||||||
|
|
||||||
vite@8.0.5(@types/node@20.19.43)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3):
|
vite@8.0.5(@types/node@20.19.43)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
@@ -22361,8 +22492,7 @@ snapshots:
|
|||||||
|
|
||||||
xpath@0.0.34: {}
|
xpath@0.0.34: {}
|
||||||
|
|
||||||
xtend@4.0.2:
|
xtend@4.0.2: {}
|
||||||
optional: true
|
|
||||||
|
|
||||||
y-indexeddb@9.0.12(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)):
|
y-indexeddb@9.0.12(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)):
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user