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 当前工作区状态
- **更新时间**2026-09-06
- **更新时间**2026-09-08
- **状态来源**:本地工作区盘点、代码验证和最新提交
- **状态分支**`experiment`
- **最新已推送功能提交**:以当前 `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-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。
+16 -1
View File
@@ -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:
+39
View File
@@ -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());
}
}
+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.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/reasonRust 另保留删除审计快照,返回删除前快照。 |
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 查询、确定性诊断和审核/删除 mutationGo 只转发,不持有 Glossary 状态 |
| `LocalizedBackend` | `localized.status``localized.publish``localized.rollback` | 鉴权后的汉化 release 状态、发布与显式回滚 |
`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 已实现,扩展能力仍缺失
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 已实现,协作视图仍缺失
+1 -1
View File
@@ -95,7 +95,7 @@
| 组件 | 路径 | 状态 | 说明 |
|---|---|---|---|
| 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` |
| 试验 CLI | `cmd/bat` | **试验** | doctor 固定 okmanifest/sync 走 FFI |
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
+28 -3
View File
@@ -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<i32> {
| 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!(
+87
View File
@@ -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();
@@ -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<Option<serde_json
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!(),
}
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(
request_id: String,
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))
}
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(
state_dir: &Path,
output_root: &Path,
@@ -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 <NAME> / --provider <NAME> Provider for i18n worker run (mock/crowdin)
--translation-fixture <PATH> Mock/provider fixture for i18n worker run
--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-recommended-translation <TEXT> Recommended target translation
--glossary-source-text <TEXT> Source TextUnit text for Glossary query/diagnose
--glossary-context-json <JSON> TextUnit context for Glossary diagnose
--glossary-reviewer <ID> Reviewer for Glossary updates/reviews
--glossary-reason <TEXT> Reason for Glossary review or override
--glossary-reviewer <ID> Reviewer for Glossary updates/reviews/delete
--glossary-reason <TEXT> Reason for Glossary review/delete or override
--glossary-provenance <TEXT> Provenance for an explicit Glossary override
--worker-concurrency <N> Translation worker concurrency (default: 8, range 1..=256)
--worker-max-attempts <N> Maximum claims per translation task
+139 -14
View File
@@ -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<GlossaryReviewStatus>,
) -> Result<Vec<GlossaryTerm>> {
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<GlossaryTerm> {
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<String>,
) -> Result<GlossaryTerm> {
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<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.
pub async fn review(
&self,
@@ -416,7 +524,7 @@ impl SqliteGlossaryRepository {
context: &TranslationMemoryContext,
) -> Result<GlossaryEvaluation> {
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);
}
}
+10 -1
View File
@@ -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 {
+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-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, &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:
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 ""
}
+17 -4
View File
@@ -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":""}`))
+16 -1
View File
@@ -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:
+4
View File
@@ -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)
+14
View File
@@ -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)
}
+97
View File
@@ -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, &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":
var params GlossaryReviewParams
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"},
},
}}
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:
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) {