feat(glossary): 实现 Rust Glossary V1

This commit is contained in:
2026-09-07 22:38:53 +08:00
parent 8fc93b8f39
commit 94483ff14d
42 changed files with 4543 additions and 92 deletions
+353 -3
View File
@@ -13,8 +13,9 @@ use bat_infrastructure::{
localized_patch_operations, open_append_file, read_download_manifest_at, read_file_no_symlink,
read_localized_patch_manifest_at, read_localized_version_state, read_parse_cache_at,
read_snapshot, read_textunit_index_at, read_translation_workbench, read_version_state,
redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation, unset_translation,
validate_output_root, validate_runtime_state_dir, validate_translation_workbench,
redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation,
set_translation_checked_with_glossary_path, unset_translation, validate_output_root,
validate_runtime_state_dir, validate_translation_workbench_with_glossary_path,
write_file_atomic, write_official_textunit_queues, CurlProxyConfig, CurlProxyMode,
LocalizedPatchConfig, LocalizedPatchReport, LocalizedPatchService, LocalizedRollbackReport,
OfficialEndpointMarkerRole, OfficialFailedVersionRecord, OfficialParseCacheService,
@@ -54,6 +55,8 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
#[path = "config_file.rs"]
mod config_file;
#[path = "glossary_query.rs"]
mod glossary_query;
#[path = "patch_commands.rs"]
mod patch_commands;
#[path = "readonly_query.rs"]
@@ -70,6 +73,11 @@ mod terminal_output;
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,
};
use patch_commands::{
is_write_patch_command, run_write_patch_command, validate_write_patch_options,
};
@@ -229,6 +237,16 @@ fn run() -> anyhow::Result<i32> {
run_translation_memory_command(&options)?;
Ok(0)
}
CliCommand::GlossarySummary
| CliCommand::GlossaryQuery
| CliCommand::GlossaryAdd
| CliCommand::GlossaryUpdate
| CliCommand::GlossaryApprove
| CliCommand::GlossaryDeprecate
| CliCommand::GlossaryDiagnose => {
run_glossary_command(&options)?;
Ok(0)
}
CliCommand::TranslationWorker => {
run_repeated_workflow(&options, "translation-worker", run_translation_worker)?;
Ok(0)
@@ -385,6 +403,8 @@ struct CliOptions {
translation_provider: Option<String>,
translation_fixture: Option<PathBuf>,
translation_memory_path: Option<PathBuf>,
glossary_path: Option<PathBuf>,
glossary_path_option_explicit: bool,
translation_memory_option_explicit: bool,
translation_memory_command_option_explicit: bool,
translation_memory_source_text: Option<String>,
@@ -392,6 +412,26 @@ struct CliOptions {
translation_memory_record_id: Option<String>,
translation_memory_reviewer: Option<String>,
translation_memory_reason: Option<String>,
glossary_term_id: Option<String>,
glossary_source_term: Option<String>,
glossary_aliases_json: Option<String>,
glossary_recommended_translation: Option<String>,
glossary_allowed_translations_json: Option<String>,
glossary_source_language: Option<String>,
glossary_target_language: Option<String>,
glossary_category: Option<String>,
glossary_priority: i32,
glossary_scope_json: Option<String>,
glossary_source_kind: Option<String>,
glossary_source_ref: Option<String>,
glossary_source_author: Option<String>,
glossary_source_note: Option<String>,
glossary_reviewer: Option<String>,
glossary_reason: Option<String>,
glossary_override_provenance: Option<String>,
glossary_source_text: Option<String>,
glossary_context_json: Option<String>,
glossary_review_status: Option<String>,
worker_concurrency: usize,
worker_max_attempts: u32,
worker_lease_seconds: u64,
@@ -488,6 +528,8 @@ impl Default for CliOptions {
translation_provider: None,
translation_fixture: None,
translation_memory_path: None,
glossary_path: None,
glossary_path_option_explicit: false,
translation_memory_option_explicit: false,
translation_memory_command_option_explicit: false,
translation_memory_source_text: None,
@@ -495,6 +537,26 @@ impl Default for CliOptions {
translation_memory_record_id: None,
translation_memory_reviewer: None,
translation_memory_reason: None,
glossary_term_id: None,
glossary_source_term: None,
glossary_aliases_json: None,
glossary_recommended_translation: None,
glossary_allowed_translations_json: None,
glossary_source_language: None,
glossary_target_language: None,
glossary_category: None,
glossary_priority: 0,
glossary_scope_json: None,
glossary_source_kind: None,
glossary_source_ref: None,
glossary_source_author: None,
glossary_source_note: None,
glossary_reviewer: None,
glossary_reason: None,
glossary_override_provenance: None,
glossary_source_text: None,
glossary_context_json: None,
glossary_review_status: None,
worker_concurrency: DEFAULT_TRANSLATION_CONCURRENCY,
worker_max_attempts: DEFAULT_TRANSLATION_MAX_ATTEMPTS,
worker_lease_seconds: DEFAULT_TRANSLATION_LEASE_SECONDS,
@@ -590,6 +652,13 @@ enum CliCommand {
TranslationMemorySummary,
TranslationMemoryQuery,
TranslationMemoryConfirm,
GlossarySummary,
GlossaryQuery,
GlossaryAdd,
GlossaryUpdate,
GlossaryApprove,
GlossaryDeprecate,
GlossaryDiagnose,
Repack,
PublishLocalized,
LocalizedRollback,
@@ -1107,6 +1176,13 @@ const RPC_METHOD_TRANSLATION_WORKER_RUN: &str = "translation.worker.run";
const RPC_METHOD_TRANSLATION_MEMORY_SUMMARY: &str = "translation.memory.summary";
const RPC_METHOD_TRANSLATION_MEMORY_QUERY: &str = "translation.memory.query";
const RPC_METHOD_TRANSLATION_MEMORY_CONFIRM: &str = "translation.memory.confirm";
const RPC_METHOD_GLOSSARY_SUMMARY: &str = "translation.glossary.summary";
const RPC_METHOD_GLOSSARY_QUERY: &str = "translation.glossary.query";
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_DIAGNOSE: &str = "translation.glossary.diagnose";
const RPC_METHOD_LOCALIZED_STATUS: &str = "localized.status";
const RPC_METHOD_LOCALIZED_PUBLISH: &str = "localized.publish";
const RPC_METHOD_LOCALIZED_ROLLBACK: &str = "localized.rollback";
@@ -2105,7 +2181,11 @@ fn dispatch_rpc_method(
RPC_METHOD_TRANSLATION_TASK_UPDATE => rpc_envelope_from_result(
request_id,
"translation.task.update",
update_translation_task_status_report(state_dir, request.params.as_ref()),
update_translation_task_status_report(
state_dir,
request.params.as_ref(),
tasks.translation_worker_config.glossary_path.as_deref(),
),
),
RPC_METHOD_TRANSLATION_PROOFREAD => {
let _sync_guard = tasks
@@ -2155,6 +2235,67 @@ fn dispatch_rpc_method(
request.params.as_ref(),
),
),
RPC_METHOD_GLOSSARY_SUMMARY => glossary_rpc_envelope(
request_id,
glossary_summary_rpc_report(
state_dir,
&tasks.base_config.output_root,
tasks.translation_worker_config.glossary_path.as_deref(),
request.params.as_ref(),
),
),
RPC_METHOD_GLOSSARY_QUERY => glossary_rpc_envelope(
request_id,
glossary_query_rpc_report(
state_dir,
&tasks.base_config.output_root,
tasks.translation_worker_config.glossary_path.as_deref(),
request.params.as_ref(),
),
),
RPC_METHOD_GLOSSARY_DIAGNOSE => glossary_rpc_envelope(
request_id,
glossary_diagnose_rpc_report(
state_dir,
&tasks.base_config.output_root,
tasks.translation_worker_config.glossary_path.as_deref(),
request.params.as_ref(),
),
),
RPC_METHOD_GLOSSARY_ADD => glossary_rpc_envelope(
request_id,
glossary_mutation_rpc_report(
state_dir,
&tasks.base_config.output_root,
tasks.translation_worker_config.glossary_path.as_deref(),
request.params.as_ref(),
false,
),
),
RPC_METHOD_GLOSSARY_UPDATE => glossary_rpc_envelope(
request_id,
glossary_mutation_rpc_report(
state_dir,
&tasks.base_config.output_root,
tasks.translation_worker_config.glossary_path.as_deref(),
request.params.as_ref(),
true,
),
),
RPC_METHOD_GLOSSARY_APPROVE | RPC_METHOD_GLOSSARY_DEPRECATE => glossary_rpc_envelope(
request_id,
glossary_review_rpc_report(
state_dir,
&tasks.base_config.output_root,
tasks.translation_worker_config.glossary_path.as_deref(),
request.params.as_ref(),
if request.method == RPC_METHOD_GLOSSARY_APPROVE {
bat_core::domain::GlossaryReviewStatus::Approved
} else {
bat_core::domain::GlossaryReviewStatus::Deprecated
},
),
),
RPC_METHOD_TRANSLATION_WORKER_RUN => {
let config = match rpc_translation_worker_config_with_defaults(
request.params.as_ref(),
@@ -3504,6 +3645,13 @@ fn rpc_translation_worker_config_with_defaults(
)?
.map(PathBuf::from)
.or_else(|| defaults.translation_memory_path.clone()),
glossary_path: rpc_translation_worker_string_param(
params,
&["glossary_path", "translation_glossary_path"],
"glossary_path",
)?
.map(PathBuf::from)
.or_else(|| defaults.glossary_path.clone()),
};
config.validate().map_err(|error| {
ApiError::new(
@@ -6109,6 +6257,10 @@ fn daemon_child_args(options: &CliOptions) -> Vec<String> {
args.push("--translation-memory-path".to_string());
args.push(path.to_string_lossy().to_string());
}
if let Some(path) = options.glossary_path.as_ref() {
args.push("--glossary-path".to_string());
args.push(path.to_string_lossy().to_string());
}
args.push("--worker-concurrency".to_string());
args.push(options.worker_concurrency.to_string());
args.push("--worker-max-attempts".to_string());
@@ -6420,6 +6572,9 @@ fn apply_bat_env_overrides(
{
options.translation_memory_path = Some(PathBuf::from(v));
}
if let Some(v) = value("BAT_GLOSSARY_PATH") {
options.glossary_path = Some(PathBuf::from(v));
}
if let Some(v) = value("BAT_TRANSLATION_CONCURRENCY") {
options.worker_concurrency =
parse_translation_worker_concurrency(&v, "环境变量 BAT_TRANSLATION_CONCURRENCY")?;
@@ -6702,6 +6857,10 @@ fn parse_args_with_env(
options.translation_memory_option_explicit = true;
options.translation_worker_option_explicit = true;
}
"--glossary-path" | "--translation-glossary-path" => {
options.glossary_path = Some(PathBuf::from(next_option_value(&mut args, &flag)?));
options.glossary_path_option_explicit = true;
}
"--tm-source-text" => {
options.translation_memory_source_text = Some(next_option_value(&mut args, &flag)?);
options.translation_memory_command_option_explicit = true;
@@ -6723,6 +6882,70 @@ fn parse_args_with_env(
options.translation_memory_reason = Some(next_option_value(&mut args, &flag)?);
options.translation_memory_command_option_explicit = true;
}
"--glossary-term-id" => {
options.glossary_term_id = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-source-term" => {
options.glossary_source_term = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-aliases-json" => {
options.glossary_aliases_json = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-recommended-translation" => {
options.glossary_recommended_translation =
Some(next_option_value(&mut args, &flag)?);
}
"--glossary-allowed-translations-json" => {
options.glossary_allowed_translations_json =
Some(next_option_value(&mut args, &flag)?);
}
"--glossary-source-language" => {
options.glossary_source_language = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-target-language" => {
options.glossary_target_language = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-category" => {
options.glossary_category = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-priority" => {
options.glossary_priority = next_option_value(&mut args, &flag)?
.parse()
.map_err(|error| anyhow::anyhow!("--glossary-priority 无效:{error}"))?;
}
"--glossary-scope-json" => {
options.glossary_scope_json = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-source-kind" => {
options.glossary_source_kind = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-source-ref" => {
options.glossary_source_ref = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-source-author" => {
options.glossary_source_author = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-source-note" => {
options.glossary_source_note = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-reviewer" => {
options.glossary_reviewer = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-reason" => {
options.glossary_reason = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-provenance" | "--glossary-override-provenance" => {
options.glossary_override_provenance = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-source-text" => {
options.glossary_source_text = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-context-json" => {
options.glossary_context_json = Some(next_option_value(&mut args, &flag)?);
}
"--glossary-review-status" => {
options.glossary_review_status = Some(next_option_value(&mut args, &flag)?);
}
"--worker-concurrency" | "--translation-concurrency" => {
options.worker_concurrency = parse_translation_worker_concurrency(
&next_option_value(&mut args, &flag)?,
@@ -7240,6 +7463,30 @@ fn parse_args_with_env(
"翻译 worker 参数只适用于 i18n worker run 或 daemon restart/reload"
));
}
if options.glossary_path_option_explicit
&& !options.daemon_child
&& !matches!(
options.command,
CliCommand::TranslationWorker
| CliCommand::GlossarySummary
| CliCommand::GlossaryQuery
| CliCommand::GlossaryAdd
| CliCommand::GlossaryUpdate
| CliCommand::GlossaryApprove
| CliCommand::GlossaryDeprecate
| CliCommand::GlossaryDiagnose
| CliCommand::TranslationSet
| CliCommand::TranslationValidate
| CliCommand::TranslationTaskUpdate
| CliCommand::PublishLocalized
| CliCommand::Restart
| CliCommand::Reload
)
{
return Err(anyhow::anyhow!(
"Glossary 路径参数只适用于 Glossary、翻译 worker、工作台/任务发布或 daemon restart/reload"
));
}
if options.translation_memory_option_explicit
&& !matches!(
options.command,
@@ -7247,6 +7494,13 @@ fn parse_args_with_env(
| CliCommand::TranslationMemorySummary
| CliCommand::TranslationMemoryQuery
| CliCommand::TranslationMemoryConfirm
| CliCommand::GlossarySummary
| CliCommand::GlossaryQuery
| CliCommand::GlossaryAdd
| CliCommand::GlossaryUpdate
| CliCommand::GlossaryApprove
| CliCommand::GlossaryDeprecate
| CliCommand::GlossaryDiagnose
| CliCommand::Restart
| CliCommand::Reload
)
@@ -7369,6 +7623,74 @@ fn parse_args_with_env(
options.progress = false;
options.banner = false;
}
CliCommand::GlossarySummary
| CliCommand::GlossaryQuery
| CliCommand::GlossaryAdd
| CliCommand::GlossaryUpdate
| CliCommand::GlossaryApprove
| CliCommand::GlossaryDeprecate
| CliCommand::GlossaryDiagnose => {
if options.watch
|| options.daemon
|| options.daemon_child
|| options.config.force
|| options.config.dry_run
|| options.run_count.is_some()
|| options.sync_option_explicit
|| options.output_explicit
|| options.proxy_option_explicit
|| tools_are_non_default(&options.config, &options.env_baseline_config)
{
return Err(anyhow::anyhow!(
"i18n glossary 命令只接受 --state-dir、Glossary 参数和 --json/--human"
));
}
match options.command {
CliCommand::GlossarySummary => {}
CliCommand::GlossaryQuery => {
if options.glossary_term_id.is_some()
|| options.glossary_recommended_translation.is_some()
|| options.glossary_reviewer.is_some()
{
return Err(anyhow::anyhow!("Glossary query 不接受 mutation 参数"));
}
}
CliCommand::GlossaryDiagnose => {
if options.glossary_source_text.is_none() {
return Err(anyhow::anyhow!(
"Glossary diagnose 必须指定 --glossary-source-text"
));
}
}
CliCommand::GlossaryAdd | CliCommand::GlossaryUpdate => {
if options.glossary_term_id.is_none()
|| options.glossary_source_term.is_none()
|| options.glossary_recommended_translation.is_none()
{
return Err(anyhow::anyhow!(
"Glossary add/update 必须指定 term-id、source-term 和 recommended-translation"
));
}
if options.command == CliCommand::GlossaryUpdate
&& options.glossary_reviewer.is_none()
{
return Err(anyhow::anyhow!(
"Glossary update 必须指定 --glossary-reviewer"
));
}
}
CliCommand::GlossaryApprove | CliCommand::GlossaryDeprecate => {
if options.glossary_term_id.is_none() || options.glossary_reviewer.is_none() {
return Err(anyhow::anyhow!(
"Glossary review 必须指定 --glossary-term-id 和 --glossary-reviewer"
));
}
}
_ => unreachable!(),
}
options.progress = false;
options.banner = false;
}
CliCommand::PatchApply
| CliCommand::UnityFsPatchTextAsset
| CliCommand::UnityFsPatchStringField
@@ -7916,6 +8238,9 @@ fn parse_translation_command(
if action == "memory" || action == "tm" {
return parse_translation_memory_command(args, options);
}
if action == "glossary" || action == "terms" {
return parse_translation_glossary_command(args, options);
}
let command = match action.as_str() {
"run" => CliCommand::Translate,
"export" => CliCommand::Translate,
@@ -7956,6 +8281,30 @@ fn parse_translation_memory_command(
Ok(())
}
fn parse_translation_glossary_command(
args: &mut impl Iterator<Item = String>,
options: &mut CliOptions,
) -> anyhow::Result<()> {
let action = next_option_value(args, "translation glossary")?;
let command = match action.as_str() {
"summary" | "status" => CliCommand::GlossarySummary,
"query" | "find" => CliCommand::GlossaryQuery,
"add" | "create" => CliCommand::GlossaryAdd,
"update" | "edit" => CliCommand::GlossaryUpdate,
"approve" | "trust" => CliCommand::GlossaryApprove,
"deprecate" | "retire" => CliCommand::GlossaryDeprecate,
"diagnose" | "check" => CliCommand::GlossaryDiagnose,
other => {
return Err(anyhow::anyhow!(
"未知 translation glossary 二级命令:{other}"
))
}
};
ensure_command_not_set(options.command, &format!("translation glossary {action}"))?;
options.command = command;
Ok(())
}
fn parse_translation_worker_command(
args: &mut impl Iterator<Item = String>,
options: &mut CliOptions,
@@ -8086,6 +8435,7 @@ fn translation_worker_config_from_options(
.clone()
.unwrap_or_else(|| default_worker_id.to_string()),
translation_memory_path: options.translation_memory_path.clone(),
glossary_path: options.glossary_path.clone(),
};
config.validate()?;
Ok(config)
+105
View File
@@ -366,6 +366,77 @@ fn translation_memory_subcommands_reject_irrelevant_options() {
assert!(error.to_string().contains("query 参数"));
}
#[test]
fn glossary_commands_parse_and_validate() {
let query = parse(&[
"bat",
"i18n",
"glossary",
"query",
"--glossary-source-text",
"Sensei",
"--glossary-review-status",
"approved",
"--limit",
"5",
])
.unwrap();
assert_eq!(query.command, CliCommand::GlossaryQuery);
assert_eq!(query.glossary_source_text.as_deref(), Some("Sensei"));
assert_eq!(query.glossary_review_status.as_deref(), Some("approved"));
assert_eq!(query.query_limit, 5);
let add = parse(&[
"bat",
"i18n",
"glossary",
"add",
"--glossary-term-id",
"term-sensei",
"--glossary-source-term",
"Sensei",
"--glossary-recommended-translation",
"老师",
"--glossary-source-kind",
"manual",
"--glossary-scope-json",
r#"{"destination":"story.bundle"}"#,
"--glossary-path",
"/tmp/project-glossary.sqlite",
])
.unwrap();
assert_eq!(add.command, CliCommand::GlossaryAdd);
assert_eq!(add.glossary_term_id.as_deref(), Some("term-sensei"));
assert_eq!(add.glossary_priority, 0);
assert_eq!(
add.glossary_path,
Some(PathBuf::from("/tmp/project-glossary.sqlite"))
);
let diagnose = parse(&[
"bat",
"translation",
"glossary",
"diagnose",
"--glossary-source-text",
"Sensei",
"--glossary-context-json",
r#"{"destination":"story.bundle"}"#,
])
.unwrap();
assert_eq!(diagnose.command, CliCommand::GlossaryDiagnose);
assert!(parse(&["bat", "i18n", "glossary", "diagnose"]).is_err());
assert!(parse(&[
"bat",
"i18n",
"glossary",
"query",
"--glossary-term-id",
"term-sensei",
])
.is_err());
}
#[test]
fn translation_worker_env_defaults_apply() {
let options = parse_with_env(
@@ -765,6 +836,8 @@ fn translation_workbench_commands_read_update_and_clear_entries() {
review_status: None,
format: Some("plain".to_string()),
text_source_kind: Some("text_asset".to_string()),
glossary_qa: None,
glossary_override: None,
}],
};
bat_infrastructure::write_translation_workbench(&path, &workbench).unwrap();
@@ -2679,6 +2752,38 @@ fn dispatch_translation_memory_summary_defaults_to_worker_config_path() {
);
}
#[test]
fn dispatch_glossary_summary_reports_missing_database_without_creating_it() {
let temp = tempfile::TempDir::new().unwrap();
let output_root = temp.path().join("output");
let state_dir = temp.path().join("state");
let (queue, _rx) = mpsc::channel::<TaskJob>();
let context = DaemonTaskContext {
registry: TaskRegistry::new(),
queue,
base_config: OfficialUpdateConfig {
output_root: output_root.clone(),
..Default::default()
},
translation_worker_config: TranslationWorkerConfig::default(),
sync_lock: Arc::new(Mutex::new(())),
restart_controller: test_restart_controller,
};
let envelope = dispatch_rpc_method(
&rpc_request("translation.glossary.summary", None),
&state_dir,
&new_daemon_control(),
&context,
"req-glossary-summary-1".to_string(),
);
let value = serde_json::to_value(envelope).unwrap();
assert_eq!(value["ok"], true);
assert_eq!(value["data"]["available"], false);
assert_eq!(value["data"]["reason"], "database_missing");
assert!(!output_root.join("glossary.sqlite").exists());
}
#[test]
fn dispatch_translation_memory_rejects_invalid_params_with_stable_error_code() {
let temp = tempfile::TempDir::new().unwrap();
@@ -67,6 +67,7 @@ download_concurrency = 8
provider = 'mock'
fixture = ''
translation_memory_path = ''
glossary_path = ''
concurrency = 8
max_attempts = 3
lease_seconds = 300
@@ -150,6 +151,7 @@ struct TranslationWorkerSection {
provider: Option<String>,
fixture: Option<PathBuf>,
translation_memory_path: Option<PathBuf>,
glossary_path: Option<PathBuf>,
concurrency: Option<usize>,
max_attempts: Option<u32>,
lease_seconds: Option<u64>,
@@ -367,6 +369,9 @@ impl BatConfigFile {
if let Some(value) = self.translation.worker.translation_memory_path.as_ref() {
options.translation_memory_path = Some(value.clone());
}
if let Some(value) = self.translation.worker.glossary_path.as_ref() {
options.glossary_path = Some(value.clone());
}
if let Some(value) = self.translation.worker.concurrency {
options.worker_concurrency = value;
}
@@ -591,6 +596,10 @@ impl BatConfigFile {
line_number,
)?;
}
(SectionPath::TranslationWorker, "glossary_path") => {
self.translation.worker.glossary_path =
parse_optional_path(value, "translation.worker.glossary_path", line_number)?;
}
(SectionPath::TranslationWorker, "concurrency") => {
self.translation.worker.concurrency = Some(parse_translation_worker_concurrency(
&parse_scalar_text(value, "translation.worker.concurrency", line_number)?,
@@ -0,0 +1,649 @@
use super::report_output::print_json_value;
use super::*;
use bat_core::domain::{
GlossaryReviewStatus, GlossarySourceKind, GlossarySourceRecord, GlossaryTermDraft,
GlossaryTermSnapshot, TranslationMemoryContext,
};
use bat_infrastructure::{SqliteGlossaryRepository, GLOSSARY_SCHEMA_VERSION};
use std::collections::BTreeMap;
pub(super) fn run_glossary_command(options: &CliOptions) -> anyhow::Result<()> {
let method = glossary_method(options.command)?;
if daemon_rpc_available(&options.state_dir)
&& options.resource_root.is_none()
&& !options.output_explicit
{
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
let report = daemon_rpc_call(&options.state_dir, method, glossary_cli_params(options)?)?;
print_json_value(options.output_format, &report)?;
return Ok(());
}
let path = glossary_cli_path(options)?;
let report =
match options.command {
CliCommand::GlossarySummary => build_glossary_summary_report(&path)?,
CliCommand::GlossaryQuery => build_glossary_query_report(
&path,
options.glossary_source_text.as_deref(),
options.glossary_category.as_deref(),
options.glossary_review_status.as_deref(),
options.query_limit,
)?,
CliCommand::GlossaryDiagnose => build_glossary_diagnose_report(
&path,
options.glossary_source_text.as_deref().unwrap_or_default(),
parse_glossary_context(options.glossary_context_json.as_deref())?,
)?,
CliCommand::GlossaryAdd | CliCommand::GlossaryUpdate => {
let draft = glossary_term_draft(options)?;
let reviewer = options.glossary_reviewer.as_deref();
build_glossary_mutation_report(
&path,
&draft,
options.command == CliCommand::GlossaryUpdate,
reviewer,
options.glossary_reason.clone(),
)?
}
CliCommand::GlossaryApprove | CliCommand::GlossaryDeprecate => {
let term_id = options.glossary_term_id.as_deref().ok_or_else(|| {
anyhow::anyhow!("Glossary review 必须指定 --glossary-term-id")
})?;
let reviewer = options.glossary_reviewer.as_deref().ok_or_else(|| {
anyhow::anyhow!("Glossary review 必须指定 --glossary-reviewer")
})?;
let status = if options.command == CliCommand::GlossaryApprove {
GlossaryReviewStatus::Approved
} else {
GlossaryReviewStatus::Deprecated
};
build_glossary_review_report(
&path,
term_id,
status,
reviewer,
options.glossary_reason.clone(),
)?
}
_ => unreachable!(),
};
print_json_value(options.output_format, &report)
}
fn glossary_method(command: CliCommand) -> anyhow::Result<&'static str> {
Ok(match command {
CliCommand::GlossarySummary => RPC_METHOD_GLOSSARY_SUMMARY,
CliCommand::GlossaryQuery => RPC_METHOD_GLOSSARY_QUERY,
CliCommand::GlossaryAdd => RPC_METHOD_GLOSSARY_ADD,
CliCommand::GlossaryUpdate => RPC_METHOD_GLOSSARY_UPDATE,
CliCommand::GlossaryApprove => RPC_METHOD_GLOSSARY_APPROVE,
CliCommand::GlossaryDeprecate => RPC_METHOD_GLOSSARY_DEPRECATE,
CliCommand::GlossaryDiagnose => RPC_METHOD_GLOSSARY_DIAGNOSE,
_ => return Err(anyhow::anyhow!("不是 Glossary 命令")),
})
}
fn glossary_cli_path(options: &CliOptions) -> anyhow::Result<std::path::PathBuf> {
if let Some(path) = options.glossary_path.as_ref() {
return lexical_absolute(path).map_err(anyhow::Error::msg);
}
let resource_root = options
.resource_root
.as_deref()
.map(lexical_absolute)
.transpose()
.map_err(anyhow::Error::msg)?
.unwrap_or(active_official_resource_root(&options.config.output_root)?);
Ok(SqliteGlossaryRepository::repository_path(&resource_root))
}
fn glossary_cli_params(options: &CliOptions) -> anyhow::Result<Option<serde_json::Value>> {
let mut params = serde_json::Map::new();
if let Some(path) = options.glossary_path.as_ref() {
params.insert("glossary_path".to_string(), serde_json::json!(path));
}
match options.command {
CliCommand::GlossarySummary => {}
CliCommand::GlossaryQuery => {
if let Some(source_text) = options.glossary_source_text.as_deref() {
params.insert("source_text".to_string(), serde_json::json!(source_text));
}
if let Some(category) = options.glossary_category.as_deref() {
params.insert("category".to_string(), serde_json::json!(category));
}
if let Some(status) = options.glossary_review_status.as_deref() {
params.insert("review_status".to_string(), serde_json::json!(status));
}
params.insert("limit".to_string(), serde_json::json!(options.query_limit));
}
CliCommand::GlossaryDiagnose => {
let source_text = options.glossary_source_text.as_deref().ok_or_else(|| {
anyhow::anyhow!("Glossary diagnose 必须指定 --glossary-source-text")
})?;
params.insert("source_text".to_string(), serde_json::json!(source_text));
params.insert(
"context".to_string(),
serde_json::json!(parse_glossary_context(
options.glossary_context_json.as_deref()
)?),
);
}
CliCommand::GlossaryAdd | CliCommand::GlossaryUpdate => {
let draft = glossary_term_draft(options)?;
params.extend(
serde_json::to_value(draft)?
.as_object()
.cloned()
.unwrap_or_default(),
);
if options.command == CliCommand::GlossaryUpdate {
params.insert(
"reviewer".to_string(),
serde_json::json!(options.glossary_reviewer.as_deref().unwrap_or_default()),
);
if let Some(reason) = options.glossary_reason.as_deref() {
params.insert("reason".to_string(), serde_json::json!(reason));
}
}
}
CliCommand::GlossaryApprove | CliCommand::GlossaryDeprecate => {
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()),
);
if let Some(reason) = options.glossary_reason.as_deref() {
params.insert("reason".to_string(), serde_json::json!(reason));
}
}
_ => unreachable!(),
}
Ok(Some(serde_json::Value::Object(params)))
}
fn glossary_term_draft(options: &CliOptions) -> anyhow::Result<GlossaryTermDraft> {
let term_id = required_option(options.glossary_term_id.as_deref(), "--glossary-term-id")?;
let source_term = required_option(
options.glossary_source_term.as_deref(),
"--glossary-source-term",
)?;
let recommended_translation = required_option(
options.glossary_recommended_translation.as_deref(),
"--glossary-recommended-translation",
)?;
let aliases = parse_string_array(
options.glossary_aliases_json.as_deref(),
"--glossary-aliases-json",
)?;
let allowed_translations = parse_string_array(
options.glossary_allowed_translations_json.as_deref(),
"--glossary-allowed-translations-json",
)?;
let scope = parse_glossary_context(options.glossary_scope_json.as_deref())?;
let source_kind = options.glossary_source_kind.as_deref().unwrap_or("manual");
let source_kind = GlossarySourceKind::parse(source_kind)
.ok_or_else(|| anyhow::anyhow!("Glossary source kind 无效:{source_kind}"))?;
let review_status = options.glossary_review_status.as_deref().unwrap_or("draft");
let review_status = GlossaryReviewStatus::parse(review_status)
.ok_or_else(|| anyhow::anyhow!("Glossary review status 无效:{review_status}"))?;
let now = unix_seconds_now();
Ok(GlossaryTermDraft {
term_id,
definition: GlossaryTermSnapshot {
source_term,
aliases,
recommended_translation,
allowed_translations,
source_language: options.glossary_source_language.clone(),
target_language: options.glossary_target_language.clone(),
category: options.glossary_category.clone(),
priority: options.glossary_priority,
scope,
},
review_status,
source: GlossarySourceRecord {
source_kind,
source_ref: options.glossary_source_ref.clone(),
source_author: options.glossary_source_author.clone(),
source_note: options.glossary_source_note.clone(),
observed_unix_seconds: now,
},
})
}
fn required_option(value: Option<&str>, label: &str) -> anyhow::Result<String> {
value
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.ok_or_else(|| anyhow::anyhow!("Glossary 必须指定 {label}"))
}
fn parse_string_array(value: Option<&str>, label: &str) -> anyhow::Result<Vec<String>> {
let Some(value) = value else {
return Ok(Vec::new());
};
serde_json::from_str(value)
.map_err(|error| anyhow::anyhow!("{label} 必须是 JSON string array{error}"))
}
fn parse_glossary_context(value: Option<&str>) -> anyhow::Result<TranslationMemoryContext> {
let Some(value) = value else {
return Ok(BTreeMap::new());
};
serde_json::from_str(value)
.map_err(|error| anyhow::anyhow!("Glossary context 必须是 JSON object{error}"))
}
pub(super) fn build_glossary_summary_report(
path: &std::path::Path,
) -> anyhow::Result<serde_json::Value> {
if !sqlite_file_exists_no_symlink(path, "Glossary 数据库")? {
return Ok(serde_json::json!({
"available": false,
"path": path,
"reason": "database_missing",
}));
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let summary = runtime.block_on(async {
let repository = SqliteGlossaryRepository::open(path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.summary()
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"schema_version": GLOSSARY_SCHEMA_VERSION,
"summary": summary,
}))
}
pub(super) fn build_glossary_query_report(
path: &std::path::Path,
source_text: Option<&str>,
category: Option<&str>,
review_status: Option<&str>,
limit: usize,
) -> anyhow::Result<serde_json::Value> {
let status = review_status
.map(|value| {
GlossaryReviewStatus::parse(value)
.ok_or_else(|| anyhow::anyhow!("Glossary review_status 无效"))
})
.transpose()?;
if !sqlite_file_exists_no_symlink(path, "Glossary 数据库")? {
return Ok(serde_json::json!({
"available": false,
"path": path,
"source_text": source_text,
"terms": [],
"reason": "database_missing",
}));
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let terms = runtime.block_on(async {
let repository = SqliteGlossaryRepository::open(path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.query(source_text, category, status, limit)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"source_text": source_text,
"terms": terms,
}))
}
pub(super) fn build_glossary_diagnose_report(
path: &std::path::Path,
source_text: &str,
context: TranslationMemoryContext,
) -> anyhow::Result<serde_json::Value> {
if source_text.trim().is_empty() {
return Err(anyhow::anyhow!("Glossary diagnose 的 source_text 不能为空"));
}
if !sqlite_file_exists_no_symlink(path, "Glossary 数据库")? {
return Ok(serde_json::json!({
"available": false,
"path": path,
"source_text": source_text,
"context": context,
"evaluation": {
"constraints": [],
"diagnostics": [],
"blocked": false
},
"reason": "database_missing",
}));
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let evaluation = runtime.block_on(async {
let repository = SqliteGlossaryRepository::open(path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.diagnose(source_text, &context)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"source_text": source_text,
"context": context,
"evaluation": evaluation,
}))
}
pub(super) fn build_glossary_mutation_report(
path: &std::path::Path,
draft: &GlossaryTermDraft,
update: bool,
reviewer: Option<&str>,
reason: Option<String>,
) -> 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::new(path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
let result = if update {
repository
.update(
draft.clone(),
reviewer.ok_or_else(|| anyhow::anyhow!("Glossary update 需要 reviewer"))?,
reason,
)
.await
} else {
repository.add(draft.clone()).await
};
result.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"schema_version": GLOSSARY_SCHEMA_VERSION,
"term": term,
}))
}
pub(super) fn build_glossary_review_report(
path: &std::path::Path,
term_id: &str,
status: GlossaryReviewStatus,
reviewer: &str,
reason: Option<String>,
) -> 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
.review(term_id, status, reviewer, reason)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"path": path,
"term": term,
}))
}
pub(super) fn glossary_rpc_envelope(
request_id: String,
result: Result<serde_json::Value, ApiError>,
) -> RpcEnvelope {
match result {
Ok(data) => rpc_envelope_ok(request_id, "ok", data),
Err(error) => rpc_envelope_error(request_id, error),
}
}
pub(super) fn glossary_summary_rpc_report(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
) -> Result<serde_json::Value, ApiError> {
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
params,
RPC_METHOD_GLOSSARY_SUMMARY,
)?;
build_glossary_summary_report(&path)
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_SUMMARY, error))
}
pub(super) fn glossary_query_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_QUERY)?;
let source_text = glossary_string(&params, "source_text", RPC_METHOD_GLOSSARY_QUERY)?;
let category = glossary_string(&params, "category", RPC_METHOD_GLOSSARY_QUERY)?;
let review_status = glossary_string(&params, "review_status", RPC_METHOD_GLOSSARY_QUERY)?;
let limit = glossary_limit(&params, RPC_METHOD_GLOSSARY_QUERY)?;
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
Some(&serde_json::Value::Object(params.clone())),
RPC_METHOD_GLOSSARY_QUERY,
)?;
build_glossary_query_report(&path, source_text, category, review_status, limit)
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_QUERY, error))
}
pub(super) fn glossary_diagnose_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_DIAGNOSE)?;
let source_text = glossary_string(&params, "source_text", RPC_METHOD_GLOSSARY_DIAGNOSE)?
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DIAGNOSE, "缺少 source_text"))?;
let context = glossary_context(&params, RPC_METHOD_GLOSSARY_DIAGNOSE)?;
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
Some(&serde_json::Value::Object(params.clone())),
RPC_METHOD_GLOSSARY_DIAGNOSE,
)?;
build_glossary_diagnose_report(&path, source_text, context)
.map_err(|error| glossary_internal_error(RPC_METHOD_GLOSSARY_DIAGNOSE, error))
}
pub(super) fn glossary_mutation_rpc_report(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
update: bool,
) -> Result<serde_json::Value, ApiError> {
let method = if update {
RPC_METHOD_GLOSSARY_UPDATE
} else {
RPC_METHOD_GLOSSARY_ADD
};
let params = glossary_params(params, method)?;
let draft: GlossaryTermDraft =
serde_json::from_value(serde_json::Value::Object(params.clone())).map_err(|error| {
glossary_invalid(method, format!("Glossary term 参数无效:{error}"))
})?;
let reviewer = glossary_string(&params, "reviewer", method)?;
if update && reviewer.is_none() {
return Err(glossary_invalid(method, "update 缺少 reviewer"));
}
let reason = glossary_string(&params, "reason", method)?.map(str::to_string);
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
Some(&serde_json::Value::Object(params.clone())),
method,
)?;
build_glossary_mutation_report(&path, &draft, update, reviewer, reason)
.map_err(|error| glossary_internal_error(method, error))
}
pub(super) fn glossary_review_rpc_report(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
status: GlossaryReviewStatus,
) -> Result<serde_json::Value, ApiError> {
let method = if status == GlossaryReviewStatus::Approved {
RPC_METHOD_GLOSSARY_APPROVE
} else {
RPC_METHOD_GLOSSARY_DEPRECATE
};
let params = glossary_params(params, method)?;
let term_id = glossary_string(&params, "term_id", method)?
.ok_or_else(|| glossary_invalid(method, "缺少 term_id"))?;
let reviewer = glossary_string(&params, "reviewer", method)?
.ok_or_else(|| glossary_invalid(method, "缺少 reviewer"))?;
let reason = glossary_string(&params, "reason", method)?.map(str::to_string);
let path = glossary_rpc_path(
state_dir,
output_root,
default_path,
Some(&serde_json::Value::Object(params.clone())),
method,
)?;
build_glossary_review_report(&path, term_id, status, reviewer, reason)
.map_err(|error| glossary_internal_error(method, error))
}
fn glossary_rpc_path(
state_dir: &Path,
output_root: &Path,
default_path: Option<&Path>,
params: Option<&serde_json::Value>,
method: &'static str,
) -> Result<std::path::PathBuf, ApiError> {
if let Some(path) = params
.and_then(|value| value.get("glossary_path"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return lexical_absolute(Path::new(path))
.map_err(|error| glossary_internal_error(method, anyhow::anyhow!(error)));
}
if let Some(path) = default_path {
return lexical_absolute(path)
.map_err(|error| glossary_internal_error(method, anyhow::anyhow!(error)));
}
let (_, version_state) = read_daemon_resource_state(state_dir)
.map_err(|error| glossary_internal_error(method, error))?;
if let Some(record) = version_state
.as_ref()
.and_then(|state| state.current_completed_version.as_ref())
{
return Ok(SqliteGlossaryRepository::repository_path(
&record.resource_root,
));
}
Ok(SqliteGlossaryRepository::repository_path(output_root))
}
fn glossary_params(
params: Option<&serde_json::Value>,
method: &'static str,
) -> Result<serde_json::Map<String, serde_json::Value>, ApiError> {
match params {
None | Some(serde_json::Value::Null) => Ok(serde_json::Map::new()),
Some(serde_json::Value::Object(value)) => Ok(value.clone()),
Some(_) => Err(glossary_invalid(method, "params 必须是 JSON object")),
}
}
fn glossary_string<'a>(
params: &'a serde_json::Map<String, serde_json::Value>,
key: &str,
method: &'static str,
) -> Result<Option<&'a str>, ApiError> {
let Some(value) = params.get(key) else {
return Ok(None);
};
if value.is_null() {
return Ok(None);
}
value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| glossary_invalid(method, format!("{key} 必须是非空字符串")))
.map(Some)
}
fn glossary_context(
params: &serde_json::Map<String, serde_json::Value>,
method: &'static str,
) -> Result<TranslationMemoryContext, ApiError> {
let Some(value) = params
.get("context")
.or_else(|| params.get("source_context"))
else {
return Ok(BTreeMap::new());
};
serde_json::from_value(value.clone()).map_err(|error| {
glossary_invalid(method, format!("context 必须是 JSON string map{error}"))
})
}
fn glossary_limit(
params: &serde_json::Map<String, serde_json::Value>,
method: &'static str,
) -> Result<usize, ApiError> {
let limit = params
.get("limit")
.and_then(serde_json::Value::as_u64)
.unwrap_or(100);
let limit = usize::try_from(limit)
.map_err(|error| glossary_invalid(method, format!("limit 无效:{error}")))?;
if !(1..=1000).contains(&limit) {
return Err(glossary_invalid(method, "limit 必须在 1..=1000 范围内"));
}
Ok(limit)
}
fn glossary_invalid(method: &'static str, message: impl Into<String>) -> ApiError {
ApiError::new(ErrorCode::RPC_INVALID_PARAMS, method, message.into())
}
fn glossary_internal_error(method: &'static str, error: anyhow::Error) -> ApiError {
ApiError::new(ErrorCode::INTERNAL, method, error.to_string())
}
@@ -115,9 +115,15 @@ impl HumanReport for bat_infrastructure::TranslationWorkerReport {
print_field("TM 可用", format_bool(self.translation_memory_available));
print_field("TM 命中 TextUnit", self.translation_memory_hit_count);
print_field("Provider TextUnit", self.provider_unit_count);
print_path_field("Glossary", &self.glossary_path);
print_field("Glossary 可用", format_bool(self.glossary_available));
print_field("Glossary blocking TextUnit", self.glossary_blocked_count);
for failure in &self.translation_memory_failures {
println!(" - TM: {failure}");
}
for failure in &self.glossary_failures {
println!(" - Glossary: {failure}");
}
for failure in &self.failures {
println!(
" - {} [{}] retryable={} {}",
@@ -345,6 +345,10 @@ Commands:
i18n handoff Query current translation handoff
i18n status Show localized release status for current official release
i18n task update Update one provider worker task status
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 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
i18n schedule Manage translation schedules
@@ -384,6 +388,7 @@ Examples:
{binary} i18n unset --translation-file /tmp/bat-workbench.json --translation-id unit-1
{binary} i18n proofread --json
{binary} i18n worker run --provider mock --worker-concurrency 8 --run-count 2 --interval 30s
{binary} i18n glossary diagnose --glossary-source-text Sensei --json
{binary} i18n tasks --json
{binary} i18n handoff --json
{binary} i18n status --json
@@ -433,6 +438,15 @@ Sync:
--provider-run-id <ID> Provider run ID for i18n task update
--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-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-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
--worker-lease-seconds <N> Lease seconds for one claimed task
@@ -1,6 +1,6 @@
use super::report_output::print_json_value;
use super::*;
use bat_core::domain::TranslationMemoryContext;
use bat_core::domain::{GlossaryOverride, TranslationMemoryContext};
use bat_core::repositories::TranslationMemoryRepository;
use std::collections::BTreeMap;
@@ -9,6 +9,8 @@ struct TranslationTaskResultUpdateParam {
unit_id: String,
source_text: String,
translated_text: String,
#[serde(default)]
glossary_override: Option<GlossaryOverride>,
}
pub(super) fn build_translation_tasks_report(
@@ -170,6 +172,7 @@ pub(super) fn build_translation_handoff_report(
pub(super) fn update_translation_task_status_report(
state_dir: &Path,
params: Option<&serde_json::Value>,
configured_glossary_path: Option<&Path>,
) -> anyhow::Result<serde_json::Value> {
let task_id = rpc_string_param(params, "task_id")
.ok_or_else(|| anyhow::anyhow!("translation.task.update 缺少 task_id"))?;
@@ -229,6 +232,22 @@ pub(super) fn update_translation_task_status_report(
.find(task_id)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
let glossary_path = configured_glossary_path
.map(PathBuf::from)
.unwrap_or_else(|| {
bat_infrastructure::SqliteGlossaryRepository::repository_path(
&current.resource_root,
)
});
let glossary = if std::fs::symlink_metadata(&glossary_path).is_ok() {
Some(
bat_infrastructure::SqliteGlossaryRepository::open(&glossary_path)
.await
.map_err(|error| anyhow::anyhow!("打开 Glossary 数据库失败:{error}"))?,
)
} else {
None
};
let results = build_manual_translation_results(
&current_task,
index,
@@ -236,7 +255,9 @@ pub(super) fn update_translation_task_status_report(
&result_provider,
&result_provider_run_id,
result_timestamp,
)?;
glossary.as_ref(),
)
.await?;
repository
.update_status_with_results(
task_id,
@@ -291,13 +312,14 @@ fn translation_task_result_params(
.map_err(|error| anyhow::anyhow!("translation_results 必须是结果数组:{error}"))
}
fn build_manual_translation_results(
async fn build_manual_translation_results(
task: &bat_infrastructure::PersistedTranslationTask,
index: &bat_infrastructure::OfficialTextUnitIndex,
params: &[TranslationTaskResultUpdateParam],
provider: &str,
provider_run_id: &str,
translated_unix_seconds: u64,
glossary: Option<&bat_infrastructure::SqliteGlossaryRepository>,
) -> anyhow::Result<Vec<bat_infrastructure::TranslationTaskUnitResult>> {
let index_by_id = index
.units
@@ -332,6 +354,53 @@ fn build_manual_translation_results(
"TextUnit {unit_id} 的 source_text 与当前索引不一致"
));
}
let glossary_qa = if let Some(glossary) = glossary {
let context = bat_infrastructure::translation_memory_context(
&unit.destination,
unit.archive_entry.as_deref(),
unit.serialized_file.as_deref(),
unit.path_id,
unit.class_id,
unit.field_path.as_deref(),
unit.format.as_deref(),
unit.asset_name.as_deref(),
unit.text_source_kind.as_deref(),
&unit.context,
);
Some(
glossary
.diagnose(&unit.source_text, &context)
.await
.map_err(|error| anyhow::anyhow!("Glossary QA 失败:{error}"))?
.check_translation(&param.translated_text),
)
} else {
None
};
if let Some(qa) = glossary_qa.as_ref().filter(|qa| qa.status.is_blocked()) {
let Some(override_record) = param.glossary_override.as_ref() else {
return Err(anyhow::anyhow!(
"TextUnit {} 的 Glossary QA blocked;必须提供 glossary_override",
unit_id
));
};
if override_record.reviewer.trim().is_empty()
|| override_record.reason.trim().is_empty()
|| override_record.provenance.trim().is_empty()
|| override_record.confirmed_unix_seconds == 0
{
return Err(anyhow::anyhow!(
"TextUnit {} 的 glossary_override 不完整或 confirmed_unix_seconds 无效",
unit_id
));
}
let _ = qa;
} else if param.glossary_override.is_some() {
return Err(anyhow::anyhow!(
"TextUnit {} 不能为非 blocking Glossary QA 指定 override",
unit_id
));
}
results.push(bat_infrastructure::TranslationTaskUnitResult {
unit_id: unit_id.to_string(),
source_text: param.source_text.clone(),
@@ -341,6 +410,8 @@ fn build_manual_translation_results(
provider: provider.to_string(),
provider_run_id: provider_run_id.to_string(),
translated_unix_seconds,
glossary_qa,
glossary_override: param.glossary_override.clone(),
});
}
Ok(results)
@@ -104,7 +104,12 @@ pub(super) fn run_translation_validate(options: &CliOptions) -> anyhow::Result<(
.ok_or_else(|| anyhow::anyhow!("i18n validate 必须指定 --translation-file"))?;
let (resource_root, release_id) = current_official_release(options)?;
let workbench = read_translation_workbench(path)?;
let validation = validate_translation_workbench(&resource_root, &release_id, &workbench)?;
let validation = validate_translation_workbench_with_glossary_path(
&resource_root,
&release_id,
&workbench,
options.glossary_path.as_deref(),
)?;
let data = serde_json::json!({
"official_release_id": release_id,
"resource_root": resource_root,
@@ -141,7 +146,50 @@ pub(super) fn run_translation_set(options: &CliOptions) -> anyhow::Result<()> {
.translation_id
.as_deref()
.ok_or_else(|| anyhow::anyhow!("translation-set 必须指定 --translation-id"))?;
let entry = set_translation(path, entry_id, text)?;
let glossary_override = match (
options.glossary_reviewer.as_deref(),
options.glossary_reason.as_deref(),
options.glossary_override_provenance.as_deref(),
) {
(None, None, None) => None,
(Some(reviewer), Some(reason), Some(provenance)) => Some(
bat_core::domain::GlossaryOverride {
reviewer: reviewer.to_string(),
reason: reason.to_string(),
provenance: provenance.to_string(),
confirmed_unix_seconds: unix_seconds_now(),
},
),
_ => {
return Err(anyhow::anyhow!(
"Glossary override 必须同时指定 --glossary-reviewer、--glossary-reason 和 --glossary-provenance"
))
}
};
let workbench = read_translation_workbench(path)?;
let glossary_path = options.glossary_path.clone().unwrap_or_else(|| {
bat_infrastructure::SqliteGlossaryRepository::repository_path(
&workbench.official_resource_root,
)
});
let entry = if std::fs::symlink_metadata(&glossary_path).is_ok() {
let (resource_root, _) = current_official_release(options)?;
set_translation_checked_with_glossary_path(
&resource_root,
path,
entry_id,
text,
glossary_override,
options.glossary_path.as_deref(),
)?
} else {
if glossary_override.is_some() {
return Err(anyhow::anyhow!(
"当前项目没有 Glossary 数据库,不能提交 Glossary override"
));
}
set_translation(path, entry_id, text)?
};
let data = serde_json::json!({
"translation_file": path,
"entry": entry,
@@ -232,6 +280,7 @@ pub(super) fn run_translation_task_update(options: &CliOptions) -> anyhow::Resul
let report = update_translation_task_status_report(
&options.state_dir,
Some(&serde_json::Value::Object(params)),
options.glossary_path.as_deref(),
)?;
print_json_value(options.output_format, &report)
}
@@ -316,6 +365,12 @@ pub(super) fn publish_localized_report(
"翻译工作台资源根目录与当前 release 不一致;请重新导出"
));
}
validate_translation_workbench_with_glossary_path(
&resource_root,
&official_release_id,
&workbench,
options.glossary_path.as_deref(),
)?;
let operations = localized_patch_operations(&resource_root, &workbench)?;
let localized_release_id = options.localized_release_id.clone().or_else(|| {
options