diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index 3e38fb3..ab3e54a 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -1,6 +1,6 @@ # BlueArchiveToolkit 当前工作区状态 -- **更新时间**:2026-09-06 +- **更新时间**:2026-09-08 - **状态来源**:本地工作区盘点、代码验证和最新提交 - **状态分支**:`experiment` - **最新已推送功能提交**:以当前 `git log --oneline -1` 为准 diff --git a/USERGUIDE.md b/USERGUIDE.md index 0eeacb4..f734776 100644 --- a/USERGUIDE.md +++ b/USERGUIDE.md @@ -194,6 +194,7 @@ launcher 兼容端点只服务启动前资源发现。它们复用 Rust `bat` sn | `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-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-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 '术语审校通过' bat i18n glossary diagnose --glossary-source-text 'Sensei' \ --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。 diff --git a/api/openapi/bat-api.yaml b/api/openapi/bat-api.yaml index c1c3b7f..496eeda 100644 --- a/api/openapi/bat-api.yaml +++ b/api/openapi/bat-api.yaml @@ -553,7 +553,7 @@ paths: required: true schema: 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: required: false content: @@ -619,6 +619,21 @@ paths: type: string translated_text: 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: type: string concurrency: diff --git a/core/src/domain/glossary.rs b/core/src/domain/glossary.rs index 7ccf097..3552aca 100644 --- a/core/src/domain/glossary.rs +++ b/core/src/domain/glossary.rs @@ -551,6 +551,16 @@ pub fn validate_glossary_draft(draft: &GlossaryTermDraft) -> crate::Result<()> { "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 .definition .allowed_translations @@ -687,4 +697,33 @@ mod tests { .iter() .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()); + } } diff --git a/docs/reference/rpc-backend-api.md b/docs/reference/rpc-backend-api.md index e8227af..a954559 100644 --- a/docs/reference/rpc-backend-api.md +++ b/docs/reference/rpc-backend-api.md @@ -230,6 +230,7 @@ SQLite `ResourceRepository`,索引不存在时返回 `ok=true` 且 | `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.deprecate` | 已实现 | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | 保留历史但停止自动应用。 | +| `translation.glossary.delete` | 已实现 | `{ "term_id": "...", "reviewer": "...", "reason": "..." }` | 显式删除当前 term;需要 reviewer/reason,Rust 另保留删除审计快照,返回删除前快照。 | TM 的自动复用规则是 raw source 完全相同、完整 context 完全相同且状态为 `trusted`; context 缺失/不一致、normalized source 仅辅助查询、candidate 或 provider 成功都不会 @@ -285,7 +286,9 @@ offset 和 error。TypeTree-covered managed reference 字段会进入结构化 记录完成时间,`failed` 可写入 `failure_reason`。人工校对流程可以在 `status=completed` 时额外提交 `provider`、`provider_run_id` 和 `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 或人工校对流程 消费 handoff 后,bat-api 可通过 `translation.tasks` 查询单项任务,也可通过 `translation.handoff` 获取完整 job/unit/provider run 状态。`translation.handoff` @@ -472,6 +475,7 @@ CLI 对应关系: | `bat i18n glossary diagnose` | `translation.glossary.diagnose` | | `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 delete` | `translation.glossary.delete` | | `bat localized-status` | `localized.status` | | `bat resource-index` | `resource.index` | @@ -507,6 +511,7 @@ CLI 对应关系: `translation.memory.query`、`translation.memory.confirm`、`translation.glossary.summary`、 `translation.glossary.query`、`translation.glossary.diagnose`、`translation.glossary.add`、 `translation.glossary.update`、`translation.glossary.approve`、`translation.glossary.deprecate`、 + `translation.glossary.delete`、 `task.*` 和三个 `unityfs.patch_*` 方法。 - `resource.index` 和 `patch.apply` 当前没有专用 typed helper;需要直接使用 `Call`,并仍须遵守 本契约的参数和响应定义。 @@ -523,7 +528,7 @@ CLI 对应关系: | `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 触发与人工校对标记 | | `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 查询、确定性诊断和审核/删除 mutation;Go 只转发,不持有 Glossary 状态 | | `LocalizedBackend` | `localized.status`、`localized.publish`、`localized.rollback` | 鉴权后的汉化 release 状态、发布与显式回滚 | `daemon.stop`、`daemon.clean-stable` 和任意通用 RPC 不属于 bat-api 管理控制面。 diff --git a/docs/reports/CURRENT_GAPS.md b/docs/reports/CURRENT_GAPS.md index 079a372..9530572 100644 --- a/docs/reports/CURRENT_GAPS.md +++ b/docs/reports/CURRENT_GAPS.md @@ -113,7 +113,7 @@ rollback,`localized.status` 能校验当前官方 release 与 patch manifest ### G-012:Translation 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-013:Glossary V1 已实现,协作视图仍缺失 diff --git a/docs/reports/GO_STATUS.md b/docs/reports/GO_STATUS.md index b126327..8af22a0 100644 --- a/docs/reports/GO_STATUS.md +++ b/docs/reports/GO_STATUS.md @@ -95,7 +95,7 @@ | 组件 | 路径 | 状态 | 说明 | |---|---|---|---| | 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/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` 走同一 contract;fake transport 单测和 `internal/api/testdata/contract/` mirror test 固化 Rust 输出字段 | +| 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`、`translation.memory.summary/query/confirm`、`translation.glossary.summary/query/diagnose/add/update/approve/deprecate/delete` 和文件级 UnityFS patch 调用;`resource.index`、`patch.apply` 仍通过通用 `Call` 走同一 contract;fake 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` | | 试验 CLI | `cmd/bat` | **试验** | doctor 固定 ok;manifest/sync 走 FFI | | FFI | `internal/ffi` | **可选** | 需 `build-ffi` | diff --git a/infrastructure/src/bin/bat/app.rs b/infrastructure/src/bin/bat/app.rs index 4548536..32bffda 100644 --- a/infrastructure/src/bin/bat/app.rs +++ b/infrastructure/src/bin/bat/app.rs @@ -74,9 +74,9 @@ mod translation_query; #[path = "workflow_commands.rs"] mod workflow_commands; use glossary_query::{ - glossary_diagnose_rpc_report, glossary_mutation_rpc_report, glossary_query_rpc_report, - glossary_review_rpc_report, glossary_rpc_envelope, glossary_summary_rpc_report, - run_glossary_command, + glossary_delete_rpc_report, glossary_diagnose_rpc_report, glossary_mutation_rpc_report, + glossary_query_rpc_report, glossary_review_rpc_report, glossary_rpc_envelope, + glossary_summary_rpc_report, run_glossary_command, }; use patch_commands::{ is_write_patch_command, run_write_patch_command, validate_write_patch_options, @@ -243,6 +243,7 @@ fn run() -> anyhow::Result { | CliCommand::GlossaryUpdate | CliCommand::GlossaryApprove | CliCommand::GlossaryDeprecate + | CliCommand::GlossaryDelete | CliCommand::GlossaryDiagnose => { run_glossary_command(&options)?; Ok(0) @@ -658,6 +659,7 @@ enum CliCommand { GlossaryUpdate, GlossaryApprove, GlossaryDeprecate, + GlossaryDelete, GlossaryDiagnose, Repack, 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_APPROVE: &str = "translation.glossary.approve"; 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_LOCALIZED_STATUS: &str = "localized.status"; 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 => { let config = match rpc_translation_worker_config_with_defaults( request.params.as_ref(), @@ -7474,6 +7486,7 @@ fn parse_args_with_env( | CliCommand::GlossaryUpdate | CliCommand::GlossaryApprove | CliCommand::GlossaryDeprecate + | CliCommand::GlossaryDelete | CliCommand::GlossaryDiagnose | CliCommand::TranslationSet | CliCommand::TranslationValidate @@ -7629,6 +7642,7 @@ fn parse_args_with_env( | CliCommand::GlossaryUpdate | CliCommand::GlossaryApprove | CliCommand::GlossaryDeprecate + | CliCommand::GlossaryDelete | CliCommand::GlossaryDiagnose => { if options.watch || 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!(), } options.progress = false; @@ -8293,6 +8317,7 @@ fn parse_translation_glossary_command( "update" | "edit" => CliCommand::GlossaryUpdate, "approve" | "trust" => CliCommand::GlossaryApprove, "deprecate" | "retire" => CliCommand::GlossaryDeprecate, + "delete" | "remove" => CliCommand::GlossaryDelete, "diagnose" | "check" => CliCommand::GlossaryDiagnose, other => { return Err(anyhow::anyhow!( diff --git a/infrastructure/src/bin/bat/app_tests.rs b/infrastructure/src/bin/bat/app_tests.rs index 3db07b9..111bfb9 100644 --- a/infrastructure/src/bin/bat/app_tests.rs +++ b/infrastructure/src/bin/bat/app_tests.rs @@ -425,6 +425,20 @@ fn glossary_commands_parse_and_validate() { ]) .unwrap(); 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", @@ -2784,6 +2798,79 @@ fn dispatch_glossary_summary_reports_missing_database_without_creating_it() { 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] fn dispatch_translation_memory_rejects_invalid_params_with_stable_error_code() { let temp = tempfile::TempDir::new().unwrap(); diff --git a/infrastructure/src/bin/bat/glossary_query.rs b/infrastructure/src/bin/bat/glossary_query.rs index b69d6f6..30fb382 100644 --- a/infrastructure/src/bin/bat/glossary_query.rs +++ b/infrastructure/src/bin/bat/glossary_query.rs @@ -65,6 +65,19 @@ pub(super) fn run_glossary_command(options: &CliOptions) -> anyhow::Result<()> { 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!(), }; 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::GlossaryApprove => RPC_METHOD_GLOSSARY_APPROVE, CliCommand::GlossaryDeprecate => RPC_METHOD_GLOSSARY_DEPRECATE, + CliCommand::GlossaryDelete => RPC_METHOD_GLOSSARY_DELETE, CliCommand::GlossaryDiagnose => RPC_METHOD_GLOSSARY_DIAGNOSE, _ => return Err(anyhow::anyhow!("不是 Glossary 命令")), }) @@ -159,6 +173,20 @@ fn glossary_cli_params(options: &CliOptions) -> anyhow::Result { + 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!(), } 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 { + 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( request_id: String, result: Result, @@ -546,6 +601,30 @@ pub(super) fn glossary_review_rpc_report( .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 { + let params = glossary_params(params, RPC_METHOD_GLOSSARY_DELETE)?; + let term_id = glossary_string(¶ms, "term_id", RPC_METHOD_GLOSSARY_DELETE)? + .ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DELETE, "缺少 term_id"))?; + let reviewer = glossary_string(¶ms, "reviewer", RPC_METHOD_GLOSSARY_DELETE)? + .ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DELETE, "缺少 reviewer"))?; + let reason = glossary_string(¶ms, "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( state_dir: &Path, output_root: &Path, diff --git a/infrastructure/src/bin/bat/terminal_output.rs b/infrastructure/src/bin/bat/terminal_output.rs index 0bd447c..b94d97e 100644 --- a/infrastructure/src/bin/bat/terminal_output.rs +++ b/infrastructure/src/bin/bat/terminal_output.rs @@ -348,6 +348,7 @@ Commands: i18n glossary summary/query Show project Glossary terms and review counts i18n glossary add/update Add or replace one Glossary term definition 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 publish Publish a localized release from a workbench or worker results i18n rollback Roll back the current localized release @@ -439,13 +440,13 @@ Sync: --translation-provider / --provider Provider for i18n worker run (mock/crowdin) --translation-fixture Mock/provider fixture for i18n worker run --glossary-path Project Glossary SQLite path - --glossary-term-id Glossary term ID for add/update/review + --glossary-term-id Glossary term ID for add/update/review/delete --glossary-source-term Source spelling for a Glossary term --glossary-recommended-translation Recommended target translation --glossary-source-text Source TextUnit text for Glossary query/diagnose --glossary-context-json TextUnit context for Glossary diagnose - --glossary-reviewer Reviewer for Glossary updates/reviews - --glossary-reason Reason for Glossary review or override + --glossary-reviewer Reviewer for Glossary updates/reviews/delete + --glossary-reason Reason for Glossary review/delete or override --glossary-provenance Provenance for an explicit Glossary override --worker-concurrency Translation worker concurrency (default: 8, range 1..=256) --worker-max-attempts Maximum claims per translation task diff --git a/infrastructure/src/glossary.rs b/infrastructure/src/glossary.rs index 7e6dffd..978dc50 100644 --- a/infrastructure/src/glossary.rs +++ b/infrastructure/src/glossary.rs @@ -160,6 +160,21 @@ impl SqliteGlossaryRepository { .execute(&self.pool) .await .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( "CREATE INDEX IF NOT EXISTS idx_glossary_status ON glossary_terms(review_status, priority DESC, term_id)", @@ -244,6 +259,31 @@ impl SqliteGlossaryRepository { "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, + ) -> Result> { let rows = sqlx::query( "SELECT term_id FROM glossary_terms WHERE (?1 IS NULL OR category = ?1) @@ -258,19 +298,7 @@ impl SqliteGlossaryRepository { let mut terms = Vec::new(); for row in rows { let term_id: String = row.try_get("term_id").map_err(db_error)?; - let term = 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; - } - } + terms.push(self.find(&term_id).await?); } Ok(terms) } @@ -303,6 +331,11 @@ impl SqliteGlossaryRepository { /// Adds a term and records its source snapshot. pub async fn add(&self, draft: GlossaryTermDraft) -> Result { 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 term = term_from_draft(&draft, now, now); let mut transaction = self.pool.begin().await.map_err(db_error)?; @@ -332,6 +365,11 @@ impl SqliteGlossaryRepository { reason: Option, ) -> Result { 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() { return Err(Error::InvalidArgument( "Glossary update reviewer 不能为空".to_string(), @@ -355,6 +393,76 @@ impl SqliteGlossaryRepository { 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 { + 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. pub async fn review( &self, @@ -416,7 +524,7 @@ impl SqliteGlossaryRepository { context: &TranslationMemoryContext, ) -> Result { let terms = self - .query(None, None, Some(GlossaryReviewStatus::Approved), 1000) + .load_terms(None, Some(GlossaryReviewStatus::Approved)) .await?; Ok(evaluate_glossary(&terms, source_text, context)) } @@ -723,6 +831,10 @@ mod tests { let repository = SqliteGlossaryRepository::new(temp.path().join("glossary.sqlite")) .await .unwrap(); + assert!(repository + .add(draft(GlossaryReviewStatus::Approved)) + .await + .is_err()); repository .add(draft(GlossaryReviewStatus::Draft)) .await @@ -753,5 +865,18 @@ mod tests { assert_eq!(term.history[1].action, "approved"); let summary = repository.summary().await.unwrap(); 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); } } diff --git a/infrastructure/src/translation_worker.rs b/infrastructure/src/translation_worker.rs index cb72f76..17f37b4 100644 --- a/infrastructure/src/translation_worker.rs +++ b/infrastructure/src/translation_worker.rs @@ -1660,7 +1660,7 @@ mod tests { priority: 10, scope: BTreeMap::new(), }, - review_status: bat_core::domain::GlossaryReviewStatus::Approved, + review_status: bat_core::domain::GlossaryReviewStatus::Draft, source: bat_core::domain::GlossarySourceRecord { source_kind: bat_core::domain::GlossarySourceKind::Manual, source_ref: Some("worker-test".to_string()), @@ -1671,6 +1671,15 @@ mod tests { }) .await .unwrap(); + glossary + .review( + "term-source-0", + bat_core::domain::GlossaryReviewStatus::Approved, + "reviewer", + Some("test approval".to_string()), + ) + .await + .unwrap(); #[derive(Debug)] struct GlossaryProvider { diff --git a/internal/api/admin.go b/internal/api/admin.go index c0ed38f..30ddbd5 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -82,6 +82,7 @@ func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { "/admin/control/translation-glossary-update", "/admin/control/translation-glossary-approve", "/admin/control/translation-glossary-deprecate", + "/admin/control/translation-glossary-delete", "/admin/control/localized-publish", "/admin/control/localized-rollback", }, @@ -495,6 +496,29 @@ func (s *Server) handleAdminGlossaryControl(w http.ResponseWriter, r *http.Reque Status: "accepted", Result: result, }) + case "translation-glossary-delete": + var params backendrpc.GlossaryDeleteParams + if !decodeAdminTranslationJSON(w, r, ¶ms) { + 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: writeErrorJSON(w, http.StatusNotFound, "control_not_found", "unknown glossary control action") } @@ -510,6 +534,8 @@ func glossaryControlRPCMethod(action string) string { return "translation.glossary.approve" case "translation-glossary-deprecate": return "translation.glossary.deprecate" + case "translation-glossary-delete": + return "translation.glossary.delete" default: return "" } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 47f38ae..052c99f 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -642,6 +642,7 @@ type controlBackend struct { glossaryDiagnoseParams []backendrpc.GlossaryDiagnoseParams glossaryMutationParams []backendrpc.GlossaryTermMutationParams glossaryReviewParams []backendrpc.GlossaryReviewParams + glossaryDeleteParams []backendrpc.GlossaryDeleteParams localizedPublishParams []backendrpc.LocalizedPublishParams 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 } +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) { b.calls = append(b.calls, "localized.status") 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/parse/text-units") || !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(), "/admin/translation/memory/query") || !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: "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: "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 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"}, @@ -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 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 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 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 || backend.translationTaskUpdates[0].Provider != "manual" || 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) } if len(backend.translationMemoryConfirmParams) != 1 || @@ -1984,8 +1995,10 @@ func TestAdminControlForwardsAllowlistedActions(t *testing.T) { backend.glossaryMutationParams[0].TermID != "term-sensei" || backend.glossaryMutationParams[1].Reviewer != "reviewer" || len(backend.glossaryReviewParams) != 2 || - backend.glossaryReviewParams[0].TermID != "term-sensei" { - t.Fatalf("Glossary params mutation=%#v review=%#v", backend.glossaryMutationParams, backend.glossaryReviewParams) + backend.glossaryReviewParams[0].TermID != "term-sensei" || + 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":""}`)) diff --git a/internal/api/openapi.go b/internal/api/openapi.go index 846722c..bb1c4ab 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -560,7 +560,7 @@ paths: required: true schema: 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: required: false content: @@ -626,6 +626,21 @@ paths: type: string translated_text: 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: type: string concurrency: diff --git a/internal/api/rpc_release.go b/internal/api/rpc_release.go index 05b1e2c..9215d0b 100644 --- a/internal/api/rpc_release.go +++ b/internal/api/rpc_release.go @@ -102,6 +102,7 @@ type GlossaryBackend interface { GlossaryUpdate(ctx context.Context, params backendrpc.GlossaryTermMutationParams) (*backendrpc.GlossaryMutationReport, error) GlossaryApprove(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 @@ -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) { 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) { return r.Client.LocalizedStatus(ctx) diff --git a/internal/backendrpc/client.go b/internal/backendrpc/client.go index 1d79195..19a9693 100644 --- a/internal/backendrpc/client.go +++ b/internal/backendrpc/client.go @@ -595,6 +595,13 @@ type GlossaryReviewParams struct { 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 { Available bool `json:"available"` Path string `json:"path"` @@ -615,6 +622,7 @@ type GlossaryMutationReport struct { Available bool `json:"available"` Path string `json:"path"` SchemaVersion *uint64 `json:"schema_version,omitempty"` + Deleted bool `json:"deleted,omitempty"` Term GlossaryTerm `json:"term"` } @@ -944,6 +952,12 @@ func (c *Client) GlossaryDeprecate(ctx context.Context, params GlossaryReviewPar 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) { return c.rawData(ctx, "localized.publish", params) } diff --git a/internal/backendrpc/client_test.go b/internal/backendrpc/client_test.go index 4ca8f15..df41f68 100644 --- a/internal/backendrpc/client_test.go +++ b/internal/backendrpc/client_test.go @@ -463,6 +463,12 @@ func TestTranslationTaskUpdateSendsWorkerParams(t *testing.T) { UnitID: "direct:a#unit:0", SourceText: "source", TranslatedText: "译文", + GlossaryOverride: &GlossaryOverride{ + Reviewer: "reviewer", + Reason: "approved deviation", + Provenance: "manual-review", + ConfirmedUnixSeconds: 100, + }, }}, }) 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, ¶ms); 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": var params GlossaryReviewParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { @@ -944,6 +975,39 @@ func TestGlossaryTypedContract(t *testing.T) { "term": map[string]any{"term_id": "term-sensei", "review_status": "approved"}, }, }} + case "translation.glossary.deprecate": + var params GlossaryReviewParams + if err := json.Unmarshal(req.Params, ¶ms); 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, ¶ms); 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: t.Fatalf("unexpected method %q", req.Method) return testResponse{} @@ -986,6 +1050,21 @@ func TestGlossaryTypedContract(t *testing.T) { if err != nil || add.Term.TermID != "term-sensei" { 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{ GlossaryPath: "/var/lib/bat/glossary.sqlite", TermID: "term-sensei", @@ -994,6 +1073,24 @@ func TestGlossaryTypedContract(t *testing.T) { if err != nil || approved.Term.ReviewStatus != GlossaryStatusApproved { 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) {