mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 07:24:55 +08:00
+13
-13
@@ -28,7 +28,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
|
|||||||
11. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
|
11. 官方同步会拒绝危险输出目录、路径逃逸和现有 symlink 路径组件;下载目标、`.part`、manifest、snapshot、PID、status、log 和控制锁文件不会跟随 symlink,daemon 状态类文件默认以 `0600` 权限创建。
|
||||||
12. `<output>/official-version-state.json` 会明确保存当前已完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,同一版本开始重新拉取或后续发布成功时会清理对应失败记录;`bat status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、结构化日志路径和轮转日志路径,人类可读输出不会把完整版本状态 JSON 内联打印。
|
12. `<output>/official-version-state.json` 会明确保存当前已完成版本、正在拉取版本、上一个可用版本和失败版本;同一 app version、bundle version 和 Addressables root 的失败只保留最新一条,同一版本开始重新拉取或后续发布成功时会清理对应失败记录;`bat status` 会显示最后成功时间、下次检查时间、最后错误摘要、当前阶段、当前下载 URL 进度、版本状态摘要、最近历史失败版本和原因、结构化日志路径和轮转日志路径,人类可读输出不会把完整版本状态 JSON 内联打印。
|
||||||
13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `<output>/.cas`、SQLite 索引为 `<output>/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 或单测覆盖。
|
13. 资源导入链路已支持 CAS + `ResourceRepository` 索引写入,官方同步可用 `--import-repository` / `BAT_IMPORT_REPOSITORY=1` 在已校验 release 发布后触发导入,默认 CAS 路径为 `<output>/.cas`、SQLite 索引为 `<output>/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 翻译任务状态与跳过/失败原因;`translation.task.update` 可回写 provider worker 状态,`translation.worker.run` 可触发 Rust provider worker 独立 claim/lease/retry 并落库 TextUnit 级译文结果,`translation.proofread` 可把汉化 workflow 标记为人工校对中;TextUnit 已包含 class id、field path、字段 offset/byte size 等可追溯定位。Crowdin provider 通过 `CROWDIN_*` 环境变量接入,mock provider 支持本地 fixture;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` 和 `BAT_TRANSLATION_*` 等键,实现编辑 `.env` 后无参启动;优先级为命令行参数 > 进程环境变量 > `.env` > 内置默认值,`BAT_SKIP_ENV_FILE=1` 可整体禁用;Redis 键为预留。daemon 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted`(`BAT-ERR-700005`)。
|
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 翻译任务状态与跳过/失败原因;`translation.task.update` 可回写 provider worker 状态,`translation.worker.run` 可触发 Rust provider worker 独立 claim/lease/retry 并落库 TextUnit 级译文结果,`translation.proofread` 可把汉化 workflow 标记为人工校对中;TextUnit 已包含 class id、field path、字段 offset/byte size 等可追溯定位。Crowdin provider 通过 `CROWDIN_*` 环境变量接入,mock provider 支持本地 fixture;up-to-date 轮询在已有有效解析缓存、TextUnit 明细索引和 TextUnit 队列时只读取摘要,不重复解析。官方同步报告默认 `localized_release_status=not_localized`,含义是原版资源已经发布、汉化资源未发布;受支持的 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 发布成功后会写带 trace 的 `localized-patch-manifest.json`,校验 hash/size/diff/rollback 后才允许 `localized.status` 返回 `status=published`、`status_code=localized.published` 和 `localized_release_status=localized`,并可用 `localized.rollback` 显式恢复上一 release。`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` 和 `BAT_TRANSLATION_*` 等键,实现编辑 `.env` 后无参启动;优先级为命令行参数 > 进程环境变量 > `.env` > 内置默认值,`BAT_SKIP_ENV_FILE=1` 可整体禁用;Redis 键为预留。daemon 任务历史持久化在 `<state-dir>/bat-tasks.json`(版本化、`0600` 原子写),重启后任务经 `task.*` 仍可查,中断任务标记 `task_interrupted`(`BAT-ERR-700005`)。
|
||||||
|
|
||||||
15. issue 43 已补齐 Rust `bat` 的 `res` / `parse` / `i18n` 工作流入口:支持资源拉取、解析刷新、可再生解析缓存清理、离线翻译工作台、人工文本查看/修改/清空、工作台发布前校验、有限 TextAsset 汉化发布、人工校对状态标记、既有 patch 能力的批量重打包、单次/限定次数/周期执行和版本化 schedule CRUD。issue 44 已接入 `translation.worker.run` provider worker:默认并发 8、范围 `1..=256`,每个 worker 独立 claim 下一项任务并落库 lease、失败分类、重试计划和 TextUnit 译文结果。schedule 查询现在按一级工作流过滤,删除/执行会校验作用域,单轮执行可限制计划数;schedule CRUD、翻译任务查询/交接视图、翻译任务状态回写、provider worker 触发和 `translation.proofread` 状态标记已通过 `bat.sock` 的 RPC 以及 `bat-api` 的鉴权管理接口暴露,dashboard 不维护第二套状态。该例外只编排已有解析和 patch 能力,不扩大解析器覆盖;完整 AssetBundle 重打包和 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。issue 44 已接入 `translation.worker.run` provider worker:默认并发 8、范围 `1..=256`,每个 worker 独立 claim 下一项任务并落库 lease、失败分类、重试计划和 TextUnit 译文结果。schedule 查询现在按一级工作流过滤,删除/执行会校验作用域,单轮执行可限制计划数;schedule CRUD、翻译任务查询/交接视图、翻译任务状态回写、provider worker 触发和 `translation.proofread` 状态标记已通过 `bat.sock` 的 RPC 以及 `bat-api` 的鉴权管理接口暴露,dashboard 不维护第二套状态。该例外只编排已有解析和 patch 能力,不扩大解析器覆盖;完整 AssetBundle 重打包和 Web 前端仍是后续工作。G-008(产品级 Go 同步 CLI)已决策关闭。真实官方网络全量拉取 smoke 已固化(G-018 已关闭);真实大文件与运行报告默认在 `/tmp` 隔离目录,不纳入 Git。Go 细节见 `docs/reports/GO_STATUS.md`。
|
||||||
|
|
||||||
@@ -104,7 +104,7 @@ job/unit/provider run 状态。当前下载实现使用默认 8 个独立 worker
|
|||||||
|
|
||||||
待完成:
|
待完成:
|
||||||
|
|
||||||
- `crates/bat-assetbundle` 已具备 UnityFS 容器、对象表、TypeTree 元数据、基础字段读取、TextAsset 和 TextUnit 提取;UnityFS 容器已补充总大小、计数、路径、重复 directory、LZMA 和边界校验,并通过 UnityPy 真实 bundle 隔离回归;UnityFS TextAsset patch 发布前置链路已可用,真实复杂版本差异、重打包和通用 Patch 仍未实现。
|
- `crates/bat-assetbundle` 已具备 UnityFS 容器、对象表、TypeTree 元数据、基础字段读取、TextAsset 和 TextUnit 提取;UnityFS 容器已补充总大小、计数、路径、重复 directory、LZMA 和边界校验,并通过 UnityPy 真实 bundle 隔离回归;已有 TextAsset、TypeTree string field 和 managed-reference string field 的 localized patch 发布闭环,真实复杂版本差异、整体 AssetBundle 重打包和通用 Patch 仍未实现。
|
||||||
- Addressables parser 已覆盖当前真实形态 fixture/golden 与 `m_Crc`,但仍需继续覆盖二进制/压缩字段组合和更细失败诊断。
|
- Addressables parser 已覆盖当前真实形态 fixture/golden 与 `m_Crc`,但仍需继续覆盖二进制/压缩字段组合和更细失败诊断。
|
||||||
- 客户端发现、备份、应用补丁流程尚未连接真实实现。
|
- 客户端发现、备份、应用补丁流程尚未连接真实实现。
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ job/unit/provider run 状态。当前下载实现使用默认 8 个独立 worker
|
|||||||
|
|
||||||
### `bat-assetbundle`
|
### `bat-assetbundle`
|
||||||
|
|
||||||
状态:**UnityFS 解包、TypeTree 字段读取、TextUnit 提取和 TextAsset patch 发布前置已起步;复杂结构覆盖、重打包与 Patch 发布统一未完成**
|
状态:**UnityFS 解包、TypeTree 字段读取、TextUnit 提取和受支持 localized patch 发布已可用;复杂结构覆盖与整体 AssetBundle 重打包仍冻结**
|
||||||
|
|
||||||
冻结说明:自 2026-07-30 起,解析模块进入维护冻结。冻结期只允许修复编译、测试、clippy、崩溃、错误诊断、真实运行回归和文档不一致;不新增 TypeTree 语义类型、不扩大 UnityFS / AssetBundle / Addressables 解析覆盖、不开放新的写入型解析 RPC/CLI,也不以合成 fixture 宣称新增解析能力。冻结细则见 `docs/reports/PARSER_FREEZE.md`。
|
冻结说明:自 2026-07-30 起,解析模块进入维护冻结。冻结期只允许修复编译、测试、clippy、崩溃、错误诊断、真实运行回归和文档不一致;不新增 TypeTree 语义类型、不扩大 UnityFS / AssetBundle / Addressables 解析覆盖、不开放新的写入型解析 RPC/CLI,也不以合成 fixture 宣称新增解析能力。冻结细则见 `docs/reports/PARSER_FREEZE.md`。
|
||||||
|
|
||||||
@@ -171,20 +171,20 @@ job/unit/provider run 状态。当前下载实现使用默认 8 个独立 worker
|
|||||||
待完成:
|
待完成:
|
||||||
|
|
||||||
- 真实 MonoBehaviour、ScriptableObject 版本差异、复杂容器结构调整、unknown 字段结构语义和未见样本驱动的完整 managed reference registry / map entry 变体覆盖;TypeTree-covered managed reference 字段与 registry 记录已可结构化解码,常见 full typename 可拆解为 assembly/namespace/class,不做低保真猜测。
|
- 真实 MonoBehaviour、ScriptableObject 版本差异、复杂容器结构调整、unknown 字段结构语义和未见样本驱动的完整 managed reference registry / map entry 变体覆盖;TypeTree-covered managed reference 字段与 registry 记录已可结构化解码,常见 full typename 可拆解为 assembly/namespace/class,不做低保真猜测。
|
||||||
- 复杂对象整体结构修改后的发布级 AssetBundle 重打包;UnityFS TextAsset、TypeTree string 字段、managed-reference registry payload 字符串、基础语义字段、enum、bit_field、object 字段组合和 TypeTree schema 支撑的 array/vector/map 整体替换的文件级链路已具备重建后校验,发布级 manifest/apply/diff/rollback 仍需统一。
|
- 复杂对象整体结构修改后的发布级 AssetBundle 重打包;UnityFS TextAsset、TypeTree string 字段、managed-reference registry payload 字符串、基础语义字段、enum、bit_field、object 字段组合和 TypeTree schema 支撑的 array/vector/map 整体替换的文件级链路已具备重建后校验,受支持 localized patch 已有独立 staging、manifest、current、状态校验和显式 rollback;整体 AssetBundle 发布仍不在冻结范围内。
|
||||||
- 真实资源 fixture 覆盖对象级解析和文本提取。
|
- 真实资源 fixture 覆盖对象级解析和文本提取。
|
||||||
- 详细补全顺序见 `docs/architecture/assetbundle.md`。
|
- 详细补全顺序见 `docs/architecture/assetbundle.md`。
|
||||||
|
|
||||||
### `bat-patch`
|
### `bat-patch`
|
||||||
|
|
||||||
状态:**通用 Binary/JSON/Text Patch 基础可用;文件级 patch / UnityFS 写入入口已开放,发布级 Patch 仍未完成**
|
状态:**通用 Binary/JSON/Text Patch 基础可用;受支持 localized patch 发布/rollback 已完成,通用 Patch 发布仍未完成**
|
||||||
|
|
||||||
当前已有确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据。`patch.apply` RPC 与 `patch-apply` CLI 已可对显式 source/patch/target 文件执行 Binary/JSON/Text patch,并返回 size/BLAKE3 报告;`unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` RPC 和 `unityfs-patch-text-asset` / `unityfs-patch-string-field` / `unityfs-patch-field` CLI 已可对显式 UnityFS bundle 输出目标文件。`unityfs.patch_field` 支持 bool、signed/unsigned integer、float raw bits、string、bytes、enum、bit_field、固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、unknown fixed-size raw bytes 同长度替换、PPtr、object 字段组合和 TypeTree schema 支撑的 array/vector/List/HashSet/map 整体替换语义 JSON 值;array/vector/List/HashSet/map 扩容会复用当前首个元素或 TypeTree data node 的编码 schema,空容器扩容已用合成 fixture 覆盖,嵌套 vector `Array`、`List<T>` 和 `HashSet<T>` 形态、enum、bit_field、unknown fixed-size raw bytes、managed-reference registry `data` 和 `managedReferenceData` payload 字符串已有重建后重解析 fixture。`bat-assetbundle` + `LocalizedPatchService` 已能对 UnityFS TextAsset 执行替换、写 patch manifest、记录 diff/rollback,并发布到独立汉化 release;`LocalizedPatchManifest` 可转换为通用 `bat_patch::PatchManifest`,但现有汉化 manifest 文件格式不强制迁移。
|
当前已有确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据。`patch.apply` RPC 与 `patch-apply` CLI 已可对显式 source/patch/target 文件执行 Binary/JSON/Text patch,并返回 size/BLAKE3 报告;`unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` RPC 和 `unityfs-patch-text-asset` / `unityfs-patch-string-field` / `unityfs-patch-field` CLI 已可对显式 UnityFS bundle 输出目标文件。`unityfs.patch_field` 支持 bool、signed/unsigned integer、float raw bits、string、bytes、enum、bit_field、固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、unknown fixed-size raw bytes 同长度替换、PPtr、object 字段组合和 TypeTree schema 支撑的 array/vector/List/HashSet/map 整体替换语义 JSON 值;array/vector/List/HashSet/map 扩容会复用当前首个元素或 TypeTree data node 的编码 schema,空容器扩容已用合成 fixture 覆盖,嵌套 vector `Array`、`List<T>` 和 `HashSet<T>` 形态、enum、bit_field、unknown fixed-size raw bytes、managed-reference registry `data` 和 `managedReferenceData` payload 字符串已有重建后重解析 fixture。`bat-assetbundle` + `LocalizedPatchService` 已能对 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 执行替换,写入带 TextUnit/provider/review/rollback trace 的 localized patch manifest,在独立 staging 校验后发布汉化 release,并通过 `localized.publish` / `localized.rollback` RPC、`i18n publish` / `i18n rollback` CLI 和 bat-api 控制面暴露;`LocalizedPatchManifest` 可转换为通用 `bat_patch::PatchManifest`,通用 manifest 驱动发布仍未迁移。
|
||||||
|
|
||||||
待完成:
|
待完成:
|
||||||
|
|
||||||
- 未见样本驱动的 map entry schema 变化、unknown 字段结构语义、完整 managed reference registry 变体驱动字段修改后的语义重打包。
|
- 未见样本驱动的 map entry schema 变化、unknown 字段结构语义、完整 managed reference registry 变体驱动字段修改后的语义重打包。
|
||||||
- 发布级 `patch build` / `patch rollback` / 通用 manifest 驱动发布;当前文件级写入入口不切换 release,不替代汉化发布流程。
|
- 通用 manifest 驱动的跨类型 patch build/apply/diff 发布;当前 localized 发布仅接受已验证 TextUnit 对应的受支持 UnityFS 文本字段,并不等价于整体 AssetBundle 重打包。
|
||||||
- `unityfs.inspect`、复杂 UnityFS 语义编辑和写入型发布工作流仍未开放。
|
- `unityfs.inspect`、复杂 UnityFS 语义编辑和写入型发布工作流仍未开放。
|
||||||
|
|
||||||
### `bat-ffi`
|
### `bat-ffi`
|
||||||
@@ -291,12 +291,12 @@ GitHub issue 状态:#1 已关闭;#17 的历史决定不代表当前下载实
|
|||||||
|
|
||||||
下一阶段必须优先完成:
|
下一阶段必须优先完成:
|
||||||
|
|
||||||
1. Issue #1(P0,主体已实现):`bat.sock` Unix socket JSON-RPC 已扩展为面向 Go 服务层的 Rust Resource Backend API。统一 envelope(`ok`、`status`、`error`、`data`、`request_id`)与 `BAT-ERR` 错误码模型已落地;`daemon.*`(status/logs/stop/restart/reload/refresh/doctor)、`resource.*`(state/sync/verify/repair/manifest/list/index)、`schedule.*`(list/add/update/remove/run)、`parse.*`(status/text_units/errors)、`translation.*`(tasks/handoff/task.update/proofread)、`localized.*`(status)、`catalog.*`(status/refresh/diff/versions)、`task.*`(status/list/cancel/logs)、文件级 `patch.apply` 与 `unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` 已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `<state-dir>/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:发布级 `patch build` / `patch rollback`、复杂 `unityfs.*` 语义编辑、`task.create`(按设计由语义方法创建)、`daemon.clean-stable`(由 CLI 侧按进程生命周期显式执行,live RPC 内不做在线清理)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFI(FFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。
|
1. Issue #1(P0,主体已实现):`bat.sock` Unix socket JSON-RPC 已扩展为面向 Go 服务层的 Rust Resource Backend API。统一 envelope(`ok`、`status`、`error`、`data`、`request_id`)与 `BAT-ERR` 错误码模型已落地;`daemon.*`(status/logs/stop/restart/reload/refresh/doctor)、`resource.*`(state/sync/verify/repair/manifest/list/index)、`schedule.*`(list/add/update/remove/run)、`parse.*`(status/text_units/errors)、`translation.*`(tasks/handoff/task.update/proofread/worker.run)、`localized.*`(status/publish/rollback)、`catalog.*`(status/refresh/diff/versions)、`task.*`(status/list/cancel/logs)、文件级 `patch.apply` 与 `unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field` 已实现,长任务返回 `task_id` 可轮询(任务执行器单 worker FIFO,与 watch 循环互斥;任务历史持久化于 `<state-dir>/bat-tasks.json`,daemon 重启后仍可查,中断任务标记 `task_interrupted`);错误码已接入下载、launcher/metadata、server-info/marker 与配置校验路径。剩余:通用 manifest 驱动发布、复杂 `unityfs.*` 语义编辑、`task.create`(按设计由语义方法创建)、`daemon.clean-stable`(由 CLI 侧按进程生命周期显式执行,live RPC 内不做在线清理)、Redis 任务后端(`.env` 已预留配置键,接入时机另议)。Go 层通过 RPC 调用 Rust backend,不走 FFI(FFI 降级说明见 `docs/architecture/official-resource-backend.md` §7)。
|
||||||
2. Go 侧:进度见 `docs/reports/GO_STATUS.md`。G-008 已关闭;`bat-api` 资源 bootstrap/分发 MVP 已落地,已含 `/v1/bootstrap`、`/v1/launcher/bootstrap`、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、动态 JSON no-store、OpenAPI、管理控制白名单、translation admin 查询/控制、CDN Range/缓存头、RPC 周期刷新和 USERGUIDE 基础章节;仓库内 Rust/Go snapshot contract fixture 与同机 live smoke 已落地。失效 `resource_root` 或 manifest 不完整时会清空旧索引并使 `/readyz` 返回 `503`;编码 dot-segment 会在路由规范化前拒绝。refresh mtime/size 增量缓存和可选持久化另议。
|
2. Go 侧:进度见 `docs/reports/GO_STATUS.md`。G-008 已关闭;`bat-api` 资源 bootstrap/分发 MVP 已落地,已含 `/v1/bootstrap`、`/v1/launcher/bootstrap`、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、动态 JSON no-store、OpenAPI、管理控制白名单、translation admin 查询/控制、CDN Range/缓存头、RPC 周期刷新和 USERGUIDE 基础章节;仓库内 Rust/Go snapshot contract fixture 与同机 live smoke 已落地。失效 `resource_root` 或 manifest 不完整时会清空旧索引并使 `/readyz` 返回 `503`;编码 dot-segment 会在路由规范化前拒绝。refresh mtime/size 增量缓存和可选持久化另议。
|
||||||
3. 文本提取 / 翻译队列 / Patch 输入:`official-textunit-index.json`、`official-textunit-tasks.json` 与 `crowdin-textunit-queue.json` 已生成;TextUnit 明细、解析错误和离线翻译任务状态/失败原因已可查询,`translation.tasks` / `translation.handoff` 已提供 Go typed helper 和 bat-api dashboard 查询入口,`translation.task.update` 已提供 Rust CLI、Go typed helper 和 bat-api dashboard 状态回写入口,`translation.worker.run` 已提供 mock/Crowdin provider worker、lease、重试和 TextUnit 译文结果落库,`translation.proofread` 已提供人工校对状态标记入口。剩余为翻译记忆、完整 Patch 构建/rollback 和复杂重打包。
|
3. 文本提取 / 翻译队列 / Patch 输入:`official-textunit-index.json`、`official-textunit-tasks.json` 与 `crowdin-textunit-queue.json` 已生成;TextUnit 明细、解析错误和离线翻译任务状态/失败原因已可查询,`translation.tasks` / `translation.handoff` 已提供 Go typed helper 和 bat-api dashboard 查询入口,`translation.task.update` 已提供 Rust CLI、Go typed helper 和 bat-api dashboard 状态回写入口,`translation.worker.run` 已提供 mock/Crowdin provider worker、lease、重试和 TextUnit 译文结果落库,`translation.proofread` 已提供人工校对状态标记入口。受支持 TextAsset、TypeTree string field 和 managed-reference string field 已可从 workbench/worker 结果生成 localized patch,在独立 staging 校验后发布并显式 rollback,相关 status/publish/rollback RPC 与 bat-api 控制入口已暴露。剩余为翻译记忆、通用 manifest 发布和复杂重打包。
|
||||||
4. Issue #3(已完成当前目标):AssetBundle UnityFS 基础解析校验已覆盖 header、block、directory、LZ4/LZMA、alignment、总大小、计数、路径安全、重复 directory、边界和隔离 UnityPy 真实样本;复杂对象解析与发布级重打包继续跟踪 G-005。
|
4. Issue #3(已完成当前目标):AssetBundle UnityFS 基础解析校验已覆盖 header、block、directory、LZ4/LZMA、alignment、总大小、计数、路径安全、重复 directory、边界和隔离 UnityPy 真实样本;复杂对象解析与发布级重打包继续跟踪 G-005。
|
||||||
5. Issue #2(已完成当前目标):Addressables JSON/compact catalog 已提取 bundle provider、name、hash/size/CRC、资源类型和依赖关系,并贯通 ResourceEntry、SQLite 与 resource.index 输出;独立二进制格式仍明确拒绝。
|
5. Issue #2(已完成当前目标):Addressables JSON/compact catalog 已提取 bundle provider、name、hash/size/CRC、资源类型和依赖关系,并贯通 ResourceEntry、SQLite 与 resource.index 输出;独立二进制格式仍明确拒绝。
|
||||||
6. 通用 Binary/JSON/Text Patch 基础已落地;复杂 AssetBundle 重打包和真实翻译系统仍应后置,UnityFS TextAsset patch 发布前置已具备回归测试。
|
6. 通用 Binary/JSON/Text Patch 基础已落地;受支持 localized patch 发布/rollback 已具备回归测试,复杂 AssetBundle 重打包、通用 manifest 发布和真实翻译记忆仍后置。
|
||||||
|
|
||||||
非阻塞跟踪项:官方同步长期运行测试正在进行,运行报告将在后续提供。
|
非阻塞跟踪项:官方同步长期运行测试正在进行,运行报告将在后续提供。
|
||||||
|
|
||||||
@@ -306,7 +306,7 @@ GitHub issue 状态:#1 已关闭;#17 的历史决定不代表当前下载实
|
|||||||
|
|
||||||
立即任务:
|
立即任务:
|
||||||
|
|
||||||
1. Issue #1 收尾:协议基础设施、最小方法集、`catalog.*`、`parse.*`、`localized.*`、`task.*`、`resource.repair`、`daemon.restart`、文件级 `patch.apply` / `unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field`、任务持久化、错误码模型与文档均已完成;剩余发布级 `patch build`/`rollback`、复杂 `unityfs.*` 语义编辑以及 `task.create`、`daemon.clean-stable` 的设计边界确认。
|
1. Issue #1 收尾:协议基础设施、最小方法集、`catalog.*`、`parse.*`、`localized.*`、`task.*`、`resource.repair`、`daemon.restart`、文件级 `patch.apply` / `unityfs.patch_text_asset` / `unityfs.patch_string_field` / `unityfs.patch_field`、任务持久化、错误码模型与文档均已完成;剩余通用 manifest 发布、复杂 `unityfs.*` 语义编辑以及 `task.create`、`daemon.clean-stable` 的设计边界确认。
|
||||||
2. 真实官方网络全量下载长期运行报告(`make official-smoke`);issue #19 的同机 live 联调已完成。
|
2. 真实官方网络全量下载长期运行报告(`make official-smoke`);issue #19 的同机 live 联调已完成。
|
||||||
3. 跟进官方同步长期运行测试报告。
|
3. 跟进官方同步长期运行测试报告。
|
||||||
4. AssetBundle / Addressables(issue #3 / #2);CAS 用户级导入(G-011)。
|
4. AssetBundle / Addressables(issue #3 / #2);CAS 用户级导入(G-011)。
|
||||||
@@ -314,5 +314,5 @@ GitHub issue 状态:#1 已关闭;#17 的历史决定不代表当前下载实
|
|||||||
---
|
---
|
||||||
|
|
||||||
- **当前总体完成度**:不再固定写单一百分比,以各模块状态、`GO_STATUS.md` 和 issue 为准。
|
- **当前总体完成度**:不再固定写单一百分比,以各模块状态、`GO_STATUS.md` 和 issue 为准。
|
||||||
- **当前基线状态**:Rust `bat` 同步闭环可用;Go `bat-api` 资源 bootstrap/分发 MVP + 玩家-facing HTTP 控制面 + launcher 资源引导兼容 + RPC 周期刷新/诊断 + readiness + `backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、通用 Binary/JSON/Text Patch 基础和 UnityFS TextAsset patch 发布前置可用;完整 AssetBundle 重打包未完成。
|
- **当前基线状态**:Rust `bat` 同步闭环可用;Go `bat-api` 资源 bootstrap/分发 MVP + 玩家-facing HTTP 控制面 + launcher 资源引导兼容 + RPC 周期刷新/诊断 + readiness + `backendrpc` 可用;CAS 用户级导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、通用 Binary/JSON/Text Patch 基础和受支持 localized patch 发布/rollback 可用;完整 AssetBundle 重打包与通用 manifest 发布未完成。
|
||||||
- **下一工程里程碑**:翻译记忆、完整 localized Patch 构建/rollback、复杂 AssetBundle 解析和重打包;`bat-api` 同机 live 联调已完成。
|
- **下一工程里程碑**:翻译记忆、通用 manifest Patch 构建、复杂 AssetBundle 解析和重打包;`bat-api` 同机 live 联调已完成。
|
||||||
|
|||||||
+6
-6
@@ -41,12 +41,12 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
|
|
||||||
### 仍是骨架或占位
|
### 仍是骨架或占位
|
||||||
|
|
||||||
1. `bat-assetbundle` 已具备 UnityFS 解包和 TextAsset 提取基础能力(header/block info/directory、LZ4/LZMA block info 与数据 block、directory 文件提取、serialized file object table、TypeTree node 元数据、TextAsset bytes、TypeTree-covered managed reference payload TextUnit 上下文),并已有 UnityFS TextAsset patch 前置能力;MonoBehaviour/ScriptableObject 复杂字段级解析、重打包和通用 Patch 仍未完成。
|
1. `bat-assetbundle` 已具备 UnityFS 解包和 TextAsset 提取基础能力(header/block info/directory、LZ4/LZMA block info 与数据 block、directory 文件提取、serialized file object table、TypeTree node 元数据、TextAsset bytes、TypeTree-covered managed reference payload TextUnit 上下文),并已有受支持 localized patch 发布能力;MonoBehaviour/ScriptableObject 复杂字段级解析、整体重打包和通用 Patch 仍未完成。
|
||||||
2. `bat-patch` 已具备确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,当前发布级可用的是 `bat-assetbundle` + `LocalizedPatchService` 的 UnityFS TextAsset patch 前置链路。
|
2. `bat-patch` 已具备确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、通用 Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,`bat-assetbundle` + `LocalizedPatchService` 已完成受支持 TextUnit 到 localized patch manifest、独立 staging、发布和 rollback 闭环,通用 manifest 发布与整体 AssetBundle 重打包仍后置。
|
||||||
3. Go 侧边界已冻结(见 `docs/reports/GO_STATUS.md`):同步/运维命令行 = Rust `bat`;资源分发 = `cmd/bat-api` MVP;`internal/backendrpc` 完成;`cmd/bat` 仅为试验(`bin/bat-go`)。完整游戏业务 API / Web / SDK 仍未完成。
|
3. Go 侧边界已冻结(见 `docs/reports/GO_STATUS.md`):同步/运维命令行 = Rust `bat`;资源分发 = `cmd/bat-api` MVP;`internal/backendrpc` 完成;`cmd/bat` 仅为试验(`bin/bat-go`)。完整游戏业务 API / Web / SDK 仍未完成。
|
||||||
4. Addressables parser 已覆盖当前真实形态 fixture/golden,但还不是完整 Unity Addressables/SBP catalog 兼容层。
|
4. Addressables parser 已覆盖当前真实形态 fixture/golden,但还不是完整 Unity Addressables/SBP catalog 兼容层。
|
||||||
5. 官方同步结果可配置为发布后自动导入 CAS + ResourceRepository,并通过 `resource.index` RPC/CLI 查询;Resource metadata 已保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 摘要,资源级查询已覆盖 release、平台、destination、archive entry、parse status 和 TextUnit format;单条 TextUnit 明细和解析错误已持久化到 `official-textunit-index.json`,可通过 `parse.text_units` / `parse.errors` 查询;离线 TextUnit 翻译任务状态和跳过/失败原因可通过 `translation.tasks` 查询,`translation.task.update` 已提供 worker 状态回写 contract,`translation.worker.run` 已提供真实 provider worker 触发、lease/retry 和结果落库 contract,`translation.proofread` 已提供汉化 workflow 人工校对标记 contract。
|
5. 官方同步结果可配置为发布后自动导入 CAS + ResourceRepository,并通过 `resource.index` RPC/CLI 查询;Resource metadata 已保存 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 摘要,资源级查询已覆盖 release、平台、destination、archive entry、parse status 和 TextUnit format;单条 TextUnit 明细和解析错误已持久化到 `official-textunit-index.json`,可通过 `parse.text_units` / `parse.errors` 查询;离线 TextUnit 翻译任务状态和跳过/失败原因可通过 `translation.tasks` 查询,`translation.task.update` 已提供 worker 状态回写 contract,`translation.worker.run` 已提供真实 provider worker 触发、lease/retry 和结果落库 contract,`translation.proofread` 已提供汉化 workflow 人工校对标记 contract。
|
||||||
6. 汉化 Patch 发布前置已具备 UnityFS TextAsset manifest/apply/diff/rollback/完整性校验和 `localized.status` 严格校验;真实 provider worker 已接入,翻译记忆到完整汉化文件集合的构建仍未完成。
|
6. 受支持汉化 Patch 发布已具备 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 的 manifest/apply/rollback/完整性校验和 `localized.status` 严格校验;真实 provider worker 已接入,翻译记忆到完整汉化文件集合的构建仍未完成。
|
||||||
7. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook(G-018 已关闭);真实运行记录处于长期运行测试阶段,报告待后续提供。
|
7. 真实官方网络全量下载 smoke 已固化为可重复脚本和 runbook(G-018 已关闭);真实运行记录处于长期运行测试阶段,报告待后续提供。
|
||||||
8. 完整 Web、数据库迁移、插件加载机制尚未实现;`bat-api` 资源 bootstrap/分发 OpenAPI 已通过 `/openapi.yaml` 提供,完整游戏业务 API 的 OpenAPI 仍未完成。
|
8. 完整 Web、数据库迁移、插件加载机制尚未实现;`bat-api` 资源 bootstrap/分发 OpenAPI 已通过 `/openapi.yaml` 提供,完整游戏业务 API 的 OpenAPI 仍未完成。
|
||||||
9. 原 Git 历史未恢复;当前仓库以新初始化基线为准。
|
9. 原 Git 历史未恢复;当前仓库以新初始化基线为准。
|
||||||
@@ -183,7 +183,7 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
8. Linux 生产同步不依赖已安装官方启动器:**已完成当前 Rust 入口**。`--auto-discover` 只使用官方 HTTP metadata 和临时目录解析 `GameMainConfig`。
|
8. Linux 生产同步不依赖已安装官方启动器:**已完成当前 Rust 入口**。`--auto-discover` 只使用官方 HTTP metadata 和临时目录解析 `GameMainConfig`。
|
||||||
9. 真实官方网络全量下载 smoke test:**命令已固化(G-018 已关闭)**。`scripts/official-full-pull-smoke.sh` / `make official-smoke` 已固化 dry-run、首次下载、二次 up-to-date 和本地损坏 repair 的可重复流程;真实运行处于长期运行测试阶段,报告待后续提供。
|
9. 真实官方网络全量下载 smoke test:**命令已固化(G-018 已关闭)**。`scripts/official-full-pull-smoke.sh` / `make official-smoke` 已固化 dry-run、首次下载、二次 up-to-date 和本地损坏 repair 的可重复流程;真实运行处于长期运行测试阶段,报告待后续提供。
|
||||||
10. 官方发布后的增量 handoff 与解析缓存:**已完成基础入口**。新 release 发布后先生成 `official-resource-changes.json` 和 `crowdin-translation-handoff.json`,新增+变更资源进入解析/翻译候选;`official-parse-cache.json` 基于下载 manifest 覆盖直接 UnityFS bundle、zip 内 UnityFS 条目和非候选资源记录;随后生成 `official-textunit-index.json`、`official-textunit-tasks.json` 与 `crowdin-textunit-queue.json`,本地文件未变化且缓存/索引有效时跳过重复解析。
|
10. 官方发布后的增量 handoff 与解析缓存:**已完成基础入口**。新 release 发布后先生成 `official-resource-changes.json` 和 `crowdin-translation-handoff.json`,新增+变更资源进入解析/翻译候选;`official-parse-cache.json` 基于下载 manifest 覆盖直接 UnityFS bundle、zip 内 UnityFS 条目和非候选资源记录;随后生成 `official-textunit-index.json`、`official-textunit-tasks.json` 与 `crowdin-textunit-queue.json`,本地文件未变化且缓存/索引有效时跳过重复解析。
|
||||||
11. 汉化发布状态:**已完成前置闭环**。官方同步默认报告 `not_localized`,表示只发布原版资源;UnityFS TextAsset patch 发布成功并通过 `localized-patch-manifest.json`、current symlink 和 release ID 校验后才切换为 `localized`。
|
11. 汉化发布状态:**受支持范围已完成闭环**。官方同步默认报告 `not_localized`,表示只发布原版资源;TextAsset、TypeTree string field 和 managed-reference string field patch 发布成功并通过 `localized-patch-manifest.json`、current symlink、release ID 和 rollback 校验后才切换为 `localized`,可通过 `localized.publish` / `localized.rollback` 与 bat-api 管理接口控制。
|
||||||
|
|
||||||
验收标准:
|
验收标准:
|
||||||
|
|
||||||
@@ -459,9 +459,9 @@ BlueArchiveToolkit 不是一次性脚本,也不是演示项目。最终交付
|
|||||||
|
|
||||||
按最终目标计算,当前总体完成度不再固定写单一百分比,以模块状态和 issue 收敛情况为准。
|
按最终目标计算,当前总体完成度不再固定写单一百分比,以模块状态和 issue 收敛情况为准。
|
||||||
|
|
||||||
已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、通用 Binary/JSON/Text Patch 基础、UnityFS TextAsset patch 发布前置,以及 Go `bat-api` 资源分发和同机 live 联调。下一阶段的关键是翻译记忆、完整 localized Patch 构建/rollback、复杂 AssetBundle 解析/重打包和官方资源长期运行报告。
|
已完成的是稳定基线、架构骨架、部分接口、CAS V1、Rust 官方资源同步闭环、可配置 CAS/ResourceRepository 导入、TextUnit 明细索引/查询、增量 Crowdin 离线队列、provider worker、通用 Binary/JSON/Text Patch 基础、受支持 localized patch 发布/rollback,以及 Go `bat-api` 资源分发和同机 live 联调。下一阶段的关键是翻译记忆、通用 manifest 发布、复杂 AssetBundle 解析/重打包和官方资源长期运行报告。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
- **下一份应更新文档**:真实官方网络 smoke 记录
|
- **下一份应更新文档**:真实官方网络 smoke 记录
|
||||||
- **下一项工程任务**:推进翻译记忆、完整 localized Patch 构建/rollback、复杂 AssetBundle 解析,并持续执行官方资源长期运行 smoke。
|
- **下一项工程任务**:推进翻译记忆、通用 manifest Patch 构建、复杂 AssetBundle 解析,并持续执行官方资源长期运行 smoke。
|
||||||
|
|||||||
@@ -20,8 +20,8 @@
|
|||||||
- 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
|
- 官方同步会维护 `<output>/official-version-state.json`,明确记录当前已完成版本、正在拉取版本、上一个可用版本和失败版本。
|
||||||
- 资源导入链路可配置为在官方 release 发布后写入 CAS + `ResourceRepository`,资源 metadata 会记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式,TextAsset/Table/Media 会按类型分类索引;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、archive entry、parse status 和 TextUnit format 分页查询索引。
|
- 资源导入链路可配置为在官方 release 发布后写入 CAS + `ResourceRepository`,资源 metadata 会记录 release、平台、bundle path、parse status、TextAsset 名称和 TextUnit 数量/格式,TextAsset/Table/Media 会按类型分类索引;`resource.index` RPC/CLI 可按类型、hash、路径模式、官方 release ID、平台、destination、archive entry、parse status 和 TextUnit format 分页查询索引。
|
||||||
- 新 release 发布后会生成 `official-resource-changes.json`、`official-parse-cache.json`、`official-textunit-index.json`、`official-textunit-tasks.json`、`crowdin-translation-handoff.json`、`crowdin-textunit-queue.json`、`translation-tasks.sqlite` 和 `translation-handoff.json`;其中 TextUnit/Crowdin 队列只使用 Added/Modified 资源,不调用 Crowdin 网络 API,离线 TextUnit 翻译任务可通过 `translation.tasks` / `translation.handoff` RPC 或 CLI 查询状态、跳过/失败原因和 provider run 交接。
|
- 新 release 发布后会生成 `official-resource-changes.json`、`official-parse-cache.json`、`official-textunit-index.json`、`official-textunit-tasks.json`、`crowdin-translation-handoff.json`、`crowdin-textunit-queue.json`、`translation-tasks.sqlite` 和 `translation-handoff.json`;其中 TextUnit/Crowdin 队列只使用 Added/Modified 资源,不调用 Crowdin 网络 API,离线 TextUnit 翻译任务可通过 `translation.tasks` / `translation.handoff` RPC 或 CLI 查询状态、跳过/失败原因和 provider run 交接。
|
||||||
- `LocalizedPatchService` 已具备 UnityFS TextAsset patch 发布前置能力:在 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 配置的独立汉化目录 staging 中复制官方 release、应用 TextAsset patch、写 `localized-patch-manifest.json`(hash、size、diff、rollback)、校验后发布到 `versions/<id>` 并切换 `current`。
|
- `LocalizedPatchService` 已具备受支持的 UnityFS localized patch 发布/回滚能力:在 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 配置的独立汉化目录 staging 中复制官方 release、应用 TextAsset、TypeTree string field 或 managed-reference string field patch、写入带 TextUnit/provider/review/rollback trace 的 `localized-patch-manifest.json`,校验后发布到 `versions/<id>` 并切换 `current`,也可显式 rollback。
|
||||||
- `bat-patch` 已具备通用 Patch 基础:确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,TypeTree 语义字段支持基础标量、固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、PPtr、managed-reference registry payload 字符串、object 字段组合、unknown fixed-size raw bytes 同长度替换和 TypeTree schema 支撑的 array/vector/map 整体替换;TextUnit 提取会把 managed-reference 类型信息保留为上下文而非翻译文本,汉化发布当前仍走 UnityFS TextAsset 前置链路。
|
- `bat-patch` 已具备通用 Patch 基础:确定性 Binary hunk diff/apply、RFC 6902 JSON Patch apply、UTF-8 Text Patch、Patch manifest、BLAKE3/size 完整性校验和 rollback 元数据;文件级 `patch.apply` RPC / `patch-apply` CLI 与 UnityFS TextAsset / TypeTree string / TypeTree 语义字段写入入口已开放,TypeTree 语义字段支持基础标量、固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、PPtr、managed-reference registry payload 字符串、object 字段组合、unknown fixed-size raw bytes 同长度替换和 TypeTree schema 支撑的 array/vector/map 整体替换;TextUnit 提取会把 managed-reference 类型信息保留为上下文而非翻译文本,受支持 localized 发布通过独立 manifest/staging/current 流程完成。
|
||||||
- `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。
|
- `bat-ffi` 可选无状态 C ABI 兼容层:仅保留 Manifest inspect 和官方 sync plan 的粗粒度 JSON helper,不作为 Go CLI 或生产同步的主集成边界。
|
||||||
- 文档路线图、当前状态、缺口清单、官方资源运行指南。
|
- 文档路线图、当前状态、缺口清单、官方资源运行指南。
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
- `bat-api` 完整游戏业务 API / launcher 安装包更新全链仍未完成;资源 CDN、HTTP 控制面和 launcher 资源引导兼容已可用。
|
- `bat-api` 完整游戏业务 API / launcher 安装包更新全链仍未完成;资源 CDN、HTTP 控制面和 launcher 资源引导兼容已可用。
|
||||||
- 完整 AssetBundle 对象级解析(UnityFS 解包、object table、TypeTree node 元数据、TextAsset bytes、MonoBehaviour/ScriptableObject 基础 TypeTree 字段级解析已起步;复杂字段覆盖、发布级重打包和 Patch 发布统一仍未完成)。
|
- 完整 AssetBundle 对象级解析(UnityFS 解包、object table、TypeTree node 元数据、TextAsset bytes、MonoBehaviour/ScriptableObject 基础 TypeTree 字段级解析已起步;复杂字段覆盖、发布级重打包和 Patch 发布统一仍未完成)。
|
||||||
- 复杂 AssetBundle 重打包和真实翻译构建 worker;当前通用 Binary/JSON/Text Patch 基础已在 crate 层可用,发布链路仍只开放 UnityFS TextAsset patch 前置能力。
|
- 复杂 AssetBundle 重打包和真实翻译构建 worker;当前通用 Binary/JSON/Text Patch 基础已在 crate 层可用,localized 发布仅开放已验证 TextUnit 对应的 UnityFS 文本字段。
|
||||||
- Translation Memory、Glossary、AI Provider。
|
- Translation Memory、Glossary、AI Provider。
|
||||||
- SDK、Web 管理后台。
|
- SDK、Web 管理后台。
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ BAT_API_SKIP_ENV_FILE=1 go run ./cmd/bat-api \
|
|||||||
| `GET /admin/schedules?id=...&group=...&enabled=...` | 读取/过滤 Rust `bat` 调度计划;需要管理 token |
|
| `GET /admin/schedules?id=...&group=...&enabled=...` | 读取/过滤 Rust `bat` 调度计划;需要管理 token |
|
||||||
| `GET /admin/translation/tasks?limit=100&worker_status=failed` | 读取/过滤 Rust 翻译任务和 provider worker 状态;需要管理 token |
|
| `GET /admin/translation/tasks?limit=100&worker_status=failed` | 读取/过滤 Rust 翻译任务和 provider worker 状态;需要管理 token |
|
||||||
| `GET /admin/translation/handoff` | 读取当前 release 的完整翻译交接视图;需要管理 token |
|
| `GET /admin/translation/handoff` | 读取当前 release 的完整翻译交接视图;需要管理 token |
|
||||||
|
| `GET /admin/translation/status` | 读取当前汉化 release、current 指针和 workflow 状态;需要管理 token |
|
||||||
| `POST /admin/control/{action}` | 经白名单转发 Rust `bat` 控制请求;见下文 |
|
| `POST /admin/control/{action}` | 经白名单转发 Rust `bat` 控制请求;见下文 |
|
||||||
|
|
||||||
launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` snapshot 中的 `launcher_metadata` 和 `game_main_config_bootstrap`,显式标记 `scope=resource_bootstrap_only` / `package_update_manifest=false`。`bat-api` 不下载 launcher 包,不生成官方 PC package update manifest,也不仿造登录、账号、网关、鉴权或游戏业务协议。
|
launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` snapshot 中的 `launcher_metadata` 和 `game_main_config_bootstrap`,显式标记 `scope=resource_bootstrap_only` / `package_update_manifest=false`。`bat-api` 不下载 launcher 包,不生成官方 PC package update manifest,也不仿造登录、账号、网关、鉴权或游戏业务协议。
|
||||||
@@ -172,6 +173,8 @@ launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` sn
|
|||||||
| `translation-task-update` | `translation.task.update` | `{ "task_id": "...", "status": "...", "failure_reason": "...", "provider_run_id": "..." }` | `202` + 当前任务记录 |
|
| `translation-task-update` | `translation.task.update` | `{ "task_id": "...", "status": "...", "failure_reason": "...", "provider_run_id": "..." }` | `202` + 当前任务记录 |
|
||||||
| `translation-worker-run` | `translation.worker.run` | `{ "provider": "mock", "concurrency": 8, "max_tasks": 2 }` | `202` + worker task |
|
| `translation-worker-run` | `translation.worker.run` | `{ "provider": "mock", "concurrency": 8, "max_tasks": 2 }` | `202` + worker task |
|
||||||
| `translation-proofread` | `translation.proofread` | 无 | `202` + 汉化状态 |
|
| `translation-proofread` | `translation.proofread` | 无 | `202` + 汉化状态 |
|
||||||
|
| `localized-publish` | `localized.publish` | `{ "translation_file": "...", "localized_release_id": "..." }` 或 `{ "from_worker": true, "localized_release_id": "..." }` | `202` + localized release manifest |
|
||||||
|
| `localized-rollback` | `localized.rollback` | 可选 `{ "localized_release_id": "..." }` | `202` + rollback report |
|
||||||
|
|
||||||
`stop`、`clean-stable`、patch 和 UnityFS 写入命令不会经 HTTP 暴露。
|
`stop`、`clean-stable`、patch 和 UnityFS 写入命令不会经 HTTP 暴露。
|
||||||
|
|
||||||
|
|||||||
@@ -206,6 +206,16 @@ paths:
|
|||||||
description: Missing or invalid admin token.
|
description: Missing or invalid admin token.
|
||||||
"503":
|
"503":
|
||||||
description: Rust bat translation backend is unavailable.
|
description: Rust bat translation backend is unavailable.
|
||||||
|
/admin/translation/status:
|
||||||
|
get:
|
||||||
|
summary: Read Rust-owned localized release status
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Current localized status JSON report from Rust bat.
|
||||||
|
"401":
|
||||||
|
description: Missing or invalid admin token.
|
||||||
|
"503":
|
||||||
|
description: Rust bat localized backend is unavailable.
|
||||||
/admin/control/{action}:
|
/admin/control/{action}:
|
||||||
post:
|
post:
|
||||||
summary: Forward an allowlisted control or schedule action to Rust bat
|
summary: Forward an allowlisted control or schedule action to Rust bat
|
||||||
@@ -215,7 +225,7 @@ paths:
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, translation-task-update, translation-worker-run, translation-proofread]
|
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, translation-task-update, translation-worker-run, translation-proofread, localized-publish, localized-rollback]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: false
|
required: false
|
||||||
content:
|
content:
|
||||||
@@ -294,6 +304,12 @@ paths:
|
|||||||
minimum: 1
|
minimum: 1
|
||||||
worker_id:
|
worker_id:
|
||||||
type: string
|
type: string
|
||||||
|
translation_file:
|
||||||
|
type: string
|
||||||
|
from_worker:
|
||||||
|
type: boolean
|
||||||
|
localized_release_id:
|
||||||
|
type: string
|
||||||
responses:
|
responses:
|
||||||
"202":
|
"202":
|
||||||
description: Rust bat accepted the control request.
|
description: Rust bat accepted the control request.
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
| UnityFS container | `.bundle`、zip 内 bundle | header、block、directory、解压文件、基础摘要 | 已支持基础解包、LZ4/LZMA、alignment、大小/计数/路径/边界校验 |
|
| UnityFS container | `.bundle`、zip 内 bundle | header、block、directory、解压文件、基础摘要 | 已支持基础解包、LZ4/LZMA、alignment、大小/计数/路径/边界校验 |
|
||||||
| Serialized file | UnityFS directory 文件 | header、type table、TypeTree node、object table、TextAsset bytes | 已支持基础表结构和 TextAsset bytes |
|
| Serialized file | UnityFS directory 文件 | header、type table、TypeTree node、object table、TextAsset bytes | 已支持基础表结构和 TextAsset bytes |
|
||||||
| Unity 对象字段 | TextAsset、MonoBehaviour、ScriptableObject | 可翻译文本单元、上下文、资源定位 | TypeTree 基础字段读取、`SerializedReference` / prefixed managed-reference metadata alias、payload 提取和字符串提取已落地,真实结构覆盖继续扩大 |
|
| Unity 对象字段 | TextAsset、MonoBehaviour、ScriptableObject | 可翻译文本单元、上下文、资源定位 | TypeTree 基础字段读取、`SerializedReference` / prefixed managed-reference metadata alias、payload 提取和字符串提取已落地,真实结构覆盖继续扩大 |
|
||||||
| Patch 发布前解析 | 已翻译文本、中间格式、原版资源 | 可验证 patch manifest、汉化 release 目录 | UnityFS TextAsset 前置已落地,通用 Binary/JSON/Text Patch 与文件级 patch / UnityFS 写入入口可用,发布级 build/rollback 未开放 |
|
| Patch 发布 | 已翻译 TextUnit、中间格式、原版资源 | 可验证 localized patch manifest、汉化 release 目录、current/state | TextAsset、TypeTree string field 和 managed-reference string field 的 localized publish/rollback 已落地;整体 AssetBundle 重打包与通用 manifest 发布仍冻结 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -163,16 +163,16 @@ catalog 仍按“明确不支持”处理,不把低保真路径伪装成完整
|
|||||||
2. 解析缓存、资源变更集和 repository 数据能从同一 manifest fingerprint 追溯。
|
2. 解析缓存、资源变更集和 repository 数据能从同一 manifest fingerprint 追溯。
|
||||||
3. CAS 对象跨版本复用,不重复存储相同文件。
|
3. CAS 对象跨版本复用,不重复存储相同文件。
|
||||||
|
|
||||||
### P5:Patch 发布前置解析
|
### P5:Patch 发布
|
||||||
|
|
||||||
目标:让解析结果成为可生成汉化 patch 的输入。
|
目标:让解析结果成为可生成、校验和回滚汉化 patch 的输入。
|
||||||
|
|
||||||
交付:
|
交付:
|
||||||
|
|
||||||
1. 已定义 `localized-patch-manifest.json`:目标官方版本、localized release、输出文件、hash、size、byte delta、TextAsset 操作和回滚信息。
|
1. 已定义 `localized-patch-manifest.json`:目标官方版本、localized release、输出文件、hash、size、byte delta、TextUnit/provider/review trace 和回滚信息。
|
||||||
2. 已支持 UnityFS TextAsset raw bytes 替换的最小 patch 路径。
|
2. 已支持 UnityFS TextAsset、TypeTree string field 和 managed-reference string field 的 localized patch 操作。
|
||||||
3. MonoBehaviour/ScriptableObject 字段替换必须依赖 P2 字段级解析结果。
|
3. MonoBehaviour/ScriptableObject 字段替换必须依赖 P2 字段级解析结果。
|
||||||
4. Patch 产物写入配置化汉化发布根下的 `.staging/<id>`,校验通过后发布到 `versions/<id>` 并切换 `current`。
|
4. Patch 产物写入配置化汉化发布根下的 `.staging/<id>`,校验通过后发布到 `versions/<id>` 并切换 `current`;rollback 按 manifest 恢复上一 release。
|
||||||
5. 成功后发布状态从 `not_localized` 切到 `localized`;`localized.status` 要求 state、current symlink 和 patch manifest 同时匹配当前官方 release。
|
5. 成功后发布状态从 `not_localized` 切到 `localized`;`localized.status` 要求 state、current symlink 和 patch manifest 同时匹配当前官方 release。
|
||||||
|
|
||||||
验收:
|
验收:
|
||||||
@@ -208,6 +208,6 @@ catalog 仍按“明确不支持”处理,不把低保真路径伪装成完整
|
|||||||
|
|
||||||
1. 完成 Addressables Windows/Android 当前版本 catalog 样本集合,关闭 G-007 当前阶段。
|
1. 完成 Addressables Windows/Android 当前版本 catalog 样本集合,关闭 G-007 当前阶段。
|
||||||
2. 完成 TypeTree 字段 reader 和 MonoBehaviour/ScriptableObject 遍历,推进 G-005。
|
2. 完成 TypeTree 字段 reader 和 MonoBehaviour/ScriptableObject 遍历,推进 G-005。
|
||||||
3. 基于 `translation.worker.run` 推进翻译记忆和 Patch 构建。
|
3. 基于 `translation.worker.run` 推进翻译记忆和通用 manifest Patch 构建。
|
||||||
4. 扩展翻译任务结果在 CAS/ResourceRepository 查询面的索引,推进 G-011。
|
4. 扩展翻译任务结果在 CAS/ResourceRepository 查询面的索引,推进 G-011。
|
||||||
5. 在通用 Binary/JSON/Text Patch 基础上继续扩展复杂 AssetBundle 重打包和发布流程统一,保留当前 UnityFS TextAsset patch 发布前置链路。
|
5. 在通用 Binary/JSON/Text Patch 基础上继续扩展复杂 AssetBundle 重打包和通用 Patch 发布流程统一,保留当前受支持 localized patch 发布/rollback 链路。
|
||||||
|
|||||||
@@ -92,9 +92,9 @@ CAS V1 和 Rust 官方同步闭环完成后,下一阶段优先推进:
|
|||||||
|
|
||||||
1. 继续联调 Go `bat-api` 与 Rust daemon 的资源分发路径;Go 同步 CLI 不再作为产品目标。
|
1. 继续联调 Go `bat-api` 与 Rust daemon 的资源分发路径;Go 同步 CLI 不再作为产品目标。
|
||||||
2. 按 `docs/guides/official-full-pull-smoke.md` 在隔离目录执行真实官方网络全量下载 smoke,并保留运行报告。
|
2. 按 `docs/guides/official-full-pull-smoke.md` 在隔离目录执行真实官方网络全量下载 smoke,并保留运行报告。
|
||||||
3. 基于 `translation.worker.run` 推进翻译记忆和 Patch 构建。
|
3. 基于 `translation.worker.run` 推进翻译记忆和通用 manifest Patch 构建。
|
||||||
4. 扩展 ResourceRepository 查询面:CAS 诊断入口、面向大索引的查询优化和更丰富 TextUnit 查询。
|
4. 扩展 ResourceRepository 查询面:CAS 诊断入口、面向大索引的查询优化和更丰富 TextUnit 查询。
|
||||||
5. 继续完善 AssetBundle 复杂对象解析、复杂对象重打包和 Patch 发布流程统一;通用 Binary/JSON/Text Patch 基础与 UnityFS TextAsset patch 发布前置链路已可用。
|
5. 继续完善 AssetBundle 复杂对象解析、复杂对象重打包和通用 Patch 发布流程统一;通用 Binary/JSON/Text Patch 基础与受支持 localized patch 发布/rollback 链路已可用。
|
||||||
|
|
||||||
优先阅读:
|
优先阅读:
|
||||||
|
|
||||||
|
|||||||
@@ -119,8 +119,8 @@ bat i18n validate \
|
|||||||
--translation-file /tmp/bat-workbench.json
|
--translation-file /tmp/bat-workbench.json
|
||||||
```
|
```
|
||||||
|
|
||||||
报告会区分未审核、原文未变化、可直接 `i18n publish` 的 TextAsset,以及必须使用
|
报告会区分未审核、原文未变化、可直接 `i18n publish` 的受支持 TextAsset/
|
||||||
`parse repack` 的 TypeTree/嵌套 archive 条目。
|
TypeTree string field 条目,以及需要先经过独立 repack 流程的 ZIP 内或其他不支持条目。
|
||||||
|
|
||||||
发布汉化 release:
|
发布汉化 release:
|
||||||
|
|
||||||
@@ -131,7 +131,12 @@ bat i18n publish \
|
|||||||
--translation-file /tmp/bat-workbench.json
|
--translation-file /tmp/bat-workbench.json
|
||||||
```
|
```
|
||||||
|
|
||||||
发布只接受当前实现支持的直接 TextAsset 条目;TypeTree 字段和 zip 内 bundle 使用 `parse repack` 的 spec 单独处理。`--force` 不覆盖已有目录,而是生成独立的 `<official-release>-manual-<unix-seconds>` 汉化 release ID;也可以用 `--localized-release-id` 显式指定新 ID。因此强制发布仍保留旧 release 和 rollback 信息。
|
发布接受当前实现支持的直接 TextAsset、TypeTree string field 和 managed-reference
|
||||||
|
string field 条目;zip 内 bundle 和其他不支持条目使用 `parse repack` 的 spec
|
||||||
|
单独处理。`--force` 不覆盖已有目录,而是生成独立的
|
||||||
|
`<official-release>-manual-<unix-seconds>` 汉化 release ID;也可以用
|
||||||
|
`--localized-release-id` 显式指定新 ID。因此强制发布仍保留旧 release 和 rollback
|
||||||
|
信息。
|
||||||
|
|
||||||
当前 `i18n run` 是离线工作流:刷新 TextUnit 队列,并可用 `--translation-file` 导出工作台;真实 provider 由单独的 worker 命令消费 `translation-tasks.sqlite`。
|
当前 `i18n run` 是离线工作流:刷新 TextUnit 队列,并可用 `--translation-file` 导出工作台;真实 provider 由单独的 worker 命令消费 `translation-tasks.sqlite`。
|
||||||
|
|
||||||
@@ -266,6 +271,45 @@ Rust `translation.tasks` / `translation.handoff`,不在 Go 侧维护状态。
|
|||||||
`POST /admin/control/translation-proofread` 会把当前汉化 workflow 标记为人工校对中;
|
`POST /admin/control/translation-proofread` 会把当前汉化 workflow 标记为人工校对中;
|
||||||
该接口只转发 `translation.proofread`,不会改动已发布汉化 release 指针。
|
该接口只转发 `translation.proofread`,不会改动已发布汉化 release 指针。
|
||||||
|
|
||||||
|
## localized patch 发布与回滚
|
||||||
|
|
||||||
|
`i18n publish` 会在独立的 `.staging/<localized-release-id>` 中复制当前官方
|
||||||
|
release,校验工作台与当前 TextUnit 索引的 source/location 一致后,写入已有支持
|
||||||
|
范围内的 TextAsset、TypeTree string field 和 managed-reference string field
|
||||||
|
patch。校验通过后才原子切换 `localized/current`,并在 release manifest 中记录
|
||||||
|
源/目标 BLAKE3、字节数、patch kind、TextUnit、provider、review 和 rollback
|
||||||
|
信息。ZIP 内 bundle 不会被静默改写。
|
||||||
|
|
||||||
|
使用人工编辑的工作台发布:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bat i18n publish \
|
||||||
|
--translation-file /tmp/bat-workbench.json \
|
||||||
|
--localized-release-id release-manual-1
|
||||||
|
```
|
||||||
|
|
||||||
|
使用已完成 provider worker 的译文结果发布:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bat i18n publish \
|
||||||
|
--from-worker \
|
||||||
|
--localized-release-id release-worker-1
|
||||||
|
```
|
||||||
|
|
||||||
|
发布失败会清理 staging,不切换 `current`。当前 release 的 rollback 目标由
|
||||||
|
manifest 记录,执行后删除本次版本目录并恢复上一版本;没有上一版本时移除
|
||||||
|
`current`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bat i18n rollback --localized-release-id release-worker-1
|
||||||
|
```
|
||||||
|
|
||||||
|
Rust RPC 方法为 `localized.publish` 和 `localized.rollback`;bat-api 对应为
|
||||||
|
`POST /admin/control/localized-publish`、`POST /admin/control/localized-rollback`
|
||||||
|
以及鉴权的 `GET /admin/translation/status`。发布请求必须且只能包含
|
||||||
|
`translation_file` 或 `from_worker=true`;rollback 可省略 release ID 以操作当前
|
||||||
|
release。Go 只做鉴权、参数校验和转发,状态与产物仍由 Rust 持有。
|
||||||
|
|
||||||
## 边界
|
## 边界
|
||||||
|
|
||||||
解析器新增类型覆盖和新的解析格式仍受 `docs/reports/PARSER_FREEZE.md` 约束。本次 issue 43 的例外只开放已有解析输出的手动编排、缓存刷新、工作台编辑、既有 patch 实现的重打包和独立汉化发布,不扩展 UnityFS/AssetBundle/Addressables/TypeTree 的解析类型覆盖。
|
解析器新增类型覆盖和新的解析格式仍受 `docs/reports/PARSER_FREEZE.md` 约束。本次 issue 43 的例外只开放已有解析输出的手动编排、缓存刷新、工作台编辑、既有 patch 实现的重打包和独立汉化发布,不扩展 UnityFS/AssetBundle/Addressables/TypeTree 的解析类型覆盖。
|
||||||
|
|||||||
@@ -292,6 +292,8 @@ token 不会进入报告、任务记录或调试输出。
|
|||||||
| 方法 | 状态 | params | data |
|
| 方法 | 状态 | params | data |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `localized.status` | 已实现 | `null` | 汉化发布状态、当前官方 release 匹配关系和汉化输出目录。 |
|
| `localized.status` | 已实现 | `null` | 汉化发布状态、当前官方 release 匹配关系和汉化输出目录。 |
|
||||||
|
| `localized.publish` | 已实现 | `{ "translation_file": "...", "localized_release_id": "...", "force": false }` 或 `{ "from_worker": true, "localized_release_id": "...", "force": false }` | 已校验并发布的汉化 release、manifest 和完整性报告。 |
|
||||||
|
| `localized.rollback` | 已实现 | `{ "localized_release_id": "..." }`,可省略 | 删除当前 release、恢复 manifest 记录的上一 release 和新状态。 |
|
||||||
|
|
||||||
`localized.status` 严格按 daemon / `.env` 中的 `BAT_LOCALIZED_OUTPUT` 或
|
`localized.status` 严格按 daemon / `.env` 中的 `BAT_LOCALIZED_OUTPUT` 或
|
||||||
`--localized-output` 查询汉化产物目录,不把 `./bat-resources` 与
|
`--localized-output` 查询汉化产物目录,不把 `./bat-resources` 与
|
||||||
@@ -392,7 +394,7 @@ daemon 重启后仍处于 `queued` 或 `running` 的历史任务会被标记为
|
|||||||
`data` 会返回 source / patch 或 replacement / target 的 size 与 BLAKE3。`target_path`
|
`data` 会返回 source / patch 或 replacement / target 的 size 与 BLAKE3。`target_path`
|
||||||
不能与输入文件相同。
|
不能与输入文件相同。
|
||||||
|
|
||||||
仍关闭的范围:发布级 `patch build` / `patch rollback`、复杂 UnityFS 语义编辑、
|
仍关闭的范围:通用 manifest 驱动的发布级 `patch build` / `patch rollback`、复杂 UnityFS 语义编辑、
|
||||||
`unityfs.inspect`、通用 manifest 驱动 release 切换。调用这些规划方法仍返回
|
`unityfs.inspect`、通用 manifest 驱动 release 切换。调用这些规划方法仍返回
|
||||||
`BAT-ERR-700003`。
|
`BAT-ERR-700003`。
|
||||||
|
|
||||||
@@ -452,8 +454,9 @@ CLI 对应关系:
|
|||||||
- typed helper 已覆盖 daemon 已实现方法(`status/logs/stop/restart/reload/refresh/doctor`)、
|
- typed helper 已覆盖 daemon 已实现方法(`status/logs/stop/restart/reload/refresh/doctor`)、
|
||||||
`resource.state/sync/verify/repair/manifest/list`、`schedule.list/add/update/remove/run`、
|
`resource.state/sync/verify/repair/manifest/list`、`schedule.list/add/update/remove/run`、
|
||||||
`catalog.*`、`parse.*`、
|
`catalog.*`、`parse.*`、
|
||||||
`localized.status`、`translation.tasks`、`translation.handoff`、
|
`localized.status`、`localized.publish`、`localized.rollback`、
|
||||||
`translation.task.update`、`translation.worker.run`、`translation.proofread`、
|
`translation.tasks`、`translation.handoff`、`translation.task.update`、
|
||||||
|
`translation.worker.run`、`translation.proofread`、
|
||||||
`task.*` 和三个 `unityfs.patch_*` 方法。
|
`task.*` 和三个 `unityfs.patch_*` 方法。
|
||||||
- `resource.index` 和 `patch.apply` 当前没有专用 typed helper;需要直接使用 `Call`,并仍须遵守
|
- `resource.index` 和 `patch.apply` 当前没有专用 typed helper;需要直接使用 `Call`,并仍须遵守
|
||||||
本契约的参数和响应定义。
|
本契约的参数和响应定义。
|
||||||
@@ -466,6 +469,7 @@ CLI 对应关系:
|
|||||||
| `ControlBackend` | `daemon.restart`、`daemon.reload`、`daemon.refresh`、`resource.sync`、`resource.verify`、`resource.repair`、`catalog.refresh` | 鉴权后的管理控制白名单 |
|
| `ControlBackend` | `daemon.restart`、`daemon.reload`、`daemon.refresh`、`resource.sync`、`resource.verify`、`resource.repair`、`catalog.refresh` | 鉴权后的管理控制白名单 |
|
||||||
| `ScheduleBackend` | `schedule.list`、`schedule.add`、`schedule.update`、`schedule.remove`、`schedule.run` | 鉴权后的 dashboard 调度计划控制 |
|
| `ScheduleBackend` | `schedule.list`、`schedule.add`、`schedule.update`、`schedule.remove`、`schedule.run` | 鉴权后的 dashboard 调度计划控制 |
|
||||||
| `TranslationBackend` | `translation.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread` | 鉴权后的 dashboard 翻译任务查询、交接视图、状态回写、provider worker 触发与人工校对标记 |
|
| `TranslationBackend` | `translation.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread` | 鉴权后的 dashboard 翻译任务查询、交接视图、状态回写、provider worker 触发与人工校对标记 |
|
||||||
|
| `LocalizedBackend` | `localized.status`、`localized.publish`、`localized.rollback` | 鉴权后的汉化 release 状态、发布与显式回滚 |
|
||||||
|
|
||||||
`daemon.stop`、`daemon.clean-stable` 和任意通用 RPC 不属于 bat-api 管理控制面。
|
`daemon.stop`、`daemon.clean-stable` 和任意通用 RPC 不属于 bat-api 管理控制面。
|
||||||
Rust dispatch、Go transport 和 bat-api 接口的权威实现位置分别是
|
Rust dispatch、Go transport 和 bat-api 接口的权威实现位置分别是
|
||||||
|
|||||||
@@ -123,7 +123,7 @@
|
|||||||
- `ResourceImportService` 的 UnityFS 摘要已经能暴露解包文件数、serialized file 数、TextAsset 名称、TextUnit 数量/格式和字段诊断。
|
- `ResourceImportService` 的 UnityFS 摘要已经能暴露解包文件数、serialized file 数、TextAsset 名称、TextUnit 数量/格式和字段诊断。
|
||||||
- `MonoBehaviour`、`ScriptableObject` 已有基础 TypeTree 字段级反序列化和字符串提取入口;array/vector/staticvector/`List<T>`/`HashSet<T>`/map 元素与 TypeTree-covered managed reference registry payload 已保留独立 field path、offset 和 byte size,enum `value__` backing field 会暴露为语义化 `{type_name, storage_type, value}`,`LayerMask` / `BitField` 的 `m_Bits` backing field 会暴露为语义化 `{type_name, storage_type, bits}`,managed-reference full typename 可拆为 assembly/namespace/class,常见 `m_ManagedReferences` / `RefIds` / `m_RefIds` / verbose type 字段命名、`managedReference*` / `serializedReference*` prefixed metadata、`SerializedReference` 节点 alias 和 `data` / `value` / `payload` / `object` / `managedReferencePayload` / `referencePayload` / `serializedReferencePayload` / `managedReferenceValue` / `referenceValue` / `serializedReferenceValue` / `managedReferenceObject` / `referenceObject` / `serializedReferenceObject` / `managedReferenceData` / `referenceData` / `serializedData` / `serializedReferenceData` payload 命名已有回归覆盖,多记录 registry 聚合已有单元回归,TextUnit 提取会跳过 registry 元数据字符串并把它们作为 payload 文本上下文,fallback 字段遍历也会跳过常见 managed-reference 元数据别名,并按 `RefIds[n]` 等记录前缀或子字段推导 metadata 写入 payload TextUnit context;当前可对 string、bool、integer、float raw bits、bytes、enum、bit_field、常见固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、unknown fixed-size raw bytes 同长度替换、PPtr、object 字段组合和 TypeTree schema 支撑的 array/vector/List/HashSet/map 整体替换执行文件级 patch,map entry 的 `first/second` 与 `key/value` 字段命名已有 serialized 和 UnityFS 重建回归,ScriptableObject `key/value` map 解析、变长替换和 UnityFS 重建已有专门回归;真实版本差异、未见样本驱动的复杂 managed reference registry / map entry 变体、unknown 字段结构语义和发布级重打包入口仍未完成。
|
- `MonoBehaviour`、`ScriptableObject` 已有基础 TypeTree 字段级反序列化和字符串提取入口;array/vector/staticvector/`List<T>`/`HashSet<T>`/map 元素与 TypeTree-covered managed reference registry payload 已保留独立 field path、offset 和 byte size,enum `value__` backing field 会暴露为语义化 `{type_name, storage_type, value}`,`LayerMask` / `BitField` 的 `m_Bits` backing field 会暴露为语义化 `{type_name, storage_type, bits}`,managed-reference full typename 可拆为 assembly/namespace/class,常见 `m_ManagedReferences` / `RefIds` / `m_RefIds` / verbose type 字段命名、`managedReference*` / `serializedReference*` prefixed metadata、`SerializedReference` 节点 alias 和 `data` / `value` / `payload` / `object` / `managedReferencePayload` / `referencePayload` / `serializedReferencePayload` / `managedReferenceValue` / `referenceValue` / `serializedReferenceValue` / `managedReferenceObject` / `referenceObject` / `serializedReferenceObject` / `managedReferenceData` / `referenceData` / `serializedData` / `serializedReferenceData` payload 命名已有回归覆盖,多记录 registry 聚合已有单元回归,TextUnit 提取会跳过 registry 元数据字符串并把它们作为 payload 文本上下文,fallback 字段遍历也会跳过常见 managed-reference 元数据别名,并按 `RefIds[n]` 等记录前缀或子字段推导 metadata 写入 payload TextUnit context;当前可对 string、bool、integer、float raw bits、bytes、enum、bit_field、常见固定 Unity float/int/hash 值类型的 leaf/direct-child 形态、unknown fixed-size raw bytes 同长度替换、PPtr、object 字段组合和 TypeTree schema 支撑的 array/vector/List/HashSet/map 整体替换执行文件级 patch,map entry 的 `first/second` 与 `key/value` 字段命名已有 serialized 和 UnityFS 重建回归,ScriptableObject `key/value` map 解析、变长替换和 UnityFS 重建已有专门回归;真实版本差异、未见样本驱动的复杂 managed reference registry / map entry 变体、unknown 字段结构语义和发布级重打包入口仍未完成。
|
||||||
|
|
||||||
issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、TypeTree string 和受支持语义 field patch 实现;它不新增解析器类型、字段族或 catalog 覆盖。人工翻译工作台和有限 TextAsset 发布也只消费已有 TextUnit 输出。
|
issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、TypeTree string 和受支持语义 field patch 实现;它不新增解析器类型、字段族或 catalog 覆盖。人工翻译工作台和 localized publish 只消费已有 TextUnit 输出,当前 publish 支持 TextAsset、TypeTree string field 和 managed-reference string field。
|
||||||
|
|
||||||
影响:
|
影响:
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、Typ
|
|||||||
|
|
||||||
- 见 `docs/architecture/assetbundle.md` 的 P2/P3/P5。
|
- 见 `docs/architecture/assetbundle.md` 的 P2/P3/P5。
|
||||||
|
|
||||||
### G-006:Patch 引擎基础已落地,发布入口仍未完成
|
### G-006:Patch 引擎基础已落地,通用发布入口仍未完成
|
||||||
|
|
||||||
状态:**部分关闭**
|
状态:**部分关闭**
|
||||||
|
|
||||||
@@ -358,7 +358,7 @@ issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、Typ
|
|||||||
- `cargo test -p bat-infrastructure version_state`
|
- `cargo test -p bat-infrastructure version_state`
|
||||||
- `cargo test -p bat-infrastructure --test official_game_main_config_bootstrap`
|
- `cargo test -p bat-infrastructure --test official_game_main_config_bootstrap`
|
||||||
|
|
||||||
### G-011D:汉化发布状态与 Patch 发布流程未完成
|
### G-011D:受支持汉化发布已完成,通用 Patch 发布流程仍未完成
|
||||||
|
|
||||||
状态:**部分关闭**
|
状态:**部分关闭**
|
||||||
|
|
||||||
@@ -368,13 +368,14 @@ issue 43 例外说明:新增的 `parse repack` 只编排已有 TextAsset、Typ
|
|||||||
- CLI 支持 `--localized-output` / `BAT_LOCALIZED_OUTPUT`,并拒绝官方目录和汉化目录相同或互相嵌套。
|
- CLI 支持 `--localized-output` / `BAT_LOCALIZED_OUTPUT`,并拒绝官方目录和汉化目录相同或互相嵌套。
|
||||||
- 官方同步报告新增 `localized_release_status=not_localized`,明确表示原版资源已发布、汉化资源未发布;`translation.proofread` 只写 workflow 标记,不会把 `localized_release_status` 从 `localized` 回退成 `not_localized`。
|
- 官方同步报告新增 `localized_release_status=not_localized`,明确表示原版资源已发布、汉化资源未发布;`translation.proofread` 只写 workflow 标记,不会把 `localized_release_status` 从 `localized` 回退成 `not_localized`。
|
||||||
- `LocalizedPatchService` 已具备将给定汉化文件按官方相对路径发布到 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 指定目录下的 `.staging/<id>`、校验后移动到 `versions/<id>`、原子切换 `current` 并写入 `localized-version-state.json` 的基础能力。
|
- `LocalizedPatchService` 已具备将给定汉化文件按官方相对路径发布到 `--localized-output` / `BAT_LOCALIZED_OUTPUT` 指定目录下的 `.staging/<id>`、校验后移动到 `versions/<id>`、原子切换 `current` 并写入 `localized-version-state.json` 的基础能力。
|
||||||
- `LocalizedPatchService` 已写入结构化 `localized-patch-manifest.json`,记录 TextAsset 操作、原始/汉化 hash、size、byte delta 和 rollback 信息;发布前后会校验 manifest hash/size 与 current symlink,失败时清理 staging / 未完成 version。
|
- `LocalizedPatchService` 已写入结构化 `localized-patch-manifest.json`,记录 TextAsset、TypeTree string field 和 managed-reference string field 操作、原始/汉化 hash、size、byte delta、TextUnit/provider/review 和 rollback 信息;发布前后会校验 manifest hash/size 与 current symlink,失败时清理 staging / 未完成 version。
|
||||||
|
- 已完成从 workbench 或 completed provider worker 结果生成受支持 patch operation 的流程;`localized.publish` / `localized.rollback` RPC、`i18n publish` / `i18n rollback` CLI,以及 bat-api 的鉴权 status/publish/rollback 接口已落地。
|
||||||
- `localized.status` RPC 会读取 `.env` / daemon 配置中的汉化输出目录,校验汉化状态是否匹配当前官方 release,且要求 patch manifest 存在并匹配 release,避免写死 `./bat-localized` 或误报手工状态;其中 `status` / `status_code` 返回生命周期状态,`localized_release_status` 保留 `localized` / `not_localized` 发布标签,`translation_workflow_status=manual_proofreading` 仅描述人工校对流程,不影响已发布汉化资源继续正常发布。
|
- `localized.status` RPC 会读取 `.env` / daemon 配置中的汉化输出目录,校验汉化状态是否匹配当前官方 release,且要求 patch manifest 存在并匹配 release,避免写死 `./bat-localized` 或误报手工状态;其中 `status` / `status_code` 返回生命周期状态,`localized_release_status` 保留 `localized` / `not_localized` 发布标签,`translation_workflow_status=manual_proofreading` 仅描述人工校对流程,不影响已发布汉化资源继续正常发布。
|
||||||
|
|
||||||
仍未完成:
|
仍未完成:
|
||||||
|
|
||||||
- 真实 Patch/翻译构建阶段尚未从 `crowdin-textunit-queue.json`、翻译记忆和 Crowdin 结果生成完整汉化文件集合。
|
- 真实 Patch/翻译构建阶段尚未从 `crowdin-textunit-queue.json`、翻译记忆和 Crowdin 结果生成完整汉化文件集合。
|
||||||
- 通用 Binary/JSON/Text Patch crate 基础和文件级 `patch.apply` / UnityFS 写入入口已经实现;复杂 AssetBundle 重打包、发布级 patch build/rollback 和与汉化发布流程的统一仍未完成。
|
- 通用 Binary/JSON/Text Patch crate 基础和文件级 `patch.apply` / UnityFS 写入入口已经实现;通用 manifest 驱动的跨类型发布、复杂 AssetBundle 重打包和双发布后的清理策略仍未完成。
|
||||||
- 尚未实现原版资源与汉化资源双发布后的查询、分发和清理策略。
|
- 尚未实现原版资源与汉化资源双发布后的查询、分发和清理策略。
|
||||||
|
|
||||||
验收:
|
验收:
|
||||||
|
|||||||
@@ -95,7 +95,7 @@
|
|||||||
| 组件 | 路径 | 状态 | 说明 |
|
| 组件 | 路径 | 状态 | 说明 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Module | `go.mod` → `bat-api` | 已用 | 服务层模块名 |
|
| Module | `go.mod` → `bat-api` | 已用 | 服务层模块名 |
|
||||||
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helper;typed helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list`、`catalog.*`、`parse.*`、`localized.status`、`task.*`、`translation.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread` 和文件级 UnityFS patch 调用;`resource.index`、`patch.apply` 仍通过通用 `Call` 走同一 contract;fake transport 单测,配合 `internal/api/testdata/contract/` 固化 Rust 输出 mirror |
|
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helper;typed helper 覆盖 daemon 已实现控制/查询、`resource.state/sync/verify/repair/manifest/list`、`catalog.*`、`parse.*`、`localized.status/publish/rollback`、`task.*`、`translation.tasks`、`translation.handoff`、`translation.task.update`、`translation.worker.run`、`translation.proofread` 和文件级 UnityFS patch 调用;`resource.index`、`patch.apply` 仍通过通用 `Call` 走同一 contract;fake transport 单测,配合 `internal/api/testdata/contract/` 固化 Rust 输出 mirror |
|
||||||
| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + `.env` |
|
| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + `.env` |
|
||||||
| 试验 CLI | `cmd/bat` | **试验** | doctor 固定 ok;manifest/sync 走 FFI |
|
| 试验 CLI | `cmd/bat` | **试验** | doctor 固定 ok;manifest/sync 走 FFI |
|
||||||
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
|
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
|
||||||
|
|||||||
@@ -7,24 +7,25 @@ use bat_core::{ApiError, ErrorCode};
|
|||||||
use bat_infrastructure::DEFAULT_DOWNLOAD_CONCURRENCY;
|
use bat_infrastructure::DEFAULT_DOWNLOAD_CONCURRENCY;
|
||||||
use bat_infrastructure::{
|
use bat_infrastructure::{
|
||||||
apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file,
|
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,
|
apply_unityfs_text_asset_patch_file, changed_endpoint_urls,
|
||||||
export_translation_workbench, gc_orphan_staging, get_translation_entry, lexical_absolute,
|
completed_worker_translation_workbench, diff_extended_snapshot, export_translation_workbench,
|
||||||
localized_text_asset_patches, open_append_file, read_download_manifest_at,
|
gc_orphan_staging, get_translation_entry, lexical_absolute, localized_patch_operations,
|
||||||
read_file_no_symlink, read_localized_patch_manifest_at, read_localized_version_state,
|
open_append_file, read_download_manifest_at, read_file_no_symlink,
|
||||||
read_parse_cache_at, read_snapshot, read_textunit_index_at, read_translation_workbench,
|
read_localized_patch_manifest_at, read_localized_version_state, read_parse_cache_at,
|
||||||
read_version_state, redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation,
|
read_snapshot, read_textunit_index_at, read_translation_workbench, read_version_state,
|
||||||
unset_translation, validate_output_root, validate_runtime_state_dir,
|
redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation, unset_translation,
|
||||||
validate_translation_workbench, write_file_atomic, write_official_textunit_queues,
|
validate_output_root, validate_runtime_state_dir, validate_translation_workbench,
|
||||||
CurlProxyConfig, CurlProxyMode, LocalizedPatchConfig, LocalizedPatchReport,
|
write_file_atomic, write_official_textunit_queues, CurlProxyConfig, CurlProxyMode,
|
||||||
LocalizedPatchService, OfficialEndpointMarkerRole, OfficialFailedVersionRecord,
|
LocalizedPatchConfig, LocalizedPatchReport, LocalizedPatchService, LocalizedRollbackReport,
|
||||||
OfficialParseCacheService, OfficialParseConfig, OfficialResourceHashVerification,
|
OfficialEndpointMarkerRole, OfficialFailedVersionRecord, OfficialParseCacheService,
|
||||||
OfficialResourceVerification, OfficialServerInfoSource, OfficialTextUnitQuery,
|
OfficialParseConfig, OfficialResourceHashVerification, OfficialResourceVerification,
|
||||||
OfficialTextUnitTaskQuery, OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport,
|
OfficialServerInfoSource, OfficialTextUnitQuery, OfficialTextUnitTaskQuery,
|
||||||
OfficialUpdateService, OfficialUpdateSnapshot, OfficialUpdateStatus,
|
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
|
||||||
OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, PatchApplyKind,
|
OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary,
|
||||||
PatchApplyParams, PatchApplyReport, ReleaseFlowStatusCode, RepackReport,
|
OfficialVersionRecord, OfficialVersionState, PatchApplyKind, PatchApplyParams,
|
||||||
SqliteResourceRepository, SqliteTranslationTaskRepository, TranslationProviderKind,
|
PatchApplyReport, ReleaseFlowStatusCode, RepackReport, SqliteResourceRepository,
|
||||||
TranslationTaskStatus, TranslationWorkerConfig, UnityFsFieldPatchParams, UnityFsPatchReport,
|
SqliteTranslationTaskRepository, TranslationProviderKind, TranslationTaskStatus,
|
||||||
|
TranslationWorkerConfig, UnityFsFieldPatchParams, UnityFsPatchReport,
|
||||||
UnityFsStringFieldPatchParams, UnityFsTextAssetPatchParams, CROWDIN_TEXTUNIT_QUEUE_FILE,
|
UnityFsStringFieldPatchParams, UnityFsTextAssetPatchParams, CROWDIN_TEXTUNIT_QUEUE_FILE,
|
||||||
DEFAULT_TRANSLATION_CONCURRENCY, DEFAULT_TRANSLATION_LEASE_SECONDS,
|
DEFAULT_TRANSLATION_CONCURRENCY, DEFAULT_TRANSLATION_LEASE_SECONDS,
|
||||||
DEFAULT_TRANSLATION_MAX_ATTEMPTS, DEFAULT_TRANSLATION_RETRY_BACKOFF, LOCALIZED_CURRENT_LINK,
|
DEFAULT_TRANSLATION_MAX_ATTEMPTS, DEFAULT_TRANSLATION_RETRY_BACKOFF, LOCALIZED_CURRENT_LINK,
|
||||||
@@ -84,6 +85,7 @@ use translation_query::{
|
|||||||
update_translation_task_status_report,
|
update_translation_task_status_report,
|
||||||
};
|
};
|
||||||
use workflow_commands::{
|
use workflow_commands::{
|
||||||
|
localized_rollback_report, publish_localized_report, run_localized_rollback,
|
||||||
run_parse_clear_cache, run_parse_once, run_publish_localized, run_repack, run_translate_once,
|
run_parse_clear_cache, run_parse_once, run_publish_localized, run_repack, run_translate_once,
|
||||||
run_translation_get, run_translation_proofread, run_translation_set,
|
run_translation_get, run_translation_proofread, run_translation_set,
|
||||||
run_translation_task_update, run_translation_unset, run_translation_validate,
|
run_translation_task_update, run_translation_unset, run_translation_validate,
|
||||||
@@ -244,6 +246,10 @@ fn run() -> anyhow::Result<i32> {
|
|||||||
run_repeated_workflow(&options, "publish-localized", run_publish_localized)?;
|
run_repeated_workflow(&options, "publish-localized", run_publish_localized)?;
|
||||||
Ok(0)
|
Ok(0)
|
||||||
}
|
}
|
||||||
|
CliCommand::LocalizedRollback => {
|
||||||
|
run_localized_rollback(&options)?;
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
CliCommand::ScheduleList => {
|
CliCommand::ScheduleList => {
|
||||||
run_schedule_list(&options)?;
|
run_schedule_list(&options)?;
|
||||||
Ok(0)
|
Ok(0)
|
||||||
@@ -385,6 +391,7 @@ struct CliOptions {
|
|||||||
translation_text_file: Option<PathBuf>,
|
translation_text_file: Option<PathBuf>,
|
||||||
translation_failure_reason: Option<String>,
|
translation_failure_reason: Option<String>,
|
||||||
translation_provider_run_id: Option<String>,
|
translation_provider_run_id: Option<String>,
|
||||||
|
translation_from_worker: bool,
|
||||||
translation_provider: Option<String>,
|
translation_provider: Option<String>,
|
||||||
translation_fixture: Option<PathBuf>,
|
translation_fixture: Option<PathBuf>,
|
||||||
worker_concurrency: usize,
|
worker_concurrency: usize,
|
||||||
@@ -479,6 +486,7 @@ impl Default for CliOptions {
|
|||||||
translation_text_file: None,
|
translation_text_file: None,
|
||||||
translation_failure_reason: None,
|
translation_failure_reason: None,
|
||||||
translation_provider_run_id: None,
|
translation_provider_run_id: None,
|
||||||
|
translation_from_worker: false,
|
||||||
translation_provider: None,
|
translation_provider: None,
|
||||||
translation_fixture: None,
|
translation_fixture: None,
|
||||||
worker_concurrency: DEFAULT_TRANSLATION_CONCURRENCY,
|
worker_concurrency: DEFAULT_TRANSLATION_CONCURRENCY,
|
||||||
@@ -575,6 +583,7 @@ enum CliCommand {
|
|||||||
TranslationProofread,
|
TranslationProofread,
|
||||||
Repack,
|
Repack,
|
||||||
PublishLocalized,
|
PublishLocalized,
|
||||||
|
LocalizedRollback,
|
||||||
ScheduleList,
|
ScheduleList,
|
||||||
ScheduleAdd,
|
ScheduleAdd,
|
||||||
ScheduleUpdate,
|
ScheduleUpdate,
|
||||||
@@ -648,6 +657,7 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
|
|||||||
registry,
|
registry,
|
||||||
queue: task_tx,
|
queue: task_tx,
|
||||||
base_config: options.config.clone(),
|
base_config: options.config.clone(),
|
||||||
|
sync_lock: Arc::clone(&sync_lock),
|
||||||
restart_controller: spawn_daemon_restart_controller,
|
restart_controller: spawn_daemon_restart_controller,
|
||||||
};
|
};
|
||||||
let server =
|
let server =
|
||||||
@@ -1023,6 +1033,35 @@ struct DaemonRpcAck {
|
|||||||
force: Option<bool>,
|
force: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct LocalizedPublishRpcParams {
|
||||||
|
#[serde(default, alias = "workbench", alias = "workbench_path")]
|
||||||
|
translation_file: Option<PathBuf>,
|
||||||
|
#[serde(
|
||||||
|
default,
|
||||||
|
alias = "translation_from_worker",
|
||||||
|
alias = "from_worker_results"
|
||||||
|
)]
|
||||||
|
from_worker: bool,
|
||||||
|
#[serde(default, alias = "release_id")]
|
||||||
|
localized_release_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
force: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct LocalizedRollbackRpcParams {
|
||||||
|
#[serde(
|
||||||
|
default,
|
||||||
|
alias = "release_id",
|
||||||
|
alias = "expected_release_id",
|
||||||
|
alias = "expected_localized_release_id"
|
||||||
|
)]
|
||||||
|
localized_release_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
// 规范方法名采用国际惯例的 `<namespace>.<action>`。`bat.*` 保留为向后兼容别名。
|
// 规范方法名采用国际惯例的 `<namespace>.<action>`。`bat.*` 保留为向后兼容别名。
|
||||||
const RPC_METHOD_STATUS: &str = "daemon.status";
|
const RPC_METHOD_STATUS: &str = "daemon.status";
|
||||||
const RPC_METHOD_STOP: &str = "daemon.stop";
|
const RPC_METHOD_STOP: &str = "daemon.stop";
|
||||||
@@ -1053,6 +1092,8 @@ const RPC_METHOD_TRANSLATION_TASK_UPDATE: &str = "translation.task.update";
|
|||||||
const RPC_METHOD_TRANSLATION_PROOFREAD: &str = "translation.proofread";
|
const RPC_METHOD_TRANSLATION_PROOFREAD: &str = "translation.proofread";
|
||||||
const RPC_METHOD_TRANSLATION_WORKER_RUN: &str = "translation.worker.run";
|
const RPC_METHOD_TRANSLATION_WORKER_RUN: &str = "translation.worker.run";
|
||||||
const RPC_METHOD_LOCALIZED_STATUS: &str = "localized.status";
|
const RPC_METHOD_LOCALIZED_STATUS: &str = "localized.status";
|
||||||
|
const RPC_METHOD_LOCALIZED_PUBLISH: &str = "localized.publish";
|
||||||
|
const RPC_METHOD_LOCALIZED_ROLLBACK: &str = "localized.rollback";
|
||||||
const RPC_METHOD_CATALOG_STATUS: &str = "catalog.status";
|
const RPC_METHOD_CATALOG_STATUS: &str = "catalog.status";
|
||||||
const RPC_METHOD_CATALOG_VERSIONS: &str = "catalog.versions";
|
const RPC_METHOD_CATALOG_VERSIONS: &str = "catalog.versions";
|
||||||
const RPC_METHOD_CATALOG_DIFF: &str = "catalog.diff";
|
const RPC_METHOD_CATALOG_DIFF: &str = "catalog.diff";
|
||||||
@@ -2048,12 +2089,18 @@ fn dispatch_rpc_method(
|
|||||||
"translation.task.update",
|
"translation.task.update",
|
||||||
update_translation_task_status_report(state_dir, request.params.as_ref()),
|
update_translation_task_status_report(state_dir, request.params.as_ref()),
|
||||||
),
|
),
|
||||||
RPC_METHOD_TRANSLATION_PROOFREAD => rpc_envelope_from_result(
|
RPC_METHOD_TRANSLATION_PROOFREAD => {
|
||||||
request_id,
|
let _sync_guard = tasks
|
||||||
"translation.proofread",
|
.sync_lock
|
||||||
mark_localized_manual_proofreading_report(state_dir, &tasks.base_config)
|
.lock()
|
||||||
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)),
|
.unwrap_or_else(|poison| poison.into_inner());
|
||||||
),
|
rpc_envelope_from_result(
|
||||||
|
request_id,
|
||||||
|
"translation.proofread",
|
||||||
|
mark_localized_manual_proofreading_report(state_dir, &tasks.base_config)
|
||||||
|
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)),
|
||||||
|
)
|
||||||
|
}
|
||||||
RPC_METHOD_TRANSLATION_WORKER_RUN => {
|
RPC_METHOD_TRANSLATION_WORKER_RUN => {
|
||||||
let config = match rpc_translation_worker_config(request.params.as_ref()) {
|
let config = match rpc_translation_worker_config(request.params.as_ref()) {
|
||||||
Ok(config) => config,
|
Ok(config) => config,
|
||||||
@@ -2061,11 +2108,53 @@ fn dispatch_rpc_method(
|
|||||||
};
|
};
|
||||||
enqueue_translation_worker_envelope(tasks, config, request_id)
|
enqueue_translation_worker_envelope(tasks, config, request_id)
|
||||||
}
|
}
|
||||||
RPC_METHOD_LOCALIZED_STATUS => rpc_envelope_from_result(
|
RPC_METHOD_LOCALIZED_STATUS => {
|
||||||
request_id,
|
let _sync_guard = tasks
|
||||||
"localized.status",
|
.sync_lock
|
||||||
build_localized_status_report(state_dir, &tasks.base_config),
|
.lock()
|
||||||
),
|
.unwrap_or_else(|poison| poison.into_inner());
|
||||||
|
rpc_envelope_from_result(
|
||||||
|
request_id,
|
||||||
|
"localized.status",
|
||||||
|
build_localized_status_report(state_dir, &tasks.base_config),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
RPC_METHOD_LOCALIZED_PUBLISH => {
|
||||||
|
let _sync_guard = tasks
|
||||||
|
.sync_lock
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poison| poison.into_inner());
|
||||||
|
match localized_publish_rpc_report(
|
||||||
|
state_dir,
|
||||||
|
&tasks.base_config,
|
||||||
|
request.params.as_ref(),
|
||||||
|
) {
|
||||||
|
Ok(report) => rpc_envelope_from_result(
|
||||||
|
request_id,
|
||||||
|
RPC_METHOD_LOCALIZED_PUBLISH,
|
||||||
|
serde_json::to_value(report).map_err(anyhow::Error::from),
|
||||||
|
),
|
||||||
|
Err(error) => rpc_envelope_error(request_id, error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RPC_METHOD_LOCALIZED_ROLLBACK => {
|
||||||
|
let _sync_guard = tasks
|
||||||
|
.sync_lock
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poison| poison.into_inner());
|
||||||
|
match localized_rollback_rpc_report(
|
||||||
|
state_dir,
|
||||||
|
&tasks.base_config,
|
||||||
|
request.params.as_ref(),
|
||||||
|
) {
|
||||||
|
Ok(report) => rpc_envelope_from_result(
|
||||||
|
request_id,
|
||||||
|
RPC_METHOD_LOCALIZED_ROLLBACK,
|
||||||
|
serde_json::to_value(report).map_err(anyhow::Error::from),
|
||||||
|
),
|
||||||
|
Err(error) => rpc_envelope_error(request_id, error),
|
||||||
|
}
|
||||||
|
}
|
||||||
RPC_METHOD_CATALOG_STATUS => rpc_envelope_from_result(
|
RPC_METHOD_CATALOG_STATUS => rpc_envelope_from_result(
|
||||||
request_id,
|
request_id,
|
||||||
"catalog.status",
|
"catalog.status",
|
||||||
@@ -3150,6 +3239,98 @@ fn rpc_struct_params<T: DeserializeOwned>(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rpc_optional_struct_params<T: DeserializeOwned + Default>(
|
||||||
|
params: Option<&serde_json::Value>,
|
||||||
|
method: &'static str,
|
||||||
|
) -> Result<T, ApiError> {
|
||||||
|
let Some(params) = params else {
|
||||||
|
return Ok(T::default());
|
||||||
|
};
|
||||||
|
if params.is_null() {
|
||||||
|
return Ok(T::default());
|
||||||
|
}
|
||||||
|
if !params.is_object() {
|
||||||
|
return Err(ApiError::new(
|
||||||
|
ErrorCode::RPC_INVALID_PARAMS,
|
||||||
|
method,
|
||||||
|
"params 必须是 JSON object",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
serde_json::from_value(params.clone()).map_err(|error| {
|
||||||
|
ApiError::new(
|
||||||
|
ErrorCode::RPC_INVALID_PARAMS,
|
||||||
|
method,
|
||||||
|
format!("params 无效:{error}"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn localized_publish_rpc_report(
|
||||||
|
state_dir: &Path,
|
||||||
|
base_config: &OfficialUpdateConfig,
|
||||||
|
params: Option<&serde_json::Value>,
|
||||||
|
) -> Result<LocalizedPatchReport, ApiError> {
|
||||||
|
let params: LocalizedPublishRpcParams =
|
||||||
|
rpc_optional_struct_params(params, RPC_METHOD_LOCALIZED_PUBLISH)?;
|
||||||
|
let translation_file = params
|
||||||
|
.translation_file
|
||||||
|
.filter(|path| !path.as_os_str().is_empty());
|
||||||
|
if translation_file.is_some() == params.from_worker {
|
||||||
|
return Err(ApiError::new(
|
||||||
|
ErrorCode::RPC_INVALID_PARAMS,
|
||||||
|
RPC_METHOD_LOCALIZED_PUBLISH,
|
||||||
|
"localized.publish 必须且只能指定 translation_file 或 from_worker",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut config = base_config.clone();
|
||||||
|
config.force = params.force;
|
||||||
|
let options = CliOptions {
|
||||||
|
command: CliCommand::PublishLocalized,
|
||||||
|
config,
|
||||||
|
state_dir: state_dir.to_path_buf(),
|
||||||
|
translation_file,
|
||||||
|
translation_from_worker: params.from_worker,
|
||||||
|
localized_release_id: normalize_optional_rpc_string(params.localized_release_id),
|
||||||
|
..CliOptions::default()
|
||||||
|
};
|
||||||
|
publish_localized_report(&options).map_err(|error| {
|
||||||
|
ApiError::new(
|
||||||
|
ErrorCode::INTERNAL,
|
||||||
|
RPC_METHOD_LOCALIZED_PUBLISH,
|
||||||
|
error.to_string(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn localized_rollback_rpc_report(
|
||||||
|
state_dir: &Path,
|
||||||
|
base_config: &OfficialUpdateConfig,
|
||||||
|
params: Option<&serde_json::Value>,
|
||||||
|
) -> Result<LocalizedRollbackReport, ApiError> {
|
||||||
|
let params: LocalizedRollbackRpcParams =
|
||||||
|
rpc_optional_struct_params(params, RPC_METHOD_LOCALIZED_ROLLBACK)?;
|
||||||
|
let options = CliOptions {
|
||||||
|
command: CliCommand::LocalizedRollback,
|
||||||
|
config: base_config.clone(),
|
||||||
|
state_dir: state_dir.to_path_buf(),
|
||||||
|
localized_release_id: normalize_optional_rpc_string(params.localized_release_id),
|
||||||
|
..CliOptions::default()
|
||||||
|
};
|
||||||
|
localized_rollback_report(&options).map_err(|error| {
|
||||||
|
ApiError::new(
|
||||||
|
ErrorCode::INTERNAL,
|
||||||
|
RPC_METHOD_LOCALIZED_ROLLBACK,
|
||||||
|
error.to_string(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_optional_rpc_string(value: Option<String>) -> Option<String> {
|
||||||
|
value
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
fn rpc_translation_worker_config(
|
fn rpc_translation_worker_config(
|
||||||
params: Option<&serde_json::Value>,
|
params: Option<&serde_json::Value>,
|
||||||
) -> Result<TranslationWorkerConfig, ApiError> {
|
) -> Result<TranslationWorkerConfig, ApiError> {
|
||||||
@@ -4484,6 +4665,24 @@ impl HumanReport for LocalizedPatchReport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl HumanReport for LocalizedRollbackReport {
|
||||||
|
fn print_human(&self) -> anyhow::Result<()> {
|
||||||
|
print_title("汉化 release 回滚");
|
||||||
|
print_field("命令", self.command);
|
||||||
|
print_field("状态", self.status);
|
||||||
|
print_field("回滚 release", &self.rolled_back_release_id);
|
||||||
|
print_optional_field("恢复 release", self.restored_release_id.as_deref());
|
||||||
|
print_path_field("汉化输出目录", &self.localized_output_root);
|
||||||
|
print_path_field("current", &self.current_path);
|
||||||
|
print_path_field("状态文件", &self.state_path);
|
||||||
|
print_path_field("删除版本目录", &self.removed_version_path);
|
||||||
|
print_optional_path_field("恢复 current 目标", self.restored_current_target.as_ref());
|
||||||
|
print_field("新状态", &self.state.status);
|
||||||
|
print_optional_field("当前 release", self.state.current_release_id.as_deref());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl HumanReport for bat_infrastructure::LocalizedTranslationWorkflowReport {
|
impl HumanReport for bat_infrastructure::LocalizedTranslationWorkflowReport {
|
||||||
fn print_human(&self) -> anyhow::Result<()> {
|
fn print_human(&self) -> anyhow::Result<()> {
|
||||||
print_title("汉化工作流状态");
|
print_title("汉化工作流状态");
|
||||||
@@ -6971,6 +7170,10 @@ fn parse_args_with_env(
|
|||||||
ensure_command_not_set(options.command, "localized-status")?;
|
ensure_command_not_set(options.command, "localized-status")?;
|
||||||
options.command = CliCommand::LocalizedStatus;
|
options.command = CliCommand::LocalizedStatus;
|
||||||
}
|
}
|
||||||
|
"localized-rollback" => {
|
||||||
|
ensure_command_not_set(options.command, "localized-rollback")?;
|
||||||
|
options.command = CliCommand::LocalizedRollback;
|
||||||
|
}
|
||||||
"resource-index" => {
|
"resource-index" => {
|
||||||
ensure_command_not_set(options.command, "resource-index")?;
|
ensure_command_not_set(options.command, "resource-index")?;
|
||||||
options.command = CliCommand::ResourceIndex;
|
options.command = CliCommand::ResourceIndex;
|
||||||
@@ -7069,6 +7272,9 @@ fn parse_args_with_env(
|
|||||||
options.translation_text_file =
|
options.translation_text_file =
|
||||||
Some(PathBuf::from(next_option_value(&mut args, &flag)?));
|
Some(PathBuf::from(next_option_value(&mut args, &flag)?));
|
||||||
}
|
}
|
||||||
|
"--from-worker" | "--translation-from-worker" => {
|
||||||
|
options.translation_from_worker = true;
|
||||||
|
}
|
||||||
"--failure-reason" | "--reason" => {
|
"--failure-reason" | "--reason" => {
|
||||||
options.translation_failure_reason = Some(next_option_value(&mut args, &flag)?);
|
options.translation_failure_reason = Some(next_option_value(&mut args, &flag)?);
|
||||||
}
|
}
|
||||||
@@ -7589,6 +7795,9 @@ fn parse_args_with_env(
|
|||||||
{
|
{
|
||||||
return Err(anyhow::anyhow!("翻译 worker 参数只适用于 i18n worker run"));
|
return Err(anyhow::anyhow!("翻译 worker 参数只适用于 i18n worker run"));
|
||||||
}
|
}
|
||||||
|
if options.command != CliCommand::PublishLocalized && options.translation_from_worker {
|
||||||
|
return Err(anyhow::anyhow!("--from-worker 只适用于 i18n publish"));
|
||||||
|
}
|
||||||
|
|
||||||
match options.command {
|
match options.command {
|
||||||
CliCommand::Status | CliCommand::Stop | CliCommand::Logs => {
|
CliCommand::Status | CliCommand::Stop | CliCommand::Logs => {
|
||||||
@@ -7671,6 +7880,13 @@ fn parse_args_with_env(
|
|||||||
"parse/translate/publish-localized 不能使用 --dry-run"
|
"parse/translate/publish-localized 不能使用 --dry-run"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if matches!(options.command, CliCommand::PublishLocalized)
|
||||||
|
&& options.translation_file.is_some() == options.translation_from_worker
|
||||||
|
{
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"i18n publish 必须且只能指定 --translation-file 或 --from-worker"
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
CliCommand::ParseClearCache => {
|
CliCommand::ParseClearCache => {
|
||||||
if options.watch || options.daemon || options.daemon_child {
|
if options.watch || options.daemon || options.daemon_child {
|
||||||
@@ -7693,9 +7909,13 @@ fn parse_args_with_env(
|
|||||||
if options.watch || options.daemon || options.daemon_child {
|
if options.watch || options.daemon || options.daemon_child {
|
||||||
return Err(anyhow::anyhow!("i18n validate 只支持单次执行"));
|
return Err(anyhow::anyhow!("i18n validate 只支持单次执行"));
|
||||||
}
|
}
|
||||||
if options.config.force || options.config.dry_run || options.run_count.is_some() {
|
if options.config.force
|
||||||
|
|| options.config.dry_run
|
||||||
|
|| options.run_count.is_some()
|
||||||
|
|| options.translation_from_worker
|
||||||
|
{
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
"i18n validate 不支持 --force、--dry-run 或 --run-count"
|
"i18n validate 不支持 --force、--dry-run、--run-count 或 --from-worker"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
options.progress = false;
|
options.progress = false;
|
||||||
@@ -7737,6 +7957,7 @@ fn parse_args_with_env(
|
|||||||
|| options.schedule_option_explicit
|
|| options.schedule_option_explicit
|
||||||
|| options.translation_failure_reason.is_some()
|
|| options.translation_failure_reason.is_some()
|
||||||
|| options.translation_provider_run_id.is_some()
|
|| options.translation_provider_run_id.is_some()
|
||||||
|
|| options.translation_from_worker
|
||||||
|| options.proxy_option_explicit
|
|| options.proxy_option_explicit
|
||||||
|| tools_are_non_default(&options.config, &options.env_baseline_config)
|
|| tools_are_non_default(&options.config, &options.env_baseline_config)
|
||||||
{
|
{
|
||||||
@@ -7751,7 +7972,11 @@ fn parse_args_with_env(
|
|||||||
if options.watch || options.daemon || options.daemon_child {
|
if options.watch || options.daemon || options.daemon_child {
|
||||||
return Err(anyhow::anyhow!("repack 只支持单次执行"));
|
return Err(anyhow::anyhow!("repack 只支持单次执行"));
|
||||||
}
|
}
|
||||||
if options.config.force || options.sync_option_explicit || options.run_count.is_some() {
|
if options.config.force
|
||||||
|
|| options.sync_option_explicit
|
||||||
|
|| options.run_count.is_some()
|
||||||
|
|| options.translation_from_worker
|
||||||
|
{
|
||||||
return Err(anyhow::anyhow!("repack 不接受资源同步选项"));
|
return Err(anyhow::anyhow!("repack 不接受资源同步选项"));
|
||||||
}
|
}
|
||||||
options.progress = false;
|
options.progress = false;
|
||||||
@@ -7778,6 +8003,7 @@ fn parse_args_with_env(
|
|||||||
|| options.translation_id.is_some()
|
|| options.translation_id.is_some()
|
||||||
|| options.translation_text.is_some()
|
|| options.translation_text.is_some()
|
||||||
|| options.translation_text_file.is_some()
|
|| options.translation_text_file.is_some()
|
||||||
|
|| options.translation_from_worker
|
||||||
|| options.localized_release_id.is_some()
|
|| options.localized_release_id.is_some()
|
||||||
|| options.repack_spec.is_some()
|
|| options.repack_spec.is_some()
|
||||||
|| options.query_offset != 0
|
|| options.query_offset != 0
|
||||||
@@ -7828,6 +8054,7 @@ fn parse_args_with_env(
|
|||||||
|| options.translation_text_file.is_some()
|
|| options.translation_text_file.is_some()
|
||||||
|| options.translation_failure_reason.is_some()
|
|| options.translation_failure_reason.is_some()
|
||||||
|| options.translation_provider_run_id.is_some()
|
|| options.translation_provider_run_id.is_some()
|
||||||
|
|| options.translation_from_worker
|
||||||
|| options.localized_release_id.is_some()
|
|| options.localized_release_id.is_some()
|
||||||
|| options.repack_spec.is_some()
|
|| options.repack_spec.is_some()
|
||||||
|| options.proxy_option_explicit
|
|| options.proxy_option_explicit
|
||||||
@@ -7882,6 +8109,7 @@ fn parse_args_with_env(
|
|||||||
|| options.translation_text_file.is_some()
|
|| options.translation_text_file.is_some()
|
||||||
|| options.translation_failure_reason.is_some()
|
|| options.translation_failure_reason.is_some()
|
||||||
|| options.translation_provider_run_id.is_some()
|
|| options.translation_provider_run_id.is_some()
|
||||||
|
|| options.translation_from_worker
|
||||||
|| options.localized_release_id.is_some()
|
|| options.localized_release_id.is_some()
|
||||||
|| options.repack_spec.is_some()
|
|| options.repack_spec.is_some()
|
||||||
|| options.query_option_explicit
|
|| options.query_option_explicit
|
||||||
@@ -7894,6 +8122,41 @@ fn parse_args_with_env(
|
|||||||
options.progress = false;
|
options.progress = false;
|
||||||
options.banner = false;
|
options.banner = false;
|
||||||
}
|
}
|
||||||
|
CliCommand::LocalizedRollback => {
|
||||||
|
if options.watch || options.daemon || options.daemon_child {
|
||||||
|
return Err(anyhow::anyhow!("i18n rollback 只支持单次执行或 RPC 调用"));
|
||||||
|
}
|
||||||
|
if options.config.force
|
||||||
|
|| options.config.dry_run
|
||||||
|
|| options.run_count.is_some()
|
||||||
|
|| options.sync_option_explicit
|
||||||
|
|| options.proxy_option_explicit
|
||||||
|
|| tools_are_non_default(&options.config, &options.env_baseline_config)
|
||||||
|
{
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"i18n rollback 只接受 --output、--localized-output、--state-dir、--localized-release-id 和 --json/--human"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if options.resource_root.is_some()
|
||||||
|
|| options.translation_file.is_some()
|
||||||
|
|| options.translation_id.is_some()
|
||||||
|
|| options.translation_text.is_some()
|
||||||
|
|| options.translation_text_file.is_some()
|
||||||
|
|| options.translation_failure_reason.is_some()
|
||||||
|
|| options.translation_provider_run_id.is_some()
|
||||||
|
|| options.translation_from_worker
|
||||||
|
|| options.repack_spec.is_some()
|
||||||
|
|| options.query_option_explicit
|
||||||
|
|| options.schedule_option_explicit
|
||||||
|
|| options.translation_worker_option_explicit
|
||||||
|
{
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"i18n rollback 不接受工作台、任务、查询、调度、repack 或 worker 参数"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
options.progress = false;
|
||||||
|
options.banner = false;
|
||||||
|
}
|
||||||
CliCommand::ScheduleList
|
CliCommand::ScheduleList
|
||||||
| CliCommand::ScheduleAdd
|
| CliCommand::ScheduleAdd
|
||||||
| CliCommand::ScheduleUpdate
|
| CliCommand::ScheduleUpdate
|
||||||
@@ -8103,6 +8366,7 @@ fn parse_translation_command(
|
|||||||
"unset" | "clear" => CliCommand::TranslationUnset,
|
"unset" | "clear" => CliCommand::TranslationUnset,
|
||||||
"validate" => CliCommand::TranslationValidate,
|
"validate" => CliCommand::TranslationValidate,
|
||||||
"publish" => CliCommand::PublishLocalized,
|
"publish" => CliCommand::PublishLocalized,
|
||||||
|
"rollback" => CliCommand::LocalizedRollback,
|
||||||
"proofread" => CliCommand::TranslationProofread,
|
"proofread" => CliCommand::TranslationProofread,
|
||||||
"tasks" => CliCommand::TranslationTasks,
|
"tasks" => CliCommand::TranslationTasks,
|
||||||
"handoff" => CliCommand::TranslationHandoff,
|
"handoff" => CliCommand::TranslationHandoff,
|
||||||
@@ -8333,7 +8597,8 @@ fn print_usage(binary: &str) {
|
|||||||
eprintln!(" i18n handoff Query current translation handoff");
|
eprintln!(" i18n handoff Query current translation handoff");
|
||||||
eprintln!(" i18n status Show localized release status for current official release");
|
eprintln!(" i18n status Show localized release status for current official release");
|
||||||
eprintln!(" i18n task update Update one provider worker task status");
|
eprintln!(" i18n task update Update one provider worker task status");
|
||||||
eprintln!(" i18n publish Publish a localized release");
|
eprintln!(" i18n publish Publish a localized release from a workbench or worker results");
|
||||||
|
eprintln!(" i18n rollback Roll back the current localized release");
|
||||||
eprintln!(" i18n schedule Manage translation schedules");
|
eprintln!(" i18n schedule Manage translation schedules");
|
||||||
eprintln!(" refresh Run one update check, or ask a live daemon to refresh");
|
eprintln!(" refresh Run one update check, or ask a live daemon to refresh");
|
||||||
eprintln!(" verify Verify remote plan, local manifest, and official seed hashes");
|
eprintln!(" verify Verify remote plan, local manifest, and official seed hashes");
|
||||||
@@ -8344,6 +8609,7 @@ fn print_usage(binary: &str) {
|
|||||||
eprintln!(" translation-tasks Query current offline TextUnit translation task status");
|
eprintln!(" translation-tasks Query current offline TextUnit translation task status");
|
||||||
eprintln!(" translation-handoff Query current translation job/unit/provider handoff");
|
eprintln!(" translation-handoff Query current translation job/unit/provider handoff");
|
||||||
eprintln!(" localized-status Show localized release status for current official release");
|
eprintln!(" localized-status Show localized release status for current official release");
|
||||||
|
eprintln!(" localized-rollback Roll back the current localized release");
|
||||||
eprintln!(" resource-index Query CAS + ResourceRepository index");
|
eprintln!(" resource-index Query CAS + ResourceRepository index");
|
||||||
eprintln!(" patch-apply Apply a Binary/JSON/Text patch file");
|
eprintln!(" patch-apply Apply a Binary/JSON/Text patch file");
|
||||||
eprintln!(" unityfs-patch-text-asset Patch one UnityFS TextAsset object");
|
eprintln!(" unityfs-patch-text-asset Patch one UnityFS TextAsset object");
|
||||||
@@ -8381,6 +8647,8 @@ fn print_usage(binary: &str) {
|
|||||||
eprintln!(" {binary} i18n handoff --json");
|
eprintln!(" {binary} i18n handoff --json");
|
||||||
eprintln!(" {binary} i18n status --json");
|
eprintln!(" {binary} i18n status --json");
|
||||||
eprintln!(" {binary} i18n publish --translation-file /tmp/bat-workbench.json --force");
|
eprintln!(" {binary} i18n publish --translation-file /tmp/bat-workbench.json --force");
|
||||||
|
eprintln!(" {binary} i18n publish --from-worker --localized-release-id release-manual-1");
|
||||||
|
eprintln!(" {binary} i18n rollback --localized-release-id release-manual-1");
|
||||||
eprintln!(" {binary} status");
|
eprintln!(" {binary} status");
|
||||||
eprintln!(" {binary} refresh --force --json");
|
eprintln!(" {binary} refresh --force --json");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
@@ -8429,6 +8697,7 @@ fn print_usage(binary: &str) {
|
|||||||
eprintln!(" --translation-id <ID> TextUnit ID for i18n set");
|
eprintln!(" --translation-id <ID> TextUnit ID for i18n set");
|
||||||
eprintln!(" --translated-text <TEXT> Inline translation for i18n set");
|
eprintln!(" --translated-text <TEXT> Inline translation for i18n set");
|
||||||
eprintln!(" --translated-file <PATH> UTF-8 translation file for i18n set");
|
eprintln!(" --translated-file <PATH> UTF-8 translation file for i18n set");
|
||||||
|
eprintln!(" --from-worker Build publish input from completed provider worker results");
|
||||||
eprintln!(" --failure-reason <TEXT> Provider failure reason for i18n task update");
|
eprintln!(" --failure-reason <TEXT> Provider failure reason for i18n task update");
|
||||||
eprintln!(" --provider-run-id <ID> Provider run ID for i18n task update");
|
eprintln!(" --provider-run-id <ID> Provider run ID for i18n task update");
|
||||||
eprintln!(" --translation-provider <NAME> / --provider <NAME> Provider for i18n worker run (mock/crowdin)");
|
eprintln!(" --translation-provider <NAME> / --provider <NAME> Provider for i18n worker run (mock/crowdin)");
|
||||||
|
|||||||
@@ -299,6 +299,27 @@ fn translation_worker_rpc_params_are_strict_and_accept_aliases() {
|
|||||||
assert!(rpc_translation_worker_config(Some(&serde_json::json!({"max_tasks": 0}))).is_err());
|
assert!(rpc_translation_worker_config(Some(&serde_json::json!({"max_tasks": 0}))).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn localized_publish_rpc_params_require_one_input_source() {
|
||||||
|
let config = OfficialUpdateConfig::default();
|
||||||
|
assert!(localized_publish_rpc_report(Path::new("/tmp/bat-state"), &config, None,).is_err());
|
||||||
|
assert!(localized_publish_rpc_report(
|
||||||
|
Path::new("/tmp/bat-state"),
|
||||||
|
&config,
|
||||||
|
Some(&serde_json::json!({
|
||||||
|
"translation_file": "/tmp/workbench.json",
|
||||||
|
"from_worker": true
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
assert!(localized_publish_rpc_report(
|
||||||
|
Path::new("/tmp/bat-state"),
|
||||||
|
&config,
|
||||||
|
Some(&serde_json::json!({"unexpected": true})),
|
||||||
|
)
|
||||||
|
.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn grouped_workflow_commands_use_short_top_level_aliases() {
|
fn grouped_workflow_commands_use_short_top_level_aliases() {
|
||||||
let options = parse(&[
|
let options = parse(&[
|
||||||
@@ -388,6 +409,38 @@ fn grouped_workflow_commands_use_short_top_level_aliases() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(options.command, CliCommand::TranslationValidate);
|
assert_eq!(options.command, CliCommand::TranslationValidate);
|
||||||
|
|
||||||
|
let options = parse(&[
|
||||||
|
"bat",
|
||||||
|
"i18n",
|
||||||
|
"publish",
|
||||||
|
"--from-worker",
|
||||||
|
"--localized-release-id",
|
||||||
|
"localized-1",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(options.command, CliCommand::PublishLocalized);
|
||||||
|
assert!(options.translation_from_worker);
|
||||||
|
assert_eq!(options.localized_release_id.as_deref(), Some("localized-1"));
|
||||||
|
assert!(parse(&[
|
||||||
|
"bat",
|
||||||
|
"i18n",
|
||||||
|
"publish",
|
||||||
|
"--from-worker",
|
||||||
|
"--translation-file",
|
||||||
|
"/tmp/workbench.json",
|
||||||
|
])
|
||||||
|
.is_err());
|
||||||
|
|
||||||
|
let options = parse(&[
|
||||||
|
"bat",
|
||||||
|
"i18n",
|
||||||
|
"rollback",
|
||||||
|
"--localized-release-id",
|
||||||
|
"localized-1",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(options.command, CliCommand::LocalizedRollback);
|
||||||
|
|
||||||
let options = parse(&[
|
let options = parse(&[
|
||||||
"bat",
|
"bat",
|
||||||
"i18n",
|
"i18n",
|
||||||
@@ -548,8 +601,13 @@ fn translation_workbench_commands_read_update_and_clear_entries() {
|
|||||||
serialized_file: Some("CAB-test".to_string()),
|
serialized_file: Some("CAB-test".to_string()),
|
||||||
path_id: Some(7),
|
path_id: Some(7),
|
||||||
asset_name: Some("Story".to_string()),
|
asset_name: Some("Story".to_string()),
|
||||||
|
field_path: None,
|
||||||
source_text: "原文".to_string(),
|
source_text: "原文".to_string(),
|
||||||
translated_text: None,
|
translated_text: None,
|
||||||
|
translation_provider: None,
|
||||||
|
provider_run_id: None,
|
||||||
|
translated_unix_seconds: None,
|
||||||
|
review_status: None,
|
||||||
format: Some("plain".to_string()),
|
format: Some("plain".to_string()),
|
||||||
text_source_kind: Some("text_asset".to_string()),
|
text_source_kind: Some("text_asset".to_string()),
|
||||||
}],
|
}],
|
||||||
@@ -2048,6 +2106,7 @@ fn test_task_context_with_config(base_config: OfficialUpdateConfig) -> DaemonTas
|
|||||||
registry: TaskRegistry::new(),
|
registry: TaskRegistry::new(),
|
||||||
queue,
|
queue,
|
||||||
base_config,
|
base_config,
|
||||||
|
sync_lock: Arc::new(Mutex::new(())),
|
||||||
restart_controller: test_restart_controller,
|
restart_controller: test_restart_controller,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2217,6 +2276,7 @@ fn dispatch_daemon_doctor_returns_report() {
|
|||||||
registry: TaskRegistry::new(),
|
registry: TaskRegistry::new(),
|
||||||
queue,
|
queue,
|
||||||
base_config,
|
base_config,
|
||||||
|
sync_lock: Arc::new(Mutex::new(())),
|
||||||
restart_controller: test_restart_controller,
|
restart_controller: test_restart_controller,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2246,6 +2306,7 @@ fn dispatch_resource_sync_enqueues_task() {
|
|||||||
registry: TaskRegistry::new(),
|
registry: TaskRegistry::new(),
|
||||||
queue,
|
queue,
|
||||||
base_config: OfficialUpdateConfig::default(),
|
base_config: OfficialUpdateConfig::default(),
|
||||||
|
sync_lock: Arc::new(Mutex::new(())),
|
||||||
restart_controller: test_restart_controller,
|
restart_controller: test_restart_controller,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2306,6 +2367,7 @@ fn dispatch_resource_repair_enqueues_repair_task() {
|
|||||||
registry: TaskRegistry::new(),
|
registry: TaskRegistry::new(),
|
||||||
queue,
|
queue,
|
||||||
base_config,
|
base_config,
|
||||||
|
sync_lock: Arc::new(Mutex::new(())),
|
||||||
restart_controller: test_restart_controller,
|
restart_controller: test_restart_controller,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3597,6 +3659,7 @@ fn dispatch_catalog_refresh_enqueues_task() {
|
|||||||
registry: TaskRegistry::new(),
|
registry: TaskRegistry::new(),
|
||||||
queue,
|
queue,
|
||||||
base_config: OfficialUpdateConfig::default(),
|
base_config: OfficialUpdateConfig::default(),
|
||||||
|
sync_lock: Arc::new(Mutex::new(())),
|
||||||
restart_controller: test_restart_controller,
|
restart_controller: test_restart_controller,
|
||||||
};
|
};
|
||||||
let envelope = dispatch_rpc_method(
|
let envelope = dispatch_rpc_method(
|
||||||
@@ -4508,6 +4571,111 @@ fn dispatch_translation_proofread_marks_state_for_dashboard() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn dispatch_localized_rollback_restores_manifest_previous_release() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
|
let state_dir = temp.path().join("state");
|
||||||
|
let output_root = temp.path().join("output");
|
||||||
|
let localized_root = temp.path().join("localized");
|
||||||
|
write_catalog_fixture_with_localized(
|
||||||
|
&state_dir,
|
||||||
|
&output_root,
|
||||||
|
&localized_root,
|
||||||
|
"bundle-b2",
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let previous = localized_root
|
||||||
|
.join(LOCALIZED_VERSIONS_DIR)
|
||||||
|
.join("release-1");
|
||||||
|
let current = localized_root
|
||||||
|
.join(LOCALIZED_VERSIONS_DIR)
|
||||||
|
.join("release-2");
|
||||||
|
fs::create_dir_all(&previous).unwrap();
|
||||||
|
fs::create_dir_all(¤t).unwrap();
|
||||||
|
fs::write(
|
||||||
|
previous.join(LOCALIZED_PATCH_MANIFEST_FILE),
|
||||||
|
serde_json::to_vec(&bat_infrastructure::LocalizedPatchManifest {
|
||||||
|
manifest_version: bat_infrastructure::LOCALIZED_PATCH_MANIFEST_VERSION,
|
||||||
|
official_release_id: "v-current".to_string(),
|
||||||
|
localized_release_id: "release-1".to_string(),
|
||||||
|
generated_unix_seconds: 123,
|
||||||
|
file_count: 0,
|
||||||
|
text_asset_operation_count: 0,
|
||||||
|
files: Vec::new(),
|
||||||
|
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
|
||||||
|
previous_current_target: None,
|
||||||
|
remove_version_path: previous.clone(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
current.join(LOCALIZED_PATCH_MANIFEST_FILE),
|
||||||
|
serde_json::to_vec(&bat_infrastructure::LocalizedPatchManifest {
|
||||||
|
manifest_version: bat_infrastructure::LOCALIZED_PATCH_MANIFEST_VERSION,
|
||||||
|
official_release_id: "v-current".to_string(),
|
||||||
|
localized_release_id: "release-2".to_string(),
|
||||||
|
generated_unix_seconds: 124,
|
||||||
|
file_count: 0,
|
||||||
|
text_asset_operation_count: 0,
|
||||||
|
files: Vec::new(),
|
||||||
|
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
|
||||||
|
previous_current_target: Some(PathBuf::from("versions/release-1")),
|
||||||
|
remove_version_path: current.clone(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
symlink(
|
||||||
|
Path::new(LOCALIZED_VERSIONS_DIR).join("release-2"),
|
||||||
|
localized_root.join(LOCALIZED_CURRENT_LINK),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::write(
|
||||||
|
localized_root.join(LOCALIZED_VERSION_STATE_FILE),
|
||||||
|
serde_json::to_vec(&bat_infrastructure::LocalizedVersionState {
|
||||||
|
state_version: bat_infrastructure::LOCALIZED_VERSION_STATE_VERSION,
|
||||||
|
official_release_id: "v-current".to_string(),
|
||||||
|
current_release_id: Some("release-2".to_string()),
|
||||||
|
status: "localized".to_string(),
|
||||||
|
translation_workflow_status: None,
|
||||||
|
updated_unix_seconds: 124,
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let tasks = test_task_context_with_config(OfficialUpdateConfig {
|
||||||
|
localized_output_root: localized_root.clone(),
|
||||||
|
..OfficialUpdateConfig::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let envelope = dispatch_rpc_method(
|
||||||
|
&rpc_request(
|
||||||
|
"localized.rollback",
|
||||||
|
Some(serde_json::json!({"localized_release_id": "release-2"})),
|
||||||
|
),
|
||||||
|
&state_dir,
|
||||||
|
&new_daemon_control(),
|
||||||
|
&tasks,
|
||||||
|
"req-localized-rollback-1".to_string(),
|
||||||
|
);
|
||||||
|
let value = serde_json::to_value(&envelope).unwrap();
|
||||||
|
assert_eq!(value["ok"], true);
|
||||||
|
assert_eq!(value["data"]["status"], "rolled_back");
|
||||||
|
assert_eq!(value["data"]["rolled_back_release_id"], "release-2");
|
||||||
|
assert_eq!(value["data"]["restored_release_id"], "release-1");
|
||||||
|
assert!(!current.exists());
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_link(localized_root.join(LOCALIZED_CURRENT_LINK)).unwrap(),
|
||||||
|
PathBuf::from("versions/release-1")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn localized_status_keeps_published_release_during_manual_proofreading() {
|
fn localized_status_keeps_published_release_during_manual_proofreading() {
|
||||||
|
|||||||
@@ -515,6 +515,8 @@ pub(super) struct DaemonTaskContext {
|
|||||||
pub(super) registry: TaskRegistry,
|
pub(super) registry: TaskRegistry,
|
||||||
pub(super) queue: mpsc::Sender<TaskJob>,
|
pub(super) queue: mpsc::Sender<TaskJob>,
|
||||||
pub(super) base_config: OfficialUpdateConfig,
|
pub(super) base_config: OfficialUpdateConfig,
|
||||||
|
/// 串行化会读取或修改已发布资源状态的 daemon 操作。
|
||||||
|
pub(super) sync_lock: Arc<Mutex<()>>,
|
||||||
pub(super) restart_controller: DaemonRestartController,
|
pub(super) restart_controller: DaemonRestartController,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -299,13 +299,25 @@ pub(super) fn run_repack(options: &CliOptions) -> anyhow::Result<()> {
|
|||||||
print_report(options.output_format, &report)
|
print_report(options.output_format, &report)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn run_publish_localized(options: &CliOptions) -> anyhow::Result<()> {
|
pub(super) fn publish_localized_report(
|
||||||
let translation_file = options
|
options: &CliOptions,
|
||||||
.translation_file
|
) -> anyhow::Result<LocalizedPatchReport> {
|
||||||
.as_ref()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("publish-localized 必须指定 --translation-file"))?;
|
|
||||||
let (resource_root, official_release_id) = current_official_release(options)?;
|
let (resource_root, official_release_id) = current_official_release(options)?;
|
||||||
let workbench = read_translation_workbench(translation_file)?;
|
let workbench = if options.translation_from_worker {
|
||||||
|
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
runtime.block_on(completed_worker_translation_workbench(
|
||||||
|
&resource_root,
|
||||||
|
&official_release_id,
|
||||||
|
))?
|
||||||
|
} else {
|
||||||
|
let translation_file = options
|
||||||
|
.translation_file
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("publish-localized 必须指定 --translation-file"))?;
|
||||||
|
read_translation_workbench(translation_file)?
|
||||||
|
};
|
||||||
if workbench.official_release_id != official_release_id {
|
if workbench.official_release_id != official_release_id {
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
"翻译工作台 release={} 与当前官方 release={} 不一致;请重新导出",
|
"翻译工作台 release={} 与当前官方 release={} 不一致;请重新导出",
|
||||||
@@ -319,7 +331,7 @@ pub(super) fn run_publish_localized(options: &CliOptions) -> anyhow::Result<()>
|
|||||||
"翻译工作台资源根目录与当前 release 不一致;请重新导出"
|
"翻译工作台资源根目录与当前 release 不一致;请重新导出"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let patches = localized_text_asset_patches(&resource_root, &workbench)?;
|
let operations = localized_patch_operations(&resource_root, &workbench)?;
|
||||||
let localized_release_id = options.localized_release_id.clone().or_else(|| {
|
let localized_release_id = options.localized_release_id.clone().or_else(|| {
|
||||||
options
|
options
|
||||||
.config
|
.config
|
||||||
@@ -330,17 +342,36 @@ pub(super) fn run_publish_localized(options: &CliOptions) -> anyhow::Result<()>
|
|||||||
resource_root,
|
resource_root,
|
||||||
options.config.localized_output_root.clone(),
|
options.config.localized_output_root.clone(),
|
||||||
official_release_id,
|
official_release_id,
|
||||||
patches,
|
Vec::new(),
|
||||||
)
|
)
|
||||||
|
.with_operations(operations)
|
||||||
.with_force(options.config.force);
|
.with_force(options.config.force);
|
||||||
if let Some(release_id) = localized_release_id {
|
if let Some(release_id) = localized_release_id {
|
||||||
config = config.with_localized_release_id(release_id);
|
config = config.with_localized_release_id(release_id);
|
||||||
}
|
}
|
||||||
let report = LocalizedPatchService::new().publish(&config)?;
|
LocalizedPatchService::new().publish(&config)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn run_publish_localized(options: &CliOptions) -> anyhow::Result<()> {
|
||||||
|
let report = publish_localized_report(options)?;
|
||||||
print_report(options.output_format, &report)
|
print_report(options.output_format, &report)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn current_official_release(options: &CliOptions) -> anyhow::Result<(PathBuf, String)> {
|
pub(super) fn localized_rollback_report(
|
||||||
|
options: &CliOptions,
|
||||||
|
) -> anyhow::Result<LocalizedRollbackReport> {
|
||||||
|
LocalizedPatchService::new().rollback(
|
||||||
|
&options.config.localized_output_root,
|
||||||
|
options.localized_release_id.as_deref(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn run_localized_rollback(options: &CliOptions) -> anyhow::Result<()> {
|
||||||
|
let report = localized_rollback_report(options)?;
|
||||||
|
print_report(options.output_format, &report)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn current_official_release(options: &CliOptions) -> anyhow::Result<(PathBuf, String)> {
|
||||||
let state = read_version_state(&options.config.version_state_path())?;
|
let state = read_version_state(&options.config.version_state_path())?;
|
||||||
let resource_root = if let Some(resource_root) = options.resource_root.clone() {
|
let resource_root = if let Some(resource_root) = options.resource_root.clone() {
|
||||||
lexical_absolute(&resource_root).map_err(anyhow::Error::msg)?
|
lexical_absolute(&resource_root).map_err(anyhow::Error::msg)?
|
||||||
|
|||||||
@@ -48,12 +48,14 @@ pub use import::{
|
|||||||
};
|
};
|
||||||
pub use localized_patch::{
|
pub use localized_patch::{
|
||||||
mark_localized_manual_proofreading, read_localized_patch_manifest_at,
|
mark_localized_manual_proofreading, read_localized_patch_manifest_at,
|
||||||
read_localized_version_state, write_localized_version_state, LocalizedPatchConfig,
|
read_localized_version_state, write_localized_version_state, LocalizedFieldPatch,
|
||||||
LocalizedPatchFile, LocalizedPatchIntegrity, LocalizedPatchManifest, LocalizedPatchOperation,
|
LocalizedPatchConfig, LocalizedPatchFile, LocalizedPatchInput, LocalizedPatchIntegrity,
|
||||||
|
LocalizedPatchManifest, LocalizedPatchOperation, LocalizedPatchOperationMetadata,
|
||||||
LocalizedPatchReport, LocalizedPatchRollbackInfo, LocalizedPatchService,
|
LocalizedPatchReport, LocalizedPatchRollbackInfo, LocalizedPatchService,
|
||||||
LocalizedTextAssetPatch, LocalizedTranslationWorkflowReport, LocalizedVersionState,
|
LocalizedRollbackReport, LocalizedStringFieldPatch, LocalizedTextAssetPatch,
|
||||||
LOCALIZED_CURRENT_LINK, LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_PATCH_MANIFEST_VERSION,
|
LocalizedTranslationWorkflowReport, LocalizedVersionState, LOCALIZED_CURRENT_LINK,
|
||||||
LOCALIZED_STAGING_DIR, LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING,
|
LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_PATCH_MANIFEST_VERSION, LOCALIZED_STAGING_DIR,
|
||||||
|
LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING,
|
||||||
LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL, LOCALIZED_VERSIONS_DIR,
|
LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL, LOCALIZED_VERSIONS_DIR,
|
||||||
LOCALIZED_VERSION_STATE_FILE, LOCALIZED_VERSION_STATE_VERSION,
|
LOCALIZED_VERSION_STATE_FILE, LOCALIZED_VERSION_STATE_VERSION,
|
||||||
};
|
};
|
||||||
@@ -155,10 +157,11 @@ pub use translation_worker::{
|
|||||||
MOCK_TRANSLATION_FIXTURE_VERSION,
|
MOCK_TRANSLATION_FIXTURE_VERSION,
|
||||||
};
|
};
|
||||||
pub use translation_workflow::{
|
pub use translation_workflow::{
|
||||||
export_translation_workbench, get_translation_entry, localized_text_asset_patches,
|
completed_worker_translation_workbench, export_completed_worker_translation_workbench,
|
||||||
read_translation_workbench, repack_bundle, set_translation, unset_translation,
|
export_translation_workbench, get_translation_entry, localized_patch_operations,
|
||||||
validate_translation_workbench, write_translation_workbench, RepackOperation, RepackReport,
|
localized_text_asset_patches, read_translation_workbench, repack_bundle, set_translation,
|
||||||
RepackSpec, TranslationWorkbench, TranslationWorkbenchEntry,
|
unset_translation, validate_translation_workbench, write_translation_workbench,
|
||||||
|
RepackOperation, RepackReport, RepackSpec, TranslationWorkbench, TranslationWorkbenchEntry,
|
||||||
TranslationWorkbenchValidationReport, REPACK_SPEC_VERSION, TRANSLATION_WORKBENCH_VERSION,
|
TranslationWorkbenchValidationReport, REPACK_SPEC_VERSION, TRANSLATION_WORKBENCH_VERSION,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
//! Localized release publishing for verified TextAsset patches.
|
//! Localized release publishing for verified UnityFS text patches.
|
||||||
|
|
||||||
use bat_assetbundle::{patch_unityfs_text_asset, TextAssetPatch};
|
use bat_assetbundle::{
|
||||||
|
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch,
|
||||||
|
StringFieldPatch, TextAssetPatch,
|
||||||
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
@@ -37,6 +41,58 @@ pub struct LocalizedTextAssetPatch {
|
|||||||
pub bundle_path: String,
|
pub bundle_path: String,
|
||||||
/// TextAsset replacement inside the bundle.
|
/// TextAsset replacement inside the bundle.
|
||||||
pub text_asset: TextAssetPatch,
|
pub text_asset: TextAssetPatch,
|
||||||
|
/// TextUnit/provider metadata recorded in the localized manifest.
|
||||||
|
pub metadata: Option<LocalizedPatchOperationMetadata>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One TypeTree string patch operation against a bundle in an official release.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct LocalizedStringFieldPatch {
|
||||||
|
/// Relative path of the UnityFS bundle under the official release.
|
||||||
|
pub bundle_path: String,
|
||||||
|
/// String field replacement inside the bundle.
|
||||||
|
pub string_field: StringFieldPatch,
|
||||||
|
/// TextUnit/provider metadata recorded in the localized manifest.
|
||||||
|
pub metadata: Option<LocalizedPatchOperationMetadata>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One semantic TypeTree patch operation against a bundle in an official release.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct LocalizedFieldPatch {
|
||||||
|
/// Relative path of the UnityFS bundle under the official release.
|
||||||
|
pub bundle_path: String,
|
||||||
|
/// Semantic field replacement inside the bundle.
|
||||||
|
pub field: FieldPatch,
|
||||||
|
/// TextUnit/provider metadata recorded in the localized manifest.
|
||||||
|
pub metadata: Option<LocalizedPatchOperationMetadata>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supported localized UnityFS patch operation.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum LocalizedPatchInput {
|
||||||
|
/// Replace a TextAsset payload.
|
||||||
|
TextAsset(LocalizedTextAssetPatch),
|
||||||
|
/// Replace a TypeTree string field.
|
||||||
|
StringField(LocalizedStringFieldPatch),
|
||||||
|
/// Replace a supported semantic TypeTree field.
|
||||||
|
Field(LocalizedFieldPatch),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trace metadata for one localized patch operation.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct LocalizedPatchOperationMetadata {
|
||||||
|
/// Stable TextUnit ID from the official TextUnit index.
|
||||||
|
pub text_unit_id: String,
|
||||||
|
/// BLAKE3 of the source text validated before publication.
|
||||||
|
pub source_text_blake3: String,
|
||||||
|
/// Translation provider that produced the text, if applicable.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub translation_provider: Option<String>,
|
||||||
|
/// Provider run ID that produced the text, if applicable.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub provider_run_id: Option<String>,
|
||||||
|
/// Review state used by the publication input.
|
||||||
|
pub review_status: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configuration for one localized release publication.
|
/// Configuration for one localized release publication.
|
||||||
@@ -57,6 +113,8 @@ pub struct LocalizedPatchConfig {
|
|||||||
pub force: bool,
|
pub force: bool,
|
||||||
/// Patch operations to apply.
|
/// Patch operations to apply.
|
||||||
pub patches: Vec<LocalizedTextAssetPatch>,
|
pub patches: Vec<LocalizedTextAssetPatch>,
|
||||||
|
/// General UnityFS text/field operations to apply.
|
||||||
|
pub operations: Vec<LocalizedPatchInput>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LocalizedPatchConfig {
|
impl LocalizedPatchConfig {
|
||||||
@@ -74,6 +132,7 @@ impl LocalizedPatchConfig {
|
|||||||
localized_release_id: None,
|
localized_release_id: None,
|
||||||
force: false,
|
force: false,
|
||||||
patches,
|
patches,
|
||||||
|
operations: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,11 +148,67 @@ impl LocalizedPatchConfig {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sets general localized patch operations.
|
||||||
|
pub fn with_operations(mut self, operations: Vec<LocalizedPatchInput>) -> Self {
|
||||||
|
self.operations = operations;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
fn published_release_id(&self) -> &str {
|
fn published_release_id(&self) -> &str {
|
||||||
self.localized_release_id
|
self.localized_release_id
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.unwrap_or(&self.release_id)
|
.unwrap_or(&self.release_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn patch_operations(&self) -> Vec<LocalizedPatchInput> {
|
||||||
|
let mut operations = self
|
||||||
|
.patches
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(LocalizedPatchInput::TextAsset)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
operations.extend(self.operations.iter().cloned());
|
||||||
|
operations
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LocalizedPatchInput {
|
||||||
|
fn bundle_path(&self) -> &str {
|
||||||
|
match self {
|
||||||
|
Self::TextAsset(operation) => &operation.bundle_path,
|
||||||
|
Self::StringField(operation) => &operation.bundle_path,
|
||||||
|
Self::Field(operation) => &operation.bundle_path,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply(&self, input: &[u8]) -> anyhow::Result<Vec<u8>> {
|
||||||
|
match self {
|
||||||
|
Self::TextAsset(operation) => {
|
||||||
|
Ok(patch_unityfs_text_asset(input, &operation.text_asset)?)
|
||||||
|
}
|
||||||
|
Self::StringField(operation) => {
|
||||||
|
Ok(patch_unityfs_string_field(input, &operation.string_field)?)
|
||||||
|
}
|
||||||
|
Self::Field(operation) => Ok(patch_unityfs_field(input, &operation.field)?),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest_operation(&self) -> anyhow::Result<LocalizedPatchOperation> {
|
||||||
|
match self {
|
||||||
|
Self::TextAsset(operation) => Ok(LocalizedPatchOperation::from_text_asset_patch(
|
||||||
|
&operation.text_asset,
|
||||||
|
operation.metadata.as_ref(),
|
||||||
|
)),
|
||||||
|
Self::StringField(operation) => Ok(LocalizedPatchOperation::from_string_field_patch(
|
||||||
|
&operation.string_field,
|
||||||
|
operation.metadata.as_ref(),
|
||||||
|
)),
|
||||||
|
Self::Field(operation) => LocalizedPatchOperation::from_field_patch(
|
||||||
|
&operation.field,
|
||||||
|
operation.metadata.as_ref(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persisted localized release state.
|
/// Persisted localized release state.
|
||||||
@@ -187,16 +302,38 @@ pub struct LocalizedPatchFile {
|
|||||||
/// One TextAsset patch operation recorded in the localized patch manifest.
|
/// One TextAsset patch operation recorded in the localized patch manifest.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct LocalizedPatchOperation {
|
pub struct LocalizedPatchOperation {
|
||||||
|
/// Patch operation kind, for example `unityfs_text_asset`.
|
||||||
|
#[serde(default = "default_patch_operation_kind")]
|
||||||
|
pub patch_kind: String,
|
||||||
/// UnityFS directory path of the serialized file.
|
/// UnityFS directory path of the serialized file.
|
||||||
pub serialized_file_path: String,
|
pub serialized_file_path: String,
|
||||||
/// Unity object path ID.
|
/// Unity object path ID.
|
||||||
pub path_id: i64,
|
pub path_id: i64,
|
||||||
|
/// TypeTree field path for field-level patches.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub field_path: Option<String>,
|
||||||
/// Expected TextAsset name, when provided.
|
/// Expected TextAsset name, when provided.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub expected_name: Option<String>,
|
pub expected_name: Option<String>,
|
||||||
/// Replacement payload size.
|
/// Replacement payload size.
|
||||||
pub replacement_bytes: u64,
|
pub replacement_bytes: u64,
|
||||||
/// BLAKE3 of the replacement payload.
|
/// BLAKE3 of the replacement payload.
|
||||||
pub replacement_blake3: String,
|
pub replacement_blake3: String,
|
||||||
|
/// Stable TextUnit ID from the official TextUnit index.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub text_unit_id: Option<String>,
|
||||||
|
/// BLAKE3 of the source text validated before publication.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub source_text_blake3: Option<String>,
|
||||||
|
/// Translation provider that produced the text, if applicable.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub translation_provider: Option<String>,
|
||||||
|
/// Provider run ID that produced the text, if applicable.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub provider_run_id: Option<String>,
|
||||||
|
/// Review state used by the publication input.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub review_status: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rollback information recorded for a localized publication.
|
/// Rollback information recorded for a localized publication.
|
||||||
@@ -288,7 +425,34 @@ pub struct LocalizedPatchReport {
|
|||||||
pub integrity: LocalizedPatchIntegrity,
|
pub integrity: LocalizedPatchIntegrity,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Applies TextAsset patches and atomically publishes a localized release.
|
/// Result of rolling back the current localized release.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct LocalizedRollbackReport {
|
||||||
|
/// Stable command name.
|
||||||
|
pub command: &'static str,
|
||||||
|
/// Operation status.
|
||||||
|
pub status: &'static str,
|
||||||
|
/// Localized output root.
|
||||||
|
pub localized_output_root: PathBuf,
|
||||||
|
/// Atomic current pointer.
|
||||||
|
pub current_path: PathBuf,
|
||||||
|
/// Version state path.
|
||||||
|
pub state_path: PathBuf,
|
||||||
|
/// Localized release that was current before rollback.
|
||||||
|
pub rolled_back_release_id: String,
|
||||||
|
/// Removed current version directory.
|
||||||
|
pub removed_version_path: PathBuf,
|
||||||
|
/// Restored localized release ID, if a previous release existed.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub restored_release_id: Option<String>,
|
||||||
|
/// Restored current symlink target, if a previous release existed.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub restored_current_target: Option<PathBuf>,
|
||||||
|
/// New persisted localized version state.
|
||||||
|
pub state: LocalizedVersionState,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies supported UnityFS text patches and atomically publishes a localized release.
|
||||||
#[derive(Debug, Default, Clone, Copy)]
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
pub struct LocalizedPatchService;
|
pub struct LocalizedPatchService;
|
||||||
|
|
||||||
@@ -310,7 +474,11 @@ impl LocalizedPatchService {
|
|||||||
.join(LOCALIZED_VERSIONS_DIR)
|
.join(LOCALIZED_VERSIONS_DIR)
|
||||||
.join(&published_release_id);
|
.join(&published_release_id);
|
||||||
let current_path = config.localized_output_root.join(LOCALIZED_CURRENT_LINK);
|
let current_path = config.localized_output_root.join(LOCALIZED_CURRENT_LINK);
|
||||||
let previous_current_target = current_symlink_target(¤t_path).ok().flatten();
|
validate_config(config).map_err(anyhow::Error::msg)?;
|
||||||
|
let previous_current_target = current_symlink_target(¤t_path)?;
|
||||||
|
if let Some(target) = previous_current_target.as_deref() {
|
||||||
|
validate_previous_current_target(&config.localized_output_root, target)?;
|
||||||
|
}
|
||||||
let version_existed_before = version_path.exists();
|
let version_existed_before = version_path.exists();
|
||||||
match self.publish_inner(config, previous_current_target.clone()) {
|
match self.publish_inner(config, previous_current_target.clone()) {
|
||||||
Ok(report) => Ok(report),
|
Ok(report) => Ok(report),
|
||||||
@@ -332,6 +500,159 @@ impl LocalizedPatchService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rolls back the current localized release to the manifest-recorded
|
||||||
|
/// previous current target.
|
||||||
|
pub fn rollback(
|
||||||
|
&self,
|
||||||
|
localized_output_root: &Path,
|
||||||
|
expected_release_id: Option<&str>,
|
||||||
|
) -> anyhow::Result<LocalizedRollbackReport> {
|
||||||
|
ensure_safe_directory_path(localized_output_root, "汉化输出目录")
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
let versions_root = localized_output_root.join(LOCALIZED_VERSIONS_DIR);
|
||||||
|
ensure_safe_directory_path(&versions_root, "汉化 versions 目录")
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
let current_path = localized_output_root.join(LOCALIZED_CURRENT_LINK);
|
||||||
|
let state_path = localized_output_root.join(LOCALIZED_VERSION_STATE_FILE);
|
||||||
|
let state = read_localized_version_state(localized_output_root)?.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("缺少汉化版本状态,无法 rollback:{}", state_path.display())
|
||||||
|
})?;
|
||||||
|
let current_release_id = state
|
||||||
|
.current_release_id
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("当前没有已发布汉化 release,无法 rollback"))?;
|
||||||
|
if let Some(expected) = expected_release_id {
|
||||||
|
if expected != current_release_id {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"rollback 目标 release 不一致:当前={} 请求={}",
|
||||||
|
current_release_id,
|
||||||
|
expected
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let version_path = localized_output_root
|
||||||
|
.join(LOCALIZED_VERSIONS_DIR)
|
||||||
|
.join(¤t_release_id);
|
||||||
|
ensure_safe_directory_path(&version_path, "当前汉化 release")
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
if !current_points_to_version(¤t_path, &version_path)? {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"汉化 current 未指向当前状态 release:current={} version={}",
|
||||||
|
current_path.display(),
|
||||||
|
version_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let manifest = read_localized_patch_manifest_at(&version_path)?.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"缺少当前汉化 release manifest:{}",
|
||||||
|
version_path.join(LOCALIZED_PATCH_MANIFEST_FILE).display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if manifest.localized_release_id != current_release_id {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"manifest release={} 与当前状态 release={} 不一致",
|
||||||
|
manifest.localized_release_id,
|
||||||
|
current_release_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if manifest.official_release_id != state.official_release_id {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"manifest 官方 release={} 与状态 release={} 不一致",
|
||||||
|
manifest.official_release_id,
|
||||||
|
state.official_release_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let remove_version_path = manifest.rollback.remove_version_path.clone();
|
||||||
|
let expected_version_path = localized_output_root
|
||||||
|
.join(LOCALIZED_VERSIONS_DIR)
|
||||||
|
.join(¤t_release_id);
|
||||||
|
if lexical_absolute(&remove_version_path).map_err(anyhow::Error::msg)?
|
||||||
|
!= lexical_absolute(&expected_version_path).map_err(anyhow::Error::msg)?
|
||||||
|
{
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"rollback manifest 删除路径必须指向当前 release:{}",
|
||||||
|
current_release_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
ensure_path_within_root(localized_output_root, &remove_version_path)
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
let restored_release_id =
|
||||||
|
match manifest.rollback.previous_current_target.as_deref() {
|
||||||
|
None => None,
|
||||||
|
Some(target) => Some(release_id_from_current_target(target).ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("rollback manifest 上一 current 目标格式无效")
|
||||||
|
})?),
|
||||||
|
};
|
||||||
|
let mut restored_manifest = None;
|
||||||
|
if let Some(previous_target) = manifest.rollback.previous_current_target.as_deref() {
|
||||||
|
let previous_path = localized_output_root.join(previous_target);
|
||||||
|
ensure_path_within_root(localized_output_root, &previous_path)
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
ensure_safe_directory_path(&previous_path, "上一汉化 release")
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
if !previous_path.is_dir() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"rollback 记录的上一汉化 release 不存在:{}",
|
||||||
|
previous_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
restored_manifest = Some(
|
||||||
|
read_localized_patch_manifest_at(&previous_path)?.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"rollback 记录的上一汉化 release 缺少 manifest:{}",
|
||||||
|
previous_path.display()
|
||||||
|
)
|
||||||
|
})?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_current_symlink(
|
||||||
|
localized_output_root,
|
||||||
|
¤t_path,
|
||||||
|
manifest.rollback.previous_current_target.as_ref(),
|
||||||
|
)?;
|
||||||
|
remove_owned_path(&remove_version_path)?;
|
||||||
|
|
||||||
|
let previous_official_release_id = state.official_release_id;
|
||||||
|
let previous_workflow_status = state.translation_workflow_status;
|
||||||
|
let restored_official_release_id = restored_manifest
|
||||||
|
.as_ref()
|
||||||
|
.map(|manifest| manifest.official_release_id.clone())
|
||||||
|
.unwrap_or_else(|| previous_official_release_id.clone());
|
||||||
|
let translation_workflow_status =
|
||||||
|
if restored_official_release_id == previous_official_release_id {
|
||||||
|
previous_workflow_status
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let new_state = LocalizedVersionState {
|
||||||
|
state_version: LOCALIZED_VERSION_STATE_VERSION,
|
||||||
|
official_release_id: restored_official_release_id,
|
||||||
|
current_release_id: restored_release_id.clone(),
|
||||||
|
status: if restored_release_id.is_some() {
|
||||||
|
"localized".to_string()
|
||||||
|
} else {
|
||||||
|
"not_localized".to_string()
|
||||||
|
},
|
||||||
|
translation_workflow_status,
|
||||||
|
updated_unix_seconds: unix_seconds_now(),
|
||||||
|
};
|
||||||
|
write_localized_version_state(localized_output_root, &new_state)?;
|
||||||
|
|
||||||
|
Ok(LocalizedRollbackReport {
|
||||||
|
command: "localized.rollback",
|
||||||
|
status: "rolled_back",
|
||||||
|
localized_output_root: localized_output_root.to_path_buf(),
|
||||||
|
current_path,
|
||||||
|
state_path,
|
||||||
|
rolled_back_release_id: current_release_id,
|
||||||
|
removed_version_path: remove_version_path,
|
||||||
|
restored_release_id,
|
||||||
|
restored_current_target: manifest.rollback.previous_current_target,
|
||||||
|
state: new_state,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn publish_inner(
|
fn publish_inner(
|
||||||
&self,
|
&self,
|
||||||
config: &LocalizedPatchConfig,
|
config: &LocalizedPatchConfig,
|
||||||
@@ -350,53 +671,85 @@ impl LocalizedPatchService {
|
|||||||
let state_path = config
|
let state_path = config
|
||||||
.localized_output_root
|
.localized_output_root
|
||||||
.join(LOCALIZED_VERSION_STATE_FILE);
|
.join(LOCALIZED_VERSION_STATE_FILE);
|
||||||
|
let versions_root = config.localized_output_root.join(LOCALIZED_VERSIONS_DIR);
|
||||||
|
let staging_root = config.localized_output_root.join(LOCALIZED_STAGING_DIR);
|
||||||
|
ensure_safe_directory_path(&versions_root, "汉化 versions 目录")
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
|
ensure_safe_directory_path(&staging_root, "汉化 staging 目录")
|
||||||
|
.map_err(anyhow::Error::msg)?;
|
||||||
let patch_manifest_path = version_path.join(LOCALIZED_PATCH_MANIFEST_FILE);
|
let patch_manifest_path = version_path.join(LOCALIZED_PATCH_MANIFEST_FILE);
|
||||||
let previous_state = read_localized_version_state(&config.localized_output_root)?;
|
let previous_state = read_localized_version_state(&config.localized_output_root)?;
|
||||||
let translation_workflow_status = previous_state
|
let translation_workflow_status = previous_state
|
||||||
.filter(|state| state.official_release_id == config.release_id)
|
.filter(|state| state.official_release_id == config.release_id)
|
||||||
.and_then(|state| state.translation_workflow_status);
|
.and_then(|state| state.translation_workflow_status);
|
||||||
|
|
||||||
if version_path.exists() {
|
match fs::symlink_metadata(&version_path) {
|
||||||
let message = if config.force {
|
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||||
"localized release target already exists; forced publication requires a distinct localized release id"
|
return Err(anyhow::anyhow!(
|
||||||
} else {
|
"汉化 release 目标不能是 symlink:{}",
|
||||||
"localized release already exists"
|
version_path.display()
|
||||||
};
|
));
|
||||||
return Err(anyhow::anyhow!("{message}: {}", version_path.display()));
|
}
|
||||||
|
Ok(metadata) if !metadata.is_dir() => {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"汉化 release 目标已存在但不是目录:{}",
|
||||||
|
version_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
let message = if config.force {
|
||||||
|
"localized release target already exists; forced publication requires a distinct localized release id"
|
||||||
|
} else {
|
||||||
|
"localized release already exists"
|
||||||
|
};
|
||||||
|
return Err(anyhow::anyhow!("{message}: {}", version_path.display()));
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||||
|
Err(error) => return Err(error.into()),
|
||||||
}
|
}
|
||||||
remove_owned_staging(&staging)?;
|
remove_owned_staging(&staging)?;
|
||||||
fs::create_dir_all(&staging)?;
|
fs::create_dir_all(&staging)?;
|
||||||
copy_tree(&config.official_release_root, &staging)?;
|
copy_tree(&config.official_release_root, &staging)?;
|
||||||
|
|
||||||
let mut changed_files = Vec::with_capacity(config.patches.len());
|
let operations = config.patch_operations();
|
||||||
for operation in &config.patches {
|
let mut grouped = BTreeMap::<String, Vec<LocalizedPatchInput>>::new();
|
||||||
let target = staging.join(Path::new(&operation.bundle_path));
|
for operation in operations {
|
||||||
|
grouped
|
||||||
|
.entry(operation.bundle_path().to_string())
|
||||||
|
.or_default()
|
||||||
|
.push(operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut changed_files = Vec::with_capacity(grouped.len());
|
||||||
|
for (bundle_path, operations) in grouped {
|
||||||
|
let target = staging.join(Path::new(&bundle_path));
|
||||||
ensure_path_within_root(&staging, &target).map_err(anyhow::Error::msg)?;
|
ensure_path_within_root(&staging, &target).map_err(anyhow::Error::msg)?;
|
||||||
ensure_safe_file_target(&staging, &target, "汉化 patch 输入")
|
ensure_safe_file_target(&staging, &target, "汉化 patch 输入")
|
||||||
.map_err(anyhow::Error::msg)?;
|
.map_err(anyhow::Error::msg)?;
|
||||||
let original = fs::read(&target)?;
|
let original = fs::read(&target)?;
|
||||||
let patched = patch_unityfs_text_asset(&original, &operation.text_asset)
|
let mut patched = original.clone();
|
||||||
.map_err(|error| anyhow::anyhow!("{}: {error}", operation.bundle_path))?;
|
let mut manifest_operations = Vec::with_capacity(operations.len());
|
||||||
|
for operation in operations {
|
||||||
|
patched = operation
|
||||||
|
.apply(&patched)
|
||||||
|
.map_err(|error| anyhow::anyhow!("{bundle_path}: {error}"))?;
|
||||||
|
manifest_operations.push(operation.manifest_operation()?);
|
||||||
|
}
|
||||||
if original == patched {
|
if original == patched {
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!("patch produced no change: {bundle_path}"));
|
||||||
"patch produced no change: {}",
|
|
||||||
operation.bundle_path
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
write_file_atomic(&target, &patched, STATE_FILE_MODE, "汉化 patch 输出")
|
write_file_atomic(&target, &patched, STATE_FILE_MODE, "汉化 patch 输出")
|
||||||
.map_err(anyhow::Error::msg)?;
|
.map_err(anyhow::Error::msg)?;
|
||||||
let original_blake3 = blake3::hash(&original).to_hex().to_string();
|
let original_blake3 = blake3::hash(&original).to_hex().to_string();
|
||||||
let localized_blake3 = blake3::hash(&patched).to_hex().to_string();
|
let localized_blake3 = blake3::hash(&patched).to_hex().to_string();
|
||||||
changed_files.push(LocalizedPatchFile {
|
changed_files.push(LocalizedPatchFile {
|
||||||
path: operation.bundle_path.clone(),
|
path: bundle_path,
|
||||||
original_blake3,
|
original_blake3,
|
||||||
localized_blake3,
|
localized_blake3,
|
||||||
original_bytes: original.len() as u64,
|
original_bytes: original.len() as u64,
|
||||||
localized_bytes: patched.len() as u64,
|
localized_bytes: patched.len() as u64,
|
||||||
byte_delta: patched.len() as i64 - original.len() as i64,
|
byte_delta: patched.len() as i64 - original.len() as i64,
|
||||||
text_asset_operations: vec![LocalizedPatchOperation::from_text_asset_patch(
|
text_asset_operations: manifest_operations,
|
||||||
&operation.text_asset,
|
|
||||||
)],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,13 +924,112 @@ pub fn read_localized_patch_manifest_at(
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl LocalizedPatchOperation {
|
impl LocalizedPatchOperation {
|
||||||
fn from_text_asset_patch(patch: &TextAssetPatch) -> Self {
|
fn from_text_asset_patch(
|
||||||
|
patch: &TextAssetPatch,
|
||||||
|
metadata: Option<&LocalizedPatchOperationMetadata>,
|
||||||
|
) -> Self {
|
||||||
|
Self::with_metadata(
|
||||||
|
Self {
|
||||||
|
patch_kind: "unityfs_text_asset".to_string(),
|
||||||
|
serialized_file_path: patch.serialized_file_path.clone(),
|
||||||
|
path_id: patch.path_id,
|
||||||
|
field_path: None,
|
||||||
|
expected_name: patch.expected_name.clone(),
|
||||||
|
replacement_bytes: patch.replacement.len() as u64,
|
||||||
|
replacement_blake3: blake3::hash(&patch.replacement).to_hex().to_string(),
|
||||||
|
text_unit_id: None,
|
||||||
|
source_text_blake3: None,
|
||||||
|
translation_provider: None,
|
||||||
|
provider_run_id: None,
|
||||||
|
review_status: None,
|
||||||
|
},
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_string_field_patch(
|
||||||
|
patch: &StringFieldPatch,
|
||||||
|
metadata: Option<&LocalizedPatchOperationMetadata>,
|
||||||
|
) -> Self {
|
||||||
|
Self::with_metadata(
|
||||||
|
Self {
|
||||||
|
patch_kind: "unityfs_string_field".to_string(),
|
||||||
|
serialized_file_path: patch.serialized_file_path.clone(),
|
||||||
|
path_id: patch.path_id,
|
||||||
|
field_path: Some(patch.field_path.clone()),
|
||||||
|
expected_name: None,
|
||||||
|
replacement_bytes: patch.replacement.len() as u64,
|
||||||
|
replacement_blake3: blake3::hash(patch.replacement.as_bytes())
|
||||||
|
.to_hex()
|
||||||
|
.to_string(),
|
||||||
|
text_unit_id: None,
|
||||||
|
source_text_blake3: None,
|
||||||
|
translation_provider: None,
|
||||||
|
provider_run_id: None,
|
||||||
|
review_status: None,
|
||||||
|
},
|
||||||
|
metadata,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_field_patch(
|
||||||
|
patch: &FieldPatch,
|
||||||
|
metadata: Option<&LocalizedPatchOperationMetadata>,
|
||||||
|
) -> anyhow::Result<Self> {
|
||||||
|
let replacement = serde_json::to_vec(&patch.replacement)?;
|
||||||
|
Ok(Self::with_metadata(
|
||||||
|
Self {
|
||||||
|
patch_kind: "unityfs_field".to_string(),
|
||||||
|
serialized_file_path: patch.serialized_file_path.clone(),
|
||||||
|
path_id: patch.path_id,
|
||||||
|
field_path: Some(patch.field_path.clone()),
|
||||||
|
expected_name: None,
|
||||||
|
replacement_bytes: replacement.len() as u64,
|
||||||
|
replacement_blake3: blake3::hash(&replacement).to_hex().to_string(),
|
||||||
|
text_unit_id: None,
|
||||||
|
source_text_blake3: None,
|
||||||
|
translation_provider: None,
|
||||||
|
provider_run_id: None,
|
||||||
|
review_status: None,
|
||||||
|
},
|
||||||
|
metadata,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_metadata(
|
||||||
|
mut operation: Self,
|
||||||
|
metadata: Option<&LocalizedPatchOperationMetadata>,
|
||||||
|
) -> Self {
|
||||||
|
if let Some(metadata) = metadata {
|
||||||
|
operation.text_unit_id = Some(metadata.text_unit_id.clone());
|
||||||
|
operation.source_text_blake3 = Some(metadata.source_text_blake3.clone());
|
||||||
|
operation.translation_provider = metadata.translation_provider.clone();
|
||||||
|
operation.provider_run_id = metadata.provider_run_id.clone();
|
||||||
|
operation.review_status = Some(metadata.review_status.clone());
|
||||||
|
}
|
||||||
|
operation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_patch_operation_kind() -> String {
|
||||||
|
"unityfs_text_asset".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for LocalizedPatchOperation {
|
||||||
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
serialized_file_path: patch.serialized_file_path.clone(),
|
patch_kind: default_patch_operation_kind(),
|
||||||
path_id: patch.path_id,
|
serialized_file_path: String::new(),
|
||||||
expected_name: patch.expected_name.clone(),
|
path_id: 0,
|
||||||
replacement_bytes: patch.replacement.len() as u64,
|
field_path: None,
|
||||||
replacement_blake3: blake3::hash(&patch.replacement).to_hex().to_string(),
|
expected_name: None,
|
||||||
|
replacement_bytes: 0,
|
||||||
|
replacement_blake3: String::new(),
|
||||||
|
text_unit_id: None,
|
||||||
|
source_text_blake3: None,
|
||||||
|
translation_provider: None,
|
||||||
|
provider_run_id: None,
|
||||||
|
review_status: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -690,6 +1142,25 @@ fn current_symlink_target(current_path: &Path) -> anyhow::Result<Option<PathBuf>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_previous_current_target(root: &Path, target: &Path) -> anyhow::Result<()> {
|
||||||
|
if release_id_from_current_target(target).is_none() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"汉化 current 目标不是受支持的 versions/<release> 路径:{}",
|
||||||
|
target.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let target_path = root.join(target);
|
||||||
|
ensure_path_within_root(root, &target_path).map_err(anyhow::Error::msg)?;
|
||||||
|
ensure_safe_directory_path(&target_path, "汉化 current 目标").map_err(anyhow::Error::msg)?;
|
||||||
|
if !target_path.is_dir() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"汉化 current 目标 release 不存在:{}",
|
||||||
|
target_path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn current_points_to_version(current_path: &Path, version_path: &Path) -> anyhow::Result<bool> {
|
fn current_points_to_version(current_path: &Path, version_path: &Path) -> anyhow::Result<bool> {
|
||||||
let metadata = fs::symlink_metadata(current_path)?;
|
let metadata = fs::symlink_metadata(current_path)?;
|
||||||
if !metadata.file_type().is_symlink() {
|
if !metadata.file_type().is_symlink() {
|
||||||
@@ -698,6 +1169,18 @@ fn current_points_to_version(current_path: &Path, version_path: &Path) -> anyhow
|
|||||||
Ok(fs::canonicalize(current_path)? == fs::canonicalize(version_path)?)
|
Ok(fs::canonicalize(current_path)? == fs::canonicalize(version_path)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn release_id_from_current_target(target: &Path) -> Option<String> {
|
||||||
|
let mut components = target.components();
|
||||||
|
match (components.next(), components.next(), components.next()) {
|
||||||
|
(
|
||||||
|
Some(std::path::Component::Normal(root)),
|
||||||
|
Some(std::path::Component::Normal(release_id)),
|
||||||
|
None,
|
||||||
|
) if root == LOCALIZED_VERSIONS_DIR => release_id.to_str().map(str::to_string),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn rollback_failed_publish(
|
fn rollback_failed_publish(
|
||||||
localized_output_root: &Path,
|
localized_output_root: &Path,
|
||||||
staging: &Path,
|
staging: &Path,
|
||||||
@@ -907,11 +1390,18 @@ mod tests {
|
|||||||
localized_bytes: target.len() as u64,
|
localized_bytes: target.len() as u64,
|
||||||
byte_delta: target.len() as i64 - source.len() as i64,
|
byte_delta: target.len() as i64 - source.len() as i64,
|
||||||
text_asset_operations: vec![LocalizedPatchOperation {
|
text_asset_operations: vec![LocalizedPatchOperation {
|
||||||
|
patch_kind: "unityfs_text_asset".to_string(),
|
||||||
serialized_file_path: "CAB-asset".to_string(),
|
serialized_file_path: "CAB-asset".to_string(),
|
||||||
path_id: 1,
|
path_id: 1,
|
||||||
|
field_path: None,
|
||||||
expected_name: Some("Text".to_string()),
|
expected_name: Some("Text".to_string()),
|
||||||
replacement_bytes: target.len() as u64,
|
replacement_bytes: target.len() as u64,
|
||||||
replacement_blake3: blake3::hash(target).to_hex().to_string(),
|
replacement_blake3: blake3::hash(target).to_hex().to_string(),
|
||||||
|
text_unit_id: Some("unit-1".to_string()),
|
||||||
|
source_text_blake3: Some(blake3::hash(source).to_hex().to_string()),
|
||||||
|
translation_provider: Some("mock".to_string()),
|
||||||
|
provider_run_id: Some("mock:unit-1:attempt-1".to_string()),
|
||||||
|
review_status: Some("provider_completed".to_string()),
|
||||||
}],
|
}],
|
||||||
}],
|
}],
|
||||||
rollback: LocalizedPatchRollbackInfo {
|
rollback: LocalizedPatchRollbackInfo {
|
||||||
@@ -987,6 +1477,85 @@ mod tests {
|
|||||||
assert_eq!(manifest.rollback.previous_current_target, None);
|
assert_eq!(manifest.rollback.previous_current_target, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn rollback_restores_previous_localized_release() {
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let official = temp.path().join("official-release");
|
||||||
|
let localized = temp.path().join("localized");
|
||||||
|
fs::create_dir_all(official.join("TableBundles")).unwrap();
|
||||||
|
fs::write(
|
||||||
|
official.join("TableBundles/TableCatalog.bytes"),
|
||||||
|
b"official",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let service = LocalizedPatchService::new();
|
||||||
|
|
||||||
|
service
|
||||||
|
.publish(&LocalizedPatchConfig::new(
|
||||||
|
&official,
|
||||||
|
&localized,
|
||||||
|
"release-1",
|
||||||
|
Vec::new(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
service
|
||||||
|
.publish(
|
||||||
|
&LocalizedPatchConfig::new(&official, &localized, "release-1", Vec::new())
|
||||||
|
.with_localized_release_id("release-2"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let report = service.rollback(&localized, Some("release-2")).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(report.rolled_back_release_id, "release-2");
|
||||||
|
assert_eq!(report.restored_release_id.as_deref(), Some("release-1"));
|
||||||
|
assert!(!localized
|
||||||
|
.join(LOCALIZED_VERSIONS_DIR)
|
||||||
|
.join("release-2")
|
||||||
|
.exists());
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_link(localized.join(LOCALIZED_CURRENT_LINK)).unwrap(),
|
||||||
|
PathBuf::from("versions/release-1")
|
||||||
|
);
|
||||||
|
let state = read_localized_version_state(&localized).unwrap().unwrap();
|
||||||
|
assert_eq!(state.status, "localized");
|
||||||
|
assert_eq!(state.current_release_id.as_deref(), Some("release-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn publish_rejects_invalid_existing_current_target() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let temp = TempDir::new().unwrap();
|
||||||
|
let official = temp.path().join("official-release");
|
||||||
|
let localized = temp.path().join("localized");
|
||||||
|
fs::create_dir_all(official.join("TableBundles")).unwrap();
|
||||||
|
fs::write(
|
||||||
|
official.join("TableBundles/TableCatalog.bytes"),
|
||||||
|
b"official",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
fs::create_dir_all(localized.join(LOCALIZED_VERSIONS_DIR)).unwrap();
|
||||||
|
symlink(
|
||||||
|
temp.path().join("outside"),
|
||||||
|
localized.join(LOCALIZED_CURRENT_LINK),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let error = LocalizedPatchService::new()
|
||||||
|
.publish(&LocalizedPatchConfig::new(
|
||||||
|
&official,
|
||||||
|
&localized,
|
||||||
|
"release-1",
|
||||||
|
Vec::new(),
|
||||||
|
))
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(error.to_string().contains("current 目标不是受支持"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn localized_state_reads_legacy_file_without_workflow_status() {
|
fn localized_state_reads_legacy_file_without_workflow_status() {
|
||||||
let temp = TempDir::new().unwrap();
|
let temp = TempDir::new().unwrap();
|
||||||
@@ -1060,6 +1629,7 @@ mod tests {
|
|||||||
vec![LocalizedTextAssetPatch {
|
vec![LocalizedTextAssetPatch {
|
||||||
bundle_path: "Bundles/bad.bundle".to_string(),
|
bundle_path: "Bundles/bad.bundle".to_string(),
|
||||||
text_asset: TextAssetPatch::new("CAB-bad", 1, b"replacement".to_vec()),
|
text_asset: TextAssetPatch::new("CAB-bad", 1, b"replacement".to_vec()),
|
||||||
|
metadata: None,
|
||||||
}],
|
}],
|
||||||
))
|
))
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
//! Manual translation workbench and controlled UnityFS repack workflows.
|
//! Manual translation workbench and controlled UnityFS repack workflows.
|
||||||
|
|
||||||
use crate::official_parse::{read_textunit_index_at, OfficialTextUnitIndexUnit};
|
use crate::official_parse::{read_textunit_index_at, OfficialTextUnitIndexUnit};
|
||||||
|
use crate::official_textunit_queue::OfficialTextUnitTaskQuery;
|
||||||
use crate::path_security::{
|
use crate::path_security::{
|
||||||
ensure_safe_file_target, lexical_absolute, read_file_no_symlink, write_file_atomic,
|
ensure_safe_file_target, lexical_absolute, read_file_no_symlink, write_file_atomic,
|
||||||
STATE_FILE_MODE,
|
STATE_FILE_MODE,
|
||||||
};
|
};
|
||||||
use crate::LocalizedTextAssetPatch;
|
use crate::{
|
||||||
|
LocalizedPatchInput, LocalizedPatchOperationMetadata, LocalizedStringFieldPatch,
|
||||||
|
LocalizedTextAssetPatch, PersistedTranslationTask, SqliteTranslationTaskRepository,
|
||||||
|
TranslationTaskStatus, TranslationTaskUnitResult,
|
||||||
|
};
|
||||||
use bat_assetbundle::{
|
use bat_assetbundle::{
|
||||||
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch,
|
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch,
|
||||||
StringFieldPatch, TextAssetPatch, UnitySerializedReplacementValue,
|
StringFieldPatch, TextAssetPatch, UnitySerializedReplacementValue,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::{BTreeSet, HashMap};
|
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
@@ -52,12 +57,27 @@ pub struct TranslationWorkbenchEntry {
|
|||||||
/// Unity TextAsset name, when known.
|
/// Unity TextAsset name, when known.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub asset_name: Option<String>,
|
pub asset_name: Option<String>,
|
||||||
|
/// TypeTree field path, when the source is a field-level TextUnit.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub field_path: Option<String>,
|
||||||
/// Extracted source text. This is checked again before publishing.
|
/// Extracted source text. This is checked again before publishing.
|
||||||
pub source_text: String,
|
pub source_text: String,
|
||||||
/// Human translation. `null` means not reviewed yet; an empty string is
|
/// Human translation. `null` means not reviewed yet; an empty string is
|
||||||
/// an intentional empty translation.
|
/// an intentional empty translation.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub translated_text: Option<String>,
|
pub translated_text: Option<String>,
|
||||||
|
/// Provider that produced this translation, when imported from worker output.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub translation_provider: Option<String>,
|
||||||
|
/// Provider run that produced this translation, when imported from worker output.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub provider_run_id: Option<String>,
|
||||||
|
/// Worker completion time for provider-produced text.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub translated_unix_seconds: Option<u64>,
|
||||||
|
/// Review state used by publish manifest metadata.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub review_status: Option<String>,
|
||||||
/// TextUnit format.
|
/// TextUnit format.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub format: Option<String>,
|
pub format: Option<String>,
|
||||||
@@ -83,7 +103,7 @@ pub struct TranslationWorkbenchValidationReport {
|
|||||||
pub changed_entries: usize,
|
pub changed_entries: usize,
|
||||||
/// Changed direct TextAsset entries usable by `i18n publish`.
|
/// Changed direct TextAsset entries usable by `i18n publish`.
|
||||||
pub publishable_entries: usize,
|
pub publishable_entries: usize,
|
||||||
/// Changed TypeTree or nested-archive entries requiring `parse repack`.
|
/// Changed entries outside the direct localized publish support range.
|
||||||
pub repack_entries: usize,
|
pub repack_entries: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +136,94 @@ pub fn export_translation_workbench(
|
|||||||
Ok(workbench)
|
Ok(workbench)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds a workbench from completed provider worker results.
|
||||||
|
pub async fn completed_worker_translation_workbench(
|
||||||
|
resource_root: &Path,
|
||||||
|
official_release_id: &str,
|
||||||
|
) -> anyhow::Result<TranslationWorkbench> {
|
||||||
|
let index = read_textunit_index_at(resource_root)
|
||||||
|
.map_err(anyhow::Error::msg)?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("缺少当前官方 TextUnit 索引"))?;
|
||||||
|
let index_by_id = index
|
||||||
|
.units
|
||||||
|
.iter()
|
||||||
|
.map(|unit| (unit.id.as_str(), unit))
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
let repository_path = SqliteTranslationTaskRepository::repository_path(resource_root);
|
||||||
|
let repository = SqliteTranslationTaskRepository::open(&repository_path)
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("打开翻译任务状态库失败:{error}"))?;
|
||||||
|
let tasks = repository
|
||||||
|
.list(&OfficialTextUnitTaskQuery {
|
||||||
|
official_release_id: Some(official_release_id.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|error| anyhow::anyhow!("读取翻译任务状态失败:{error}"))?;
|
||||||
|
let mut result_by_unit = BTreeMap::new();
|
||||||
|
for task in tasks
|
||||||
|
.iter()
|
||||||
|
.filter(|task| task.task_status == TranslationTaskStatus::Completed)
|
||||||
|
{
|
||||||
|
if task.task.official_release_id != official_release_id {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"worker 任务 {} 的官方 release={} 与当前 release={} 不一致",
|
||||||
|
task.task.task_id,
|
||||||
|
task.task.official_release_id,
|
||||||
|
official_release_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for result in &task.translation_results {
|
||||||
|
let unit = index_by_id.get(result.unit_id.as_str()).ok_or_else(|| {
|
||||||
|
anyhow::anyhow!("worker 结果引用了未知 TextUnit:{}", result.unit_id)
|
||||||
|
})?;
|
||||||
|
validate_worker_result(task, unit, result)?;
|
||||||
|
if result_by_unit
|
||||||
|
.insert(result.unit_id.as_str(), (unit, task, result))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"worker 结果包含重复 TextUnit:{}",
|
||||||
|
result.unit_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result_by_unit.is_empty() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"当前 release 没有 completed provider 翻译结果可发布"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let entries = index
|
||||||
|
.units
|
||||||
|
.iter()
|
||||||
|
.filter_map(|unit| {
|
||||||
|
result_by_unit
|
||||||
|
.get(unit.id.as_str())
|
||||||
|
.map(|(_, task, result)| workbench_entry_from_worker_result(unit, task, result))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(TranslationWorkbench {
|
||||||
|
schema_version: TRANSLATION_WORKBENCH_VERSION,
|
||||||
|
official_release_id: official_release_id.to_string(),
|
||||||
|
official_resource_root: lexical_absolute(resource_root).map_err(anyhow::Error::msg)?,
|
||||||
|
generated_unix_seconds: unix_seconds_now(),
|
||||||
|
entries,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exports completed provider worker results as an editable workbench.
|
||||||
|
pub async fn export_completed_worker_translation_workbench(
|
||||||
|
resource_root: &Path,
|
||||||
|
official_release_id: &str,
|
||||||
|
output_path: &Path,
|
||||||
|
) -> anyhow::Result<TranslationWorkbench> {
|
||||||
|
let workbench =
|
||||||
|
completed_worker_translation_workbench(resource_root, official_release_id).await?;
|
||||||
|
write_translation_workbench(output_path, &workbench)?;
|
||||||
|
Ok(workbench)
|
||||||
|
}
|
||||||
|
|
||||||
/// Reads and validates a manual translation workbench.
|
/// Reads and validates a manual translation workbench.
|
||||||
pub fn read_translation_workbench(path: &Path) -> anyhow::Result<TranslationWorkbench> {
|
pub fn read_translation_workbench(path: &Path) -> anyhow::Result<TranslationWorkbench> {
|
||||||
let bytes = read_file_no_symlink(path, "翻译工作台")
|
let bytes = read_file_no_symlink(path, "翻译工作台")
|
||||||
@@ -253,8 +361,12 @@ pub fn validate_translation_workbench(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
changed_entries += 1;
|
changed_entries += 1;
|
||||||
|
let source_kind = normalized_text_source_kind(entry.text_source_kind.as_deref());
|
||||||
let is_publishable = entry.archive_entry.is_none()
|
let is_publishable = entry.archive_entry.is_none()
|
||||||
&& entry.text_source_kind.as_deref() == Some("text_asset");
|
&& matches!(
|
||||||
|
source_kind.as_deref(),
|
||||||
|
Some("textasset" | "typetreefield" | "managedreferencefield")
|
||||||
|
);
|
||||||
if is_publishable {
|
if is_publishable {
|
||||||
let serialized_file = entry
|
let serialized_file = entry
|
||||||
.serialized_file
|
.serialized_file
|
||||||
@@ -263,10 +375,21 @@ pub fn validate_translation_workbench(
|
|||||||
let path_id = entry
|
let path_id = entry
|
||||||
.path_id
|
.path_id
|
||||||
.ok_or_else(|| anyhow::anyhow!("TextUnit {} 没有 path_id", entry.id))?;
|
.ok_or_else(|| anyhow::anyhow!("TextUnit {} 没有 path_id", entry.id))?;
|
||||||
|
let field_path = if source_kind.as_deref() == Some("textasset") {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(
|
||||||
|
entry
|
||||||
|
.field_path
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("TextUnit {} 没有 field_path", entry.id))?,
|
||||||
|
)
|
||||||
|
};
|
||||||
if !seen_patch_targets.insert((
|
if !seen_patch_targets.insert((
|
||||||
entry.destination.clone(),
|
entry.destination.clone(),
|
||||||
serialized_file.clone(),
|
serialized_file.clone(),
|
||||||
path_id,
|
path_id,
|
||||||
|
field_path,
|
||||||
)) {
|
)) {
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
"翻译工作台包含重复 patch 目标:{}",
|
"翻译工作台包含重复 patch 目标:{}",
|
||||||
@@ -290,11 +413,122 @@ pub fn validate_translation_workbench(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Converts reviewed direct TextAsset entries to localized patch operations.
|
/// Converts reviewed entries to localized patch operations supported by the
|
||||||
|
/// current UnityFS write layer.
|
||||||
///
|
///
|
||||||
/// TypeTree fields and zip-inner bundles are intentionally rejected here.
|
/// ZIP-inner bundles are intentionally rejected here because they require a
|
||||||
/// They need a different patch representation and must not silently become a
|
/// separate archive rewrite boundary.
|
||||||
/// TextAsset replacement.
|
pub fn localized_patch_operations(
|
||||||
|
resource_root: &Path,
|
||||||
|
workbench: &TranslationWorkbench,
|
||||||
|
) -> anyhow::Result<Vec<LocalizedPatchInput>> {
|
||||||
|
let index = read_textunit_index_at(resource_root)
|
||||||
|
.map_err(anyhow::Error::msg)?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("缺少当前官方 TextUnit 索引"))?;
|
||||||
|
let index_by_id = index
|
||||||
|
.units
|
||||||
|
.iter()
|
||||||
|
.map(|unit| (unit.id.as_str(), unit))
|
||||||
|
.collect::<std::collections::HashMap<_, _>>();
|
||||||
|
let mut seen = BTreeSet::new();
|
||||||
|
let mut operations = Vec::new();
|
||||||
|
|
||||||
|
for entry in &workbench.entries {
|
||||||
|
let Some(translated_text) = entry.translated_text.as_ref() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let current = index_by_id
|
||||||
|
.get(entry.id.as_str())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("翻译工作台条目不属于当前 release:{}", entry.id))?;
|
||||||
|
validate_workbench_entry(entry, current)?;
|
||||||
|
if translated_text == &entry.source_text {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(serialized_file) = entry.serialized_file.clone() else {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"TextUnit {} 没有 serialized_file,当前不能生成重打包 patch",
|
||||||
|
entry.id
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let Some(path_id) = entry.path_id else {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"TextUnit {} 没有 path_id,当前不能生成重打包 patch",
|
||||||
|
entry.id
|
||||||
|
));
|
||||||
|
};
|
||||||
|
if entry.archive_entry.is_some() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"TextUnit {} 位于 zip archive entry,当前 publish-localized 不支持直接修改 zip 内 bundle",
|
||||||
|
entry.id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let source_kind = normalized_text_source_kind(entry.text_source_kind.as_deref());
|
||||||
|
let field_path = entry.field_path.clone();
|
||||||
|
if !seen.insert((
|
||||||
|
entry.destination.clone(),
|
||||||
|
serialized_file.clone(),
|
||||||
|
path_id,
|
||||||
|
field_path.clone(),
|
||||||
|
)) {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"翻译工作台包含重复 patch 目标:{}",
|
||||||
|
entry.id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let metadata = Some(localized_patch_metadata(entry));
|
||||||
|
match source_kind.as_deref() {
|
||||||
|
Some("textasset") => {
|
||||||
|
let mut patch = TextAssetPatch::new(
|
||||||
|
serialized_file,
|
||||||
|
path_id,
|
||||||
|
translated_text.as_bytes().to_vec(),
|
||||||
|
);
|
||||||
|
patch.expected_name = entry.asset_name.clone();
|
||||||
|
operations.push(LocalizedPatchInput::TextAsset(LocalizedTextAssetPatch {
|
||||||
|
bundle_path: entry.destination.clone(),
|
||||||
|
text_asset: patch,
|
||||||
|
metadata,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
Some("typetreefield" | "managedreferencefield") => {
|
||||||
|
let field_path = field_path.ok_or_else(|| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"TextUnit {} 没有 field_path,不能生成 TypeTree patch",
|
||||||
|
entry.id
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
operations.push(LocalizedPatchInput::StringField(
|
||||||
|
LocalizedStringFieldPatch {
|
||||||
|
bundle_path: entry.destination.clone(),
|
||||||
|
string_field: StringFieldPatch {
|
||||||
|
serialized_file_path: serialized_file,
|
||||||
|
path_id,
|
||||||
|
field_path,
|
||||||
|
expected_value: Some(entry.source_text.clone()),
|
||||||
|
replacement: translated_text.clone(),
|
||||||
|
},
|
||||||
|
metadata,
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"TextUnit {} 的来源不在当前 localized publish 支持范围内",
|
||||||
|
entry.id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if operations.is_empty() {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"翻译工作台没有可发布的已修改 TextUnit;请先用 translation-set 调整文本或导入 worker 结果"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(operations)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts reviewed direct TextAsset entries to localized patch operations.
|
||||||
pub fn localized_text_asset_patches(
|
pub fn localized_text_asset_patches(
|
||||||
resource_root: &Path,
|
resource_root: &Path,
|
||||||
workbench: &TranslationWorkbench,
|
workbench: &TranslationWorkbench,
|
||||||
@@ -339,7 +573,9 @@ pub fn localized_text_asset_patches(
|
|||||||
entry.id
|
entry.id
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if entry.text_source_kind.as_deref() != Some("text_asset") {
|
if normalized_text_source_kind(entry.text_source_kind.as_deref()).as_deref()
|
||||||
|
!= Some("textasset")
|
||||||
|
{
|
||||||
return Err(anyhow::anyhow!(
|
return Err(anyhow::anyhow!(
|
||||||
"TextUnit {} 的来源不是 TextAsset;请使用 repack spec 的 TypeTree 操作",
|
"TextUnit {} 的来源不是 TextAsset;请使用 repack spec 的 TypeTree 操作",
|
||||||
entry.id
|
entry.id
|
||||||
@@ -360,6 +596,7 @@ pub fn localized_text_asset_patches(
|
|||||||
patches.push(LocalizedTextAssetPatch {
|
patches.push(LocalizedTextAssetPatch {
|
||||||
bundle_path: entry.destination.clone(),
|
bundle_path: entry.destination.clone(),
|
||||||
text_asset: patch,
|
text_asset: patch,
|
||||||
|
metadata: Some(localized_patch_metadata(entry)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,6 +608,31 @@ pub fn localized_text_asset_patches(
|
|||||||
Ok(patches)
|
Ok(patches)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn localized_patch_metadata(entry: &TranslationWorkbenchEntry) -> LocalizedPatchOperationMetadata {
|
||||||
|
LocalizedPatchOperationMetadata {
|
||||||
|
text_unit_id: entry.id.clone(),
|
||||||
|
source_text_blake3: blake3::hash(entry.source_text.as_bytes())
|
||||||
|
.to_hex()
|
||||||
|
.to_string(),
|
||||||
|
translation_provider: entry.translation_provider.clone(),
|
||||||
|
provider_run_id: entry.provider_run_id.clone(),
|
||||||
|
review_status: entry
|
||||||
|
.review_status
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "manual_reviewed".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_text_source_kind(value: Option<&str>) -> Option<String> {
|
||||||
|
value.map(|value| {
|
||||||
|
value
|
||||||
|
.chars()
|
||||||
|
.filter(|ch| !matches!(ch, '_' | '-' | ' '))
|
||||||
|
.collect::<String>()
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Batch UnityFS repack specification.
|
/// Batch UnityFS repack specification.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct RepackSpec {
|
pub struct RepackSpec {
|
||||||
@@ -597,6 +859,49 @@ fn read_text_replacement(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_worker_result(
|
||||||
|
task: &PersistedTranslationTask,
|
||||||
|
unit: &OfficialTextUnitIndexUnit,
|
||||||
|
result: &TranslationTaskUnitResult,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
if task.task.destination != unit.destination || task.task.archive_entry != unit.archive_entry {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"worker 任务 {} 与 TextUnit {} 的 destination/archive entry 不一致",
|
||||||
|
task.task.task_id,
|
||||||
|
unit.id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if result.source_text != unit.source_text {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"worker 结果 {} 的 source_text 与当前 TextUnit 索引不一致",
|
||||||
|
result.unit_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workbench_entry_from_worker_result(
|
||||||
|
unit: &OfficialTextUnitIndexUnit,
|
||||||
|
task: &PersistedTranslationTask,
|
||||||
|
result: &TranslationTaskUnitResult,
|
||||||
|
) -> TranslationWorkbenchEntry {
|
||||||
|
let mut entry = TranslationWorkbenchEntry::from_index(unit);
|
||||||
|
entry.translated_text = Some(result.translated_text.clone());
|
||||||
|
entry.translation_provider = task
|
||||||
|
.provider
|
||||||
|
.clone()
|
||||||
|
.or_else(|| Some(result.provider.clone()))
|
||||||
|
.filter(|provider| !provider.trim().is_empty());
|
||||||
|
entry.provider_run_id = task
|
||||||
|
.provider_run_id
|
||||||
|
.clone()
|
||||||
|
.or_else(|| Some(result.provider_run_id.clone()))
|
||||||
|
.filter(|provider_run_id| !provider_run_id.trim().is_empty());
|
||||||
|
entry.translated_unix_seconds = Some(result.translated_unix_seconds);
|
||||||
|
entry.review_status = Some("provider_completed".to_string());
|
||||||
|
entry
|
||||||
|
}
|
||||||
|
|
||||||
fn validate_workbench_entry(
|
fn validate_workbench_entry(
|
||||||
entry: &TranslationWorkbenchEntry,
|
entry: &TranslationWorkbenchEntry,
|
||||||
current: &OfficialTextUnitIndexUnit,
|
current: &OfficialTextUnitIndexUnit,
|
||||||
@@ -607,6 +912,7 @@ fn validate_workbench_entry(
|
|||||||
|| entry.serialized_file != current.serialized_file
|
|| entry.serialized_file != current.serialized_file
|
||||||
|| entry.path_id != current.path_id
|
|| entry.path_id != current.path_id
|
||||||
|| entry.asset_name != current.asset_name
|
|| entry.asset_name != current.asset_name
|
||||||
|
|| entry.field_path != current.field_path
|
||||||
|| entry.format != current.format
|
|| entry.format != current.format
|
||||||
|| entry.text_source_kind != current.text_source_kind
|
|| entry.text_source_kind != current.text_source_kind
|
||||||
{
|
{
|
||||||
@@ -627,8 +933,13 @@ impl TranslationWorkbenchEntry {
|
|||||||
serialized_file: unit.serialized_file.clone(),
|
serialized_file: unit.serialized_file.clone(),
|
||||||
path_id: unit.path_id,
|
path_id: unit.path_id,
|
||||||
asset_name: unit.asset_name.clone(),
|
asset_name: unit.asset_name.clone(),
|
||||||
|
field_path: unit.field_path.clone(),
|
||||||
source_text: unit.source_text.clone(),
|
source_text: unit.source_text.clone(),
|
||||||
translated_text: None,
|
translated_text: None,
|
||||||
|
translation_provider: None,
|
||||||
|
provider_run_id: None,
|
||||||
|
translated_unix_seconds: None,
|
||||||
|
review_status: None,
|
||||||
format: unit.format.clone(),
|
format: unit.format.clone(),
|
||||||
text_source_kind: unit.text_source_kind.clone(),
|
text_source_kind: unit.text_source_kind.clone(),
|
||||||
}
|
}
|
||||||
@@ -659,8 +970,13 @@ mod tests {
|
|||||||
serialized_file: Some("CAB-test".to_string()),
|
serialized_file: Some("CAB-test".to_string()),
|
||||||
path_id: Some(7),
|
path_id: Some(7),
|
||||||
asset_name: Some("Story".to_string()),
|
asset_name: Some("Story".to_string()),
|
||||||
|
field_path: None,
|
||||||
source_text: "原文".to_string(),
|
source_text: "原文".to_string(),
|
||||||
translated_text: None,
|
translated_text: None,
|
||||||
|
translation_provider: None,
|
||||||
|
provider_run_id: None,
|
||||||
|
translated_unix_seconds: None,
|
||||||
|
review_status: None,
|
||||||
format: Some("plain".to_string()),
|
format: Some("plain".to_string()),
|
||||||
text_source_kind: Some("text_asset".to_string()),
|
text_source_kind: Some("text_asset".to_string()),
|
||||||
}],
|
}],
|
||||||
@@ -743,4 +1059,63 @@ mod tests {
|
|||||||
assert_eq!(report.publishable_entries, 1);
|
assert_eq!(report.publishable_entries, 1);
|
||||||
assert_eq!(report.unreviewed_entries, 0);
|
assert_eq!(report.unreviewed_entries, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn localized_operations_preserve_type_tree_field_traceability() {
|
||||||
|
let temp = tempfile::TempDir::new().unwrap();
|
||||||
|
let index = crate::official_parse::OfficialTextUnitIndex {
|
||||||
|
version: crate::official_parse::OFFICIAL_TEXTUNIT_INDEX_VERSION,
|
||||||
|
generated_unix_seconds: 1,
|
||||||
|
resource_root: temp.path().to_path_buf(),
|
||||||
|
summary: Default::default(),
|
||||||
|
units: vec![OfficialTextUnitIndexUnit {
|
||||||
|
id: "unit-1".to_string(),
|
||||||
|
parse_entry_key: "bundle".to_string(),
|
||||||
|
source_url: "https://example.invalid/bundle".to_string(),
|
||||||
|
destination: "bundles/test.bundle".to_string(),
|
||||||
|
archive_entry: None,
|
||||||
|
source_kind: crate::official_parse::OfficialParseSourceKind::DirectBundle,
|
||||||
|
unity_version: None,
|
||||||
|
source_text: "原文".to_string(),
|
||||||
|
serialized_file: Some("CAB-test".to_string()),
|
||||||
|
path_id: Some(7),
|
||||||
|
class_id: Some(114),
|
||||||
|
field_path: Some("Scenario.Message".to_string()),
|
||||||
|
field_offset: Some(16),
|
||||||
|
field_byte_size: Some(8),
|
||||||
|
format: Some("plain".to_string()),
|
||||||
|
text_source_kind: Some("TypeTreeField".to_string()),
|
||||||
|
asset_name: None,
|
||||||
|
context: Default::default(),
|
||||||
|
}],
|
||||||
|
errors: Vec::new(),
|
||||||
|
};
|
||||||
|
crate::official_parse::write_textunit_index_at(temp.path(), &index).unwrap();
|
||||||
|
let mut workbench = workbench(temp.path());
|
||||||
|
let entry = &mut workbench.entries[0];
|
||||||
|
entry.asset_name = None;
|
||||||
|
entry.field_path = Some("Scenario.Message".to_string());
|
||||||
|
entry.text_source_kind = Some("TypeTreeField".to_string());
|
||||||
|
entry.translated_text = Some("译文".to_string());
|
||||||
|
entry.translation_provider = Some("mock".to_string());
|
||||||
|
entry.provider_run_id = Some("run-1".to_string());
|
||||||
|
|
||||||
|
let operations = localized_patch_operations(temp.path(), &workbench).unwrap();
|
||||||
|
assert_eq!(operations.len(), 1);
|
||||||
|
match &operations[0] {
|
||||||
|
LocalizedPatchInput::StringField(operation) => {
|
||||||
|
assert_eq!(operation.string_field.field_path, "Scenario.Message");
|
||||||
|
assert_eq!(
|
||||||
|
operation.string_field.expected_value.as_deref(),
|
||||||
|
Some("原文")
|
||||||
|
);
|
||||||
|
assert_eq!(operation.string_field.replacement, "译文");
|
||||||
|
let metadata = operation.metadata.as_ref().unwrap();
|
||||||
|
assert_eq!(metadata.text_unit_id, "unit-1");
|
||||||
|
assert_eq!(metadata.translation_provider.as_deref(), Some("mock"));
|
||||||
|
assert_eq!(metadata.provider_run_id.as_deref(), Some("run-1"));
|
||||||
|
}
|
||||||
|
other => panic!("unexpected localized operation: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
"/admin/schedules",
|
"/admin/schedules",
|
||||||
"/admin/translation/tasks",
|
"/admin/translation/tasks",
|
||||||
"/admin/translation/handoff",
|
"/admin/translation/handoff",
|
||||||
|
"/admin/translation/status",
|
||||||
},
|
},
|
||||||
Controls: []string{
|
Controls: []string{
|
||||||
"/admin/control/reload",
|
"/admin/control/reload",
|
||||||
@@ -55,6 +56,8 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
"/admin/control/translation-task-update",
|
"/admin/control/translation-task-update",
|
||||||
"/admin/control/translation-worker-run",
|
"/admin/control/translation-worker-run",
|
||||||
"/admin/control/translation-proofread",
|
"/admin/control/translation-proofread",
|
||||||
|
"/admin/control/localized-publish",
|
||||||
|
"/admin/control/localized-rollback",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if r.Method == http.MethodHead {
|
if r.Method == http.MethodHead {
|
||||||
@@ -94,6 +97,14 @@ func (s *Server) handleAdminControl(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.handleAdminTranslationProofread(w, r)
|
s.handleAdminTranslationProofread(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if action == "localized-publish" {
|
||||||
|
s.handleAdminLocalizedPublish(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if action == "localized-rollback" {
|
||||||
|
s.handleAdminLocalizedRollback(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
request, ok := decodeAdminControlRequest(w, r)
|
request, ok := decodeAdminControlRequest(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
@@ -243,6 +254,84 @@ func (s *Server) handleAdminTranslationProofread(w http.ResponseWriter, r *http.
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminLocalizedPublish(w http.ResponseWriter, r *http.Request) {
|
||||||
|
backend, ok := s.backend.(LocalizedBackend)
|
||||||
|
if !ok || backend == nil {
|
||||||
|
writeErrorJSON(w, http.StatusServiceUnavailable, "localized_backend_unavailable", "Rust bat localized backend is unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var params backendrpc.LocalizedPublishParams
|
||||||
|
if !decodeAdminTranslationJSON(w, r, ¶ms) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validateLocalizedPublishParams(params); err != nil {
|
||||||
|
writeErrorJSON(w, http.StatusBadRequest, "invalid_localized_params", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := backend.LocalizedPublish(r.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
s.writeControlBackendError(w, "localized-publish", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
|
||||||
|
Service: "bat-api",
|
||||||
|
Action: "localized-publish",
|
||||||
|
RPCMethod: "localized.publish",
|
||||||
|
Status: "accepted",
|
||||||
|
Result: result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminLocalizedRollback(w http.ResponseWriter, r *http.Request) {
|
||||||
|
backend, ok := s.backend.(LocalizedBackend)
|
||||||
|
if !ok || backend == nil {
|
||||||
|
writeErrorJSON(w, http.StatusServiceUnavailable, "localized_backend_unavailable", "Rust bat localized backend is unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var params backendrpc.LocalizedRollbackParams
|
||||||
|
if !decodeAdminTranslationJSON(w, r, ¶ms) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := backend.LocalizedRollback(r.Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
s.writeControlBackendError(w, "localized-rollback", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
|
||||||
|
Service: "bat-api",
|
||||||
|
Action: "localized-rollback",
|
||||||
|
RPCMethod: "localized.rollback",
|
||||||
|
Status: "accepted",
|
||||||
|
Result: result,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAdminLocalizedStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
|
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.requireAdminToken(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
backend, ok := s.backend.(LocalizedBackend)
|
||||||
|
if !ok || backend == nil {
|
||||||
|
writeErrorJSON(w, http.StatusServiceUnavailable, "localized_backend_unavailable", "Rust bat localized backend is unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := backend.LocalizedStatus(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
s.writeControlBackendError(w, "localized-status", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodHead {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeNoStoreJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleAdminTranslationTasks(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleAdminTranslationTasks(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||||
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
|
||||||
@@ -494,6 +583,14 @@ func validateTranslationWorkerRunParams(params backendrpc.TranslationWorkerRunPa
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateLocalizedPublishParams(params backendrpc.LocalizedPublishParams) error {
|
||||||
|
hasFile := strings.TrimSpace(params.TranslationFile) != ""
|
||||||
|
if hasFile == params.FromWorker {
|
||||||
|
return errors.New("localized publish requires exactly one of translation_file or from_worker")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func decodeAdminJSON(w http.ResponseWriter, r *http.Request, target any, errorCode string, subject string) bool {
|
func decodeAdminJSON(w http.ResponseWriter, r *http.Request, target any, errorCode string, subject string) bool {
|
||||||
if r.Body == nil {
|
if r.Body == nil {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -590,6 +590,8 @@ type controlBackend struct {
|
|||||||
*fakeBackend
|
*fakeBackend
|
||||||
calls []string
|
calls []string
|
||||||
translationTaskListParams []backendrpc.TranslationTaskListParams
|
translationTaskListParams []backendrpc.TranslationTaskListParams
|
||||||
|
localizedPublishParams []backendrpc.LocalizedPublishParams
|
||||||
|
localizedRollbackParams []backendrpc.LocalizedRollbackParams
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *controlBackend) DaemonReload(ctx context.Context) (*backendrpc.Ack, error) {
|
func (b *controlBackend) DaemonReload(ctx context.Context) (*backendrpc.Ack, error) {
|
||||||
@@ -669,6 +671,23 @@ func (b *controlBackend) TranslationProofread(ctx context.Context) (json.RawMess
|
|||||||
return json.RawMessage(`{"translation_workflow_status":"manual_proofreading"}`), nil
|
return json.RawMessage(`{"translation_workflow_status":"manual_proofreading"}`), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *controlBackend) LocalizedStatus(ctx context.Context) (json.RawMessage, error) {
|
||||||
|
b.calls = append(b.calls, "localized.status")
|
||||||
|
return json.RawMessage(`{"localized_release_status":"localized","status_code":"localized.published"}`), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *controlBackend) LocalizedPublish(ctx context.Context, params backendrpc.LocalizedPublishParams) (json.RawMessage, error) {
|
||||||
|
b.calls = append(b.calls, "localized.publish")
|
||||||
|
b.localizedPublishParams = append(b.localizedPublishParams, params)
|
||||||
|
return json.RawMessage(`{"status":"published","localized_release_id":"localized-1"}`), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *controlBackend) LocalizedRollback(ctx context.Context, params backendrpc.LocalizedRollbackParams) (json.RawMessage, error) {
|
||||||
|
b.calls = append(b.calls, "localized.rollback")
|
||||||
|
b.localizedRollbackParams = append(b.localizedRollbackParams, params)
|
||||||
|
return json.RawMessage(`{"status":"rolled_back","rolled_back_release_id":"localized-1"}`), nil
|
||||||
|
}
|
||||||
|
|
||||||
type scheduleBackend struct {
|
type scheduleBackend struct {
|
||||||
*controlBackend
|
*controlBackend
|
||||||
scheduleCalls []string
|
scheduleCalls []string
|
||||||
@@ -1420,6 +1439,8 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
|
|||||||
{name: "translation task update", action: "translation-task-update", body: `{"task_id":"textunit/v-current/Scenario","status":"failed","failure_reason":"provider rejected payload","provider_run_id":"provider-run-1"}`, rpcMethod: "translation.task.update", call: "translation.task.update"},
|
{name: "translation task update", action: "translation-task-update", body: `{"task_id":"textunit/v-current/Scenario","status":"failed","failure_reason":"provider rejected payload","provider_run_id":"provider-run-1"}`, rpcMethod: "translation.task.update", call: "translation.task.update"},
|
||||||
{name: "translation worker run", action: "translation-worker-run", body: `{"provider":"mock","concurrency":8,"max_tasks":2,"retry_backoff_seconds":0,"worker_id":"dashboard-worker"}`, rpcMethod: "translation.worker.run", call: "translation.worker.run"},
|
{name: "translation worker run", action: "translation-worker-run", body: `{"provider":"mock","concurrency":8,"max_tasks":2,"retry_backoff_seconds":0,"worker_id":"dashboard-worker"}`, rpcMethod: "translation.worker.run", call: "translation.worker.run"},
|
||||||
{name: "translation proofread", action: "translation-proofread", rpcMethod: "translation.proofread", call: "translation.proofread"},
|
{name: "translation proofread", action: "translation-proofread", rpcMethod: "translation.proofread", call: "translation.proofread"},
|
||||||
|
{name: "localized publish", action: "localized-publish", body: `{"from_worker":true,"localized_release_id":"localized-1","force":true}`, rpcMethod: "localized.publish", call: "localized.publish"},
|
||||||
|
{name: "localized rollback", action: "localized-rollback", body: `{"localized_release_id":"localized-1"}`, rpcMethod: "localized.rollback", call: "localized.rollback"},
|
||||||
}
|
}
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
@@ -1458,6 +1479,45 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
|
|||||||
if recorder.Code != http.StatusBadRequest {
|
if recorder.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("invalid translation worker status=%d body=%s", recorder.Code, recorder.Body.String())
|
t.Fatalf("invalid translation worker status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
request = httptest.NewRequest(http.MethodPost, "/admin/control/localized-publish", strings.NewReader(`{"from_worker":true,"translation_file":"/tmp/workbench.json"}`))
|
||||||
|
request.Header.Set("Authorization", "Bearer control-token")
|
||||||
|
recorder = httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(recorder, request)
|
||||||
|
if recorder.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("invalid localized publish status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminLocalizedStatusRequiresAuthAndForwards(t *testing.T) {
|
||||||
|
cfg := DefaultConfig()
|
||||||
|
cfg.AuthToken = "control-token"
|
||||||
|
if err := cfg.Normalize(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
backend := &controlBackend{fakeBackend: &fakeBackend{}}
|
||||||
|
s := NewServer(cfg, backend, nil)
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "/admin/translation/status", nil)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(recorder, request)
|
||||||
|
if recorder.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("unauthenticated status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
request = httptest.NewRequest(http.MethodGet, "/admin/translation/status", nil)
|
||||||
|
request.Header.Set("Authorization", "Bearer control-token")
|
||||||
|
recorder = httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(recorder, request)
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(recorder.Body.String(), `"localized.published"`) {
|
||||||
|
t.Fatalf("status body=%s", recorder.Body.String())
|
||||||
|
}
|
||||||
|
if len(backend.calls) != 1 || backend.calls[0] != "localized.status" {
|
||||||
|
t.Fatalf("calls=%v", backend.calls)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminControlRejectsUnauthenticatedDangerousAndUnsupportedActions(t *testing.T) {
|
func TestAdminControlRejectsUnauthenticatedDangerousAndUnsupportedActions(t *testing.T) {
|
||||||
|
|||||||
+17
-1
@@ -213,6 +213,16 @@ paths:
|
|||||||
description: Missing or invalid admin token.
|
description: Missing or invalid admin token.
|
||||||
"503":
|
"503":
|
||||||
description: Rust bat translation backend is unavailable.
|
description: Rust bat translation backend is unavailable.
|
||||||
|
/admin/translation/status:
|
||||||
|
get:
|
||||||
|
summary: Read Rust-owned localized release status
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Current localized status JSON report from Rust bat.
|
||||||
|
"401":
|
||||||
|
description: Missing or invalid admin token.
|
||||||
|
"503":
|
||||||
|
description: Rust bat localized backend is unavailable.
|
||||||
/admin/control/{action}:
|
/admin/control/{action}:
|
||||||
post:
|
post:
|
||||||
summary: Forward an allowlisted control or schedule action to Rust bat
|
summary: Forward an allowlisted control or schedule action to Rust bat
|
||||||
@@ -222,7 +232,7 @@ paths:
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, translation-task-update, translation-worker-run, translation-proofread]
|
enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, translation-task-update, translation-worker-run, translation-proofread, localized-publish, localized-rollback]
|
||||||
requestBody:
|
requestBody:
|
||||||
required: false
|
required: false
|
||||||
content:
|
content:
|
||||||
@@ -301,6 +311,12 @@ paths:
|
|||||||
minimum: 1
|
minimum: 1
|
||||||
worker_id:
|
worker_id:
|
||||||
type: string
|
type: string
|
||||||
|
translation_file:
|
||||||
|
type: string
|
||||||
|
from_worker:
|
||||||
|
type: boolean
|
||||||
|
localized_release_id:
|
||||||
|
type: string
|
||||||
responses:
|
responses:
|
||||||
"202":
|
"202":
|
||||||
description: Rust bat accepted the control request.
|
description: Rust bat accepted the control request.
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ type TranslationBackend interface {
|
|||||||
TranslationProofread(ctx context.Context) (json.RawMessage, error)
|
TranslationProofread(ctx context.Context) (json.RawMessage, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LocalizedBackend exposes localized release status and the explicit
|
||||||
|
// publish/rollback controls used by the authenticated dashboard.
|
||||||
|
type LocalizedBackend interface {
|
||||||
|
LocalizedStatus(ctx context.Context) (json.RawMessage, error)
|
||||||
|
LocalizedPublish(ctx context.Context, params backendrpc.LocalizedPublishParams) (json.RawMessage, error)
|
||||||
|
LocalizedRollback(ctx context.Context, params backendrpc.LocalizedRollbackParams) (json.RawMessage, error)
|
||||||
|
}
|
||||||
|
|
||||||
// RPCClient adapts *backendrpc.Client to Backend.
|
// RPCClient adapts *backendrpc.Client to Backend.
|
||||||
type RPCClient struct {
|
type RPCClient struct {
|
||||||
Client *backendrpc.Client
|
Client *backendrpc.Client
|
||||||
@@ -135,6 +143,19 @@ func (r RPCClient) TranslationWorkerRun(ctx context.Context, params backendrpc.T
|
|||||||
func (r RPCClient) TranslationProofread(ctx context.Context) (json.RawMessage, error) {
|
func (r RPCClient) TranslationProofread(ctx context.Context) (json.RawMessage, error) {
|
||||||
return r.Client.TranslationProofread(ctx)
|
return r.Client.TranslationProofread(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r RPCClient) LocalizedStatus(ctx context.Context) (json.RawMessage, error) {
|
||||||
|
return r.Client.LocalizedStatus(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r RPCClient) LocalizedPublish(ctx context.Context, params backendrpc.LocalizedPublishParams) (json.RawMessage, error) {
|
||||||
|
return r.Client.LocalizedPublish(ctx, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r RPCClient) LocalizedRollback(ctx context.Context, params backendrpc.LocalizedRollbackParams) (json.RawMessage, error) {
|
||||||
|
return r.Client.LocalizedRollback(ctx, params)
|
||||||
|
}
|
||||||
|
|
||||||
func (r RPCClient) ParseStatus(ctx context.Context) (json.RawMessage, error) {
|
func (r RPCClient) ParseStatus(ctx context.Context) (json.RawMessage, error) {
|
||||||
return r.Client.ParseStatus(ctx)
|
return r.Client.ParseStatus(ctx)
|
||||||
}
|
}
|
||||||
@@ -144,9 +165,6 @@ func (r RPCClient) ParseTextUnits(ctx context.Context, query backendrpc.TextUnit
|
|||||||
func (r RPCClient) ParseErrors(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error) {
|
func (r RPCClient) ParseErrors(ctx context.Context, query backendrpc.TextUnitQueryParams) (json.RawMessage, error) {
|
||||||
return r.Client.ParseErrors(ctx, query)
|
return r.Client.ParseErrors(ctx, query)
|
||||||
}
|
}
|
||||||
func (r RPCClient) LocalizedStatus(ctx context.Context) (json.RawMessage, error) {
|
|
||||||
return r.Client.LocalizedStatus(ctx)
|
|
||||||
}
|
|
||||||
func (r RPCClient) UnityFSPatchTextAsset(ctx context.Context, params backendrpc.UnityFSTextAssetPatchParams) (json.RawMessage, error) {
|
func (r RPCClient) UnityFSPatchTextAsset(ctx context.Context, params backendrpc.UnityFSTextAssetPatchParams) (json.RawMessage, error) {
|
||||||
return r.Client.UnityFSPatchTextAsset(ctx, params)
|
return r.Client.UnityFSPatchTextAsset(ctx, params)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("/admin/schedules", s.handleAdminSchedules)
|
mux.HandleFunc("/admin/schedules", s.handleAdminSchedules)
|
||||||
mux.HandleFunc("/admin/translation/tasks", s.handleAdminTranslationTasks)
|
mux.HandleFunc("/admin/translation/tasks", s.handleAdminTranslationTasks)
|
||||||
mux.HandleFunc("/admin/translation/handoff", s.handleAdminTranslationHandoff)
|
mux.HandleFunc("/admin/translation/handoff", s.handleAdminTranslationHandoff)
|
||||||
|
mux.HandleFunc("/admin/translation/status", s.handleAdminLocalizedStatus)
|
||||||
mux.HandleFunc("/admin/control/", s.handleAdminControl)
|
mux.HandleFunc("/admin/control/", s.handleAdminControl)
|
||||||
mux.HandleFunc("/admin/", s.handleAdminIndex)
|
mux.HandleFunc("/admin/", s.handleAdminIndex)
|
||||||
mux.HandleFunc("/"+ServerInfoHost+"/", s.handleServerInfoCDN)
|
mux.HandleFunc("/"+ServerInfoHost+"/", s.handleServerInfoCDN)
|
||||||
@@ -131,6 +132,7 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
|
|||||||
"/admin/schedules",
|
"/admin/schedules",
|
||||||
"/admin/translation/tasks",
|
"/admin/translation/tasks",
|
||||||
"/admin/translation/handoff",
|
"/admin/translation/handoff",
|
||||||
|
"/admin/translation/status",
|
||||||
"/admin/control/{action}",
|
"/admin/control/{action}",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -325,6 +325,20 @@ type TranslationWorkerRunResult struct {
|
|||||||
Worker TranslationWorkerConfig `json:"worker"`
|
Worker TranslationWorkerConfig `json:"worker"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LocalizedPublishParams selects the source of one localized release
|
||||||
|
// publication. TranslationFile and FromWorker are mutually exclusive.
|
||||||
|
type LocalizedPublishParams struct {
|
||||||
|
TranslationFile string `json:"translation_file,omitempty"`
|
||||||
|
FromWorker bool `json:"from_worker,omitempty"`
|
||||||
|
LocalizedReleaseID string `json:"localized_release_id,omitempty"`
|
||||||
|
Force bool `json:"force,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LocalizedRollbackParams optionally restricts rollback to one current release.
|
||||||
|
type LocalizedRollbackParams struct {
|
||||||
|
LocalizedReleaseID string `json:"localized_release_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// TranslationProofread marks the current localized workflow as manual
|
// TranslationProofread marks the current localized workflow as manual
|
||||||
// proofreading. Rust owns the persisted localized state.
|
// proofreading. Rust owns the persisted localized state.
|
||||||
func (c *Client) TranslationProofread(ctx context.Context) (json.RawMessage, error) {
|
func (c *Client) TranslationProofread(ctx context.Context) (json.RawMessage, error) {
|
||||||
@@ -568,6 +582,14 @@ func (c *Client) TranslationWorkerRun(ctx context.Context, params TranslationWor
|
|||||||
return &out, err
|
return &out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) LocalizedPublish(ctx context.Context, params LocalizedPublishParams) (json.RawMessage, error) {
|
||||||
|
return c.rawData(ctx, "localized.publish", params)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) LocalizedRollback(ctx context.Context, params LocalizedRollbackParams) (json.RawMessage, error) {
|
||||||
|
return c.rawData(ctx, "localized.rollback", params)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
|
func (c *Client) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
|
||||||
return c.rawData(ctx, "catalog.status", nil)
|
return c.rawData(ctx, "catalog.status", nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -561,6 +561,75 @@ func TestTranslationProofreadUsesRustMethod(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLocalizedPublishSendsWorkerSourceAndReleaseOptions(t *testing.T) {
|
||||||
|
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||||
|
if req.Method != "localized.publish" {
|
||||||
|
t.Fatalf("method = %s", req.Method)
|
||||||
|
}
|
||||||
|
var params LocalizedPublishParams
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
t.Fatalf("decode params: %v", err)
|
||||||
|
}
|
||||||
|
if !params.FromWorker || params.TranslationFile != "" ||
|
||||||
|
params.LocalizedReleaseID != "localized-1" || !params.Force {
|
||||||
|
t.Fatalf("params = %#v", params)
|
||||||
|
}
|
||||||
|
return testResponse{
|
||||||
|
Result: testEnvelope{
|
||||||
|
OK: true,
|
||||||
|
Status: "ok",
|
||||||
|
RequestID: "req-test-localized-publish",
|
||||||
|
Data: map[string]any{"status": "published"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
raw, err := client.LocalizedPublish(context.Background(), LocalizedPublishParams{
|
||||||
|
FromWorker: true,
|
||||||
|
LocalizedReleaseID: "localized-1",
|
||||||
|
Force: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LocalizedPublish error: %v", err)
|
||||||
|
}
|
||||||
|
if !json.Valid(raw) {
|
||||||
|
t.Fatalf("invalid raw JSON: %s", string(raw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLocalizedRollbackSendsExpectedRelease(t *testing.T) {
|
||||||
|
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||||
|
if req.Method != "localized.rollback" {
|
||||||
|
t.Fatalf("method = %s", req.Method)
|
||||||
|
}
|
||||||
|
var params LocalizedRollbackParams
|
||||||
|
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||||
|
t.Fatalf("decode params: %v", err)
|
||||||
|
}
|
||||||
|
if params.LocalizedReleaseID != "localized-1" {
|
||||||
|
t.Fatalf("params = %#v", params)
|
||||||
|
}
|
||||||
|
return testResponse{
|
||||||
|
Result: testEnvelope{
|
||||||
|
OK: true,
|
||||||
|
Status: "ok",
|
||||||
|
RequestID: "req-test-localized-rollback",
|
||||||
|
Data: map[string]any{"status": "rolled_back"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
raw, err := client.LocalizedRollback(context.Background(), LocalizedRollbackParams{
|
||||||
|
LocalizedReleaseID: "localized-1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LocalizedRollback error: %v", err)
|
||||||
|
}
|
||||||
|
if !json.Valid(raw) {
|
||||||
|
t.Fatalf("invalid raw JSON: %s", string(raw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestApplicationErrorReturnsAPIError(t *testing.T) {
|
func TestApplicationErrorReturnsAPIError(t *testing.T) {
|
||||||
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
client := newTestClient(t, func(t *testing.T, req testRequest) testResponse {
|
||||||
if req.Method != "task.status" {
|
if req.Method != "task.status" {
|
||||||
|
|||||||
@@ -16,6 +16,12 @@
|
|||||||
`translation.worker.run`,触发 Rust provider worker。
|
`translation.worker.run`,触发 Rust provider worker。
|
||||||
- `POST /admin/control/translation-proofread`:转发
|
- `POST /admin/control/translation-proofread`:转发
|
||||||
`translation.proofread`,把汉化 workflow 标记为人工校对中。
|
`translation.proofread`,把汉化 workflow 标记为人工校对中。
|
||||||
|
- `POST /admin/control/localized-publish`:转发 `localized.publish`,从工作台或
|
||||||
|
completed worker 结果构建并发布汉化 release。
|
||||||
|
- `POST /admin/control/localized-rollback`:转发 `localized.rollback`,恢复
|
||||||
|
manifest 记录的上一汉化 release。
|
||||||
|
- `GET /admin/translation/status`:转发 `localized.status`,读取当前汉化 release
|
||||||
|
与 workflow 状态。
|
||||||
- `GET /admin/translation/tasks`:转发 `translation.tasks`,按分页、release、
|
- `GET /admin/translation/tasks`:转发 `translation.tasks`,按分页、release、
|
||||||
TextUnit、状态和失败原因过滤查询 worker 状态。
|
TextUnit、状态和失败原因过滤查询 worker 状态。
|
||||||
- `GET /admin/translation/handoff`:转发 `translation.handoff`,读取当前
|
- `GET /admin/translation/handoff`:转发 `translation.handoff`,读取当前
|
||||||
|
|||||||
Reference in New Issue
Block a user