feat(bat): 完善工作流调度与 dashboard RPC
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

补全资源拉取、解析、翻译、重打包和本地化发布命令,支持单次、限定次数与周期调度。移除 TUI 计划并通过 schedule.* RPC 暴露给 bat-api dashboard。

Closes #43
This commit is contained in:
2026-08-03 22:18:52 +08:00
parent 3b103be8a9
commit 0784d5b532
27 changed files with 2931 additions and 58 deletions
+578 -16
View File
@@ -8,22 +8,26 @@ 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,
gc_orphan_staging, lexical_absolute, open_append_file, read_download_manifest_at,
export_translation_workbench, gc_orphan_staging, lexical_absolute,
localized_text_asset_patches, 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_version_state,
redact_proxy_url, resolve_curl_proxy, validate_output_root, validate_runtime_state_dir,
write_file_atomic, CurlProxyConfig, CurlProxyMode, OfficialEndpointMarkerRole,
OfficialFailedVersionRecord, OfficialResourceHashVerification, OfficialResourceVerification,
OfficialServerInfoSource, OfficialTextUnitQuery, OfficialTextUnitTaskQuery,
OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
OfficialUpdateSnapshot, OfficialUpdateStatus, OfficialVerificationSummary,
OfficialVersionRecord, OfficialVersionState, PatchApplyKind, PatchApplyParams,
PatchApplyReport, ReleaseFlowStatusCode, SqliteResourceRepository,
SqliteTranslationTaskRepository, TranslationTaskStatus, UnityFsFieldPatchParams,
UnityFsPatchReport, UnityFsStringFieldPatchParams, UnityFsTextAssetPatchParams,
LOCALIZED_CURRENT_LINK, LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_VERSIONS_DIR,
LOCALIZED_VERSION_STATE_FILE, MAX_DOWNLOAD_CONCURRENCY, MIN_DOWNLOAD_CONCURRENCY,
OFFICIAL_PARSE_CACHE_FILE, OFFICIAL_TEXTUNIT_INDEX_FILE, PRIVATE_FILE_MODE,
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,
validate_output_root, validate_runtime_state_dir, write_file_atomic,
write_official_textunit_queues, CurlProxyConfig, CurlProxyMode, LocalizedPatchConfig,
LocalizedPatchReport, LocalizedPatchService, OfficialEndpointMarkerRole,
OfficialFailedVersionRecord, OfficialParseCacheService, OfficialParseConfig,
OfficialResourceHashVerification, OfficialResourceVerification, OfficialServerInfoSource,
OfficialTextUnitQuery, OfficialTextUnitTaskQuery, OfficialUpdateConfig, OfficialUpdateProgress,
OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot, OfficialUpdateStatus,
OfficialVerificationSummary, OfficialVersionRecord, OfficialVersionState, PatchApplyKind,
PatchApplyParams, PatchApplyReport, ReleaseFlowStatusCode, RepackReport,
SqliteResourceRepository, SqliteTranslationTaskRepository, TranslationTaskStatus,
UnityFsFieldPatchParams, UnityFsPatchReport, UnityFsStringFieldPatchParams,
UnityFsTextAssetPatchParams, LOCALIZED_CURRENT_LINK, LOCALIZED_PATCH_MANIFEST_FILE,
LOCALIZED_VERSIONS_DIR, 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};
@@ -46,16 +50,23 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
mod patch_commands;
#[path = "readonly_query.rs"]
mod readonly_query;
#[path = "schedule_commands.rs"]
mod schedule_commands;
#[path = "task_registry.rs"]
mod task_registry;
#[path = "translation_query.rs"]
mod translation_query;
#[path = "workflow_commands.rs"]
mod workflow_commands;
use patch_commands::{
is_write_patch_command, run_write_patch_command, validate_write_patch_options,
};
#[cfg(test)]
use readonly_query::run_readonly_query_command_with_rpc;
use readonly_query::{run_readonly_query_command, validate_readonly_query_options};
use schedule_commands::{
run_schedule_add, run_schedule_list, run_schedule_remove, run_schedule_run, run_schedule_update,
};
use task_registry::{
run_task_worker, CancelOutcome, DaemonTaskContext, TaskJob, TaskKind, TaskRegistry,
};
@@ -67,6 +78,9 @@ use translation_query::{
build_translation_handoff_report, build_translation_tasks_report, textunit_query_json,
update_translation_task_status_report,
};
use workflow_commands::{
run_parse_once, run_publish_localized, run_repack, run_translate_once, run_translation_set,
};
const EXIT_ERROR: i32 = 1;
const EXIT_LOCKED: i32 = 75;
@@ -168,6 +182,52 @@ fn run() -> anyhow::Result<i32> {
}
Ok(0)
}
CliCommand::Pull => {
run_repeated_workflow(&options, "pull", |options| {
run_sync_command_foreground(options, "pull")
})?;
Ok(0)
}
CliCommand::Parse => {
run_repeated_workflow(&options, "parse", run_parse_once)?;
Ok(0)
}
CliCommand::Translate => {
run_repeated_workflow(&options, "translate", run_translate_once)?;
Ok(0)
}
CliCommand::TranslationSet => {
run_translation_set(&options)?;
Ok(0)
}
CliCommand::Repack => {
run_repack(&options)?;
Ok(0)
}
CliCommand::PublishLocalized => {
run_repeated_workflow(&options, "publish-localized", run_publish_localized)?;
Ok(0)
}
CliCommand::ScheduleList => {
run_schedule_list(&options)?;
Ok(0)
}
CliCommand::ScheduleAdd => {
run_schedule_add(&options)?;
Ok(0)
}
CliCommand::ScheduleUpdate => {
run_schedule_update(&options)?;
Ok(0)
}
CliCommand::ScheduleRemove => {
run_schedule_remove(&options)?;
Ok(0)
}
CliCommand::ScheduleRun => {
run_schedule_run(&options)?;
Ok(0)
}
CliCommand::Status => {
let _control_lock = DaemonControlLock::acquire(&options.state_dir)?;
print_daemon_status(&options.state_dir, options.output_format)?;
@@ -233,6 +293,34 @@ fn run() -> anyhow::Result<i32> {
}
}
fn run_repeated_workflow(
options: &CliOptions,
command_name: &'static str,
mut operation: impl FnMut(&CliOptions) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
let max_runs = if options.watch {
None
} else {
Some(options.run_count.unwrap_or(1))
};
let mut completed_runs = 0usize;
loop {
operation(options)?;
completed_runs += 1;
if max_runs.is_some_and(|limit| completed_runs >= limit) {
return Ok(());
}
if options.progress {
eprintln!(
"{command_name} 下一轮将在 {} 后执行(已完成 {} 轮)",
format_duration(options.interval),
completed_runs
);
}
thread::sleep(options.interval);
}
}
#[derive(Debug, Serialize)]
struct ErrorReport<'a> {
status: &'a str,
@@ -248,10 +336,31 @@ struct CliOptions {
command: CliCommand,
output_format: OutputFormat,
watch: bool,
run_count: Option<usize>,
interval_explicit: bool,
daemon: bool,
daemon_child: bool,
state_dir: PathBuf,
output_explicit: bool,
resource_root: Option<PathBuf>,
translation_file: Option<PathBuf>,
translation_id: Option<String>,
translation_text: Option<String>,
translation_text_file: Option<PathBuf>,
localized_release_id: Option<String>,
repack_spec: Option<PathBuf>,
schedule_group: Option<String>,
schedule_action: Option<String>,
schedule_id: Option<String>,
schedule_at_unix: Option<u64>,
schedule_delay: Option<Duration>,
schedule_every: Option<Duration>,
schedule_count: Option<usize>,
schedule_args: Vec<String>,
schedule_clear_args: bool,
schedule_clear_every: bool,
schedule_enabled: Option<bool>,
schedule_option_explicit: bool,
sync_option_explicit: bool,
proxy_option_explicit: bool,
interval: Duration,
@@ -309,10 +418,31 @@ impl Default for CliOptions {
command: CliCommand::Run,
output_format: OutputFormat::Human,
watch: false,
run_count: None,
interval_explicit: false,
daemon: false,
daemon_child: false,
state_dir: PathBuf::from(DEFAULT_DAEMON_STATE_DIR),
output_explicit: false,
resource_root: None,
translation_file: None,
translation_id: None,
translation_text: None,
translation_text_file: None,
localized_release_id: None,
repack_spec: None,
schedule_group: None,
schedule_action: None,
schedule_id: None,
schedule_at_unix: None,
schedule_delay: None,
schedule_every: None,
schedule_count: None,
schedule_args: Vec::new(),
schedule_clear_args: false,
schedule_clear_every: false,
schedule_enabled: None,
schedule_option_explicit: false,
sync_option_explicit: false,
proxy_option_explicit: false,
interval: Duration::from_secs(DEFAULT_WATCH_INTERVAL_SECONDS),
@@ -372,6 +502,17 @@ enum OutputFormat {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CliCommand {
Run,
Pull,
Parse,
Translate,
TranslationSet,
Repack,
PublishLocalized,
ScheduleList,
ScheduleAdd,
ScheduleUpdate,
ScheduleRemove,
ScheduleRun,
Status,
Stop,
Restart,
@@ -831,6 +972,11 @@ const RPC_METHOD_RESOURCE_REPAIR: &str = "resource.repair";
const RPC_METHOD_RESOURCE_MANIFEST: &str = "resource.manifest";
const RPC_METHOD_RESOURCE_INDEX: &str = "resource.index";
const RPC_METHOD_RESOURCE_LIST: &str = "resource.list";
const RPC_METHOD_SCHEDULE_LIST: &str = "schedule.list";
const RPC_METHOD_SCHEDULE_ADD: &str = "schedule.add";
const RPC_METHOD_SCHEDULE_UPDATE: &str = "schedule.update";
const RPC_METHOD_SCHEDULE_REMOVE: &str = "schedule.remove";
const RPC_METHOD_SCHEDULE_RUN: &str = "schedule.run";
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";
@@ -1616,6 +1762,77 @@ fn dispatch_rpc_method(
"resource.state",
build_resource_state_report(state_dir),
),
RPC_METHOD_SCHEDULE_LIST => rpc_envelope_from_result(
request_id,
RPC_METHOD_SCHEDULE_LIST,
schedule_commands::schedule_list_report(state_dir),
),
RPC_METHOD_SCHEDULE_ADD => {
let params = match rpc_struct_params::<schedule_commands::ScheduleMutationRequest>(
request.params.as_ref(),
RPC_METHOD_SCHEDULE_ADD,
) {
Ok(params) => params,
Err(error) => return rpc_envelope_error(request_id, error),
};
rpc_envelope_from_result(
request_id,
RPC_METHOD_SCHEDULE_ADD,
schedule_commands::schedule_add_report(state_dir, params),
)
}
RPC_METHOD_SCHEDULE_UPDATE => {
let params = match rpc_struct_params::<schedule_commands::ScheduleMutationRequest>(
request.params.as_ref(),
RPC_METHOD_SCHEDULE_UPDATE,
) {
Ok(params) => params,
Err(error) => return rpc_envelope_error(request_id, error),
};
rpc_envelope_from_result(
request_id,
RPC_METHOD_SCHEDULE_UPDATE,
schedule_commands::schedule_update_report(state_dir, params),
)
}
RPC_METHOD_SCHEDULE_REMOVE => {
let params = match rpc_struct_params::<schedule_commands::ScheduleMutationRequest>(
request.params.as_ref(),
RPC_METHOD_SCHEDULE_REMOVE,
) {
Ok(params) => params,
Err(error) => return rpc_envelope_error(request_id, error),
};
rpc_envelope_from_result(
request_id,
RPC_METHOD_SCHEDULE_REMOVE,
schedule_commands::schedule_remove_report(state_dir, params),
)
}
RPC_METHOD_SCHEDULE_RUN => {
let params = request
.params
.as_ref()
.map(|value| serde_json::from_value(value.clone()))
.transpose()
.map_err(|error| {
ApiError::new(
ErrorCode::RPC_INVALID_PARAMS,
RPC_METHOD_SCHEDULE_RUN,
format!("params 无效:{error}"),
)
});
let params = match params {
Ok(Some(params)) => params,
Ok(None) => schedule_commands::ScheduleRunRequest::default(),
Err(error) => return rpc_envelope_error(request_id, error),
};
rpc_envelope_from_result(
request_id,
RPC_METHOD_SCHEDULE_RUN,
schedule_commands::schedule_run_report(state_dir, params),
)
}
RPC_METHOD_RESOURCE_SYNC => {
let force = rpc_bool_param(request.params.as_ref(), "force").unwrap_or(false);
enqueue_task_envelope(tasks, TaskKind::Sync, force, request_id)
@@ -3837,6 +4054,42 @@ trait HumanReport {
fn print_human(&self) -> anyhow::Result<()>;
}
impl HumanReport for serde_json::Value {
fn print_human(&self) -> anyhow::Result<()> {
print_human_json_value(self)
}
}
impl HumanReport for RepackReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("UnityFS 重打包");
print_field("命令", self.command);
print_field("状态", self.status);
print_path_field("源 bundle", &self.source_bundle);
print_path_field("目标 bundle", &self.target_bundle);
print_field("操作数", self.operation_count);
print_field("源字节", self.source_bytes);
print_field("目标字节", self.target_bytes);
print_field("源 BLAKE3", &self.source_blake3);
print_field("目标 BLAKE3", &self.target_blake3);
Ok(())
}
}
impl HumanReport for LocalizedPatchReport {
fn print_human(&self) -> anyhow::Result<()> {
print_title("汉化 release 发布");
print_path_field("版本目录", &self.version_path);
print_path_field("current", &self.current_path);
print_path_field("状态文件", &self.state_path);
print_path_field("patch manifest", &self.patch_manifest_path);
print_field("变更文件数", self.files.len());
print_field("TextAsset 操作数", self.manifest.text_asset_operation_count);
print_field("校验文件数", self.integrity.verified_changed_file_count);
Ok(())
}
}
fn print_human_json_value(value: &serde_json::Value) -> anyhow::Result<()> {
if value.get("running").is_some() && value.get("state_dir").is_some() {
print_title("后台状态");
@@ -6180,6 +6433,11 @@ fn parse_args_with_env(
while let Some(flag) = args.next() {
match flag.as_str() {
"res" | "resource" | "resources" => parse_resource_command(&mut args, &mut options)?,
"parse" => parse_parse_command(&mut args, &mut options)?,
"i18n" | "tr" | "translation" | "translate" => {
parse_translation_command(&mut args, &mut options)?
}
"status" => {
ensure_command_not_set(options.command, "status")?;
options.command = CliCommand::Status;
@@ -6313,6 +6571,29 @@ fn parse_args_with_env(
PathBuf::from(next_option_value(&mut args, &flag)?);
options.output_explicit = true;
}
"--resource-root" => {
options.resource_root = Some(PathBuf::from(next_option_value(&mut args, &flag)?));
}
"--translation-file" | "--workbench" => {
options.translation_file =
Some(PathBuf::from(next_option_value(&mut args, &flag)?));
}
"--translation-id" => {
options.translation_id = Some(next_option_value(&mut args, &flag)?);
}
"--translated-text" => {
options.translation_text = Some(next_option_value(&mut args, &flag)?);
}
"--translated-file" => {
options.translation_text_file =
Some(PathBuf::from(next_option_value(&mut args, &flag)?));
}
"--localized-release-id" => {
options.localized_release_id = Some(next_option_value(&mut args, &flag)?);
}
"--repack-spec" => {
options.repack_spec = Some(PathBuf::from(next_option_value(&mut args, &flag)?));
}
"--import-repository" => {
options.config.import_repository = true;
options.sync_option_explicit = true;
@@ -6334,6 +6615,18 @@ fn parse_args_with_env(
"--state-dir" | "--pid-dir" => {
options.state_dir = PathBuf::from(next_option_value(&mut args, &flag)?);
}
"--run-count" | "--repeat" => {
let value = next_option_value(&mut args, &flag)?
.parse::<usize>()
.map_err(|error| anyhow::anyhow!("{flag} 无效:{error}"))?;
if value == 0 {
return Err(anyhow::anyhow!("{flag} 必须大于 0"));
}
options.run_count = Some(value);
}
"--once" => {
options.run_count = Some(1);
}
"--snapshot" => {
options.config.snapshot_path =
Some(PathBuf::from(next_option_value(&mut args, &flag)?));
@@ -6415,6 +6708,7 @@ fn parse_args_with_env(
}
"--interval" => {
options.interval = parse_duration(&next_option_value(&mut args, &flag)?)?;
options.interval_explicit = true;
options.sync_option_explicit = true;
}
"--interval-seconds" => {
@@ -6422,8 +6716,66 @@ fn parse_args_with_env(
.parse::<u64>()
.map_err(|error| anyhow::anyhow!("{flag} 的秒数无效:{error}"))?;
options.interval = Duration::from_secs(seconds);
options.interval_explicit = true;
options.sync_option_explicit = true;
}
"--schedule-id" | "--id" => {
options.schedule_id = Some(next_option_value(&mut args, &flag)?);
options.schedule_option_explicit = true;
}
"--schedule-action" | "--action" => {
options.schedule_action = Some(next_option_value(&mut args, &flag)?);
options.schedule_option_explicit = true;
}
"--schedule-at-unix" => {
let value = next_option_value(&mut args, &flag)?
.parse::<u64>()
.map_err(|error| anyhow::anyhow!("{flag} 无效:{error}"))?;
options.schedule_at_unix = Some(value);
options.schedule_option_explicit = true;
}
"--schedule-delay" => {
options.schedule_delay =
Some(parse_duration(&next_option_value(&mut args, &flag)?)?);
options.schedule_option_explicit = true;
}
"--schedule-every" => {
options.schedule_every =
Some(parse_duration(&next_option_value(&mut args, &flag)?)?);
options.schedule_option_explicit = true;
}
"--schedule-count" => {
let value = next_option_value(&mut args, &flag)?
.parse::<usize>()
.map_err(|error| anyhow::anyhow!("{flag} 无效:{error}"))?;
if value == 0 {
return Err(anyhow::anyhow!("--schedule-count 必须大于 0"));
}
options.schedule_count = Some(value);
options.schedule_option_explicit = true;
}
"--schedule-arg" => {
options
.schedule_args
.push(next_option_value(&mut args, &flag)?);
options.schedule_option_explicit = true;
}
"--schedule-clear-args" => {
options.schedule_clear_args = true;
options.schedule_option_explicit = true;
}
"--schedule-clear-every" => {
options.schedule_clear_every = true;
options.schedule_option_explicit = true;
}
"--schedule-enabled" => {
options.schedule_enabled = Some(true);
options.schedule_option_explicit = true;
}
"--schedule-disabled" => {
options.schedule_enabled = Some(false);
options.schedule_option_explicit = true;
}
"--error-retry" => {
options.error_retry_interval =
parse_duration(&next_option_value(&mut args, &flag)?)?;
@@ -6741,7 +7093,7 @@ fn parse_args_with_env(
options.progress = false;
options.banner = false;
}
CliCommand::Refresh | CliCommand::Verify | CliCommand::Repair => {
CliCommand::Pull | CliCommand::Refresh | CliCommand::Verify | CliCommand::Repair => {
if !options.config.auto_discover
&& options.config.server_info_source.is_none()
&& options.config.connection_group.is_none()
@@ -6756,6 +7108,52 @@ fn parse_args_with_env(
options.config.repair = false;
}
}
CliCommand::Parse | CliCommand::Translate | CliCommand::PublishLocalized => {
if options.daemon || options.daemon_child {
return Err(anyhow::anyhow!(
"parse/translate/publish-localized 使用 --watch 或 schedule,不支持 daemon"
));
}
if options.config.dry_run {
return Err(anyhow::anyhow!(
"parse/translate/publish-localized 不能使用 --dry-run"
));
}
}
CliCommand::TranslationSet | CliCommand::Repack => {
if options.watch || options.daemon || options.daemon_child {
return Err(anyhow::anyhow!("translation set/repack 只支持单次执行"));
}
if options.config.force || options.sync_option_explicit {
return Err(anyhow::anyhow!("translation set/repack 不接受资源同步选项"));
}
options.progress = false;
options.banner = false;
}
CliCommand::ScheduleList
| CliCommand::ScheduleAdd
| CliCommand::ScheduleUpdate
| CliCommand::ScheduleRemove
| CliCommand::ScheduleRun => {
if options.daemon || options.daemon_child {
return Err(anyhow::anyhow!("schedule 命令不能使用 daemon"));
}
if options.output_explicit
|| options.proxy_option_explicit
|| (options.config.force && !matches!(options.command, CliCommand::ScheduleRun))
|| options.config.dry_run
|| tools_are_non_default(&options.config, &options.env_baseline_config)
{
return Err(anyhow::anyhow!(
"schedule 命令只接受 --state-dir、--json/--human、调度选项和 schedule run 的 --watch/--interval/--force"
));
}
if options.interval_explicit && !matches!(options.command, CliCommand::ScheduleRun) {
return Err(anyhow::anyhow!("--interval 只适用于 schedule run 的轮询"));
}
options.progress = false;
options.banner = false;
}
CliCommand::Restart | CliCommand::Reload => {
if (options.sync_option_explicit
|| options.output_explicit
@@ -6787,6 +7185,35 @@ fn parse_args_with_env(
if options.error_retry_interval.is_zero() {
return Err(anyhow::anyhow!("watch 失败重试间隔必须大于 0"));
}
if options.run_count.is_some_and(|count| count > 1)
&& !options.interval_explicit
&& matches!(
options.command,
CliCommand::Pull
| CliCommand::Parse
| CliCommand::Translate
| CliCommand::PublishLocalized
)
{
return Err(anyhow::anyhow!(
"--run-count 大于 1 时必须显式指定 --interval"
));
}
if matches!(
options.command,
CliCommand::Pull | CliCommand::Parse | CliCommand::Translate | CliCommand::PublishLocalized
) {
if options.watch && options.run_count.is_some() {
return Err(anyhow::anyhow!(
"--watch 与 --run-count 不能同时指定;周期执行请使用 --watch"
));
}
if options.interval_explicit && !options.watch && options.run_count.unwrap_or(1) <= 1 {
return Err(anyhow::anyhow!(
"--interval 需要配合 --watch 或 --run-count 大于 1"
));
}
}
if (options.watch || options.daemon || options.daemon_child)
&& !options.quiet_up_to_date_explicit
{
@@ -6818,6 +7245,109 @@ fn parse_args_with_env(
Ok(options)
}
fn parse_resource_command(
args: &mut impl Iterator<Item = String>,
options: &mut CliOptions,
) -> anyhow::Result<()> {
let action = next_option_value(args, "resource")?;
match action.as_str() {
"pull" => {
ensure_command_not_set(options.command, "resource pull")?;
options.command = CliCommand::Pull;
}
"refresh" => {
ensure_command_not_set(options.command, "resource refresh")?;
options.command = CliCommand::Refresh;
}
"verify" => {
ensure_command_not_set(options.command, "resource verify")?;
options.command = CliCommand::Verify;
}
"repair" => {
ensure_command_not_set(options.command, "resource repair")?;
options.command = CliCommand::Repair;
}
"status" => {
ensure_command_not_set(options.command, "resource status")?;
options.command = CliCommand::ResourceIndex;
}
"index" => {
ensure_command_not_set(options.command, "resource index")?;
options.command = CliCommand::ResourceIndex;
}
"schedule" => {
options.schedule_group = Some("res".to_string());
parse_schedule_command(args, options)?
}
other => return Err(anyhow::anyhow!("未知 resource 二级命令:{other}")),
}
Ok(())
}
fn parse_parse_command(
args: &mut impl Iterator<Item = String>,
options: &mut CliOptions,
) -> anyhow::Result<()> {
let action = next_option_value(args, "parse")?;
let command = match action.as_str() {
"run" => CliCommand::Parse,
"status" => CliCommand::ParseStatus,
"text-units" => CliCommand::ParseTextUnits,
"errors" => CliCommand::ParseErrors,
"schedule" => {
options.schedule_group = Some("parse".to_string());
return parse_schedule_command(args, options);
}
"repack" => CliCommand::Repack,
other => return Err(anyhow::anyhow!("未知 parse 二级命令:{other}")),
};
ensure_command_not_set(options.command, &format!("parse {action}"))?;
options.command = command;
Ok(())
}
fn parse_translation_command(
args: &mut impl Iterator<Item = String>,
options: &mut CliOptions,
) -> anyhow::Result<()> {
let action = next_option_value(args, "translation")?;
let command = match action.as_str() {
"run" => CliCommand::Translate,
"export" => CliCommand::Translate,
"set" => CliCommand::TranslationSet,
"publish" => CliCommand::PublishLocalized,
"tasks" => CliCommand::TranslationTasks,
"handoff" => CliCommand::TranslationHandoff,
"status" => CliCommand::LocalizedStatus,
"schedule" => {
options.schedule_group = Some("i18n".to_string());
return parse_schedule_command(args, options);
}
other => return Err(anyhow::anyhow!("未知 translation 二级命令:{other}")),
};
ensure_command_not_set(options.command, &format!("translation {action}"))?;
options.command = command;
Ok(())
}
fn parse_schedule_command(
args: &mut impl Iterator<Item = String>,
options: &mut CliOptions,
) -> anyhow::Result<()> {
let action = next_option_value(args, "schedule")?;
let command = match action.as_str() {
"list" => CliCommand::ScheduleList,
"add" => CliCommand::ScheduleAdd,
"update" => CliCommand::ScheduleUpdate,
"remove" | "delete" => CliCommand::ScheduleRemove,
"run" => CliCommand::ScheduleRun,
other => return Err(anyhow::anyhow!("未知 schedule 二级命令:{other}")),
};
ensure_command_not_set(options.command, &format!("schedule {action}"))?;
options.command = command;
Ok(())
}
fn ensure_command_not_set(command: CliCommand, next: &str) -> anyhow::Result<()> {
if command == CliCommand::Run {
Ok(())
@@ -6902,6 +7432,15 @@ fn print_usage(binary: &str) {
eprintln!(" {binary} <COMMAND> [OPTIONS]");
eprintln!();
eprintln!("Commands:");
eprintln!(" res pull Pull official resources once or repeatedly");
eprintln!(" res schedule Manage resource pull schedules (CLI/RPC/dashboard)");
eprintln!(" parse run Parse current official release");
eprintln!(" parse repack Repack a UnityFS bundle from a JSON spec");
eprintln!(" i18n run Refresh offline translation work");
eprintln!(" i18n export Export an editable translation workbench");
eprintln!(" i18n set Update one translation workbench entry");
eprintln!(" i18n publish Publish a localized release");
eprintln!(" i18n schedule Manage translation schedules");
eprintln!(" refresh Run one update check, or ask a live daemon to refresh");
eprintln!(" verify Verify remote plan, local manifest, and official seed hashes");
eprintln!(" repair Redownload resources that fail local verification");
@@ -6930,6 +7469,10 @@ fn print_usage(binary: &str) {
eprintln!(" {binary} --auto-discover --dry-run");
eprintln!(" {binary} --auto-discover --watch");
eprintln!(" {binary} --auto-discover --daemon");
eprintln!(" {binary} res pull --auto-discover --run-count 3 --interval 1h");
eprintln!(" {binary} parse run --force --resource-root /tmp/bat-release");
eprintln!(" {binary} i18n export --translation-file /tmp/bat-workbench.json");
eprintln!(" {binary} i18n publish --translation-file /tmp/bat-workbench.json --force");
eprintln!(" {binary} status");
eprintln!(" {binary} refresh --force --json");
eprintln!();
@@ -6971,6 +7514,15 @@ fn print_usage(binary: &str) {
eprintln!(" --force Force download/refresh");
eprintln!(" --audit-local | --no-audit-local Enable/disable local manifest audit");
eprintln!(" --repair | --no-repair Enable/disable automatic repair");
eprintln!(" --run-count <N> Run pull/parse/translate/publish N times");
eprintln!(" --once Explicitly select one run");
eprintln!(" --resource-root <DIR> Use an explicit published official release root");
eprintln!(" --translation-file <PATH> Translation workbench JSON file");
eprintln!(" --translation-id <ID> TextUnit ID for i18n set");
eprintln!(" --translated-text <TEXT> Inline translation for i18n set");
eprintln!(" --translated-file <PATH> UTF-8 translation file for i18n set");
eprintln!(" --localized-release-id <ID> Explicit localized publication ID");
eprintln!(" --repack-spec <PATH> UnityFS batch repack JSON spec");
eprintln!();
eprintln!("Read-only queries:");
eprintln!(" --offset <N> Query offset for resource-index/parse-text-units/parse-errors/translation-tasks");
@@ -7031,6 +7583,16 @@ fn print_usage(binary: &str) {
eprintln!(" --quiet-up-to-date Suppress clean up-to-date reports");
eprintln!(" --no-quiet-up-to-date Always print reports");
eprintln!(" --tail <N> Log lines for logs command (default: 200)");
eprintln!(" --schedule-id <ID> Schedule identifier");
eprintln!(" --schedule-action <ACTION> Schedule action (pull/run/repack/publish)");
eprintln!(" --schedule-at-unix <SECONDS> First execution time");
eprintln!(" --schedule-delay <DURATION> Delay first execution from now");
eprintln!(" --schedule-every <DURATION> Period between executions");
eprintln!(" --schedule-count <N> Bounded execution count");
eprintln!(" --schedule-arg <ARG> Argument passed to scheduled child command");
eprintln!(" --schedule-clear-args Clear args during schedule update");
eprintln!(" --schedule-clear-every Convert a periodic plan to one-shot");
eprintln!(" --schedule-enabled/--schedule-disabled Enable/disable a schedule");
eprintln!();
eprintln!("Output:");
eprintln!(" --human Human-readable output (default)");
+249
View File
@@ -1,4 +1,5 @@
use super::*;
use crate::app::schedule_commands::read_schedule_file;
fn parse(values: &[&str]) -> anyhow::Result<CliOptions> {
parse_args_from(values.iter().map(|value| value.to_string()))
@@ -136,6 +137,254 @@ fn env_invalid_values_error() {
assert!(parse(&["bat", "--download-concurrency", "257"]).is_err());
}
#[test]
fn grouped_workflow_commands_use_short_top_level_aliases() {
let options = parse(&[
"bat",
"res",
"pull",
"--run-count",
"3",
"--interval",
"10s",
"--download-concurrency",
"8",
])
.unwrap();
assert_eq!(options.command, CliCommand::Pull);
assert_eq!(options.run_count, Some(3));
assert_eq!(options.interval, Duration::from_secs(10));
assert!(options.interval_explicit);
let options = parse(&[
"bat",
"parse",
"run",
"--resource-root",
"/tmp/official-release",
"--force",
])
.unwrap();
assert_eq!(options.command, CliCommand::Parse);
assert_eq!(
options.resource_root,
Some(PathBuf::from("/tmp/official-release"))
);
assert!(options.config.force);
let options = parse(&[
"bat",
"i18n",
"set",
"--translation-file",
"/tmp/workbench.json",
"--translation-id",
"unit-1",
"--translated-text",
"你好",
])
.unwrap();
assert_eq!(options.command, CliCommand::TranslationSet);
assert_eq!(
options.translation_file,
Some(PathBuf::from("/tmp/workbench.json"))
);
assert_eq!(options.translation_id.as_deref(), Some("unit-1"));
let options = parse(&[
"bat",
"parse",
"repack",
"--repack-spec",
"/tmp/repack.json",
])
.unwrap();
assert_eq!(options.command, CliCommand::Repack);
assert_eq!(options.repack_spec, Some(PathBuf::from("/tmp/repack.json")));
}
#[test]
fn grouped_command_long_aliases_and_schedule_options_are_accepted() {
let options = parse(&[
"bat",
"resources",
"schedule",
"add",
"--schedule-id",
"nightly-pull",
"--schedule-action",
"pull",
"--schedule-delay",
"5m",
"--schedule-every",
"1h",
"--schedule-count",
"4",
"--schedule-arg",
"--auto-discover",
])
.unwrap();
assert_eq!(options.command, CliCommand::ScheduleAdd);
assert_eq!(options.schedule_group.as_deref(), Some("res"));
assert_eq!(options.schedule_id.as_deref(), Some("nightly-pull"));
assert_eq!(options.schedule_action.as_deref(), Some("pull"));
assert_eq!(options.schedule_delay, Some(Duration::from_secs(300)));
assert_eq!(options.schedule_every, Some(Duration::from_secs(3600)));
assert_eq!(options.schedule_count, Some(4));
assert_eq!(options.schedule_args, vec!["--auto-discover"]);
let options = parse(&["bat", "translate", "schedule", "run", "--force"]).unwrap();
assert_eq!(options.command, CliCommand::ScheduleRun);
assert_eq!(options.schedule_group.as_deref(), Some("i18n"));
assert!(options.config.force);
}
#[test]
fn repeated_workflow_requires_explicit_interval_after_first_run() {
assert!(parse(&["bat", "parse", "run", "--run-count", "2"]).is_err());
assert!(parse(&["bat", "parse", "run", "--run-count", "0"]).is_err());
assert!(parse(&["bat", "parse", "run", "--watch", "--run-count", "2"]).is_err());
assert!(parse(&["bat", "parse", "run", "--interval", "1m"]).is_err());
assert!(parse(&[
"bat",
"parse",
"run",
"--run-count",
"2",
"--interval",
"1m",
])
.is_ok());
}
#[test]
fn schedule_crud_persists_and_updates_a_workflow_plan() {
let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path().to_string_lossy().to_string();
let add = parse(&[
"bat",
"res",
"schedule",
"add",
"--state-dir",
&state_dir,
"--schedule-id",
"pull-once",
"--schedule-action",
"pull",
"--schedule-delay",
"1s",
"--schedule-every",
"1h",
"--schedule-count",
"2",
"--schedule-arg",
"--auto-discover",
])
.unwrap();
run_schedule_add(&add).unwrap();
let file = read_schedule_file(temp.path()).unwrap();
assert_eq!(file.schedules.len(), 1);
assert_eq!(file.schedules[0].id, "pull-once");
assert_eq!(file.schedules[0].remaining_runs, Some(2));
assert_eq!(file.schedules[0].interval_seconds, Some(3600));
assert_eq!(file.schedules[0].args, vec!["--auto-discover"]);
let update = parse(&[
"bat",
"res",
"schedule",
"update",
"--state-dir",
&state_dir,
"--schedule-id",
"pull-once",
"--schedule-disabled",
"--schedule-every",
"1h",
])
.unwrap();
run_schedule_update(&update).unwrap();
let file = read_schedule_file(temp.path()).unwrap();
assert!(!file.schedules[0].enabled);
assert_eq!(file.schedules[0].interval_seconds, Some(3600));
let clear_every = parse(&[
"bat",
"res",
"schedule",
"update",
"--state-dir",
&state_dir,
"--schedule-id",
"pull-once",
"--schedule-clear-every",
"--schedule-count",
"1",
])
.unwrap();
run_schedule_update(&clear_every).unwrap();
let file = read_schedule_file(temp.path()).unwrap();
assert_eq!(file.schedules[0].interval_seconds, None);
assert_eq!(file.schedules[0].remaining_runs, Some(1));
let remove = parse(&[
"bat",
"res",
"schedule",
"remove",
"--state-dir",
&state_dir,
"--schedule-id",
"pull-once",
])
.unwrap();
run_schedule_remove(&remove).unwrap();
assert!(read_schedule_file(temp.path())
.unwrap()
.schedules
.is_empty());
}
#[test]
fn dispatch_schedule_crud_uses_shared_state_file() {
let temp = tempfile::TempDir::new().unwrap();
let state_dir = temp.path();
let control = new_daemon_control();
let add = dispatch_rpc_method(
&rpc_request(
"schedule.add",
Some(serde_json::json!({
"id": "rpc-pull",
"group": "res",
"action": "pull",
"delay_seconds": 60,
"every_seconds": 3600,
"count": 2
})),
),
state_dir,
&control,
&test_task_context(),
"req-schedule-add".to_string(),
);
let add = serde_json::to_value(add).unwrap();
assert_eq!(add["ok"], true);
assert_eq!(add["data"]["schedule"]["id"], "rpc-pull");
let list = dispatch_rpc_method(
&rpc_request("schedule.list", None),
state_dir,
&control,
&test_task_context(),
"req-schedule-list".to_string(),
);
let list = serde_json::to_value(list).unwrap();
assert_eq!(list["ok"], true);
assert_eq!(list["data"]["schedules"][0]["id"], "rpc-pull");
}
#[test]
fn cli_download_concurrency_is_preserved_for_daemon_child() {
let options = parse(&["bat", "--download-concurrency", "4"]).unwrap();
@@ -0,0 +1,689 @@
use super::*;
const SCHEDULES_FILE_NAME: &str = "bat-schedules.json";
const SCHEDULE_LOCK_FILE_NAME: &str = "bat-schedule.lock";
const SCHEDULES_SCHEMA_VERSION: u32 = 1;
static SCHEDULE_FILE_LOCK: Mutex<()> = Mutex::new(());
#[derive(Debug)]
struct ScheduleFileLock {
path: PathBuf,
pid: u32,
}
impl ScheduleFileLock {
fn acquire(state_dir: &Path) -> anyhow::Result<Self> {
validate_runtime_state_dir(state_dir).map_err(anyhow::Error::msg)?;
fs::create_dir_all(state_dir)?;
let path = state_dir.join(SCHEDULE_LOCK_FILE_NAME);
let pid = std::process::id();
for attempt in 0..=1 {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
options.mode(PRIVATE_FILE_MODE);
match options.open(&path) {
Ok(mut file) => {
file.write_all(pid.to_string().as_bytes())?;
return Ok(Self { path, pid });
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
if attempt == 0 && remove_recoverable_pid_lock(&path)? {
continue;
}
return Err(anyhow::anyhow!(
"调度计划已被锁定:{}{}",
path.display(),
describe_pid_lock_owner(&path)?
));
}
Err(error) => {
return Err(anyhow::anyhow!(
"获取调度计划锁失败 {}:{error}",
path.display()
));
}
}
}
Err(anyhow::anyhow!("获取调度计划锁失败"))
}
}
impl Drop for ScheduleFileLock {
fn drop(&mut self) {
let expected = self.pid.to_string();
if fs::symlink_metadata(&self.path)
.map(|metadata| metadata.file_type().is_symlink())
.unwrap_or(false)
{
return;
}
if fs::read_to_string(&self.path)
.map(|contents| contents.trim() == expected)
.unwrap_or(false)
{
let _ = fs::remove_file(&self.path);
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct ScheduleFile {
pub(super) schema_version: u32,
pub(super) schedules: Vec<ScheduleEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(super) struct ScheduleEntry {
pub(super) id: String,
pub(super) group: String,
pub(super) action: String,
pub(super) args: Vec<String>,
pub(super) next_run_unix_seconds: u64,
pub(super) interval_seconds: Option<u64>,
pub(super) remaining_runs: Option<usize>,
pub(super) enabled: bool,
pub(super) created_unix_seconds: u64,
pub(super) updated_unix_seconds: u64,
pub(super) last_run_unix_seconds: Option<u64>,
pub(super) last_status: Option<String>,
pub(super) last_error: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub(super) struct ScheduleMutationRequest {
#[serde(default, alias = "schedule_id")]
pub(super) id: Option<String>,
#[serde(default)]
pub(super) group: Option<String>,
#[serde(default)]
pub(super) action: Option<String>,
#[serde(default)]
pub(super) args: Vec<String>,
#[serde(default, alias = "at_unix_seconds", alias = "schedule_at_unix")]
pub(super) next_run_unix_seconds: Option<u64>,
#[serde(default, alias = "schedule_delay_seconds")]
pub(super) delay_seconds: Option<u64>,
#[serde(default, alias = "schedule_every_seconds")]
pub(super) every_seconds: Option<u64>,
#[serde(default, alias = "schedule_count")]
pub(super) count: Option<usize>,
#[serde(default)]
pub(super) clear_args: bool,
#[serde(default)]
pub(super) clear_every: bool,
#[serde(default)]
pub(super) enabled: Option<bool>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub(super) struct ScheduleRunRequest {
#[serde(default, alias = "schedule_id")]
pub(super) id: Option<String>,
#[serde(default)]
pub(super) force: bool,
}
pub(super) fn run_schedule_list(options: &CliOptions) -> anyhow::Result<()> {
print_json_value(
options.output_format,
&schedule_list_report(&options.state_dir)?,
)
}
pub(super) fn run_schedule_add(options: &CliOptions) -> anyhow::Result<()> {
validate_schedule_command_options(options, false)?;
let request = schedule_request_from_options(options);
print_json_value(
options.output_format,
&schedule_add_report(&options.state_dir, request)?,
)
}
pub(super) fn run_schedule_update(options: &CliOptions) -> anyhow::Result<()> {
validate_schedule_command_options(options, true)?;
let request = schedule_request_from_options(options);
print_json_value(
options.output_format,
&schedule_update_report(&options.state_dir, request)?,
)
}
pub(super) fn run_schedule_remove(options: &CliOptions) -> anyhow::Result<()> {
validate_schedule_command_options(options, true)?;
let request = schedule_request_from_options(options);
print_json_value(
options.output_format,
&schedule_remove_report(&options.state_dir, request)?,
)
}
pub(super) fn run_schedule_run(options: &CliOptions) -> anyhow::Result<()> {
validate_schedule_command_options(options, true)?;
loop {
let request = ScheduleRunRequest {
id: options.schedule_id.clone(),
force: options.config.force,
};
print_json_value(
options.output_format,
&schedule_run_report(&options.state_dir, request)?,
)?;
if !options.watch {
return Ok(());
}
thread::sleep(options.interval);
}
}
pub(super) fn schedule_list_report(state_dir: &Path) -> anyhow::Result<serde_json::Value> {
let _guard = SCHEDULE_FILE_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let _schedule_lock = ScheduleFileLock::acquire(state_dir)?;
let file = read_schedule_file(state_dir)?;
Ok(serde_json::json!({
"command": "schedule-list",
"status": "ok",
"state_file": schedule_file_path(state_dir),
"schedules": file.schedules,
}))
}
pub(super) fn schedule_add_report(
state_dir: &Path,
request: ScheduleMutationRequest,
) -> anyhow::Result<serde_json::Value> {
let _guard = SCHEDULE_FILE_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let _schedule_lock = ScheduleFileLock::acquire(state_dir)?;
let mut file = read_schedule_file(state_dir)?;
let id = request
.id
.as_deref()
.ok_or_else(|| anyhow::anyhow!("schedule add 必须指定 --schedule-id"))?;
if file.schedules.iter().any(|entry| entry.id == id) {
return Err(anyhow::anyhow!("schedule 已存在:{id}"));
}
let now = unix_seconds_now();
let entry = build_schedule_entry(&request, now)?;
file.schedules.push(entry.clone());
write_schedule_file(state_dir, &file)?;
Ok(schedule_result_value(
state_dir,
"schedule-add",
"created",
&entry,
))
}
pub(super) fn schedule_update_report(
state_dir: &Path,
request: ScheduleMutationRequest,
) -> anyhow::Result<serde_json::Value> {
let _guard = SCHEDULE_FILE_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let _schedule_lock = ScheduleFileLock::acquire(state_dir)?;
validate_schedule_mutation(&request, true)?;
let id = request
.id
.as_deref()
.ok_or_else(|| anyhow::anyhow!("schedule update 必须指定 --schedule-id"))?;
let mut file = read_schedule_file(state_dir)?;
let entry = file
.schedules
.iter_mut()
.find(|entry| entry.id == id)
.ok_or_else(|| anyhow::anyhow!("schedule 不存在:{id}"))?;
if let Some(group) = request
.group
.as_deref()
.map(normalize_schedule_group)
.transpose()?
{
if group != entry.group {
return Err(anyhow::anyhow!(
"schedule {} 属于 {},不能从 {} 二级命令更新",
id,
entry.group,
group
));
}
}
if let Some(action) = request.action.as_deref() {
validate_schedule_action(entry.group.as_str(), action)?;
entry.action = action.to_string();
}
if let Some(at) = request.next_run_unix_seconds {
entry.next_run_unix_seconds = at;
}
if let Some(delay) = request.delay_seconds {
entry.next_run_unix_seconds = unix_seconds_now().saturating_add(delay);
}
if let Some(every) = request.every_seconds {
entry.interval_seconds = Some(nonzero_seconds(
Duration::from_secs(every),
"--schedule-every",
)?);
}
if request.clear_every {
entry.interval_seconds = None;
}
if let Some(count) = request.count {
entry.remaining_runs = Some(count);
}
if request.clear_args {
entry.args.clear();
}
if !request.args.is_empty() {
validate_schedule_args(&request.args)?;
entry.args = request.args.clone();
}
if let Some(enabled) = request.enabled {
entry.enabled = enabled;
}
if entry.interval_seconds.is_none() && request.clear_every && request.count.is_none() {
entry.remaining_runs = Some(1);
}
validate_schedule_entry_shape(entry)?;
entry.updated_unix_seconds = unix_seconds_now();
let updated = entry.clone();
write_schedule_file(state_dir, &file)?;
Ok(schedule_result_value(
state_dir,
"schedule-update",
"updated",
&updated,
))
}
pub(super) fn schedule_remove_report(
state_dir: &Path,
request: ScheduleMutationRequest,
) -> anyhow::Result<serde_json::Value> {
let _guard = SCHEDULE_FILE_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let _schedule_lock = ScheduleFileLock::acquire(state_dir)?;
let id = request
.id
.as_deref()
.ok_or_else(|| anyhow::anyhow!("schedule remove 必须指定 --schedule-id"))?;
let mut file = read_schedule_file(state_dir)?;
let before = file.schedules.len();
file.schedules.retain(|entry| entry.id != id);
if file.schedules.len() == before {
return Err(anyhow::anyhow!("schedule 不存在:{id}"));
}
write_schedule_file(state_dir, &file)?;
Ok(serde_json::json!({
"command": "schedule-remove",
"status": "removed",
"id": id,
"state_file": schedule_file_path(state_dir),
}))
}
pub(super) fn schedule_run_report(
state_dir: &Path,
request: ScheduleRunRequest,
) -> anyhow::Result<serde_json::Value> {
let _guard = SCHEDULE_FILE_LOCK
.lock()
.unwrap_or_else(|poison| poison.into_inner());
let _schedule_lock = ScheduleFileLock::acquire(state_dir)?;
let now = unix_seconds_now();
let selected_id = request.id.as_deref();
let mut file = read_schedule_file(state_dir)?;
let mut results = Vec::new();
for index in 0..file.schedules.len() {
let due = {
let entry = &file.schedules[index];
entry.enabled
&& (request.force || entry.next_run_unix_seconds <= now)
&& selected_id.is_none_or(|id| id == entry.id)
};
if !due {
continue;
}
let entry = &mut file.schedules[index];
let id = entry.id.clone();
let command = schedule_child_command(entry, state_dir);
let started = unix_seconds_now();
if let Some(remaining) = entry.remaining_runs.as_mut() {
*remaining = remaining.saturating_sub(1);
}
entry.last_run_unix_seconds = Some(started);
entry.updated_unix_seconds = started;
entry.enabled = entry.remaining_runs != Some(0);
entry.next_run_unix_seconds = entry
.interval_seconds
.map(|seconds| started.saturating_add(seconds))
.unwrap_or(started);
write_schedule_file(state_dir, &file)?;
let status = Command::new(&command[0]).args(&command[1..]).status();
let (status_label, error) = match status {
Ok(status) if status.success() => ("completed".to_string(), None),
Ok(status) => (
"failed".to_string(),
Some(format!("子命令退出码:{}", status.code().unwrap_or(-1))),
),
Err(error) => ("failed".to_string(), Some(error.to_string())),
};
let (next_run_unix_seconds, enabled) = {
let entry = &mut file.schedules[index];
entry.last_status = Some(status_label.clone());
entry.last_error = error.clone();
entry.updated_unix_seconds = unix_seconds_now();
(entry.next_run_unix_seconds, entry.enabled)
};
write_schedule_file(state_dir, &file)?;
results.push(serde_json::json!({
"id": id,
"command": command,
"status": status_label,
"error": error,
"next_run_unix_seconds": next_run_unix_seconds,
"enabled": enabled,
}));
}
if selected_id.is_some() && results.is_empty() {
let status = match file
.schedules
.iter()
.find(|entry| Some(entry.id.as_str()) == selected_id)
{
None => "not_found",
Some(entry) if !entry.enabled => "disabled",
Some(_) => "not_due",
};
return Ok(serde_json::json!({
"command": "schedule-run",
"status": status,
"now_unix_seconds": now,
"executed": [],
}));
}
Ok(serde_json::json!({
"command": "schedule-run",
"status": "completed",
"now_unix_seconds": now,
"executed": results,
}))
}
fn build_schedule_entry(
request: &ScheduleMutationRequest,
now: u64,
) -> anyhow::Result<ScheduleEntry> {
validate_schedule_mutation(request, false)?;
let group = request
.group
.as_deref()
.map(normalize_schedule_group)
.transpose()?
.ok_or_else(|| anyhow::anyhow!("schedule 命令缺少所属一级命令"))?;
let action = request
.action
.as_deref()
.map(str::to_string)
.unwrap_or_else(|| default_schedule_action(&group).to_string());
validate_schedule_action(&group, &action)?;
validate_schedule_args(&request.args)?;
let next_run = schedule_next_run(request, now)?;
let interval_seconds = request
.every_seconds
.map(|value| nonzero_seconds(Duration::from_secs(value), "--schedule-every"))
.transpose()?;
let remaining_runs = request
.count
.or_else(|| interval_seconds.is_none().then_some(1));
if interval_seconds.is_none() && remaining_runs.is_some_and(|count| count > 1) {
return Err(anyhow::anyhow!(
"--schedule-count 大于 1 时必须指定 --schedule-every"
));
}
Ok(ScheduleEntry {
id: request
.id
.clone()
.ok_or_else(|| anyhow::anyhow!("schedule add 必须指定 --schedule-id"))?,
group,
action,
args: request.args.clone(),
next_run_unix_seconds: next_run,
interval_seconds,
remaining_runs,
enabled: request.enabled.unwrap_or(true),
created_unix_seconds: now,
updated_unix_seconds: now,
last_run_unix_seconds: None,
last_status: None,
last_error: None,
})
}
fn validate_schedule_mutation(
request: &ScheduleMutationRequest,
update: bool,
) -> anyhow::Result<()> {
if request.next_run_unix_seconds.is_some() && request.delay_seconds.is_some() {
return Err(anyhow::anyhow!(
"next_run_unix_seconds 与 delay_seconds 只能指定一个"
));
}
if request.every_seconds.is_some() && request.clear_every {
return Err(anyhow::anyhow!("every_seconds 与 clear_every 只能指定一个"));
}
if request.count == Some(0) {
return Err(anyhow::anyhow!("count 必须大于 0"));
}
if request.clear_args && !update {
return Err(anyhow::anyhow!("clear_args 只适用于 schedule update"));
}
if request.clear_every && !update {
return Err(anyhow::anyhow!("clear_every 只适用于 schedule update"));
}
Ok(())
}
fn schedule_next_run(request: &ScheduleMutationRequest, now: u64) -> anyhow::Result<u64> {
match (request.next_run_unix_seconds, request.delay_seconds) {
(Some(_), Some(_)) => Err(anyhow::anyhow!(
"--schedule-at-unix 与 --schedule-delay 只能指定一个"
)),
(Some(at), None) => Ok(at),
(None, Some(delay)) => Ok(now.saturating_add(delay)),
(None, None) => Ok(now),
}
}
fn schedule_child_command(entry: &ScheduleEntry, state_dir: &Path) -> Vec<String> {
let executable = env::current_exe().unwrap_or_else(|_| PathBuf::from("bat"));
let mut command = vec![
executable.to_string_lossy().into_owned(),
entry.group.clone(),
entry.action.clone(),
];
command.extend(entry.args.iter().cloned());
if !entry.args.iter().any(|arg| arg == "--state-dir") {
command.push("--state-dir".to_string());
command.push(state_dir.to_string_lossy().into_owned());
}
command.push("--no-banner".to_string());
command.push("--no-progress".to_string());
command
}
fn validate_schedule_command_options(
options: &CliOptions,
allow_empty: bool,
) -> anyhow::Result<()> {
if !allow_empty && options.schedule_group.is_none() {
return Err(anyhow::anyhow!("schedule 命令缺少所属一级命令"));
}
if options.watch && !matches!(options.command, CliCommand::ScheduleRun) {
return Err(anyhow::anyhow!("只有 schedule run 支持 --watch"));
}
if options.interval.is_zero() {
return Err(anyhow::anyhow!("schedule 轮询间隔必须大于 0"));
}
if options.schedule_every.is_some_and(|value| value.is_zero()) {
return Err(anyhow::anyhow!("--schedule-every 必须大于 0"));
}
if options.schedule_delay.is_some_and(|value| value.is_zero()) {
return Err(anyhow::anyhow!("--schedule-delay 必须大于 0"));
}
if options.schedule_count == Some(0) {
return Err(anyhow::anyhow!("--schedule-count 必须大于 0"));
}
if options.schedule_clear_args && !matches!(options.command, CliCommand::ScheduleUpdate) {
return Err(anyhow::anyhow!(
"--schedule-clear-args 只适用于 schedule update"
));
}
if options.schedule_clear_every && !matches!(options.command, CliCommand::ScheduleUpdate) {
return Err(anyhow::anyhow!(
"--schedule-clear-every 只适用于 schedule update"
));
}
Ok(())
}
fn validate_schedule_action(group: &str, action: &str) -> anyhow::Result<()> {
let valid = match group {
"res" => matches!(action, "pull" | "refresh" | "verify" | "repair"),
"parse" => matches!(action, "run" | "repack"),
"i18n" => matches!(action, "run" | "export" | "publish"),
_ => false,
};
if valid {
Ok(())
} else {
Err(anyhow::anyhow!(
"不支持的 schedule actiongroup={group}, action={action}"
))
}
}
fn validate_schedule_entry_shape(entry: &ScheduleEntry) -> anyhow::Result<()> {
if entry.interval_seconds.is_none() && entry.remaining_runs.is_some_and(|count| count > 1) {
return Err(anyhow::anyhow!(
"非周期 schedule 不能保留多次执行次数;请设置 --schedule-every"
));
}
Ok(())
}
fn default_schedule_action(group: &str) -> &'static str {
match group {
"res" => "pull",
"parse" => "run",
"i18n" => "run",
_ => "",
}
}
fn validate_schedule_args(args: &[String]) -> anyhow::Result<()> {
if let Some(arg) = args
.iter()
.find(|arg| arg.starts_with("--schedule-") || matches!(arg.as_str(), "--id" | "--action"))
{
return Err(anyhow::anyhow!(
"schedule 子命令参数不能嵌套调度控制选项:{arg}"
));
}
Ok(())
}
fn nonzero_seconds(value: Duration, flag: &str) -> anyhow::Result<u64> {
let seconds = value.as_secs();
if seconds == 0 {
return Err(anyhow::anyhow!("{flag} 必须至少为 1s"));
}
Ok(seconds)
}
fn schedule_request_from_options(options: &CliOptions) -> ScheduleMutationRequest {
ScheduleMutationRequest {
id: options.schedule_id.clone(),
group: options.schedule_group.clone(),
action: options.schedule_action.clone(),
args: options.schedule_args.clone(),
next_run_unix_seconds: options.schedule_at_unix,
delay_seconds: options.schedule_delay.map(|value| value.as_secs()),
every_seconds: options.schedule_every.map(|value| value.as_secs()),
count: options.schedule_count,
clear_args: options.schedule_clear_args,
clear_every: options.schedule_clear_every,
enabled: options.schedule_enabled,
}
}
fn normalize_schedule_group(group: &str) -> anyhow::Result<String> {
let normalized = match group {
"res" | "resource" | "resources" => "res",
"parse" => "parse",
"i18n" | "tr" | "translation" | "translate" => "i18n",
other => {
return Err(anyhow::anyhow!(
"schedule 不支持的一级命令:{other}(支持 res、parse、i18n"
))
}
};
Ok(normalized.to_string())
}
fn schedule_result_value(
state_dir: &Path,
command: &'static str,
status: &'static str,
entry: &ScheduleEntry,
) -> serde_json::Value {
serde_json::json!({
"command": command,
"status": status,
"schedule": entry,
"state_file": schedule_file_path(state_dir),
})
}
fn schedule_file_path(state_dir: &Path) -> PathBuf {
state_dir.join(SCHEDULES_FILE_NAME)
}
pub(super) fn read_schedule_file(state_dir: &Path) -> anyhow::Result<ScheduleFile> {
let path = schedule_file_path(state_dir);
let Some(bytes) = read_file_no_symlink(&path, "调度计划文件").map_err(anyhow::Error::msg)?
else {
return Ok(ScheduleFile {
schema_version: SCHEDULES_SCHEMA_VERSION,
schedules: Vec::new(),
});
};
let file: ScheduleFile = serde_json::from_slice(&bytes)?;
if file.schema_version != SCHEDULES_SCHEMA_VERSION {
return Err(anyhow::anyhow!(
"不支持的调度计划 schema:{},当前版本={}",
file.schema_version,
SCHEDULES_SCHEMA_VERSION
));
}
Ok(file)
}
fn write_schedule_file(state_dir: &Path, file: &ScheduleFile) -> anyhow::Result<()> {
validate_runtime_state_dir(state_dir).map_err(anyhow::Error::msg)?;
let path = schedule_file_path(state_dir);
let bytes = serde_json::to_vec_pretty(file)?;
write_file_atomic(
&path,
&bytes,
bat_infrastructure::STATE_FILE_MODE,
"调度计划文件",
)
.map_err(anyhow::Error::msg)
}
@@ -0,0 +1,193 @@
use super::*;
pub(super) fn run_parse_once(options: &CliOptions) -> anyhow::Result<()> {
let (resource_root, release_id) = current_official_release(options)?;
let parse_config =
OfficialParseConfig::new(&resource_root, options.config.unzip_command.clone())
.with_force(options.config.force);
let parse_report = OfficialParseCacheService::new()
.run(&parse_config)
.map_err(anyhow::Error::msg)?;
let queue_report =
write_official_textunit_queues(&resource_root).map_err(anyhow::Error::msg)?;
let data = serde_json::json!({
"official_release_id": release_id,
"resource_root": resource_root,
"forced": options.config.force,
"parse": parse_report,
"translation_queue": queue_report,
});
print_report(
options.output_format,
&CommandReport {
command: "parse",
status: "completed",
message: "官方资源解析已执行",
data,
},
)
}
pub(super) fn run_translate_once(options: &CliOptions) -> anyhow::Result<()> {
let (resource_root, release_id) = current_official_release(options)?;
let queue = write_official_textunit_queues(&resource_root).map_err(anyhow::Error::msg)?;
let exported = options
.translation_file
.as_ref()
.map(|path| {
export_translation_workbench(&resource_root, release_id.clone(), path).map(
|workbench| {
serde_json::json!({
"path": path,
"entry_count": workbench.entries.len(),
})
},
)
})
.transpose()?;
let data = serde_json::json!({
"official_release_id": release_id,
"resource_root": resource_root,
"queue": queue,
"workbench": exported,
"provider": "offline",
"note": "当前 translate 只生成/刷新离线队列和可编辑工作台,不调用外部翻译 provider",
});
print_report(
options.output_format,
&CommandReport {
command: "translate",
status: "queued",
message: "翻译离线队列已刷新",
data,
},
)
}
pub(super) fn run_translation_set(options: &CliOptions) -> anyhow::Result<()> {
let path = options
.translation_file
.as_ref()
.ok_or_else(|| anyhow::anyhow!("translation-set 必须指定 --translation-file"))?;
let text = match (&options.translation_text, &options.translation_text_file) {
(Some(_), Some(_)) => {
return Err(anyhow::anyhow!(
"--translated-text 与 --translated-file 只能指定一个"
))
}
(Some(text), None) => text.clone(),
(None, Some(path)) => String::from_utf8(
read_file_no_symlink(path, "翻译文本文件")
.map_err(anyhow::Error::msg)?
.ok_or_else(|| anyhow::anyhow!("翻译文本文件不存在:{}", path.display()))?,
)?,
(None, None) => {
return Err(anyhow::anyhow!(
"translation-set 必须指定 --translated-text 或 --translated-file"
))
}
};
let entry_id = options
.translation_id
.as_deref()
.ok_or_else(|| anyhow::anyhow!("translation-set 必须指定 --translation-id"))?;
let entry = set_translation(path, entry_id, text)?;
let data = serde_json::json!({
"translation_file": path,
"entry": entry,
});
print_report(
options.output_format,
&CommandReport {
command: "translation-set",
status: "updated",
message: "翻译工作台条目已更新",
data,
},
)
}
pub(super) fn run_repack(options: &CliOptions) -> anyhow::Result<()> {
let spec = options
.repack_spec
.as_ref()
.ok_or_else(|| anyhow::anyhow!("repack 必须指定 --repack-spec"))?;
let report = repack_bundle(spec)?;
print_report(options.output_format, &report)
}
pub(super) fn run_publish_localized(options: &CliOptions) -> anyhow::Result<()> {
let translation_file = options
.translation_file
.as_ref()
.ok_or_else(|| anyhow::anyhow!("publish-localized 必须指定 --translation-file"))?;
let (resource_root, official_release_id) = current_official_release(options)?;
let workbench = read_translation_workbench(translation_file)?;
if workbench.official_release_id != official_release_id {
return Err(anyhow::anyhow!(
"翻译工作台 release={} 与当前官方 release={} 不一致;请重新导出",
workbench.official_release_id,
official_release_id
));
}
let expected_root = lexical_absolute(&resource_root).map_err(anyhow::Error::msg)?;
if workbench.official_resource_root != expected_root {
return Err(anyhow::anyhow!(
"翻译工作台资源根目录与当前 release 不一致;请重新导出"
));
}
let patches = localized_text_asset_patches(&resource_root, &workbench)?;
let localized_release_id = options.localized_release_id.clone().or_else(|| {
options
.config
.force
.then(|| format!("{}-manual-{}", official_release_id, unix_seconds_now()))
});
let mut config = LocalizedPatchConfig::new(
resource_root,
options.config.localized_output_root.clone(),
official_release_id,
patches,
)
.with_force(options.config.force);
if let Some(release_id) = localized_release_id {
config = config.with_localized_release_id(release_id);
}
let report = LocalizedPatchService::new().publish(&config)?;
print_report(options.output_format, &report)
}
fn current_official_release(options: &CliOptions) -> anyhow::Result<(PathBuf, String)> {
let state = read_version_state(&options.config.version_state_path())?;
let resource_root = if let Some(resource_root) = options.resource_root.clone() {
lexical_absolute(&resource_root).map_err(anyhow::Error::msg)?
} else {
state
.as_ref()
.and_then(|state| state.current_completed_version.as_ref())
.map(|record| record.resource_root.clone())
.unwrap_or(active_official_resource_root(&options.config.output_root)?)
};
let release_id = state
.as_ref()
.and_then(|state| state.current_completed_version.as_ref())
.filter(|_| options.resource_root.is_none())
.map(|record| record.id.clone())
.or_else(|| {
resource_root
.file_name()
.and_then(|name| name.to_str())
.map(str::to_string)
})
.ok_or_else(|| anyhow::anyhow!("无法从当前官方资源根目录确定 release id"))?;
if read_download_manifest_at(&resource_root)
.map_err(anyhow::Error::msg)?
.is_none()
{
return Err(anyhow::anyhow!(
"当前官方 release 缺少官方下载 manifest{}",
resource_root.display()
));
}
Ok((resource_root, release_id))
}
+7
View File
@@ -30,6 +30,7 @@ pub mod path_security;
pub mod release_flow;
pub mod resources;
pub mod translation_tasks;
pub mod translation_workflow;
mod zip_validation;
pub use cas::FileSystemCasRepository;
@@ -138,6 +139,12 @@ pub use translation_tasks::{
TRANSLATION_HANDOFF_FILE, TRANSLATION_HANDOFF_SCHEMA_VERSION, TRANSLATION_TASK_REPOSITORY_FILE,
TRANSLATION_TASK_SCHEMA_VERSION,
};
pub use translation_workflow::{
export_translation_workbench, localized_text_asset_patches, read_translation_workbench,
repack_bundle, set_translation, write_translation_workbench, RepackOperation, RepackReport,
RepackSpec, TranslationWorkbench, TranslationWorkbenchEntry, REPACK_SPEC_VERSION,
TRANSLATION_WORKBENCH_VERSION,
};
/// Infrastructure 版本号
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
+54 -19
View File
@@ -42,6 +42,13 @@ pub struct LocalizedPatchConfig {
pub localized_output_root: PathBuf,
/// Version identifier shared with the official release.
pub release_id: String,
/// Optional distinct localized release ID. When omitted, `release_id` is
/// used for backward-compatible publication paths.
pub localized_release_id: Option<String>,
/// Allow publishing a new localized release even when the source release
/// already has a localized current release. The caller should normally
/// provide a distinct localized release ID.
pub force: bool,
/// Patch operations to apply.
pub patches: Vec<LocalizedTextAssetPatch>,
}
@@ -58,9 +65,29 @@ impl LocalizedPatchConfig {
official_release_root: official_release_root.into(),
localized_output_root: localized_output_root.into(),
release_id: release_id.into(),
localized_release_id: None,
force: false,
patches,
}
}
/// Sets a distinct localized release ID.
pub fn with_localized_release_id(mut self, release_id: impl Into<String>) -> Self {
self.localized_release_id = Some(release_id.into());
self
}
/// Enables or disables forced publication.
pub fn with_force(mut self, force: bool) -> Self {
self.force = force;
self
}
fn published_release_id(&self) -> &str {
self.localized_release_id
.as_deref()
.unwrap_or(&self.release_id)
}
}
/// Persisted localized release state.
@@ -187,7 +214,7 @@ impl LocalizedPatchManifest {
}
/// Result of a successful localized release publication.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LocalizedPatchReport {
/// Published version directory.
pub version_path: PathBuf,
@@ -217,14 +244,15 @@ impl LocalizedPatchService {
/// Copies the official release, applies patches in staging and publishes it.
pub fn publish(&self, config: &LocalizedPatchConfig) -> anyhow::Result<LocalizedPatchReport> {
let published_release_id = config.published_release_id().to_string();
let staging = config
.localized_output_root
.join(LOCALIZED_STAGING_DIR)
.join(&config.release_id);
.join(&published_release_id);
let version_path = config
.localized_output_root
.join(LOCALIZED_VERSIONS_DIR)
.join(&config.release_id);
.join(&published_release_id);
let current_path = config.localized_output_root.join(LOCALIZED_CURRENT_LINK);
let previous_current_target = current_symlink_target(&current_path).ok().flatten();
let version_existed_before = version_path.exists();
@@ -257,11 +285,11 @@ impl LocalizedPatchService {
let staging = config
.localized_output_root
.join(LOCALIZED_STAGING_DIR)
.join(&config.release_id);
.join(config.published_release_id());
let version_path = config
.localized_output_root
.join(LOCALIZED_VERSIONS_DIR)
.join(&config.release_id);
.join(config.published_release_id());
let current_path = config.localized_output_root.join(LOCALIZED_CURRENT_LINK);
let state_path = config
.localized_output_root
@@ -269,10 +297,12 @@ impl LocalizedPatchService {
let patch_manifest_path = version_path.join(LOCALIZED_PATCH_MANIFEST_FILE);
if version_path.exists() {
return Err(anyhow::anyhow!(
"localized release already exists: {}",
version_path.display()
));
let message = if config.force {
"localized release target already exists; forced publication requires a distinct localized release id"
} else {
"localized release already exists"
};
return Err(anyhow::anyhow!("{message}: {}", version_path.display()));
}
remove_owned_staging(&staging)?;
fs::create_dir_all(&staging)?;
@@ -313,7 +343,7 @@ impl LocalizedPatchService {
let manifest = LocalizedPatchManifest {
manifest_version: LOCALIZED_PATCH_MANIFEST_VERSION,
official_release_id: config.release_id.clone(),
localized_release_id: config.release_id.clone(),
localized_release_id: config.published_release_id().to_string(),
generated_unix_seconds: unix_seconds_now(),
file_count: changed_files.len(),
text_asset_operation_count: changed_files
@@ -339,13 +369,13 @@ impl LocalizedPatchService {
switch_current_symlink(
&config.localized_output_root,
&current_path,
&config.release_id,
config.published_release_id(),
)?;
let state = LocalizedVersionState {
state_version: 1,
official_release_id: config.release_id.clone(),
current_release_id: Some(config.release_id.clone()),
current_release_id: Some(config.published_release_id().to_string()),
status: "localized".to_string(),
updated_unix_seconds: unix_seconds_now(),
};
@@ -623,13 +653,18 @@ fn validate_config(config: &LocalizedPatchConfig) -> Result<(), String> {
localized.display()
));
}
if config.release_id.is_empty()
|| config.release_id.contains('/')
|| config.release_id.contains('\\')
|| config.release_id == "."
|| config.release_id == ".."
{
return Err(format!("非法汉化 release id{}", config.release_id));
for (label, release_id) in [
("官方", config.release_id.as_str()),
("汉化", config.published_release_id()),
] {
if release_id.is_empty()
|| release_id.contains('/')
|| release_id.contains('\\')
|| release_id == "."
|| release_id == ".."
{
return Err(format!("非法{label} release id{release_id}"));
}
}
ensure_safe_directory_path(&config.official_release_root, "官方 release")?;
ensure_safe_directory_path(&config.localized_output_root, "汉化输出目录")?;
+19 -2
View File
@@ -35,6 +35,8 @@ pub struct OfficialParseConfig {
pub resource_root: PathBuf,
/// `unzip` executable used to inspect zip archives without extracting them.
pub unzip_command: PathBuf,
/// Ignore a matching previous cache and inspect every manifest candidate.
pub force: bool,
}
impl OfficialParseConfig {
@@ -43,9 +45,16 @@ impl OfficialParseConfig {
Self {
resource_root: resource_root.into(),
unzip_command: unzip_command.into(),
force: false,
}
}
/// Enables or disables forced cache regeneration.
pub fn with_force(mut self, force: bool) -> Self {
self.force = force;
self
}
/// Returns the parse-cache path for this resource root.
pub fn cache_path(&self) -> PathBuf {
self.resource_root.join(OFFICIAL_PARSE_CACHE_FILE)
@@ -357,8 +366,16 @@ impl OfficialParseCacheService {
config.resource_root.display()
)
})?;
let previous_cache = read_parse_cache_at(&config.resource_root)?;
let previous_textunit_index = read_textunit_index_at(&config.resource_root)?;
let previous_cache = if config.force {
None
} else {
read_parse_cache_at(&config.resource_root)?
};
let previous_textunit_index = if config.force {
None
} else {
read_textunit_index_at(&config.resource_root)?
};
let mut summary = OfficialParseSummary {
manifest_entry_count: manifest.entries.len(),
..OfficialParseSummary::default()
+544
View File
@@ -0,0 +1,544 @@
//! Manual translation workbench and controlled UnityFS repack workflows.
use crate::official_parse::{read_textunit_index_at, OfficialTextUnitIndexUnit};
use crate::path_security::{
ensure_safe_file_target, lexical_absolute, read_file_no_symlink, write_file_atomic,
STATE_FILE_MODE,
};
use crate::LocalizedTextAssetPatch;
use bat_assetbundle::{
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch,
StringFieldPatch, TextAssetPatch, UnitySerializedReplacementValue,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
/// Current manual translation workbench schema.
pub const TRANSLATION_WORKBENCH_VERSION: u32 = 1;
/// A manually editable translation file for one official release.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TranslationWorkbench {
/// Workbench schema version.
pub schema_version: u32,
/// Official release consumed by this workbench.
pub official_release_id: String,
/// Official release root used to generate the entries.
pub official_resource_root: PathBuf,
/// Workbench generation time.
pub generated_unix_seconds: u64,
/// TextUnit entries in stable parse-index order.
pub entries: Vec<TranslationWorkbenchEntry>,
}
/// One manually editable TextUnit translation entry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TranslationWorkbenchEntry {
/// Stable TextUnit ID.
pub id: String,
/// Relative official resource destination.
pub destination: String,
/// Archive entry, when the source is nested in a zip.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub archive_entry: Option<String>,
/// Unity serialized file path.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub serialized_file: Option<String>,
/// Unity object path ID.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path_id: Option<i64>,
/// Unity TextAsset name, when known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_name: Option<String>,
/// Extracted source text. This is checked again before publishing.
pub source_text: String,
/// Human translation. `null` means not reviewed yet; an empty string is
/// an intentional empty translation.
#[serde(default)]
pub translated_text: Option<String>,
/// TextUnit format.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
/// Extraction source kind such as TextAsset or TypeTreeField.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_source_kind: Option<String>,
}
/// Exports the current official TextUnit index as an editable workbench.
pub fn export_translation_workbench(
resource_root: &Path,
official_release_id: impl Into<String>,
output_path: &Path,
) -> anyhow::Result<TranslationWorkbench> {
let index = read_textunit_index_at(resource_root)
.map_err(anyhow::Error::msg)?
.ok_or_else(|| {
anyhow::anyhow!(
"缺少官方 TextUnit 索引,无法导出翻译工作台:{}",
resource_root.display()
)
})?;
let workbench = TranslationWorkbench {
schema_version: TRANSLATION_WORKBENCH_VERSION,
official_release_id: official_release_id.into(),
official_resource_root: lexical_absolute(resource_root).map_err(anyhow::Error::msg)?,
generated_unix_seconds: unix_seconds_now(),
entries: index
.units
.iter()
.map(TranslationWorkbenchEntry::from_index)
.collect(),
};
write_translation_workbench(output_path, &workbench)?;
Ok(workbench)
}
/// Reads and validates a manual translation workbench.
pub fn read_translation_workbench(path: &Path) -> anyhow::Result<TranslationWorkbench> {
let bytes = read_file_no_symlink(path, "翻译工作台")
.map_err(anyhow::Error::msg)?
.ok_or_else(|| anyhow::anyhow!("翻译工作台不存在:{}", path.display()))?;
let workbench: TranslationWorkbench = serde_json::from_slice(&bytes)?;
if workbench.schema_version != TRANSLATION_WORKBENCH_VERSION {
return Err(anyhow::anyhow!(
"不支持的翻译工作台 schema:{},当前版本={}",
workbench.schema_version,
TRANSLATION_WORKBENCH_VERSION
));
}
Ok(workbench)
}
/// Writes a translation workbench atomically.
pub fn write_translation_workbench(
path: &Path,
workbench: &TranslationWorkbench,
) -> anyhow::Result<()> {
let path = lexical_absolute(path).map_err(anyhow::Error::msg)?;
let parent = path
.parent()
.ok_or_else(|| anyhow::anyhow!("翻译工作台缺少父目录:{}", path.display()))?;
ensure_safe_file_target(parent, &path, "翻译工作台").map_err(anyhow::Error::msg)?;
let bytes = serde_json::to_vec_pretty(workbench)?;
write_file_atomic(&path, &bytes, STATE_FILE_MODE, "翻译工作台").map_err(anyhow::Error::msg)?;
Ok(())
}
/// Updates one translation entry and writes the workbench atomically.
pub fn set_translation(
workbench_path: &Path,
entry_id: &str,
translated_text: String,
) -> anyhow::Result<TranslationWorkbenchEntry> {
let mut workbench = read_translation_workbench(workbench_path)?;
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);
let updated = entry.clone();
workbench.generated_unix_seconds = unix_seconds_now();
write_translation_workbench(workbench_path, &workbench)?;
Ok(updated)
}
/// Converts reviewed direct TextAsset entries to localized patch operations.
///
/// TypeTree fields and zip-inner bundles are intentionally rejected here.
/// They need a different patch representation and must not silently become a
/// TextAsset replacement.
pub fn localized_text_asset_patches(
resource_root: &Path,
workbench: &TranslationWorkbench,
) -> anyhow::Result<Vec<LocalizedTextAssetPatch>> {
let index = read_textunit_index_at(resource_root)
.map_err(anyhow::Error::msg)?
.ok_or_else(|| anyhow::anyhow!("缺少当前官方 TextUnit 索引"))?;
let index_by_id = index
.units
.iter()
.map(|unit| (unit.id.as_str(), unit))
.collect::<std::collections::HashMap<_, _>>();
let mut seen = BTreeSet::new();
let mut patches = Vec::new();
for entry in &workbench.entries {
let Some(translated_text) = entry.translated_text.as_ref() else {
continue;
};
let current = index_by_id
.get(entry.id.as_str())
.ok_or_else(|| anyhow::anyhow!("翻译工作台条目不属于当前 release:{}", entry.id))?;
validate_workbench_entry(entry, current)?;
if translated_text == &entry.source_text {
continue;
}
let Some(serialized_file) = entry.serialized_file.clone() else {
return Err(anyhow::anyhow!(
"TextUnit {} 没有 serialized_file,当前不能生成重打包 patch",
entry.id
));
};
let Some(path_id) = entry.path_id else {
return Err(anyhow::anyhow!(
"TextUnit {} 没有 path_id,当前不能生成重打包 patch",
entry.id
));
};
if entry.archive_entry.is_some() {
return Err(anyhow::anyhow!(
"TextUnit {} 位于 zip archive entry,当前 publish-localized 不支持直接修改 zip 内 bundle",
entry.id
));
}
if entry.text_source_kind.as_deref() != Some("text_asset") {
return Err(anyhow::anyhow!(
"TextUnit {} 的来源不是 TextAsset;请使用 repack spec 的 TypeTree 操作",
entry.id
));
}
if !seen.insert((entry.destination.clone(), serialized_file.clone(), path_id)) {
return Err(anyhow::anyhow!(
"翻译工作台包含重复 patch 目标:{}",
entry.id
));
}
let mut patch = TextAssetPatch::new(
serialized_file,
path_id,
translated_text.as_bytes().to_vec(),
);
patch.expected_name = entry.asset_name.clone();
patches.push(LocalizedTextAssetPatch {
bundle_path: entry.destination.clone(),
text_asset: patch,
});
}
if patches.is_empty() {
return Err(anyhow::anyhow!(
"翻译工作台没有可发布的已修改 TextAsset;请先用 translation-set 调整文本"
));
}
Ok(patches)
}
/// Batch UnityFS repack specification.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepackSpec {
/// Repack specification schema.
pub schema_version: u32,
/// Source bundle file.
pub source_bundle: PathBuf,
/// Atomically written target bundle file.
pub target_bundle: PathBuf,
/// Ordered operations applied to the source bytes.
pub operations: Vec<RepackOperation>,
}
/// Current batch repack schema.
pub const REPACK_SPEC_VERSION: u32 = 1;
/// One ordered UnityFS repack operation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RepackOperation {
/// Replace one TextAsset payload.
TextAsset {
/// Unity serialized file path.
serialized_file: String,
/// Unity object path ID.
path_id: i64,
/// Optional expected TextAsset name.
#[serde(default)]
expected_name: Option<String>,
/// Inline replacement UTF-8 text.
#[serde(default)]
replacement_text: Option<String>,
/// File containing replacement bytes.
#[serde(default)]
replacement_file: Option<PathBuf>,
},
/// Replace one TypeTree string field.
StringField {
/// Unity serialized file path.
serialized_file: String,
/// Unity object path ID.
path_id: i64,
/// TypeTree field path.
field_path: String,
/// Optional expected source string.
#[serde(default)]
expected_value: Option<String>,
/// Inline replacement UTF-8 text.
#[serde(default)]
replacement_text: Option<String>,
/// File containing replacement UTF-8 text.
#[serde(default)]
replacement_file: Option<PathBuf>,
},
/// Replace one supported semantic TypeTree field.
Field {
/// Unity serialized file path.
serialized_file: String,
/// Unity object path ID.
path_id: i64,
/// TypeTree field path.
field_path: String,
/// Replacement semantic value.
replacement: UnitySerializedReplacementValue,
/// Optional expected semantic source value.
#[serde(default)]
expected_value: Option<UnitySerializedReplacementValue>,
},
}
/// Result of a batch repack.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RepackReport {
/// Stable command name.
pub command: &'static str,
/// Operation status.
pub status: &'static str,
/// Absolute source bundle path.
pub source_bundle: PathBuf,
/// Absolute target bundle path.
pub target_bundle: PathBuf,
/// Number of operations applied.
pub operation_count: usize,
/// Source BLAKE3.
pub source_blake3: String,
/// Target BLAKE3.
pub target_blake3: String,
/// Source size.
pub source_bytes: u64,
/// Target size.
pub target_bytes: u64,
}
/// Applies an ordered repack specification and verifies each rebuild through
/// the underlying UnityFS patch implementation.
pub fn repack_bundle(spec_path: &Path) -> anyhow::Result<RepackReport> {
let spec_bytes = read_file_no_symlink(spec_path, "UnityFS repack spec")
.map_err(anyhow::Error::msg)?
.ok_or_else(|| anyhow::anyhow!("UnityFS repack spec 不存在:{}", spec_path.display()))?;
let spec: RepackSpec = serde_json::from_slice(&spec_bytes)?;
if spec.schema_version != REPACK_SPEC_VERSION {
return Err(anyhow::anyhow!(
"不支持的 UnityFS repack spec schema{},当前版本={}",
spec.schema_version,
REPACK_SPEC_VERSION
));
}
if spec.operations.is_empty() {
return Err(anyhow::anyhow!(
"UnityFS repack spec 至少需要一个 operation"
));
}
let source_path = lexical_absolute(&spec.source_bundle).map_err(anyhow::Error::msg)?;
let target_path = lexical_absolute(&spec.target_bundle).map_err(anyhow::Error::msg)?;
if source_path == target_path {
return Err(anyhow::anyhow!(
"repack target_bundle 不能与 source_bundle 相同"
));
}
let source = read_file_no_symlink(&source_path, "UnityFS source bundle")
.map_err(anyhow::Error::msg)?
.ok_or_else(|| {
anyhow::anyhow!("UnityFS source bundle 不存在:{}", source_path.display())
})?;
let mut current = source.clone();
for (index, operation) in spec.operations.iter().enumerate() {
current = apply_repack_operation(&current, operation)
.map_err(|error| anyhow::anyhow!("repack operation {} 失败:{error}", index + 1))?;
}
let parent = target_path
.parent()
.ok_or_else(|| anyhow::anyhow!("repack target_bundle 缺少父目录"))?;
ensure_safe_file_target(parent, &target_path, "UnityFS repack target")
.map_err(anyhow::Error::msg)?;
write_file_atomic(
&target_path,
&current,
STATE_FILE_MODE,
"UnityFS repack target",
)
.map_err(anyhow::Error::msg)?;
Ok(RepackReport {
command: "repack",
status: "repacked",
source_bundle: source_path,
target_bundle: target_path,
operation_count: spec.operations.len(),
source_blake3: blake3::hash(&source).to_hex().to_string(),
target_blake3: blake3::hash(&current).to_hex().to_string(),
source_bytes: source.len() as u64,
target_bytes: current.len() as u64,
})
}
fn apply_repack_operation(input: &[u8], operation: &RepackOperation) -> anyhow::Result<Vec<u8>> {
match operation {
RepackOperation::TextAsset {
serialized_file,
path_id,
expected_name,
replacement_text,
replacement_file,
} => {
let replacement = read_text_replacement(replacement_text, replacement_file)?;
let mut patch = TextAssetPatch::new(serialized_file, *path_id, replacement);
patch.expected_name = expected_name.clone();
Ok(patch_unityfs_text_asset(input, &patch)?)
}
RepackOperation::StringField {
serialized_file,
path_id,
field_path,
expected_value,
replacement_text,
replacement_file,
} => {
let replacement =
String::from_utf8(read_text_replacement(replacement_text, replacement_file)?)?;
Ok(patch_unityfs_string_field(
input,
&StringFieldPatch {
serialized_file_path: serialized_file.clone(),
path_id: *path_id,
field_path: field_path.clone(),
expected_value: expected_value.clone(),
replacement,
},
)?)
}
RepackOperation::Field {
serialized_file,
path_id,
field_path,
replacement,
expected_value,
} => Ok(patch_unityfs_field(
input,
&FieldPatch {
serialized_file_path: serialized_file.clone(),
path_id: *path_id,
field_path: field_path.clone(),
expected_value: expected_value.clone(),
replacement: replacement.clone(),
},
)?),
}
}
fn read_text_replacement(
replacement_text: &Option<String>,
replacement_file: &Option<PathBuf>,
) -> anyhow::Result<Vec<u8>> {
match (replacement_text, replacement_file) {
(Some(_), Some(_)) => Err(anyhow::anyhow!(
"replacement_text 与 replacement_file 只能指定一个"
)),
(Some(text), None) => Ok(text.as_bytes().to_vec()),
(None, Some(path)) => read_file_no_symlink(path, "repack replacement file")
.map_err(anyhow::Error::msg)?
.ok_or_else(|| anyhow::anyhow!("repack replacement file 不存在:{}", path.display())),
(None, None) => Err(anyhow::anyhow!(
"必须指定 replacement_text 或 replacement_file"
)),
}
}
fn validate_workbench_entry(
entry: &TranslationWorkbenchEntry,
current: &OfficialTextUnitIndexUnit,
) -> anyhow::Result<()> {
if entry.source_text != current.source_text
|| entry.destination != current.destination
|| entry.archive_entry != current.archive_entry
|| entry.serialized_file != current.serialized_file
|| entry.path_id != current.path_id
|| entry.asset_name != current.asset_name
|| entry.format != current.format
|| entry.text_source_kind != current.text_source_kind
{
return Err(anyhow::anyhow!(
"翻译工作台条目 {} 与当前 TextUnit 索引不一致,请重新 translation-export",
entry.id
));
}
Ok(())
}
impl TranslationWorkbenchEntry {
fn from_index(unit: &OfficialTextUnitIndexUnit) -> Self {
Self {
id: unit.id.clone(),
destination: unit.destination.clone(),
archive_entry: unit.archive_entry.clone(),
serialized_file: unit.serialized_file.clone(),
path_id: unit.path_id,
asset_name: unit.asset_name.clone(),
source_text: unit.source_text.clone(),
translated_text: None,
format: unit.format.clone(),
text_source_kind: unit.text_source_kind.clone(),
}
}
}
fn unix_seconds_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
fn workbench(path: &Path) -> TranslationWorkbench {
TranslationWorkbench {
schema_version: TRANSLATION_WORKBENCH_VERSION,
official_release_id: "release-1".to_string(),
official_resource_root: path.to_path_buf(),
generated_unix_seconds: 1,
entries: vec![TranslationWorkbenchEntry {
id: "unit-1".to_string(),
destination: "bundles/test.bundle".to_string(),
archive_entry: None,
serialized_file: Some("CAB-test".to_string()),
path_id: Some(7),
asset_name: Some("Story".to_string()),
source_text: "原文".to_string(),
translated_text: None,
format: Some("plain".to_string()),
text_source_kind: Some("text_asset".to_string()),
}],
}
}
#[test]
fn translation_set_round_trips_atomically() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("workbench.json");
write_translation_workbench(&path, &workbench(temp.path())).unwrap();
let updated = set_translation(&path, "unit-1", "译文".to_string()).unwrap();
assert_eq!(updated.translated_text.as_deref(), Some("译文"));
let loaded = read_translation_workbench(&path).unwrap();
assert_eq!(loaded.entries[0].translated_text.as_deref(), Some("译文"));
}
#[test]
fn translation_set_rejects_unknown_unit() {
let temp = tempfile::TempDir::new().unwrap();
let path = temp.path().join("workbench.json");
write_translation_workbench(&path, &workbench(temp.path())).unwrap();
let error = set_translation(&path, "missing", "译文".to_string()).unwrap_err();
assert!(error.to_string().contains("不存在 TextUnit"));
}
}
@@ -379,7 +379,10 @@ fn official_update_reuses_failed_staging_after_interrupted_download() {
}),
);
let config = harness.sync_config("failed-staging-output");
// 该 fixture 使用一个共享失败计数文件模拟单个资源的中断;
// 设为单 worker,避免其它并行资源消耗这个测试专用的失败次数。
let mut config = harness.sync_config("failed-staging-output");
config.download_concurrency = 1;
let first_error = OfficialUpdateService::new().run(&config).unwrap_err();
assert!(first_error.to_string().contains("quarantine"));
assert!(first_error.to_string().contains("simulated failure"));