mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 13:54:53 +08:00
feat(translation): add Rust Translation Memory and config migration
This commit is contained in:
+653
-208
File diff suppressed because it is too large
Load Diff
@@ -17,9 +17,11 @@ fn parse_with_env(values: &[&str], env: &[(&str, &str)]) -> anyhow::Result<CliOp
|
||||
.iter()
|
||||
.map(|(key, value)| (key.to_string(), value.to_string()))
|
||||
.collect();
|
||||
parse_args_with_env(values.iter().map(|value| value.to_string()), move |key| {
|
||||
map.get(key).cloned()
|
||||
})
|
||||
parse_args_with_env(
|
||||
values.iter().map(|value| value.to_string()),
|
||||
move |key| map.get(key).cloned(),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -105,7 +107,7 @@ fn env_watch_daemon_only_affect_bare_run() {
|
||||
|
||||
#[test]
|
||||
fn env_values_do_not_break_status_and_reload_guard() {
|
||||
// .env 提供的代理/工具/输出目录不算"显式同步参数",status 应照常可用。
|
||||
// 配置文件/环境变量提供的代理/工具/输出目录不算"显式同步参数",status 应照常可用。
|
||||
let options = parse_with_env(
|
||||
&["bat", "status"],
|
||||
&[
|
||||
@@ -222,6 +224,148 @@ fn translation_worker_command_options_are_validated() {
|
||||
assert!(parse(&["bat", "i18n", "worker", "run", "--provider", "unknown"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translation_memory_commands_parse_and_validate() {
|
||||
let summary = parse(&["bat", "i18n", "memory", "summary"]).unwrap();
|
||||
assert_eq!(summary.command, CliCommand::TranslationMemorySummary);
|
||||
let query = parse(&[
|
||||
"bat",
|
||||
"i18n",
|
||||
"tm",
|
||||
"query",
|
||||
"--tm-source-text",
|
||||
"Hello",
|
||||
"--tm-context-json",
|
||||
r#"{"destination":"story.bundle"}"#,
|
||||
"--limit",
|
||||
"5",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(query.command, CliCommand::TranslationMemoryQuery);
|
||||
assert_eq!(
|
||||
query.translation_memory_source_text.as_deref(),
|
||||
Some("Hello")
|
||||
);
|
||||
let confirm = parse(&[
|
||||
"bat",
|
||||
"i18n",
|
||||
"memory",
|
||||
"confirm",
|
||||
"--tm-record-id",
|
||||
"tm-record",
|
||||
"--tm-reviewer",
|
||||
"reviewer",
|
||||
"--tm-reason",
|
||||
"accepted",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(confirm.command, CliCommand::TranslationMemoryConfirm);
|
||||
assert!(parse(&["bat", "i18n", "memory", "query"]).is_err());
|
||||
assert!(parse(&[
|
||||
"bat",
|
||||
"i18n",
|
||||
"memory",
|
||||
"confirm",
|
||||
"--tm-record-id",
|
||||
"tm-record"
|
||||
])
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_reload_accept_translation_worker_startup_options() {
|
||||
let restart = parse(&[
|
||||
"bat",
|
||||
"restart",
|
||||
"--translation-provider",
|
||||
"mock",
|
||||
"--translation-memory-path",
|
||||
"/tmp/tm.sqlite",
|
||||
"--worker-concurrency",
|
||||
"2",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(restart.command, CliCommand::Restart);
|
||||
assert!(restart.translation_worker_option_explicit);
|
||||
assert_eq!(
|
||||
restart.translation_memory_path,
|
||||
Some(PathBuf::from("/tmp/tm.sqlite"))
|
||||
);
|
||||
assert_eq!(restart.worker_concurrency, 2);
|
||||
assert!(restart.config.auto_discover);
|
||||
|
||||
let reload = parse(&["bat", "reload", "--worker-id", "reload-worker"]).unwrap();
|
||||
assert_eq!(reload.command, CliCommand::Reload);
|
||||
assert!(reload.translation_worker_option_explicit);
|
||||
assert_eq!(reload.worker_id.as_deref(), Some("reload-worker"));
|
||||
assert!(reload.config.auto_discover);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translation_memory_command_options_do_not_apply_to_other_commands() {
|
||||
let error = parse(&["bat", "restart", "--tm-source-text", "Hello"]).unwrap_err();
|
||||
assert!(error.to_string().contains("查询/confirm"));
|
||||
|
||||
let error = parse(&["bat", "status", "--tm-record-id", "tm-record"]).unwrap_err();
|
||||
assert!(error.to_string().contains("查询/confirm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translation_memory_subcommands_reject_irrelevant_options() {
|
||||
let error = parse(&[
|
||||
"bat",
|
||||
"i18n",
|
||||
"memory",
|
||||
"summary",
|
||||
"--tm-source-text",
|
||||
"Hello",
|
||||
])
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("summary"));
|
||||
let error = parse(&["bat", "i18n", "memory", "summary", "--limit", "5"]).unwrap_err();
|
||||
assert!(error.to_string().contains("summary"));
|
||||
|
||||
let error = parse(&[
|
||||
"bat",
|
||||
"i18n",
|
||||
"memory",
|
||||
"query",
|
||||
"--tm-source-text",
|
||||
"Hello",
|
||||
"--tm-record-id",
|
||||
"tm-record",
|
||||
])
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("confirm 参数"));
|
||||
let error = parse(&[
|
||||
"bat",
|
||||
"i18n",
|
||||
"memory",
|
||||
"query",
|
||||
"--tm-source-text",
|
||||
"Hello",
|
||||
"--offset",
|
||||
"1",
|
||||
])
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("查询过滤"));
|
||||
|
||||
let error = parse(&[
|
||||
"bat",
|
||||
"i18n",
|
||||
"memory",
|
||||
"confirm",
|
||||
"--tm-record-id",
|
||||
"tm-record",
|
||||
"--tm-reviewer",
|
||||
"reviewer",
|
||||
"--tm-context-json",
|
||||
"{}",
|
||||
])
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("query 参数"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translation_worker_env_defaults_apply() {
|
||||
let options = parse_with_env(
|
||||
@@ -229,6 +373,7 @@ fn translation_worker_env_defaults_apply() {
|
||||
&[
|
||||
("BAT_TRANSLATION_PROVIDER", "mock"),
|
||||
("BAT_TRANSLATION_FIXTURE", "/tmp/fixture.json"),
|
||||
("BAT_TRANSLATION_MEMORY_PATH", "/tmp/tm.sqlite"),
|
||||
("BAT_TRANSLATION_CONCURRENCY", "16"),
|
||||
("BAT_TRANSLATION_MAX_ATTEMPTS", "5"),
|
||||
("BAT_TRANSLATION_LEASE_SECONDS", "120"),
|
||||
@@ -244,6 +389,10 @@ fn translation_worker_env_defaults_apply() {
|
||||
options.translation_fixture,
|
||||
Some(PathBuf::from("/tmp/fixture.json"))
|
||||
);
|
||||
assert_eq!(
|
||||
options.translation_memory_path,
|
||||
Some(PathBuf::from("/tmp/tm.sqlite"))
|
||||
);
|
||||
assert_eq!(options.worker_concurrency, 16);
|
||||
assert_eq!(options.worker_max_attempts, 5);
|
||||
assert_eq!(options.worker_lease_seconds, 120);
|
||||
@@ -610,6 +759,8 @@ fn translation_workbench_commands_read_update_and_clear_entries() {
|
||||
translated_text: None,
|
||||
translation_provider: None,
|
||||
provider_run_id: None,
|
||||
translation_source_kind: None,
|
||||
translation_memory_record_id: None,
|
||||
translated_unix_seconds: None,
|
||||
review_status: None,
|
||||
format: Some("plain".to_string()),
|
||||
@@ -1030,46 +1181,72 @@ fn cli_download_concurrency_is_preserved_for_daemon_child() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_env_line_handles_quotes_and_rejects_bad_keys() {
|
||||
assert_eq!(
|
||||
parse_env_line("KEY=value"),
|
||||
Some(("KEY".to_string(), "value".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_env_line("KEY=\"quoted value\""),
|
||||
Some(("KEY".to_string(), "quoted value".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_env_line("KEY='single'"),
|
||||
Some(("KEY".to_string(), "single".to_string()))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_env_line("BAT_OUTPUT = ./x"),
|
||||
Some(("BAT_OUTPUT".to_string(), "./x".to_string()))
|
||||
);
|
||||
assert_eq!(parse_env_line("no_equals_sign"), None);
|
||||
assert_eq!(parse_env_line("1BAD=x"), None);
|
||||
assert_eq!(parse_env_line("BAD KEY=x"), None);
|
||||
}
|
||||
fn config_file_is_applied_before_env_and_cli() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let config_path = temp.path().join(super::config_file::CONFIG_FILE_NAME);
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
r#"
|
||||
[runtime]
|
||||
state_dir = '/srv/state'
|
||||
output_format = 'json'
|
||||
|
||||
#[test]
|
||||
fn env_template_is_parseable_and_bootstrap_ready() {
|
||||
// 模板每个非注释行必须可解析;无参启动所需的最小配置默认启用。
|
||||
let mut keys = Vec::new();
|
||||
for line in ENV_TEMPLATE.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let (key, _) = parse_env_line(line).unwrap_or_else(|| panic!("模板行必须可解析:{line}"));
|
||||
keys.push(key);
|
||||
[resource]
|
||||
output_root = '/srv/from-config'
|
||||
auto_discover = true
|
||||
|
||||
[localized]
|
||||
output_root = '/srv/from-config-localized'
|
||||
|
||||
[network]
|
||||
proxy = 'none'
|
||||
download_concurrency = 12
|
||||
|
||||
[translation.worker]
|
||||
translation_memory_path = '/srv/config-tm.sqlite'
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut permissions = std::fs::metadata(&config_path).unwrap().permissions();
|
||||
permissions.set_mode(0o600);
|
||||
std::fs::set_permissions(&config_path, permissions).unwrap();
|
||||
}
|
||||
assert!(keys.contains(&"BAT_OUTPUT".to_string()));
|
||||
assert!(keys.contains(&"BAT_LOCALIZED_OUTPUT".to_string()));
|
||||
assert!(keys.contains(&"BAT_IMPORT_REPOSITORY".to_string()));
|
||||
assert!(keys.contains(&"BAT_IMPORT_CAS_ROOT".to_string()));
|
||||
assert!(keys.contains(&"BAT_IMPORT_RESOURCE_DB".to_string()));
|
||||
assert!(keys.contains(&"BAT_AUTO_DISCOVER".to_string()));
|
||||
let config = super::config_file::load_from_binary_dir(temp.path())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let options = parse_args_with_env(
|
||||
vec![
|
||||
"bat".to_string(),
|
||||
"--daemon".to_string(),
|
||||
"--output".to_string(),
|
||||
"/cli/output".to_string(),
|
||||
"--translation-memory-path".to_string(),
|
||||
"/cli/tm.sqlite".to_string(),
|
||||
],
|
||||
|key| match key {
|
||||
"BAT_OUTPUT" => Some("/env/output".to_string()),
|
||||
"BAT_TRANSLATION_MEMORY_PATH" => Some("/env/tm.sqlite".to_string()),
|
||||
_ => None,
|
||||
},
|
||||
Some(&config),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(options.state_dir, PathBuf::from("/srv/state"));
|
||||
assert_eq!(options.output_format, OutputFormat::Json);
|
||||
assert_eq!(options.config.output_root, PathBuf::from("/cli/output"));
|
||||
assert_eq!(
|
||||
options.config.localized_output_root,
|
||||
PathBuf::from("/srv/from-config-localized")
|
||||
);
|
||||
assert_eq!(options.config.download_concurrency, 12);
|
||||
assert_eq!(options.config.curl_proxy.mode(), &CurlProxyMode::Disabled);
|
||||
assert_eq!(
|
||||
options.translation_memory_path,
|
||||
Some(PathBuf::from("/cli/tm.sqlite"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1326,6 +1503,14 @@ fn rejects_proxy_with_unsupported_scheme() {
|
||||
assert!(parse(&["bat", "--proxy", "127.0.0.1:7890"]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_parse_errors_redact_credentials() {
|
||||
let error = parse(&["bat", "--proxy", "http://user:secret@"]).unwrap_err();
|
||||
let message = error.to_string();
|
||||
assert!(!message.contains("secret"));
|
||||
assert!(message.contains("<redacted>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_watch_defaults_to_one_hour_and_quiet_up_to_date() {
|
||||
let options = parse(&["bat", "--auto-discover", "--watch", "--interval", "30m"]).unwrap();
|
||||
@@ -1997,6 +2182,54 @@ fn daemon_child_args_preserve_sync_options() {
|
||||
assert!(args.contains(&"--no-banner".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daemon_child_args_preserve_translation_worker_configuration() {
|
||||
let options = parse(&[
|
||||
"bat",
|
||||
"--daemon",
|
||||
"--output",
|
||||
"/tmp/daemon-output",
|
||||
"--translation-provider",
|
||||
"mock",
|
||||
"--translation-fixture",
|
||||
"/tmp/provider.json",
|
||||
"--translation-memory-path",
|
||||
"/tmp/tm.sqlite",
|
||||
"--worker-concurrency",
|
||||
"4",
|
||||
"--worker-max-attempts",
|
||||
"5",
|
||||
"--worker-lease-seconds",
|
||||
"60",
|
||||
"--worker-retry-backoff-seconds",
|
||||
"2",
|
||||
"--worker-max-tasks",
|
||||
"3",
|
||||
"--worker-id",
|
||||
"daemon-worker",
|
||||
])
|
||||
.unwrap();
|
||||
let args = daemon_child_args(&options);
|
||||
let mut child_args = vec!["bat".to_string()];
|
||||
child_args.extend(args);
|
||||
let child = parse_args_from(child_args).unwrap();
|
||||
assert_eq!(child.translation_provider.as_deref(), Some("mock"));
|
||||
assert_eq!(
|
||||
child.translation_fixture,
|
||||
Some(PathBuf::from("/tmp/provider.json"))
|
||||
);
|
||||
assert_eq!(
|
||||
child.translation_memory_path,
|
||||
Some(PathBuf::from("/tmp/tm.sqlite"))
|
||||
);
|
||||
assert_eq!(child.worker_concurrency, 4);
|
||||
assert_eq!(child.worker_max_attempts, 5);
|
||||
assert_eq!(child.worker_lease_seconds, 60);
|
||||
assert_eq!(child.worker_retry_backoff, Duration::from_secs(2));
|
||||
assert_eq!(child.worker_max_tasks, Some(3));
|
||||
assert_eq!(child.worker_id.as_deref(), Some("daemon-worker"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curl_proxy_url_extracts_only_url_mode() {
|
||||
assert_eq!(
|
||||
@@ -2184,6 +2417,7 @@ fn test_task_context_with_config(base_config: OfficialUpdateConfig) -> DaemonTas
|
||||
registry: TaskRegistry::new(),
|
||||
queue,
|
||||
base_config,
|
||||
translation_worker_config: TranslationWorkerConfig::default(),
|
||||
sync_lock: Arc::new(Mutex::new(())),
|
||||
restart_controller: test_restart_controller,
|
||||
}
|
||||
@@ -2354,6 +2588,7 @@ fn dispatch_daemon_doctor_returns_report() {
|
||||
registry: TaskRegistry::new(),
|
||||
queue,
|
||||
base_config,
|
||||
translation_worker_config: TranslationWorkerConfig::default(),
|
||||
sync_lock: Arc::new(Mutex::new(())),
|
||||
restart_controller: test_restart_controller,
|
||||
};
|
||||
@@ -2374,6 +2609,112 @@ fn dispatch_daemon_doctor_returns_report() {
|
||||
assert!(checks.iter().any(|check| check["name"] == "daemon_rpc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_translation_memory_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.memory.summary", None),
|
||||
&state_dir,
|
||||
&new_daemon_control(),
|
||||
&context,
|
||||
"req-tm-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("translation-memory.sqlite").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_translation_memory_summary_defaults_to_worker_config_path() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let output_root = temp.path().join("output");
|
||||
let state_dir = temp.path().join("state");
|
||||
let configured_tm_path = temp.path().join("configured-tm.sqlite");
|
||||
let (queue, _rx) = mpsc::channel::<TaskJob>();
|
||||
let context = DaemonTaskContext {
|
||||
registry: TaskRegistry::new(),
|
||||
queue,
|
||||
base_config: OfficialUpdateConfig {
|
||||
output_root,
|
||||
..Default::default()
|
||||
},
|
||||
translation_worker_config: TranslationWorkerConfig {
|
||||
translation_memory_path: Some(configured_tm_path.clone()),
|
||||
..TranslationWorkerConfig::default()
|
||||
},
|
||||
sync_lock: Arc::new(Mutex::new(())),
|
||||
restart_controller: test_restart_controller,
|
||||
};
|
||||
|
||||
let envelope = dispatch_rpc_method(
|
||||
&rpc_request("translation.memory.summary", None),
|
||||
&state_dir,
|
||||
&new_daemon_control(),
|
||||
&context,
|
||||
"req-tm-summary-configured-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"]["path"].as_str(),
|
||||
Some(configured_tm_path.to_str().unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_translation_memory_rejects_invalid_params_with_stable_error_code() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let context = test_task_context_with_config(OfficialUpdateConfig {
|
||||
output_root: temp.path().join("output"),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
for (method, params) in [
|
||||
("translation.memory.query", None),
|
||||
(
|
||||
"translation.memory.query",
|
||||
Some(serde_json::json!({ "source_text": "Hello", "limit": "1" })),
|
||||
),
|
||||
(
|
||||
"translation.memory.summary",
|
||||
Some(serde_json::json!({ "translation_memory_path": 42 })),
|
||||
),
|
||||
(
|
||||
"translation.memory.confirm",
|
||||
Some(serde_json::json!({ "record_id": "tm-record", "reviewer": 42 })),
|
||||
),
|
||||
] {
|
||||
let envelope = dispatch_rpc_method(
|
||||
&rpc_request(method, params),
|
||||
temp.path(),
|
||||
&new_daemon_control(),
|
||||
&context,
|
||||
format!("req-invalid-{method}"),
|
||||
);
|
||||
let value = serde_json::to_value(envelope).unwrap();
|
||||
assert_eq!(value["ok"], false, "method={method}");
|
||||
assert_eq!(value["error"]["code"], "BAT-ERR-700002", "method={method}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_resource_sync_enqueues_task() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
@@ -2384,6 +2725,7 @@ fn dispatch_resource_sync_enqueues_task() {
|
||||
registry: TaskRegistry::new(),
|
||||
queue,
|
||||
base_config: OfficialUpdateConfig::default(),
|
||||
translation_worker_config: TranslationWorkerConfig::default(),
|
||||
sync_lock: Arc::new(Mutex::new(())),
|
||||
restart_controller: test_restart_controller,
|
||||
};
|
||||
@@ -2445,6 +2787,7 @@ fn dispatch_resource_repair_enqueues_repair_task() {
|
||||
registry: TaskRegistry::new(),
|
||||
queue,
|
||||
base_config,
|
||||
translation_worker_config: TranslationWorkerConfig::default(),
|
||||
sync_lock: Arc::new(Mutex::new(())),
|
||||
restart_controller: test_restart_controller,
|
||||
};
|
||||
@@ -3737,6 +4080,7 @@ fn dispatch_catalog_refresh_enqueues_task() {
|
||||
registry: TaskRegistry::new(),
|
||||
queue,
|
||||
base_config: OfficialUpdateConfig::default(),
|
||||
translation_worker_config: TranslationWorkerConfig::default(),
|
||||
sync_lock: Arc::new(Mutex::new(())),
|
||||
restart_controller: test_restart_controller,
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -111,6 +111,13 @@ impl HumanReport for bat_infrastructure::TranslationWorkerReport {
|
||||
print_field("失败任务", self.failed_count);
|
||||
print_field("已安排重试", self.retry_scheduled_count);
|
||||
print_field("剩余任务", self.remaining_count);
|
||||
print_path_field("Translation Memory", &self.translation_memory_path);
|
||||
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);
|
||||
for failure in &self.translation_memory_failures {
|
||||
println!(" - TM: {failure}");
|
||||
}
|
||||
for failure in &self.failures {
|
||||
println!(
|
||||
" - {} [{}] retryable={} {}",
|
||||
|
||||
@@ -519,6 +519,8 @@ pub(super) struct DaemonTaskContext {
|
||||
pub(super) registry: TaskRegistry,
|
||||
pub(super) queue: mpsc::Sender<TaskJob>,
|
||||
pub(super) base_config: OfficialUpdateConfig,
|
||||
/// daemon 中未显式传入参数的 translation worker 默认配置。
|
||||
pub(super) translation_worker_config: TranslationWorkerConfig,
|
||||
/// 串行化会读取或修改已发布资源状态的 daemon 操作。
|
||||
pub(super) sync_lock: Arc<Mutex<()>>,
|
||||
pub(super) restart_controller: DaemonRestartController,
|
||||
@@ -562,11 +564,15 @@ pub(super) fn run_task_worker(
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let cancel_check = Arc::clone(&cancel);
|
||||
runtime
|
||||
.block_on(bat_infrastructure::run_translation_worker_at(
|
||||
&resource_root,
|
||||
worker_config,
|
||||
))
|
||||
.block_on(
|
||||
bat_infrastructure::run_translation_worker_at_with_cancellation(
|
||||
&resource_root,
|
||||
worker_config,
|
||||
Arc::new(move || cancel_check.load(Ordering::Relaxed)),
|
||||
),
|
||||
)
|
||||
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from))
|
||||
})
|
||||
} else {
|
||||
@@ -608,7 +614,8 @@ pub(super) fn run_task_worker(
|
||||
record.result = Some(report);
|
||||
}),
|
||||
Err(error) => {
|
||||
let cancelled = cancel.load(Ordering::Relaxed);
|
||||
let cancelled =
|
||||
cancel.load(Ordering::Relaxed) || daemon_control_stop_requested(Some(&control));
|
||||
// 下载失败携带类型化 DownloadError(含准确网络域码);其余归 internal。
|
||||
let code = error
|
||||
.downcast_ref::<bat_infrastructure::DownloadError>()
|
||||
|
||||
@@ -71,28 +71,24 @@ pub(super) fn print_startup_banner() {
|
||||
eprintln!("{STARTUP_BANNER}");
|
||||
}
|
||||
|
||||
pub(super) fn print_env_template_created(path: &Path) {
|
||||
pub(super) fn print_config_template_created(path: &Path) {
|
||||
eprintln!(
|
||||
"已生成配置模板 {}(编辑其中的 BAT_* 配置后,直接运行 `bat` 即可按 .env 启动)",
|
||||
"已生成配置模板 {}(编辑 `config.toml`,`config.toml.example` 不会被程序自动读取)",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn print_env_template_warning(path: &Path, error: impl std::fmt::Display) {
|
||||
eprintln!("警告:生成 .env 配置模板失败 {}:{error}", path.display());
|
||||
}
|
||||
|
||||
pub(super) fn print_env_read_warning(path: &Path, error: impl std::fmt::Display) {
|
||||
eprintln!("警告:读取 .env 失败 {}:{error}", path.display());
|
||||
}
|
||||
|
||||
pub(super) fn print_env_parse_warning(line_number: usize, raw_line: &str) {
|
||||
pub(super) fn print_config_template_warning(path: &Path, error: impl std::fmt::Display) {
|
||||
eprintln!(
|
||||
"警告:.env 第 {} 行无法解析,已忽略:{raw_line}",
|
||||
line_number
|
||||
"警告:生成 config.toml.example 模板失败 {}:{error}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn print_deprecated_env_file_warning() {
|
||||
eprintln!("警告:BAT_SKIP_ENV_FILE 已废弃且不再影响启动,已忽略");
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct ProgressLogger {
|
||||
enabled: bool,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
use super::report_output::print_json_value;
|
||||
use super::*;
|
||||
use bat_core::domain::TranslationMemoryContext;
|
||||
use bat_core::repositories::TranslationMemoryRepository;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TranslationTaskResultUpdateParam {
|
||||
@@ -332,6 +336,8 @@ fn build_manual_translation_results(
|
||||
unit_id: unit_id.to_string(),
|
||||
source_text: param.source_text.clone(),
|
||||
translated_text: param.translated_text.clone(),
|
||||
source_kind: bat_infrastructure::TranslationTaskResultSourceKind::Manual,
|
||||
translation_memory_record_id: None,
|
||||
provider: provider.to_string(),
|
||||
provider_run_id: provider_run_id.to_string(),
|
||||
translated_unix_seconds,
|
||||
@@ -355,3 +361,241 @@ pub(super) fn translation_task_query_json(query: &OfficialTextUnitTaskQuery) ->
|
||||
"has_failure_reason": query.has_failure_reason,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn run_translation_memory_command(options: &CliOptions) -> anyhow::Result<()> {
|
||||
let method = match options.command {
|
||||
CliCommand::TranslationMemorySummary => RPC_METHOD_TRANSLATION_MEMORY_SUMMARY,
|
||||
CliCommand::TranslationMemoryQuery => RPC_METHOD_TRANSLATION_MEMORY_QUERY,
|
||||
CliCommand::TranslationMemoryConfirm => RPC_METHOD_TRANSLATION_MEMORY_CONFIRM,
|
||||
_ => return Err(anyhow::anyhow!("不是 Translation Memory 命令")),
|
||||
};
|
||||
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,
|
||||
translation_memory_cli_params(options)?,
|
||||
)?;
|
||||
print_json_value(options.output_format, &report)?;
|
||||
return Ok(());
|
||||
}
|
||||
let path = translation_memory_cli_path(options)?;
|
||||
let report = match options.command {
|
||||
CliCommand::TranslationMemorySummary => build_translation_memory_summary_report(&path)?,
|
||||
CliCommand::TranslationMemoryQuery => {
|
||||
let source_text = options
|
||||
.translation_memory_source_text
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("TM query 必须指定 --tm-source-text"))?;
|
||||
let context = parse_translation_memory_context(
|
||||
options.translation_memory_context_json.as_deref(),
|
||||
)?;
|
||||
build_translation_memory_query_report(
|
||||
&path,
|
||||
source_text,
|
||||
&context,
|
||||
options.query_limit,
|
||||
)?
|
||||
}
|
||||
CliCommand::TranslationMemoryConfirm => {
|
||||
let record_id = options
|
||||
.translation_memory_record_id
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("TM confirm 必须指定 --tm-record-id"))?;
|
||||
let reviewer = options
|
||||
.translation_memory_reviewer
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("TM confirm 必须指定 --tm-reviewer"))?;
|
||||
build_translation_memory_confirm_report(
|
||||
&path,
|
||||
record_id,
|
||||
reviewer,
|
||||
options.translation_memory_reason.clone(),
|
||||
)?
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
print_json_value(options.output_format, &report)
|
||||
}
|
||||
|
||||
fn translation_memory_cli_path(options: &CliOptions) -> anyhow::Result<std::path::PathBuf> {
|
||||
if let Some(path) = options.translation_memory_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(bat_infrastructure::translation_memory_repository_path(
|
||||
&resource_root,
|
||||
))
|
||||
}
|
||||
|
||||
fn translation_memory_cli_params(
|
||||
options: &CliOptions,
|
||||
) -> anyhow::Result<Option<serde_json::Value>> {
|
||||
let mut params = serde_json::Map::new();
|
||||
if let Some(path) = options.translation_memory_path.as_ref() {
|
||||
params.insert(
|
||||
"translation_memory_path".to_string(),
|
||||
serde_json::json!(path),
|
||||
);
|
||||
}
|
||||
match options.command {
|
||||
CliCommand::TranslationMemorySummary => {}
|
||||
CliCommand::TranslationMemoryQuery => {
|
||||
let source_text = options
|
||||
.translation_memory_source_text
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("TM query 必须指定 --tm-source-text"))?;
|
||||
let context = parse_translation_memory_context(
|
||||
options.translation_memory_context_json.as_deref(),
|
||||
)?;
|
||||
params.insert("source_text".to_string(), serde_json::json!(source_text));
|
||||
params.insert("source_context".to_string(), serde_json::json!(context));
|
||||
params.insert("limit".to_string(), serde_json::json!(options.query_limit));
|
||||
}
|
||||
CliCommand::TranslationMemoryConfirm => {
|
||||
let record_id = options
|
||||
.translation_memory_record_id
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("TM confirm 必须指定 --tm-record-id"))?;
|
||||
let reviewer = options
|
||||
.translation_memory_reviewer
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("TM confirm 必须指定 --tm-reviewer"))?;
|
||||
params.insert("record_id".to_string(), serde_json::json!(record_id));
|
||||
params.insert("reviewer".to_string(), serde_json::json!(reviewer));
|
||||
if let Some(reason) = options.translation_memory_reason.as_deref() {
|
||||
params.insert("reason".to_string(), serde_json::json!(reason));
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
Ok(Some(serde_json::Value::Object(params)))
|
||||
}
|
||||
|
||||
pub(super) fn build_translation_memory_summary_report(
|
||||
path: &std::path::Path,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
if !sqlite_file_exists_no_symlink(path, "Translation Memory 数据库")? {
|
||||
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 = bat_infrastructure::SqliteTranslationMemoryRepository::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": summary.schema_version,
|
||||
"summary": summary,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn build_translation_memory_query_report(
|
||||
path: &std::path::Path,
|
||||
source_text: &str,
|
||||
source_context: &TranslationMemoryContext,
|
||||
limit: usize,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
if source_text.trim().is_empty() {
|
||||
return Err(anyhow::anyhow!("TM query 的 source_text 不能为空"));
|
||||
}
|
||||
if !(1..=1000).contains(&limit) {
|
||||
return Err(anyhow::anyhow!("TM query 的 limit 必须在 1..=1000 范围内"));
|
||||
}
|
||||
if !sqlite_file_exists_no_symlink(path, "Translation Memory 数据库")? {
|
||||
return Ok(serde_json::json!({
|
||||
"available": false,
|
||||
"path": path,
|
||||
"source_text": source_text,
|
||||
"source_context": source_context,
|
||||
"matches": [],
|
||||
"reason": "database_missing",
|
||||
}));
|
||||
}
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let matches = runtime.block_on(async {
|
||||
let repository = bat_infrastructure::SqliteTranslationMemoryRepository::open(path)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
repository
|
||||
.find_matches(source_text, source_context, limit)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||
})?;
|
||||
Ok(serde_json::json!({
|
||||
"available": true,
|
||||
"path": path,
|
||||
"source_text": source_text,
|
||||
"source_context": source_context,
|
||||
"matches": matches,
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn build_translation_memory_confirm_report(
|
||||
path: &std::path::Path,
|
||||
record_id: &str,
|
||||
reviewer: &str,
|
||||
reason: Option<String>,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
if record_id.trim().is_empty() || reviewer.trim().is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"TM confirm 必须指定非空 record_id 和 reviewer"
|
||||
));
|
||||
}
|
||||
if !sqlite_file_exists_no_symlink(path, "Translation Memory 数据库")? {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Translation Memory 数据库不存在:{}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let entry = runtime.block_on(async {
|
||||
let repository = bat_infrastructure::SqliteTranslationMemoryRepository::open(path)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||
repository
|
||||
.confirm(record_id, reviewer, reason)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("{error}"))
|
||||
})?;
|
||||
Ok(serde_json::json!({
|
||||
"available": true,
|
||||
"path": path,
|
||||
"entry": entry,
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_translation_memory_context(
|
||||
value: Option<&str>,
|
||||
) -> anyhow::Result<TranslationMemoryContext> {
|
||||
let Some(value) = value else {
|
||||
return Ok(BTreeMap::new());
|
||||
};
|
||||
serde_json::from_str::<TranslationMemoryContext>(value)
|
||||
.map_err(|error| anyhow::anyhow!("--tm-context-json 必须是 JSON object:{error}"))
|
||||
}
|
||||
|
||||
@@ -261,26 +261,10 @@ pub(super) fn run_translation_worker(options: &CliOptions) -> anyhow::Result<()>
|
||||
.map(|path| lexical_absolute(&path).map_err(anyhow::Error::msg))
|
||||
.transpose()?
|
||||
.unwrap_or(active_official_resource_root(&options.config.output_root)?);
|
||||
let provider = options
|
||||
.translation_provider
|
||||
.as_deref()
|
||||
.unwrap_or(TranslationProviderKind::Mock.as_str());
|
||||
let provider = TranslationProviderKind::parse(provider)
|
||||
.ok_or_else(|| anyhow::anyhow!("i18n worker run 的 provider 无效:{provider}"))?;
|
||||
let config = TranslationWorkerConfig {
|
||||
provider,
|
||||
fixture_path: options.translation_fixture.clone(),
|
||||
concurrency: options.worker_concurrency,
|
||||
max_attempts: options.worker_max_attempts,
|
||||
lease_seconds: options.worker_lease_seconds,
|
||||
retry_backoff: options.worker_retry_backoff,
|
||||
max_tasks: options.worker_max_tasks,
|
||||
worker_id: options
|
||||
.worker_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("bat-worker-{}", std::process::id())),
|
||||
};
|
||||
config.validate()?;
|
||||
let config = super::translation_worker_config_from_options(
|
||||
options,
|
||||
&format!("bat-worker-{}", std::process::id()),
|
||||
)?;
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
|
||||
Reference in New Issue
Block a user