From 869193e13779ee5dc9374a9e7e1e46154624c199 Mon Sep 17 00:00:00 2001 From: Yao Jingxi <23722032@bjtu.edu.cn> Date: Thu, 16 Jul 2026 23:16:14 +0800 Subject: [PATCH] Harden CMP review and recovery workflow --- .github/workflows/build.yml | 4 + AGENTS.md | 4 + README.md | 13 +- docs/architecture.md | 12 +- docs/cmp-format.md | 10 +- docs/decisions/001-token-span-over-regex.md | 12 +- .../005-rate-limit-retry-consistency.md | 4 +- docs/logging.md | 4 +- docs/release-checklist.md | 2 + docs/testing-strategy.md | 18 +- docs/translation-pipeline.md | 8 +- docs/writeback-transaction.md | 2 +- package-lock.json | 4 +- package.json | 4 +- scripts/check-versions.mjs | 40 +++ src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/atomic_file.rs | 92 +++++ src-tauri/src/chapters.rs | 320 +++++++++++++++--- src-tauri/src/cmp.rs | 154 ++++++++- src-tauri/src/commands.rs | 114 ++++++- src-tauri/src/core.rs | 79 ++++- src-tauri/src/core/review.rs | 126 +++++-- src-tauri/src/core/translation.rs | 126 +++++-- src-tauri/src/core/writeback.rs | 49 ++- src-tauri/src/lib.rs | 23 +- src-tauri/src/logging.rs | 17 +- src-tauri/src/providers.rs | 48 ++- src-tauri/src/storage.rs | 6 +- src-tauri/src/task_state.rs | 189 ++++++++++- src-tauri/tauri.conf.json | 2 +- .../fixtures/chapters-nested/expected.cmp | 2 +- .../tests/fixtures/lang-rich/expected.cmp | 2 +- src-tauri/tests/golden_fixtures.rs | 2 + src/app/App.tsx | 186 +++++++++- src/components/CmpTable.tsx | 105 +++++- src/components/Dialogs.tsx | 2 +- src/lib/cmpQa.ts | 106 ++++++ src/lib/taskRecovery.ts | 21 ++ src/models/cmp.ts | 1 + src/models/task.ts | 8 + src/pages/WorkbenchPage.tsx | 13 +- src/services/tauri.ts | 40 ++- src/styles.css | 1 + tests/frontend/cmpQa.test.ts | 54 +++ tests/frontend/cmpTable.ssr.test.mjs | 64 ++++ .../frontend/fixtures/cmp-table-preview.html | 12 + tests/frontend/fixtures/cmpTablePreview.tsx | 76 +++++ tests/frontend/taskRecovery.test.ts | 37 ++ 49 files changed, 1992 insertions(+), 230 deletions(-) create mode 100644 scripts/check-versions.mjs create mode 100644 src-tauri/src/atomic_file.rs create mode 100644 src/lib/cmpQa.ts create mode 100644 src/lib/taskRecovery.ts create mode 100644 src/models/task.ts create mode 100644 tests/frontend/cmpQa.test.ts create mode 100644 tests/frontend/cmpTable.ssr.test.mjs create mode 100644 tests/frontend/fixtures/cmp-table-preview.html create mode 100644 tests/frontend/fixtures/cmpTablePreview.tsx create mode 100644 tests/frontend/taskRecovery.test.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 92f3c0d..464da24 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -58,6 +58,10 @@ jobs: cache: npm - name: Install frontend dependencies run: npm ci + - name: Check version metadata + run: npm run check:versions + - name: Run frontend logic tests + run: npm run test:frontend - name: Build frontend run: npm run build diff --git a/AGENTS.md b/AGENTS.md index a66dfab..3f616c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -101,9 +101,11 @@ API 模式下,`batch_size=auto` 使用 25,`concurrency=auto` 使用 6,并 - API 阶段结束后,前端必须提供“是,直接覆盖”和“否,人工校对”。两条路径最终都调用同一套 CMP 解析、格式守卫、备份和提交逻辑,不能为“直接覆盖”建立低校验旁路。 - 扫描结果按源文件列出条目数;CMP 也按 `lang/en_us.snbt` 或 `chapters/<文件名>.snbt` 分组。 - CMP v1 的人工可编辑内容仅为 `"英文" -> "中文"` 右侧 JSON 字符串。文件头、`# meta`、`## file`、`@` 回填位置和左侧英文都属于受保护内容。 +- 新写出的 CMP 必须保存不包含右侧译文的 `protected_hash`;旧 v1 可缺少该字段。应用内校对另以整文件 revision 防止外部编辑与旧页面互相覆盖。 - CMP 元数据可以保存非敏感的提供商、模型、接口地址、翻译要求和词表指纹;禁止保存 API Key、Authorization、钥匙串值或完整 HTTP 请求/响应。 - CMP 元数据保存非敏感 `task_id`,用于串联 API、人工校对、写回和历史日志;兼容缺少该字段的早期 v1 文件。 - 应用 CMP 时必须重新扫描当前任务书并校验目录、模式、条目数、源内容 SHA-256、文件归属、条目 ID、JSON Pointer 和左侧英文。任务书变化、条目缺失/重复或元数据被修改时拒绝写入。 +- 新任务的源指纹必须覆盖解析管线版本、源文件相对路径和完整字节;旧 v1 的条目 ID/英文指纹只保留读取兼容。 - 手工译文仍要经过换行、格式码、占位符、选择器、数字、URL、资源 ID、标签和 JSON 富文本结构校验;人工编辑不能绕过格式守卫。 - 所有验证通过后才创建备份并生成完整输出。语言文件重新解析完整 SNBT;章节文件验证替换位置、引号、转义和括号结构;多文件提交失败时回滚。 - 应用重新启动后,允许用户扫描同一目录并导入已有 CMP,不得强制重新调用翻译接口。 @@ -143,12 +145,14 @@ cargo test --manifest-path src-tauri/Cargo.toml React、TypeScript 或设置页修改: ```bash +npm run test:frontend npm run build ``` 文档和所有改动: ```bash +npm run check:versions git diff --check ``` diff --git a/README.md b/README.md index 74ee557..ac3aa35 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,10 @@ npm run tauri dev 2. 回到“翻译工作台”,选择整合包根目录,或直接选择 `config/ftbquests/quests`、`lang`、`chapters` 目录。 3. 点击“扫描任务书”,确认识别出的格式、文件数和待翻译条目数。 4. 点击“开始翻译”。程序调用所选服务,但不会立即覆盖任务书;完成后先生成可编辑的 `.cmp` 英文 → 中文校对文件。 -5. 在弹窗中选择“是,直接覆盖”,或选择“否,人工校对”。人工校对时可以打开、另存或选择修改后的 CMP;只修改箭头右侧中文。 +5. 在弹窗中选择“是,直接覆盖”,或选择“否,人工校对”。人工校对时可以打开、另存或选择修改后的 CMP;表格会标出同源多译、保持英文、疑似未汉化和异常状态,只修改箭头右侧中文。 6. 可以先点击“验证 CMP”查看任务书归属、源指纹、可应用/保持英文/格式失败数量和预计修改文件;这一步只在内存中验证当前表格译文,不修改 CMP、任务书、备份、缓存、历史或报告。 7. 点击“校验并覆盖”。程序会独立重新核对任务书指纹、英文原文、标签、数字、JSON 和 SNBT 结构,不依赖之前的验证结果;通过后才创建备份并写回。 -8. 翻译与写回状态会保存在系统应用数据目录。重复点击、并发命令或重启后重新导入已应用的 CMP 都会由后端拒绝再次写回;这项保护不依赖前端按钮状态。 +8. 翻译与写回状态会保存在系统应用数据目录。重复点击、并发命令或重启后重新导入已应用的 CMP 都会由后端拒绝再次写回;若上次进程在翻译中异常退出,重新开始时可显式确认恢复中断状态,正在写回的状态不会自动解锁。 9. 完成后检查格式告警。网页翻译只能作为机器初译,发布前仍应进行术语和语义审校。 ### 构建安装包 @@ -56,6 +56,7 @@ npm run tauri -- build - 支持两种模式:语言文件(`lang/en_us.snbt`)和章节文件(`chapters/*.snbt`) - 自动识别整合包目录结构,并按文件列出待翻译条目 - API 翻译后先生成可编辑、可导入导出的 `.cmp` 英文 → 中文校对文件 +- CMP 表格提供同源多译、保持英文、疑似未汉化和异常状态筛选,帮助集中人工审校 - 只有确认应用 CMP 后才备份并写回;拒绝英文、回填位置或任务书指纹被修改的文件 - 官方 API 支持批量并发,网页翻译以大批次、低并发方式减少匿名请求 - 可选的版本化 Minecraft/模组词表,默认关闭;切换到 API 模式后可按需启用 @@ -72,13 +73,13 @@ npm run tauri -- build 工具支持两种文件格式,分别对应 FTB Quests 的两种任务书结构: - **lang 模式**:解析 `lang/en_us.snbt`,这是一个类 JSON 的 SNBT 格式文件,值可以是字符串或字符串数组(多行描述)。工具自己实现了 SNBT 解析器(`snbt.rs`),保留键的原始顺序,写出时也生成合法 SNBT。 -- **chapters 模式**:遍历 `chapters/*.snbt`,从每个章节文件中提取可翻译的字符串字段(任务标题、描述等)。 +- **chapters 模式**:遍历 `chapters/*.snbt`,用逐字符 token/span walker 跳过注释和字符串内伪字段,从嵌套结构提取任务标题、描述等目标字符串,同时保留原始字节位置用于写回。 如果条目是 Minecraft JSON 富文本组件,程序不会把整个 JSON 交给翻译接口。它会先解析组件树,只抽取玩家可见的根字符串、`text`、`extra`、`with`、`separator`,以及 `hoverEvent` 中可显示的文本;`translate`、`keybind`、`clickEvent`、颜色、样式、命令、URL 和资源 ID 保持原值。每个抽取结果会形成一条带原条目 ID 和 JSON Pointer 回填路径的翻译单元,API 只翻译其中的纯文本,随后程序按路径写回原组件并再次比较结构。包含重复键或无法安全解析的疑似 JSON 组件会保留原文,不会交给翻译接口重新生成。 -API 阶段完成后,程序在任务书目录的 `.ftb-translator/reviews/` 中生成玩家可见、可导出的 `.cmp` 校对文件。每个单元包含不可修改的 `@` 回填位置,以及一行 JSON 转义后的 `"英文" -> "中文"`。JSON 字符串转义让换行、引号和文本中原有的箭头不会破坏格式;人工校对只修改箭头右侧。应用 CMP 时会再次核对原任务书内容和每个回填位置,文件缺条目、重复位置、英文被修改或格式标签变化都会拒绝写入。 +API 阶段完成后,程序在任务书目录的 `.ftb-translator/reviews/` 中生成玩家可见、可导出的 `.cmp` 校对文件。每个单元包含不可修改的 `@` 回填位置,以及一行 JSON 转义后的 `"英文" -> "中文"`。新版 CMP 还保存不覆盖右侧译文的受保护区域哈希;人工校对只修改箭头右侧,修改 meta、状态、位置或左侧英文会被拒绝。内置表格使用整文件修订号,外部编辑后不会被旧页面静默覆盖。 -校对表格可以按状态筛选。接口限流会单独标记为 `rate_limited`,可用“重试限流项”仅重新请求这一批;重试前会再次校验 CMP 与当前任务书的一致性,成功后更新同一个 CMP,其他译文和人工编辑保持不变。 +校对表格可以按状态和审校线索筛选。线索使用保守启发式发现同一英文对应多个中文、译文仍等于原文、译文没有中文以及异常处理状态;它们只用于缩小人工检查范围,不是语义准确率结论,也不参与写回权限判断。接口限流会单独标记为 `rate_limited`,可用“重试限流项”仅重新请求这一批;重试前会再次校验 CMP 与当前任务书的一致性,成功后更新同一个 CMP,其他译文和人工编辑保持不变。 ```text ## file "chapters/example.snbt" @@ -196,7 +197,7 @@ API 返回后,每条译文经过两步处理: **校验失败**:该条译文**不写入**,原文被保留,条目进入「人工修正」列表。校验通过的译文才写入文件并存入缓存。 -生成 CMP 时不会创建备份或修改任务书。用户确认应用后,程序会先验证 CMP 与当前任务书的 SHA-256 内容指纹、英文原文和回填位置;验证通过才创建备份。最终写回时,语言文件会重新解析生成后的完整 SNBT;章节文件会验证每个替换位置唯一且存在,并检查写回后的引号、转义和括号结构。多文件写入任一失败时会回滚本次已写文件。 +生成 CMP 时不会创建备份或修改任务书。用户确认应用后,程序会验证受保护区域哈希,以及覆盖源文件路径、完整字节、解析管线和提取身份的 SHA-256 内容指纹,再核对英文原文和回填位置;验证通过才创建备份。最终写回时,语言文件会重新解析生成后的完整 SNBT;章节文件会验证每个替换位置唯一且存在,并检查写回后的引号、转义和括号结构。多文件写入任一失败时会回滚本次已写文件。 ### 5. 翻译缓存 diff --git a/docs/architecture.md b/docs/architecture.md index 72c829f..2460651 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -32,7 +32,7 @@ React 界面(src/{App,components,services,types}.tsx/ts) 扫描先把所选目录解析为任务书根目录,并选择一种模式。若同一目录同时存在 `lang/en_us.snbt` 和 `chapters/*.snbt`,当前实现优先使用 `lang`。 - `lang`:`snbt.rs` 解析根 compound,值只接受字符串或字符串数组,使用有序 `Vec` 保持键顺序;写回生成完整 `lang/zh_cn.snbt` 并重新解析。 -- `chapters`:`chapters.rs` 当前用受限正则寻找 `title`、`subtitle`、`description`、`text`、`name` 字段,记录原字符串字面量的字节区间、引号和序号。替换按倒序 span 完成,并在替换前后检查引号、转义和括号结构。它不是通用 SNBT 解析器。 +- `chapters`:`chapters.rs` 使用面向任务的逐字符 token/span walker,跳过注释和字符串内部的伪字段,识别嵌套 compound/list 中的 `title`、`subtitle`、`description`、`text`、`name`,并记录原字面量字节区间、引号和序号。替换按倒序 span 完成,并在替换前后检查引号、转义和括号结构;它仍不是覆盖所有 SNBT 类型的通用 AST。 - 普通条目进入一个 `$` 翻译单元;可安全解析的 Minecraft JSON 富文本按 JSON Pointer 拆为多个玩家可见单元;疑似富文本但无法安全解析或含重复键时整条保留英文。 翻译单元携带 `entry_id`、`path`、英文原文、保护后的文本和占位符映射。CMP 再加入源文件归属、状态与译文,使每个译文都能回到唯一位置。 @@ -46,7 +46,7 @@ React 界面(src/{App,components,services,types}.tsx/ts) “直接覆盖”和“人工校对”只是在 UI 中到达应用阶段的方式不同,后端最终都调用相同的 CMP 应用路径。详细流程分别见 [翻译流水线](translation-pipeline.md) 与 [写回事务](writeback-transaction.md)。 -后端另以 `created → translating → review_ready → applying → applied` 表示成功路径,操作失败进入 `failed`;未修改任务书的 apply 失败恢复为 `review_ready`。命令在启动异步任务或写回前先原子转换状态,不能只依赖前端按钮防止重复操作。 +后端另以 `created → translating → review_ready → applying → applied` 表示成功路径,操作失败进入 `failed`;未修改任务书的 apply 失败恢复为 `review_ready`。命令在启动异步任务或写回前先原子转换状态,不能只依赖前端按钮防止重复操作。状态诊断会列出活动任务、更新时间以及翻译记录是否早于本次进程:只有旧 `translating` 可在用户确认后标记为中断;本次进程后的翻译视为可能仍存活,`applying` 则始终不自动解锁。 ## 持久化边界 @@ -66,7 +66,7 @@ React 界面(src/{App,components,services,types}.tsx/ts) `providers.rs` 适配 Google 网页、DeepL 网页、DeepL 官方 API 和 OpenAI 兼容接口。API 模式 `auto` 批大小为 25、并发为 6,并发硬上限 12;两个网页提供商的有效并发固定收敛为 1。提供商层负责各自协议、拆分和最多三次递增等待的 HTTP 尝试,核心层负责批次并发、失败状态、恢复、格式守卫和 CMP 汇总。 -缓存键包含原文和提供商身份;OpenAI 兼容模式包含模型,其他模式还包含接口地址。开启词表时,词表内容指纹参与缓存键;富文本另带处理管线版本,避免复用旧的整段 JSON 结果。 +缓存键包含原文、提供商、模型和规范化接口地址,避免两个 OpenAI 兼容端点使用同名模型时交叉复用。默认 DeepSeek 地址仍可只读迁移旧缓存,自定义地址不读取旧式键。开启词表时,词表内容指纹参与缓存键;富文本另带处理管线版本,避免复用旧的整段 JSON 结果。 ## 必须保持的不变量 @@ -80,10 +80,10 @@ React 界面(src/{App,components,services,types}.tsx/ts) ## 已知边界与演进方向 -- 当前 `chapters` 提取仍依赖受限正则,不等于完整 SNBT token walker。历史 Python 实现曾使用 token span walker;为何不应扩大为“用正则解析所有 SNBT”见 [ADR-001](decisions/001-token-span-over-regex.md)。 -- 历史 Python 格式守卫有轻量颜色/样式 AST;当前 Rust 版只把颜色码当不透明 token 并比较排序后的多重集合,尚不能证明样式作用域等价。见 [ADR-002](decisions/002-colour-ast.md)。 +- `chapters` 已恢复轻量 token/span walker,但仍只理解本工具需要的字段和值边界,不是完整 SNBT AST;新增 typed array 或未知合法语法时仍要补 fixture。见 [ADR-001](decisions/001-token-span-over-regex.md)。 +- Rust 已恢复轻量颜色/样式 AST,并与 token 多重集合共同验证活动样式作用域。见 [ADR-002](decisions/002-colour-ast.md)。 - 文件提交是应用级补偿事务,不是文件系统原子事务;进程崩溃或回滚自身失败仍需使用已创建的备份人工恢复。 -- 当前已有 Rust 单元测试、临时目录流程测试,以及使用确定性 Mock 响应的 `lang`/`chapters` 扫描→CMP→备份→写回 Golden fixture;尚无前端组件/浏览器测试,异步 HTTP provider 也未通过可注入客户端贯穿 Golden。见 [测试策略](testing-strategy.md)。 +- 当前已有 Rust 单元测试、临时目录流程测试,使用确定性 Mock 响应的 `lang`/`chapters` 扫描→CMP→备份→写回 Golden fixture,以及前端纯逻辑和 SSR 组件测试;尚无真实浏览器交互测试,异步 HTTP provider 也未通过可注入客户端贯穿 Golden。见 [测试策略](testing-strategy.md)。 ## 决策索引 diff --git a/docs/cmp-format.md b/docs/cmp-format.md index e7f55ea..4efe549 100644 --- a/docs/cmp-format.md +++ b/docs/cmp-format.md @@ -18,7 +18,7 @@ CMP 是 FTB Translator 面向玩家和校对者的翻译工程文件。它把“ ```text # FTB Translator CMP v1 # 只修改箭头右侧的中文;保留 @ 行、英文原文、引号与 JSON 转义。 -# meta {"version":1,"task_id":"20260714T120000.000Z-0001","quests_dir":"/pack/config/ftbquests/quests","mode":"chapters","source_fingerprint":"...","provider":"google_web","base_url":"https://translate.googleapis.com","model":"google-web","style":"自然玩家向简体中文汉化","glossary_enabled":false,"glossary_fingerprint":"","total_entries":2,"cache_hits":0} +# meta {"version":1,"task_id":"20260714T120000.000Z-0001","quests_dir":"/pack/config/ftbquests/quests","mode":"chapters","source_fingerprint":"v2:...","provider":"google_web","base_url":"https://translate.googleapis.com","model":"google-web","style":"自然玩家向简体中文汉化","glossary_enabled":false,"glossary_fingerprint":"","total_entries":2,"cache_hits":0,"protected_hash":"..."} ## file "chapters/example.snbt" @@ -72,7 +72,7 @@ CMP 语法层允许 JSON 空字符串 `""`,以便兼容旧 v1 文件并完整 | 2 | `task_id` | 可选 | 串联 API 请求、CMP 操作、写回与历史日志;早期 v1 文件缺少时按空字符串读取,应用时为本次操作生成新编号 | | 3 | `quests_dir` | 必需 | 生成 CMP 时的任务书目录 | | 4 | `mode` | 必需 | `lang` 或 `chapters` | -| 5 | `source_fingerprint` | 必需 | 按条目 ID 和完整英文原文计算的 SHA-256 指纹 | +| 5 | `source_fingerprint` | 必需 | 新文件使用 `v2:` 前缀,覆盖解析管线版本、源文件相对路径、完整字节以及提取后的条目身份;旧 v1 的条目 ID/英文指纹继续兼容 | | 6 | `provider` | 必需 | 生成机器译文的提供商 | | 7 | `base_url` | 必需 | 非敏感接口地址,用于缓存与历史归属 | | 8 | `model` | 必需 | 模型或网页翻译标识 | @@ -81,8 +81,9 @@ CMP 语法层允许 JSON 空字符串 `""`,以便兼容旧 v1 文件并完整 | 11 | `glossary_fingerprint` | 必需 | 当次词表内容指纹;不包含词表正文 | | 12 | `total_entries` | 必需 | 原始任务书条目数,不是富文本拆分后的单元数 | | 13 | `cache_hits` | 必需 | 当次 API 阶段的完整条目缓存命中数 | +| 14 | `protected_hash` | 可选 | 写入器生成的受保护区域 SHA-256;覆盖 meta 与每条记录除右侧译文外的字段,旧 v1 可缺少 | -应用写出时,元数据始终使用表中的字段顺序。除 `task_id` 外,CMP v1 当前没有其他可选元数据字段。 +应用写出时,元数据始终使用表中的字段顺序。人工修改右侧译文不会改变 `protected_hash`;修改 `task_id`、提供商信息、状态、位置或左侧英文会使校验失败。早期 v1 缺少 `task_id` 或 `protected_hash` 时仍按兼容规则读取。 CMP 禁止包含 API Key、Authorization、钥匙串内容或完整 HTTP 请求/响应。 @@ -118,6 +119,7 @@ CMP 禁止包含 API Key、Authorization、钥匙串内容或完整 HTTP 请求/ 出现以下任一情况时,不创建备份、不写入任何任务书文件: - CMP 版本、文件头或 JSON 语法无效; +- 新版 CMP 的 `protected_hash` 与受保护内容不一致; - CMP 不属于当前扫描的任务书目录或模式; - 当前任务书的条目数量或 SHA-256 指纹已经变化; - 翻译单元缺失、重复或包含未知回填位置; @@ -132,7 +134,7 @@ CMP 禁止包含 API Key、Authorization、钥匙串内容或完整 HTTP 请求/ ## 兼容性规则 - 写入器只生成 `# FTB Translator CMP v1`;解析器同时接受更名前的旧拼写文件头。未来不兼容变化必须提升版本号。 -- 旧 v1 缺少 `task_id` 时仍可读取;其余必需字段缺失会被拒绝。 +- 旧 v1 缺少 `task_id`、`protected_hash` 或仍使用旧式源指纹时仍可读取;其余必需字段缺失会被拒绝。 - v1 允许增加普通 `#` 注释,但 `# meta` 和 `@` 的未知字段会被拒绝,不能改变翻译对照行的语义。 - 写出时按 `file` 的字典序生成 `## file` 分组,同一文件内保持原翻译单元顺序;元数据与 `@` 字段按上表顺序生成。规范写出的 CMP 再次读取和写出时,数据与字节内容都保持稳定。 - 不要把内部的 `translation-units-latest.jsonl` 当作 CMP。JSONL 仅用于诊断实际接口调用,CMP 才是人工校对和导入导出格式。 diff --git a/docs/decisions/001-token-span-over-regex.md b/docs/decisions/001-token-span-over-regex.md index 855ddeb..3e037a5 100644 --- a/docs/decisions/001-token-span-over-regex.md +++ b/docs/decisions/001-token-span-over-regex.md @@ -1,6 +1,6 @@ # ADR-001:用 token/span 解析边界,而不是正则解析全部 SNBT -- 状态:接受;Rust `chapters` 端尚未完整落实 +- 状态:接受并已在 Rust `chapters` 端落实 - 决策日期:2026-07-01 - 历史依据:`feaa107`、`329b269` @@ -8,7 +8,7 @@ FTB Quests 同时使用较规则的 `lang/en_us.snbt` 和结构更自由的 `chapters/*.snbt`。翻译器不仅要找到文本,还要保留注释、嵌套结构、引号和转义,并在写回时替换原来的唯一位置。 -历史 Python 版本最初用正则寻找章节字段,后来在 `feaa107` 改为轻量 SNBT walker:逐字符识别 key、字符串、compound、list、注释和未知值,并给可翻译字符串记录精确 `start/end` span。Rust 重写后,`lang` 保留了手写解析器;但当前 `chapters.rs` 又使用受限字段正则产生 span,再用独立结构检查兜底。因此本 ADR 同时记录目标边界和当前未完成的迁移。 +历史 Python 版本最初用正则寻找章节字段,后来在 `feaa107` 改为轻量 SNBT walker。Rust 重写一度退回受限字段正则;当前 `chapters.rs` 已重新使用逐字符 walker 识别 key、字符串、compound、list 和注释,并为可翻译字符串记录精确 `start/end` span。 ## 原始问题 @@ -30,7 +30,7 @@ FTB Quests 同时使用较规则的 `lang/en_us.snbt` 和结构更自由的 `cha 完整文件结构的识别应使用逐字符 token/walker 或等价的结构化 parser;正则只用于字符串内部的格式 token,或作为边界严格、测试充分的临时字段提取器。章节写回应继续基于原字面量 span,而不是重新序列化整个文件。 -当前代码的准确表述是:`lang` 已采用 parser;`chapters` 采用方案 4,尚未回到历史 Python 已验证过的方案 3。不得把当前 `chapters.rs` 描述成完整 token walker。 +当前代码的准确表述是:`lang` 使用面向语言文件的 parser;`chapters` 采用方案 3 的轻量 token/span walker。后者只解析定位目标字段所需的结构,不能描述成完整通用 SNBT AST。 ## 选择原因 @@ -41,13 +41,13 @@ token/walker 能在保留原文件非翻译内容的同时理解最小必要结 - 需要维护状态机、转义和错误位置,比简单正则代码更多。 - 轻量 walker 仍不是完整 SNBT 规范实现,遇到新语法需要显式补测试。 - span 依赖读取时的原始字节内容;源文件变化后不能复用,必须重新扫描。 -- 当前 Rust 章节提取尚未完成迁移,受限正则仍有嵌套和注释边界风险。 +- walker 只收集目标字段的直接字符串值;新增合法 SNBT 类型仍需要显式扩展和测试。 ## 后续影响 - `chapters` 相关改动必须优先增加嵌套、注释、单双引号、转义、typed array 和相似字段 fixture。 -- 迁移 `chapters.rs` 时应保持现有 `Segment` 的路径、序号、引号和 span 契约,以免改变 CMP `entry_id`。 +- `chapters.rs` 必须继续保持 `Segment` 的路径、序号、引号和 span 契约,以免改变 CMP `entry_id`。 - 即使采用 walker,替换后仍要验证括号、引号和转义结构;parser 不是省略写回守卫的理由。 -- 在迁移完成前,架构和发布说明应持续披露当前实现是受限字段提取,而非通用 SNBT parser。 +- 架构和发布说明应持续披露当前实现是面向任务的 walker,而非通用 SNBT parser。 相关文档:[架构概览](../architecture.md)、[翻译流水线](../translation-pipeline.md)、[测试策略](../testing-strategy.md)。 diff --git a/docs/decisions/005-rate-limit-retry-consistency.md b/docs/decisions/005-rate-limit-retry-consistency.md index 6257486..152d35e 100644 --- a/docs/decisions/005-rate-limit-retry-consistency.md +++ b/docs/decisions/005-rate-limit-retry-consistency.md @@ -28,7 +28,7 @@ CMP 的 `entry_id` 和 JSON Pointer 只有在它对应的源任务书版本中 ## 最终选择 -采用方案 4。重试前验证规范化目录、模式、条目总数、源 SHA-256 指纹、记录数、`(entry_id, path)`、文件归属和英文原文。没有限流项也拒绝启动。验证通过后待请求集合仅保留 `rate_limited` 位置;合并结果时仅替换这些记录,其他译文和人工编辑保持不变。 +采用方案 4。重试前验证规范化目录、模式、条目总数、源 SHA-256 指纹、记录数、`(entry_id, path)`、文件归属和英文原文。没有限流项也拒绝启动。验证通过后待请求集合仅保留状态为 `rate_limited` 且右侧仍等于英文原文的位置;应用内人工补译会转为 `review`,因此不会再次发送或被覆盖。合并结果时仅替换实际重试记录。 ## 选择原因 @@ -41,7 +41,7 @@ CMP 的 `entry_id` 和 JSON Pointer 只有在它对应的源任务书版本中 - 每次重试都要重新读取和解析整个任务书,大包会增加少量启动时间。 - 任何源文本变化都会拒绝,即使变化与限流项无关;用户需要重新翻译或重新建立 CMP。 -- `source_fingerprint` 依赖当前条目 ID、顺序和完整英文,解析规则变化也可能令旧 CMP 不再可重试。 +- 新 `source_fingerprint` 覆盖解析管线版本、源文件相对路径、完整字节和提取条目身份;旧 v1 的条目 ID/英文指纹继续兼容,但保护范围较窄。 - 429 识别当前依赖错误字符串包含 `HTTP 429` 或 `rate limit`,不同提供商错误格式可能被归为 `request_failed`。 ## 后续影响 diff --git a/docs/logging.md b/docs/logging.md index b19591c..a34f3e7 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -65,7 +65,7 @@ logs/ - 前端调用后端命令失败; - 主题与页面切换; - 目录选择、扫描开始和扫描结果; -- 翻译启动、逐条进度、限流批次重试、CMP 校对就绪、打开、另存、重新选择、人工校对选择、最终写回完成和失败事件; +- 翻译启动、逐条进度、限流批次重试、任务状态冲突诊断、CMP 校对就绪、打开、另存、重新选择、人工校对选择、最终写回完成和失败事件; - 提供商切换和设置保存; - 词表选择与默认路径恢复; - 用户明确查看凭证的状态,不记录凭证内容; @@ -89,7 +89,7 @@ logs/ - 提供商 HTTP 尝试、状态失败、网络失败和重试; - CMP 解析、应用开始、安全校验通过或失败; - 用户确认应用 CMP 后的备份开始与完成、输出提交开始与完成、提交失败与回滚结果,以及最终历史编号; -- 后端任务状态的原子转换与 CMP 状态加载;只记录非敏感任务身份和状态名,不记录 CMP 正文; +- 后端任务状态的原子转换、活动状态检查、显式中断恢复与 CMP 状态加载;只记录非敏感任务身份和状态名,不记录 CMP 正文; - 输出已经提交后,缓存、历史或最新报告保存失败的非破坏性警告;这些警告不得被记录成任务书写回失败。 翻译日志必须区分两个阶段: diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 30876de..125fb58 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -1,5 +1,7 @@ # v0.2.0 发布检查表 +> 这是 v0.2.0 候选在 2026-07-15 的历史证据快照,不代表当前清单或当前版本;现行版本以各 manifest 及 `npm run check:versions` 为准。 + 本文把可自动复现的发布候选证据与必须人工完成的平台/服务验证分开。勾选表示当前候选分支已经实际执行;未勾选项不得在发布说明中声称完成。 ## 1. 版本与仓库元数据 diff --git a/docs/testing-strategy.md b/docs/testing-strategy.md index 165dfb4..bd8ed65 100644 --- a/docs/testing-strategy.md +++ b/docs/testing-strategy.md @@ -6,7 +6,7 @@ 阶段 0 在 `5ecd891` 上记录的结果为:53 个 Rust 测试中 52 个通过、1 个真实网页接口 smoke test 忽略;`npm run build` 和 `cargo check` 通过。样本与命令细节见 [当前行为基线](current-baseline.md)。 -当前测试主要放在各 Rust 模块的 `#[cfg(test)]` 中,并由 `tests/golden_fixtures.rs` 补充跨模块离线 fixture,使用 `tempfile` 覆盖临时目录读写。前端目前以 TypeScript/Vite production build 作为最低检查,没有组件或浏览器自动化测试。 +当前测试主要放在各 Rust 模块的 `#[cfg(test)]` 中,并由 `tests/golden_fixtures.rs` 补充跨模块离线 fixture,使用 `tempfile` 覆盖临时目录读写。前端以 Node 原生 test runner 覆盖审校线索和中断恢复决策等纯逻辑,并通过 Vite SSR 实际渲染关键 CMP 组件;TypeScript/Vite production build继续检查类型和打包。目前仍没有真实浏览器交互自动化测试。 ## 分层策略 @@ -47,16 +47,19 @@ ### 4. 前端契约与构建 -`npm run build` 同时运行 `tsc` 与 Vite production build,可发现 TypeScript 类型、导入和打包错误,但不能证明页面交互正确。前端模块拆分或 Tauri payload 变化后,至少手工核对: +`npm run test:frontend` 使用 Node 原生 test runner 直接执行 TypeScript 纯逻辑测试,不引入额外测试框架;当前覆盖 CMP 审校线索聚合、同源多译定位,以及中断翻译/活动翻译/写回状态的恢复决策。它还通过 Vite SSR 加载并渲染 `CmpTable`,固定审校栏与可编辑表格的组件装配。`npm run build` 同时运行 `tsc` 与 Vite production build,可发现类型、导入和打包错误,但这些检查仍不能证明真实浏览器点击、焦点和 Tauri IPC 行为正确。前端模块拆分或 Tauri payload 变化后,至少手工核对: + +`tests/frontend/fixtures/cmp-table-preview.html` 是不进入生产构建的视觉 fixture;开发服务器下可直接检查校对表格,并用 `?theme=dark` 核对深色主题。 - 扫描结果和按文件计数; - 四种提供商切换后可见配置; - 网页模式清空词表并恢复自动批次/并发; - CMP 表格保存、打开、导出、选择已有 CMP; +- 审校线索计数、筛选和同源多译定位; - “直接覆盖”和“人工校对”都进入同一写回命令; - 限流筛选与重试只更新对应记录。 -关键工作流已经使用强类型 Tauri command,反序列化测试会拒绝受保护字段与未知顶层字段;设置、历史和日志命令仍通过兼容桥接。长期应为关键校对交互增加组件测试,并逐步移除剩余字符串桥接;本文不把 production build 当作 GUI 行为测试。 +关键工作流已经使用强类型 Tauri command,反序列化测试会拒绝受保护字段与未知顶层字段;设置、历史和日志命令仍通过兼容桥接。长期应增加真实浏览器交互测试,并逐步移除剩余字符串桥接;本文不把 SSR 或 production build 当作完整 GUI 行为测试。 ### 5. 桌面集成与真实服务 smoke test @@ -70,7 +73,7 @@ |---|---|---| | Rust 解析、CMP、写回、设置 | `cargo fmt --check`、`cargo test` | 对应拒绝用例、离线 fixture | | 提供商协议或重试 | 上述 Rust 检查 | Mock 响应;真实 smoke test 仅按需 | -| React/TypeScript/设置页 | `npm run build` | 关键交互手工核对 | +| React/TypeScript/设置页 | `npm run test:frontend`、`npm run build` | 关键交互手工核对 | | 文档 | `git diff --check` | 相对链接与代码事实核对 | | 发布候选 | Rust 检查 + 前端构建 + Tauri 编译/打包 | 安装包桌面 smoke test | @@ -79,11 +82,12 @@ ```bash cargo fmt --manifest-path src-tauri/Cargo.toml -- --check cargo test --manifest-path src-tauri/Cargo.toml +npm run test:frontend npm run build git diff --check ``` -GitHub Actions 会在 pull request、`master` push 和版本标签上分别执行 Rust 单元测试、离线集成测试、文档测试、Clippy、格式检查与前端 production build;真实网页接口 smoke test 继续保持 ignored,不进入默认自动化。 +GitHub Actions 会在 pull request、`master` push 和版本标签上分别执行 Rust 单元测试、离线集成测试、文档测试、Clippy、格式检查、前端纯逻辑/SSR 组件测试与 production build;真实网页接口 smoke test 继续保持 ignored,不进入默认自动化。 ## 不变量测试清单 @@ -99,7 +103,7 @@ CMP:只允许右侧译文变化;旧 v1 可选字段兼容;未知状态、 ## 当前缺口 -- `chapters` 当前仍为受限正则提取,应为嵌套 compound/list、注释边界和相似非目标字段增加 golden fixtures,并评估迁移到 token walker。 +- `chapters` 已迁移到轻量 token/span walker,并覆盖嵌套 compound/list、注释、URL、单双引号和字符串内伪字段;后续仍需随真实任务书补充 typed array 与未知合法值 fixture。 - 确定性 Mock 响应已覆盖扫描到写回,但尚未把异步 `translate` 的 HTTP 客户端抽象成可注入替身;提供商协议解析仍以局部测试覆盖。 -- 没有前端组件或浏览器自动测试;强类型工作流 command 有反序列化契约测试,但剩余兼容桥接尚未全部迁移。 +- 已有前端纯逻辑和 SSR 组件测试,但没有真实浏览器交互自动化;强类型工作流 command 有反序列化契约测试,剩余兼容桥接尚未全部迁移。 - 同目录 staging、rename 和可控故障注入已覆盖正常失败补偿,但跨文件提交仍不是文件系统原子事务,也没有进程崩溃后的自动恢复。 diff --git a/docs/translation-pipeline.md b/docs/translation-pipeline.md index a3a7903..e18c2d0 100644 --- a/docs/translation-pipeline.md +++ b/docs/translation-pipeline.md @@ -18,7 +18,7 @@ `lang` 模式由手写 SNBT 子集解析器读取。根值只支持字符串与字符串数组;数组在内部合并为带真实换行的一个条目,应用时再按换行拆回数组。 -`chapters` 模式按文件名排序。当前实现用受限字段正则提取字符串字面量,忽略当前规则识别到的注释,并仅保留含 ASCII 英文字母的值。每个片段记录原文件、字段名、字节 `start/end`、原引号、文件内序号和稳定的 `cache_id`。这是面向已知 FTB 字段的提取器,不是完整 SNBT 语法解析。 +`chapters` 模式按文件名排序。当前实现用逐字符 token/span walker 跳过注释和完整字符串,识别嵌套结构中的目标字段,并仅保留含 ASCII 英文字母的值。每个片段记录原文件、字段名、字节 `start/end`、原引号、文件内序号和稳定的 `cache_id`。这是面向已知 FTB 字段的提取器,不是完整 SNBT AST。 ## 2. 富文本拆分 @@ -71,9 +71,9 @@ API 阶段写入: - `.ftb-translator/translation-units-latest.jsonl`:本次实际请求单元的诊断结果; - `.ftb-translator/cache.json`:已通过条目级格式守卫的缓存; -- `.ftb-translator/reviews/translation-<时间>.cmp`:人工校对文件。 +- `.ftb-translator/reviews/translation-<毫秒时间>-<任务号>.cmp`:人工校对文件;任务号避免同一时刻的文件名冲突。 -CMP 元数据包含任务目录、模式、源指纹、提供商、接口、模型、翻译要求、词表指纹、条目数和缓存命中数,不包含凭证。每条记录保存源文件、条目 ID、JSON Pointer、状态、英文和译文。生成成功以 `review_ready` 结束;此时没有备份,也没有任务书写回。 +CMP 元数据包含任务目录、模式、`v2` 源文件指纹、提供商、接口、模型、翻译要求、词表指纹、条目数和缓存命中数,不包含凭证。写入器另生成不包含右侧译文的 `protected_hash`,用于拒绝 meta、状态、位置或左侧英文被修改的新版 CMP。每条记录保存源文件、条目 ID、JSON Pointer、状态、英文和译文。生成成功以 `review_ready` 结束;此时没有备份,也没有任务书写回。 翻译命令在异步任务启动前,先在系统应用数据目录的状态库中把任务原子转换为 `translating`。同一任务书已有 `translating` 或 `applying` 时拒绝重复启动;CMP 成功落盘后转为 `review_ready`,失败转为 `failed`。限流重试复用同一身份,只允许从 `review_ready` 或 `failed` 进入 `translating`。 @@ -86,7 +86,7 @@ CMP 元数据包含任务目录、模式、源指纹、提供商、接口、模 - 条目数与源指纹相同; - CMP 记录数、回填位置、文件归属和英文原文与当前提取结果一致。 -验证通过后只保留状态为 `rate_limited` 的 `(entry_id, path)` 进入待请求集合。新结果只替换这些位置,其他机器译文和人工编辑按原记录保留,并原地更新同一 CMP。设计理由见 [ADR-005](decisions/005-rate-limit-retry-consistency.md)。 +验证通过后只保留状态为 `rate_limited` 且右侧仍等于原文的 `(entry_id, path)` 进入待请求集合。人工补译会由应用改为 `review`,不会再次发送或被覆盖。新结果只替换这些位置,并原地更新同一 CMP。设计理由见 [ADR-005](decisions/005-rate-limit-retry-consistency.md)。 ## 状态不等于写回权限 diff --git a/docs/writeback-transaction.md b/docs/writeback-transaction.md index cf66262..78c26e7 100644 --- a/docs/writeback-transaction.md +++ b/docs/writeback-transaction.md @@ -21,7 +21,7 @@ 应用不会相信生成 CMP 时留在内存里的扫描结果。它重新读取 `lang/en_us.snbt` 或排序后的 `chapters/*.snbt`,用与翻译阶段相同的 `prepare_entry` 规则重建条目和富文本单元,然后比较: 1. `total_entries`; -2. 由条目 ID、NUL 分隔符、完整英文和 `0xff` 分隔符计算的 SHA-256 `source_fingerprint`; +2. 新 CMP 使用覆盖解析管线版本、源文件相对路径、完整文件字节和提取条目身份的 `v2` SHA-256 `source_fingerprint`;旧 v1 的条目 ID/英文指纹继续兼容; 3. CMP 记录数与当前翻译单元数; 4. 每个 `(entry_id, path)` 是否存在且唯一; 5. `file` 是否由当前模式和条目 ID 推导得到; diff --git a/package-lock.json b/package-lock.json index 2aca346..59f9629 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ftb-translator-desktop", - "version": "0.2.0", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ftb-translator-desktop", - "version": "0.2.0", + "version": "0.2.2", "engines": { "node": ">=24" }, diff --git a/package.json b/package.json index 1784656..d77b906 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ftb-translator-desktop", "private": true, - "version": "0.2.0", + "version": "0.2.2", "type": "module", "engines": { "node": ">=24" @@ -9,6 +9,8 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", + "check:versions": "node scripts/check-versions.mjs", + "test:frontend": "node --test --experimental-strip-types tests/frontend/*.test.ts tests/frontend/*.test.mjs", "tauri": "tauri" }, "dependencies": { diff --git a/scripts/check-versions.mjs b/scripts/check-versions.mjs new file mode 100644 index 0000000..df413fa --- /dev/null +++ b/scripts/check-versions.mjs @@ -0,0 +1,40 @@ +import fs from "node:fs"; + +const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8")); +const packageLock = JSON.parse(fs.readFileSync("package-lock.json", "utf8")); +const tauriConfig = JSON.parse(fs.readFileSync("src-tauri/tauri.conf.json", "utf8")); +const cargoToml = fs.readFileSync("src-tauri/Cargo.toml", "utf8"); +const cargoLock = fs.readFileSync("src-tauri/Cargo.lock", "utf8"); + +const cargoVersion = cargoToml.match(/^version = "([^"]+)"/m)?.[1]; +const cargoLockVersion = cargoLock.match( + /\[\[package\]\]\nname = "ftb-translator"\nversion = "([^"]+)"/, +)?.[1]; +const versions = new Map([ + ["package.json", packageJson.version], + ["package-lock.json", packageLock.version], + ["package-lock root package", packageLock.packages?.[""]?.version], + ["src-tauri/Cargo.toml", cargoVersion], + ["src-tauri/Cargo.lock", cargoLockVersion], + ["src-tauri/tauri.conf.json", tauriConfig.version], +]); +const expected = packageJson.version; +const mismatches = [...versions].filter(([, version]) => version !== expected); +if (mismatches.length) { + throw new Error( + `Version mismatch; expected ${expected}: ${mismatches + .map(([file, version]) => `${file}=${version ?? "missing"}`) + .join(", ")}`, + ); +} + +if (process.env.GITHUB_REF_TYPE === "tag") { + const expectedTag = `v${expected}`; + if (process.env.GITHUB_REF_NAME !== expectedTag) { + throw new Error( + `Release tag ${process.env.GITHUB_REF_NAME} does not match manifest version ${expectedTag}`, + ); + } +} + +console.log(`Version metadata is consistent: ${expected}`); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 25624e5..e31b605 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -911,7 +911,7 @@ dependencies = [ [[package]] name = "ftb-translator" -version = "0.2.0" +version = "0.2.2" dependencies = [ "chrono", "futures", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c09bd70..2dc7ceb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ftb-translator" -version = "0.2.0" +version = "0.2.2" description = "FTB Quests translation workbench" authors = ["FTB Translator contributors"] edition = "2021" diff --git a/src-tauri/src/atomic_file.rs b/src-tauri/src/atomic_file.rs new file mode 100644 index 0000000..5991682 --- /dev/null +++ b/src-tauri/src/atomic_file.rs @@ -0,0 +1,92 @@ +use std::{ + fs::{self, OpenOptions}, + io::{self, Write}, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, +}; + +static SEQUENCE: AtomicU64 = AtomicU64::new(0); + +fn sibling(path: &Path, kind: &str) -> PathBuf { + let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed); + let name = path.file_name().unwrap_or_default().to_string_lossy(); + path.with_file_name(format!( + ".{name}.ftb-translator-{kind}-{}-{sequence}", + std::process::id() + )) +} + +fn create_temporary(path: &Path) -> io::Result<(PathBuf, fs::File)> { + for _ in 0..1000 { + let temporary = sibling(path, "tmp"); + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + { + Ok(file) => return Ok((temporary, file)), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + } + } + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "cannot allocate atomic-write temporary file", + )) +} + +pub fn write(path: &Path, bytes: impl AsRef<[u8]>) -> io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let (temporary, mut file) = create_temporary(path)?; + let result = (|| { + file.write_all(bytes.as_ref())?; + file.sync_all()?; + if let Ok(metadata) = fs::metadata(path) { + fs::set_permissions(&temporary, metadata.permissions())?; + } + drop(file); + + #[cfg(not(target_os = "windows"))] + fs::rename(&temporary, path)?; + + #[cfg(target_os = "windows")] + { + let rollback = sibling(path, "rollback"); + let had_original = path.exists(); + if had_original { + fs::rename(path, &rollback)?; + } + if let Err(error) = fs::rename(&temporary, path) { + if had_original { + let _ = fs::rename(&rollback, path); + } + return Err(error); + } + if had_original { + let _ = fs::remove_file(rollback); + } + } + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(temporary); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn writes_new_files_and_replaces_existing_content() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("state.json"); + write(&path, b"first").unwrap(); + write(&path, b"second").unwrap(); + assert_eq!(fs::read(&path).unwrap(), b"second"); + assert_eq!(fs::read_dir(directory.path()).unwrap().count(), 1); + } +} diff --git a/src-tauri/src/chapters.rs b/src-tauri/src/chapters.rs index 8850ba2..390a20d 100644 --- a/src-tauri/src/chapters.rs +++ b/src-tauri/src/chapters.rs @@ -1,10 +1,8 @@ -use regex::Regex; use serde::{Deserialize, Serialize}; use std::{ collections::HashSet, fs, path::{Path, PathBuf}, - sync::OnceLock, }; #[derive(Clone, Debug, Serialize, Deserialize)] @@ -47,6 +45,156 @@ fn decode(s: &str) -> String { } o } + +#[derive(Debug)] +struct StringToken { + start: usize, + end: usize, + quote: char, + value: String, +} + +fn quoted_token(text: &str, start: usize) -> Result { + let quote = text[start..] + .chars() + .next() + .filter(|character| matches!(character, '\'' | '"')) + .ok_or_else(|| format!("章节 SNBT 在偏移 {start} 处需要引号字符串"))?; + let mut position = start + quote.len_utf8(); + let content_start = position; + let mut escaped = false; + while position < text.len() { + let character = text[position..] + .chars() + .next() + .ok_or_else(|| "章节 SNBT 字符串意外结束".to_string())?; + if escaped { + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == quote { + return Ok(StringToken { + start, + end: position + character.len_utf8(), + quote, + value: decode(&text[content_start..position]), + }); + } + position += character.len_utf8(); + } + Err(format!("章节 SNBT 在偏移 {start} 处的字符串没有结束引号")) +} + +fn skip_trivia(text: &str, mut position: usize) -> usize { + loop { + while position < text.len() { + let character = text[position..].chars().next().unwrap(); + if !character.is_whitespace() { + break; + } + position += character.len_utf8(); + } + if text[position..].starts_with("//") || text[position..].starts_with('#') { + position = text[position..] + .find('\n') + .map_or(text.len(), |offset| position + offset + 1); + continue; + } + return position; + } +} + +fn bare_token_end(text: &str, mut position: usize) -> usize { + while position < text.len() { + let character = text[position..].chars().next().unwrap(); + if character.is_whitespace() + || matches!(character, '{' | '}' | '[' | ']' | '(' | ')' | ':' | ',') + || text[position..].starts_with("//") + || character == '#' + { + break; + } + position += character.len_utf8(); + } + position +} + +fn is_translatable_key(value: &str) -> bool { + matches!( + value, + "title" | "subtitle" | "description" | "text" | "name" + ) +} + +fn push_segment(path: &Path, key: &str, token: StringToken, output: &mut Vec) { + if !token + .value + .chars() + .any(|character| character.is_ascii_alphabetic()) + { + return; + } + let index = output.len(); + output.push(Segment { + path: path.to_path_buf(), + key: key.into(), + source: token.value, + start: token.start, + end: token.end, + quote: token.quote, + index, + cache_id: format!( + "{}:{index}:{key}", + path.file_name().unwrap_or_default().to_string_lossy() + ), + }); +} + +fn collect_list_strings( + text: &str, + start: usize, + path: &Path, + key: &str, + output: &mut Vec, +) -> Result { + let mut stack = vec!['[']; + let mut position = start + 1; + while position < text.len() { + position = skip_trivia(text, position); + if position >= text.len() { + break; + } + let character = text[position..].chars().next().unwrap(); + if matches!(character, '\'' | '"') { + let token = quoted_token(text, position)?; + position = token.end; + if stack.as_slice() == ['['] { + push_segment(path, key, token, output); + } + continue; + } + match character { + '[' | '{' | '(' => stack.push(character), + ']' | '}' | ')' => { + let expected = match character { + ']' => '[', + '}' => '{', + ')' => '(', + _ => unreachable!(), + }; + if stack.pop() != Some(expected) { + return Err(format!("章节 SNBT 的 {character} 没有匹配的起始括号")); + } + if stack.is_empty() { + return Ok(position + character.len_utf8()); + } + } + _ => {} + } + position += character.len_utf8(); + } + Err("章节 SNBT 的目标字符串列表没有结束括号".into()) +} fn quote(s: &str, q: char) -> String { format!( "{q}{}{q}", @@ -117,52 +265,63 @@ fn validate_structure(text: &str) -> Result<(), String> { } pub fn extract(path: &Path) -> Result, String> { - static FIELD: OnceLock = OnceLock::new(); - static STRINGS: OnceLock = OnceLock::new(); let text = fs::read_to_string(path).map_err(|e| e.to_string())?; - let re = FIELD.get_or_init(|| { - Regex::new(r#"(?s)(?:\b(title|subtitle|description|text|name)|["'](title|subtitle|description|text|name)["'])\s*:\s*(\[[^\]]*]|"(?:\\.|[^"])*"|'(?:\\.|[^'])*')"#) - .expect("chapter-field regex must be valid") - }); - let strings = STRINGS.get_or_init(|| { - Regex::new(r#""((?:\\.|[^"])*)"|'((?:\\.|[^'])*)'"#) - .expect("chapter-string regex must be valid") - }); - let mut out = vec![]; - for cap in re.captures_iter(&text) { - let match_start = cap.get(0).unwrap().start(); - let line_start = text[..match_start].rfind('\n').map_or(0, |i| i + 1); - let prefix = &text[line_start..match_start]; - if prefix.trim_start().starts_with('#') || prefix.contains("//") { - continue; + validate_structure(&text)?; + let mut output = Vec::new(); + let mut position = 0; + while position < text.len() { + position = skip_trivia(&text, position); + if position >= text.len() { + break; } - let key = cap.get(1).or(cap.get(2)).unwrap().as_str(); - let value = cap.get(3).unwrap(); - for sc in strings.captures_iter(value.as_str()) { - let m = sc.get(0).unwrap(); - let raw = sc.get(1).or(sc.get(2)).unwrap().as_str(); - let source = decode(raw); - if !source.chars().any(|c| c.is_ascii_alphabetic()) { + let character = text[position..].chars().next().unwrap(); + let (key, token_end) = if matches!(character, '\'' | '"') { + let token = quoted_token(&text, position)?; + (token.value, token.end) + } else if matches!(character, '{' | '}' | '[' | ']' | '(' | ')' | ':' | ',') { + position += character.len_utf8(); + continue; + } else { + let end = bare_token_end(&text, position); + if end == position { + position += character.len_utf8(); continue; } - let start = value.start() + m.start(); - let idx = out.len(); - out.push(Segment { - path: path.to_path_buf(), - key: key.into(), - source, - start, - end: value.start() + m.end(), - quote: m.as_str().chars().next().unwrap(), - index: idx, - cache_id: format!( - "{}:{idx}:{key}", - path.file_name().unwrap().to_string_lossy() - ), - }); + (text[position..end].to_string(), end) + }; + let colon = skip_trivia(&text, token_end); + if !is_translatable_key(&key) || colon >= text.len() || !text[colon..].starts_with(':') { + position = token_end; + continue; + } + let value = skip_trivia(&text, colon + 1); + if value >= text.len() { + return Err(format!("章节字段 {key} 缺少值")); } + let value_start = text[value..].chars().next().unwrap(); + if matches!(value_start, '\'' | '"') { + let token = quoted_token(&text, value)?; + position = token.end; + push_segment(path, &key, token, &mut output); + } else if value_start == '[' { + collect_list_strings(&text, value, path, &key, &mut output)?; + // Keep walking inside the list so rich/nested components such as + // `description: [{ text: "..." }]` are found as normal fields. + position = value + 1; + } else { + position = value; + } + } + output.sort_by_key(|segment| segment.start); + for (index, segment) in output.iter_mut().enumerate() { + segment.index = index; + segment.cache_id = format!( + "{}:{index}:{}", + path.file_name().unwrap_or_default().to_string_lossy(), + segment.key + ); } - Ok(out) + Ok(output) } pub fn render_replacements( path: &Path, @@ -217,6 +376,83 @@ mod tests { assert!(rendered.contains("你好")); } + #[test] + fn token_walker_ignores_field_names_and_comment_markers_inside_strings() { + let d = tempdir().unwrap(); + let p = d.path().join("a.snbt"); + fs::write( + &p, + r#"{ + description: ["Visit https://example.com/a]b", "Line two"], title: "Real title", + note: "title: \"Not a field\"", + // subtitle: "Commented" + "name": 'Quoted key' +}"#, + ) + .unwrap(); + + let segments = extract(&p).unwrap(); + assert_eq!( + segments + .iter() + .map(|segment| segment.source.as_str()) + .collect::>(), + [ + "Visit https://example.com/a]b", + "Line two", + "Real title", + "Quoted key" + ] + ); + } + + #[test] + fn token_walker_finds_target_fields_in_nested_compounds() { + let d = tempdir().unwrap(); + let p = d.path().join("nested.snbt"); + fs::write( + &p, + r#"{ group: { quests: [{ title: "First" }, { subtitle: 'Second' }] } }"#, + ) + .unwrap(); + + let segments = extract(&p).unwrap(); + assert_eq!( + segments + .iter() + .map(|segment| segment.source.as_str()) + .collect::>(), + ["First", "Second"] + ); + } + + #[test] + fn token_walker_finds_rich_text_fields_inside_target_lists_in_source_order() { + let d = tempdir().unwrap(); + let p = d.path().join("rich-list.snbt"); + fs::write( + &p, + r#"{ description: [{ text: "Nested first" }, "Direct second"] }"#, + ) + .unwrap(); + + let segments = extract(&p).unwrap(); + assert_eq!( + segments + .iter() + .map(|segment| segment.source.as_str()) + .collect::>(), + ["Nested first", "Direct second"] + ); + assert_eq!( + segments + .iter() + .map(|segment| segment.index) + .collect::>(), + [0, 1] + ); + } + #[test] fn replacement_text_cannot_escape_the_original_snbt_string() { let d = tempdir().unwrap(); diff --git a/src-tauri/src/cmp.rs b/src-tauri/src/cmp.rs index 74f3f4b..eeca7d2 100644 --- a/src-tauri/src/cmp.rs +++ b/src-tauri/src/cmp.rs @@ -1,5 +1,7 @@ use crate::error::{AppError, AppResult}; use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; use std::{collections::HashSet, fs, path::Path}; const HEADER: &str = "# FTB Translator CMP v1"; @@ -62,6 +64,41 @@ pub struct Document { pub records: Vec, } +fn hash_field(hash: &mut Sha256, value: &str) { + hash.update((value.len() as u64).to_le_bytes()); + hash.update(value.as_bytes()); +} + +/// Covers every CMP field that a human editor is not allowed to change. +/// The target text is deliberately excluded so normal review edits remain valid. +pub fn protected_fingerprint(document: &Document) -> String { + let mut hash = Sha256::new(); + hash.update(b"ftb-translator-cmp-protected-v1\0"); + hash_field( + &mut hash, + &serde_json::to_string(&document.meta) + .expect("CMP metadata contains only values supported by serde_json"), + ); + let mut records = document.records.iter().collect::>(); + records.sort_by(|left, right| left.file.cmp(&right.file)); + for record in records { + for value in [ + record.file.as_str(), + record.entry_id.as_str(), + record.path.as_str(), + record.source.as_str(), + record.status.as_str(), + ] { + hash_field(&mut hash, value); + } + } + hex::encode(hash.finalize()) +} + +fn content_revision(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct Location { @@ -78,10 +115,16 @@ pub fn write(path: &Path, document: &Document) -> AppResult<()> { output.push('\n'); output.push_str("# 只修改箭头右侧的中文;保留 @ 行、英文原文、引号与 JSON 转义。\n"); output.push_str("# meta "); - output.push_str( - &serde_json::to_string(&document.meta) + let mut metadata = serde_json::to_string(&document.meta) + .map_err(|error| invalid_with(error.to_string(), error.to_string()))?; + metadata.pop(); + metadata.push_str(",\"protected_hash\":"); + metadata.push_str( + &serde_json::to_string(&protected_fingerprint(document)) .map_err(|error| invalid_with(error.to_string(), error.to_string()))?, ); + metadata.push('}'); + output.push_str(&metadata); output.push_str("\n\n"); // CMP file sections are presentation-only, but keeping them in a canonical order // makes repeated saves stable. Rust's slice sort is stable, so source order within @@ -121,11 +164,7 @@ pub fn write(path: &Path, document: &Document) -> AppResult<()> { ); output.push_str("\n\n"); } - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|error| invalid_with(error.to_string(), error.to_string()))?; - } - fs::write(path, output) + crate::atomic_file::write(path, output) .map_err(|error| invalid_with(format!("无法保存 CMP 校对文件:{error}"), error.to_string())) } @@ -152,13 +191,34 @@ fn validate_document(document: &Document) -> AppResult<()> { } pub fn load(path: &Path) -> AppResult { - let content = fs::read_to_string(path).map_err(|error| { + load_with_revision(path).map(|(document, _)| document) +} + +pub fn load_with_revision(path: &Path) -> AppResult<(Document, String)> { + let bytes = fs::read(path).map_err(|error| { invalid_with( format!("无法读取 CMP 校对文件 {}:{error}", path.display()), error.to_string(), ) })?; - parse(&content) + let content = std::str::from_utf8(&bytes).map_err(|error| { + invalid_with( + format!("CMP 校对文件不是有效 UTF-8:{error}"), + error.to_string(), + ) + })?; + parse(content).map(|document| (document, content_revision(&bytes))) +} + +pub fn revision(path: &Path) -> AppResult { + fs::read(path) + .map(|bytes| content_revision(&bytes)) + .map_err(|error| { + invalid_with( + format!("无法读取 CMP 校对文件 {}:{error}", path.display()), + error.to_string(), + ) + }) } pub fn parse(content: &str) -> AppResult { @@ -169,6 +229,7 @@ pub fn parse(content: &str) -> AppResult { return Err(invalid("CMP 文件头无效或版本不受支持")); } let mut meta = None; + let mut protected_hash = None; let mut records = Vec::new(); let mut locations = HashSet::new(); let mut current_file = None; @@ -184,7 +245,30 @@ pub fn parse(content: &str) -> AppResult { line_index + 1 ))); } - let value = serde_json::from_str::(raw).map_err(|error| { + let mut raw_value = serde_json::from_str::(raw).map_err(|error| { + invalid_with( + format!("CMP 第 {} 行 meta 无效:{error}", line_index + 1), + error.to_string(), + ) + })?; + let object = raw_value.as_object_mut().ok_or_else(|| { + invalid(format!( + "CMP 第 {} 行 meta 必须是 JSON 对象", + line_index + 1 + )) + })?; + protected_hash = object + .remove("protected_hash") + .map(|value| { + value.as_str().map(str::to_string).ok_or_else(|| { + invalid(format!( + "CMP 第 {} 行 protected_hash 必须是字符串", + line_index + 1 + )) + }) + }) + .transpose()?; + let value = serde_json::from_value::(raw_value).map_err(|error| { invalid_with( format!("CMP 第 {} 行 meta 无效:{error}", line_index + 1), error.to_string(), @@ -266,7 +350,18 @@ pub fn parse(content: &str) -> AppResult { if records.is_empty() { return Err(invalid("CMP 文件没有翻译条目")); } - Ok(Document { meta, records }) + let document = Document { meta, records }; + validate_document(&document)?; + if let Some(expected) = protected_hash { + let actual = protected_fingerprint(&document); + if expected != actual { + return Err(invalid_with( + "CMP 的受保护内容已被修改;只允许编辑箭头右侧译文", + format!("protected hash mismatch: expected={expected} actual={actual}"), + )); + } + } + Ok(document) } fn parse_pair(line: &str) -> AppResult<(String, String)> { @@ -490,6 +585,29 @@ mod tests { assert_eq!(after.records[0].target, "引用 \"指南\",然后打开 C:\\mods"); } + #[test] + fn protected_hash_rejects_metadata_and_location_edits() { + let dir = tempdir().unwrap(); + let path = dir.path().join("protected.cmp"); + write(&path, &document()).unwrap(); + let original = fs::read_to_string(path).unwrap(); + assert!(original.contains("\"protected_hash\"")); + + let changed_task = + original.replace("20260714T120000.000Z-0001", "20260714T120000.000Z-tampered"); + assert!(parse(&changed_task) + .unwrap_err() + .user_message + .contains("受保护内容")); + + let changed_status = + original.replacen("\"status\":\"translated\"", "\"status\":\"review\"", 1); + assert!(parse(&changed_status) + .unwrap_err() + .user_message + .contains("受保护内容")); + } + #[test] fn rejects_duplicate_locations_and_broken_pairs() { let mut content = String::from(HEADER); @@ -573,12 +691,14 @@ mod tests { .user_message .contains("provider")); - let unknown_meta = missing_content.replace( - &serde_json::to_string(&missing).unwrap(), - &format!( - "{{\"future\":true,{}}}", - &serde_json::to_string(&document().meta).unwrap()[1..] - ), + let mut unknown = serde_json::to_value(&document().meta).unwrap(); + unknown + .as_object_mut() + .unwrap() + .insert("future".into(), serde_json::Value::Bool(true)); + let unknown_meta = format!( + "{HEADER}\n# meta {}\n@ {{\"file\":\"lang/en_us.snbt\",\"entry_id\":\"a\",\"path\":\"$\",\"status\":\"translated\"}}\n\"A\" -> \"甲\"\n", + serde_json::to_string(&unknown).unwrap() ); assert!(parse(&unknown_meta) .unwrap_err() diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index b4af6c6..7a78f01 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,7 +1,7 @@ use crate::core; use crate::error::AppError; pub use crate::protocol::CmpTargetEdit; -use crate::task_state::{TaskState, TaskStateStore}; +use crate::task_state::{ActiveTask, TaskState, TaskStateStore}; use serde::{Deserialize, Serialize}; #[derive(Debug, Deserialize)] @@ -65,6 +65,7 @@ pub struct LoadCmpResponse { pub task_id: String, pub task_state: TaskState, pub can_apply: bool, + pub cmp_revision: String, } #[derive(Debug, Deserialize, Serialize)] @@ -82,6 +83,7 @@ pub struct CmpEntryResponse { #[serde(deny_unknown_fields)] pub struct SaveCmpTargetsRequest { pub cmp_path: String, + pub expected_revision: String, pub edits: Vec, } @@ -89,6 +91,7 @@ pub struct SaveCmpTargetsRequest { pub struct SaveCmpTargetsResponse { pub saved: bool, pub entries: usize, + pub cmp_revision: String, } #[derive(Debug, Deserialize)] @@ -96,6 +99,7 @@ pub struct SaveCmpTargetsResponse { pub struct CmpScopeRequest { pub cmp_path: String, pub quests_dir: String, + pub cmp_revision: String, } #[derive(Debug, Deserialize)] @@ -103,6 +107,7 @@ pub struct CmpScopeRequest { pub struct ValidateCmpRequest { pub cmp_path: String, pub quests_dir: String, + pub cmp_revision: String, #[serde(default)] pub edits: Vec, } @@ -115,6 +120,28 @@ pub struct ApplyCmpResponse { pub post_commit_warnings: Vec, } +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RecoverTranslationRequest { + pub quests_dir: String, +} + +#[derive(Debug, Serialize)] +pub struct RecoverTranslationResponse { + pub recovered: usize, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InspectTaskStateRequest { + pub quests_dir: String, +} + +#[derive(Debug, Serialize)] +pub struct InspectTaskStateResponse { + pub activities: Vec, +} + pub fn invalid_input(error: impl Into) -> AppError { let message = error.into(); AppError::invalid_input(message.clone(), message) @@ -133,28 +160,40 @@ pub fn load_cmp( data_dir: &std::path::Path, request: LoadCmpRequest, ) -> Result { - let value = core::review_cmp_result(&serde_json::json!({"cmp_path": &request.cmp_path}))?; - let entries = serde_json::from_value( - value - .get("entries") - .cloned() - .ok_or_else(|| invalid_input("CMP 响应缺少 entries"))?, - ) - .map_err(|error| invalid_input(error.to_string()))?; - let document = crate::cmp::load(std::path::Path::new(&request.cmp_path))?; + let (document, cmp_revision) = + crate::cmp::load_with_revision(std::path::Path::new(&request.cmp_path))?; + let entries = document + .records + .iter() + .enumerate() + .map(|(index, record)| CmpEntryResponse { + index, + entry_id: record.entry_id.clone(), + path: record.path.clone(), + file: record.file.clone(), + source: record.source.clone(), + target: record.target.clone(), + status: record.status.clone(), + }) + .collect(); let (_, task_status) = TaskStateStore::new(data_dir)?.register_cmp(&document)?; Ok(LoadCmpResponse { entries, task_id: task_status.task_id, task_state: task_status.state, can_apply: task_status.can_apply, + cmp_revision, }) } pub fn save_cmp_targets( request: SaveCmpTargetsRequest, ) -> Result { - let value = core::save_cmp_targets(&request.cmp_path, &request.edits)?; + let value = core::save_cmp_targets( + &request.cmp_path, + &request.expected_revision, + &request.edits, + )?; serde_json::from_value(value).map_err(|error| invalid_input(error.to_string())) } @@ -166,6 +205,7 @@ pub fn validate_cmp( &serde_json::json!({ "cmp_path": &request.cmp_path, "quests_dir": request.quests_dir, + "cmp_revision": request.cmp_revision, }), &request.edits, )?; @@ -180,7 +220,17 @@ pub fn apply_cmp( data_dir: &std::path::Path, request: CmpScopeRequest, ) -> Result { - let document = crate::cmp::load(std::path::Path::new(&request.cmp_path))?; + let (document, revision) = + crate::cmp::load_with_revision(std::path::Path::new(&request.cmp_path))?; + if revision != request.cmp_revision { + return Err(AppError::cmp_invalid( + "CMP 已被其他编辑器修改,请重新打开后再应用", + format!( + "CMP revision conflict before apply: expected={} actual={revision}", + request.cmp_revision + ), + )); + } let store = TaskStateStore::new(data_dir)?; let identity = store.begin_apply(&document)?; let value = core::apply_cmp_result( @@ -189,6 +239,7 @@ pub fn apply_cmp( "cmp_path": request.cmp_path, "quests_dir": request.quests_dir, "_task_id": identity.task_id, + "_cmp_revision": revision, }), ); match value { @@ -225,6 +276,25 @@ pub fn apply_cmp( } } +pub fn recover_translation( + data_dir: &std::path::Path, + request: RecoverTranslationRequest, +) -> Result { + let store = TaskStateStore::new(data_dir)?; + let recovered = + store.recover_interrupted_translation(std::path::Path::new(&request.quests_dir))?; + Ok(RecoverTranslationResponse { recovered }) +} + +pub fn inspect_task_state( + data_dir: &std::path::Path, + request: InspectTaskStateRequest, +) -> Result { + let activities = TaskStateStore::new(data_dir)? + .active_operations(std::path::Path::new(&request.quests_dir))?; + Ok(InspectTaskStateResponse { activities }) +} + #[cfg(test)] mod tests { use super::*; @@ -256,6 +326,7 @@ mod tests { fn cmp_requests_reject_unknown_top_level_fields() { let error = serde_json::from_value::(json!({ "cmp_path": "/tmp/review.cmp", + "expected_revision": "revision", "edits": [], "source": "tampered" })) @@ -265,12 +336,14 @@ mod tests { let valid = serde_json::from_value::(json!({ "cmp_path": "/tmp/review.cmp", "quests_dir": "/tmp/quests", + "cmp_revision": "revision", "edits": [{"index": 0, "target": "译文"}] })); assert!(valid.is_ok()); let error = serde_json::from_value::(json!({ "cmp_path": "/tmp/review.cmp", "quests_dir": "/tmp/quests", + "cmp_revision": "revision", "edits": [], "source": "tampered" })) @@ -332,6 +405,7 @@ mod tests { CmpScopeRequest { cmp_path: cmp_path.display().to_string(), quests_dir: quests.display().to_string(), + cmp_revision: cmp::revision(&cmp_path).unwrap(), }, ) .unwrap(); @@ -345,6 +419,7 @@ mod tests { CmpScopeRequest { cmp_path: cmp_path.display().to_string(), quests_dir: quests.display().to_string(), + cmp_revision: cmp::revision(&cmp_path).unwrap(), }, ) .unwrap_err(); @@ -360,5 +435,20 @@ mod tests { .unwrap(); assert_eq!(reimported.task_state, TaskState::Applied); assert!(!reimported.can_apply); + + let original = fs::read_to_string(&cmp_path).unwrap(); + fs::write( + &cmp_path, + original.replace("command-apply-task", "tampered-apply-task"), + ) + .unwrap(); + let tampered = load_cmp( + &data_dir, + LoadCmpRequest { + cmp_path: cmp_path.display().to_string(), + }, + ) + .unwrap_err(); + assert_eq!(tampered.code, crate::error::ErrorCode::CmpInvalid); } } diff --git a/src-tauri/src/core.rs b/src-tauri/src/core.rs index aff4437..81a2822 100644 --- a/src-tauri/src/core.rs +++ b/src-tauri/src/core.rs @@ -136,12 +136,12 @@ pub fn export_cmp(payload: &Value) -> Result { review::export_cmp(payload) } -pub fn review_cmp_result(payload: &Value) -> AppResult { - review::review_cmp_result(payload) -} - -pub fn save_cmp_targets(path: &str, edits: &[CmpTargetEdit]) -> AppResult { - review::save_cmp_targets(path, edits) +pub fn save_cmp_targets( + path: &str, + expected_revision: &str, + edits: &[CmpTargetEdit], +) -> AppResult { + review::save_cmp_targets(path, expected_revision, edits) } pub fn open_cmp(payload: &Value) -> Result { @@ -155,7 +155,8 @@ pub fn apply_cmp(data_dir: &Path, payload: &Value) -> Result { pub(crate) use protection::{prepare_entry, protect, render_entry, restore}; pub(crate) use review::{ - cmp_records, entry_source_file, source_fingerprint, validate_cmp_identity, validate_cmp_source, + cmp_records, current_source_fingerprint, entry_source_file, source_fingerprint_matches, + validate_cmp_identity, validate_cmp_source, }; pub(crate) use scan::{mode, parse_auto}; pub(crate) use translation::{cache_key, load_cache, save_cache, warnings}; @@ -163,6 +164,9 @@ pub(crate) use translation::{cache_key, load_cache, save_cache, warnings}; #[cfg(test)] pub(crate) use protection::protect_for_translation; +#[cfg(test)] +pub(crate) use review::source_fingerprint; + #[cfg(test)] pub(crate) use writeback::{ apply_cmp_inner, apply_cmp_inner_with_options, backup_with_options, @@ -276,7 +280,7 @@ mod tests { ); } #[test] - fn disabled_glossary_keeps_legacy_cache_key() { + fn cache_key_isolates_openai_compatible_endpoints() { let settings = Settings { provider: providers::OPENAI_COMPATIBLE.into(), base_url: "https://api.deepseek.com".into(), @@ -287,7 +291,7 @@ mod tests { hash.update( json!({ "source_text":"Mekanism", - "model":"deepseek-chat", + "model":"openai_compatible:deepseek-chat:https://api.deepseek.com", "target_locale":"zh_cn", "style":settings.style }) @@ -298,6 +302,14 @@ mod tests { hex::encode(hash.finalize()) ); + let mut other_endpoint = settings.clone(); + other_endpoint.base_url = "https://example.com/v1".into(); + assert_ne!( + cache_key("Mekanism", &settings), + cache_key("Mekanism", &other_endpoint) + ); + assert!(super::translation::legacy_openai_cache_key("Mekanism", &other_endpoint).is_none()); + let mut enabled = settings; enabled.glossary_enabled = true; enabled.glossary_fingerprint = "custom-content-hash".into(); @@ -355,6 +367,27 @@ mod tests { assert_eq!(result["mode"], "lang"); } + #[test] + fn source_v2_fingerprint_detects_non_translatable_chapter_changes() { + let directory = tempdir().unwrap(); + let quests = directory.path().join("quests"); + let chapter = quests.join("chapters/a.snbt"); + fs::create_dir_all(chapter.parent().unwrap()).unwrap(); + fs::write(&chapter, r#"{ id: "quest-one", title: "Hello" }"#).unwrap(); + let segment = chapters::extract(&chapter).unwrap().remove(0); + let (entry, _) = prepare_entry(segment.cache_id, segment.source, 0, None); + let entries = vec![entry]; + let fingerprint = current_source_fingerprint(&quests, "chapters", &entries).unwrap(); + + fs::write(&chapter, r#"{ id: "quest-two", title: "Hello" }"#).unwrap(); + let segment = chapters::extract(&chapter).unwrap().remove(0); + let (current_entry, _) = prepare_entry(segment.cache_id, segment.source, 0, None); + assert!( + !source_fingerprint_matches(&fingerprint, &quests, "chapters", &[current_entry]) + .unwrap() + ); + } + fn write_test_cmp( path: &Path, quests: &Path, @@ -679,6 +712,7 @@ mod tests { save_cmp_targets( cmp_path.to_str().unwrap(), + &cmp::revision(&cmp_path).unwrap(), &[CmpTargetEdit { index: 0, target: "错误地删除颜色码".into(), @@ -713,6 +747,33 @@ mod tests { assert!(!quests.join(".ftb-translator/backups").exists()); } + #[test] + fn cmp_target_save_rejects_a_stale_file_revision() { + let directory = tempdir().unwrap(); + let quests = directory.path().join("config/ftbquests/quests"); + fs::create_dir_all(quests.join("lang")).unwrap(); + fs::write(quests.join("lang/en_us.snbt"), r#"{ title: "Hello" }"#).unwrap(); + let cmp_path = directory.path().join("review.cmp"); + write_test_cmp(&cmp_path, &quests, "Hello", "Hello", "你好"); + let stale_revision = cmp::revision(&cmp_path).unwrap(); + + let content = fs::read_to_string(&cmp_path).unwrap(); + fs::write(&cmp_path, content.replace("你好", "您好")).unwrap(); + let error = save_cmp_targets( + cmp_path.to_str().unwrap(), + &stale_revision, + &[CmpTargetEdit { + index: 0, + target: "再次覆盖".into(), + }], + ) + .unwrap_err(); + + assert_eq!(error.code, ErrorCode::CmpInvalid); + assert!(error.user_message.contains("其他编辑器")); + assert!(fs::read_to_string(cmp_path).unwrap().contains("您好")); + } + #[test] fn history_failure_after_commit_returns_success_with_an_unavailable_run_id() { let directory = tempdir().unwrap(); diff --git a/src-tauri/src/core/review.rs b/src-tauri/src/core/review.rs index 76b6907..5b29fd3 100644 --- a/src-tauri/src/core/review.rs +++ b/src-tauri/src/core/review.rs @@ -1,6 +1,6 @@ use super::{ - cmp, json, logging, rich_text, AppError, AppResult, BTreeMap, CmpTargetEdit, Entry, EntryKind, - HashMap, HashSet, Item, Path, PathBuf, Sha256, Value, + chapters, cmp, fs, json, logging, rich_text, AppError, AppResult, BTreeMap, CmpTargetEdit, + Entry, EntryKind, HashMap, HashSet, Item, Path, PathBuf, Sha256, Value, }; use sha2::Digest; use std::process::Command; @@ -16,6 +16,64 @@ pub(crate) fn source_fingerprint(entries: &[Entry]) -> String { hex::encode(hash.finalize()) } +pub(crate) fn current_source_fingerprint( + quests_dir: &Path, + mode: &str, + entries: &[Entry], +) -> AppResult { + let files = if mode == "lang" { + vec![quests_dir.join("lang/en_us.snbt")] + } else { + chapters::files(quests_dir) + }; + let mut hash = Sha256::new(); + hash.update(b"ftb-translator-source-v2\0"); + hash.update(if mode == "lang" { + b"lang-parser-v1".as_slice() + } else { + b"chapter-token-walker-v1".as_slice() + }); + hash.update([0]); + hash.update(source_fingerprint(entries).as_bytes()); + for path in files { + let relative = path.strip_prefix(quests_dir).map_err(|error| { + AppError::source_changed( + "无法建立任务书源文件指纹", + format!( + "source path {} is outside task book: {error}", + path.display() + ), + ) + })?; + let bytes = fs::read(&path).map_err(|error| { + AppError::source_changed( + format!("无法读取任务书源文件 {}:{error}", path.display()), + error.to_string(), + ) + })?; + let relative = relative.to_string_lossy(); + hash.update((relative.len() as u64).to_le_bytes()); + hash.update(relative.as_bytes()); + hash.update((bytes.len() as u64).to_le_bytes()); + hash.update(bytes); + } + Ok(format!("v2:{}", hex::encode(hash.finalize()))) +} + +pub(crate) fn source_fingerprint_matches( + recorded: &str, + quests_dir: &Path, + mode: &str, + entries: &[Entry], +) -> AppResult { + if recorded.starts_with("v2:") { + Ok(recorded == current_source_fingerprint(quests_dir, mode, entries)?) + } else { + // CMP v1 files created before source-v2 hashed only extracted IDs and English. + Ok(recorded == source_fingerprint(entries)) + } +} + pub(crate) fn validate_cmp_identity( document: &cmp::Document, quests_dir: &Path, @@ -60,7 +118,12 @@ pub(crate) fn validate_cmp_source( ) -> AppResult<()> { validate_cmp_identity(document, quests_dir, mode)?; if document.meta.total_entries != entries.len() - || document.meta.source_fingerprint != source_fingerprint(entries) + || !source_fingerprint_matches( + &document.meta.source_fingerprint, + quests_dir, + mode, + entries, + )? { return Err(AppError::source_changed( "任务书内容在 CMP 生成后发生了变化,请重新扫描并翻译", @@ -185,29 +248,19 @@ pub fn export_cmp(payload: &Value) -> Result { Ok(json!({"path":target})) } -pub fn review_cmp_result(payload: &Value) -> AppResult { - let path = Path::new( - payload["cmp_path"] - .as_str() - .ok_or_else(|| AppError::invalid_input("缺少 CMP 文件路径", "cmp_path is missing"))?, - ); - let document = cmp::load(path)?; - Ok(json!({ - "entries": document.records.iter().enumerate().map(|(index, record)| json!({ - "index": index, - "entry_id": record.entry_id, - "path": record.path, - "file": record.file, - "source": record.source, - "target": record.target, - "status": record.status, - })).collect::>(), - })) -} - -pub fn save_cmp_targets(path: &str, edits: &[CmpTargetEdit]) -> AppResult { +pub fn save_cmp_targets( + path: &str, + expected_revision: &str, + edits: &[CmpTargetEdit], +) -> AppResult { let path = Path::new(path); - let mut document = cmp::load(path)?; + let (mut document, revision) = cmp::load_with_revision(path)?; + if revision != expected_revision { + return Err(AppError::cmp_invalid( + "CMP 已被其他编辑器修改,请重新打开后再保存", + format!("CMP revision conflict: expected={expected_revision} actual={revision}"), + )); + } if edits.len() != document.records.len() { return Err(AppError::cmp_invalid( "CMP 校对表格条目数已变化,请重新打开校对表格", @@ -232,16 +285,35 @@ pub fn save_cmp_targets(path: &str, edits: &[CmpTargetEdit]) -> AppResult let message = format!("第 {} 条译文不能为空", expected_index + 1); return Err(AppError::cmp_invalid(message.clone(), message)); } - document.records[edit.index].target.clone_from(&edit.target); + let record = &mut document.records[edit.index]; + let target_changed = record.target != edit.target; + record.target.clone_from(&edit.target); + if target_changed + && matches!( + record.status.as_str(), + "rate_limited" | "request_failed" | "format_guard" | "unchanged" | "fallback" + ) + && record.target != record.source + { + record.status = "review".into(); + } + } + let current_revision = cmp::revision(path)?; + if current_revision != revision { + return Err(AppError::cmp_invalid( + "CMP 在保存期间被其他编辑器修改,请重新打开后再试", + format!("CMP changed during save: loaded={revision} current={current_revision}"), + )); } cmp::write(path, &document)?; + let revision = cmp::revision(path)?; logging::info( "translation", "cmp_edits_saved", "CMP 校对表格修改已保存", json!({"task_id":document.meta.task_id,"cmp_path":path,"entries":edits.len()}), ); - Ok(json!({"saved":true,"entries":edits.len()})) + Ok(json!({"saved":true,"entries":edits.len(),"cmp_revision":revision})) } pub fn open_cmp(payload: &Value) -> Result { diff --git a/src-tauri/src/core/translation.rs b/src-tauri/src/core/translation.rs index 19cdfd4..17a45a4 100644 --- a/src-tauri/src/core/translation.rs +++ b/src-tauri/src/core/translation.rs @@ -1,9 +1,9 @@ use super::{ - chapters, cmp, cmp_records, crate_root, fs, glossary, json, logging, mode, parse_auto, - prepare_entry, protect, providers, render_entry, restore, rich_text, snbt, source_fingerprint, - stream, validate_cmp_identity, validate_cmp_source, AppError, AppResult, BTreeMap, Client, - Duration, Emitter, EntryKind, ErrorCode, HashMap, HashSet, Instant, Item, LangValue, Local, - Path, PathBuf, Settings, Sha256, StreamExt, Value, + chapters, cmp, cmp_records, crate_root, current_source_fingerprint, fs, glossary, json, + logging, mode, parse_auto, prepare_entry, protect, providers, render_entry, restore, rich_text, + snbt, stream, validate_cmp_identity, validate_cmp_source, AppError, AppResult, BTreeMap, + Client, Duration, Emitter, EntryKind, ErrorCode, HashMap, HashSet, Instant, Item, LangValue, + Local, Path, PathBuf, Settings, Sha256, StreamExt, Value, }; use serde::Serialize; use sha2::Digest; @@ -42,7 +42,7 @@ fn save_translation_units( output.push_str(&serde_json::to_string(&record).map_err(|e| e.to_string())?); output.push('\n'); } - fs::write(directory.join("translation-units-latest.jsonl"), output) + crate_root::atomic_file::write(&directory.join("translation-units-latest.jsonl"), output) .map_err(|e| format!("无法保存翻译中间文件:{e}")) } @@ -54,6 +54,15 @@ fn request_failure_status(error: &AppError) -> &'static str { } } +fn retry_locations(document: &cmp::Document) -> HashSet<(String, String)> { + document + .records + .iter() + .filter(|record| record.status == "rate_limited" && record.target == record.source) + .map(|record| (record.entry_id.clone(), record.path.clone())) + .collect() +} + pub(crate) fn warnings(source: &str, target: &str) -> Vec { let (_, st) = protect(source); let (_, tt) = protect(target); @@ -84,17 +93,20 @@ pub(crate) fn warnings(source: &str, target: &str) -> Vec { w } pub(crate) fn cache_key(source: &str, s: &Settings) -> String { - let mut h = Sha256::new(); - let cache_model = if s.provider == providers::OPENAI_COMPATIBLE { - s.model.clone() - } else { - format!( + cache_key_with_model( + source, + s, + &format!( "{}:{}:{}", s.provider, s.model, s.base_url.trim_end_matches('/') - ) - }; + ), + ) +} + +fn cache_key_with_model(source: &str, s: &Settings, cache_model: &str) -> String { + let mut h = Sha256::new(); let cache_data = if s.glossary_enabled { let mut value = json!({ "source_text":source, @@ -118,6 +130,12 @@ pub(crate) fn cache_key(source: &str, s: &Settings) -> String { h.update(cache_data.to_string()); hex::encode(h.finalize()) } + +pub(crate) fn legacy_openai_cache_key(source: &str, settings: &Settings) -> Option { + (settings.provider == providers::OPENAI_COMPATIBLE + && settings.base_url.trim_end_matches('/') == "https://api.deepseek.com") + .then(|| cache_key_with_model(source, settings, &settings.model)) +} pub(crate) fn load_cache(q: &Path) -> HashMap { [".ftb-translator", ".ftb-translater"] .into_iter() @@ -131,7 +149,8 @@ pub(crate) fn load_cache(q: &Path) -> HashMap { pub(crate) fn save_cache(q: &Path, c: &HashMap) -> Result<(), String> { let p = q.join(".ftb-translator/cache.json"); fs::create_dir_all(p.parent().unwrap()).map_err(|e| e.to_string())?; - fs::write(p, serde_json::to_vec_pretty(c).unwrap()).map_err(|e| e.to_string()) + crate_root::atomic_file::write(&p, serde_json::to_vec_pretty(c).unwrap()) + .map_err(|e| e.to_string()) } async fn request( client: &Client, @@ -159,14 +178,7 @@ pub async fn translate(app: AppHandle, data_dir: PathBuf, payload: Value) -> Res .transpose()?; let retry_locations = retry_document .as_ref() - .map_or_else(HashSet::new, |document| { - document - .records - .iter() - .filter(|record| record.status == "rate_limited") - .map(|record| (record.entry_id.clone(), record.path.clone())) - .collect() - }); + .map_or_else(HashSet::new, retry_locations); if payload["retry_cmp_path"].is_string() && retry_locations.is_empty() { return Err("CMP 中没有可重试的限流条目".into()); } @@ -297,7 +309,13 @@ pub async fn translate(app: AppHandle, data_dir: PathBuf, payload: Value) -> Res results.insert(entry.id.clone(), entry.source.clone()); continue; } - if let Some(value) = cache.get(&cache_key(&entry.source, &settings)) { + let key = cache_key(&entry.source, &settings); + let cached = cache.get(&key).cloned().or_else(|| { + legacy_openai_cache_key(&entry.source, &settings) + .and_then(|legacy| cache.get(&legacy).cloned()) + }); + if let Some(value) = cached { + cache.insert(key, value.clone()); results.insert(entry.id.clone(), value.clone()); hits += 1 } else { @@ -538,9 +556,16 @@ pub async fn translate(app: AppHandle, data_dir: PathBuf, payload: Value) -> Res .as_str() .map(PathBuf::from) .unwrap_or_else(|| { + let safe_task_id = task_id + .chars() + .filter(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_') + }) + .take(48) + .collect::(); q.join(".ftb-translator/reviews").join(format!( - "translation-{}.cmp", - Local::now().format("%Y%m%d-%H%M%S") + "translation-{}-{safe_task_id}.cmp", + Local::now().format("%Y%m%d-%H%M%S%.3f") )) }); cmp::write( @@ -551,7 +576,7 @@ pub async fn translate(app: AppHandle, data_dir: PathBuf, payload: Value) -> Res task_id: task_id.clone(), quests_dir: q.display().to_string(), mode: m.into(), - source_fingerprint: source_fingerprint(&entries), + source_fingerprint: current_source_fingerprint(&q, m, &entries)?, provider: settings.provider.clone(), base_url: settings.base_url.clone(), model: settings.model.clone(), @@ -601,3 +626,52 @@ pub async fn translate(app: AppHandle, data_dir: PathBuf, payload: Value) -> Res ); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn retry_excludes_rate_limited_records_with_manual_targets() { + let document = cmp::Document { + meta: cmp::Meta { + version: 1, + task_id: "retry-test".into(), + quests_dir: "/tmp/quests".into(), + mode: "lang".into(), + source_fingerprint: "source".into(), + provider: providers::GOOGLE_WEB.into(), + base_url: "https://translate.googleapis.com".into(), + model: "google-web".into(), + style: "自然中文".into(), + glossary_enabled: false, + glossary_fingerprint: String::new(), + total_entries: 2, + cache_hits: 0, + }, + records: vec![ + cmp::Record { + file: "lang/en_us.snbt".into(), + entry_id: "pending".into(), + path: "$".into(), + source: "Pending".into(), + target: "Pending".into(), + status: "rate_limited".into(), + }, + cmp::Record { + file: "lang/en_us.snbt".into(), + entry_id: "manual".into(), + path: "$".into(), + source: "Manual".into(), + target: "人工译文".into(), + status: "rate_limited".into(), + }, + ], + }; + + assert_eq!( + retry_locations(&document), + HashSet::from([("pending".into(), "$".into())]) + ); + } +} diff --git a/src-tauri/src/core/writeback.rs b/src-tauri/src/core/writeback.rs index f7a8403..46883a9 100644 --- a/src-tauri/src/core/writeback.rs +++ b/src-tauri/src/core/writeback.rs @@ -1,6 +1,6 @@ use super::{ cache_key, chapters, cmp, entry_source_file, fs, json, load_cache, logging, mode, - prepare_entry, render_entry, resolve, save_cache, snbt, source_fingerprint, + prepare_entry, render_entry, resolve, save_cache, snbt, source_fingerprint_matches, validate_cmp_identity, warnings, AppError, AppResult, BTreeMap, CmpTargetEdit, CmpValidationReport, Entry, EntryKind, HashMap, HashSet, History, Item, LangValue, Local, Path, PathBuf, Report, Settings, Value, WalkDir, @@ -452,8 +452,8 @@ pub fn apply_cmp_result(data_dir: &Path, payload: &Value) -> AppResult { .ok_or_else(|| AppError::invalid_input("缺少 CMP 文件路径", "cmp_path is missing"))?, ); let operation_id = logging::task_id(); - let document = match cmp::load(&cmp_path) { - Ok(document) => document, + let (document, revision) = match cmp::load_with_revision(&cmp_path) { + Ok(value) => value, Err(error) => { logging::warn( "translation", @@ -464,6 +464,19 @@ pub fn apply_cmp_result(data_dir: &Path, payload: &Value) -> AppResult { return Err(AppError::cmp_invalid(error.clone(), error)); } }; + if let Some(expected_revision) = payload["_cmp_revision"] + .as_str() + .filter(|revision| !revision.is_empty()) + { + if expected_revision != revision { + return Err(AppError::cmp_invalid( + "CMP 已被其他编辑器修改,请重新打开后再应用", + format!( + "CMP revision conflict in apply: expected={expected_revision} actual={revision}" + ), + )); + } + } let task_id = if let Some(task_id) = payload["_task_id"] .as_str() .filter(|task_id| !task_id.trim().is_empty()) @@ -632,8 +645,21 @@ pub fn validate_cmp(payload: &Value, edits: &[CmpTargetEdit]) -> AppResult AppResult Result { + let dir = data_dir(&app).map_err(commands::invalid_input)?; + commands::recover_translation(&dir, request) +} + +#[tauri::command] +fn inspect_task_state( + app: tauri::AppHandle, + request: commands::InspectTaskStateRequest, +) -> Result { + let dir = data_dir(&app).map_err(commands::invalid_input)?; + commands::inspect_task_state(&dir, request) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -267,7 +286,9 @@ pub fn run() { load_cmp, save_cmp_targets, validate_cmp, - apply_cmp + apply_cmp, + recover_translation, + inspect_task_state ]) .setup(|app| { let dir = data_dir(app.handle()).map_err(std::io::Error::other)?; diff --git a/src-tauri/src/logging.rs b/src-tauri/src/logging.rs index c63340a..6b13c95 100644 --- a/src-tauri/src/logging.rs +++ b/src-tauri/src/logging.rs @@ -292,7 +292,10 @@ fn clean_value(value: Value, depth: usize) -> Value { let hidden = is_sensitive_key(&key); let value = if hidden { Value::String("[REDACTED]".into()) - } else if key.eq_ignore_ascii_case("error") { + } else if key.eq_ignore_ascii_case("error") + || key.eq_ignore_ascii_case("user_message") + || key.eq_ignore_ascii_case("internal_message") + { match value { Value::String(value) => Value::String(clean_error(&value)), value => clean_value(value, depth + 1), @@ -445,6 +448,18 @@ mod tests { 0, ); assert_eq!(value["error"], "HTTP 429 Too Many Requests"); + + let nested = clean_value( + json!({ + "error": { + "user_message": "HTTP 401 Unauthorized: echoed response body", + "internal_message": "HTTP 401 Unauthorized: echoed response body" + } + }), + 0, + ); + assert_eq!(nested["error"]["user_message"], "HTTP 401 Unauthorized"); + assert_eq!(nested["error"]["internal_message"], "HTTP 401 Unauthorized"); } #[test] diff --git a/src-tauri/src/providers.rs b/src-tauri/src/providers.rs index 3f35077..78e5ac1 100644 --- a/src-tauri/src/providers.rs +++ b/src-tauri/src/providers.rs @@ -64,6 +64,21 @@ fn classify_request_error(message: String) -> AppError { } } +fn retryable_status(status: StatusCode) -> bool { + status == StatusCode::REQUEST_TIMEOUT + || status == StatusCode::TOO_MANY_REQUESTS + || status.is_server_error() +} + +fn retry_after(response: &Response) -> Option { + response + .headers() + .get(header::RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .map(|seconds| Duration::from_secs(seconds.min(60))) +} + async fn request_openai( client: &Client, s: &Settings, @@ -87,6 +102,7 @@ async fn request_openai( let mut use_response_format = true; let mut last = String::new(); for attempt in 0..3 { + let mut wait = Duration::from_millis(800 * (attempt + 1)); logging::debug( "provider", "openai_attempt_started", @@ -106,6 +122,7 @@ async fn request_openai( { Ok(response) => { let status = response.status(); + let server_wait = retry_after(&response); let text = response.text().await.unwrap_or_default(); if !status.is_success() { logging::warn( @@ -124,7 +141,13 @@ async fn request_openai( use_response_format = false; continue; } - last = format!("HTTP {status}: {text}"); + last = format!("HTTP {status}"); + if !retryable_status(status) { + return Err(last); + } + if let Some(server_wait) = server_wait { + wait = server_wait; + } } else { let value: Value = serde_json::from_str(&text) .map_err(|e| format!("OpenAI 兼容接口返回无效 JSON:{e}"))?; @@ -156,7 +179,7 @@ async fn request_openai( } } if attempt < 2 { - tokio::time::sleep(Duration::from_millis(800 * (attempt + 1))).await; + tokio::time::sleep(wait).await; } } Err(last) @@ -421,6 +444,7 @@ where { let mut last = String::new(); for attempt in 0..3 { + let mut wait = Duration::from_millis(1000 * (attempt + 1)); logging::debug( "provider", "http_attempt_started", @@ -439,14 +463,23 @@ where } Ok(response) => { let status = response.status(); + let server_wait = retry_after(&response); logging::warn( "provider", "http_attempt_failed", "翻译接口返回失败状态", json!({"task_id":task_id,"provider":provider,"attempt":attempt + 1,"http_status":status.as_u16()}), ); - let body = response.text().await.unwrap_or_default(); - last = format!("HTTP {status}: {body}"); + // Consume and discard the body so connection reuse still works, but never + // propagate third-party response text into application errors or logs. + let _ = response.text().await; + last = format!("HTTP {status}"); + if !retryable_status(status) { + return Err(last); + } + if let Some(server_wait) = server_wait { + wait = server_wait; + } } Err(error) => { logging::warn( @@ -459,7 +492,7 @@ where } } if attempt < 2 { - tokio::time::sleep(Duration::from_millis(1000 * (attempt + 1))).await; + tokio::time::sleep(wait).await; } } Err(last) @@ -545,6 +578,11 @@ mod tests { assert_eq!(provider.code, crate::error::ErrorCode::ProviderFailed); assert!(provider.retryable); assert_eq!(provider.user_message, "HTTP 503 Service Unavailable"); + + assert!(retryable_status(StatusCode::TOO_MANY_REQUESTS)); + assert!(retryable_status(StatusCode::SERVICE_UNAVAILABLE)); + assert!(!retryable_status(StatusCode::UNAUTHORIZED)); + assert!(!retryable_status(StatusCode::BAD_REQUEST)); } #[test] diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index 25cbc68..c510177 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -251,7 +251,7 @@ pub fn save_settings(dir: &Path, v: &Value) -> Result { // Validate every non-sensitive field before changing the system credential. // Otherwise a rejected settings form can still replace or delete a working key. let config = serde_json::to_vec_pretty(&c).map_err(|e| e.to_string())?; - fs::write(dir.join("settings.json"), config).map_err(|e| e.to_string())?; + crate::atomic_file::write(&dir.join("settings.json"), config).map_err(|e| e.to_string())?; crate::logging::set_level(log_level)?; if v["api_key_changed"].as_bool().unwrap_or(false) { let key = v["api_key"].as_str().unwrap_or("").trim(); @@ -368,7 +368,9 @@ impl History { let c = self.conn()?; let mut q=c.prepare("SELECT id,pack_name,quests_dir,mode,model,style,total_entries,translated_entries,cache_hits,failed_count,warning_count,created_at FROM translation_runs ORDER BY created_at DESC,id DESC LIMIT 100").map_err(|e|e.to_string())?; let rows=q.query_map([],|r|Ok(json!({"id":r.get::<_,i64>(0)?,"pack_name":r.get::<_,String>(1)?,"quests_dir":r.get::<_,String>(2)?,"mode":r.get::<_,String>(3)?,"model":r.get::<_,String>(4)?,"style":r.get::<_,String>(5)?,"total_entries":r.get::<_,i64>(6)?,"translated_entries":r.get::<_,i64>(7)?,"cache_hits":r.get::<_,i64>(8)?,"failed_count":r.get::<_,i64>(9)?,"warning_count":r.get::<_,i64>(10)?,"created_at":r.get::<_,String>(11)?}))).map_err(|e|e.to_string())?; - let rows = rows.filter_map(Result::ok).collect::>(); + let rows = rows + .collect::, _>>() + .map_err(|error| error.to_string())?; crate::logging::debug( "history", "history_listed", diff --git a/src-tauri/src/task_state.rs b/src-tauri/src/task_state.rs index e6163e6..a42c55e 100644 --- a/src-tauri/src/task_state.rs +++ b/src-tauri/src/task_state.rs @@ -9,12 +9,13 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{ path::{Path, PathBuf}, - sync::{Mutex, MutexGuard, OnceLock}, + sync::{LazyLock, Mutex, MutexGuard, OnceLock}, }; const DATABASE_FILENAME: &str = "task-state.sqlite3"; static STATE_TRANSITION_LOCK: OnceLock> = OnceLock::new(); +static PROCESS_STARTED_AT: LazyLock> = LazyLock::new(Utc::now); #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] @@ -72,6 +73,14 @@ pub struct TaskStatus { pub can_apply: bool, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct ActiveTask { + pub task_id: String, + pub state: TaskState, + pub updated_at: String, + pub recoverable: bool, +} + #[derive(Clone, Debug)] pub struct TaskStateStore { database_path: PathBuf, @@ -79,6 +88,7 @@ pub struct TaskStateStore { impl TaskStateStore { pub fn new(data_dir: &Path) -> AppResult { + let _ = &*PROCESS_STARTED_AT; std::fs::create_dir_all(data_dir).map_err(|error| { state_storage_error(format!( "create application data directory {}: {error}", @@ -244,6 +254,118 @@ impl TaskStateStore { ) } + pub fn recover_interrupted_translation(&self, quests_dir: &Path) -> AppResult { + self.recover_interrupted_translation_before(quests_dir, &PROCESS_STARTED_AT) + } + + pub fn active_operations(&self, quests_dir: &Path) -> AppResult> { + let quests_dir = normalize_existing_path(quests_dir)?; + let _guard = transition_lock()?; + let connection = self.connection()?; + let mut query = connection + .prepare( + "SELECT task_id,state,updated_at FROM task_states + WHERE quests_dir=? AND state IN ('translating','applying') + ORDER BY updated_at,task_id", + ) + .map_err(sql_error)?; + let rows = query + .query_map(params![quests_dir], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }) + .map_err(sql_error)?; + let activities = rows + .map(|row| { + let (task_id, state, updated_at) = row.map_err(sql_error)?; + let state = TaskState::parse(&state)?; + let timestamp = chrono::DateTime::parse_from_rfc3339(&updated_at) + .map_err(|error| { + state_storage_error(format!("invalid task timestamp: {error}")) + })? + .with_timezone(&Utc); + Ok(ActiveTask { + task_id, + state, + updated_at, + recoverable: state == TaskState::Translating && timestamp < *PROCESS_STARTED_AT, + }) + }) + .collect::>>()?; + logging::debug( + "task_state", + "active_operations_inspected", + "已检查任务书的活动任务状态", + serde_json::json!({ + "quests_dir":quests_dir, + "activities":activities.iter().map(|activity| serde_json::json!({ + "task_id":activity.task_id, + "state":activity.state.as_str(), + "recoverable":activity.recoverable + })).collect::>() + }), + ); + Ok(activities) + } + + fn recover_interrupted_translation_before( + &self, + quests_dir: &Path, + cutoff: &chrono::DateTime, + ) -> AppResult { + let quests_dir = normalize_existing_path(quests_dir)?; + let _guard = transition_lock()?; + let mut connection = self.connection()?; + let transaction = connection.transaction().map_err(sql_error)?; + let candidates = { + let mut query = transaction + .prepare( + "SELECT identity,updated_at FROM task_states + WHERE quests_dir=? AND state='translating'", + ) + .map_err(sql_error)?; + let rows = query + .query_map(params![quests_dir], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(sql_error)?; + rows.collect::, _>>().map_err(sql_error)? + }; + let mut recovered = Vec::new(); + for (identity, updated_at) in candidates { + let updated_at = chrono::DateTime::parse_from_rfc3339(&updated_at) + .map_err(|error| state_storage_error(format!("invalid task timestamp: {error}")))? + .with_timezone(&Utc); + if updated_at >= *cutoff { + continue; + } + set_state( + &transaction, + &identity, + TaskState::Translating, + TaskState::Failed, + )?; + recovered.push(identity); + } + transaction.commit().map_err(sql_error)?; + if recovered.is_empty() { + return Err(AppError::task_state_conflict( + "没有可恢复的中断翻译任务;当前任务可能仍在运行", + format!("no translating task older than process start for {quests_dir}"), + )); + } + logging::warn( + "task_state", + "interrupted_translations_recovered", + "用户确认将上次进程中断的翻译任务标记为失败", + serde_json::json!({"quests_dir":quests_dir,"count":recovered.len()}), + ); + Ok(recovered.len()) + } + fn transition(&self, identity: &str, from: TaskState, to: TaskState) -> AppResult<()> { let _guard = transition_lock()?; let mut connection = self.connection()?; @@ -464,8 +586,13 @@ fn reject_busy_task_book( .optional() .map_err(sql_error)?; if let Some((identity, state)) = busy { + let user_message = if state == TaskState::Applying.as_str() { + "当前任务书存在未完成的写回状态。为避免重复覆盖,应用不会自动解锁;请先检查任务书、备份和诊断日志" + } else { + "当前任务书已有翻译任务正在执行,或上次翻译异常中断;请先检查任务状态" + }; return Err(AppError::task_state_conflict( - "当前任务书已有翻译或写回任务正在执行,请等待完成后再试", + user_message, format!("busy identity={identity} state={state} quests_dir={quests_dir}"), )); } @@ -580,6 +707,64 @@ mod tests { assert!(status.can_apply); } + #[test] + fn explicit_recovery_marks_only_an_older_translation_as_failed() { + let directory = tempdir().unwrap(); + let quests = directory.path().join("quests"); + std::fs::create_dir_all(&quests).unwrap(); + let store = TaskStateStore::new(&directory.path().join("data")).unwrap(); + store + .reserve_new_translation(&quests, "interrupted-task") + .unwrap(); + + let cutoff = Utc::now() + chrono::Duration::seconds(1); + assert_eq!( + store + .recover_interrupted_translation_before(&quests, &cutoff) + .unwrap(), + 1 + ); + store + .reserve_new_translation(&quests, "replacement-task") + .unwrap(); + } + + #[test] + fn active_operations_distinguish_recoverable_translation_from_writeback() { + let directory = tempdir().unwrap(); + let quests = directory.path().join("quests"); + std::fs::create_dir_all(&quests).unwrap(); + let store = TaskStateStore::new(&directory.path().join("data")).unwrap(); + let interrupted = store + .reserve_new_translation(&quests, "interrupted-task") + .unwrap(); + store + .connection() + .unwrap() + .execute( + "UPDATE task_states SET updated_at=? WHERE identity=?", + params![ + (*PROCESS_STARTED_AT - chrono::Duration::minutes(1)).to_rfc3339(), + interrupted.id + ], + ) + .unwrap(); + + let active = store.active_operations(&quests).unwrap(); + assert_eq!(active.len(), 1); + assert_eq!(active[0].state, TaskState::Translating); + assert!(active[0].recoverable); + store.recover_interrupted_translation(&quests).unwrap(); + + let cmp = document(&quests, "writeback-task", "你好"); + store.begin_apply(&cmp).unwrap(); + let active = store.active_operations(&quests).unwrap(); + assert_eq!(active.len(), 1); + assert_eq!(active[0].task_id, "writeback-task"); + assert_eq!(active[0].state, TaskState::Applying); + assert!(!active[0].recoverable); + } + #[test] fn successful_apply_is_remembered_after_ui_loss_and_restart() { let directory = tempdir().unwrap(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 19b75a3..68a5e33 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "FTB Translator", - "version": "0.2.0", + "version": "0.2.2", "identifier": "com.openres.ftb-translator", "build": { "beforeDevCommand": "npm run dev", diff --git a/src-tauri/tests/fixtures/chapters-nested/expected.cmp b/src-tauri/tests/fixtures/chapters-nested/expected.cmp index fca0b8e..4e920c0 100644 --- a/src-tauri/tests/fixtures/chapters-nested/expected.cmp +++ b/src-tauri/tests/fixtures/chapters-nested/expected.cmp @@ -1,6 +1,6 @@ # FTB Translator CMP v1 # 只修改箭头右侧的中文;保留 @ 行、英文原文、引号与 JSON 转义。 -# meta {"version":1,"task_id":"fixture-chapters-nested","quests_dir":"{{QUESTS_DIR}}","mode":"chapters","source_fingerprint":"67782d86d4d2ebcf25768287ae3c2f47c5558ea46c826f1e55eb855b5f3777a1","provider":"fixture_mock","base_url":"mock://offline","model":"deterministic-v1","style":"fixture","glossary_enabled":false,"glossary_fingerprint":"","total_entries":8,"cache_hits":0} +# meta {"version":1,"task_id":"fixture-chapters-nested","quests_dir":"{{QUESTS_DIR}}","mode":"chapters","source_fingerprint":"67782d86d4d2ebcf25768287ae3c2f47c5558ea46c826f1e55eb855b5f3777a1","provider":"fixture_mock","base_url":"mock://offline","model":"deterministic-v1","style":"fixture","glossary_enabled":false,"glossary_fingerprint":"","total_entries":8,"cache_hits":0,"protected_hash":"c8310d5a9e20e77d6494bbeddbb9f82423a6ecb61203bf94c1f72944c50cf292"} ## file "chapters/alpha.snbt" diff --git a/src-tauri/tests/fixtures/lang-rich/expected.cmp b/src-tauri/tests/fixtures/lang-rich/expected.cmp index b83dadb..5ab3495 100644 --- a/src-tauri/tests/fixtures/lang-rich/expected.cmp +++ b/src-tauri/tests/fixtures/lang-rich/expected.cmp @@ -1,6 +1,6 @@ # FTB Translator CMP v1 # 只修改箭头右侧的中文;保留 @ 行、英文原文、引号与 JSON 转义。 -# meta {"version":1,"task_id":"fixture-lang-rich","quests_dir":"{{QUESTS_DIR}}","mode":"lang","source_fingerprint":"42c4e588c6c2984793f36aea4bbbe68b042c85f0de16b37a3e3da640585d00ad","provider":"fixture_mock","base_url":"mock://offline","model":"deterministic-v1","style":"fixture","glossary_enabled":false,"glossary_fingerprint":"","total_entries":6,"cache_hits":0} +# meta {"version":1,"task_id":"fixture-lang-rich","quests_dir":"{{QUESTS_DIR}}","mode":"lang","source_fingerprint":"42c4e588c6c2984793f36aea4bbbe68b042c85f0de16b37a3e3da640585d00ad","provider":"fixture_mock","base_url":"mock://offline","model":"deterministic-v1","style":"fixture","glossary_enabled":false,"glossary_fingerprint":"","total_entries":6,"cache_hits":0,"protected_hash":"04e8f9516f6a00f941dbbdebce6e75144c4f3887fbada73c4a90c5f6f22a04f8"} ## file "lang/en_us.snbt" diff --git a/src-tauri/tests/golden_fixtures.rs b/src-tauri/tests/golden_fixtures.rs index 93a7b6f..f8dfd2a 100644 --- a/src-tauri/tests/golden_fixtures.rs +++ b/src-tauri/tests/golden_fixtures.rs @@ -1,5 +1,7 @@ #![allow(dead_code)] +#[path = "../src/atomic_file.rs"] +mod atomic_file; #[path = "../src/chapters.rs"] mod chapters; #[path = "../src/cmp.rs"] diff --git a/src/app/App.tsx b/src/app/App.tsx index dcf72d2..f1957c7 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -6,6 +6,7 @@ import { Nav } from "../components/Nav"; import { QuestMark } from "../components/QuestMark"; import { useTauriEvents } from "../hooks/useTauriEvents"; import type { CmpDraft, CmpEntry, CmpValidationReport } from "../models/cmp"; +import { classifyTaskRecovery } from "../lib/taskRecovery"; import { defaultSettings, providerOptions, @@ -24,12 +25,16 @@ import { import { HistoryPage } from "../pages/HistoryPage"; import { SettingsPage } from "../pages/SettingsPage"; import { WorkbenchPage } from "../pages/WorkbenchPage"; +import type { ActiveTask } from "../models/task"; import { applyCmp as applyCmpCommand, call, + errorCode, errorText, frontendLog, + inspectTaskState, loadCmp, + recoverInterruptedTranslation, saveCmpTargets, scanTask, translateTask, @@ -52,10 +57,15 @@ export function App() { const progressDisplayed = useRef({ done: 0, total: 0 }); const progressTimer = useRef(null); const retryingRateLimited = useRef(false); + const activeTaskId = useRef(null); + const translationStarting = useRef(false); + const scanGeneration = useRef(0); + const cmpLoadGeneration = useRef(0); const [logs, setLogs] = useState([]); const [report, setReport] = useState(null); const [runs, setRuns] = useState([]); const [toast, setToast] = useState(""); + const toastTimer = useRef(null); const [confirm, setConfirm] = useState(false); const [cmpDraft, setCmpDraft] = useState(null); const [cmpEntries, setCmpEntries] = useState([]); @@ -88,6 +98,7 @@ export function App() { window.removeEventListener("error", onError); window.removeEventListener("unhandledrejection", onRejection); stopProgressAnimation(); + if (toastTimer.current !== null) window.clearTimeout(toastTimer.current); }; }, []); @@ -101,9 +112,40 @@ export function App() { }); }) .catch((error) => notify(errorText(error))); + call("logs-info").catch((error) => + notify(`诊断日志不可用:${errorText(error)}`), + ); }, []); useTauriEvents((event) => { + if (event.task_id && activeTaskId.current && event.task_id !== activeTaskId.current) { + void frontendLog("debug", "stale_translation_event_ignored", "已忽略其他任务的翻译事件", { + active_task_id: activeTaskId.current, + event_task_id: event.task_id, + event_type: event.type, + }); + return; + } + if ( + event.task_id && + !activeTaskId.current && + !translationStarting.current && + stage !== "running" + ) { + void frontendLog("debug", "orphan_translation_event_ignored", "已忽略当前页面未启动的翻译事件", { + event_task_id: event.task_id, + event_type: event.type, + stage, + }); + return; + } + if ( + event.task_id && + !activeTaskId.current && + (translationStarting.current || stage === "running") + ) { + activeTaskId.current = event.task_id; + } if (event.type === "log" && event.message) { setLogs((value) => [...value.slice(-299), note(event.message!)]); } @@ -140,6 +182,7 @@ export function App() { total_entries: event.total_entries || 0, warning_count: event.warning_count || 0, failed_count: event.failed_count || 0, + can_apply: false, }; retryingRateLimited.current = false; stopProgressAnimation(); @@ -148,11 +191,14 @@ export function App() { setStage("review"); setCmpDraft(draft); setCmpValidation(null); - void loadCmpEntries(draft); - setReviewPrompt(true); + void loadCmpEntries(draft).then((loaded) => { + if (loaded) setReviewPrompt(true); + }); setLogs((value) => [...value, note("API 翻译完成,已打开可编辑校对表格,尚未覆盖任务书。")]); void frontendLog("info", "cmp_review_ready", "CMP 校对文件已生成", draft); notify("翻译完成,请确认是否直接覆盖"); + activeTaskId.current = null; + translationStarting.current = false; } if (event.type === "done" && event.report) { retryingRateLimited.current = false; @@ -171,6 +217,8 @@ export function App() { }); notify("任务书汉化完成"); loadHistory(); + activeTaskId.current = null; + translationStarting.current = false; } if (event.type === "error") { const retrying = retryingRateLimited.current; @@ -183,12 +231,18 @@ export function App() { error: event.message || "翻译失败", }); notify(event.message || "翻译失败"); + activeTaskId.current = null; + translationStarting.current = false; } }); const notify = (text: string) => { + if (toastTimer.current !== null) window.clearTimeout(toastTimer.current); setToast(text); - window.setTimeout(() => setToast(""), 3200); + toastTimer.current = window.setTimeout(() => { + setToast(""); + toastTimer.current = null; + }, 3200); }; const loadHistory = () => call("history-list").then(setRuns).catch((error) => notify(errorText(error))); @@ -261,6 +315,8 @@ export function App() { void frontendLog("warn", "scan_rejected", "扫描未开始:目录为空"); return notify("请先选择整合包目录"); } + const generation = ++scanGeneration.current; + cmpLoadGeneration.current += 1; resetProgress(); setBusy(true); setReport(null); @@ -270,6 +326,7 @@ export function App() { void frontendLog("info", "scan_started", "用户开始扫描任务书", { path }); try { const result = await scanTask({ path, batch_size: settings.batch_size }); + if (generation !== scanGeneration.current) return; setScan(result); setSelectedPath(result.quests_dir); setStage("scanned"); @@ -280,15 +337,20 @@ export function App() { files: result.file_count, }); } catch (error) { + if (generation !== scanGeneration.current) return; setStage("error"); notify(errorText(error)); } finally { - setBusy(false); + if (generation === scanGeneration.current) setBusy(false); } } async function beginTranslation() { setConfirm(false); + await runTranslation(false); + } + + async function runTranslation(recoveryAttempt: boolean) { if (!scan) { void frontendLog("warn", "translation_rejected", "翻译未开始:没有扫描结果"); return; @@ -303,23 +365,81 @@ export function App() { provider: settings.provider, }); try { - await translateTask(scan.quests_dir, settings); + translationStarting.current = true; + const accepted = await translateTask(scan.quests_dir, settings); + if (translationStarting.current) activeTaskId.current = accepted.task_id; + translationStarting.current = false; } catch (error) { void frontendLog("error", "translation_start_failed", "启动翻译命令失败", { error: errorText(error), }); setBusy(false); setStage("scanned"); + activeTaskId.current = null; + translationStarting.current = false; + if (!recoveryAttempt && errorCode(error) === "task_state_conflict") { + try { + const inspection = await inspectTaskState(scan.quests_dir); + const decision = classifyTaskRecovery(inspection.activities); + void frontendLog("warn", "task_conflict_diagnosed", "已诊断任务状态冲突", { + decision: decision.kind, + task_ids: decision.activities.map((activity) => activity.task_id), + }); + const detail = (activities: ActiveTask[]) => + activities + .map( + (activity) => + `• ${activity.task_id} · ${new Date(activity.updated_at).toLocaleString()}`, + ) + .join("\n"); + if (decision.kind === "writeback_blocked") { + window.alert( + `检测到未完成的任务书写回:\n${detail(decision.activities)}\n\n为避免二次覆盖,应用不会自动解锁。请先检查任务书、备份目录和诊断日志,再决定是否人工恢复。`, + ); + return; + } + if (decision.kind === "translation_active") { + window.alert( + `检测到可能仍在运行的翻译任务:\n${detail(decision.activities)}\n\n这些记录不早于本次应用启动时间,当前不能按中断任务恢复。请确认是否有其他实例仍在工作。`, + ); + return; + } + if (decision.kind === "unknown") { + notify(errorText(error)); + return; + } + const confirmed = window.confirm( + `检测到 ${decision.activities.length} 个上次进程中断的翻译任务:\n${detail(decision.activities)}\n\n确认当前没有其他 FTB Translator 实例仍在翻译后,是否将它们标记为中断并重新开始?`, + ); + if (!confirmed) return; + await recoverInterruptedTranslation(scan.quests_dir); + notify("已恢复中断状态,正在重新开始翻译"); + await runTranslation(true); + return; + } catch (recoveryError) { + void frontendLog("error", "task_recovery_failed", "中断任务诊断或恢复失败", { + error: errorText(recoveryError), + }); + notify(errorText(recoveryError)); + return; + } + } notify(errorText(error)); } } async function retryRateLimited() { if (!scan || !cmpDraft) return; - const count = cmpEntries.filter((entry) => entry.status === "rate_limited").length; + const count = cmpEntries.filter( + (entry) => entry.status === "rate_limited" && entry.target === entry.source, + ).length; if (!count) return notify("当前没有可重试的限流条目"); try { - await saveCmpTargets(cmpDraft.cmp_path, cmpEntries); + if (!cmpDraft.cmp_revision) return notify("CMP 尚未加载完成,请稍后重试"); + const saved = await saveCmpTargets(cmpDraft.cmp_path, cmpDraft.cmp_revision, cmpEntries); + setCmpDraft((current) => + current ? { ...current, cmp_revision: saved.cmp_revision } : current, + ); retryingRateLimited.current = true; resetProgress(count); setBusy(true); @@ -330,11 +450,17 @@ export function App() { cmp_path: cmpDraft.cmp_path, count, }); - await translateTask(scan.quests_dir, settings, cmpDraft.cmp_path); + activeTaskId.current = cmpDraft.task_id || null; + translationStarting.current = true; + const accepted = await translateTask(scan.quests_dir, settings, cmpDraft.cmp_path); + if (translationStarting.current) activeTaskId.current = accepted.task_id; + translationStarting.current = false; } catch (error) { retryingRateLimited.current = false; setBusy(false); setStage("review"); + activeTaskId.current = null; + translationStarting.current = false; void frontendLog("error", "rate_limited_retry_failed", "限流条目重试启动失败", { task_id: cmpDraft.task_id || "", error: errorText(error), @@ -344,8 +470,10 @@ export function App() { } async function loadCmpEntries(draft: CmpDraft) { + const generation = ++cmpLoadGeneration.current; try { const result = await loadCmp(draft.cmp_path); + if (generation !== cmpLoadGeneration.current) return false; setCmpEntries(result.entries); setCmpDraft((current) => current && current.cmp_path === draft.cmp_path @@ -354,6 +482,7 @@ export function App() { task_id: result.task_id, task_state: result.task_state, can_apply: result.can_apply, + cmp_revision: result.cmp_revision, } : current, ); @@ -361,13 +490,19 @@ export function App() { setLogs((value) => [...value, note("该 CMP 已经应用,后端将阻止重复写回。")]); notify("该 CMP 已经应用,不能再次写回"); } + return true; } catch (error) { + if (generation !== cmpLoadGeneration.current) return false; notify(errorText(error)); + return false; } } async function applyCmp() { if (!scan || !cmpDraft) return; + if (!cmpDraft.cmp_revision || !cmpEntries.length) { + return notify("CMP 尚未加载完成,请稍后重试"); + } if (cmpDraft.can_apply === false) { return notify( cmpDraft.task_state === "applied" ? "该 CMP 已经应用,不能再次写回" : "当前任务状态不允许应用 CMP", @@ -383,12 +518,18 @@ export function App() { entries: cmpEntries.length, }); try { - if (cmpEntries.length) { - await saveCmpTargets(cmpDraft.cmp_path, cmpEntries); - } + const saved = await saveCmpTargets( + cmpDraft.cmp_path, + cmpDraft.cmp_revision, + cmpEntries, + ); + setCmpDraft((current) => + current ? { ...current, cmp_revision: saved.cmp_revision } : current, + ); const request = { cmp_path: cmpDraft.cmp_path, quests_dir: scan.quests_dir, + cmp_revision: saved.cmp_revision, }; const validation = await validateCmp(request); if (validation.blocking) { @@ -404,13 +545,21 @@ export function App() { setProgress(100); setStage("done"); setReport(result.report); - setLogs((value) => [...value, note("校对表格已通过校验,翻译结果已写入任务书。")]); + setLogs((value) => [ + ...value, + note("校对表格已通过校验,翻译结果已写入任务书。"), + ...result.post_commit_warnings.map(note), + ]); void frontendLog("info", "cmp_applied", "CMP 已应用", { task_id: result.task_id, run_id: result.run_id, cmp_path: cmpDraft.cmp_path, }); - notify("任务书汉化完成"); + notify( + result.post_commit_warnings.length + ? "任务书已写入,但有附属数据保存告警" + : "任务书汉化完成", + ); loadHistory(); } catch (error) { void frontendLog("warn", "cmp_apply_failed", "CMP 校验或应用失败", { @@ -426,6 +575,7 @@ export function App() { async function validateCurrentCmp() { if (!scan || !cmpDraft) return; + if (!cmpDraft.cmp_revision) return notify("CMP 尚未加载完成,请稍后重试"); setCmpValidation(null); setValidatingCmp(true); void frontendLog("info", "cmp_dry_run_started", "用户开始仅验证 CMP", { @@ -435,7 +585,11 @@ export function App() { }); try { const validation = await validateCmp( - { cmp_path: cmpDraft.cmp_path, quests_dir: scan.quests_dir }, + { + cmp_path: cmpDraft.cmp_path, + quests_dir: scan.quests_dir, + cmp_revision: cmpDraft.cmp_revision, + }, cmpEntries, ); setCmpValidation(validation); @@ -515,6 +669,7 @@ export function App() { task_id: undefined, task_state: undefined, can_apply: undefined, + cmp_revision: undefined, } : { cmp_path: value, @@ -523,10 +678,11 @@ export function App() { failed_count: 0, }; setCmpDraft(draft); + setCmpEntries([]); setCmpValidation(null); setStage("review"); setReviewPrompt(false); - await loadCmpEntries(draft); + if (!(await loadCmpEntries(draft))) return; void frontendLog("info", "cmp_selected", "用户选择了 CMP 校对文件", { cmp_path: value }); notify("已打开 CMP 校对表格"); } diff --git a/src/components/CmpTable.tsx b/src/components/CmpTable.tsx index 3f6b106..991deaa 100644 --- a/src/components/CmpTable.tsx +++ b/src/components/CmpTable.tsx @@ -1,7 +1,13 @@ import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from "react"; import { CircleAlert, Download, FileCheck2, FileText, ShieldCheck, Upload } from "lucide-react"; +import { + analyzeCmpEntries, + type CmpQaFlag, +} from "../lib/cmpQa"; import type { CmpDraft, CmpEntry, CmpValidationReport } from "../models/cmp"; +type QualityFilter = "all" | "flagged" | CmpQaFlag; + export function CmpTable({ draft, entries, @@ -27,22 +33,34 @@ export function CmpTable({ }) { const [query, setQuery] = useState(""); const [status, setStatus] = useState("all"); + const [quality, setQuality] = useState("all"); const [page, setPage] = useState(0); const pageSize = 60; + const qa = useMemo(() => analyzeCmpEntries(entries), [entries]); const filtered = useMemo( () => - entries.filter( - (entry) => + entries.filter((entry) => { + const flags = qa.flags_by_index.get(entry.index); + const qualityMatches = + quality === "all" || + (quality === "flagged" ? Boolean(flags?.size) : Boolean(flags?.has(quality))); + return ( (status === "all" || entry.status === status) && + qualityMatches && `${entry.file} ${entry.entry_id} ${entry.source} ${entry.target}` .toLowerCase() - .includes(query.toLowerCase()), - ), - [entries, query, status], + .includes(query.toLowerCase()) + ); + }), + [entries, qa, query, quality, status], ); const rows = filtered.slice(page * pageSize, page * pageSize + pageSize); const pages = Math.max(1, Math.ceil(filtered.length / pageSize)); - useEffect(() => setPage(0), [query, status]); + useEffect(() => setPage(0), [query, quality, status]); + useEffect(() => setPage(0), [draft.cmp_path]); + useEffect(() => { + if (page >= pages) setPage(pages - 1); + }, [page, pages]); function update(index: number, target: string) { setEntries((items) => items.map((item) => (item.index === index ? { ...item, target } : item))); @@ -62,6 +80,23 @@ export function CmpTable({ : "原文保留"; } + function qaLabel(flag: CmpQaFlag) { + return flag === "review_status" + ? "状态需确认" + : flag === "unchanged" + ? "与原文相同" + : flag === "likely_untranslated" + ? "疑似未汉化" + : "同源多译"; + } + + const qaMarks: { flag: CmpQaFlag; label: string; hint: string }[] = [ + { flag: "review_status", label: "状态需确认", hint: "接口失败、格式保护或人工审校状态" }, + { flag: "inconsistent", label: "同源多译", hint: "相同英文出现了不同中文" }, + { flag: "unchanged", label: "保持英文", hint: "可能是专名,也可能尚未翻译" }, + { flag: "likely_untranslated", label: "疑似未汉化", hint: "译文仍为拉丁文字且没有中文" }, + ]; + return (
@@ -75,6 +110,45 @@ export function CmpTable({ 条译文
+
+
+ +
+ 审校线索 + 启发式提示,不影响后端格式校验与写回权限 +
+
+
+ {qaMarks.map((mark) => ( + + ))} +
+ {qa.inconsistent_groups.length > 0 && ( +
+ {qa.inconsistent_groups.slice(0, 3).map((group) => ( + + ))} +
+ )} +
原文保留 + {filtered.length} 条匹配 -
diff --git a/src/lib/cmpQa.ts b/src/lib/cmpQa.ts new file mode 100644 index 0000000..cf22991 --- /dev/null +++ b/src/lib/cmpQa.ts @@ -0,0 +1,106 @@ +import type { CmpEntry } from "../models/cmp"; + +export type CmpQaFlag = + | "review_status" + | "unchanged" + | "likely_untranslated" + | "inconsistent"; + +export type CmpQaCounts = Record & { + flagged_entries: number; +}; + +export type CmpQaInconsistentGroup = { + source: string; + targets: string[]; + entry_indexes: number[]; +}; + +export type CmpQaReport = { + flags_by_index: Map>; + counts: CmpQaCounts; + inconsistent_groups: CmpQaInconsistentGroup[]; +}; + +const REVIEW_STATUSES = new Set([ + "rate_limited", + "request_failed", + "format_guard", + "review", + "unchanged", + "fallback", +]); +const LATIN_TEXT = /[A-Za-z]/; +const HAN_TEXT = /[\u3400-\u4dbf\u4e00-\u9fff]/u; + +function normalized(value: string) { + return value.trim().replace(/\s+/g, " ").toLocaleLowerCase(); +} +function addFlag( + flagsByIndex: Map>, + index: number, + flag: CmpQaFlag, +) { + const flags = flagsByIndex.get(index) ?? new Set(); + flags.add(flag); + flagsByIndex.set(index, flags); +} + +/** + * Produces review hints only. These heuristics never decide whether a CMP may + * be written; the Rust format guard remains the authoritative safety check. + */ +export function analyzeCmpEntries(entries: CmpEntry[]): CmpQaReport { + const flagsByIndex = new Map>(); + const bySource = new Map(); + + for (const entry of entries) { + const source = normalized(entry.source); + const target = normalized(entry.target); + if (REVIEW_STATUSES.has(entry.status)) { + addFlag(flagsByIndex, entry.index, "review_status"); + } + if (source === target) { + addFlag(flagsByIndex, entry.index, "unchanged"); + } else if (LATIN_TEXT.test(entry.source) && !HAN_TEXT.test(entry.target)) { + addFlag(flagsByIndex, entry.index, "likely_untranslated"); + } + if (source) { + const group = bySource.get(source) ?? []; + group.push(entry); + bySource.set(source, group); + } + } + + const inconsistentGroups: CmpQaInconsistentGroup[] = []; + for (const group of bySource.values()) { + if (group.length < 2) continue; + const targets = new Map(); + for (const entry of group) { + const key = normalized(entry.target); + if (key) targets.set(key, entry.target.trim()); + } + if (targets.size < 2) continue; + const entryIndexes = group.map((entry) => entry.index); + for (const index of entryIndexes) addFlag(flagsByIndex, index, "inconsistent"); + inconsistentGroups.push({ + source: group[0].source, + targets: [...targets.values()], + entry_indexes: entryIndexes, + }); + } + + const count = (flag: CmpQaFlag) => + [...flagsByIndex.values()].filter((flags) => flags.has(flag)).length; + return { + flags_by_index: flagsByIndex, + counts: { + review_status: count("review_status"), + unchanged: count("unchanged"), + likely_untranslated: count("likely_untranslated"), + inconsistent: count("inconsistent"), + flagged_entries: [...flagsByIndex.values()].filter((flags) => flags.size > 0).length, + }, + inconsistent_groups: inconsistentGroups, + }; +} diff --git a/src/lib/taskRecovery.ts b/src/lib/taskRecovery.ts new file mode 100644 index 0000000..2c9e84b --- /dev/null +++ b/src/lib/taskRecovery.ts @@ -0,0 +1,21 @@ +import type { ActiveTask } from "../models/task"; + +export type TaskRecoveryDecision = + | { kind: "recover_translation"; activities: ActiveTask[] } + | { kind: "writeback_blocked"; activities: ActiveTask[] } + | { kind: "translation_active"; activities: ActiveTask[] } + | { kind: "unknown"; activities: [] }; + +export function classifyTaskRecovery(activities: ActiveTask[]): TaskRecoveryDecision { + const writeback = activities.filter((activity) => activity.state === "applying"); + if (writeback.length) return { kind: "writeback_blocked", activities: writeback }; + + const recoverable = activities.filter( + (activity) => activity.state === "translating" && activity.recoverable, + ); + if (recoverable.length) return { kind: "recover_translation", activities: recoverable }; + + const translating = activities.filter((activity) => activity.state === "translating"); + if (translating.length) return { kind: "translation_active", activities: translating }; + return { kind: "unknown", activities: [] }; +} diff --git a/src/models/cmp.ts b/src/models/cmp.ts index f76b4db..7e058a9 100644 --- a/src/models/cmp.ts +++ b/src/models/cmp.ts @@ -12,6 +12,7 @@ export type CmpDraft = { total_entries: number; warning_count: number; failed_count: number; + cmp_revision?: string; task_state?: TaskState; can_apply?: boolean; }; diff --git a/src/models/task.ts b/src/models/task.ts new file mode 100644 index 0000000..0ba5a46 --- /dev/null +++ b/src/models/task.ts @@ -0,0 +1,8 @@ +import type { TaskState } from "./cmp"; + +export type ActiveTask = { + task_id: string; + state: TaskState; + updated_at: string; + recoverable: boolean; +}; diff --git a/src/pages/WorkbenchPage.tsx b/src/pages/WorkbenchPage.tsx index 25a2b6a..4c09658 100644 --- a/src/pages/WorkbenchPage.tsx +++ b/src/pages/WorkbenchPage.tsx @@ -65,7 +65,9 @@ export function WorkbenchPage(props: WorkbenchPageProps) { done: 3, error: props.scan ? 1 : 0, }[props.stage]; - const rateLimitedCount = props.cmpEntries.filter((entry) => entry.status === "rate_limited").length; + const rateLimitedCount = props.cmpEntries.filter( + (entry) => entry.status === "rate_limited" && entry.target === entry.source, + ).length; return (
@@ -121,18 +123,19 @@ export function WorkbenchPage(props: WorkbenchPageProps) {
props.setPath(event.target.value)} placeholder="选择一个整合包目录…" onKeyDown={(event) => event.key === "Enter" && props.onScan()} /> -
{props.scan ? ( ) : ( -
+
!props.busy && props.onChoose()}>
从扫描开始 @@ -157,10 +160,10 @@ export function WorkbenchPage(props: WorkbenchPageProps) { <>

生成校对文件

API 翻译完成后先生成 CMP,确认之前不会修改任务书。

- -