feat(translation): add Rust Translation Memory and config migration

This commit is contained in:
2026-09-06 22:48:51 +08:00
parent 93f4bc69b3
commit 7d6389806b
18 changed files with 4414 additions and 384 deletions
File diff suppressed because it is too large Load Diff
+386 -42
View File
@@ -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={} {}",
+12 -5
View File
@@ -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>()
+9 -13
View File
@@ -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()?;