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