mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
feat(glossary): 实现 Rust Glossary V1
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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(¶ms, "source_text", RPC_METHOD_GLOSSARY_QUERY)?;
|
||||
let category = glossary_string(¶ms, "category", RPC_METHOD_GLOSSARY_QUERY)?;
|
||||
let review_status = glossary_string(¶ms, "review_status", RPC_METHOD_GLOSSARY_QUERY)?;
|
||||
let limit = glossary_limit(¶ms, 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(¶ms, "source_text", RPC_METHOD_GLOSSARY_DIAGNOSE)?
|
||||
.ok_or_else(|| glossary_invalid(RPC_METHOD_GLOSSARY_DIAGNOSE, "缺少 source_text"))?;
|
||||
let context = glossary_context(¶ms, 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(¶ms, "reviewer", method)?;
|
||||
if update && reviewer.is_none() {
|
||||
return Err(glossary_invalid(method, "update 缺少 reviewer"));
|
||||
}
|
||||
let reason = glossary_string(¶ms, "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(¶ms, "term_id", method)?
|
||||
.ok_or_else(|| glossary_invalid(method, "缺少 term_id"))?;
|
||||
let reviewer = glossary_string(¶ms, "reviewer", method)?
|
||||
.ok_or_else(|| glossary_invalid(method, "缺少 reviewer"))?;
|
||||
let reason = glossary_string(¶ms, "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(
|
||||
¤t.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(
|
||||
¤t_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(¶m.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
|
||||
|
||||
@@ -0,0 +1,757 @@
|
||||
//! Project-level Glossary V1 SQLite repository.
|
||||
|
||||
use crate::path_security::{
|
||||
ensure_safe_directory_path, lexical_absolute, set_file_mode, STATE_FILE_MODE,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use bat_core::domain::{
|
||||
evaluate_glossary, validate_glossary_draft, GlossaryEvaluation, GlossaryHistoryRecord,
|
||||
GlossaryReviewStatus, GlossarySourceKind, GlossarySourceRecord, GlossarySummary, GlossaryTerm,
|
||||
GlossaryTermDraft, GlossaryTermSnapshot, TranslationMemoryContext,
|
||||
};
|
||||
use bat_core::repositories::GlossaryRepository;
|
||||
use bat_core::{Error, Result};
|
||||
use serde::de::DeserializeOwned;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Glossary SQLite schema version.
|
||||
pub const GLOSSARY_SCHEMA_VERSION: u32 = 1;
|
||||
/// Schema migration component.
|
||||
pub const GLOSSARY_SCHEMA_COMPONENT: &str = "glossary";
|
||||
/// Default project-level glossary file.
|
||||
pub const GLOSSARY_REPOSITORY_FILE: &str = "glossary.sqlite";
|
||||
|
||||
/// SQLite-backed project Glossary repository.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SqliteGlossaryRepository {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl SqliteGlossaryRepository {
|
||||
/// Creates or opens a glossary database.
|
||||
pub async fn new(path: impl AsRef<Path>) -> Result<Self> {
|
||||
Self::open_with(path.as_ref(), true).await
|
||||
}
|
||||
|
||||
/// Opens an existing glossary database without creating it.
|
||||
pub async fn open(path: impl AsRef<Path>) -> Result<Self> {
|
||||
Self::open_with(path.as_ref(), false).await
|
||||
}
|
||||
|
||||
/// Returns the project-level glossary path for an official release root.
|
||||
pub fn repository_path(resource_root: &Path) -> PathBuf {
|
||||
if resource_root
|
||||
.parent()
|
||||
.and_then(Path::file_name)
|
||||
.is_some_and(|name| name == "versions")
|
||||
{
|
||||
if let Some(output_root) = resource_root.parent().and_then(Path::parent) {
|
||||
return output_root.join(GLOSSARY_REPOSITORY_FILE);
|
||||
}
|
||||
}
|
||||
resource_root.join(GLOSSARY_REPOSITORY_FILE)
|
||||
}
|
||||
|
||||
async fn open_with(path: &Path, create_if_missing: bool) -> Result<Self> {
|
||||
let absolute = lexical_absolute(path).map_err(Error::InvalidArgument)?;
|
||||
let parent = absolute.parent().ok_or_else(|| {
|
||||
Error::InvalidArgument(format!("Glossary 数据库缺少父目录:{}", absolute.display()))
|
||||
})?;
|
||||
ensure_safe_directory_path(parent, "Glossary 数据库").map_err(Error::InvalidArgument)?;
|
||||
if create_if_missing {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
ensure_safe_directory_path(parent, "Glossary 数据库")
|
||||
.map_err(Error::InvalidArgument)?;
|
||||
} else if !absolute.is_file() {
|
||||
return Err(Error::NotFound(absolute.display().to_string()));
|
||||
}
|
||||
if let Ok(metadata) = std::fs::symlink_metadata(&absolute) {
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Err(Error::InvalidArgument(format!(
|
||||
"Glossary 数据库必须是普通文件:{}",
|
||||
absolute.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
let options = SqliteConnectOptions::from_str(&format!("sqlite://{}", absolute.display()))
|
||||
.map_err(|error| Error::Other(error.into()))?
|
||||
.create_if_missing(create_if_missing)
|
||||
.journal_mode(SqliteJournalMode::Wal)
|
||||
.busy_timeout(Duration::from_secs(30));
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
if create_if_missing {
|
||||
set_file_mode(&absolute, STATE_FILE_MODE, "Glossary 数据库")
|
||||
.map_err(Error::InvalidArgument)?;
|
||||
}
|
||||
let repository = Self { pool };
|
||||
repository.init_schema().await?;
|
||||
Ok(repository)
|
||||
}
|
||||
|
||||
async fn init_schema(&self) -> Result<()> {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
component TEXT PRIMARY KEY NOT NULL,
|
||||
version INTEGER NOT NULL CHECK(version >= 1)
|
||||
)",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS glossary_terms (
|
||||
term_id TEXT PRIMARY KEY NOT NULL,
|
||||
source_term TEXT NOT NULL,
|
||||
aliases_json TEXT NOT NULL,
|
||||
recommended_translation TEXT NOT NULL,
|
||||
allowed_translations_json TEXT NOT NULL,
|
||||
source_language TEXT,
|
||||
target_language TEXT,
|
||||
category TEXT,
|
||||
priority INTEGER NOT NULL,
|
||||
scope_json TEXT NOT NULL,
|
||||
review_status TEXT NOT NULL,
|
||||
source_kind TEXT NOT NULL,
|
||||
source_ref TEXT,
|
||||
source_author TEXT,
|
||||
source_note TEXT,
|
||||
source_observed_unix_seconds INTEGER NOT NULL,
|
||||
created_unix_seconds INTEGER NOT NULL,
|
||||
updated_unix_seconds INTEGER NOT NULL,
|
||||
CHECK(length(term_id) > 0),
|
||||
CHECK(length(source_term) > 0),
|
||||
CHECK(length(recommended_translation) > 0),
|
||||
CHECK(review_status IN ('draft', 'approved', 'deprecated', 'rejected')),
|
||||
CHECK(source_kind IN ('manual', 'imported'))
|
||||
)",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
ensure_column(
|
||||
&self.pool,
|
||||
"glossary_terms",
|
||||
"source_observed_unix_seconds",
|
||||
"INTEGER NOT NULL DEFAULT 1",
|
||||
)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS glossary_term_history (
|
||||
history_id TEXT PRIMARY KEY NOT NULL,
|
||||
term_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
reviewer TEXT,
|
||||
reason TEXT,
|
||||
source_json TEXT NOT NULL,
|
||||
review_status TEXT NOT NULL,
|
||||
snapshot_json TEXT NOT NULL,
|
||||
observed_unix_seconds INTEGER NOT NULL,
|
||||
FOREIGN KEY(term_id) REFERENCES glossary_terms(term_id)
|
||||
)",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_glossary_status
|
||||
ON glossary_terms(review_status, priority DESC, term_id)",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_glossary_source_term
|
||||
ON glossary_terms(source_term)",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
let current: Option<i64> =
|
||||
sqlx::query_scalar("SELECT version FROM schema_migrations WHERE component = ?1")
|
||||
.bind(GLOSSARY_SCHEMA_COMPONENT)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
if current.is_some_and(|version| version > i64::from(GLOSSARY_SCHEMA_VERSION)) {
|
||||
return Err(Error::InvalidArgument(format!(
|
||||
"不支持的 Glossary schema 版本:{}",
|
||||
current.unwrap_or_default()
|
||||
)));
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO schema_migrations(component, version) VALUES (?1, ?2)
|
||||
ON CONFLICT(component) DO UPDATE SET version = excluded.version",
|
||||
)
|
||||
.bind(GLOSSARY_SCHEMA_COMPONENT)
|
||||
.bind(i64::from(GLOSSARY_SCHEMA_VERSION))
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns one term with complete history.
|
||||
pub async fn find(&self, term_id: &str) -> Result<GlossaryTerm> {
|
||||
let row = sqlx::query(
|
||||
"SELECT term_id, source_term, aliases_json, recommended_translation,
|
||||
allowed_translations_json, source_language, target_language, category,
|
||||
priority, scope_json, review_status, source_kind, source_ref,
|
||||
source_author, source_note, source_observed_unix_seconds,
|
||||
created_unix_seconds, updated_unix_seconds
|
||||
FROM glossary_terms WHERE term_id = ?1",
|
||||
)
|
||||
.bind(term_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?
|
||||
.ok_or_else(|| Error::NotFound(term_id.to_string()))?;
|
||||
let mut term = row_to_term(row)?;
|
||||
let history_rows = sqlx::query(
|
||||
"SELECT history_id, action, reviewer, reason, source_json, review_status,
|
||||
snapshot_json, observed_unix_seconds
|
||||
FROM glossary_term_history WHERE term_id = ?1
|
||||
ORDER BY observed_unix_seconds ASC, history_id ASC",
|
||||
)
|
||||
.bind(term_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
term.history = history_rows
|
||||
.into_iter()
|
||||
.map(row_to_history)
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(term)
|
||||
}
|
||||
|
||||
/// Queries terms, including non-approved terms for review.
|
||||
pub async fn query(
|
||||
&self,
|
||||
source_text: Option<&str>,
|
||||
category: Option<&str>,
|
||||
review_status: Option<GlossaryReviewStatus>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<GlossaryTerm>> {
|
||||
if !(1..=1000).contains(&limit) {
|
||||
return Err(Error::InvalidArgument(
|
||||
"Glossary query limit 必须在 1..=1000 范围内".to_string(),
|
||||
));
|
||||
}
|
||||
let rows = sqlx::query(
|
||||
"SELECT term_id FROM glossary_terms
|
||||
WHERE (?1 IS NULL OR category = ?1)
|
||||
AND (?2 IS NULL OR review_status = ?2)
|
||||
ORDER BY priority DESC, term_id ASC",
|
||||
)
|
||||
.bind(category)
|
||||
.bind(review_status.map(|value| value.as_str()))
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
let mut terms = Vec::new();
|
||||
for row in rows {
|
||||
let term_id: String = row.try_get("term_id").map_err(db_error)?;
|
||||
let term = self.find(&term_id).await?;
|
||||
if source_text.is_none_or(|source| {
|
||||
let mut spellings = vec![term.definition.source_term.as_str()];
|
||||
spellings.extend(term.definition.aliases.iter().map(String::as_str));
|
||||
spellings
|
||||
.into_iter()
|
||||
.any(|spelling| source.contains(spelling))
|
||||
}) {
|
||||
terms.push(term);
|
||||
if terms.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(terms)
|
||||
}
|
||||
|
||||
/// Returns review-state counts.
|
||||
pub async fn summary(&self) -> Result<GlossarySummary> {
|
||||
let row = sqlx::query(
|
||||
"SELECT COUNT(*) AS term_count,
|
||||
SUM(CASE WHEN review_status = 'approved' THEN 1 ELSE 0 END) AS approved_count,
|
||||
SUM(CASE WHEN review_status = 'draft' THEN 1 ELSE 0 END) AS draft_count,
|
||||
SUM(CASE WHEN review_status = 'deprecated' THEN 1 ELSE 0 END) AS deprecated_count,
|
||||
SUM(CASE WHEN review_status = 'rejected' THEN 1 ELSE 0 END) AS rejected_count
|
||||
FROM glossary_terms",
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(GlossarySummary {
|
||||
schema_version: GLOSSARY_SCHEMA_VERSION,
|
||||
term_count: row.try_get::<i64, _>("term_count").map_err(db_error)? as u64,
|
||||
approved_count: row.try_get::<i64, _>("approved_count").map_err(db_error)? as u64,
|
||||
draft_count: row.try_get::<i64, _>("draft_count").map_err(db_error)? as u64,
|
||||
deprecated_count: row
|
||||
.try_get::<i64, _>("deprecated_count")
|
||||
.map_err(db_error)? as u64,
|
||||
rejected_count: row.try_get::<i64, _>("rejected_count").map_err(db_error)? as u64,
|
||||
})
|
||||
}
|
||||
|
||||
/// Adds a term and records its source snapshot.
|
||||
pub async fn add(&self, draft: GlossaryTermDraft) -> Result<GlossaryTerm> {
|
||||
validate_glossary_draft(&draft)?;
|
||||
let now = draft.source.observed_unix_seconds;
|
||||
let term = term_from_draft(&draft, now, now);
|
||||
let mut transaction = self.pool.begin().await.map_err(db_error)?;
|
||||
let existing: Option<String> =
|
||||
sqlx::query_scalar("SELECT term_id FROM glossary_terms WHERE term_id = ?1")
|
||||
.bind(&draft.term_id)
|
||||
.fetch_optional(&mut *transaction)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
if existing.is_some() {
|
||||
return Err(Error::InvalidArgument(format!(
|
||||
"Glossary term_id 已存在:{}",
|
||||
draft.term_id
|
||||
)));
|
||||
}
|
||||
insert_term(&mut transaction, &term).await?;
|
||||
insert_history(&mut transaction, &term, "created", None, None, now).await?;
|
||||
transaction.commit().await.map_err(db_error)?;
|
||||
Ok(term)
|
||||
}
|
||||
|
||||
/// Replaces a term definition and records the previous source history.
|
||||
pub async fn update(
|
||||
&self,
|
||||
draft: GlossaryTermDraft,
|
||||
reviewer: &str,
|
||||
reason: Option<String>,
|
||||
) -> Result<GlossaryTerm> {
|
||||
validate_glossary_draft(&draft)?;
|
||||
if reviewer.trim().is_empty() {
|
||||
return Err(Error::InvalidArgument(
|
||||
"Glossary update reviewer 不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
let current = self.find(&draft.term_id).await?;
|
||||
let now = draft.source.observed_unix_seconds;
|
||||
let term = term_from_draft(&draft, current.created_unix_seconds, now);
|
||||
let mut transaction = self.pool.begin().await.map_err(db_error)?;
|
||||
update_term(&mut transaction, &term).await?;
|
||||
insert_history(
|
||||
&mut transaction,
|
||||
&term,
|
||||
"updated",
|
||||
Some(reviewer.trim()),
|
||||
reason.as_deref(),
|
||||
now,
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await.map_err(db_error)?;
|
||||
self.find(&draft.term_id).await
|
||||
}
|
||||
|
||||
/// Changes review state and records a source/review history entry.
|
||||
pub async fn review(
|
||||
&self,
|
||||
term_id: &str,
|
||||
status: GlossaryReviewStatus,
|
||||
reviewer: &str,
|
||||
reason: Option<String>,
|
||||
) -> Result<GlossaryTerm> {
|
||||
if reviewer.trim().is_empty() {
|
||||
return Err(Error::InvalidArgument(
|
||||
"Glossary reviewer 不能为空".to_string(),
|
||||
));
|
||||
}
|
||||
if !matches!(
|
||||
status,
|
||||
GlossaryReviewStatus::Approved
|
||||
| GlossaryReviewStatus::Deprecated
|
||||
| GlossaryReviewStatus::Rejected
|
||||
) {
|
||||
return Err(Error::InvalidArgument(
|
||||
"Glossary review 只允许 approved、deprecated 或 rejected".to_string(),
|
||||
));
|
||||
}
|
||||
let mut term = self.find(term_id).await?;
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
term.review_status = status;
|
||||
term.updated_unix_seconds = now;
|
||||
let mut transaction = self.pool.begin().await.map_err(db_error)?;
|
||||
sqlx::query(
|
||||
"UPDATE glossary_terms SET review_status = ?2, updated_unix_seconds = ?3
|
||||
WHERE term_id = ?1",
|
||||
)
|
||||
.bind(term_id)
|
||||
.bind(status.as_str())
|
||||
.bind(i64::try_from(now).unwrap_or(i64::MAX))
|
||||
.execute(&mut *transaction)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
insert_history(
|
||||
&mut transaction,
|
||||
&term,
|
||||
status.as_str(),
|
||||
Some(reviewer.trim()),
|
||||
reason.as_deref(),
|
||||
now,
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await.map_err(db_error)?;
|
||||
self.find(term_id).await
|
||||
}
|
||||
|
||||
/// Evaluates approved terms for one TextUnit.
|
||||
pub async fn diagnose(
|
||||
&self,
|
||||
source_text: &str,
|
||||
context: &TranslationMemoryContext,
|
||||
) -> Result<GlossaryEvaluation> {
|
||||
let terms = self
|
||||
.query(None, None, Some(GlossaryReviewStatus::Approved), 1000)
|
||||
.await?;
|
||||
Ok(evaluate_glossary(&terms, source_text, context))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl GlossaryRepository for SqliteGlossaryRepository {
|
||||
async fn evaluate(
|
||||
&self,
|
||||
source_text: &str,
|
||||
context: &TranslationMemoryContext,
|
||||
) -> Result<GlossaryEvaluation> {
|
||||
self.diagnose(source_text, context).await
|
||||
}
|
||||
}
|
||||
|
||||
fn term_from_draft(draft: &GlossaryTermDraft, created: u64, updated: u64) -> GlossaryTerm {
|
||||
GlossaryTerm {
|
||||
term_id: draft.term_id.clone(),
|
||||
definition: draft.definition.clone(),
|
||||
review_status: draft.review_status,
|
||||
source: draft.source.clone(),
|
||||
history: Vec::new(),
|
||||
created_unix_seconds: created,
|
||||
updated_unix_seconds: updated,
|
||||
}
|
||||
}
|
||||
|
||||
async fn insert_term(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
term: &GlossaryTerm,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO glossary_terms (
|
||||
term_id, source_term, aliases_json, recommended_translation,
|
||||
allowed_translations_json, source_language, target_language, category,
|
||||
priority, scope_json, review_status, source_kind, source_ref,
|
||||
source_author, source_note, source_observed_unix_seconds,
|
||||
created_unix_seconds, updated_unix_seconds
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
|
||||
)
|
||||
.bind(&term.term_id)
|
||||
.bind(&term.definition.source_term)
|
||||
.bind(json(&term.definition.aliases)?)
|
||||
.bind(&term.definition.recommended_translation)
|
||||
.bind(json(&term.definition.allowed_translations)?)
|
||||
.bind(&term.definition.source_language)
|
||||
.bind(&term.definition.target_language)
|
||||
.bind(&term.definition.category)
|
||||
.bind(term.definition.priority)
|
||||
.bind(json(&term.definition.scope)?)
|
||||
.bind(term.review_status.as_str())
|
||||
.bind(term.source.source_kind.as_str())
|
||||
.bind(&term.source.source_ref)
|
||||
.bind(&term.source.source_author)
|
||||
.bind(&term.source.source_note)
|
||||
.bind(i64::try_from(term.source.observed_unix_seconds).unwrap_or(i64::MAX))
|
||||
.bind(i64::try_from(term.created_unix_seconds).unwrap_or(i64::MAX))
|
||||
.bind(i64::try_from(term.updated_unix_seconds).unwrap_or(i64::MAX))
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_term(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
term: &GlossaryTerm,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE glossary_terms SET source_term = ?2, aliases_json = ?3,
|
||||
recommended_translation = ?4, allowed_translations_json = ?5,
|
||||
source_language = ?6, target_language = ?7, category = ?8,
|
||||
priority = ?9, scope_json = ?10, review_status = ?11, source_kind = ?12,
|
||||
source_ref = ?13, source_author = ?14, source_note = ?15,
|
||||
source_observed_unix_seconds = ?16, updated_unix_seconds = ?17 WHERE term_id = ?1",
|
||||
)
|
||||
.bind(&term.term_id)
|
||||
.bind(&term.definition.source_term)
|
||||
.bind(json(&term.definition.aliases)?)
|
||||
.bind(&term.definition.recommended_translation)
|
||||
.bind(json(&term.definition.allowed_translations)?)
|
||||
.bind(&term.definition.source_language)
|
||||
.bind(&term.definition.target_language)
|
||||
.bind(&term.definition.category)
|
||||
.bind(term.definition.priority)
|
||||
.bind(json(&term.definition.scope)?)
|
||||
.bind(term.review_status.as_str())
|
||||
.bind(term.source.source_kind.as_str())
|
||||
.bind(&term.source.source_ref)
|
||||
.bind(&term.source.source_author)
|
||||
.bind(&term.source.source_note)
|
||||
.bind(i64::try_from(term.source.observed_unix_seconds).unwrap_or(i64::MAX))
|
||||
.bind(i64::try_from(term.updated_unix_seconds).unwrap_or(i64::MAX))
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn insert_history(
|
||||
transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
|
||||
term: &GlossaryTerm,
|
||||
action: &str,
|
||||
reviewer: Option<&str>,
|
||||
reason: Option<&str>,
|
||||
observed: u64,
|
||||
) -> Result<()> {
|
||||
let source_json = json(&term.source)?;
|
||||
let snapshot_json = json(&term.definition)?;
|
||||
let mut history_hasher = blake3::Hasher::new();
|
||||
for value in [
|
||||
term.term_id.as_str(),
|
||||
action,
|
||||
reviewer.unwrap_or_default(),
|
||||
reason.unwrap_or_default(),
|
||||
source_json.as_str(),
|
||||
snapshot_json.as_str(),
|
||||
] {
|
||||
history_hasher.update(value.as_bytes());
|
||||
history_hasher.update(&[0]);
|
||||
}
|
||||
history_hasher.update(&observed.to_le_bytes());
|
||||
let nonce = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
history_hasher.update(&nonce.to_le_bytes());
|
||||
let history_id = format!("glh-{}", history_hasher.finalize().to_hex());
|
||||
sqlx::query(
|
||||
"INSERT INTO glossary_term_history (
|
||||
history_id, term_id, action, reviewer, reason, source_json,
|
||||
review_status, snapshot_json, observed_unix_seconds
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
)
|
||||
.bind(history_id)
|
||||
.bind(&term.term_id)
|
||||
.bind(action)
|
||||
.bind(reviewer)
|
||||
.bind(reason.filter(|value| !value.trim().is_empty()))
|
||||
.bind(source_json)
|
||||
.bind(term.review_status.as_str())
|
||||
.bind(snapshot_json)
|
||||
.bind(i64::try_from(observed).unwrap_or(i64::MAX))
|
||||
.execute(&mut **transaction)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn row_to_term(row: sqlx::sqlite::SqliteRow) -> Result<GlossaryTerm> {
|
||||
Ok(GlossaryTerm {
|
||||
term_id: row.try_get("term_id").map_err(db_error)?,
|
||||
definition: GlossaryTermSnapshot {
|
||||
source_term: row.try_get("source_term").map_err(db_error)?,
|
||||
aliases: parse_json(row.try_get("aliases_json").map_err(db_error)?)?,
|
||||
recommended_translation: row.try_get("recommended_translation").map_err(db_error)?,
|
||||
allowed_translations: parse_json(
|
||||
row.try_get("allowed_translations_json").map_err(db_error)?,
|
||||
)?,
|
||||
source_language: row.try_get("source_language").map_err(db_error)?,
|
||||
target_language: row.try_get("target_language").map_err(db_error)?,
|
||||
category: row.try_get("category").map_err(db_error)?,
|
||||
priority: row.try_get("priority").map_err(db_error)?,
|
||||
scope: parse_json(row.try_get("scope_json").map_err(db_error)?)?,
|
||||
},
|
||||
review_status: parse_review_status(
|
||||
row.try_get::<String, _>("review_status")
|
||||
.map_err(db_error)?
|
||||
.as_str(),
|
||||
)?,
|
||||
source: GlossarySourceRecord {
|
||||
source_kind: parse_source_kind(
|
||||
row.try_get::<String, _>("source_kind")
|
||||
.map_err(db_error)?
|
||||
.as_str(),
|
||||
)?,
|
||||
source_ref: row.try_get("source_ref").map_err(db_error)?,
|
||||
source_author: row.try_get("source_author").map_err(db_error)?,
|
||||
source_note: row.try_get("source_note").map_err(db_error)?,
|
||||
observed_unix_seconds: to_u64(
|
||||
row.try_get("source_observed_unix_seconds")
|
||||
.map_err(db_error)?,
|
||||
"source",
|
||||
)?,
|
||||
},
|
||||
history: Vec::new(),
|
||||
created_unix_seconds: to_u64(
|
||||
row.try_get("created_unix_seconds").map_err(db_error)?,
|
||||
"created",
|
||||
)?,
|
||||
updated_unix_seconds: to_u64(
|
||||
row.try_get("updated_unix_seconds").map_err(db_error)?,
|
||||
"updated",
|
||||
)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn row_to_history(row: sqlx::sqlite::SqliteRow) -> Result<GlossaryHistoryRecord> {
|
||||
Ok(GlossaryHistoryRecord {
|
||||
history_id: row.try_get("history_id").map_err(db_error)?,
|
||||
action: row.try_get("action").map_err(db_error)?,
|
||||
reviewer: row.try_get("reviewer").map_err(db_error)?,
|
||||
reason: row.try_get("reason").map_err(db_error)?,
|
||||
source: parse_json(row.try_get("source_json").map_err(db_error)?)?,
|
||||
review_status: parse_review_status(
|
||||
row.try_get::<String, _>("review_status")
|
||||
.map_err(db_error)?
|
||||
.as_str(),
|
||||
)?,
|
||||
snapshot: parse_json(row.try_get("snapshot_json").map_err(db_error)?)?,
|
||||
observed_unix_seconds: to_u64(
|
||||
row.try_get("observed_unix_seconds").map_err(db_error)?,
|
||||
"history",
|
||||
)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_json<T: DeserializeOwned>(value: String) -> Result<T> {
|
||||
serde_json::from_str(&value).map_err(|error| Error::Serialization(error.to_string()))
|
||||
}
|
||||
|
||||
fn json<T: serde::Serialize>(value: &T) -> Result<String> {
|
||||
serde_json::to_string(value).map_err(|error| Error::Serialization(error.to_string()))
|
||||
}
|
||||
|
||||
fn parse_review_status(value: &str) -> Result<GlossaryReviewStatus> {
|
||||
GlossaryReviewStatus::parse(value)
|
||||
.ok_or_else(|| Error::Serialization(format!("未知 Glossary review status:{value}")))
|
||||
}
|
||||
|
||||
fn parse_source_kind(value: &str) -> Result<GlossarySourceKind> {
|
||||
GlossarySourceKind::parse(value)
|
||||
.ok_or_else(|| Error::Serialization(format!("未知 Glossary source kind:{value}")))
|
||||
}
|
||||
|
||||
fn to_u64(value: i64, label: &str) -> Result<u64> {
|
||||
u64::try_from(value).map_err(|_| Error::Serialization(format!("Glossary {label} 时间无效")))
|
||||
}
|
||||
|
||||
fn db_error(error: sqlx::Error) -> Error {
|
||||
Error::Other(error.into())
|
||||
}
|
||||
|
||||
async fn ensure_column(
|
||||
pool: &SqlitePool,
|
||||
table: &str,
|
||||
column: &str,
|
||||
definition: &str,
|
||||
) -> Result<()> {
|
||||
let columns = sqlx::query(&format!("PRAGMA table_info({table})"))
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
let exists = columns.iter().any(|row| {
|
||||
row.try_get::<String, _>("name")
|
||||
.map(|name| name == column)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
if !exists {
|
||||
sqlx::query(&format!(
|
||||
"ALTER TABLE {table} ADD COLUMN {column} {definition}"
|
||||
))
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn draft(status: GlossaryReviewStatus) -> GlossaryTermDraft {
|
||||
GlossaryTermDraft {
|
||||
term_id: "term-sensei".to_string(),
|
||||
definition: GlossaryTermSnapshot {
|
||||
source_term: "Sensei".to_string(),
|
||||
aliases: vec!["Teacher".to_string()],
|
||||
recommended_translation: "老师".to_string(),
|
||||
allowed_translations: vec!["老师大人".to_string()],
|
||||
source_language: Some("en".to_string()),
|
||||
target_language: Some("zh-Hans".to_string()),
|
||||
category: Some("person".to_string()),
|
||||
priority: 10,
|
||||
scope: BTreeMap::new(),
|
||||
},
|
||||
review_status: status,
|
||||
source: GlossarySourceRecord {
|
||||
source_kind: GlossarySourceKind::Manual,
|
||||
source_ref: Some("test".to_string()),
|
||||
source_author: Some("tester".to_string()),
|
||||
source_note: None,
|
||||
observed_unix_seconds: 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sqlite_glossary_preserves_history_and_only_approved_terms_match() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let repository = SqliteGlossaryRepository::new(temp.path().join("glossary.sqlite"))
|
||||
.await
|
||||
.unwrap();
|
||||
repository
|
||||
.add(draft(GlossaryReviewStatus::Draft))
|
||||
.await
|
||||
.unwrap();
|
||||
let before = repository
|
||||
.diagnose("Sensei", &BTreeMap::new())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(before.constraints.is_empty());
|
||||
|
||||
repository
|
||||
.review(
|
||||
"term-sensei",
|
||||
GlossaryReviewStatus::Approved,
|
||||
"reviewer",
|
||||
Some("ok".to_string()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let after = repository
|
||||
.diagnose("Sensei", &BTreeMap::new())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(after.constraints.len(), 1);
|
||||
|
||||
let term = repository.find("term-sensei").await.unwrap();
|
||||
assert_eq!(term.history.len(), 2);
|
||||
assert_eq!(term.history[1].action, "approved");
|
||||
let summary = repository.summary().await.unwrap();
|
||||
assert_eq!(summary.approved_count, 1);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
pub mod cas;
|
||||
mod curl_transfer;
|
||||
pub mod downloader;
|
||||
pub mod glossary;
|
||||
pub mod import;
|
||||
pub mod localized_patch;
|
||||
pub mod official_changes;
|
||||
@@ -43,6 +44,10 @@ pub use downloader::{
|
||||
DownloadResults, DownloadScheduler, DownloaderBackend, DEFAULT_DOWNLOAD_CONCURRENCY,
|
||||
MAX_DOWNLOAD_CONCURRENCY, MIN_DOWNLOAD_CONCURRENCY,
|
||||
};
|
||||
pub use glossary::{
|
||||
SqliteGlossaryRepository, GLOSSARY_REPOSITORY_FILE, GLOSSARY_SCHEMA_COMPONENT,
|
||||
GLOSSARY_SCHEMA_VERSION,
|
||||
};
|
||||
pub use import::{
|
||||
BundleSource, ImportedResource, ResourceImportCategory, ResourceImportReport,
|
||||
ResourceImportService,
|
||||
@@ -169,9 +174,11 @@ pub use translation_workflow::{
|
||||
completed_worker_translation_workbench, export_completed_worker_translation_workbench,
|
||||
export_translation_workbench, get_translation_entry, localized_patch_operations,
|
||||
localized_text_asset_patches, read_translation_workbench, repack_bundle, set_translation,
|
||||
unset_translation, validate_translation_workbench, write_translation_workbench,
|
||||
RepackOperation, RepackReport, RepackSpec, TranslationWorkbench, TranslationWorkbenchEntry,
|
||||
TranslationWorkbenchValidationReport, REPACK_SPEC_VERSION, TRANSLATION_WORKBENCH_VERSION,
|
||||
set_translation_checked, set_translation_checked_with_glossary_path, unset_translation,
|
||||
validate_translation_workbench, validate_translation_workbench_with_glossary_path,
|
||||
write_translation_workbench, RepackOperation, RepackReport, RepackSpec, TranslationWorkbench,
|
||||
TranslationWorkbenchEntry, TranslationWorkbenchValidationReport, REPACK_SPEC_VERSION,
|
||||
TRANSLATION_WORKBENCH_VERSION,
|
||||
};
|
||||
|
||||
/// Infrastructure 版本号
|
||||
|
||||
@@ -99,6 +99,12 @@ pub struct LocalizedPatchOperationMetadata {
|
||||
pub translation_memory_record_id: Option<String>,
|
||||
/// Review state used by the publication input.
|
||||
pub review_status: String,
|
||||
/// Deterministic Glossary QA recorded for this translation.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub glossary_qa: Option<bat_core::domain::GlossaryQaReport>,
|
||||
/// Explicit confirmation for a blocking Glossary deviation.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub glossary_override: Option<bat_core::domain::GlossaryOverride>,
|
||||
}
|
||||
|
||||
/// Configuration for one localized release publication.
|
||||
|
||||
@@ -173,6 +173,12 @@ pub struct TranslationTaskUnitResult {
|
||||
pub provider_run_id: String,
|
||||
/// Result persistence time.
|
||||
pub translated_unix_seconds: u64,
|
||||
/// Deterministic Glossary QA result, when a project Glossary was available.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub glossary_qa: Option<bat_core::domain::GlossaryQaReport>,
|
||||
/// Explicit human confirmation for a blocking Glossary deviation.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub glossary_override: Option<bat_core::domain::GlossaryOverride>,
|
||||
}
|
||||
|
||||
/// Source of one persisted TextUnit translation result.
|
||||
@@ -1808,6 +1814,8 @@ mod tests {
|
||||
provider: "manual".to_string(),
|
||||
provider_run_id: "manual-run-1".to_string(),
|
||||
translated_unix_seconds: 321,
|
||||
glossary_qa: None,
|
||||
glossary_override: None,
|
||||
};
|
||||
|
||||
let updated = repository
|
||||
@@ -1927,6 +1935,8 @@ mod tests {
|
||||
provider: "mock".to_string(),
|
||||
provider_run_id: second_run.clone(),
|
||||
translated_unix_seconds: 1,
|
||||
glossary_qa: None,
|
||||
glossary_override: None,
|
||||
};
|
||||
|
||||
assert!(repository
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! 租约和任务结果写入 release 级 `translation-tasks.sqlite`,跨 release 的
|
||||
//! Translation Memory 写入项目级独立 SQLite 数据库。
|
||||
|
||||
use crate::glossary::SqliteGlossaryRepository;
|
||||
use crate::official_parse::{read_textunit_index_at, OfficialTextUnitIndexUnit};
|
||||
use crate::official_textunit_queue::read_textunit_task_queue_at;
|
||||
use crate::translation_memory::{translation_memory_context, SqliteTranslationMemoryRepository};
|
||||
@@ -14,9 +15,10 @@ use crate::translation_tasks::{
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use bat_core::domain::{
|
||||
TranslationMemoryDraft, TranslationMemorySourceKind, TranslationMemorySourceTrace,
|
||||
GlossaryConstraint, GlossaryQaReport, TranslationMemoryDraft, TranslationMemorySourceKind,
|
||||
TranslationMemorySourceTrace,
|
||||
};
|
||||
use bat_core::repositories::TranslationMemoryRepository;
|
||||
use bat_core::repositories::{GlossaryRepository, TranslationMemoryRepository};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::env;
|
||||
@@ -98,6 +100,9 @@ pub struct TranslationWorkerConfig {
|
||||
/// Translation Memory SQLite path. `None` uses the output-root default.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub translation_memory_path: Option<PathBuf>,
|
||||
/// Project-level Glossary SQLite path. `None` uses the output-root default.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub glossary_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for TranslationWorkerConfig {
|
||||
@@ -112,6 +117,7 @@ impl Default for TranslationWorkerConfig {
|
||||
max_tasks: None,
|
||||
worker_id: format!("bat-worker-{}", std::process::id()),
|
||||
translation_memory_path: None,
|
||||
glossary_path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,6 +186,9 @@ pub struct TranslationProviderUnit {
|
||||
/// 解析器保留的上下文,包括可选 `crowdin_string_id`。
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub context: BTreeMap<String, String>,
|
||||
/// Approved Glossary constraints for this TextUnit.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub glossary_constraints: Vec<GlossaryConstraint>,
|
||||
}
|
||||
|
||||
/// 一次 provider 批处理请求。
|
||||
@@ -596,6 +605,14 @@ pub struct TranslationWorkerReport {
|
||||
pub provider_unit_count: usize,
|
||||
/// Translation Memory diagnostics that did not invalidate provider work.
|
||||
pub translation_memory_failures: Vec<String>,
|
||||
/// Project-level Glossary database path used by this run.
|
||||
pub glossary_path: PathBuf,
|
||||
/// Whether a Glossary database was available.
|
||||
pub glossary_available: bool,
|
||||
/// TextUnits whose Glossary QA blocked automatic reuse or publication.
|
||||
pub glossary_blocked_count: usize,
|
||||
/// Glossary diagnostics that did not abort worker startup.
|
||||
pub glossary_failures: Vec<String>,
|
||||
}
|
||||
|
||||
/// worker 失败诊断。
|
||||
@@ -623,6 +640,8 @@ struct WorkerStats {
|
||||
provider_unit_count: AtomicUsize,
|
||||
failures: Mutex<Vec<TranslationWorkerFailure>>,
|
||||
translation_memory_failures: Mutex<Vec<String>>,
|
||||
glossary_blocked_count: AtomicUsize,
|
||||
glossary_failures: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
struct WorkerTaskContext<'a> {
|
||||
@@ -635,6 +654,7 @@ struct WorkerTaskContext<'a> {
|
||||
retry_backoff: Duration,
|
||||
stats: &'a WorkerStats,
|
||||
translation_memory: Option<&'a dyn TranslationMemoryRepository>,
|
||||
glossary: Option<&'a dyn GlossaryRepository>,
|
||||
}
|
||||
|
||||
/// 运行一个 provider worker 轮次。
|
||||
@@ -715,6 +735,28 @@ async fn run_translation_worker_with_provider_and_cancellation(
|
||||
)),
|
||||
),
|
||||
};
|
||||
let glossary_path = config
|
||||
.glossary_path
|
||||
.clone()
|
||||
.unwrap_or_else(|| SqliteGlossaryRepository::repository_path(resource_root));
|
||||
let (glossary, glossary_startup_failure) = if std::fs::symlink_metadata(&glossary_path).is_ok()
|
||||
{
|
||||
match SqliteGlossaryRepository::open(&glossary_path).await {
|
||||
Ok(repository) => (Some(Arc::new(repository)), None),
|
||||
Err(error) => (
|
||||
None,
|
||||
Some(format!(
|
||||
"打开 Glossary 数据库失败 {}:{error}",
|
||||
glossary_path.display()
|
||||
)),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
if let Some(failure) = glossary_startup_failure.as_deref() {
|
||||
return Err(anyhow::anyhow!(failure.to_string()));
|
||||
}
|
||||
let repository = Arc::new(
|
||||
SqliteTranslationTaskRepository::new(SqliteTranslationTaskRepository::repository_path(
|
||||
resource_root,
|
||||
@@ -754,6 +796,7 @@ async fn run_translation_worker_with_provider_and_cancellation(
|
||||
let lease_seconds = config.lease_seconds;
|
||||
let retry_backoff = config.retry_backoff;
|
||||
let translation_memory = translation_memory.clone();
|
||||
let glossary = glossary.clone();
|
||||
let should_cancel = Arc::clone(&should_cancel);
|
||||
handles.push(tokio::spawn(async move {
|
||||
loop {
|
||||
@@ -791,6 +834,9 @@ async fn run_translation_worker_with_provider_and_cancellation(
|
||||
translation_memory: translation_memory
|
||||
.as_deref()
|
||||
.map(|repository| repository as &dyn TranslationMemoryRepository),
|
||||
glossary: glossary
|
||||
.as_deref()
|
||||
.map(|repository| repository as &dyn GlossaryRepository),
|
||||
},
|
||||
&task,
|
||||
)
|
||||
@@ -842,6 +888,11 @@ async fn run_translation_worker_with_provider_and_cancellation(
|
||||
.lock()
|
||||
.map_err(|_| anyhow::anyhow!("读取 Translation Memory 诊断时 mutex poisoned"))?
|
||||
.clone();
|
||||
let glossary_failures = stats
|
||||
.glossary_failures
|
||||
.lock()
|
||||
.map_err(|_| anyhow::anyhow!("读取 Glossary 诊断时 mutex poisoned"))?
|
||||
.clone();
|
||||
Ok(TranslationWorkerReport {
|
||||
command: "translation-worker",
|
||||
status: if failed_count == 0 {
|
||||
@@ -863,6 +914,10 @@ async fn run_translation_worker_with_provider_and_cancellation(
|
||||
translation_memory_hit_count: stats.translation_memory_hit_count.load(Ordering::Relaxed),
|
||||
provider_unit_count: stats.provider_unit_count.load(Ordering::Relaxed),
|
||||
translation_memory_failures,
|
||||
glossary_path,
|
||||
glossary_available: glossary.is_some(),
|
||||
glossary_blocked_count: stats.glossary_blocked_count.load(Ordering::Relaxed),
|
||||
glossary_failures,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -873,35 +928,99 @@ async fn process_claimed_task(
|
||||
let task_units = task_index_units(task, context.index)?;
|
||||
let mut results = BTreeMap::new();
|
||||
let mut provider_units = Vec::new();
|
||||
let mut glossary_evaluations = BTreeMap::new();
|
||||
for unit in &task_units {
|
||||
let source_context = 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,
|
||||
);
|
||||
let glossary_evaluation = if let Some(glossary) = context.glossary {
|
||||
match glossary.evaluate(&unit.source_text, &source_context).await {
|
||||
Ok(evaluation) => evaluation,
|
||||
Err(error) => {
|
||||
record_glossary_failure(
|
||||
context,
|
||||
format!(
|
||||
"任务 {} TextUnit {} 查询失败:{}",
|
||||
task.task.task_id, unit.id, error
|
||||
),
|
||||
)?;
|
||||
record_provider_failure(
|
||||
context,
|
||||
task,
|
||||
TranslationProviderError::new(
|
||||
TranslationProviderFailureClass::InvalidRequest,
|
||||
format!("TextUnit {} 无法完成 Glossary QA;自动翻译已阻止", unit.id),
|
||||
),
|
||||
&results,
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
bat_core::domain::GlossaryEvaluation {
|
||||
constraints: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
blocked: false,
|
||||
}
|
||||
};
|
||||
if glossary_evaluation.blocked {
|
||||
context
|
||||
.stats
|
||||
.glossary_blocked_count
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
record_provider_failure(
|
||||
context,
|
||||
task,
|
||||
TranslationProviderError::new(
|
||||
TranslationProviderFailureClass::InvalidRequest,
|
||||
format!(
|
||||
"TextUnit {} 的 Glossary 存在未解决冲突,必须人工确认后才能继续",
|
||||
unit.id
|
||||
),
|
||||
),
|
||||
&results,
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
glossary_evaluations.insert(unit.id.clone(), glossary_evaluation);
|
||||
if let Some(translation_memory) = context.translation_memory {
|
||||
let source_context = 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,
|
||||
);
|
||||
match translation_memory
|
||||
.find_matches(&unit.source_text, &source_context, 1)
|
||||
.await
|
||||
{
|
||||
Ok(matches) => {
|
||||
if let Some(found) = matches.into_iter().find(|item| item.can_auto_reuse) {
|
||||
context
|
||||
.stats
|
||||
.translation_memory_hit_count
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
results.insert(
|
||||
unit.id.clone(),
|
||||
translation_memory_result(task, unit, &found.entry),
|
||||
);
|
||||
continue;
|
||||
let qa = glossary_evaluations
|
||||
.get(&unit.id)
|
||||
.expect("Glossary evaluation inserted before TM lookup")
|
||||
.check_translation(&found.entry.translated_text);
|
||||
if qa.status.is_blocked() {
|
||||
context
|
||||
.stats
|
||||
.glossary_blocked_count
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
} else {
|
||||
context
|
||||
.stats
|
||||
.translation_memory_hit_count
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
results.insert(
|
||||
unit.id.clone(),
|
||||
translation_memory_result(task, unit, &found.entry, qa),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -923,7 +1042,7 @@ async fn process_claimed_task(
|
||||
.stats
|
||||
.provider_unit_count
|
||||
.fetch_add(provider_units.len(), Ordering::Relaxed);
|
||||
let request = match provider_request(task, &provider_units) {
|
||||
let request = match provider_request(task, &provider_units, &glossary_evaluations) {
|
||||
Ok(request) => request,
|
||||
Err(error) => {
|
||||
record_provider_failure(
|
||||
@@ -941,23 +1060,48 @@ async fn process_claimed_task(
|
||||
};
|
||||
match context.provider.translate(request.clone()).await {
|
||||
Ok(response) => {
|
||||
let provider_results =
|
||||
match validate_provider_response(&request, response, context.provider_name) {
|
||||
Ok(results) => results,
|
||||
Err(error) => {
|
||||
record_provider_failure(
|
||||
context,
|
||||
task,
|
||||
TranslationProviderError::new(
|
||||
TranslationProviderFailureClass::InvalidRequest,
|
||||
error.to_string(),
|
||||
),
|
||||
&results,
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let provider_results = match validate_provider_response(
|
||||
&request,
|
||||
response,
|
||||
context.provider_name,
|
||||
&glossary_evaluations,
|
||||
) {
|
||||
Ok(results) => results,
|
||||
Err(error) => {
|
||||
record_provider_failure(
|
||||
context,
|
||||
task,
|
||||
TranslationProviderError::new(
|
||||
TranslationProviderFailureClass::InvalidRequest,
|
||||
error.to_string(),
|
||||
),
|
||||
&results,
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if provider_results.iter().any(|result| {
|
||||
result
|
||||
.glossary_qa
|
||||
.as_ref()
|
||||
.is_some_and(|qa| qa.status.is_blocked())
|
||||
}) {
|
||||
for result in &provider_results {
|
||||
results.insert(result.unit_id.clone(), result.clone());
|
||||
}
|
||||
record_provider_failure(
|
||||
context,
|
||||
task,
|
||||
TranslationProviderError::new(
|
||||
TranslationProviderFailureClass::InvalidRequest,
|
||||
"provider 译文未通过 Glossary QA;需要人工 override 后才能发布",
|
||||
),
|
||||
&results,
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
for result in &provider_results {
|
||||
results.insert(result.unit_id.clone(), result.clone());
|
||||
if let Some(unit) = provider_units.iter().find(|unit| unit.id == result.unit_id)
|
||||
@@ -986,6 +1130,13 @@ async fn process_claimed_task(
|
||||
observed_unix_seconds: unix_seconds_now(),
|
||||
};
|
||||
if let Some(translation_memory) = context.translation_memory {
|
||||
if results
|
||||
.get(&result.unit_id)
|
||||
.and_then(|value| value.glossary_qa.as_ref())
|
||||
.is_some_and(|qa| qa.status.is_blocked())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Err(error) = translation_memory.upsert_candidate(draft).await {
|
||||
record_translation_memory_failure(
|
||||
context,
|
||||
@@ -1066,6 +1217,7 @@ fn task_index_units<'a>(
|
||||
fn provider_request(
|
||||
task: &PersistedTranslationTask,
|
||||
index_units: &[&OfficialTextUnitIndexUnit],
|
||||
glossary_evaluations: &BTreeMap<String, bat_core::domain::GlossaryEvaluation>,
|
||||
) -> anyhow::Result<TranslationProviderRequest> {
|
||||
let provider_run_id = task
|
||||
.provider_run_id
|
||||
@@ -1079,7 +1231,16 @@ fn provider_request(
|
||||
archive_entry: task.task.archive_entry.clone(),
|
||||
units: index_units
|
||||
.iter()
|
||||
.map(|unit| provider_unit(task, unit))
|
||||
.map(|unit| {
|
||||
provider_unit(
|
||||
task,
|
||||
unit,
|
||||
glossary_evaluations
|
||||
.get(&unit.id)
|
||||
.map(|evaluation| evaluation.constraints.clone())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
@@ -1087,6 +1248,7 @@ fn provider_request(
|
||||
fn provider_unit(
|
||||
task: &PersistedTranslationTask,
|
||||
unit: &OfficialTextUnitIndexUnit,
|
||||
glossary_constraints: Vec<GlossaryConstraint>,
|
||||
) -> TranslationProviderUnit {
|
||||
TranslationProviderUnit {
|
||||
unit_id: unit.id.clone(),
|
||||
@@ -1103,6 +1265,7 @@ fn provider_unit(
|
||||
text_source_kind: unit.text_source_kind.clone(),
|
||||
asset_name: unit.asset_name.clone(),
|
||||
context: unit.context.clone(),
|
||||
glossary_constraints,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1110,6 +1273,7 @@ fn validate_provider_response(
|
||||
request: &TranslationProviderRequest,
|
||||
response: TranslationProviderResponse,
|
||||
provider_name: &str,
|
||||
glossary_evaluations: &BTreeMap<String, bat_core::domain::GlossaryEvaluation>,
|
||||
) -> anyhow::Result<Vec<TranslationTaskUnitResult>> {
|
||||
if response.provider_run_id != request.provider_run_id {
|
||||
return Err(anyhow::anyhow!(
|
||||
@@ -1150,6 +1314,9 @@ fn validate_provider_response(
|
||||
result.unit_id
|
||||
));
|
||||
}
|
||||
let glossary_qa = glossary_evaluations
|
||||
.get(&result.unit_id)
|
||||
.map(|evaluation| evaluation.check_translation(&result.translated_text));
|
||||
results.push(TranslationTaskUnitResult {
|
||||
unit_id: result.unit_id,
|
||||
source_text: result.source_text,
|
||||
@@ -1159,6 +1326,8 @@ fn validate_provider_response(
|
||||
provider: provider_name.to_string(),
|
||||
provider_run_id: request.provider_run_id.clone(),
|
||||
translated_unix_seconds: unix_seconds_now(),
|
||||
glossary_qa,
|
||||
glossary_override: None,
|
||||
});
|
||||
}
|
||||
if seen.len() != expected.len() {
|
||||
@@ -1175,6 +1344,7 @@ fn translation_memory_result(
|
||||
task: &PersistedTranslationTask,
|
||||
unit: &OfficialTextUnitIndexUnit,
|
||||
entry: &bat_core::domain::TranslationMemoryEntry,
|
||||
glossary_qa: GlossaryQaReport,
|
||||
) -> TranslationTaskUnitResult {
|
||||
TranslationTaskUnitResult {
|
||||
unit_id: unit.id.clone(),
|
||||
@@ -1185,6 +1355,8 @@ fn translation_memory_result(
|
||||
provider: "translation_memory".to_string(),
|
||||
provider_run_id: task.provider_run_id.clone().unwrap_or_default(),
|
||||
translated_unix_seconds: unix_seconds_now(),
|
||||
glossary_qa: Some(glossary_qa),
|
||||
glossary_override: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1222,6 +1394,16 @@ fn record_translation_memory_failure(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn record_glossary_failure(context: &WorkerTaskContext<'_>, message: String) -> anyhow::Result<()> {
|
||||
context
|
||||
.stats
|
||||
.glossary_failures
|
||||
.lock()
|
||||
.map_err(|_| anyhow::anyhow!("写入 Glossary 诊断时 mutex poisoned"))?
|
||||
.push(message);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn record_provider_failure(
|
||||
context: &WorkerTaskContext<'_>,
|
||||
task: &PersistedTranslationTask,
|
||||
@@ -1455,6 +1637,122 @@ mod tests {
|
||||
assert_eq!(task.translation_results[0].translated_text, "translated-0");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_sends_approved_glossary_constraints_and_persists_qa() {
|
||||
let (temp, queue) = fixture_root();
|
||||
let textunit_index = index(temp.path());
|
||||
crate::official_textunit_queue::write_textunit_task_queue_at(temp.path(), &queue).unwrap();
|
||||
crate::official_parse::write_textunit_index_at(temp.path(), &textunit_index).unwrap();
|
||||
|
||||
let glossary_path = temp.path().join("glossary.sqlite");
|
||||
let glossary = SqliteGlossaryRepository::new(&glossary_path).await.unwrap();
|
||||
glossary
|
||||
.add(bat_core::domain::GlossaryTermDraft {
|
||||
term_id: "term-source-0".to_string(),
|
||||
definition: bat_core::domain::GlossaryTermSnapshot {
|
||||
source_term: "source-0".to_string(),
|
||||
aliases: Vec::new(),
|
||||
recommended_translation: "term-0".to_string(),
|
||||
allowed_translations: Vec::new(),
|
||||
source_language: Some("en".to_string()),
|
||||
target_language: Some("zh-Hans".to_string()),
|
||||
category: Some("test".to_string()),
|
||||
priority: 10,
|
||||
scope: BTreeMap::new(),
|
||||
},
|
||||
review_status: bat_core::domain::GlossaryReviewStatus::Approved,
|
||||
source: bat_core::domain::GlossarySourceRecord {
|
||||
source_kind: bat_core::domain::GlossarySourceKind::Manual,
|
||||
source_ref: Some("worker-test".to_string()),
|
||||
source_author: Some("test".to_string()),
|
||||
source_note: None,
|
||||
observed_unix_seconds: 1,
|
||||
},
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
#[derive(Debug)]
|
||||
struct GlossaryProvider {
|
||||
requests: Arc<Mutex<Vec<TranslationProviderRequest>>>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl TranslationProvider for GlossaryProvider {
|
||||
fn name(&self) -> &'static str {
|
||||
"glossary-test"
|
||||
}
|
||||
|
||||
async fn translate(
|
||||
&self,
|
||||
request: TranslationProviderRequest,
|
||||
) -> Result<TranslationProviderResponse, TranslationProviderError> {
|
||||
self.requests.lock().unwrap().push(request.clone());
|
||||
Ok(TranslationProviderResponse {
|
||||
provider_run_id: request.provider_run_id,
|
||||
units: request
|
||||
.units
|
||||
.into_iter()
|
||||
.map(|unit| TranslationProviderUnitResult {
|
||||
unit_id: unit.unit_id,
|
||||
source_text: unit.source_text.clone(),
|
||||
translated_text: if unit.source_text == "source-0" {
|
||||
"term-0".to_string()
|
||||
} else {
|
||||
"translated-1".to_string()
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||
let config = TranslationWorkerConfig {
|
||||
glossary_path: Some(glossary_path),
|
||||
concurrency: 1,
|
||||
retry_backoff: Duration::ZERO,
|
||||
..TranslationWorkerConfig::default()
|
||||
};
|
||||
let report = run_translation_worker_with_provider(
|
||||
temp.path(),
|
||||
&config,
|
||||
Arc::new(GlossaryProvider {
|
||||
requests: Arc::clone(&requests),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(report.completed_count, 1);
|
||||
assert!(report.glossary_available);
|
||||
assert_eq!(report.glossary_blocked_count, 0);
|
||||
{
|
||||
let requests = requests.lock().unwrap();
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0].units[0].glossary_constraints.len(), 1);
|
||||
assert_eq!(
|
||||
requests[0].units[0].glossary_constraints[0].term_id,
|
||||
"term-source-0"
|
||||
);
|
||||
}
|
||||
|
||||
let repository = SqliteTranslationTaskRepository::open(
|
||||
SqliteTranslationTaskRepository::repository_path(temp.path()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let task = repository.find(&queue.tasks[0].task_id).await.unwrap();
|
||||
assert_eq!(task.translation_results[0].translated_text, "term-0");
|
||||
assert_eq!(
|
||||
task.translation_results[0]
|
||||
.glossary_qa
|
||||
.as_ref()
|
||||
.map(|qa| qa.status),
|
||||
Some(bat_core::domain::GlossaryQaStatus::Pass)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_honors_cancellation_before_claiming_tasks() {
|
||||
let (temp, queue) = fixture_root();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Manual translation workbench and controlled UnityFS repack workflows.
|
||||
|
||||
use crate::glossary::SqliteGlossaryRepository;
|
||||
use crate::official_parse::{read_textunit_index_at, OfficialTextUnitIndexUnit};
|
||||
use crate::official_textunit_queue::OfficialTextUnitTaskQuery;
|
||||
use crate::path_security::{
|
||||
@@ -15,6 +16,7 @@ use bat_assetbundle::{
|
||||
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch,
|
||||
StringFieldPatch, TextAssetPatch, UnitySerializedReplacementValue,
|
||||
};
|
||||
use bat_core::domain::{GlossaryOverride, GlossaryQaReport};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -90,6 +92,12 @@ pub struct TranslationWorkbenchEntry {
|
||||
/// Extraction source kind such as TextAsset or TypeTreeField.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub text_source_kind: Option<String>,
|
||||
/// Deterministic Glossary QA for the current translation.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub glossary_qa: Option<GlossaryQaReport>,
|
||||
/// Explicit human confirmation for a blocking Glossary deviation.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub glossary_override: Option<GlossaryOverride>,
|
||||
}
|
||||
|
||||
/// Summary produced by `i18n validate`.
|
||||
@@ -274,6 +282,73 @@ pub fn set_translation(
|
||||
.find(|entry| entry.id == entry_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit:{entry_id}"))?;
|
||||
entry.translated_text = Some(translated_text);
|
||||
entry.glossary_qa = None;
|
||||
entry.glossary_override = None;
|
||||
let updated = entry.clone();
|
||||
workbench.generated_unix_seconds = unix_seconds_now();
|
||||
write_translation_workbench(workbench_path, &workbench)?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// Updates one translation and evaluates the project Glossary.
|
||||
pub fn set_translation_checked(
|
||||
resource_root: &Path,
|
||||
workbench_path: &Path,
|
||||
entry_id: &str,
|
||||
translated_text: String,
|
||||
glossary_override: Option<GlossaryOverride>,
|
||||
) -> anyhow::Result<TranslationWorkbenchEntry> {
|
||||
set_translation_checked_with_glossary_path(
|
||||
resource_root,
|
||||
workbench_path,
|
||||
entry_id,
|
||||
translated_text,
|
||||
glossary_override,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Updates one translation using an optional configured Glossary path.
|
||||
pub fn set_translation_checked_with_glossary_path(
|
||||
resource_root: &Path,
|
||||
workbench_path: &Path,
|
||||
entry_id: &str,
|
||||
translated_text: String,
|
||||
glossary_override: Option<GlossaryOverride>,
|
||||
configured_glossary_path: Option<&Path>,
|
||||
) -> anyhow::Result<TranslationWorkbenchEntry> {
|
||||
let mut workbench = read_translation_workbench(workbench_path)?;
|
||||
let current = read_textunit_index_at(resource_root)
|
||||
.map_err(anyhow::Error::msg)?
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少当前官方 TextUnit 索引"))?
|
||||
.units
|
||||
.into_iter()
|
||||
.find(|unit| unit.id == entry_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("当前 release 不存在 TextUnit:{entry_id}"))?;
|
||||
let glossary_path = configured_glossary_path
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| SqliteGlossaryRepository::repository_path(resource_root));
|
||||
let glossary = open_glossary_if_present(&glossary_path)?;
|
||||
let qa = glossary
|
||||
.as_ref()
|
||||
.map(|glossary| evaluate_glossary_entry(glossary, ¤t, &translated_text))
|
||||
.transpose()?;
|
||||
if let Some(qa) = qa.as_ref().filter(|qa| qa.status.is_blocked()) {
|
||||
validate_glossary_override(glossary_override.as_ref())?;
|
||||
let _ = qa;
|
||||
} else if glossary_override.is_some() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Glossary override 只能用于存在 blocking QA 的译文"
|
||||
));
|
||||
}
|
||||
let entry = workbench
|
||||
.entries
|
||||
.iter_mut()
|
||||
.find(|entry| entry.id == entry_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit:{entry_id}"))?;
|
||||
entry.translated_text = Some(translated_text);
|
||||
entry.glossary_qa = qa;
|
||||
entry.glossary_override = glossary_override;
|
||||
let updated = entry.clone();
|
||||
workbench.generated_unix_seconds = unix_seconds_now();
|
||||
write_translation_workbench(workbench_path, &workbench)?;
|
||||
@@ -305,6 +380,8 @@ pub fn unset_translation(
|
||||
.find(|entry| entry.id == entry_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit:{entry_id}"))?;
|
||||
entry.translated_text = None;
|
||||
entry.glossary_qa = None;
|
||||
entry.glossary_override = None;
|
||||
let updated = entry.clone();
|
||||
workbench.generated_unix_seconds = unix_seconds_now();
|
||||
write_translation_workbench(workbench_path, &workbench)?;
|
||||
@@ -320,6 +397,21 @@ pub fn validate_translation_workbench(
|
||||
resource_root: &Path,
|
||||
official_release_id: &str,
|
||||
workbench: &TranslationWorkbench,
|
||||
) -> anyhow::Result<TranslationWorkbenchValidationReport> {
|
||||
validate_translation_workbench_with_glossary_path(
|
||||
resource_root,
|
||||
official_release_id,
|
||||
workbench,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Validates a workbench using an optional configured Glossary path.
|
||||
pub fn validate_translation_workbench_with_glossary_path(
|
||||
resource_root: &Path,
|
||||
official_release_id: &str,
|
||||
workbench: &TranslationWorkbench,
|
||||
configured_glossary_path: Option<&Path>,
|
||||
) -> anyhow::Result<TranslationWorkbenchValidationReport> {
|
||||
let expected_root = lexical_absolute(resource_root).map_err(anyhow::Error::msg)?;
|
||||
if workbench.official_release_id != official_release_id {
|
||||
@@ -349,6 +441,10 @@ pub fn validate_translation_workbench(
|
||||
let mut changed_entries = 0;
|
||||
let mut publishable_entries = 0;
|
||||
let mut repack_entries = 0;
|
||||
let glossary_path = configured_glossary_path
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| SqliteGlossaryRepository::repository_path(resource_root));
|
||||
let glossary = open_glossary_if_present(&glossary_path)?;
|
||||
|
||||
for entry in &workbench.entries {
|
||||
if !seen_ids.insert(entry.id.as_str()) {
|
||||
@@ -366,6 +462,12 @@ pub fn validate_translation_workbench(
|
||||
unchanged_entries += 1;
|
||||
continue;
|
||||
}
|
||||
if let Some(glossary) = glossary.as_ref() {
|
||||
let qa = evaluate_glossary_entry(glossary, current, translated_text)?;
|
||||
if qa.status.is_blocked() {
|
||||
validate_glossary_override(entry.glossary_override.as_ref())?;
|
||||
}
|
||||
}
|
||||
changed_entries += 1;
|
||||
let source_kind = normalized_text_source_kind(entry.text_source_kind.as_deref());
|
||||
let is_publishable = entry.archive_entry.is_none()
|
||||
@@ -419,6 +521,63 @@ pub fn validate_translation_workbench(
|
||||
})
|
||||
}
|
||||
|
||||
fn open_glossary_if_present(path: &Path) -> anyhow::Result<Option<SqliteGlossaryRepository>> {
|
||||
if std::fs::symlink_metadata(path).is_err() {
|
||||
return Ok(None);
|
||||
}
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
runtime
|
||||
.block_on(SqliteGlossaryRepository::open(path))
|
||||
.map(Some)
|
||||
.map_err(|error| anyhow::anyhow!("打开 Glossary 数据库失败:{error}"))
|
||||
}
|
||||
|
||||
fn evaluate_glossary_entry(
|
||||
glossary: &SqliteGlossaryRepository,
|
||||
unit: &OfficialTextUnitIndexUnit,
|
||||
translated_text: &str,
|
||||
) -> anyhow::Result<GlossaryQaReport> {
|
||||
let context = crate::translation_memory::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,
|
||||
);
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
runtime
|
||||
.block_on(glossary.diagnose(&unit.source_text, &context))
|
||||
.map(|evaluation| evaluation.check_translation(translated_text))
|
||||
.map_err(|error| anyhow::anyhow!("执行 Glossary QA 失败:{error}"))
|
||||
}
|
||||
|
||||
fn validate_glossary_override(glossary_override: Option<&GlossaryOverride>) -> anyhow::Result<()> {
|
||||
let Some(glossary_override) = glossary_override else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Glossary QA blocked;需要 reviewer、reason 和 provenance 显式确认"
|
||||
));
|
||||
};
|
||||
if glossary_override.reviewer.trim().is_empty()
|
||||
|| glossary_override.reason.trim().is_empty()
|
||||
|| glossary_override.provenance.trim().is_empty()
|
||||
|| glossary_override.confirmed_unix_seconds == 0
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Glossary override 的 reviewer、reason、provenance 和 confirmed_unix_seconds 必须有效"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Converts reviewed entries to localized patch operations supported by the
|
||||
/// current UnityFS write layer.
|
||||
///
|
||||
@@ -628,6 +787,8 @@ fn localized_patch_metadata(entry: &TranslationWorkbenchEntry) -> LocalizedPatch
|
||||
.review_status
|
||||
.clone()
|
||||
.unwrap_or_else(|| "manual_reviewed".to_string()),
|
||||
glossary_qa: entry.glossary_qa.clone(),
|
||||
glossary_override: entry.glossary_override.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -911,6 +1072,8 @@ fn workbench_entry_from_worker_result(
|
||||
entry.translation_source_kind = Some(result.source_kind.as_str().to_string());
|
||||
entry.translation_memory_record_id = result.translation_memory_record_id.clone();
|
||||
entry.translated_unix_seconds = Some(result.translated_unix_seconds);
|
||||
entry.glossary_qa = result.glossary_qa.clone();
|
||||
entry.glossary_override = result.glossary_override.clone();
|
||||
entry.review_status = Some(
|
||||
match result.source_kind {
|
||||
TranslationTaskResultSourceKind::Provider => "provider_completed",
|
||||
@@ -964,6 +1127,8 @@ impl TranslationWorkbenchEntry {
|
||||
review_status: None,
|
||||
format: unit.format.clone(),
|
||||
text_source_kind: unit.text_source_kind.clone(),
|
||||
glossary_qa: None,
|
||||
glossary_override: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1003,6 +1168,8 @@ mod tests {
|
||||
review_status: None,
|
||||
format: Some("plain".to_string()),
|
||||
text_source_kind: Some("text_asset".to_string()),
|
||||
glossary_qa: None,
|
||||
glossary_override: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user