fix: 完成下载并发与翻译交接链路

This commit is contained in:
2026-08-02 22:34:23 +08:00
parent d533c88108
commit 8b64cc94f3
19 changed files with 2923 additions and 138 deletions
+426 -22
View File
@@ -3,6 +3,8 @@ use bat_assetbundle::UnitySerializedReplacementValue;
use bat_core::domain::{Resource, ResourceType};
use bat_core::repositories::resource_repository::{ResourceQuery, ResourceRepository};
use bat_core::{ApiError, ErrorCode};
#[cfg(test)]
use bat_infrastructure::DEFAULT_DOWNLOAD_CONCURRENCY;
use bat_infrastructure::{
apply_patch_file, apply_unityfs_field_patch_file, apply_unityfs_string_field_patch_file,
apply_unityfs_text_asset_patch_file, changed_endpoint_urls, diff_extended_snapshot,
@@ -16,11 +18,12 @@ use bat_infrastructure::{
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary,
OfficialVersionRecord, OfficialVersionState, PatchApplyKind, PatchApplyParams,
PatchApplyReport, ReleaseFlowStatusCode, SqliteResourceRepository, UnityFsFieldPatchParams,
PatchApplyReport, ReleaseFlowStatusCode, SqliteResourceRepository,
SqliteTranslationTaskRepository, TranslationTaskStatus, UnityFsFieldPatchParams,
UnityFsPatchReport, UnityFsStringFieldPatchParams, UnityFsTextAssetPatchParams,
LOCALIZED_CURRENT_LINK, LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_VERSIONS_DIR,
LOCALIZED_VERSION_STATE_FILE, OFFICIAL_PARSE_CACHE_FILE, OFFICIAL_TEXTUNIT_INDEX_FILE,
PRIVATE_FILE_MODE,
LOCALIZED_VERSION_STATE_FILE, MAX_DOWNLOAD_CONCURRENCY, MIN_DOWNLOAD_CONCURRENCY,
OFFICIAL_PARSE_CACHE_FILE, OFFICIAL_TEXTUNIT_INDEX_FILE, PRIVATE_FILE_MODE,
};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
@@ -175,6 +178,7 @@ fn run() -> anyhow::Result<i32> {
| CliCommand::ParseTextUnits
| CliCommand::ParseErrors
| CliCommand::TranslationTasks
| CliCommand::TranslationHandoff
| CliCommand::LocalizedStatus
| CliCommand::ResourceIndex => {
run_readonly_query_command(&options)?;
@@ -243,12 +247,14 @@ struct CliOptions {
query_bundle_path: Option<String>,
query_archive_entry: Option<String>,
query_task_status: Option<String>,
query_worker_status: Option<String>,
query_parse_status: Option<String>,
query_path_id: Option<i64>,
query_class_id: Option<i32>,
query_field_path: Option<String>,
query_format: Option<String>,
query_has_reason: Option<bool>,
query_has_failure_reason: Option<bool>,
query_option_explicit: bool,
patch_kind: Option<PatchApplyKind>,
patch_source_path: Option<PathBuf>,
@@ -302,12 +308,14 @@ impl Default for CliOptions {
query_bundle_path: None,
query_archive_entry: None,
query_task_status: None,
query_worker_status: None,
query_parse_status: None,
query_path_id: None,
query_class_id: None,
query_field_path: None,
query_format: None,
query_has_reason: None,
query_has_failure_reason: None,
query_option_explicit: false,
patch_kind: None,
patch_source_path: None,
@@ -349,6 +357,7 @@ enum CliCommand {
ParseTextUnits,
ParseErrors,
TranslationTasks,
TranslationHandoff,
LocalizedStatus,
ResourceIndex,
PatchApply,
@@ -800,6 +809,8 @@ const RPC_METHOD_PARSE_STATUS: &str = "parse.status";
const RPC_METHOD_PARSE_TEXT_UNITS: &str = "parse.text_units";
const RPC_METHOD_PARSE_ERRORS: &str = "parse.errors";
const RPC_METHOD_TRANSLATION_TASKS: &str = "translation.tasks";
const RPC_METHOD_TRANSLATION_HANDOFF: &str = "translation.handoff";
const RPC_METHOD_TRANSLATION_TASK_UPDATE: &str = "translation.task.update";
const RPC_METHOD_LOCALIZED_STATUS: &str = "localized.status";
const RPC_METHOD_CATALOG_STATUS: &str = "catalog.status";
const RPC_METHOD_CATALOG_VERSIONS: &str = "catalog.versions";
@@ -2278,6 +2289,16 @@ fn dispatch_rpc_method(
build_translation_tasks_report(state_dir, query, offset, limit),
)
}
RPC_METHOD_TRANSLATION_HANDOFF => rpc_envelope_from_result(
request_id,
"translation.handoff",
build_translation_handoff_report(state_dir),
),
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()),
),
RPC_METHOD_LOCALIZED_STATUS => rpc_envelope_from_result(
request_id,
"localized.status",
@@ -2873,17 +2894,46 @@ fn query_resource_repository(
})
}
fn query_translation_task_repository(
repository_path: &Path,
query: &OfficialTextUnitTaskQuery,
offset: usize,
limit: usize,
) -> anyhow::Result<(u64, Vec<bat_infrastructure::PersistedTranslationTask>)> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime.block_on(async {
let repository = SqliteTranslationTaskRepository::open(repository_path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
let total_entries = repository
.count(query)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
let entries = repository
.list(query)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?
.into_iter()
.skip(offset)
.take(limit)
.collect();
Ok((total_entries, entries))
})
}
fn repository_file_exists_no_symlink(path: &Path) -> anyhow::Result<bool> {
sqlite_file_exists_no_symlink(path, "资源索引数据库")
}
fn sqlite_file_exists_no_symlink(path: &Path, label: &str) -> anyhow::Result<bool> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => Err(anyhow::anyhow!(
"资源索引数据库不能是 symlink{}",
path.display()
)),
Ok(metadata) if metadata.file_type().is_symlink() => {
Err(anyhow::anyhow!("{label}不能是 symlink{}", path.display()))
}
Ok(metadata) if metadata.is_file() => Ok(true),
Ok(_) => Err(anyhow::anyhow!(
"资源索引数据库不是普通文件:{}",
path.display()
)),
Ok(_) => Err(anyhow::anyhow!("{label}不是普通文件:{}", path.display())),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(error.into()),
}
@@ -3089,6 +3139,8 @@ fn build_translation_tasks_report(
let task_queue_path = record
.resource_root
.join(bat_infrastructure::OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE);
let task_repository_path =
SqliteTranslationTaskRepository::repository_path(&record.resource_root);
let Some(queue) = bat_infrastructure::read_textunit_task_queue_at(&record.resource_root)
.map_err(anyhow::Error::msg)?
else {
@@ -3097,21 +3149,56 @@ fn build_translation_tasks_report(
"current_version_id": record.id,
"resource_root": record.resource_root,
"textunit_task_queue_path": task_queue_path,
"task_repository_path": task_repository_path,
"task_repository_available": false,
}));
};
let matches = bat_infrastructure::query_textunit_tasks(&queue, &query);
let total_entries = matches.len();
let entries = matches
.into_iter()
.skip(offset)
.take(limit)
.cloned()
.collect::<Vec<_>>();
let task_repository_available =
sqlite_file_exists_no_symlink(&task_repository_path, "翻译任务状态数据库")?;
let (total_entries, entries) = if task_repository_available {
query_translation_task_repository(&task_repository_path, &query, offset, limit)?
} else {
let mut queue_query = query.clone();
queue_query.task_status = None;
queue_query.has_failure_reason = None;
let matches = bat_infrastructure::query_textunit_tasks(&queue, &queue_query);
let persisted = matches
.into_iter()
.cloned()
.map(|task| {
bat_infrastructure::PersistedTranslationTask::from_queued_task(
task,
queue.generated_unix_seconds,
)
})
.filter(|task| {
query
.task_status
.as_ref()
.is_none_or(|status| task.task_status.as_str() == status)
})
.filter(|task| {
query
.has_failure_reason
.is_none_or(|has_reason| task.failure_reason.is_some() == has_reason)
})
.collect::<Vec<_>>();
let total_entries = persisted.len();
let entries = persisted
.into_iter()
.skip(offset)
.take(limit)
.collect::<Vec<_>>();
(total_entries as u64, entries)
};
Ok(serde_json::json!({
"available": true,
"current_version_id": record.id,
"resource_root": record.resource_root,
"textunit_task_queue_path": task_queue_path,
"task_repository_path": task_repository_path,
"task_repository_available": task_repository_available,
"task_repository_schema_version": bat_infrastructure::TRANSLATION_TASK_SCHEMA_VERSION,
"summary": queue.summary,
"total_entries": total_entries,
"offset": offset,
@@ -3121,6 +3208,124 @@ fn build_translation_tasks_report(
}))
}
/// `translation.handoff`:当前已发布版本的动态翻译交接视图。
fn build_translation_handoff_report(state_dir: &Path) -> anyhow::Result<serde_json::Value> {
let (_, version_state) = read_daemon_resource_state(state_dir)?;
let current = version_state
.as_ref()
.and_then(|state| state.current_completed_version.as_ref());
let Some(record) = current else {
return Ok(serde_json::json!({ "available": false }));
};
let resource_root = &record.resource_root;
let task_queue_path = resource_root.join(bat_infrastructure::OFFICIAL_TEXTUNIT_TASK_QUEUE_FILE);
let handoff_path = resource_root.join(bat_infrastructure::TRANSLATION_HANDOFF_FILE);
let repository_path = SqliteTranslationTaskRepository::repository_path(resource_root);
let Some(queue) = bat_infrastructure::read_textunit_task_queue_at(resource_root)
.map_err(anyhow::Error::msg)?
else {
return Ok(serde_json::json!({
"available": false,
"current_version_id": record.id,
"resource_root": resource_root,
"textunit_task_queue_path": task_queue_path,
"translation_handoff_path": handoff_path,
"task_repository_path": repository_path,
}));
};
let task_repository_available =
sqlite_file_exists_no_symlink(&repository_path, "翻译任务状态数据库")?;
let tasks = if task_repository_available {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime.block_on(async {
let repository = SqliteTranslationTaskRepository::open(&repository_path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.list(&OfficialTextUnitTaskQuery::default())
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?
} else {
queue
.tasks
.iter()
.cloned()
.map(|task| {
bat_infrastructure::PersistedTranslationTask::from_queued_task(
task,
queue.generated_unix_seconds,
)
})
.collect::<Vec<_>>()
};
let handoff = bat_infrastructure::build_translation_handoff(&queue, &tasks);
let handoff_file_available = sqlite_file_exists_no_symlink(&handoff_path, "翻译 handoff")?;
Ok(serde_json::json!({
"available": true,
"current_version_id": record.id,
"resource_root": resource_root,
"textunit_task_queue_path": task_queue_path,
"translation_handoff_path": handoff_path,
"translation_handoff_file_available": handoff_file_available,
"task_repository_path": repository_path,
"task_repository_available": task_repository_available,
"task_repository_schema_version": bat_infrastructure::TRANSLATION_TASK_SCHEMA_VERSION,
"handoff_schema_version": bat_infrastructure::TRANSLATION_HANDOFF_SCHEMA_VERSION,
"handoff": handoff,
}))
}
/// `translation.task.update`:写入 provider worker 的可回查状态。
fn update_translation_task_status_report(
state_dir: &Path,
params: Option<&serde_json::Value>,
) -> anyhow::Result<serde_json::Value> {
let task_id = rpc_string_param(params, "task_id")
.ok_or_else(|| anyhow::anyhow!("translation.task.update 缺少 task_id"))?;
let status_label = rpc_string_param(params, "status")
.ok_or_else(|| anyhow::anyhow!("translation.task.update 缺少 status"))?;
let status = TranslationTaskStatus::parse(status_label)
.ok_or_else(|| anyhow::anyhow!("不支持的翻译任务 worker 状态:{status_label}"))?;
let failure_reason = rpc_string_param(params, "failure_reason")
.or_else(|| rpc_string_param(params, "reason"))
.map(str::to_string);
let provider_run_id = rpc_string_param(params, "provider_run_id").map(str::to_string);
let (_, version_state) = read_daemon_resource_state(state_dir)?;
let current = version_state
.as_ref()
.and_then(|state| state.current_completed_version.as_ref())
.ok_or_else(|| anyhow::anyhow!("没有可更新翻译任务的当前官方 release"))?;
let repository_path = SqliteTranslationTaskRepository::repository_path(&current.resource_root);
if !sqlite_file_exists_no_symlink(&repository_path, "翻译任务状态数据库")? {
return Err(anyhow::anyhow!(
"翻译任务状态数据库不存在:{}",
repository_path.display()
));
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let task = runtime.block_on(async {
let repository = SqliteTranslationTaskRepository::open(&repository_path)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?;
repository
.update_status(task_id, status, failure_reason, provider_run_id)
.await
.map_err(|error| anyhow::anyhow!("{error}"))
})?;
Ok(serde_json::json!({
"available": true,
"current_version_id": current.id,
"task_repository_path": repository_path,
"entry": task,
}))
}
fn textunit_query_json(query: &OfficialTextUnitQuery) -> serde_json::Value {
serde_json::json!({
"destination": query.destination.clone(),
@@ -3141,9 +3346,11 @@ fn translation_task_query_json(query: &OfficialTextUnitTaskQuery) -> serde_json:
"path_pattern": query.path_pattern.clone(),
"archive_entry": query.archive_entry.clone(),
"status": query.status.clone(),
"task_status": query.task_status.clone(),
"parse_status": query.parse_status.clone(),
"text_unit_format": query.text_unit_format.clone(),
"has_reason": query.has_reason,
"has_failure_reason": query.has_failure_reason,
})
}
@@ -3398,11 +3605,13 @@ fn rpc_translation_task_query_params(
status: rpc_string_param(params, "status")
.or_else(|| rpc_string_param(params, "task_status"))
.map(str::to_string),
task_status: rpc_string_param(params, "worker_status").map(str::to_string),
parse_status: rpc_string_param(params, "parse_status").map(str::to_string),
text_unit_format: rpc_string_param(params, "text_unit_format")
.or_else(|| rpc_string_param(params, "format"))
.map(str::to_string),
has_reason: rpc_bool_param(params, "has_reason"),
has_failure_reason: rpc_bool_param(params, "has_failure_reason"),
};
Ok((query, offset, limit))
}
@@ -4357,6 +4566,7 @@ fn readonly_query_rpc_method(command: CliCommand) -> Option<&'static str> {
CliCommand::ParseTextUnits => Some(RPC_METHOD_PARSE_TEXT_UNITS),
CliCommand::ParseErrors => Some(RPC_METHOD_PARSE_ERRORS),
CliCommand::TranslationTasks => Some(RPC_METHOD_TRANSLATION_TASKS),
CliCommand::TranslationHandoff => Some(RPC_METHOD_TRANSLATION_HANDOFF),
CliCommand::LocalizedStatus => Some(RPC_METHOD_LOCALIZED_STATUS),
CliCommand::ResourceIndex => Some(RPC_METHOD_RESOURCE_INDEX),
_ => None,
@@ -4471,6 +4681,9 @@ fn readonly_query_rpc_params(options: &CliOptions) -> Option<serde_json::Value>
if let Some(status) = options.query_task_status.as_ref() {
params.insert("status".to_string(), serde_json::json!(status));
}
if let Some(status) = options.query_worker_status.as_ref() {
params.insert("worker_status".to_string(), serde_json::json!(status));
}
if let Some(parse_status) = options.query_parse_status.as_ref() {
params.insert("parse_status".to_string(), serde_json::json!(parse_status));
}
@@ -4480,6 +4693,12 @@ fn readonly_query_rpc_params(options: &CliOptions) -> Option<serde_json::Value>
if let Some(has_reason) = options.query_has_reason {
params.insert("has_reason".to_string(), serde_json::json!(has_reason));
}
if let Some(has_failure_reason) = options.query_has_failure_reason {
params.insert(
"has_failure_reason".to_string(),
serde_json::json!(has_failure_reason),
);
}
}
_ => {}
}
@@ -4515,6 +4734,7 @@ fn build_readonly_query_report(
options.query_offset,
options.query_limit,
),
RPC_METHOD_TRANSLATION_HANDOFF => build_translation_handoff_report(&options.state_dir),
RPC_METHOD_LOCALIZED_STATUS => {
build_localized_status_report(&options.state_dir, &options.config)
}
@@ -4539,13 +4759,15 @@ fn validate_readonly_query_options(options: &CliOptions) -> anyhow::Result<()> {
|| options.query_field_path.is_some();
let has_translation_task_filter = options.query_task_id.is_some()
|| options.query_task_status.is_some()
|| options.query_has_reason.is_some();
|| options.query_worker_status.is_some()
|| options.query_has_reason.is_some()
|| options.query_has_failure_reason.is_some();
match options.command {
CliCommand::ResourceIndex => {
if has_parse_object_filter || has_translation_task_filter {
return Err(anyhow::anyhow!(
"--path-id/--class-id/--field-path 只适用于 parse-text-units 或 parse-errors--task-id/--task-status/--has-reason 只适用于 translation-tasks"
"--path-id/--class-id/--field-path 只适用于 parse-text-units 或 parse-errors--task-id/--task-status/--worker-status/--has-reason/--has-failure-reason 只适用于 translation-tasks"
));
}
}
@@ -4556,7 +4778,7 @@ fn validate_readonly_query_options(options: &CliOptions) -> anyhow::Result<()> {
|| options.query_parse_status.is_some()
{
return Err(anyhow::anyhow!(
"--resource-type/--hash/--release-id/--platform/--bundle-path/--parse-status 只适用于 resource-index 或 translation-tasks--task-id/--task-status/--has-reason 只适用于 translation-tasks"
"--resource-type/--hash/--release-id/--platform/--bundle-path/--parse-status 只适用于 resource-index 或 translation-tasks--task-id/--task-status/--worker-status/--has-reason/--has-failure-reason 只适用于 translation-tasks"
));
}
}
@@ -4567,6 +4789,11 @@ fn validate_readonly_query_options(options: &CliOptions) -> anyhow::Result<()> {
));
}
}
CliCommand::TranslationHandoff if options.query_option_explicit => {
return Err(anyhow::anyhow!(
"translation-handoff 不接受查询过滤参数;请使用 translation-tasks 查询单项任务"
));
}
CliCommand::ParseStatus | CliCommand::LocalizedStatus if options.query_option_explicit => {
return Err(anyhow::anyhow!(
"查询过滤参数只适用于 resource-index、parse-text-units、parse-errors 或 translation-tasks"
@@ -4788,6 +5015,8 @@ fn translation_task_query_from_options(options: &CliOptions) -> OfficialTextUnit
parse_status: options.query_parse_status.clone(),
text_unit_format: options.query_format.clone(),
has_reason: options.query_has_reason,
has_failure_reason: options.query_has_failure_reason,
task_status: options.query_worker_status.clone(),
}
}
@@ -6446,6 +6675,8 @@ fn daemon_child_args(options: &CliOptions) -> Vec<String> {
}
args.push("--curl".to_string());
args.push(config.curl_command.to_string_lossy().to_string());
args.push("--download-concurrency".to_string());
args.push(config.download_concurrency.to_string());
match config.curl_proxy.mode() {
CurlProxyMode::Auto if options.proxy_option_explicit => {
args.push("--proxy".to_string());
@@ -6960,6 +7191,8 @@ BAT_AUTO_DISCOVER=1
# windows,android
#BAT_PLATFORMS=windows,android
#BAT_CURL=curl
# 8 1..=256
#BAT_DOWNLOAD_CONCURRENCY=8
#BAT_UNZIP=unzip
# ---- ----
@@ -7138,6 +7371,10 @@ fn apply_bat_env_overrides(
if let Some(v) = value("BAT_CURL") {
options.config.curl_command = PathBuf::from(v);
}
if let Some(v) = value("BAT_DOWNLOAD_CONCURRENCY") {
options.config.download_concurrency =
parse_download_concurrency(&v, "环境变量 BAT_DOWNLOAD_CONCURRENCY")?;
}
if let Some(v) = value("BAT_UNZIP") {
options.config.unzip_command = PathBuf::from(v);
}
@@ -7245,6 +7482,10 @@ fn parse_args_with_env(
ensure_command_not_set(options.command, "translation-tasks")?;
options.command = CliCommand::TranslationTasks;
}
"translation-handoff" => {
ensure_command_not_set(options.command, "translation-handoff")?;
options.command = CliCommand::TranslationHandoff;
}
"localized-status" => {
ensure_command_not_set(options.command, "localized-status")?;
options.command = CliCommand::LocalizedStatus;
@@ -7359,6 +7600,11 @@ fn parse_args_with_env(
"--curl" => {
options.config.curl_command = PathBuf::from(next_option_value(&mut args, &flag)?);
}
"--download-concurrency" => {
options.config.download_concurrency =
parse_download_concurrency(&next_option_value(&mut args, &flag)?, &flag)?;
options.sync_option_explicit = true;
}
"--proxy" => {
options.config.curl_proxy =
parse_proxy_config(&next_option_value(&mut args, &flag)?)?;
@@ -7543,6 +7789,10 @@ fn parse_args_with_env(
options.query_task_status = Some(next_option_value(&mut args, &flag)?);
options.query_option_explicit = true;
}
"--worker-status" => {
options.query_worker_status = Some(next_option_value(&mut args, &flag)?);
options.query_option_explicit = true;
}
"--parse-status" => {
options.query_parse_status = Some(next_option_value(&mut args, &flag)?);
options.query_option_explicit = true;
@@ -7587,6 +7837,14 @@ fn parse_args_with_env(
options.query_has_reason = Some(false);
options.query_option_explicit = true;
}
"--has-failure-reason" => {
options.query_has_failure_reason = Some(true);
options.query_option_explicit = true;
}
"--no-failure-reason" => {
options.query_has_failure_reason = Some(false);
options.query_option_explicit = true;
}
"--patch-kind" => {
options.patch_kind = Some(parse_patch_apply_kind(&next_option_value(
&mut args, &flag,
@@ -7704,6 +7962,7 @@ fn parse_args_with_env(
| CliCommand::ParseTextUnits
| CliCommand::ParseErrors
| CliCommand::TranslationTasks
| CliCommand::TranslationHandoff
| CliCommand::LocalizedStatus
| CliCommand::ResourceIndex => {
validate_readonly_query_options(&options)?;
@@ -7833,6 +8092,18 @@ fn tools_are_non_default(config: &OfficialUpdateConfig, baseline: &OfficialUpdat
|| config.unzip_command != baseline.unzip_command
}
fn parse_download_concurrency(value: &str, source: &str) -> anyhow::Result<usize> {
let parsed = value
.parse::<usize>()
.map_err(|error| anyhow::anyhow!("{source} 无效:{error}"))?;
if !(MIN_DOWNLOAD_CONCURRENCY..=MAX_DOWNLOAD_CONCURRENCY).contains(&parsed) {
return Err(anyhow::anyhow!(
"{source} 必须在 {MIN_DOWNLOAD_CONCURRENCY}..={MAX_DOWNLOAD_CONCURRENCY} 范围内"
));
}
Ok(parsed)
}
fn next_option_value(
args: &mut impl Iterator<Item = String>,
flag: &str,
@@ -7896,6 +8167,7 @@ fn print_usage(binary: &str) {
eprintln!(" parse-text-units Query current official TextUnit detail index");
eprintln!(" parse-errors Query current official parse/extraction diagnostics");
eprintln!(" translation-tasks Query current offline TextUnit translation task status");
eprintln!(" translation-handoff Query current translation job/unit/provider handoff");
eprintln!(" localized-status Show localized release status for current official release");
eprintln!(" resource-index Query CAS + ResourceRepository index");
eprintln!(" patch-apply Apply a Binary/JSON/Text patch file");
@@ -7946,6 +8218,9 @@ fn print_usage(binary: &str) {
eprintln!(" --import-resource-db <PATH> SQLite ResourceRepository path");
eprintln!(" --snapshot <PATH> Override snapshot path (default: <output>/current/official-sync-snapshot.json)");
eprintln!(" --curl <PATH> curl executable (default: curl)");
eprintln!(
" --download-concurrency <N> Bounded parallel downloads (default: 8, range 1..=256)"
);
eprintln!(" --proxy <URL|auto|none> curl proxy override (default: auto from env)");
eprintln!(" --no-proxy Force direct curl connections");
eprintln!(" --unzip <PATH> unzip executable (default: unzip)");
@@ -7970,6 +8245,9 @@ fn print_usage(binary: &str) {
eprintln!(" --bundle-path <PATH> Filter resource-index by metadata bundle path");
eprintln!(" --archive-entry <PATH> Filter resource-index, parse detail, or translation-tasks by ZIP/archive entry");
eprintln!(" --task-status <STATUS> Filter translation-tasks by task status");
eprintln!(
" --worker-status <STATUS> Filter translation-tasks by provider worker status"
);
eprintln!(" --parse-status <STATUS> Filter resource-index or translation-tasks by parse status");
eprintln!(" --path-id <ID> Filter parse detail by Unity object path ID");
eprintln!(" --class-id <ID> Filter parse detail by Unity class ID");
@@ -7978,6 +8256,9 @@ fn print_usage(binary: &str) {
eprintln!(
" --has-reason | --no-reason Filter translation-tasks by diagnostic reason presence"
);
eprintln!(
" --has-failure-reason | --no-failure-reason Filter translation-tasks by provider failure reason"
);
eprintln!();
eprintln!("Write patch:");
eprintln!(" --patch-kind <binary|json|text> Patch type for patch-apply");
@@ -8098,6 +8379,10 @@ mod tests {
#[test]
fn env_defaults_apply_and_cli_overrides() {
assert_eq!(
CliOptions::default().config.download_concurrency,
DEFAULT_DOWNLOAD_CONCURRENCY
);
let options = parse_with_env(
&["bat"],
&[
@@ -8109,6 +8394,7 @@ mod tests {
("BAT_AUTO_DISCOVER", "1"),
("BAT_STATE_DIR", "/srv/state"),
("BAT_INTERVAL_SECONDS", "120"),
("BAT_DOWNLOAD_CONCURRENCY", "3"),
],
)
.unwrap();
@@ -8127,6 +8413,7 @@ mod tests {
Some(PathBuf::from("/srv/bat/resources.sqlite"))
);
assert!(options.config.auto_discover);
assert_eq!(options.config.download_concurrency, 3);
assert_eq!(options.state_dir, PathBuf::from("/srv/state"));
assert_eq!(options.interval, Duration::from_secs(120));
@@ -8206,6 +8493,20 @@ mod tests {
assert!(parse_with_env(&["bat"], &[("BAT_WATCH", "maybe")]).is_err());
assert!(parse_with_env(&["bat"], &[("BAT_INTERVAL_SECONDS", "abc")]).is_err());
assert!(parse_with_env(&["bat"], &[("BAT_PROXY", "ftp://x")]).is_err());
assert!(parse_with_env(&["bat"], &[("BAT_DOWNLOAD_CONCURRENCY", "0")]).is_err());
assert!(parse_with_env(&["bat"], &[("BAT_DOWNLOAD_CONCURRENCY", "257")]).is_err());
assert!(parse(&["bat", "--download-concurrency", "0"]).is_err());
assert!(parse(&["bat", "--download-concurrency", "257"]).is_err());
}
#[test]
fn cli_download_concurrency_is_preserved_for_daemon_child() {
let options = parse(&["bat", "--download-concurrency", "4"]).unwrap();
assert_eq!(options.config.download_concurrency, 4);
let args = daemon_child_args(&options);
assert!(args
.windows(2)
.any(|pair| pair == ["--download-concurrency", "4"]));
}
#[test]
@@ -11242,6 +11543,34 @@ mod tests {
bat_infrastructure::write_textunit_task_queue_at(current_dir, &queue).unwrap();
}
fn write_translation_task_repository_fixture(current_dir: &Path) {
let queue = bat_infrastructure::read_textunit_task_queue_at(current_dir)
.unwrap()
.unwrap();
let repository_path =
bat_infrastructure::SqliteTranslationTaskRepository::repository_path(current_dir);
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
runtime.block_on(async {
let repository =
bat_infrastructure::SqliteTranslationTaskRepository::new(&repository_path)
.await
.unwrap();
repository.sync_queue(&queue).await.unwrap();
repository
.update_status(
"textunit/v-current/Bundle/a.bundle",
bat_infrastructure::TranslationTaskStatus::Failed,
Some("Crowdin provider rejected the payload".to_string()),
Some("crowdin-run-1".to_string()),
)
.await
.unwrap();
});
}
fn write_textunit_index_fixture(current_dir: &Path) {
let index = bat_infrastructure::OfficialTextUnitIndex {
version: bat_infrastructure::OFFICIAL_TEXTUNIT_INDEX_VERSION,
@@ -11326,6 +11655,7 @@ mod tests {
let output_root = temp.path().join("output");
let current_dir = write_catalog_fixture(&state_dir, &output_root, "bundle-b2", None);
write_textunit_task_queue_fixture(&current_dir);
write_translation_task_repository_fixture(&current_dir);
let envelope = dispatch_rpc_method(
&rpc_request(
@@ -11392,6 +11722,80 @@ mod tests {
value["data"]["entries"][0]["reason"],
serde_json::Value::Null
);
let envelope = dispatch_rpc_method(
&rpc_request(
"translation.tasks",
Some(serde_json::json!({
"worker_status": "failed",
"has_failure_reason": true,
"offset": 0,
"limit": 10
})),
),
&state_dir,
&new_daemon_control(),
&test_task_context(),
"req-translation-3".to_string(),
);
let value = serde_json::to_value(&envelope).unwrap();
assert_eq!(value["ok"], true);
assert_eq!(value["data"]["task_repository_available"], true);
assert_eq!(value["data"]["total_entries"], 1);
assert_eq!(value["data"]["entries"][0]["task_status"], "failed");
assert_eq!(
value["data"]["entries"][0]["failure_reason"],
"Crowdin provider rejected the payload"
);
assert_eq!(value["data"]["entries"][0]["attempt_count"], 0);
let envelope = dispatch_rpc_method(
&rpc_request(
"translation.task.update",
Some(serde_json::json!({
"task_id": "textunit/v-current/Bundle/a.bundle",
"status": "completed",
"provider_run_id": "crowdin-run-2"
})),
),
&state_dir,
&new_daemon_control(),
&test_task_context(),
"req-translation-4".to_string(),
);
let value = serde_json::to_value(&envelope).unwrap();
assert_eq!(value["ok"], true);
assert_eq!(value["data"]["entry"]["task_status"], "completed");
assert_eq!(
value["data"]["entry"]["failure_reason"],
serde_json::Value::Null
);
assert_eq!(value["data"]["entry"]["provider_run_id"], "crowdin-run-2");
assert!(value["data"]["entry"]["completed_unix_seconds"].is_number());
let envelope = dispatch_rpc_method(
&rpc_request("translation.handoff", None),
&state_dir,
&new_daemon_control(),
&test_task_context(),
"req-translation-handoff".to_string(),
);
let value = serde_json::to_value(&envelope).unwrap();
assert_eq!(value["ok"], true);
assert_eq!(value["data"]["available"], true);
assert_eq!(
value["data"]["handoff_schema_version"],
bat_infrastructure::TRANSLATION_HANDOFF_SCHEMA_VERSION
);
assert_eq!(
value["data"]["handoff"]["units"].as_array().unwrap().len(),
2
);
assert_eq!(value["data"]["handoff"]["units"][0]["status"], "translated");
assert_eq!(
value["data"]["handoff"]["provider_runs"][0]["provider_run_id"],
"crowdin-run-2"
);
}
#[test]