From 546be17d47d7fe1418acd373fccbef6787d7dca0 Mon Sep 17 00:00:00 2001 From: Auto-Dev Agent Date: Mon, 18 May 2026 00:49:54 +0000 Subject: [PATCH 01/27] feat: add content-node editor scope --- .claude/skills/bootstrap-dataset/SKILL.md | 381 ++++++++++ .../collection-screenshot-evidence.spec.ts | 155 ++++ apps/app-api/src/orpc/router.ts | 3 + apps/app-api/src/orpc/routers/collection.ts | 54 ++ apps/app-api/src/orpc/routers/document.ts | 239 ++++-- apps/app-api/src/orpc/routers/editor.spec.ts | 634 ++++++++++++++++ apps/app-api/src/orpc/routers/editor.ts | 286 ++++++++ apps/app-api/src/orpc/routers/translation.ts | 13 +- apps/app-e2e/tests/editor.spec.ts | 41 +- apps/app-e2e/tests/pages/editor-page.ts | 42 +- apps/app/locales/en_us.json | 25 + apps/app/src/components/ContentNodeTree.vue | 114 +++ .../src/components/ContentNodeTreeNode.vue | 139 ++++ .../DocumentTranslationProgress.vue | 38 +- apps/app/src/components/DocumentTree.vue | 79 +- apps/app/src/components/DocumentTreeNode.vue | 2 +- .../components/agent/AgentChatPanel.spec.ts | 293 ++++++++ .../src/components/agent/AgentChatPanel.vue | 27 +- .../src/components/shared/BranchCombobox.vue | 34 +- apps/app/src/pages/editor/+Layout.vue | 84 ++- apps/app/src/pages/editor/+Page.vue | 18 +- apps/app/src/pages/editor/+guard.server.ts | 38 +- .../pages/editor/ContentNodeFilterPicker.vue | 104 +++ .../editor/CurrentTranslationQaResult.vue | 20 +- .../src/pages/editor/EditorScopeBar.spec.ts | 172 +++++ apps/app/src/pages/editor/EditorScopeBar.vue | 68 ++ .../src/pages/editor/EditorStatusFilter.vue | 67 ++ apps/app/src/pages/editor/ElementSearcher.vue | 44 +- apps/app/src/pages/editor/Header.vue | 21 +- apps/app/src/pages/editor/Sidebar.vue | 4 - apps/app/src/pages/editor/SidebarElement.vue | 18 +- .../src/pages/editor/SidebarPagination.vue | 60 +- apps/app/src/pages/editor/Toolbar.vue | 14 +- apps/app/src/pages/editor/Workbench.vue | 21 + apps/app/src/pages/editor/empty/+Page.vue | 116 +-- .../@languageToId/@elementId/+Page.vue | 7 + .../@elementId/+guard.server.spec.ts | 112 +++ .../@languageToId/@elementId/+guard.server.ts | 52 ++ .../@projectId/@languageToId/empty/+Page.vue | 113 +++ apps/app/src/pages/editor/scope-url.spec.ts | 85 +++ apps/app/src/pages/editor/scope-url.ts | 141 ++++ .../pages/editor/toolbar.qa-context.spec.ts | 161 ++++ .../@projectId/index/@languageId/+Page.vue | 12 +- .../@languageId/LanguageDocumentTree.vue | 10 +- apps/app/src/stores/__tests__/branch.spec.ts | 20 + apps/app/src/stores/branch.ts | 14 + apps/app/src/stores/editor/context.ts | 140 +++- apps/app/src/stores/editor/element.ts | 64 +- .../editor/table.scope-navigation.spec.ts | 211 ++++++ apps/app/src/stores/editor/table.ts | 188 +++-- .../src/utils/agent/register-client-tools.ts | 20 +- .../src/utils/agent/session-hydration.spec.ts | 10 + apps/cli/src/routes.generated.ts | 14 +- apps/docs/src/autodoc/.symbol-index.json | 2 +- apps/docs/src/autodoc/agent/references.json | 685 ++++++++++++++++-- .../docs/src/autodoc/ai/ai--agent-tools.en.md | 3 + .../src/autodoc/domain/domain--core.en.md | 2 +- .../infra/infra--screenshot-collector.en.md | 4 + apps/docs/src/autodoc/infra/infra--seed.en.md | 13 + .../src/autodoc/infra/infra--shared.en.md | 2 +- .../infra/infra--source-collector.en.md | 5 + apps/docs/src/autodoc/infra/infra--vcs.en.md | 4 + apps/docs/src/autodoc/llms.txt | 14 +- apps/docs/src/autodoc/overview.md | 14 +- apps/docs/src/autodoc/packages/agent-tools.md | 43 +- apps/docs/src/autodoc/packages/domain.md | 121 +++- apps/docs/src/autodoc/packages/operations.md | 4 +- .../autodoc/packages/screenshot-collector.md | 30 +- apps/docs/src/autodoc/packages/seed.md | 86 ++- apps/docs/src/autodoc/packages/shared.md | 24 +- .../src/autodoc/packages/source-collector.md | 83 ++- apps/docs/src/autodoc/packages/vcs.md | 12 +- apps/eval/src/seeder/seeder.ts | 18 +- package.json | 2 +- .../src/translation/assert-session-scope.ts | 187 ++++- .../src/translation/get-documents.tool.ts | 32 +- .../src/translation/list-elements.tool.ts | 81 ++- .../translation/submit-translation.tool.ts | 7 +- .../src/translation/translation-tools.spec.ts | 268 ++++++- .../agent/src/dag/nodes/tool-node.spec.ts | 12 + packages/agent/src/dag/nodes/tool-node.ts | 4 + .../agent/src/runtime/prompt-variables.ts | 1 + packages/agent/src/tool/tool-types.ts | 4 + .../apply-content-graph-envelope.cmd.ts | 56 +- ...-primary-element-relations-for-diff.cmd.ts | 46 ++ .../add-element-context-evidence.cmd.test.ts | 114 +++ .../add-element-context-evidence.cmd.ts | 100 +++ packages/domain/src/commands/index.ts | 2 + .../content/editor-scope-branch.test.ts | 424 +++++++++++ .../content/editor-scope-elements.query.ts | 551 ++++++++++++++ .../content/editor-scope-elements.test.ts | 391 ++++++++++ .../list-editor-scope-content-nodes.query.ts | 167 +++++ .../get-translatable-element-row.query.ts | 38 + .../list-elements-by-importer-scope.query.ts | 10 + packages/domain/src/queries/index.ts | 3 + .../apply-structured-content-graph.test.ts | 6 + .../src/diff-structured-content.test.ts | 304 +++++++- .../operations/src/diff-structured-content.ts | 135 +++- .../src/__tests__/types.spec.ts | 11 + .../src/__tests__/upload.spec.ts | 24 +- packages/screenshot-collector/src/cli.ts | 71 +- .../screenshot-collector/src/screenshot.ts | 53 +- packages/screenshot-collector/src/types.ts | 10 + packages/screenshot-collector/src/upload.ts | 146 +++- packages/seed/moon.yml | 27 +- packages/seed/package.json | 5 + .../seed/src/bootstrap/locale-bridge.spec.ts | 165 +++++ packages/seed/src/bootstrap/locale-bridge.ts | 241 ++++++ packages/seed/src/bootstrap/profile.spec.ts | 46 ++ packages/seed/src/bootstrap/report.spec.ts | 75 ++ packages/seed/src/bootstrap/report.ts | 73 ++ .../src/bootstrap/source-bootstrap.spec.ts | 213 ++++++ .../seed/src/bootstrap/source-bootstrap.ts | 263 +++++++ packages/seed/src/index.ts | 42 +- packages/seed/src/loader.ts | 4 +- packages/seed/src/pipeline.ts | 85 ++- packages/seed/src/safety.spec.ts | 36 + packages/seed/src/safety.ts | 62 ++ packages/seed/src/schemas.ts | 73 ++ packages/seed/tsconfig.json | 13 +- packages/seed/tsconfig.lib.json | 16 + packages/seed/tsconfig.spec.json | 25 + packages/shared/src/index.ts | 25 + .../src/schema/__tests__/editor.spec.ts | 54 ++ .../src/schema/__tests__/extraction.spec.ts | 20 + packages/shared/src/schema/agent.ts | 4 + packages/shared/src/schema/editor.ts | 190 +++++ packages/shared/src/schema/extraction.ts | 18 + .../src/__tests__/adapter.spec.ts | 2 + .../src/__tests__/collect.spec.ts | 28 +- .../src/__tests__/extract.spec.ts | 31 +- .../src/__tests__/vue-i18n.spec.ts | 96 ++- packages/source-collector/src/cli.ts | 12 +- packages/source-collector/src/collect.ts | 10 +- packages/source-collector/src/extract.ts | 119 ++- .../src/extractors/script-extract.ts | 98 ++- .../src/extractors/stable-ref.ts | 69 ++ .../src/extractors/template-extract.ts | 205 ++++-- .../src/extractors/vue-i18n.ts | 27 +- packages/source-collector/src/index.ts | 2 + packages/source-collector/src/types.ts | 27 + packages/vcs/package.json | 3 +- .../vcs/src/editor-overlay-payload.spec.ts | 72 ++ packages/vcs/src/editor-overlay-payload.ts | 133 ++++ packages/vcs/src/index.ts | 13 + .../src/wire-entity-state-fetchers.spec.ts | 154 ++++ .../vcs/src/wire-entity-state-fetchers.ts | 96 ++- pnpm-lock.yaml | 7 + tools/seeder/main.ts | 20 +- vitest.config.ts | 8 + 150 files changed, 11304 insertions(+), 974 deletions(-) create mode 100644 .claude/skills/bootstrap-dataset/SKILL.md create mode 100644 apps/app-api/src/__tests__/collection-screenshot-evidence.spec.ts create mode 100644 apps/app-api/src/orpc/routers/editor.spec.ts create mode 100644 apps/app-api/src/orpc/routers/editor.ts create mode 100644 apps/app/src/components/ContentNodeTree.vue create mode 100644 apps/app/src/components/ContentNodeTreeNode.vue create mode 100644 apps/app/src/components/agent/AgentChatPanel.spec.ts create mode 100644 apps/app/src/pages/editor/ContentNodeFilterPicker.vue create mode 100644 apps/app/src/pages/editor/EditorScopeBar.spec.ts create mode 100644 apps/app/src/pages/editor/EditorScopeBar.vue create mode 100644 apps/app/src/pages/editor/EditorStatusFilter.vue create mode 100644 apps/app/src/pages/editor/Workbench.vue create mode 100644 apps/app/src/pages/editor/project/@projectId/@languageToId/@elementId/+Page.vue create mode 100644 apps/app/src/pages/editor/project/@projectId/@languageToId/@elementId/+guard.server.spec.ts create mode 100644 apps/app/src/pages/editor/project/@projectId/@languageToId/@elementId/+guard.server.ts create mode 100644 apps/app/src/pages/editor/project/@projectId/@languageToId/empty/+Page.vue create mode 100644 apps/app/src/pages/editor/scope-url.spec.ts create mode 100644 apps/app/src/pages/editor/scope-url.ts create mode 100644 apps/app/src/pages/editor/toolbar.qa-context.spec.ts create mode 100644 apps/app/src/stores/editor/table.scope-navigation.spec.ts create mode 100644 packages/domain/src/commands/content/update-primary-element-relations-for-diff.cmd.ts create mode 100644 packages/domain/src/commands/context/add-element-context-evidence.cmd.test.ts create mode 100644 packages/domain/src/commands/context/add-element-context-evidence.cmd.ts create mode 100644 packages/domain/src/queries/content/editor-scope-branch.test.ts create mode 100644 packages/domain/src/queries/content/editor-scope-elements.query.ts create mode 100644 packages/domain/src/queries/content/editor-scope-elements.test.ts create mode 100644 packages/domain/src/queries/content/list-editor-scope-content-nodes.query.ts create mode 100644 packages/domain/src/queries/element/get-translatable-element-row.query.ts create mode 100644 packages/seed/src/bootstrap/locale-bridge.spec.ts create mode 100644 packages/seed/src/bootstrap/locale-bridge.ts create mode 100644 packages/seed/src/bootstrap/profile.spec.ts create mode 100644 packages/seed/src/bootstrap/report.spec.ts create mode 100644 packages/seed/src/bootstrap/report.ts create mode 100644 packages/seed/src/bootstrap/source-bootstrap.spec.ts create mode 100644 packages/seed/src/bootstrap/source-bootstrap.ts create mode 100644 packages/seed/src/safety.spec.ts create mode 100644 packages/seed/src/safety.ts create mode 100644 packages/seed/tsconfig.lib.json create mode 100644 packages/seed/tsconfig.spec.json create mode 100644 packages/shared/src/schema/__tests__/editor.spec.ts create mode 100644 packages/shared/src/schema/editor.ts create mode 100644 packages/source-collector/src/extractors/stable-ref.ts create mode 100644 packages/vcs/src/editor-overlay-payload.spec.ts create mode 100644 packages/vcs/src/editor-overlay-payload.ts create mode 100644 packages/vcs/src/wire-entity-state-fetchers.spec.ts diff --git a/.claude/skills/bootstrap-dataset/SKILL.md b/.claude/skills/bootstrap-dataset/SKILL.md new file mode 100644 index 000000000..c3b4daeb3 --- /dev/null +++ b/.claude/skills/bootstrap-dataset/SKILL.md @@ -0,0 +1,381 @@ +--- +name: bootstrap-dataset +description: 使用 CAT 自举数据集从当前 `apps/app` 源码生成真实测试数据,执行 source-first seed、可选截图回填和上下文验证。当 agent 需要为本地调试、上下文回归、邻居元素验证或截图证据测试生成一套“来自 CAT 自身”的真实数据时使用此 skill。 +user-invocable: true +--- + +# CAT 自举数据集(Bootstrap Dataset) + +这个 skill 用仓库内置的 `tools/seeder/datasets/bootstrap-app`,把当前 `apps/app` 自身转成一套更真实的测试数据。 + +它分成三段: + +1. **核心 seed**:从 `apps/app` 源码提取 Vue i18n 元素,桥接 locale catalog,把源码/locale 证据写入数据库。 +2. **截图增强(可选)**:对 live app 抓取页面截图,并把 `SCREENSHOT` evidence 回填到已有元素上。 +3. **抽样验证**:确认上下文查询能同时回出源码、locale、截图、邻居元素等证据。 + +## 何时使用 + +在这些场景优先用这个 skill: + +- 需要一套比 `e2e` / 静态 JSON seed 更真实的测试数据。 +- 需要验证 `source file`、`locale:*`、`screenshot:*`、`local sequence neighbor` 等上下文能否一起回归。 +- 需要从当前 CAT 自身源码生成 seed,而不是手写 `elements.json`。 +- 需要为召回、上下文组装、截图证据、源码定位等功能准备回归数据。 + +如果你只需要最小化、静态、确定性的手写种子数据,优先使用 `seeder` skill;如果你只需要单独调试源码扫描或截图采集,也可以搭配 `source-collection` skill 使用。 + +## 当前系统职责边界 + +这条链路当前由以下层次组成: + +- `@cat/source-collector`:从 `apps/app` 源码提取元素,并在 collector/graph assembly 层把文本按**每个源码文件一个 `ContentNode`** 组织起来。 +- `@cat/seed`:读取 `tools/seeder/datasets/bootstrap-app/seed.yaml`,执行 bootstrap profile,把 source graph + locale bridge 注入数据库。 +- `@cat/screenshot-collector`:读取 route manifest、bindings 和 extraction result,采集截图并上传证据。 +- `apps/app-api/src/orpc/routers/collection.ts`:负责 `prepareUpload` / `finishUpload` / `addScreenshotEvidence`。 + +**重要**:当前“按源码相对路径拆分 `ContentNode`”的行为来自 `@cat/source-collector` 的组图层,而不是应用层二次重组。 + +## 关键入口与产物 + +固定入口: + +- 数据集目录:`tools/seeder/datasets/bootstrap-app` +- 配置文件:`tools/seeder/datasets/bootstrap-app/seed.yaml` +- 截图路由:`tools/seeder/datasets/bootstrap-app/routes.yaml` +- 数据集报告:`tools/seeder/datasets/bootstrap-app/artifacts/bootstrap-report.json` + +推荐临时产物路径: + +- bindings:`/tmp/bootstrap-runtime-bindings.json` +- extraction:`/tmp/bootstrap-extraction.json` +- capture result:`/tmp/bootstrap-capture.json` +- screenshot 目录:`/tmp/bootstrap-screenshots` + +## 前置条件 + +### 核心 seed 必需 + +- `apps/app/.env` 中的 `DATABASE_URL` / `REDIS_URL` 可用。 +- 目标数据库是**可丢弃**的开发/测试库。 +- 从仓库根目录执行命令。 + +### 截图增强额外需要 + +- 有一个连到**同一数据库**的 live app(`app:dev` 或 `app:preview` 都可以)。 +- 能访问应用的 base URL,例如 `http://localhost:3000`。 +- 若路由清单包含受保护页面(当前包含 `/settings/security`),需要: + - `--auth-email` + `--auth-password`,或 + - `--auth-storage-state` +- 有一个可上传文件、且对目标项目具备编辑权限的 runtime API key。 + +### 改过代码时的附加要求 + +如果你刚改过这些包,再运行本 skill 前先构建对应产物: + +- `packages/seed` +- `packages/source-collector` +- `packages/screenshot-collector` + +尤其是 `tools/seeder/main.ts` 通过 `@cat/seed` 包入口消费逻辑时,**改了 `packages/seed` 之后要先重新 build**,否则容易出现“源码改了但 seed 实际没吃到”的幽灵问题。 + +## 最短路径:先做 source-first seed + +这是默认路径;它不依赖浏览器、不依赖 API key,也不依赖截图服务。 + +```bash +pnpm tsx tools/seeder/main.ts \ + tools/seeder/datasets/bootstrap-app \ + --skip-vectorization \ + --output-bindings /tmp/bootstrap-runtime-bindings.json +``` + +当前 `bootstrap-app/seed.yaml` 默认也是 `vectorization.enabled: false`。这意味着: + +- 对 **source graph / locale bridge / screenshot evidence / context assembly** 回归来说,这已经足够; +- 如果目标是 **向量召回 / 邻近检索 / embedding 驱动功能**,则需要额外启用向量化并保证向量服务可用,再决定是否去掉 `--skip-vectorization`。 + +### 这一步会做什么 + +- 清空目标库(受数据库安全保护约束) +- 创建基础项目/语言/用户/插件配置 +- 从 `apps/app/src/**/*.{vue,ts}` 提取 `zh-Hans` 源元素 +- 用 `apps/app/locales/en_us.json` 生成 `en` locale evidence 与 locale memory material +- 通过结构化 diff 路径把 source graph 注入数据库 +- 写出 bindings 和 bootstrap report + +### 预期产物 + +- `tools/seeder/datasets/bootstrap-app/artifacts/bootstrap-report.json` +- `/tmp/bootstrap-runtime-bindings.json` + +bindings 至少应包含: + +- `project` +- `document:root` +- `content-node:*` +- `element:vue-i18n:*` + +### 语言策略 + +当前 bootstrap profile 的语言策略是固定显式声明的: + +- source language:`zh-Hans` +- locale catalog:`apps/app/locales/en_us.json` +- locale catalog 对应 DB 语言:`en` + +**不要**把 `zh-CN`、`zh_cn`、文件名别名之类的东西当作隐式等价物,除非 profile 显式映射。 + +## 可选:启动 live app 做截图增强 + +如果只需要 source + locale 数据,可以跳过本节。 + +如果要验证截图证据、受保护页面、或最终 assembled contexts,请启动一个连到同一数据库的 live app。 + +最简单做法:确保 `apps/app/.env` 已指向刚 seed 的数据库,然后运行: + +```bash +pnpm moon run app:dev +``` + +如果你 seed 到一个临时数据库而不想改 `.env`,可以临时覆盖 `DATABASE_URL` 后再启动 app。 + +## 生成 ExtractionResult 供截图器使用 + +截图采集的 `capture` 子命令吃的是 `ExtractionResult`,不是 seeder report。 + +```bash +pnpm tsx packages/source-collector/src/cli.ts extract \ + --base-dir apps/app \ + --glob "src/**/*.{vue,ts}" \ + --framework vue-i18n \ + --source-lang zh-Hans \ + --output /tmp/bootstrap-extraction.json +``` + +### 为什么这里单独再跑一次 extract + +因为: + +- 核心 seed 是“提取并直接入库”; +- 截图器需要一份独立的 extraction JSON 作为页面定位输入; +- bindings 文件会把 `elementRef` 映射回数据库里的真实 `elementId`。 + +## 可选:采集截图 + +使用数据集自带的 route manifest: + +```bash +pnpm tsx packages/screenshot-collector/src/cli.ts capture \ + --base-url http://localhost:3000 \ + --routes tools/seeder/datasets/bootstrap-app/routes.yaml \ + --bindings /tmp/bootstrap-runtime-bindings.json \ + --elements /tmp/bootstrap-extraction.json \ + --output-dir /tmp/bootstrap-screenshots \ + --output /tmp/bootstrap-capture.json +``` + +如果需要访问登录态页面,补上认证参数,例如: + +```bash +pnpm tsx packages/screenshot-collector/src/cli.ts capture \ + --base-url http://localhost:3000 \ + --routes tools/seeder/datasets/bootstrap-app/routes.yaml \ + --bindings /tmp/bootstrap-runtime-bindings.json \ + --elements /tmp/bootstrap-extraction.json \ + --output-dir /tmp/bootstrap-screenshots \ + --output /tmp/bootstrap-capture.json \ + --auth-email admin@cat.dev \ + --auth-password password +``` + +### 严格模式(可选) + +如果你要把截图覆盖率当作回归门槛,可以加: + +- `--strict-min-screenshots ` +- `--strict-route `(可重复) + +例如: + +```bash +pnpm tsx packages/screenshot-collector/src/cli.ts capture \ + --base-url http://localhost:3000 \ + --routes tools/seeder/datasets/bootstrap-app/routes.yaml \ + --bindings /tmp/bootstrap-runtime-bindings.json \ + --elements /tmp/bootstrap-extraction.json \ + --output-dir /tmp/bootstrap-screenshots \ + --output /tmp/bootstrap-capture.json \ + --strict-min-screenshots 1 \ + --strict-route /settings/security +``` + +## 可选:上传截图证据到平台 + +`screenshot-collector upload` 会自动走当前正确的三段式上传流程: + +1. `collection/prepareUpload` +2. `PUT` 文件 +3. `collection/finishUpload` +4. `collection/addScreenshotEvidence` + +推荐命令: + +```bash +CAT_API_KEY="" \ +pnpm tsx packages/screenshot-collector/src/cli.ts upload \ + --capture /tmp/bootstrap-capture.json \ + --bindings /tmp/bootstrap-runtime-bindings.json \ + --project-id "" \ + --document-name cat-app-source \ + --api-url http://localhost:3000 +``` + +也可以显式传 `--api-key`。 + +### 取 `projectId` + +`projectId` 来自 bindings 文件里的 `project` 键。不要手猜。 + +### API key 要求 + +上传截图证据时,API key 必须对目标项目拥有足够权限。 + +实践上至少满足其一: + +- 具备 `project:*` scope,或 +- 对目标 project 有 editor/owner 权限 + +**空 scope 数组等于没有权限**;这会在 `prepareUpload` / `addScreenshotEvidence` 上直接报 `FORBIDDEN`。 + +## 抽样验证:确认上下文真的回来了 + +如果你只 seed 不截图,验证重点是 `source file` / `locale:*` / `neighbor`。 + +如果你已经上传截图,再验证 `screenshot:*`。 + +### 建议检查项 + +任选一个 bindings 里的 `element:*`,调用: + +- `element/getContexts` +- `element/getSourceLocation` + +`getContexts` 里理想上应看到这些标签中的若干项: + +- `element key` +- `source file` +- `locale:` +- `screenshot:` +- `local sequence neighbor` + +### 典型 live 验证命令 + +```bash +curl -sS \ + -H "authorization: Bearer $CAT_API_KEY" \ + -H "content-type: application/json" \ + -d '{"json":{"elementId":1234}}' \ + http://localhost:3000/api/rpc/element/getContexts +``` + +```bash +curl -sS \ + -H "authorization: Bearer $CAT_API_KEY" \ + -H "content-type: application/json" \ + -d '{"json":{"elementId":1234}}' \ + http://localhost:3000/api/rpc/element/getSourceLocation +``` + +把 `1234` 换成 bindings 中解析出的真实 `elementId`。 + +## 推荐工作流 + +### 只需要“真实源码 + locale”测试数据 + +1. 跑 bootstrap seed +2. 检查 report / bindings +3. 如需 API 层验证,抽样调用 `getContexts` + +### 需要“源码 + locale + 截图 + 邻居”完整回归 + +1. 跑 bootstrap seed +2. 启动 live app(同一数据库) +3. 跑 `source-collector extract` +4. 跑 `screenshot-collector capture` +5. 跑 `screenshot-collector upload` +6. 抽样调用 `element/getContexts` / `getSourceLocation` + +## 当前已知坑点 + +### 1. 改了 `packages/seed` 却忘了 build + +症状:你以为 seed 逻辑已经更新,但实际运行结果还是旧行为。 + +原因:`tools/seeder/main.ts` 通过包入口消费 `@cat/seed`,而不是总是直接读源文件。 + +处理:改完 `packages/seed` 后先重新构建再 seed。 + +### 2. API key scope 为空导致上传全被拒绝 + +症状:`prepareUpload` / `addScreenshotEvidence` 返回 `FORBIDDEN`。 + +原因:API key 虽然存在,但 `scopes: []` 实际上没有任何项目权限。 + +处理:使用带 `project:*` 或等效 project editor 权限的 key。 + +### 3. 误用旧的 collect/addContexts 心智模型 + +当前 bootstrap 截图回填推荐走: + +- `capture` +- `upload` + +而不是自己手工拼旧版 `collection.addContexts` 调用。 + +### 4. 语言 ID 写错 + +当前 bootstrap source language 是 `zh-Hans`,不是 `zh_cn`、`zh-CN` 或别名。 + +### 5. screenshot capture 命中受保护页面但没带登录信息 + +症状:公开页有截图,受保护页(例如 `/settings/security`)没有。 + +处理:为 `capture` 提供 `--auth-email` / `--auth-password` 或 `--auth-storage-state`。 + +### 6. 数据库安全保护拦住了 reset + +症状:seeder 在 truncate 前拒绝执行。 + +原因:目标数据库看起来不像 dev/test/local disposable DB。 + +处理: + +- 优先改用真正的测试库名; +- 只有在你确认目标库可销毁时,才使用 `--allow-unsafe-reset`。 + +## 何时停止 + +在这些情况下应停止继续“自动重试”: + +- 数据库不是可丢弃库,但你又无法确认是否可以重置。 +- 截图上传需要新的 secret(密码、API key、storage state)且当前会话里没有安全来源。 +- live app 与 seed 数据库不一致,导致截图绑定和上下文查询结果明显错位。 + +## 完成定义 + +一次 bootstrap dataset 生成任务,至少应给出这些结果中的若干项: + +- seed 命令成功日志 +- `bootstrap-report.json` +- bindings 文件 +- (可选)capture result JSON +- (可选)截图目录 +- (可选)`getContexts` / `getSourceLocation` 抽样结果 + +如果任务目标包含截图回归,则应额外确认: + +- 至少一个元素出现 `screenshot:*` 证据 +- 同一元素还能同时返回 `source file` / `locale:*` / `local sequence neighbor` + +这说明自举数据集不仅“种进去了”,而且“真的能被上下文系统用起来”。 diff --git a/apps/app-api/src/__tests__/collection-screenshot-evidence.spec.ts b/apps/app-api/src/__tests__/collection-screenshot-evidence.spec.ts new file mode 100644 index 000000000..54e3e2dc9 --- /dev/null +++ b/apps/app-api/src/__tests__/collection-screenshot-evidence.spec.ts @@ -0,0 +1,155 @@ +import type { DrizzleDB } from "@cat/domain"; + +import { createAuthedTestContext } from "@cat/test-utils"; +import { call } from "@orpc/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { Context } from "@/utils/context"; + +const domainMocks = vi.hoisted(() => ({ + executeCommand: vi.fn(), + addElementContextEvidence: Symbol("addElementContextEvidence"), +})); + +vi.mock("@cat/domain", async () => { + const actual = + await vi.importActual("@cat/domain"); + return { + ...actual, + executeCommand: domainMocks.executeCommand, + addElementContextEvidence: domainMocks.addElementContextEvidence, + }; +}); + +vi.mock("@cat/permissions", async () => { + const actual = + await vi.importActual( + "@cat/permissions", + ); + return { + ...actual, + getPermissionEngine: () => ({ + check: vi.fn().mockResolvedValue(true), + }), + }; +}); + +import { addScreenshotEvidence } from "@/orpc/routers/collection"; + +const createMockDrizzleDB = (): Context["drizzleDB"] => { + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Test-only stub; this router only reads drizzleDB.client. + const client = { mocked: true } as unknown as DrizzleDB["client"]; + + return { + client, + connect: async () => Promise.resolve(), + disconnect: async () => Promise.resolve(), + migrate: async () => Promise.resolve(), + ping: async () => Promise.resolve(), + }; +}; + +const createContext = (): Context => { + const base = createAuthedTestContext(); + + return { + ...base, + auth: { + subjectType: "user", + subjectId: base.user!.id, + systemRoles: ["admin"], + scopes: [], + }, + drizzleDB: createMockDrizzleDB(), + isSSR: true, + isWebSocket: false, + } as Context; +}; + +describe("collection.addScreenshotEvidence", () => { + beforeEach(() => { + vi.clearAllMocks(); + domainMocks.executeCommand.mockResolvedValue({ addedCount: 1 }); + }); + + it("maps uploaded screenshots to addElementContextEvidence", async () => { + const projectId = "44444444-4444-4444-8444-444444444444"; + + const result = await call( + addScreenshotEvidence, + { + projectId, + screenshots: [ + { + elementId: 42, + elementRef: "vue-i18n:src/App.vue:template:L1", + fileId: 7, + route: "/projects/demo", + highlightRegion: { x: 10, y: 20, width: 30, height: 40 }, + }, + ], + }, + { context: createContext() }, + ); + + expect(result).toEqual({ addedCount: 1 }); + expect(domainMocks.executeCommand).toHaveBeenCalledWith( + { db: expect.objectContaining({ mocked: true }) }, + domainMocks.addElementContextEvidence, + { + projectId, + evidence: [ + { + elementId: 42, + kind: "SCREENSHOT", + fileId: 7, + jsonData: { + highlightRegion: { x: 10, y: 20, width: 30, height: 40 }, + }, + displayLabel: "screenshot:/projects/demo", + trustLevel: "COLLECTED", + provenance: { + source: "screenshot-collector", + route: "/projects/demo", + elementRef: "vue-i18n:src/App.vue:template:L1", + }, + }, + ], + }, + ); + }); + + it("stores null jsonData when highlightRegion is absent", async () => { + const projectId = "44444444-4444-4444-8444-444444444444"; + + await call( + addScreenshotEvidence, + { + projectId, + screenshots: [ + { + elementId: 42, + elementRef: "vue-i18n:src/App.vue:template:L1", + fileId: 7, + route: "/projects/demo", + }, + ], + }, + { context: createContext() }, + ); + + expect(domainMocks.executeCommand).toHaveBeenCalledWith( + { db: expect.objectContaining({ mocked: true }) }, + domainMocks.addElementContextEvidence, + { + projectId, + evidence: [ + expect.objectContaining({ + jsonData: null, + displayLabel: "screenshot:/projects/demo", + }), + ], + }, + ); + }); +}); diff --git a/apps/app-api/src/orpc/router.ts b/apps/app-api/src/orpc/router.ts index 6c2c32ae6..5a753fd4a 100644 --- a/apps/app-api/src/orpc/router.ts +++ b/apps/app-api/src/orpc/router.ts @@ -5,6 +5,7 @@ import * as changeset from "./routers/changeset.ts"; import * as collection from "./routers/collection.ts"; import * as comment from "./routers/comment.ts"; import * as document from "./routers/document.ts"; +import * as editor from "./routers/editor.ts"; import * as element from "./routers/element.ts"; import * as ghostText from "./routers/ghost-text.ts"; import * as glossary from "./routers/glossary.ts"; @@ -42,6 +43,7 @@ const router: AppRouter = { user, setting, document, + editor, element, ghostText, glossary, @@ -73,6 +75,7 @@ export type AppRouter = { user: typeof user; setting: typeof setting; document: typeof document; + editor: typeof editor; element: typeof element; ghostText: typeof ghostText; glossary: typeof glossary; diff --git a/apps/app-api/src/orpc/routers/collection.ts b/apps/app-api/src/orpc/routers/collection.ts index 0c7f3b1a8..4a8a6d9f2 100644 --- a/apps/app-api/src/orpc/routers/collection.ts +++ b/apps/app-api/src/orpc/routers/collection.ts @@ -1,3 +1,4 @@ +import { addElementContextEvidence, executeCommand } from "@cat/domain"; import { finishPresignedPutFile, firstOrGivenService, @@ -127,3 +128,56 @@ export const finishUpload = authed return { fileId }; }); + +const HighlightRegionSchema = z.object({ + x: z.number(), + y: z.number(), + width: z.number(), + height: z.number(), +}); + +/** + * @zh 为元素附加截图上下文证据。 + * @en Attach screenshot context evidence to elements. + */ +export const addScreenshotEvidence = authed + .input( + z.object({ + projectId: z.uuidv4(), + screenshots: z.array( + z.object({ + elementId: z.int(), + elementRef: z.string().min(1), + fileId: z.int(), + route: z.string().min(1), + highlightRegion: HighlightRegionSchema.optional(), + }), + ), + }), + ) + .use(checkPermission("project", "editor"), (i) => i.projectId) + .output(z.object({ addedCount: z.int() })) + .handler(async ({ context, input }) => { + const { + drizzleDB: { client: drizzle }, + } = context; + + return await executeCommand({ db: drizzle }, addElementContextEvidence, { + projectId: input.projectId, + evidence: input.screenshots.map((screenshot) => ({ + elementId: screenshot.elementId, + kind: "SCREENSHOT" as const, + fileId: screenshot.fileId, + jsonData: screenshot.highlightRegion + ? { highlightRegion: screenshot.highlightRegion } + : null, + displayLabel: `screenshot:${screenshot.route}`, + trustLevel: "COLLECTED" as const, + provenance: { + source: "screenshot-collector", + route: screenshot.route, + elementRef: screenshot.elementRef, + }, + })), + }); + }); diff --git a/apps/app-api/src/orpc/routers/document.ts b/apps/app-api/src/orpc/routers/document.ts index 2d15b48c1..b179db5d5 100644 --- a/apps/app-api/src/orpc/routers/document.ts +++ b/apps/app-api/src/orpc/routers/document.ts @@ -1,10 +1,11 @@ import type { VCSContext } from "@cat/vcs"; import { - countContentNodeElements, + countEditorScopeElements, countContentNodeTranslations, createContentNodeUnderParent, deleteContentNode, + ensureCoreRelationTypes, executeCommand, executeQuery, findProjectContentNodeByLabel, @@ -12,11 +13,11 @@ import { getContentNode, getContentNodeBlobInfo, getContentNodeElementPageIndex, - getContentNodeElements, - getContentNodeFirstElement, + getEditorScopeFirstElement, getElementTranslationStatus as getElementTranslationStatusQuery, getProject, getProjectRootContentNode, + listEditorScopeElements, } from "@cat/domain"; import { StorageProvider } from "@cat/plugin-core"; import { @@ -30,10 +31,15 @@ import { ContentNodeSchema, ElementTranslationStatusSchema, FileMetaSchema, + type JSONType, TranslatableElementSchema, } from "@cat/shared"; import { sanitizeFileName } from "@cat/shared"; -import { listWithOverlay, readWithOverlay } from "@cat/vcs"; +import { + EditorOverlayContentNodeRowSchema, + EditorOverlayContentRelationRowSchema, + readWithOverlay, +} from "@cat/vcs"; import { runGraph, upsertContentNodeGraph } from "@cat/workflow/tasks"; import { ORPCError } from "@orpc/client"; import { randomUUID } from "node:crypto"; @@ -49,6 +55,10 @@ import { } from "@/orpc/server"; import { createVCSRouteHelper } from "@/utils/vcs-route-helper"; +const toJSONType = (value: unknown): JSONType => + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- VCS payloads must cross a JSON serialization boundary before being stored in changesets + JSON.parse(JSON.stringify(value)) as JSONType; + export const prepareCreateFromFile = authed .input( z.object({ @@ -156,6 +166,15 @@ export const finishCreateFromFile = authed }); } + const service = pluginManager + .getServices("FILE_IMPORTER") + .find(({ service }) => service.canImport({ name: fileName })); + + if (!service) + throw new ORPCError("NOT_FOUND", { + message: "No suitable file handler found for this file", + }); + // Isolation write: record document creation in branch changeset if ( context.branchId !== undefined && @@ -166,8 +185,36 @@ export const finishCreateFromFile = authed "branchProjectId missing when branch context is active", ); } + + const rootNode = await executeQuery( + { db: drizzle }, + getProjectRootContentNode, + { projectId }, + ); + + if (!rootNode) { + throw new ORPCError("INTERNAL_SERVER_ERROR", { + message: `Project ${projectId} has no root content node`, + }); + } + + const relationTypeIds = await executeCommand( + { db: drizzle }, + ensureCoreRelationTypes, + {}, + ); + const containsTypeId = relationTypeIds["core:contains:1.0.0"]; + + if (containsTypeId === undefined) { + throw new ORPCError("INTERNAL_SERVER_ERROR", { + message: "Core contains relation type is missing", + }); + } + const { middleware } = createVCSRouteHelper(drizzle); + const timestamp = new Date().toISOString(); const entityId = randomUUID(); + const relationId = randomUUID(); await middleware.interceptWrite( { mode: "isolation", @@ -179,7 +226,66 @@ export const finishCreateFromFile = authed entityId, "CREATE", null, - { projectId, displayLabel: fileName, languageId }, + toJSONType( + EditorOverlayContentNodeRowSchema.parse({ + id: entityId, + projectId, + creatorId: user.id, + kind: "FILE", + displayLabel: fileName, + importerId: service.id, + sourceRootRef: projectId, + stableSourceNodeRef: fileName, + sourceUri: null, + sourcePath: null, + sourceType: null, + languageId, + exportRole: "FILE", + boundaryType: "FILE", + fileHandlerId: service.dbId ?? null, + fileId, + lifecycleStatus: "ACTIVE", + provenance: null, + metadata: null, + createdAt: timestamp, + updatedAt: timestamp, + }), + ), + async () => undefined, + ); + await middleware.interceptWrite( + { + mode: "isolation", + projectId: context.branchProjectId, + branchId: context.branchId, + branchChangesetId: context.branchChangesetId, + }, + "content_relation", + relationId, + "CREATE", + null, + toJSONType( + EditorOverlayContentRelationRowSchema.parse({ + id: relationId, + projectId, + relationTypeId: containsTypeId, + sourceEndpointKind: "NODE", + sourceNodeId: rootNode.id, + sourceElementId: null, + targetEndpointKind: "NODE", + targetNodeId: entityId, + targetElementId: null, + isPrimary: true, + localOrder: 0, + confidenceBasisPoints: 10000, + lifecycleStatus: "ACTIVE", + weightHint: null, + provenance: null, + validationMetadata: null, + createdAt: timestamp, + updatedAt: timestamp, + }), + ), async () => undefined, ); return; @@ -196,15 +302,6 @@ export const finishCreateFromFile = authed }, ); - const service = pluginManager - .getServices("FILE_IMPORTER") - .find(({ service }) => service.canImport({ name: fileName })); - - if (!service) - throw new ORPCError("NOT_FOUND", { - message: "No suitable file handler found for this file", - }); - let targetContentNodeId: string; if (!existingNode) { @@ -296,6 +393,52 @@ export const get = authed }); }); +const legacyStatusFilter = (input: { + isTranslated?: boolean; + isApproved?: boolean; +}) => { + if (input.isTranslated === false) return "untranslated" as const; + if (input.isTranslated === true && input.isApproved === false) { + return "unapproved" as const; + } + if (input.isApproved === true) return "approved" as const; + if (input.isTranslated === true) return "translated" as const; + return "all" as const; +}; + +const resolveLegacyDocumentScope = async ( + drizzle: Parameters[0]["db"], + input: { + documentId: string; + languageId?: string; + searchQuery?: string; + isTranslated?: boolean; + isApproved?: boolean; + branchId?: number; + }, +) => { + const node = await executeQuery({ db: drizzle }, getContentNode, { + id: input.documentId, + }); + + if (!node) { + throw new ORPCError("NOT_FOUND", { + message: `Content node ${input.documentId} not found`, + }); + } + + return { + projectId: node.projectId, + languageToId: input.languageId ?? "", + branchId: input.branchId, + contentNodeIds: [input.documentId], + searchQuery: input.searchQuery ?? "", + statusFilter: legacyStatusFilter(input), + page: 1, + pageSize: 16, + }; +}; + export const countElement = authed .input( z.object({ @@ -312,13 +455,8 @@ export const countElement = authed const { drizzleDB: { client: drizzle }, } = context; - return await executeQuery({ db: drizzle }, countContentNodeElements, { - contentNodeId: input.documentId, - searchQuery: input.searchQuery, - isApproved: input.isApproved, - isTranslated: input.isTranslated, - languageId: input.languageId, - }); + const scope = await resolveLegacyDocumentScope(drizzle, input); + return await executeQuery({ db: drizzle }, countEditorScopeElements, scope); }); export const getFirstElement = authed @@ -339,14 +477,10 @@ export const getFirstElement = authed const { drizzleDB: { client: drizzle }, } = context; - return await executeQuery({ db: drizzle }, getContentNodeFirstElement, { - contentNodeId: input.documentId, - searchQuery: input.searchQuery, - greaterThan: input.greaterThan, - afterElementId: input.afterElementId, - isApproved: input.isApproved, - isTranslated: input.isTranslated, - languageId: input.languageId, + const scope = await resolveLegacyDocumentScope(drizzle, input); + return await executeQuery({ db: drizzle }, getEditorScopeFirstElement, { + ...scope, + afterElementId: input.afterElementId ?? input.greaterThan, }); }); @@ -430,41 +564,22 @@ export const getElements = authed const { drizzleDB: { client: drizzle }, } = context; - const { isApproved, isTranslated } = input; - - if (isApproved !== undefined && isTranslated !== true) { - throw new ORPCError("BAD_REQUEST", { - message: "isTranslated must be true when isApproved is set", - }); - } - - const mainItems = await executeQuery( - { db: drizzle }, - getContentNodeElements, - { - contentNodeId: input.documentId, - page: input.page, - pageSize: input.pageSize, - searchQuery: input.searchQuery, - isApproved: input.isApproved, - isTranslated: input.isTranslated, - languageId: input.languageId, - }, - ); - - if (context.branchId !== undefined) { - return await listWithOverlay( - drizzle, - context.branchId, - "element", - mainItems, - (item) => String(item.id), - ); - } - - return mainItems; + const scope = await resolveLegacyDocumentScope(drizzle, { + ...input, + branchId: context.branchId, + }); + return await executeQuery({ db: drizzle }, listEditorScopeElements, { + ...scope, + page: input.page, + pageSize: input.pageSize, + }); }); +/** + * @deprecated + * @zh 该接口缺少 `documentId`/`contentNodeIds` 作用域输入,无法表达项目级编辑器范围;请改用 `orpc.editor.getElementPageIndex`。 + * @en This endpoint lacks `documentId`/`contentNodeIds` scope input and cannot represent project-level editor scopes; use `orpc.editor.getElementPageIndex` instead. + */ export const getPageIndexOfElement = authed .input( z.object({ diff --git a/apps/app-api/src/orpc/routers/editor.spec.ts b/apps/app-api/src/orpc/routers/editor.spec.ts new file mode 100644 index 000000000..8b2f59e35 --- /dev/null +++ b/apps/app-api/src/orpc/routers/editor.spec.ts @@ -0,0 +1,634 @@ +import * as domain from "@cat/domain"; +import { + addChangesetEntry, + createBranch, + createChangeset, + createContentNodeUnderParent, + createElements, + createProject, + createRootContentNode, + createUser, + createVectorizedStrings, + ensureLanguages, + ensureCoreRelationTypes, + executeCommand, +} from "@cat/domain"; +import { PluginManager } from "@cat/plugin-core"; +import { + createAuthedTestContext, + setupTestDB, + type TestDB, +} from "@cat/test-utils"; +import { + EditorOverlayContentNodeRowSchema, + EditorOverlayContentRelationRowSchema, +} from "@cat/vcs"; +import { randomUUID } from "node:crypto"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; + +import type { Context } from "@/utils/context"; + +const { permissionCheck } = vi.hoisted(() => ({ + permissionCheck: vi.fn(async () => true), +})); + +vi.mock("@cat/permissions", () => ({ + getPermissionEngine: () => ({ check: permissionCheck }), + determineWriteMode: async () => "direct", + loadUserSystemRoles: async () => [], +})); + +import { + getElementPageIndex, + listContentNodes, + listElements, + resolveScope, +} from "./editor.ts"; + +let testDb: TestDB; +let creatorId: string; + +type ProcedureInternal = { + middlewares: Array< + ( + options: { + context: Context; + next: (nextOptions?: { context?: Record }) => Promise<{ + output: unknown; + context: Record; + }>; + errors: Record; + path: string[]; + signal: AbortSignal | undefined; + }, + input: unknown, + outputFn: () => void, + ) => Promise<{ output: unknown; context: Record }> + >; + handler: (options: { + context: Context; + input: unknown; + errors: Record; + path: string[]; + signal: AbortSignal | undefined; + }) => Promise; +}; + +const noop = (): undefined => undefined; + +const isProcedureInternal = (value: unknown): value is ProcedureInternal => { + if (typeof value !== "object" || value === null) return false; + const middlewares = Reflect.get(value, "middlewares"); + const handler = Reflect.get(value, "handler"); + + return Array.isArray(middlewares) && typeof handler === "function"; +}; + +const getProcedureInternal = (procedure: unknown): ProcedureInternal => { + if (typeof procedure !== "object" || procedure === null) { + throw new TypeError("Expected an oRPC procedure object"); + } + + const internal = Reflect.get(procedure, "~orpc"); + if (!isProcedureInternal(internal)) { + throw new TypeError("Expected oRPC internals on the procedure"); + } + + return internal; +}; + +const invokeProcedure = async ( + procedure: unknown, + context: Context, + input: unknown, +): Promise => { + const internal = getProcedureInternal(procedure); + + const run = async ( + index: number, + currentContext: Context, + ): Promise => { + const middleware = internal.middlewares[index]; + if (!middleware) { + return await internal.handler({ + context: currentContext, + input, + errors: {}, + path: [], + signal: undefined, + }); + } + + const result = await middleware( + { + context: currentContext, + next: async (nextOptions) => ({ + output: await run(index + 1, { + ...currentContext, + ...(nextOptions?.context ?? {}), + } as Context), + context: nextOptions?.context ?? {}, + }), + errors: {}, + path: [], + signal: undefined, + }, + input, + noop, + ); + + return result.output; + }; + + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- helper is the narrow boundary for invoking oRPC procedures in tests + return (await run(0, context)) as TOutput; +}; + +const createMockContext = ( + db: TestDB, + options?: { headerBranchId?: string }, +): Context => { + const base = createAuthedTestContext( + { + id: creatorId, + email: "editor-router@test.local", + name: "Editor Router Tester", + emailVerified: true, + avatarFileId: null, + createdAt: new Date("2024-01-01T00:00:00.000Z"), + updatedAt: new Date("2024-01-01T00:00:00.000Z"), + }, + { + drizzleDB: db, + pluginManager: new PluginManager("GLOBAL", ""), + helpers: { + setCookie: noop, + delCookie: noop, + getCookie: (name) => (name === "csrfToken" ? "csrf-token" : null), + getQueryParam: () => undefined, + getReqHeader: (name) => { + if (name === "x-csrf-token") return "csrf-token"; + if (name === "x-branch-id") return options?.headerBranchId; + return undefined; + }, + setResHeader: noop, + }, + }, + ); + + return { + ...base, + auth: { + subjectType: "user", + subjectId: creatorId, + systemRoles: [], + scopes: null, + traceId: undefined, + ip: undefined, + userAgent: undefined, + }, + csrfToken: "csrf-token", + isSSR: false, + isWebSocket: false, + requestSignal: new AbortController().signal, + }; +}; + +const insertStrings = async (rows: { value: string; languageId: string }[]) => { + const ids = await executeCommand( + { db: testDb.client }, + createVectorizedStrings, + { + data: rows.map((row) => ({ + text: row.value, + languageId: row.languageId, + })), + }, + ); + + return rows.map((row, index) => ({ + id: ids[index], + value: row.value, + })); +}; + +const seedFixture = async () => { + const relationTypeIds = await executeCommand( + { db: testDb.client }, + ensureCoreRelationTypes, + {}, + ); + + const project = await executeCommand({ db: testDb.client }, createProject, { + name: "editor-router-main", + description: null, + creatorId, + }); + const root = await executeCommand( + { db: testDb.client }, + createRootContentNode, + { projectId: project.id, creatorId }, + ); + const dir = await executeCommand( + { db: testDb.client }, + createContentNodeUnderParent, + { + projectId: project.id, + creatorId, + parentContentNodeId: root.id, + kind: "DIRECTORY", + displayLabel: "src", + importerId: "test", + sourceRootRef: "root", + stableSourceNodeRef: "src", + exportRole: "DIRECTORY", + boundaryType: "DIRECTORY", + localOrder: 0, + }, + ); + const fileA = await executeCommand( + { db: testDb.client }, + createContentNodeUnderParent, + { + projectId: project.id, + creatorId, + parentContentNodeId: dir.id, + kind: "FILE", + displayLabel: "a.json", + importerId: "test-json", + sourceRootRef: "root", + stableSourceNodeRef: "a.json", + exportRole: "FILE", + boundaryType: "FILE", + localOrder: 0, + }, + ); + const fileB = await executeCommand( + { db: testDb.client }, + createContentNodeUnderParent, + { + projectId: project.id, + creatorId, + parentContentNodeId: dir.id, + kind: "FILE", + displayLabel: "b.json", + importerId: "test-json", + sourceRootRef: "root", + stableSourceNodeRef: "b.json", + exportRole: "FILE", + boundaryType: "FILE", + localOrder: 1, + }, + ); + + const otherProject = await executeCommand( + { db: testDb.client }, + createProject, + { + name: "editor-router-other", + description: null, + creatorId, + }, + ); + const otherRoot = await executeCommand( + { db: testDb.client }, + createRootContentNode, + { projectId: otherProject.id, creatorId }, + ); + const otherFile = await executeCommand( + { db: testDb.client }, + createContentNodeUnderParent, + { + projectId: otherProject.id, + creatorId, + parentContentNodeId: otherRoot.id, + kind: "FILE", + displayLabel: "other.json", + importerId: "test-json", + sourceRootRef: "root", + stableSourceNodeRef: "other.json", + exportRole: "FILE", + boundaryType: "FILE", + localOrder: 0, + }, + ); + + const sourceStrings = await insertStrings([ + { value: "Apple", languageId: "en" }, + { value: "Banana", languageId: "en" }, + { value: "Kiwi", languageId: "en" }, + ]); + const sourceIdByValue = new Map( + sourceStrings.map((item) => [item.value, item.id]), + ); + + const elementIds = await executeCommand( + { db: testDb.client }, + createElements, + { + data: [ + { + projectId: project.id, + primaryContentNodeId: fileA.id, + importerId: "test-json", + sourceRootRef: "root", + sourceNodeRef: "a.json", + stableSourceRef: `apple-${Date.now()}`, + stringId: sourceIdByValue.get("Apple")!, + localOrder: 0, + }, + { + projectId: project.id, + primaryContentNodeId: fileB.id, + importerId: "test-json", + sourceRootRef: "root", + sourceNodeRef: "b.json", + stableSourceRef: `banana-${Date.now()}`, + stringId: sourceIdByValue.get("Banana")!, + localOrder: 0, + }, + { + projectId: otherProject.id, + primaryContentNodeId: otherFile.id, + importerId: "test-json", + sourceRootRef: "root", + sourceNodeRef: "other.json", + stableSourceRef: `kiwi-${Date.now()}`, + stringId: sourceIdByValue.get("Kiwi")!, + localOrder: 0, + }, + ], + }, + ); + + const branch = await executeCommand({ db: testDb.client }, createBranch, { + projectId: project.id, + name: "editor-scope-branch", + createdBy: creatorId, + }); + const changeset = await executeCommand( + { db: testDb.client }, + createChangeset, + { + projectId: project.id, + branchId: branch.id, + createdBy: creatorId, + }, + ); + + const containsTypeId = relationTypeIds["core:contains:1.0.0"]; + + if (containsTypeId === undefined) { + throw new Error("Missing core contains relation type"); + } + + const branchNodeId = randomUUID(); + const branchRelationId = randomUUID(); + const timestamp = new Date().toISOString(); + + await executeCommand({ db: testDb.client }, addChangesetEntry, { + changesetId: changeset.id, + entityType: "content_node", + entityId: branchNodeId, + action: "CREATE", + after: EditorOverlayContentNodeRowSchema.parse({ + id: branchNodeId, + projectId: project.id, + creatorId, + kind: "FILE", + displayLabel: "branch.json", + importerId: "test-json", + sourceRootRef: "root", + stableSourceNodeRef: "branch.json", + sourceUri: null, + sourcePath: null, + sourceType: null, + languageId: "en", + exportRole: "FILE", + boundaryType: "FILE", + fileHandlerId: null, + fileId: null, + lifecycleStatus: "ACTIVE", + provenance: null, + metadata: null, + createdAt: timestamp, + updatedAt: timestamp, + }), + riskLevel: "LOW", + }); + await executeCommand({ db: testDb.client }, addChangesetEntry, { + changesetId: changeset.id, + entityType: "content_relation", + entityId: branchRelationId, + action: "CREATE", + after: EditorOverlayContentRelationRowSchema.parse({ + id: branchRelationId, + projectId: project.id, + relationTypeId: containsTypeId, + sourceEndpointKind: "NODE", + sourceNodeId: root.id, + sourceElementId: null, + targetEndpointKind: "NODE", + targetNodeId: branchNodeId, + targetElementId: null, + isPrimary: true, + localOrder: 9, + confidenceBasisPoints: 10000, + lifecycleStatus: "ACTIVE", + weightHint: null, + provenance: null, + validationMetadata: null, + createdAt: timestamp, + updatedAt: timestamp, + }), + riskLevel: "LOW", + }); + + const otherBranch = await executeCommand( + { db: testDb.client }, + createBranch, + { + projectId: otherProject.id, + name: "other-project-branch", + createdBy: creatorId, + }, + ); + + return { + project, + root, + fileA, + fileB, + otherProject, + otherFile, + branch, + otherBranch, + branchNodeId, + elementIds: { + apple: elementIds[0], + banana: elementIds[1], + kiwi: elementIds[2], + }, + }; +}; + +beforeAll(async () => { + testDb = await setupTestDB(); + await executeCommand({ db: testDb.client }, ensureLanguages, { + languageIds: ["en", "zh-Hans"], + }); + const user = await executeCommand({ db: testDb.client }, createUser, { + email: "editor-router@test.local", + name: "Editor Router Tester", + }); + creatorId = user.id; +}); + +afterAll(async () => { + await testDb.cleanup(); +}); + +beforeEach(() => { + permissionCheck.mockClear(); + permissionCheck.mockResolvedValue(true); +}); + +describe("editor router", () => { + test("resolveScope sanitizes invalid content-node filters and deduplicates valid ones", async () => { + const fixture = await seedFixture(); + const context = createMockContext(testDb); + + const result = await invokeProcedure<{ + contentNodeIds: string[]; + invalidContentNodeIds: string[]; + contentNodeFilters: unknown[]; + }>(resolveScope, context, { + projectId: fixture.project.id, + languageToId: "zh-Hans", + contentNodeIds: [ + fixture.fileA.id, + fixture.otherFile.id, + fixture.fileA.id, + ], + searchQuery: "", + statusFilter: "all", + page: 1, + pageSize: 16, + }); + + expect(result.contentNodeIds).toEqual([fixture.fileA.id]); + expect(result.invalidContentNodeIds).toEqual([fixture.otherFile.id]); + expect(result.contentNodeFilters).toHaveLength(1); + }); + + test("listContentNodes returns branch-visible nodes, excludes project root, and includes path metadata", async () => { + const fixture = await seedFixture(); + const context = createMockContext(testDb); + + const result = await invokeProcedure< + Array<{ + id: string; + path: Array<{ id: string; label: string }>; + kind: string; + }> + >(listContentNodes, context, { + projectId: fixture.project.id, + branchId: fixture.branch.id, + }); + + expect(result.some((node) => node.kind === "PROJECT_ROOT")).toBe(false); + const branchNode = result.find((node) => node.id === fixture.branchNodeId); + expect(branchNode).toBeDefined(); + expect(branchNode?.path.at(-1)?.id).toBe(fixture.branchNodeId); + }); + + test("listContentNodes rejects cross-project branch ids before querying content nodes", async () => { + const fixture = await seedFixture(); + const context = createMockContext(testDb); + const listContentNodesSpy = vi.spyOn(domain, "listEditorScopeContentNodes"); + + await expect( + invokeProcedure(listContentNodes, context, { + projectId: fixture.project.id, + branchId: fixture.otherBranch.id, + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(listContentNodesSpy).not.toHaveBeenCalled(); + }); + + test("listElements checks project viewer permission and never yields rows from cross-project filters", async () => { + const fixture = await seedFixture(); + const context = createMockContext(testDb); + + const result = await invokeProcedure< + Array<{ id: number; primaryContentNodeId: string; value: string }> + >(listElements, context, { + projectId: fixture.project.id, + languageToId: "zh-Hans", + contentNodeIds: [fixture.otherFile.id], + searchQuery: "", + statusFilter: "all", + page: 0, + pageSize: 16, + }); + + expect(permissionCheck).toHaveBeenCalledWith( + expect.objectContaining({ subjectId: creatorId }), + { type: "project", id: fixture.project.id }, + "viewer", + ); + expect( + result.every((row) => row.primaryContentNodeId !== fixture.otherFile.id), + ).toBe(true); + }); + + test("getElementPageIndex returns null when the element falls outside the sanitized scope", async () => { + const fixture = await seedFixture(); + const context = createMockContext(testDb); + + const pageIndex = await invokeProcedure( + getElementPageIndex, + context, + { + projectId: fixture.project.id, + languageToId: "zh-Hans", + contentNodeIds: [fixture.fileA.id, fixture.otherFile.id], + searchQuery: "", + statusFilter: "all", + page: 0, + pageSize: 16, + elementId: fixture.elementIds.banana, + }, + ); + + expect(pageIndex).toBeNull(); + }); + + test("listElements rejects a branch from another project before running scope queries", async () => { + const fixture = await seedFixture(); + const context = createMockContext(testDb); + const listElementsSpy = vi.spyOn(domain, "listEditorScopeElements"); + + await expect( + invokeProcedure(listElements, context, { + projectId: fixture.project.id, + languageToId: "zh-Hans", + branchId: fixture.otherBranch.id, + contentNodeIds: [], + searchQuery: "", + statusFilter: "all", + page: 0, + pageSize: 16, + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(listElementsSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/app-api/src/orpc/routers/editor.ts b/apps/app-api/src/orpc/routers/editor.ts new file mode 100644 index 000000000..034d74317 --- /dev/null +++ b/apps/app-api/src/orpc/routers/editor.ts @@ -0,0 +1,286 @@ +import { + countEditorScopeElements, + executeQuery, + getBranchById, + getEditorScopeElementPageIndex, + getEditorScopeFirstElement, + listEditorScopeContentNodes, + listEditorScopeElements, + type ProjectContentNodeRow, +} from "@cat/domain"; +import { + EditorContentNodeFilterSchema, + type EditorContentNodeFilter, + EditorElementPageIndexQuerySchema, + type EditorScope, + EditorElementQuerySchema, + EditorElementSchema, + EditorFirstElementQuerySchema, + EditorScopeSchema, + EditorScopeViewSchema, +} from "@cat/shared"; +import { ORPCError } from "@orpc/client"; +import * as z from "zod"; + +import { authed, checkPermission } from "@/orpc/server"; + +type ProjectContentNode = ProjectContentNodeRow; + +type ResolvedEditorScope = EditorScope & { + combinationMode: "UNION"; + contentNodeFilters: EditorContentNodeFilter[]; + invalidContentNodeIds: string[]; +}; + +type EditorRouterContext = { + helpers: { getReqHeader(name: string): string | undefined }; +}; + +const buildPath = ( + node: ProjectContentNode, + byId: Map, +) => { + const path = []; + let cursor: ProjectContentNode | undefined = node; + const seen = new Set(); + + while (cursor && !seen.has(cursor.id)) { + seen.add(cursor.id); + path.unshift({ + id: cursor.id, + label: cursor.displayLabel, + kind: cursor.kind, + }); + cursor = cursor.parentId ? byId.get(cursor.parentId) : undefined; + } + + return path; +}; + +const resolveScopeView = async ( + drizzle: Parameters[0]["db"], + scope: EditorScope, +) => { + const nodes = await executeQuery( + { db: drizzle }, + listEditorScopeContentNodes, + { + projectId: scope.projectId, + branchId: scope.branchId, + }, + ); + const byId = new Map(nodes.map((node) => [node.id, node])); + const validIds = new Set(nodes.map((node) => node.id)); + const dedupedIds = [...new Set(scope.contentNodeIds)]; + const validFilterIds = dedupedIds.filter((id) => validIds.has(id)); + const invalidContentNodeIds = dedupedIds.filter((id) => !validIds.has(id)); + + const contentNodeFilters: EditorContentNodeFilter[] = validFilterIds.map( + (id) => { + const node = byId.get(id)!; + return { + id: node.id, + label: node.displayLabel, + kind: node.kind, + boundaryType: node.boundaryType, + exportRole: node.exportRole, + includeDescendants: true, + parentId: node.parentId, + path: buildPath(node, byId), + }; + }, + ); + + return { + ...scope, + combinationMode: "UNION", + contentNodeIds: validFilterIds, + contentNodeFilters, + invalidContentNodeIds, + } satisfies ResolvedEditorScope; +}; + +const validateStatusScope = (input: Pick) => { + if (!input.languageToId) { + throw new ORPCError("BAD_REQUEST", { + message: "languageToId is required for editor status queries", + }); + } +}; + +const parseHeaderBranchId = (value: string | undefined): number | undefined => { + if (value === undefined) return undefined; + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; +}; + +const resolveEditorBranchId = async ( + context: EditorRouterContext, + db: Parameters[0]["db"], + input: Pick, +): Promise => { + const branchId = + input.branchId ?? + parseHeaderBranchId(context.helpers.getReqHeader("x-branch-id")); + if (branchId === undefined) return undefined; + + const branch = await executeQuery({ db }, getBranchById, { branchId }); + if (!branch) { + throw new ORPCError("NOT_FOUND", { + message: `Branch ${branchId} not found`, + }); + } + if (branch.status !== "ACTIVE") { + throw new ORPCError("CONFLICT", { + message: `Branch ${branchId} is not ACTIVE (status: ${branch.status})`, + }); + } + if (branch.projectId !== input.projectId) { + throw new ORPCError("BAD_REQUEST", { + message: `Branch ${branchId} does not belong to project ${input.projectId}`, + }); + } + + return branchId; +}; + +/** + * @zh 解析并清洗编辑器作用域,返回服务端可消费的作用域视图。 + * @en Resolve and sanitize an editor scope into a server-ready scope view. + */ +export const resolveScope = authed + .input(EditorScopeSchema) + .use(checkPermission("project", "viewer"), (i) => i.projectId) + .output(EditorScopeViewSchema) + .handler(async ({ context, input }) => { + const { + drizzleDB: { client: drizzle }, + } = context; + const branchId = await resolveEditorBranchId(context, drizzle, input); + return EditorScopeViewSchema.parse( + await resolveScopeView(drizzle, { ...input, branchId }), + ); + }); + +/** + * @zh 列出编辑器作用域可选的内容节点过滤器。 + * @en List selectable content-node filters for an editor scope. + */ +export const listContentNodes = authed + .input(EditorScopeSchema.pick({ projectId: true, branchId: true })) + .use(checkPermission("project", "viewer"), (i) => i.projectId) + .output(z.array(EditorContentNodeFilterSchema)) + .handler(async ({ context, input }) => { + const { + drizzleDB: { client: drizzle }, + } = context; + const branchId = await resolveEditorBranchId(context, drizzle, input); + const nodes = await executeQuery( + { db: drizzle }, + listEditorScopeContentNodes, + { projectId: input.projectId, branchId }, + ); + const byId = new Map(nodes.map((node) => [node.id, node])); + return nodes + .filter((node) => node.kind !== "PROJECT_ROOT") + .map((node) => + EditorContentNodeFilterSchema.parse({ + id: node.id, + label: node.displayLabel, + kind: node.kind, + boundaryType: node.boundaryType, + exportRole: node.exportRole, + includeDescendants: true, + parentId: node.parentId, + path: buildPath(node, byId), + }), + ); + }); + +/** + * @zh 统计编辑器作用域内匹配过滤条件的元素数量。 + * @en Count elements matching filters inside an editor scope. + */ +export const countElements = authed + .input(EditorElementQuerySchema.omit({ page: true, pageSize: true })) + .use(checkPermission("project", "viewer"), (i) => i.projectId) + .output(z.int().min(0)) + .handler(async ({ context, input }) => { + validateStatusScope(input); + const { + drizzleDB: { client: drizzle }, + } = context; + const branchId = await resolveEditorBranchId(context, drizzle, input); + const scope = await resolveScopeView( + drizzle, + EditorScopeSchema.parse({ ...input, branchId }), + ); + return await executeQuery({ db: drizzle }, countEditorScopeElements, scope); + }); + +/** + * @zh 按编辑器作用域列出元素。 + * @en List elements under the given editor scope. + */ +export const listElements = authed + .input(EditorElementQuerySchema) + .use(checkPermission("project", "viewer"), (i) => i.projectId) + .output(z.array(EditorElementSchema)) + .handler(async ({ context, input }) => { + validateStatusScope(input); + const { + drizzleDB: { client: drizzle }, + } = context; + const branchId = await resolveEditorBranchId(context, drizzle, input); + const scope = await resolveScopeView(drizzle, { ...input, branchId }); + return await executeQuery({ db: drizzle }, listEditorScopeElements, scope); + }); + +/** + * @zh 获取编辑器作用域内首个匹配元素或指定元素之后的首个匹配元素。 + * @en Get the first matching element in scope, or the first one after a given element. + */ +export const getFirstElement = authed + .input(EditorFirstElementQuerySchema) + .use(checkPermission("project", "viewer"), (i) => i.projectId) + .output(EditorElementSchema.nullable()) + .handler(async ({ context, input }) => { + validateStatusScope(input); + const { + drizzleDB: { client: drizzle }, + } = context; + const branchId = await resolveEditorBranchId(context, drizzle, input); + const scope = await resolveScopeView( + drizzle, + EditorScopeSchema.parse({ ...input, branchId, page: 1 }), + ); + return await executeQuery({ db: drizzle }, getEditorScopeFirstElement, { + ...scope, + afterElementId: input.afterElementId, + }); + }); + +/** + * @zh 获取元素在编辑器作用域内的 0 基页码索引;不在作用域内返回 `null`。 + * @en Get the zero-based page index of an element inside the editor scope; returns `null` when the element is out of scope. + */ +export const getElementPageIndex = authed + .input(EditorElementPageIndexQuerySchema) + .use(checkPermission("project", "viewer"), (i) => i.projectId) + .output(z.int().min(0).nullable()) + .handler(async ({ context, input }) => { + validateStatusScope(input); + const { + drizzleDB: { client: drizzle }, + } = context; + const branchId = await resolveEditorBranchId(context, drizzle, input); + const scope = await resolveScopeView( + drizzle, + EditorScopeSchema.parse({ ...input, branchId, page: 1 }), + ); + return await executeQuery({ db: drizzle }, getEditorScopeElementPageIndex, { + ...scope, + elementId: input.elementId, + pageSize: input.pageSize, + }); + }); diff --git a/apps/app-api/src/orpc/routers/translation.ts b/apps/app-api/src/orpc/routers/translation.ts index 9df1ab130..b3f0ce071 100644 --- a/apps/app-api/src/orpc/routers/translation.ts +++ b/apps/app-api/src/orpc/routers/translation.ts @@ -29,7 +29,7 @@ import { serverLogger as logger } from "@cat/server-shared"; import { QaResultItemSchema, QaResultSchema } from "@cat/shared"; import { TranslationSchema, TranslationVoteSchema } from "@cat/shared"; import { JSONObjectSchema } from "@cat/shared"; -import { listWithOverlay } from "@cat/vcs"; +import { EditorOverlayTranslationStateSchema, listWithOverlay } from "@cat/vcs"; import { CreateTranslationPubPayloadSchema, batchAutoTranslateGraph, @@ -109,6 +109,7 @@ export const create = authed } const { middleware } = createVCSRouteHelper(drizzle); const entityId = crypto.randomUUID(); + const timestamp = new Date().toISOString(); await middleware.interceptWrite( { mode: "isolation", @@ -120,7 +121,15 @@ export const create = authed entityId, "CREATE", null, - { elementId, languageId, text, translatorId: user.id }, + EditorOverlayTranslationStateSchema.parse({ + translatableElementId: elementId, + languageId, + text, + translatorId: user.id, + approved: false, + createdAt: timestamp, + updatedAt: timestamp, + }), async () => undefined, ); return; diff --git a/apps/app-e2e/tests/editor.spec.ts b/apps/app-e2e/tests/editor.spec.ts index e47d3e472..73411e86d 100644 --- a/apps/app-e2e/tests/editor.spec.ts +++ b/apps/app-e2e/tests/editor.spec.ts @@ -1,9 +1,16 @@ import { test, expect } from "@/fixtures"; test.describe("Editor - Element Loading (P0)", () => { - test("loads elements in the sidebar", async ({ editorPage, refs }) => { + test("loads elements in the sidebar via legacy redirect", async ({ + editorPage, + refs, + page, + }) => { const documentId = refs["document:elements"]; await editorPage.navigateToEditor(documentId, "zh-Hans"); + await expect(page).toHaveURL( + /\/editor\/project\/[^/]+\/zh-Hans\/\d+\?nodes=/, + ); // 20 elements seeded, 16 per page → page 1 shows 16 const items = editorPage.getElementItems(); @@ -20,8 +27,36 @@ test.describe("Editor - Element Loading (P0)", () => { // Click the first element await editorPage.selectElement(0); - // URL should contain an element ID (numeric) - await expect(page).toHaveURL(/\/editor\/[^/]+\/zh-Hans\/\d+/); + // URL should contain the canonical project editor route. + await expect(page).toHaveURL(/\/editor\/project\/[^/]+\/zh-Hans\/\d+/); + }); +}); + +test.describe("Editor - Project Scope", () => { + test("opens full-project editor without content-node filters", async ({ + editorPage, + refs, + page, + }) => { + const projectId = refs["project"]; + await editorPage.navigateToProjectEditor(projectId, "zh-Hans"); + + await expect(page).toHaveURL(/\/editor\/project\/[^/]+\/zh-Hans\/\d+/); + await expect(page).not.toHaveURL(/nodes=/); + }); + + test("opens a content-node filtered scope", async ({ + editorPage, + refs, + page, + }) => { + const projectId = refs["project"]; + const documentId = refs["document:elements"]; + await editorPage.navigateToProjectEditor(projectId, "zh-Hans", [ + documentId, + ]); + + await expect(page).toHaveURL(new RegExp(`nodes=${documentId}`)); }); }); diff --git a/apps/app-e2e/tests/pages/editor-page.ts b/apps/app-e2e/tests/pages/editor-page.ts index 78b1e4dc9..3e0f07023 100644 --- a/apps/app-e2e/tests/pages/editor-page.ts +++ b/apps/app-e2e/tests/pages/editor-page.ts @@ -20,17 +20,37 @@ export class EditorPage { languageToId: string, ): Promise { await this.page.goto(`/editor/${documentId}/${languageToId}/auto`); - // Wait for the editor sidebar to load elements (skeleton disappears) + await this.waitForEditorReady(); + } + + /** + * Navigate to the canonical project editor route. + */ + async navigateToProjectEditor( + projectId: string, + languageToId: string, + contentNodeIds: string[] = [], + ): Promise { + const params = new URLSearchParams(); + if (contentNodeIds.length > 0) { + params.set("nodes", contentNodeIds.join(",")); + } + + const suffix = params.toString(); + await this.page.goto( + `/editor/project/${projectId}/${languageToId}/auto${suffix ? `?${suffix}` : ""}`, + ); + await this.waitForEditorReady(); + } + + /** + * Wait until the editor sidebar has loaded visible element rows. + */ + async waitForEditorReady(): Promise { await this.page .locator('[data-sidebar="group-content"] [data-sidebar="menu-button"]') .first() .waitFor({ state: "visible", timeout: 30_000 }); - // Wait for the document breadcrumb — confirms context.document is loaded - // so translate() will not silently early-exit with !context.document.value. - await this.page - .locator(".header") - .locator("span.inline-block") - .waitFor({ state: "visible", timeout: 10_000 }); } /** @@ -46,11 +66,7 @@ export class EditorPage { await this.page.goto(`/project/${projectId}/index/${languageId}`); // Click the document row by its name text await this.page.getByText(documentName).first().click(); - // Wait for editor to load - await this.page - .locator('[data-sidebar="group-content"] [data-sidebar="menu-button"]') - .first() - .waitFor({ state: "visible", timeout: 30_000 }); + await this.waitForEditorReady(); } /** @@ -72,7 +88,7 @@ export class EditorPage { const items = this.getElementItems(); await items.nth(index).click(); // Wait for the element to be selected (URL should update) - await this.page.waitForURL(/\/editor\/[^/]+\/[^/]+\/\d+/); + await this.page.waitForURL(/\/editor\/project\/[^/]+\/[^/]+\/\d+/); // Wait for the translate button — confirms toElement() completed and // elementId/context are stable before typing starts. await this.page diff --git a/apps/app/locales/en_us.json b/apps/app/locales/en_us.json index 7dfac7f64..eeb7bf48c 100644 --- a/apps/app/locales/en_us.json +++ b/apps/app/locales/en_us.json @@ -18,6 +18,31 @@ "类型:{type} | 状态:{status}": "Type: {type} | Status: {status}", "无术语": "No terms", "显示 {from} - {to} 条,共 {total} 条": "Showing {from} - {to} of {total}", + "整个项目": "Whole project", + "已选择 {count} 个内容节点": "{count} content nodes selected", + "当前编辑范围没有匹配的可翻译元素:{scope}": "No matching translatable elements in the current editor scope: {scope}", + "当前搜索或状态过滤没有匹配结果": "No results match the current search or status filter", + "所选内容节点及其子节点没有可翻译元素": "The selected content nodes and descendants have no translatable elements", + "当前分支可见范围没有可翻译元素": "The current branch-visible scope has no translatable elements", + "整个项目还没有可翻译元素": "The whole project does not have translatable elements yet", + "清除搜索": "Clear search", + "清除状态过滤": "Clear status filter", + "查看整个项目": "View whole project", + "返回项目页": "Back to project", + "重新检查": "Check again", + "搜索可翻译元素...": "Search translatable elements...", + "全部状态": "All statuses", + "未翻译": "Untranslated", + "已翻译": "Translated", + "已批准": "Approved", + "未批准": "Unapproved", + "内容节点范围": "Content node scope", + "添加内容节点": "Add content node", + "搜索内容节点...": "Search content nodes...", + "未找到内容节点": "No content nodes found", + "移除内容节点过滤器": "Remove content node filter", + "打开编辑工作台": "Open editor workbench", + "暂无内容节点": "No content nodes", "上一页": "Previous", "下一页": "Next", "源术语": "Source Term", diff --git a/apps/app/src/components/ContentNodeTree.vue b/apps/app/src/components/ContentNodeTree.vue new file mode 100644 index 000000000..7aabc9723 --- /dev/null +++ b/apps/app/src/components/ContentNodeTree.vue @@ -0,0 +1,114 @@ + + + diff --git a/apps/app/src/components/ContentNodeTreeNode.vue b/apps/app/src/components/ContentNodeTreeNode.vue new file mode 100644 index 000000000..94031bad0 --- /dev/null +++ b/apps/app/src/components/ContentNodeTreeNode.vue @@ -0,0 +1,139 @@ + + + diff --git a/apps/app/src/components/DocumentTranslationProgress.vue b/apps/app/src/components/DocumentTranslationProgress.vue index c9990a46b..589822e97 100644 --- a/apps/app/src/components/DocumentTranslationProgress.vue +++ b/apps/app/src/components/DocumentTranslationProgress.vue @@ -18,41 +18,45 @@ import ProgressBar from "./progress/bar/ProgressBar.vue"; const { t } = useI18n(); const props = defineProps<{ - document: Pick; + document: Pick; language: Pick; }>(); +const baseScope = computed(() => ({ + projectId: props.document.projectId, + languageToId: props.language.id, + contentNodeIds: [props.document.id], + searchQuery: "", + statusFilter: "all" as const, + page: 1, + pageSize: 16, +})); + const { state: elementAmountState } = useQuery({ - key: ["elementAmount", props.document.id], + key: () => ["editor-elementAmount", baseScope.value], placeholderData: 0, - query: () => - orpc.document.countElement({ - documentId: props.document.id, - }), + query: () => orpc.editor.countElements(baseScope.value), enabled: !import.meta.env.SSR, }); const { state: translatedElementAmountState } = useQuery({ - key: ["translatedElementAmount", props.document.id, props.language.id], + key: () => ["editor-translatedElementAmount", baseScope.value], placeholderData: 0, query: () => - orpc.document.countElement({ - documentId: props.document.id, - isTranslated: true, - languageId: props.language.id, + orpc.editor.countElements({ + ...baseScope.value, + statusFilter: "translated", }), enabled: !import.meta.env.SSR, }); const { state: approvedElementAmountState } = useQuery({ - key: ["approvedElementAmount", props.document.id, props.language.id], + key: () => ["editor-approvedElementAmount", baseScope.value], placeholderData: 0, query: () => - orpc.document.countElement({ - documentId: props.document.id, - isTranslated: true, - isApproved: true, - languageId: props.language.id, + orpc.editor.countElements({ + ...baseScope.value, + statusFilter: "approved", }), enabled: !import.meta.env.SSR, }); diff --git a/apps/app/src/components/DocumentTree.vue b/apps/app/src/components/DocumentTree.vue index f4fe1522a..75be4a2e1 100644 --- a/apps/app/src/components/DocumentTree.vue +++ b/apps/app/src/components/DocumentTree.vue @@ -1,9 +1,7 @@