diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index 908bcfd..ffe9f05 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -30,7 +30,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口: 13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `/.cas`、SQLite 索引为 `/resources.sqlite`,也可通过 `--import-cas-root`、`--import-resource-db`、`BAT_IMPORT_CAS_ROOT`、`BAT_IMPORT_RESOURCE_DB` 覆盖;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、bundle path、archive entry、parse status 和 TextUnit format 分页查询现有索引,数据库不存在时返回 `available=false` 且不会创建空库。`Resource` metadata 已通过 `metadata_json` 兼容迁移保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式;当前/上一个/结构变化 catalog、失败 staging 复用、403/404、hash mismatch、CRC、metadata 迁移与 UnityFS 边界校验均有离线回归 fixture 或单测覆盖。 14. 非 dry-run 官方同步在校验完成并发布后,会先对比上一完整 release 与当前 release 的 `official-download-manifest.json`,在当前 release 下写入 `official-resource-changes.json` 和 `crowdin-translation-handoff.json`;同一 destination 只有 size 或 BLAKE3 改变才算 modified,仅 URL/CDN 根变化但内容相同不会触发解析/翻译候选。随后刷新 `official-parse-cache.json` 和 `official-textunit-index.json`,并从 Added/Modified 资源、parse cache 与 TextUnit 明细索引派生 `official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`;删除资源只进入差异记录,不进入 TextUnit/Crowdin 队列。`parse.text_units` 和 `parse.errors` RPC/CLI 可按 destination、archive entry、path id、class id、field path 和 format 查询当前 release 的 TextUnit 明细与解析错误;`translation.tasks` RPC/CLI 可按 release、destination、archive entry、任务状态、parse status、TextUnit format 和 reason presence 查询离线 TextUnit 翻译任务状态与跳过/失败原因;TextUnit 已包含 class id、field path、字段 offset/byte size 等可追溯定位。Crowdin 当前仅预留本地离线队列,不发网络请求;up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,含义是原版资源已经发布、汉化资源未发布;UnityFS TextAsset patch 发布成功后会写 `localized-patch-manifest.json`,校验 hash/size/diff/rollback 后才允许 `localized.status` 返回 `status=published`、`status_code=localized.published` 和 `localized_release_status=localized`。`bat` 首次启动会在二进制所在目录释放 `.env` 配置模板(`0600`),之后每次启动自动加载(不覆盖已存在的环境变量),支持 `BAT_OUTPUT`/`BAT_LOCALIZED_OUTPUT`/`BAT_IMPORT_REPOSITORY`/`BAT_IMPORT_CAS_ROOT`/`BAT_IMPORT_RESOURCE_DB`/`BAT_STATE_DIR`/`BAT_AUTO_DISCOVER`/`BAT_WATCH`/`BAT_DAEMON`/`BAT_PROXY` 等键,实现编辑 `.env` 后无参启动;优先级为命令行参数 > 进程环境变量 > `.env` > 内置默认值,`BAT_SKIP_ENV_FILE=1` 可整体禁用;Redis 键为预留。daemon 任务历史持久化在 `/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted`(`BAT-ERR-700005`)。 -15. issue 43 已补齐 Rust `bat` 的 `res` / `parse` / `i18n` 工作流入口:支持资源拉取、解析刷新、可再生解析缓存清理、离线翻译工作台、人工文本修改、工作台发布前校验、有限 TextAsset 汉化发布、既有 patch 能力的批量重打包、单次/限定次数/周期执行和版本化 schedule CRUD。schedule 查询现在按一级工作流过滤,删除/执行会校验作用域,单轮执行可限制计划数;schedule CRUD 和翻译任务状态回写已通过 `bat.sock` 的 RPC 以及 `bat-api` 的鉴权管理接口暴露,dashboard 不维护第二套状态。该例外只编排已有解析和 patch 能力,不扩大解析器覆盖;完整 AssetBundle 重打包、真实 provider worker 和 Web 前端仍是后续工作。G-008(产品级 Go 同步 CLI)已决策关闭。真实官方网络全量拉取 smoke 已固化(G-018 已关闭);真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md`。 +15. issue 43 已补齐 Rust `bat` 的 `res` / `parse` / `i18n` 工作流入口:支持资源拉取、解析刷新、可再生解析缓存清理、离线翻译工作台、人工文本查看/修改/清空、工作台发布前校验、有限 TextAsset 汉化发布、既有 patch 能力的批量重打包、单次/限定次数/周期执行和版本化 schedule CRUD。schedule 查询现在按一级工作流过滤,删除/执行会校验作用域,单轮执行可限制计划数;schedule CRUD 和翻译任务状态回写已通过 `bat.sock` 的 RPC 以及 `bat-api` 的鉴权管理接口暴露,dashboard 不维护第二套状态。该例外只编排已有解析和 patch 能力,不扩大解析器覆盖;完整 AssetBundle 重打包、真实 provider worker 和 Web 前端仍是后续工作。G-008(产品级 Go 同步 CLI)已决策关闭。真实官方网络全量拉取 smoke 已固化(G-018 已关闭);真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md`。 当前翻译交接还包括 `translation-tasks.sqlite` 和版本化 `translation-handoff.json`; `translation.tasks` 查询单项 worker 状态,`translation.handoff` 查询完整 diff --git a/PROJECT_PLAN.md b/PROJECT_PLAN.md index fd835a6..f8b58a3 100644 --- a/PROJECT_PLAN.md +++ b/PROJECT_PLAN.md @@ -36,7 +36,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付 8. `bat-ffi` 已提供 Manifest inspect 和官方 sync plan 的可选无状态粗粒度 JSON C ABI helper。 9. 官方原版资源默认发布到 `./bat-resources`,汉化产物默认发布到独立的 `./bat-localized`;当前官方同步报告会标记 `localized_release_status=not_localized`,表示原版资源已发布、汉化资源未发布。 10. 官方同步校验完成并发布新 release 后会生成 `official-resource-changes.json`、`crowdin-translation-handoff.json`、`official-parse-cache.json`、`official-textunit-index.json`、`official-textunit-tasks.json` 和 `crowdin-textunit-queue.json`,用 Added/Modified 资源驱动后续解析/翻译增量;up-to-date 轮询在已有有效缓存、TextUnit 明细索引和队列时只读取摘要,不重复解析。 -11. issue 43 已补齐 Rust `bat` 的 `res` / `parse` / `i18n` 工作流:单次/限定次数/周期执行、版本化 schedule CRUD 与作用域过滤、解析缓存清理、翻译工作台校验、离线翻译工作台、人工文本修改、翻译任务状态回写、既有 patch 能力的批量重打包和独立汉化 release 发布;schedule CRUD 与 `translation.task.update` 已经通过 `bat.sock` 和 `bat-api` 管理接口暴露,dashboard 不维护第二套状态;该能力不扩大冻结期解析器覆盖。 +11. issue 43 已补齐 Rust `bat` 的 `res` / `parse` / `i18n` 工作流:单次/限定次数/周期执行、版本化 schedule CRUD 与作用域过滤、解析缓存清理、翻译工作台校验、离线翻译工作台、人工文本查看/修改/清空、翻译任务状态回写、既有 patch 能力的批量重打包和独立汉化 release 发布;schedule CRUD 与 `translation.task.update` 已经通过 `bat.sock` 和 `bat-api` 管理接口暴露,dashboard 不维护第二套状态;该能力不扩大冻结期解析器覆盖。 12. 文档已整理:根目录保留入口文档,历史报告进入 `docs/reports/historical/`,误嵌套的 `docs/docs` 已合并。 ### 仍是骨架或占位 diff --git a/USERGUIDE.md b/USERGUIDE.md index bf67b2f..bdae254 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -50,7 +50,7 @@ HTTPS_PROXY=http://user:pass@127.0.0.1:7890 bat --auto-discover --daemon | `parse clear-cache` | 使用 `--force` 清理当前 release 的可再生解析缓存和翻译队列 | | `parse repack` | 根据 JSON spec 批量重打包 UnityFS bundle | | `i18n run` / `i18n export` | 刷新离线翻译队列或导出可编辑翻译工作台 | -| `i18n set` | 手动修改一个翻译工作台条目 | +| `i18n set` / `i18n get` / `i18n unset` | 手动查看、修改或清空一个翻译工作台条目;也可通过 `i18n workbench ...` 访问 | | `i18n validate` | 发布前校验工作台 release、source text 和 patch 目标 | | `i18n publish` | 校验工作台并发布独立汉化 release;`--force` 使用新的手动 release ID | | `i18n schedule` | 管理翻译和汉化发布计划 | diff --git a/docs/guides/bat-workflows.md b/docs/guides/bat-workflows.md index de00922..ba682b4 100644 --- a/docs/guides/bat-workflows.md +++ b/docs/guides/bat-workflows.md @@ -89,7 +89,26 @@ bat i18n set \ --translated-text '中文文本' ``` -也可以使用 `--translated-file` 读取 UTF-8 文本。工作台会保存 source text、release ID、TextUnit 目标和人工译文;发布前会重新读取当前 TextUnit 索引,拒绝过期 release、source text 或 patch 目标。 +也可以使用 `--translated-file` 读取 UTF-8 文本。需要复核单条内容时: + +```bash +bat i18n get \ + --translation-file /tmp/bat-workbench.json \ + --translation-id \ + --json +``` + +需要把某条译文恢复为未审核状态时: + +```bash +bat i18n unset \ + --translation-file /tmp/bat-workbench.json \ + --translation-id +``` + +这些工作台操作也可以写成 `bat i18n workbench set|get|unset|validate ...`。 +工作台会保存 source text、release ID、TextUnit 目标和人工译文;发布前会重新读取当前 +TextUnit 索引,拒绝过期 release、source text 或 patch 目标。 发布前可只做工作台审计: diff --git a/docs/reports/CURRENT_GAPS.md b/docs/reports/CURRENT_GAPS.md index 1984f93..50d2ffa 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -294,7 +294,7 @@ issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、Typ - `translation-tasks.sqlite` 由 `schema_migrations` 管理 durable task state、attempt count、provider run ID 和 failure reason;重复同步会保留已有 worker 状态。 - `translation.tasks` RPC/CLI 优先查询 `translation-tasks.sqlite`,旧 release 没有状态库时回退到 `official-textunit-tasks.json`;返回队列 `status`、worker `task_status`、failure reason 和时间/尝试次数。 - `translation.task.update` 已提供 queued/running/failed/completed/skipped 状态回写契约,并已通过 `bat i18n task update`、Go typed helper 和 bat-api 鉴权控制面暴露;provider worker 可在消费 handoff 后按 task_id 更新并由同一查询接口反查。 -- `i18n validate` 可在发布前校验工作台 release、source text、重复 patch 目标,并区分可直接发布的 TextAsset 与必须进入 `parse repack` 的条目;`parse clear-cache` 只删除可再生解析/队列 JSON,保留 `translation-tasks.sqlite` 的 worker 状态。 +- `i18n get/set/unset` 可查看、修改或清空单条工作台译文,`i18n validate` 可在发布前校验工作台 release、source text、重复 patch 目标,并区分可直接发布的 TextAsset 与必须进入 `parse repack` 的条目;`parse clear-cache` 只删除可再生解析/队列 JSON,保留 `translation-tasks.sqlite` 的 worker 状态。 - 真实 Crowdin 网络 worker、翻译记忆和完整 localized repack 仍属于后续翻译系统工作,不在当前 Rust 离线状态仓储范围内。 验收: diff --git a/infrastructure/src/bin/bat/app.rs b/infrastructure/src/bin/bat/app.rs index 210fa82..3732fd3 100644 --- a/infrastructure/src/bin/bat/app.rs +++ b/infrastructure/src/bin/bat/app.rs @@ -8,18 +8,19 @@ use bat_infrastructure::DEFAULT_DOWNLOAD_CONCURRENCY; use bat_infrastructure::{ apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file, apply_unityfs_text_asset_patch_file, changed_endpoint_urls, diff_extended_snapshot, - export_translation_workbench, gc_orphan_staging, lexical_absolute, + export_translation_workbench, gc_orphan_staging, get_translation_entry, lexical_absolute, localized_text_asset_patches, open_append_file, read_download_manifest_at, read_file_no_symlink, read_localized_patch_manifest_at, read_localized_version_state, read_parse_cache_at, read_snapshot, read_textunit_index_at, read_translation_workbench, read_version_state, redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation, - validate_output_root, validate_runtime_state_dir, validate_translation_workbench, - write_file_atomic, write_official_textunit_queues, CurlProxyConfig, CurlProxyMode, - LocalizedPatchConfig, LocalizedPatchReport, LocalizedPatchService, OfficialEndpointMarkerRole, - OfficialFailedVersionRecord, OfficialParseCacheService, OfficialParseConfig, - OfficialResourceHashVerification, OfficialResourceVerification, OfficialServerInfoSource, - OfficialTextUnitQuery, OfficialTextUnitTaskQuery, OfficialUpdateConfig, OfficialUpdateProgress, - OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot, OfficialUpdateStatus, + unset_translation, validate_output_root, validate_runtime_state_dir, + validate_translation_workbench, write_file_atomic, write_official_textunit_queues, + CurlProxyConfig, CurlProxyMode, LocalizedPatchConfig, LocalizedPatchReport, + LocalizedPatchService, OfficialEndpointMarkerRole, OfficialFailedVersionRecord, + OfficialParseCacheService, OfficialParseConfig, OfficialResourceHashVerification, + OfficialResourceVerification, OfficialServerInfoSource, OfficialTextUnitQuery, + OfficialTextUnitTaskQuery, OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, + OfficialUpdateService, OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, PatchApplyKind, PatchApplyParams, PatchApplyReport, ReleaseFlowStatusCode, RepackReport, SqliteResourceRepository, SqliteTranslationTaskRepository, TranslationTaskStatus, @@ -80,7 +81,8 @@ use translation_query::{ }; use workflow_commands::{ run_parse_clear_cache, run_parse_once, run_publish_localized, run_repack, run_translate_once, - run_translation_set, run_translation_task_update, run_translation_validate, + run_translation_get, run_translation_set, run_translation_task_update, run_translation_unset, + run_translation_validate, }; const EXIT_ERROR: i32 = 1; @@ -209,6 +211,14 @@ fn run() -> anyhow::Result { run_translation_set(&options)?; Ok(0) } + CliCommand::TranslationGet => { + run_translation_get(&options)?; + Ok(0) + } + CliCommand::TranslationUnset => { + run_translation_unset(&options)?; + Ok(0) + } CliCommand::TranslationTaskUpdate => { run_translation_task_update(&options)?; Ok(0) @@ -527,6 +537,8 @@ enum CliCommand { Translate, TranslationValidate, TranslationSet, + TranslationGet, + TranslationUnset, TranslationTaskUpdate, Repack, PublishLocalized, @@ -7207,12 +7219,58 @@ fn parse_args_with_env( options.progress = false; options.banner = false; } - CliCommand::TranslationSet | CliCommand::Repack => { + CliCommand::TranslationSet | CliCommand::TranslationGet | CliCommand::TranslationUnset => { if options.watch || options.daemon || options.daemon_child { - return Err(anyhow::anyhow!("translation set/repack 只支持单次执行")); + return Err(anyhow::anyhow!( + "translation workbench 编辑命令只支持单次执行" + )); } if options.config.force || options.sync_option_explicit || options.run_count.is_some() { - return Err(anyhow::anyhow!("translation set/repack 不接受资源同步选项")); + return Err(anyhow::anyhow!( + "translation workbench 编辑命令不接受资源同步选项" + )); + } + if options.translation_file.is_none() || options.translation_id.is_none() { + return Err(anyhow::anyhow!( + "translation workbench 编辑命令必须指定 --translation-file 和 --translation-id" + )); + } + if matches!(options.command, CliCommand::TranslationSet) { + if options.translation_text.is_some() == options.translation_text_file.is_some() { + return Err(anyhow::anyhow!( + "i18n set 必须且只能指定 --translated-text 或 --translated-file" + )); + } + } else if options.translation_text.is_some() || options.translation_text_file.is_some() + { + return Err(anyhow::anyhow!( + "i18n get/unset 不接受 --translated-text 或 --translated-file" + )); + } + if options.output_explicit + || options.resource_root.is_some() + || options.localized_release_id.is_some() + || options.repack_spec.is_some() + || options.query_option_explicit + || options.schedule_option_explicit + || options.translation_failure_reason.is_some() + || options.translation_provider_run_id.is_some() + || options.proxy_option_explicit + || tools_are_non_default(&options.config, &options.env_baseline_config) + { + return Err(anyhow::anyhow!( + "translation workbench 编辑命令只接受 --translation-file、--translation-id、译文字段、--state-dir 和 --json/--human" + )); + } + options.progress = false; + options.banner = false; + } + CliCommand::Repack => { + if options.watch || options.daemon || options.daemon_child { + return Err(anyhow::anyhow!("repack 只支持单次执行")); + } + if options.config.force || options.sync_option_explicit || options.run_count.is_some() { + return Err(anyhow::anyhow!("repack 不接受资源同步选项")); } options.progress = false; options.banner = false; @@ -7461,10 +7519,15 @@ fn parse_translation_command( if action == "task" { return parse_translation_task_command(args, options); } + if action == "workbench" || action == "wb" { + return parse_translation_workbench_command(args, options); + } let command = match action.as_str() { "run" => CliCommand::Translate, "export" => CliCommand::Translate, "set" => CliCommand::TranslationSet, + "get" | "show" => CliCommand::TranslationGet, + "unset" | "clear" => CliCommand::TranslationUnset, "validate" => CliCommand::TranslationValidate, "publish" => CliCommand::PublishLocalized, "tasks" => CliCommand::TranslationTasks, @@ -7481,6 +7544,28 @@ fn parse_translation_command( Ok(()) } +fn parse_translation_workbench_command( + args: &mut impl Iterator, + options: &mut CliOptions, +) -> anyhow::Result<()> { + let action = next_option_value(args, "translation workbench")?; + let command = match action.as_str() { + "export" => CliCommand::Translate, + "set" => CliCommand::TranslationSet, + "get" | "show" => CliCommand::TranslationGet, + "unset" | "clear" => CliCommand::TranslationUnset, + "validate" => CliCommand::TranslationValidate, + other => { + return Err(anyhow::anyhow!( + "未知 translation workbench 二级命令:{other}" + )) + } + }; + ensure_command_not_set(options.command, &format!("translation workbench {action}"))?; + options.command = command; + Ok(()) +} + fn parse_translation_task_command( args: &mut impl Iterator, options: &mut CliOptions, @@ -7606,6 +7691,8 @@ fn print_usage(binary: &str) { eprintln!(" i18n run Refresh offline translation work"); eprintln!(" i18n export Export an editable translation workbench"); eprintln!(" i18n set Update one translation workbench entry"); + eprintln!(" i18n get Show one translation workbench entry"); + eprintln!(" i18n unset Clear one translated workbench entry"); eprintln!(" i18n validate Validate workbench against the current official release"); eprintln!(" i18n task update Update one provider worker task status"); eprintln!(" i18n publish Publish a localized release"); @@ -7641,6 +7728,12 @@ fn print_usage(binary: &str) { eprintln!(" {binary} res pull --auto-discover --run-count 3 --interval 1h"); eprintln!(" {binary} parse run --force --resource-root /tmp/bat-release"); eprintln!(" {binary} i18n export --translation-file /tmp/bat-workbench.json"); + eprintln!( + " {binary} i18n get --translation-file /tmp/bat-workbench.json --translation-id unit-1" + ); + eprintln!( + " {binary} i18n unset --translation-file /tmp/bat-workbench.json --translation-id unit-1" + ); eprintln!(" {binary} i18n publish --translation-file /tmp/bat-workbench.json --force"); eprintln!(" {binary} status"); eprintln!(" {binary} refresh --force --json"); diff --git a/infrastructure/src/bin/bat/app_tests.rs b/infrastructure/src/bin/bat/app_tests.rs index cdd8b26..50a862e 100644 --- a/infrastructure/src/bin/bat/app_tests.rs +++ b/infrastructure/src/bin/bat/app_tests.rs @@ -229,6 +229,31 @@ fn grouped_workflow_commands_use_short_top_level_aliases() { .unwrap(); assert_eq!(options.command, CliCommand::TranslationValidate); + let options = parse(&[ + "bat", + "i18n", + "get", + "--translation-file", + "/tmp/workbench.json", + "--translation-id", + "unit-1", + ]) + .unwrap(); + assert_eq!(options.command, CliCommand::TranslationGet); + + let options = parse(&[ + "bat", + "i18n", + "workbench", + "unset", + "--translation-file", + "/tmp/workbench.json", + "--translation-id", + "unit-1", + ]) + .unwrap(); + assert_eq!(options.command, CliCommand::TranslationUnset); + let options = parse(&[ "bat", "i18n", @@ -261,6 +286,18 @@ fn grouped_workflow_commands_use_short_top_level_aliases() { Some("provider-run-1") ); assert!(parse(&["bat", "i18n", "task", "update", "--task-id", "task-only",]).is_err()); + assert!(parse(&[ + "bat", + "i18n", + "get", + "--translation-file", + "/tmp/workbench.json", + "--translation-id", + "unit-1", + "--translated-text", + "多余文本", + ]) + .is_err()); assert!(parse(&[ "bat", "i18n", @@ -289,6 +326,74 @@ fn grouped_workflow_commands_use_short_top_level_aliases() { .is_err()); } +#[test] +fn translation_workbench_commands_read_update_and_clear_entries() { + let temp = tempfile::TempDir::new().unwrap(); + let path = temp.path().join("workbench.json"); + let workbench = bat_infrastructure::TranslationWorkbench { + schema_version: bat_infrastructure::TRANSLATION_WORKBENCH_VERSION, + official_release_id: "release-1".to_string(), + official_resource_root: temp.path().to_path_buf(), + generated_unix_seconds: 1, + entries: vec![bat_infrastructure::TranslationWorkbenchEntry { + id: "unit-1".to_string(), + destination: "bundles/test.bundle".to_string(), + archive_entry: None, + serialized_file: Some("CAB-test".to_string()), + path_id: Some(7), + asset_name: Some("Story".to_string()), + source_text: "原文".to_string(), + translated_text: None, + format: Some("plain".to_string()), + text_source_kind: Some("text_asset".to_string()), + }], + }; + bat_infrastructure::write_translation_workbench(&path, &workbench).unwrap(); + let path_arg = path.to_string_lossy().to_string(); + + let set = parse(&[ + "bat", + "i18n", + "set", + "--translation-file", + &path_arg, + "--translation-id", + "unit-1", + "--translated-text", + "译文", + ]) + .unwrap(); + run_translation_set(&set).unwrap(); + + let get = parse(&[ + "bat", + "i18n", + "get", + "--translation-file", + &path_arg, + "--translation-id", + "unit-1", + ]) + .unwrap(); + run_translation_get(&get).unwrap(); + + let unset = parse(&[ + "bat", + "i18n", + "workbench", + "clear", + "--translation-file", + &path_arg, + "--translation-id", + "unit-1", + ]) + .unwrap(); + run_translation_unset(&unset).unwrap(); + + let loaded = bat_infrastructure::read_translation_workbench(&path).unwrap(); + assert_eq!(loaded.entries[0].translated_text, None); +} + #[test] fn grouped_command_long_aliases_and_schedule_options_are_accepted() { let options = parse(&[ diff --git a/infrastructure/src/bin/bat/workflow_commands.rs b/infrastructure/src/bin/bat/workflow_commands.rs index dc53015..9cb1ff9 100644 --- a/infrastructure/src/bin/bat/workflow_commands.rs +++ b/infrastructure/src/bin/bat/workflow_commands.rs @@ -156,6 +156,56 @@ pub(super) fn run_translation_set(options: &CliOptions) -> anyhow::Result<()> { ) } +pub(super) fn run_translation_get(options: &CliOptions) -> anyhow::Result<()> { + let path = options + .translation_file + .as_ref() + .ok_or_else(|| anyhow::anyhow!("i18n get 必须指定 --translation-file"))?; + let entry_id = options + .translation_id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("i18n get 必须指定 --translation-id"))?; + let entry = get_translation_entry(path, entry_id)?; + let data = serde_json::json!({ + "translation_file": path, + "entry": entry, + }); + print_report( + options.output_format, + &CommandReport { + command: "translation-get", + status: "ok", + message: "翻译工作台条目已读取", + data, + }, + ) +} + +pub(super) fn run_translation_unset(options: &CliOptions) -> anyhow::Result<()> { + let path = options + .translation_file + .as_ref() + .ok_or_else(|| anyhow::anyhow!("i18n unset 必须指定 --translation-file"))?; + let entry_id = options + .translation_id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("i18n unset 必须指定 --translation-id"))?; + let entry = unset_translation(path, entry_id)?; + let data = serde_json::json!({ + "translation_file": path, + "entry": entry, + }); + print_report( + options.output_format, + &CommandReport { + command: "translation-unset", + status: "updated", + message: "翻译工作台条目已恢复为未审核", + data, + }, + ) +} + pub(super) fn run_translation_task_update(options: &CliOptions) -> anyhow::Result<()> { let task_id = options .query_task_id diff --git a/infrastructure/src/lib.rs b/infrastructure/src/lib.rs index d927a76..4060f78 100644 --- a/infrastructure/src/lib.rs +++ b/infrastructure/src/lib.rs @@ -140,9 +140,10 @@ pub use translation_tasks::{ TRANSLATION_TASK_SCHEMA_VERSION, }; pub use translation_workflow::{ - export_translation_workbench, localized_text_asset_patches, read_translation_workbench, - repack_bundle, set_translation, validate_translation_workbench, write_translation_workbench, - RepackOperation, RepackReport, RepackSpec, TranslationWorkbench, TranslationWorkbenchEntry, + export_translation_workbench, get_translation_entry, localized_text_asset_patches, + read_translation_workbench, repack_bundle, set_translation, unset_translation, + validate_translation_workbench, write_translation_workbench, RepackOperation, RepackReport, + RepackSpec, TranslationWorkbench, TranslationWorkbenchEntry, TranslationWorkbenchValidationReport, REPACK_SPEC_VERSION, TRANSLATION_WORKBENCH_VERSION, }; diff --git a/infrastructure/src/translation_workflow.rs b/infrastructure/src/translation_workflow.rs index d9968ae..c76c2e2 100644 --- a/infrastructure/src/translation_workflow.rs +++ b/infrastructure/src/translation_workflow.rs @@ -166,6 +166,37 @@ pub fn set_translation( Ok(updated) } +/// Reads one translation entry from a workbench. +pub fn get_translation_entry( + workbench_path: &Path, + entry_id: &str, +) -> anyhow::Result { + let workbench = read_translation_workbench(workbench_path)?; + workbench + .entries + .into_iter() + .find(|entry| entry.id == entry_id) + .ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit:{entry_id}")) +} + +/// Clears one reviewed translation and writes the workbench atomically. +pub fn unset_translation( + workbench_path: &Path, + entry_id: &str, +) -> anyhow::Result { + let mut workbench = read_translation_workbench(workbench_path)?; + let entry = workbench + .entries + .iter_mut() + .find(|entry| entry.id == entry_id) + .ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit:{entry_id}"))?; + entry.translated_text = None; + let updated = entry.clone(); + workbench.generated_unix_seconds = unix_seconds_now(); + write_translation_workbench(workbench_path, &workbench)?; + Ok(updated) +} + /// Validates a workbench against the current official TextUnit index. /// /// This checks the release identity and every stored source/target location @@ -656,6 +687,23 @@ mod tests { assert!(error.to_string().contains("不存在 TextUnit")); } + #[test] + fn translation_get_and_unset_round_trip_atomically() { + let temp = tempfile::TempDir::new().unwrap(); + let path = temp.path().join("workbench.json"); + let mut workbench = workbench(temp.path()); + workbench.entries[0].translated_text = Some("译文".to_string()); + write_translation_workbench(&path, &workbench).unwrap(); + + let entry = get_translation_entry(&path, "unit-1").unwrap(); + assert_eq!(entry.translated_text.as_deref(), Some("译文")); + + let updated = unset_translation(&path, "unit-1").unwrap(); + assert_eq!(updated.translated_text, None); + let loaded = read_translation_workbench(&path).unwrap(); + assert_eq!(loaded.entries[0].translated_text, None); + } + #[test] fn validation_reports_publishable_and_unreviewed_entries() { let temp = tempfile::TempDir::new().unwrap();