fix(glossary): 补齐术语删除和审核门禁
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

This commit is contained in:
2026-09-08 20:59:27 +08:00
parent 94483ff14d
commit 0275a890bc
19 changed files with 589 additions and 32 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# BlueArchiveToolkit 当前工作区状态 # BlueArchiveToolkit 当前工作区状态
- **更新时间**2026-09-06 - **更新时间**2026-09-08
- **状态来源**:本地工作区盘点、代码验证和最新提交 - **状态来源**:本地工作区盘点、代码验证和最新提交
- **状态分支**`experiment` - **状态分支**`experiment`
- **最新已推送功能提交**:以当前 `git log --oneline -1` 为准 - **最新已推送功能提交**:以当前 `git log --oneline -1` 为准
+3
View File
@@ -194,6 +194,7 @@ launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` sn
| `translation-glossary-update` | `translation.glossary.update` | term draft + `reviewer` | `202` + Glossary term | | `translation-glossary-update` | `translation.glossary.update` | term draft + `reviewer` | `202` + Glossary term |
| `translation-glossary-approve` | `translation.glossary.approve` | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | `202` + approved term | | `translation-glossary-approve` | `translation.glossary.approve` | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | `202` + approved term |
| `translation-glossary-deprecate` | `translation.glossary.deprecate` | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | `202` + deprecated term | | `translation-glossary-deprecate` | `translation.glossary.deprecate` | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | `202` + deprecated term |
| `translation-glossary-delete` | `translation.glossary.delete` | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | `202` + deleted term snapshot |
| `localized-publish` | `localized.publish` | `{ "translation_file": "...", "localized_release_id": "..." }``{ "from_worker": true, "localized_release_id": "..." }` | `202` + localized release manifest | | `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 | | `localized-rollback` | `localized.rollback` | 可选 `{ "localized_release_id": "..." }` | `202` + rollback report |
@@ -337,6 +338,8 @@ bat i18n glossary approve --glossary-term-id term-sensei \
--glossary-reviewer operator --glossary-reason '术语审校通过' --glossary-reviewer operator --glossary-reason '术语审校通过'
bat i18n glossary diagnose --glossary-source-text 'Sensei' \ bat i18n glossary diagnose --glossary-source-text 'Sensei' \
--glossary-context-json '{"destination":"Table.bytes"}' --glossary-context-json '{"destination":"Table.bytes"}'
bat i18n glossary delete --glossary-term-id term-sensei \
--glossary-reviewer operator --glossary-reason '重复术语'
``` ```
只有 `approved` term 会进入 provider constraints 和 TM 自动复用前的 QA。 只有 `approved` term 会进入 provider constraints 和 TM 自动复用前的 QA。
+16 -1
View File
@@ -553,7 +553,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, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, localized-publish, localized-rollback] enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, translation-glossary-delete, localized-publish, localized-rollback]
requestBody: requestBody:
required: false required: false
content: content:
@@ -619,6 +619,21 @@ paths:
type: string type: string
translated_text: translated_text:
type: string type: string
glossary_override:
type: object
required: [reviewer, reason, provenance, confirmed_unix_seconds]
additionalProperties: false
properties:
reviewer:
type: string
reason:
type: string
provenance:
type: string
confirmed_unix_seconds:
type: integer
format: int64
minimum: 1
fixture_path: fixture_path:
type: string type: string
concurrency: concurrency:
+39
View File
@@ -551,6 +551,16 @@ pub fn validate_glossary_draft(draft: &GlossaryTermDraft) -> crate::Result<()> {
"Glossary allowed_translations 不能包含空字符串".to_string(), "Glossary allowed_translations 不能包含空字符串".to_string(),
)); ));
} }
if draft
.definition
.aliases
.iter()
.any(|value| value.trim().is_empty())
{
return Err(crate::Error::InvalidArgument(
"Glossary aliases 不能包含空字符串".to_string(),
));
}
let accepted = draft let accepted = draft
.definition .definition
.allowed_translations .allowed_translations
@@ -687,4 +697,33 @@ mod tests {
.iter() .iter()
.any(|diagnostic| diagnostic.kind == GlossaryDiagnosticKind::Overridden)); .any(|diagnostic| diagnostic.kind == GlossaryDiagnosticKind::Overridden));
} }
#[test]
fn empty_alias_is_rejected() {
let mut draft = GlossaryTermDraft {
term_id: "term".to_string(),
definition: GlossaryTermSnapshot {
source_term: "Sensei".to_string(),
aliases: vec![" ".to_string()],
recommended_translation: "老师".to_string(),
allowed_translations: Vec::new(),
source_language: None,
target_language: None,
category: None,
priority: 0,
scope: BTreeMap::new(),
},
review_status: GlossaryReviewStatus::Draft,
source: GlossarySourceRecord {
source_kind: GlossarySourceKind::Manual,
source_ref: None,
source_author: None,
source_note: None,
observed_unix_seconds: 1,
},
};
assert!(validate_glossary_draft(&draft).is_err());
draft.definition.aliases.clear();
assert!(validate_glossary_draft(&draft).is_ok());
}
} }
+7 -2
View File
@@ -230,6 +230,7 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且
| `translation.glossary.update` | 已实现 | term draft + `reviewer`,可选 `reason` | Rust 替换 term definition,并记录 source/review history。 | | `translation.glossary.update` | 已实现 | term draft + `reviewer`,可选 `reason` | Rust 替换 term definition,并记录 source/review history。 |
| `translation.glossary.approve` | 已实现 | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | 将 term 明确置为 approved;只有 approved term 进入 worker/TM 自动流程。 | | `translation.glossary.approve` | 已实现 | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | 将 term 明确置为 approved;只有 approved term 进入 worker/TM 自动流程。 |
| `translation.glossary.deprecate` | 已实现 | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | 保留历史但停止自动应用。 | | `translation.glossary.deprecate` | 已实现 | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | 保留历史但停止自动应用。 |
| `translation.glossary.delete` | 已实现 | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | 显式删除当前 term;需要 reviewer/reasonRust 另保留删除审计快照,返回删除前快照。 |
TM 的自动复用规则是 raw source 完全相同、完整 context 完全相同且状态为 `trusted` TM 的自动复用规则是 raw source 完全相同、完整 context 完全相同且状态为 `trusted`
context 缺失/不一致、normalized source 仅辅助查询、candidate 或 provider 成功都不会 context 缺失/不一致、normalized source 仅辅助查询、candidate 或 provider 成功都不会
@@ -285,7 +286,9 @@ offset 和 error。TypeTree-covered managed reference 字段会进入结构化
记录完成时间,`failed` 可写入 `failure_reason`。人工校对流程可以在 记录完成时间,`failed` 可写入 `failure_reason`。人工校对流程可以在
`status=completed` 时额外提交 `provider``provider_run_id` `status=completed` 时额外提交 `provider``provider_run_id`
`translation_results[]`,每个结果必须包含 `unit_id``source_text` `translation_results[]`,每个结果必须包含 `unit_id``source_text`
`translated_text`Rust 会用当前 `official-textunit-index.json` 校验 unit `translated_text`结果也可以提交完整的 `glossary_override``reviewer`
`reason``provenance``confirmed_unix_seconds`),用于人工确认 Glossary
blocking deviation。Rust 会用当前 `official-textunit-index.json` 校验 unit、
source text、destination 和 archive entry 后再落库。因此 worker 或人工校对流程 source text、destination 和 archive entry 后再落库。因此 worker 或人工校对流程
消费 handoff 后,bat-api 可通过 `translation.tasks` 查询单项任务,也可通过 消费 handoff 后,bat-api 可通过 `translation.tasks` 查询单项任务,也可通过
`translation.handoff` 获取完整 job/unit/provider run 状态。`translation.handoff` `translation.handoff` 获取完整 job/unit/provider run 状态。`translation.handoff`
@@ -472,6 +475,7 @@ CLI 对应关系:
| `bat i18n glossary diagnose` | `translation.glossary.diagnose` | | `bat i18n glossary diagnose` | `translation.glossary.diagnose` |
| `bat i18n glossary add/update` | `translation.glossary.add` / `translation.glossary.update` | | `bat i18n glossary add/update` | `translation.glossary.add` / `translation.glossary.update` |
| `bat i18n glossary approve/deprecate` | `translation.glossary.approve` / `translation.glossary.deprecate` | | `bat i18n glossary approve/deprecate` | `translation.glossary.approve` / `translation.glossary.deprecate` |
| `bat i18n glossary delete` | `translation.glossary.delete` |
| `bat localized-status` | `localized.status` | | `bat localized-status` | `localized.status` |
| `bat resource-index` | `resource.index` | | `bat resource-index` | `resource.index` |
@@ -507,6 +511,7 @@ CLI 对应关系:
`translation.memory.query``translation.memory.confirm``translation.glossary.summary` `translation.memory.query``translation.memory.confirm``translation.glossary.summary`
`translation.glossary.query``translation.glossary.diagnose``translation.glossary.add` `translation.glossary.query``translation.glossary.diagnose``translation.glossary.add`
`translation.glossary.update``translation.glossary.approve``translation.glossary.deprecate` `translation.glossary.update``translation.glossary.approve``translation.glossary.deprecate`
`translation.glossary.delete`
`task.*` 和三个 `unityfs.patch_*` 方法。 `task.*` 和三个 `unityfs.patch_*` 方法。
- `resource.index``patch.apply` 当前没有专用 typed helper;需要直接使用 `Call`,并仍须遵守 - `resource.index``patch.apply` 当前没有专用 typed helper;需要直接使用 `Call`,并仍须遵守
本契约的参数和响应定义。 本契约的参数和响应定义。
@@ -523,7 +528,7 @@ CLI 对应关系:
| `ParseBackend` | `parse.status``parse.text_units``parse.errors` | 鉴权后的当前 release 解析状态、TextUnit 和解析错误只读查询 | | `ParseBackend` | `parse.status``parse.text_units``parse.errors` | 鉴权后的当前 release 解析状态、TextUnit 和解析错误只读查询 |
| `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 触发与人工校对标记 |
| `TranslationMemoryBackend` | `translation.memory.summary``translation.memory.query``translation.memory.confirm` | 鉴权后的 TM 摘要、source/context 查询和显式 candidate 确认;Go 只转发,不持有 TM 状态 | | `TranslationMemoryBackend` | `translation.memory.summary``translation.memory.query``translation.memory.confirm` | 鉴权后的 TM 摘要、source/context 查询和显式 candidate 确认;Go 只转发,不持有 TM 状态 |
| `GlossaryBackend` | `translation.glossary.summary/query/diagnose/add/update/approve/deprecate` | 鉴权后的 Glossary 摘要、term/history 查询、确定性诊断和审核 mutation;Go 只转发,不持有 Glossary 状态 | | `GlossaryBackend` | `translation.glossary.summary/query/diagnose/add/update/approve/deprecate/delete` | 鉴权后的 Glossary 摘要、term/history 查询、确定性诊断和审核/删除 mutationGo 只转发,不持有 Glossary 状态 |
| `LocalizedBackend` | `localized.status``localized.publish``localized.rollback` | 鉴权后的汉化 release 状态、发布与显式回滚 | | `LocalizedBackend` | `localized.status``localized.publish``localized.rollback` | 鉴权后的汉化 release 状态、发布与显式回滚 |
`daemon.stop``daemon.clean-stable` 和任意通用 RPC 不属于 bat-api 管理控制面。 `daemon.stop``daemon.clean-stable` 和任意通用 RPC 不属于 bat-api 管理控制面。
+1 -1
View File
@@ -113,7 +113,7 @@ rollback`localized.status` 能校验当前官方 release 与 patch manifest
### G-012Translation Memory V1 已实现,扩展能力仍缺失 ### G-012Translation Memory V1 已实现,扩展能力仍缺失
Rust `bat` 已提供独立项目级 SQLite TM,记录 raw source/hash、完整 context、release/TextUnit/provider/run provenance,区分 candidate/trusted,只有显式 confirm 才能建立 trusted 记录;worker 只自动复用 trusted 的 raw source + 完整 context exact match。Go `bat-api` 已提供鉴权的 summary/query 只读接口和 confirm 转发,但 Go 不持有 TM 状态。仍缺少模糊匹配、Glossary 联动和更丰富的导入导出历史能力。 Rust `bat` 已提供独立项目级 SQLite TM,记录 raw source/hash、完整 context、release/TextUnit/provider/run provenance,区分 candidate/trusted,只有显式 confirm 才能建立 trusted 记录;worker 只自动复用 trusted 的 raw source + 完整 context exact match,并在复用前执行已批准 Glossary 的确定性 QA。Go `bat-api` 已提供鉴权的 summary/query 只读接口和 confirm 转发,但 Go 不持有 TM 状态。仍缺少模糊匹配和更丰富的导入导出历史能力。
### G-013Glossary V1 已实现,协作视图仍缺失 ### G-013Glossary V1 已实现,协作视图仍缺失
+1 -1
View File
@@ -95,7 +95,7 @@
| 组件 | 路径 | 状态 | 说明 | | 组件 | 路径 | 状态 | 说明 |
|---|---|---|---| |---|---|---|---|
| Module | `go.mod``bat-api` | 已用 | 服务层模块名 | | Module | `go.mod``bat-api` | 已用 | 服务层模块名 |
| RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helpertyped 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``translation.memory.summary/query/confirm``translation.glossary.summary/query/diagnose/add/update/approve/deprecate` 和文件级 UnityFS patch 调用;`resource.index``patch.apply` 仍通过通用 `Call` 走同一 contractfake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 | | RPC client | `internal/backendrpc` | **完成** | Unix socket JSON-RPC transport + typed helpertyped 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``translation.memory.summary/query/confirm``translation.glossary.summary/query/diagnose/add/update/approve/deprecate/delete` 和文件级 UnityFS patch 调用;`resource.index``patch.apply` 仍通过通用 `Call` 走同一 contractfake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 |
| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + translation/TM/Glossary admin forwarding + 内嵌 dashboard + `.env` | | 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理控制白名单 + translation/TM/Glossary admin forwarding + 内嵌 dashboard + `.env` |
| 试验 CLI | `cmd/bat` | **试验** | doctor 固定 okmanifest/sync 走 FFI | | 试验 CLI | `cmd/bat` | **试验** | doctor 固定 okmanifest/sync 走 FFI |
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` | | FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
+28 -3
View File
@@ -74,9 +74,9 @@ mod translation_query;
#[path = "workflow_commands.rs"] #[path = "workflow_commands.rs"]
mod workflow_commands; mod workflow_commands;
use glossary_query::{ use glossary_query::{
glossary_diagnose_rpc_report, glossary_mutation_rpc_report, glossary_query_rpc_report, glossary_delete_rpc_report, glossary_diagnose_rpc_report, glossary_mutation_rpc_report,
glossary_review_rpc_report, glossary_rpc_envelope, glossary_summary_rpc_report, glossary_query_rpc_report, glossary_review_rpc_report, glossary_rpc_envelope,
run_glossary_command, glossary_summary_rpc_report, run_glossary_command,
}; };
use patch_commands::{ use patch_commands::{
is_write_patch_command, run_write_patch_command, validate_write_patch_options, is_write_patch_command, run_write_patch_command, validate_write_patch_options,
@@ -243,6 +243,7 @@ fn run() -> anyhow::Result<i32> {
| CliCommand::GlossaryUpdate | CliCommand::GlossaryUpdate
| CliCommand::GlossaryApprove | CliCommand::GlossaryApprove
| CliCommand::GlossaryDeprecate | CliCommand::GlossaryDeprecate
| CliCommand::GlossaryDelete
| CliCommand::GlossaryDiagnose => { | CliCommand::GlossaryDiagnose => {
run_glossary_command(&options)?; run_glossary_command(&options)?;
Ok(0) Ok(0)
@@ -658,6 +659,7 @@ enum CliCommand {
GlossaryUpdate, GlossaryUpdate,
GlossaryApprove, GlossaryApprove,
GlossaryDeprecate, GlossaryDeprecate,
GlossaryDelete,
GlossaryDiagnose, GlossaryDiagnose,
Repack, Repack,
PublishLocalized, PublishLocalized,
@@ -1182,6 +1184,7 @@ const RPC_METHOD_GLOSSARY_ADD: &str = "translation.glossary.add";
const RPC_METHOD_GLOSSARY_UPDATE: &str = "translation.glossary.update"; const RPC_METHOD_GLOSSARY_UPDATE: &str = "translation.glossary.update";
const RPC_METHOD_GLOSSARY_APPROVE: &str = "translation.glossary.approve"; const RPC_METHOD_GLOSSARY_APPROVE: &str = "translation.glossary.approve";
const RPC_METHOD_GLOSSARY_DEPRECATE: &str = "translation.glossary.deprecate"; const RPC_METHOD_GLOSSARY_DEPRECATE: &str = "translation.glossary.deprecate";
const RPC_METHOD_GLOSSARY_DELETE: &str = "translation.glossary.delete";
const RPC_METHOD_GLOSSARY_DIAGNOSE: &str = "translation.glossary.diagnose"; const RPC_METHOD_GLOSSARY_DIAGNOSE: &str = "translation.glossary.diagnose";
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_PUBLISH: &str = "localized.publish";
@@ -2296,6 +2299,15 @@ fn dispatch_rpc_method(
}, },
), ),
), ),
RPC_METHOD_GLOSSARY_DELETE => glossary_rpc_envelope(
request_id,
glossary_delete_rpc_report(
state_dir,
&tasks.base_config.output_root,
tasks.translation_worker_config.glossary_path.as_deref(),
request.params.as_ref(),
),
),
RPC_METHOD_TRANSLATION_WORKER_RUN => { RPC_METHOD_TRANSLATION_WORKER_RUN => {
let config = match rpc_translation_worker_config_with_defaults( let config = match rpc_translation_worker_config_with_defaults(
request.params.as_ref(), request.params.as_ref(),
@@ -7474,6 +7486,7 @@ fn parse_args_with_env(
| CliCommand::GlossaryUpdate | CliCommand::GlossaryUpdate
| CliCommand::GlossaryApprove | CliCommand::GlossaryApprove
| CliCommand::GlossaryDeprecate | CliCommand::GlossaryDeprecate
| CliCommand::GlossaryDelete
| CliCommand::GlossaryDiagnose | CliCommand::GlossaryDiagnose
| CliCommand::TranslationSet | CliCommand::TranslationSet
| CliCommand::TranslationValidate | CliCommand::TranslationValidate
@@ -7629,6 +7642,7 @@ fn parse_args_with_env(
| CliCommand::GlossaryUpdate | CliCommand::GlossaryUpdate
| CliCommand::GlossaryApprove | CliCommand::GlossaryApprove
| CliCommand::GlossaryDeprecate | CliCommand::GlossaryDeprecate
| CliCommand::GlossaryDelete
| CliCommand::GlossaryDiagnose => { | CliCommand::GlossaryDiagnose => {
if options.watch if options.watch
|| options.daemon || options.daemon
@@ -7686,6 +7700,16 @@ fn parse_args_with_env(
)); ));
} }
} }
CliCommand::GlossaryDelete => {
if options.glossary_term_id.is_none()
|| options.glossary_reviewer.is_none()
|| options.glossary_reason.is_none()
{
return Err(anyhow::anyhow!(
"Glossary delete 必须指定 --glossary-term-id、--glossary-reviewer 和 --glossary-reason"
));
}
}
_ => unreachable!(), _ => unreachable!(),
} }
options.progress = false; options.progress = false;
@@ -8293,6 +8317,7 @@ fn parse_translation_glossary_command(
"update" | "edit" => CliCommand::GlossaryUpdate, "update" | "edit" => CliCommand::GlossaryUpdate,
"approve" | "trust" => CliCommand::GlossaryApprove, "approve" | "trust" => CliCommand::GlossaryApprove,
"deprecate" | "retire" => CliCommand::GlossaryDeprecate, "deprecate" | "retire" => CliCommand::GlossaryDeprecate,
"delete" | "remove" => CliCommand::GlossaryDelete,
"diagnose" | "check" => CliCommand::GlossaryDiagnose, "diagnose" | "check" => CliCommand::GlossaryDiagnose,
other => { other => {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
+87
View File
@@ -425,6 +425,20 @@ fn glossary_commands_parse_and_validate() {
]) ])
.unwrap(); .unwrap();
assert_eq!(diagnose.command, CliCommand::GlossaryDiagnose); assert_eq!(diagnose.command, CliCommand::GlossaryDiagnose);
let delete = parse(&[
"bat",
"i18n",
"glossary",
"delete",
"--glossary-term-id",
"term-sensei",
"--glossary-reviewer",
"operator",
"--glossary-reason",
"duplicate",
])
.unwrap();
assert_eq!(delete.command, CliCommand::GlossaryDelete);
assert!(parse(&["bat", "i18n", "glossary", "diagnose"]).is_err()); assert!(parse(&["bat", "i18n", "glossary", "diagnose"]).is_err());
assert!(parse(&[ assert!(parse(&[
"bat", "bat",
@@ -2784,6 +2798,79 @@ fn dispatch_glossary_summary_reports_missing_database_without_creating_it() {
assert!(!output_root.join("glossary.sqlite").exists()); assert!(!output_root.join("glossary.sqlite").exists());
} }
#[test]
fn dispatch_glossary_delete_removes_term_and_returns_snapshot() {
let temp = tempfile::TempDir::new().unwrap();
let output_root = temp.path().join("output");
let state_dir = temp.path().join("state");
let glossary_path = output_root.join("glossary.sqlite");
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
runtime.block_on(async {
let repository = bat_infrastructure::SqliteGlossaryRepository::new(&glossary_path)
.await
.unwrap();
repository
.add(bat_core::domain::GlossaryTermDraft {
term_id: "term-sensei".to_string(),
definition: bat_core::domain::GlossaryTermSnapshot {
source_term: "Sensei".to_string(),
aliases: vec!["Teacher".to_string()],
recommended_translation: "老师".to_string(),
allowed_translations: Vec::new(),
source_language: None,
target_language: None,
category: Some("person".to_string()),
priority: 10,
scope: Default::default(),
},
review_status: bat_core::domain::GlossaryReviewStatus::Draft,
source: bat_core::domain::GlossarySourceRecord {
source_kind: bat_core::domain::GlossarySourceKind::Manual,
source_ref: Some("test".to_string()),
source_author: Some("tester".to_string()),
source_note: None,
observed_unix_seconds: 100,
},
})
.await
.unwrap();
});
let context = test_task_context_with_config(OfficialUpdateConfig {
output_root: output_root.clone(),
..Default::default()
});
let envelope = dispatch_rpc_method(
&rpc_request(
"translation.glossary.delete",
Some(serde_json::json!({
"term_id": "term-sensei",
"reviewer": "reviewer",
"reason": "duplicate"
})),
),
&state_dir,
&new_daemon_control(),
&context,
"req-glossary-delete-1".to_string(),
);
let value = serde_json::to_value(envelope).unwrap();
assert_eq!(value["ok"], true);
assert_eq!(value["data"]["deleted"], true);
assert_eq!(value["data"]["term"]["term_id"], "term-sensei");
assert_eq!(value["data"]["term"]["source_term"], "Sensei");
runtime.block_on(async {
let repository = bat_infrastructure::SqliteGlossaryRepository::open(&glossary_path)
.await
.unwrap();
assert!(repository.find("term-sensei").await.is_err());
});
}
#[test] #[test]
fn dispatch_translation_memory_rejects_invalid_params_with_stable_error_code() { fn dispatch_translation_memory_rejects_invalid_params_with_stable_error_code() {
let temp = tempfile::TempDir::new().unwrap(); let temp = tempfile::TempDir::new().unwrap();
@@ -65,6 +65,19 @@ pub(super) fn run_glossary_command(options: &CliOptions) -> anyhow::Result<()> {
options.glossary_reason.clone(), options.glossary_reason.clone(),
)? )?
} }
CliCommand::GlossaryDelete => {
let term_id = options.glossary_term_id.as_deref().ok_or_else(|| {
anyhow::anyhow!("Glossary delete 必须指定 --glossary-term-id")
})?;
let reviewer = options.glossary_reviewer.as_deref().ok_or_else(|| {
anyhow::anyhow!("Glossary delete 必须指定 --glossary-reviewer")
})?;
let reason = options
.glossary_reason
.as_deref()
.ok_or_else(|| anyhow::anyhow!("Glossary delete 必须指定 --glossary-reason"))?;
build_glossary_delete_report(&path, term_id, reviewer, reason)?
}
_ => unreachable!(), _ => unreachable!(),
}; };
print_json_value(options.output_format, &report) print_json_value(options.output_format, &report)
@@ -78,6 +91,7 @@ fn glossary_method(command: CliCommand) -> anyhow::Result<&'static str> {
CliCommand::GlossaryUpdate => RPC_METHOD_GLOSSARY_UPDATE, CliCommand::GlossaryUpdate => RPC_METHOD_GLOSSARY_UPDATE,
CliCommand::GlossaryApprove => RPC_METHOD_GLOSSARY_APPROVE, CliCommand::GlossaryApprove => RPC_METHOD_GLOSSARY_APPROVE,
CliCommand::GlossaryDeprecate => RPC_METHOD_GLOSSARY_DEPRECATE, CliCommand::GlossaryDeprecate => RPC_METHOD_GLOSSARY_DEPRECATE,
CliCommand::GlossaryDelete => RPC_METHOD_GLOSSARY_DELETE,
CliCommand::GlossaryDiagnose => RPC_METHOD_GLOSSARY_DIAGNOSE, CliCommand::GlossaryDiagnose => RPC_METHOD_GLOSSARY_DIAGNOSE,
_ => return Err(anyhow::anyhow!("不是 Glossary 命令")), _ => return Err(anyhow::anyhow!("不是 Glossary 命令")),
}) })
@@ -159,6 +173,20 @@ fn glossary_cli_params(options: &CliOptions) -> anyhow::Result<Option<serde_json
params.insert("reason".to_string(), serde_json::json!(reason)); params.insert("reason".to_string(), serde_json::json!(reason));
} }
} }
CliCommand::GlossaryDelete => {
params.insert(
"term_id".to_string(),
serde_json::json!(options.glossary_term_id.as_deref().unwrap_or_default()),
);
params.insert(
"reviewer".to_string(),
serde_json::json!(options.glossary_reviewer.as_deref().unwrap_or_default()),
);
params.insert(
"reason".to_string(),
serde_json::json!(options.glossary_reason.as_deref().unwrap_or_default()),
);
}
_ => unreachable!(), _ => unreachable!(),
} }
Ok(Some(serde_json::Value::Object(params))) Ok(Some(serde_json::Value::Object(params)))
@@ -414,6 +442,33 @@ pub(super) fn build_glossary_review_report(
})) }))
} }
pub(super) fn build_glossary_delete_report(
path: &std::path::Path,
term_id: &str,
reviewer: &str,
reason: &str,
) -> anyhow::Result<serde_json::Value> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let term = runtime.block_on(async {
let repository = SqliteGlossaryRepository::open(path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.delete(term_id, reviewer, reason)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"schema_version": GLOSSARY_SCHEMA_VERSION,
"deleted": true,
"term": term,
}))
}
pub(super) fn glossary_rpc_envelope( pub(super) fn glossary_rpc_envelope(
request_id: String, request_id: String,
result: Result<serde_json::Value, ApiError>, result: Result<serde_json::Value, ApiError>,
@@ -546,6 +601,30 @@ pub(super) fn glossary_review_rpc_report(
.map_err(|error| glossary_internal_error(method, error)) .map_err(|error| glossary_internal_error(method, error))
} }
pub(super) fn glossary_delete_rpc_report(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
) -> Result<serde_json::Value, ApiError> {
let params = glossary_params(params, RPC_METHOD_GLOSSARY_DELETE)?;
let term_id = glossary_string(&params, "term_id", RPC_METHOD_GLOSSARY_DELETE)?
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DELETE, "缺少 term_id"))?;
let reviewer = glossary_string(&params, "reviewer", RPC_METHOD_GLOSSARY_DELETE)?
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DELETE, "缺少 reviewer"))?;
let reason = glossary_string(&params, "reason", RPC_METHOD_GLOSSARY_DELETE)?
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DELETE, "缺少 reason"))?;
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
Some(&serde_json::Value::Object(params.clone())),
RPC_METHOD_GLOSSARY_DELETE,
)?;
build_glossary_delete_report(&path, term_id, reviewer, reason)
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_DELETE, error))
}
fn glossary_rpc_path( fn glossary_rpc_path(
state_dir: &Path, state_dir: &Path,
output_root: &Path, output_root: &Path,
@@ -348,6 +348,7 @@ Commands:
i18n glossary summary/query Show project Glossary terms and review counts i18n glossary summary/query Show project Glossary terms and review counts
i18n glossary add/update Add or replace one Glossary term definition i18n glossary add/update Add or replace one Glossary term definition
i18n glossary approve/deprecate Review one Glossary term i18n glossary approve/deprecate Review one Glossary term
i18n glossary delete Remove one Glossary term with reviewer and reason
i18n glossary diagnose Run deterministic Glossary QA for one TextUnit source i18n glossary diagnose Run deterministic Glossary QA for one TextUnit source
i18n publish Publish a localized release from a workbench or worker results i18n publish Publish a localized release from a workbench or worker results
i18n rollback Roll back the current localized release i18n rollback Roll back the current localized release
@@ -439,13 +440,13 @@ Sync:
--translation-provider <NAME> / --provider <NAME> Provider for i18n worker run (mock/crowdin) --translation-provider <NAME> / --provider <NAME> Provider for i18n worker run (mock/crowdin)
--translation-fixture <PATH> Mock/provider fixture for i18n worker run --translation-fixture <PATH> Mock/provider fixture for i18n worker run
--glossary-path <PATH> Project Glossary SQLite path --glossary-path <PATH> Project Glossary SQLite path
--glossary-term-id <ID> Glossary term ID for add/update/review --glossary-term-id <ID> Glossary term ID for add/update/review/delete
--glossary-source-term <TEXT> Source spelling for a Glossary term --glossary-source-term <TEXT> Source spelling for a Glossary term
--glossary-recommended-translation <TEXT> Recommended target translation --glossary-recommended-translation <TEXT> Recommended target translation
--glossary-source-text <TEXT> Source TextUnit text for Glossary query/diagnose --glossary-source-text <TEXT> Source TextUnit text for Glossary query/diagnose
--glossary-context-json <JSON> TextUnit context for Glossary diagnose --glossary-context-json <JSON> TextUnit context for Glossary diagnose
--glossary-reviewer <ID> Reviewer for Glossary updates/reviews --glossary-reviewer <ID> Reviewer for Glossary updates/reviews/delete
--glossary-reason <TEXT> Reason for Glossary review or override --glossary-reason <TEXT> Reason for Glossary review/delete or override
--glossary-provenance <TEXT> Provenance for an explicit Glossary override --glossary-provenance <TEXT> Provenance for an explicit Glossary override
--worker-concurrency <N> Translation worker concurrency (default: 8, range 1..=256) --worker-concurrency <N> Translation worker concurrency (default: 8, range 1..=256)
--worker-max-attempts <N> Maximum claims per translation task --worker-max-attempts <N> Maximum claims per translation task
+139 -14
View File
@@ -160,6 +160,21 @@ impl SqliteGlossaryRepository {
.execute(&self.pool) .execute(&self.pool)
.await .await
.map_err(db_error)?; .map_err(db_error)?;
sqlx::query(
"CREATE TABLE IF NOT EXISTS glossary_term_deletions (
deletion_id TEXT PRIMARY KEY NOT NULL,
term_id TEXT NOT NULL,
reviewer TEXT NOT NULL,
reason TEXT NOT NULL,
source_json TEXT NOT NULL,
snapshot_json TEXT NOT NULL,
history_json TEXT NOT NULL,
observed_unix_seconds INTEGER NOT NULL
)",
)
.execute(&self.pool)
.await
.map_err(db_error)?;
sqlx::query( sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_glossary_status "CREATE INDEX IF NOT EXISTS idx_glossary_status
ON glossary_terms(review_status, priority DESC, term_id)", ON glossary_terms(review_status, priority DESC, term_id)",
@@ -244,6 +259,31 @@ impl SqliteGlossaryRepository {
"Glossary query limit 必须在 1..=1000 范围内".to_string(), "Glossary query limit 必须在 1..=1000 范围内".to_string(),
)); ));
} }
let candidates = self.load_terms(category, review_status).await?;
let mut terms = Vec::new();
for term in candidates {
if source_text.is_none_or(|source| {
let mut spellings = vec![term.definition.source_term.as_str()];
spellings.extend(term.definition.aliases.iter().map(String::as_str));
spellings
.into_iter()
.filter(|spelling| !spelling.is_empty())
.any(|spelling| source.contains(spelling))
}) {
terms.push(term);
if terms.len() >= limit {
break;
}
}
}
Ok(terms)
}
async fn load_terms(
&self,
category: Option<&str>,
review_status: Option<GlossaryReviewStatus>,
) -> Result<Vec<GlossaryTerm>> {
let rows = sqlx::query( let rows = sqlx::query(
"SELECT term_id FROM glossary_terms "SELECT term_id FROM glossary_terms
WHERE (?1 IS NULL OR category = ?1) WHERE (?1 IS NULL OR category = ?1)
@@ -258,19 +298,7 @@ impl SqliteGlossaryRepository {
let mut terms = Vec::new(); let mut terms = Vec::new();
for row in rows { for row in rows {
let term_id: String = row.try_get("term_id").map_err(db_error)?; let term_id: String = row.try_get("term_id").map_err(db_error)?;
let term = self.find(&term_id).await?; terms.push(self.find(&term_id).await?);
if source_text.is_none_or(|source| {
let mut spellings = vec![term.definition.source_term.as_str()];
spellings.extend(term.definition.aliases.iter().map(String::as_str));
spellings
.into_iter()
.any(|spelling| source.contains(spelling))
}) {
terms.push(term);
if terms.len() >= limit {
break;
}
}
} }
Ok(terms) Ok(terms)
} }
@@ -303,6 +331,11 @@ impl SqliteGlossaryRepository {
/// Adds a term and records its source snapshot. /// Adds a term and records its source snapshot.
pub async fn add(&self, draft: GlossaryTermDraft) -> Result<GlossaryTerm> { pub async fn add(&self, draft: GlossaryTermDraft) -> Result<GlossaryTerm> {
validate_glossary_draft(&draft)?; validate_glossary_draft(&draft)?;
if draft.review_status != GlossaryReviewStatus::Draft {
return Err(Error::InvalidArgument(
"Glossary add 只能创建 draft;请通过 review/approve 使术语生效".to_string(),
));
}
let now = draft.source.observed_unix_seconds; let now = draft.source.observed_unix_seconds;
let term = term_from_draft(&draft, now, now); let term = term_from_draft(&draft, now, now);
let mut transaction = self.pool.begin().await.map_err(db_error)?; let mut transaction = self.pool.begin().await.map_err(db_error)?;
@@ -332,6 +365,11 @@ impl SqliteGlossaryRepository {
reason: Option<String>, reason: Option<String>,
) -> Result<GlossaryTerm> { ) -> Result<GlossaryTerm> {
validate_glossary_draft(&draft)?; validate_glossary_draft(&draft)?;
if draft.review_status != GlossaryReviewStatus::Draft {
return Err(Error::InvalidArgument(
"Glossary update 只能写入 draft;修改 approved 术语后必须重新 approve".to_string(),
));
}
if reviewer.trim().is_empty() { if reviewer.trim().is_empty() {
return Err(Error::InvalidArgument( return Err(Error::InvalidArgument(
"Glossary update reviewer 不能为空".to_string(), "Glossary update reviewer 不能为空".to_string(),
@@ -355,6 +393,76 @@ impl SqliteGlossaryRepository {
self.find(&draft.term_id).await self.find(&draft.term_id).await
} }
/// Permanently removes a term and its stored history after explicit review.
pub async fn delete(
&self,
term_id: &str,
reviewer: &str,
reason: &str,
) -> Result<GlossaryTerm> {
if reviewer.trim().is_empty() {
return Err(Error::InvalidArgument(
"Glossary delete reviewer 不能为空".to_string(),
));
}
if reason.trim().is_empty() {
return Err(Error::InvalidArgument(
"Glossary delete reason 不能为空".to_string(),
));
}
let term = self.find(term_id).await?;
let mut transaction = self.pool.begin().await.map_err(db_error)?;
let source_json = json(&term.source)?;
let snapshot_json = json(&term.definition)?;
let history_json = json(&term.history)?;
let observed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut deletion_hasher = blake3::Hasher::new();
for value in [term_id, reviewer.trim(), reason.trim()] {
deletion_hasher.update(value.as_bytes());
deletion_hasher.update(&[0]);
}
deletion_hasher.update(&observed.to_le_bytes());
deletion_hasher.update(
&SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
.to_le_bytes(),
);
sqlx::query(
"INSERT INTO glossary_term_deletions (
deletion_id, term_id, reviewer, reason, source_json,
snapshot_json, history_json, observed_unix_seconds
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
)
.bind(format!("gld-{}", deletion_hasher.finalize().to_hex()))
.bind(term_id)
.bind(reviewer.trim())
.bind(reason.trim())
.bind(source_json)
.bind(snapshot_json)
.bind(history_json)
.bind(i64::try_from(observed).unwrap_or(i64::MAX))
.execute(&mut *transaction)
.await
.map_err(db_error)?;
sqlx::query("DELETE FROM glossary_term_history WHERE term_id = ?1")
.bind(term_id)
.execute(&mut *transaction)
.await
.map_err(db_error)?;
sqlx::query("DELETE FROM glossary_terms WHERE term_id = ?1")
.bind(term_id)
.execute(&mut *transaction)
.await
.map_err(db_error)?;
transaction.commit().await.map_err(db_error)?;
Ok(term)
}
/// Changes review state and records a source/review history entry. /// Changes review state and records a source/review history entry.
pub async fn review( pub async fn review(
&self, &self,
@@ -416,7 +524,7 @@ impl SqliteGlossaryRepository {
context: &TranslationMemoryContext, context: &TranslationMemoryContext,
) -> Result<GlossaryEvaluation> { ) -> Result<GlossaryEvaluation> {
let terms = self let terms = self
.query(None, None, Some(GlossaryReviewStatus::Approved), 1000) .load_terms(None, Some(GlossaryReviewStatus::Approved))
.await?; .await?;
Ok(evaluate_glossary(&terms, source_text, context)) Ok(evaluate_glossary(&terms, source_text, context))
} }
@@ -723,6 +831,10 @@ mod tests {
let repository = SqliteGlossaryRepository::new(temp.path().join("glossary.sqlite")) let repository = SqliteGlossaryRepository::new(temp.path().join("glossary.sqlite"))
.await .await
.unwrap(); .unwrap();
assert!(repository
.add(draft(GlossaryReviewStatus::Approved))
.await
.is_err());
repository repository
.add(draft(GlossaryReviewStatus::Draft)) .add(draft(GlossaryReviewStatus::Draft))
.await .await
@@ -753,5 +865,18 @@ mod tests {
assert_eq!(term.history[1].action, "approved"); assert_eq!(term.history[1].action, "approved");
let summary = repository.summary().await.unwrap(); let summary = repository.summary().await.unwrap();
assert_eq!(summary.approved_count, 1); assert_eq!(summary.approved_count, 1);
let deleted = repository
.delete("term-sensei", "reviewer", "remove duplicate")
.await
.unwrap();
assert_eq!(deleted.history.len(), 2);
assert!(repository.find("term-sensei").await.is_err());
assert_eq!(repository.summary().await.unwrap().term_count, 0);
let deletion_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM glossary_term_deletions")
.fetch_one(&repository.pool)
.await
.unwrap();
assert_eq!(deletion_count, 1);
} }
} }
+10 -1
View File
@@ -1660,7 +1660,7 @@ mod tests {
priority: 10, priority: 10,
scope: BTreeMap::new(), scope: BTreeMap::new(),
}, },
review_status: bat_core::domain::GlossaryReviewStatus::Approved, review_status: bat_core::domain::GlossaryReviewStatus::Draft,
source: bat_core::domain::GlossarySourceRecord { source: bat_core::domain::GlossarySourceRecord {
source_kind: bat_core::domain::GlossarySourceKind::Manual, source_kind: bat_core::domain::GlossarySourceKind::Manual,
source_ref: Some("worker-test".to_string()), source_ref: Some("worker-test".to_string()),
@@ -1671,6 +1671,15 @@ mod tests {
}) })
.await .await
.unwrap(); .unwrap();
glossary
.review(
"term-source-0",
bat_core::domain::GlossaryReviewStatus::Approved,
"reviewer",
Some("test approval".to_string()),
)
.await
.unwrap();
#[derive(Debug)] #[derive(Debug)]
struct GlossaryProvider { struct GlossaryProvider {
+26
View File
@@ -82,6 +82,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
"/admin/control/translation-glossary-update", "/admin/control/translation-glossary-update",
"/admin/control/translation-glossary-approve", "/admin/control/translation-glossary-approve",
"/admin/control/translation-glossary-deprecate", "/admin/control/translation-glossary-deprecate",
"/admin/control/translation-glossary-delete",
"/admin/control/localized-publish", "/admin/control/localized-publish",
"/admin/control/localized-rollback", "/admin/control/localized-rollback",
}, },
@@ -495,6 +496,29 @@ func (s *Server) handleAdminGlossaryControl(w http.ResponseWriter, r *http.Reque
Status: "accepted", Status: "accepted",
Result: result, Result: result,
}) })
case "translation-glossary-delete":
var params backendrpc.GlossaryDeleteParams
if !decodeAdminTranslationJSON(w, r, &params) {
return
}
if strings.TrimSpace(params.TermID) == "" ||
strings.TrimSpace(params.Reviewer) == "" ||
strings.TrimSpace(params.Reason) == "" {
writeErrorJSON(w, http.StatusBadRequest, "invalid_glossary_params", "term_id, reviewer and reason are required for delete")
return
}
result, err := backend.GlossaryDelete(r.Context(), params)
if err != nil {
s.writeControlBackendError(w, action, err)
return
}
writeNoStoreJSON(w, http.StatusAccepted, AdminControlResponse{
Service: "bat-api",
Action: action,
RPCMethod: glossaryControlRPCMethod(action),
Status: "accepted",
Result: result,
})
default: default:
writeErrorJSON(w, http.StatusNotFound, "control_not_found", "unknown glossary control action") writeErrorJSON(w, http.StatusNotFound, "control_not_found", "unknown glossary control action")
} }
@@ -510,6 +534,8 @@ func glossaryControlRPCMethod(action string) string {
return "translation.glossary.approve" return "translation.glossary.approve"
case "translation-glossary-deprecate": case "translation-glossary-deprecate":
return "translation.glossary.deprecate" return "translation.glossary.deprecate"
case "translation-glossary-delete":
return "translation.glossary.delete"
default: default:
return "" return ""
} }
+17 -4
View File
@@ -642,6 +642,7 @@ type controlBackend struct {
glossaryDiagnoseParams []backendrpc.GlossaryDiagnoseParams glossaryDiagnoseParams []backendrpc.GlossaryDiagnoseParams
glossaryMutationParams []backendrpc.GlossaryTermMutationParams glossaryMutationParams []backendrpc.GlossaryTermMutationParams
glossaryReviewParams []backendrpc.GlossaryReviewParams glossaryReviewParams []backendrpc.GlossaryReviewParams
glossaryDeleteParams []backendrpc.GlossaryDeleteParams
localizedPublishParams []backendrpc.LocalizedPublishParams localizedPublishParams []backendrpc.LocalizedPublishParams
localizedRollbackParams []backendrpc.LocalizedRollbackParams localizedRollbackParams []backendrpc.LocalizedRollbackParams
} }
@@ -905,6 +906,12 @@ func (b *controlBackend) GlossaryDeprecate(ctx context.Context, params backendrp
return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil
} }
func (b *controlBackend) GlossaryDelete(ctx context.Context, params backendrpc.GlossaryDeleteParams) (*backendrpc.GlossaryMutationReport, error) {
b.calls = append(b.calls, "translation.glossary.delete")
b.glossaryDeleteParams = append(b.glossaryDeleteParams, params)
return &backendrpc.GlossaryMutationReport{Available: true, Path: params.GlossaryPath}, nil
}
func (b *controlBackend) LocalizedStatus(ctx context.Context) (json.RawMessage, error) { func (b *controlBackend) LocalizedStatus(ctx context.Context) (json.RawMessage, error) {
b.calls = append(b.calls, "localized.status") b.calls = append(b.calls, "localized.status")
return json.RawMessage(`{"localized_release_status":"localized","status_code":"localized.published"}`), nil return json.RawMessage(`{"localized_release_status":"localized","status_code":"localized.published"}`), nil
@@ -1708,6 +1715,7 @@ func TestOpenAPIAndAdminReservedEndpoints(t *testing.T) {
!strings.Contains(rr.Body.String(), "/admin/tasks") || !strings.Contains(rr.Body.String(), "/admin/tasks") ||
!strings.Contains(rr.Body.String(), "/admin/parse/text-units") || !strings.Contains(rr.Body.String(), "/admin/parse/text-units") ||
!strings.Contains(rr.Body.String(), "translation_results") || !strings.Contains(rr.Body.String(), "translation_results") ||
!strings.Contains(rr.Body.String(), "glossary_override") ||
!strings.Contains(rr.Body.String(), "task-cancel") || !strings.Contains(rr.Body.String(), "task-cancel") ||
!strings.Contains(rr.Body.String(), "/admin/translation/memory/query") || !strings.Contains(rr.Body.String(), "/admin/translation/memory/query") ||
!strings.Contains(rr.Body.String(), "translation-memory-confirm") { !strings.Contains(rr.Body.String(), "translation-memory-confirm") {
@@ -1936,7 +1944,7 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
{name: "repair", action: "repair", rpcMethod: "resource.repair", call: "resource.repair"}, {name: "repair", action: "repair", rpcMethod: "resource.repair", call: "resource.repair"},
{name: "catalog refresh", action: "catalog-refresh", rpcMethod: "catalog.refresh", call: "catalog.refresh"}, {name: "catalog refresh", action: "catalog-refresh", rpcMethod: "catalog.refresh", call: "catalog.refresh"},
{name: "task cancel", action: "task-cancel", body: `{"task_id":"task-sync-1"}`, rpcMethod: "task.cancel", call: "task.cancel"}, {name: "task cancel", action: "task-cancel", body: `{"task_id":"task-sync-1"}`, rpcMethod: "task.cancel", call: "task.cancel"},
{name: "translation task update", action: "translation-task-update", body: `{"task_id":"textunit/v-current/Scenario","status":"completed","provider":"manual","provider_run_id":"manual-run-1","translation_results":[{"unit_id":"direct:a#unit:0","source_text":"source","translated_text":"译文"}]}`, rpcMethod: "translation.task.update", call: "translation.task.update"}, {name: "translation task update", action: "translation-task-update", body: `{"task_id":"textunit/v-current/Scenario","status":"completed","provider":"manual","provider_run_id":"manual-run-1","translation_results":[{"unit_id":"direct:a#unit:0","source_text":"source","translated_text":"译文","glossary_override":{"reviewer":"reviewer","reason":"approved deviation","provenance":"manual-review","confirmed_unix_seconds":100}}]}`, 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: "translation memory confirm", action: "translation-memory-confirm", body: `{"record_id":"tm-record-1","reviewer":"reviewer","reason":"reviewed"}`, rpcMethod: "translation.memory.confirm", call: "translation.memory.confirm"}, {name: "translation memory confirm", action: "translation-memory-confirm", body: `{"record_id":"tm-record-1","reviewer":"reviewer","reason":"reviewed"}`, rpcMethod: "translation.memory.confirm", call: "translation.memory.confirm"},
@@ -1944,6 +1952,7 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
{name: "translation glossary update", action: "translation-glossary-update", body: `{"term_id":"term-sensei","source_term":"Sensei","recommended_translation":"老师","review_status":"draft","reviewer":"reviewer","source":{"source_kind":"manual","observed_unix_seconds":100}}`, rpcMethod: "translation.glossary.update", call: "translation.glossary.update"}, {name: "translation glossary update", action: "translation-glossary-update", body: `{"term_id":"term-sensei","source_term":"Sensei","recommended_translation":"老师","review_status":"draft","reviewer":"reviewer","source":{"source_kind":"manual","observed_unix_seconds":100}}`, rpcMethod: "translation.glossary.update", call: "translation.glossary.update"},
{name: "translation glossary approve", action: "translation-glossary-approve", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"approved"}`, rpcMethod: "translation.glossary.approve", call: "translation.glossary.approve"}, {name: "translation glossary approve", action: "translation-glossary-approve", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"approved"}`, rpcMethod: "translation.glossary.approve", call: "translation.glossary.approve"},
{name: "translation glossary deprecate", action: "translation-glossary-deprecate", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"retired"}`, rpcMethod: "translation.glossary.deprecate", call: "translation.glossary.deprecate"}, {name: "translation glossary deprecate", action: "translation-glossary-deprecate", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"retired"}`, rpcMethod: "translation.glossary.deprecate", call: "translation.glossary.deprecate"},
{name: "translation glossary delete", action: "translation-glossary-delete", body: `{"term_id":"term-sensei","reviewer":"reviewer","reason":"duplicate"}`, rpcMethod: "translation.glossary.delete", call: "translation.glossary.delete"},
{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 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"}, {name: "localized rollback", action: "localized-rollback", body: `{"localized_release_id":"localized-1"}`, rpcMethod: "localized.rollback", call: "localized.rollback"},
} }
@@ -1972,7 +1981,9 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
if len(backend.translationTaskUpdates) != 1 || if len(backend.translationTaskUpdates) != 1 ||
backend.translationTaskUpdates[0].Provider != "manual" || backend.translationTaskUpdates[0].Provider != "manual" ||
len(backend.translationTaskUpdates[0].TranslationResults) != 1 || len(backend.translationTaskUpdates[0].TranslationResults) != 1 ||
backend.translationTaskUpdates[0].TranslationResults[0].TranslatedText != "译文" { backend.translationTaskUpdates[0].TranslationResults[0].TranslatedText != "译文" ||
backend.translationTaskUpdates[0].TranslationResults[0].GlossaryOverride == nil ||
backend.translationTaskUpdates[0].TranslationResults[0].GlossaryOverride.Reviewer != "reviewer" {
t.Fatalf("translation task updates=%#v", backend.translationTaskUpdates) t.Fatalf("translation task updates=%#v", backend.translationTaskUpdates)
} }
if len(backend.translationMemoryConfirmParams) != 1 || if len(backend.translationMemoryConfirmParams) != 1 ||
@@ -1984,8 +1995,10 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) {
backend.glossaryMutationParams[0].TermID != "term-sensei" || backend.glossaryMutationParams[0].TermID != "term-sensei" ||
backend.glossaryMutationParams[1].Reviewer != "reviewer" || backend.glossaryMutationParams[1].Reviewer != "reviewer" ||
len(backend.glossaryReviewParams) != 2 || len(backend.glossaryReviewParams) != 2 ||
backend.glossaryReviewParams[0].TermID != "term-sensei" { backend.glossaryReviewParams[0].TermID != "term-sensei" ||
t.Fatalf("Glossary params mutation=%#v review=%#v", backend.glossaryMutationParams, backend.glossaryReviewParams) len(backend.glossaryDeleteParams) != 1 ||
backend.glossaryDeleteParams[0].Reason != "duplicate" {
t.Fatalf("Glossary params mutation=%#v review=%#v delete=%#v", backend.glossaryMutationParams, backend.glossaryReviewParams, backend.glossaryDeleteParams)
} }
request := httptest.NewRequest(http.MethodPost, "/admin/control/translation-task-update", strings.NewReader(`{"task_id":""}`)) request := httptest.NewRequest(http.MethodPost, "/admin/control/translation-task-update", strings.NewReader(`{"task_id":""}`))
+16 -1
View File
@@ -560,7 +560,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, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, localized-publish, localized-rollback] enum: [reload, refresh, restart, sync, verify, repair, catalog-refresh, schedule-add, schedule-update, schedule-remove, schedule-run, task-cancel, translation-task-update, translation-worker-run, translation-proofread, translation-memory-confirm, translation-glossary-add, translation-glossary-update, translation-glossary-approve, translation-glossary-deprecate, translation-glossary-delete, localized-publish, localized-rollback]
requestBody: requestBody:
required: false required: false
content: content:
@@ -626,6 +626,21 @@ paths:
type: string type: string
translated_text: translated_text:
type: string type: string
glossary_override:
type: object
required: [reviewer, reason, provenance, confirmed_unix_seconds]
additionalProperties: false
properties:
reviewer:
type: string
reason:
type: string
provenance:
type: string
confirmed_unix_seconds:
type: integer
format: int64
minimum: 1
fixture_path: fixture_path:
type: string type: string
concurrency: concurrency:
+4
View File
@@ -102,6 +102,7 @@ type GlossaryBackend interface {
GlossaryUpdate(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error) GlossaryUpdate(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error)
GlossaryApprove(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) GlossaryApprove(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error)
GlossaryDeprecate(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) GlossaryDeprecate(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error)
GlossaryDelete(ctx context.Context, params backendrpc.GlossaryDeleteParams) (*backendrpc.GlossaryMutationReport, error)
} }
// LocalizedBackend exposes localized release status and the explicit // LocalizedBackend exposes localized release status and the explicit
@@ -236,6 +237,9 @@ func (r RPCClient) GlossaryApprove(ctx context.Context, params backendrpc.Glossa
func (r RPCClient) GlossaryDeprecate(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) { func (r RPCClient) GlossaryDeprecate(ctx context.Context, params backendrpc.GlossaryReviewParams) (*backendrpc.GlossaryMutationReport, error) {
return r.Client.GlossaryDeprecate(ctx, params) return r.Client.GlossaryDeprecate(ctx, params)
} }
func (r RPCClient) GlossaryDelete(ctx context.Context, params backendrpc.GlossaryDeleteParams) (*backendrpc.GlossaryMutationReport, error) {
return r.Client.GlossaryDelete(ctx, params)
}
func (r RPCClient) LocalizedStatus(ctx context.Context) (json.RawMessage, error) { func (r RPCClient) LocalizedStatus(ctx context.Context) (json.RawMessage, error) {
return r.Client.LocalizedStatus(ctx) return r.Client.LocalizedStatus(ctx)
+14
View File
@@ -595,6 +595,13 @@ type GlossaryReviewParams struct {
Reason string `json:"reason,omitempty"` Reason string `json:"reason,omitempty"`
} }
type GlossaryDeleteParams struct {
GlossaryPath string `json:"glossary_path,omitempty"`
TermID string `json:"term_id"`
Reviewer string `json:"reviewer"`
Reason string `json:"reason"`
}
type GlossarySummaryReport struct { type GlossarySummaryReport struct {
Available bool `json:"available"` Available bool `json:"available"`
Path string `json:"path"` Path string `json:"path"`
@@ -615,6 +622,7 @@ type GlossaryMutationReport struct {
Available bool `json:"available"` Available bool `json:"available"`
Path string `json:"path"` Path string `json:"path"`
SchemaVersion *uint64 `json:"schema_version,omitempty"` SchemaVersion *uint64 `json:"schema_version,omitempty"`
Deleted bool `json:"deleted,omitempty"`
Term GlossaryTerm `json:"term"` Term GlossaryTerm `json:"term"`
} }
@@ -944,6 +952,12 @@ func (c *Client) GlossaryDeprecate(ctx context.Context, params GlossaryReviewPar
return &out, err return &out, err
} }
func (c *Client) GlossaryDelete(ctx context.Context, params GlossaryDeleteParams) (*GlossaryMutationReport, error) {
var out GlossaryMutationReport
_, err := c.Call(ctx, "translation.glossary.delete", params, &out)
return &out, err
}
func (c *Client) LocalizedPublish(ctx context.Context, params LocalizedPublishParams) (json.RawMessage, error) { func (c *Client) LocalizedPublish(ctx context.Context, params LocalizedPublishParams) (json.RawMessage, error) {
return c.rawData(ctx, "localized.publish", params) return c.rawData(ctx, "localized.publish", params)
} }
+97
View File
@@ -463,6 +463,12 @@ func TestTranslationTaskUpdateSendsWorkerParams(t *testing.T) {
UnitID: "direct:a#unit:0", UnitID: "direct:a#unit:0",
SourceText: "source", SourceText: "source",
TranslatedText: "译文", TranslatedText: "译文",
GlossaryOverride: &GlossaryOverride{
Reviewer: "reviewer",
Reason: "approved deviation",
Provenance: "manual-review",
ConfirmedUnixSeconds: 100,
},
}}, }},
}) })
if err != nil { if err != nil {
@@ -928,6 +934,31 @@ func TestGlossaryTypedContract(t *testing.T) {
}, },
}, },
}} }}
case "translation.glossary.update":
var params GlossaryTermMutationParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode update params: %v", err)
}
if params.TermID != "term-sensei" || params.Reviewer != "reviewer" || params.ReviewStatus != "draft" {
t.Fatalf("update params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-update",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"term": map[string]any{
"term_id": "term-sensei",
"source_term": "Sensei",
"recommended_translation": "老师",
"review_status": "draft",
"source": map[string]any{
"source_kind": "manual",
"observed_unix_seconds": 101,
},
},
},
}}
case "translation.glossary.approve": case "translation.glossary.approve":
var params GlossaryReviewParams var params GlossaryReviewParams
if err := json.Unmarshal(req.Params, &params); err != nil { if err := json.Unmarshal(req.Params, &params); err != nil {
@@ -944,6 +975,39 @@ func TestGlossaryTypedContract(t *testing.T) {
"term": map[string]any{"term_id": "term-sensei", "review_status": "approved"}, "term": map[string]any{"term_id": "term-sensei", "review_status": "approved"},
}, },
}} }}
case "translation.glossary.deprecate":
var params GlossaryReviewParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode deprecate params: %v", err)
}
if params.TermID != "term-sensei" || params.Reviewer != "reviewer" || params.Reason != "retired" {
t.Fatalf("deprecate params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-deprecate",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"term": map[string]any{"term_id": "term-sensei", "review_status": "deprecated"},
},
}}
case "translation.glossary.delete":
var params GlossaryDeleteParams
if err := json.Unmarshal(req.Params, &params); err != nil {
t.Fatalf("decode delete params: %v", err)
}
if params.TermID != "term-sensei" || params.Reviewer != "reviewer" || params.Reason != "duplicate" {
t.Fatalf("delete params=%#v", params)
}
return testResponse{Result: testEnvelope{
OK: true, Status: "ok", RequestID: "req-glossary-delete",
Data: map[string]any{
"available": true,
"path": "/var/lib/bat/glossary.sqlite",
"deleted": true,
"term": map[string]any{"term_id": "term-sensei"},
},
}}
default: default:
t.Fatalf("unexpected method %q", req.Method) t.Fatalf("unexpected method %q", req.Method)
return testResponse{} return testResponse{}
@@ -986,6 +1050,21 @@ func TestGlossaryTypedContract(t *testing.T) {
if err != nil || add.Term.TermID != "term-sensei" { if err != nil || add.Term.TermID != "term-sensei" {
t.Fatalf("add=%#v err=%v", add, err) t.Fatalf("add=%#v err=%v", add, err)
} }
updated, err := client.GlossaryUpdate(context.Background(), GlossaryTermMutationParams{
GlossaryPath: "/var/lib/bat/glossary.sqlite",
TermID: "term-sensei",
SourceTerm: "Sensei",
RecommendedTranslation: "老师",
ReviewStatus: "draft",
Reviewer: "reviewer",
Source: GlossarySourceRecord{
SourceKind: "manual",
ObservedUnixSeconds: 101,
},
})
if err != nil || updated.Term.ReviewStatus != GlossaryStatusDraft {
t.Fatalf("update=%#v err=%v", updated, err)
}
approved, err := client.GlossaryApprove(context.Background(), GlossaryReviewParams{ approved, err := client.GlossaryApprove(context.Background(), GlossaryReviewParams{
GlossaryPath: "/var/lib/bat/glossary.sqlite", GlossaryPath: "/var/lib/bat/glossary.sqlite",
TermID: "term-sensei", TermID: "term-sensei",
@@ -994,6 +1073,24 @@ func TestGlossaryTypedContract(t *testing.T) {
if err != nil || approved.Term.ReviewStatus != GlossaryStatusApproved { if err != nil || approved.Term.ReviewStatus != GlossaryStatusApproved {
t.Fatalf("approve=%#v err=%v", approved, err) t.Fatalf("approve=%#v err=%v", approved, err)
} }
deprecated, err := client.GlossaryDeprecate(context.Background(), GlossaryReviewParams{
GlossaryPath: "/var/lib/bat/glossary.sqlite",
TermID: "term-sensei",
Reviewer: "reviewer",
Reason: "retired",
})
if err != nil || deprecated.Term.ReviewStatus != GlossaryStatusDeprecated {
t.Fatalf("deprecate=%#v err=%v", deprecated, err)
}
deleted, err := client.GlossaryDelete(context.Background(), GlossaryDeleteParams{
GlossaryPath: "/var/lib/bat/glossary.sqlite",
TermID: "term-sensei",
Reviewer: "reviewer",
Reason: "duplicate",
})
if err != nil || !deleted.Available || !deleted.Deleted {
t.Fatalf("delete=%#v err=%v", deleted, err)
}
} }
func TestApplicationErrorReturnsAPIError(t *testing.T) { func TestApplicationErrorReturnsAPIError(t *testing.T) {