mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
@@ -7,24 +7,25 @@ use bat_core::{ApiError, ErrorCode};
|
||||
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,
|
||||
export_translation_workbench, gc_orphan_staging, get_translation_entry, 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_translation_workbench,
|
||||
read_version_state, redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation,
|
||||
unset_translation, validate_output_root, validate_runtime_state_dir,
|
||||
validate_translation_workbench, 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, TranslationProviderKind,
|
||||
TranslationTaskStatus, TranslationWorkerConfig, UnityFsFieldPatchParams, UnityFsPatchReport,
|
||||
apply_unityfs_text_asset_patch_file, changed_endpoint_urls,
|
||||
completed_worker_translation_workbench, diff_extended_snapshot, export_translation_workbench,
|
||||
gc_orphan_staging, get_translation_entry, lexical_absolute, localized_patch_operations,
|
||||
open_append_file, read_download_manifest_at, read_file_no_symlink,
|
||||
read_localized_patch_manifest_at, read_localized_version_state, read_parse_cache_at,
|
||||
read_snapshot, read_textunit_index_at, read_translation_workbench, read_version_state,
|
||||
redact_proxy_url, repack_bundle, resolve_curl_proxy, set_translation, unset_translation,
|
||||
validate_output_root, validate_runtime_state_dir, validate_translation_workbench,
|
||||
write_file_atomic, write_official_textunit_queues, CurlProxyConfig, CurlProxyMode,
|
||||
LocalizedPatchConfig, LocalizedPatchReport, LocalizedPatchService, LocalizedRollbackReport,
|
||||
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, TranslationProviderKind, TranslationTaskStatus,
|
||||
TranslationWorkerConfig, UnityFsFieldPatchParams, UnityFsPatchReport,
|
||||
UnityFsStringFieldPatchParams, UnityFsTextAssetPatchParams, CROWDIN_TEXTUNIT_QUEUE_FILE,
|
||||
DEFAULT_TRANSLATION_CONCURRENCY, DEFAULT_TRANSLATION_LEASE_SECONDS,
|
||||
DEFAULT_TRANSLATION_MAX_ATTEMPTS, DEFAULT_TRANSLATION_RETRY_BACKOFF, LOCALIZED_CURRENT_LINK,
|
||||
@@ -84,6 +85,7 @@ use translation_query::{
|
||||
update_translation_task_status_report,
|
||||
};
|
||||
use workflow_commands::{
|
||||
localized_rollback_report, publish_localized_report, run_localized_rollback,
|
||||
run_parse_clear_cache, run_parse_once, run_publish_localized, run_repack, run_translate_once,
|
||||
run_translation_get, run_translation_proofread, run_translation_set,
|
||||
run_translation_task_update, run_translation_unset, run_translation_validate,
|
||||
@@ -244,6 +246,10 @@ fn run() -> anyhow::Result<i32> {
|
||||
run_repeated_workflow(&options, "publish-localized", run_publish_localized)?;
|
||||
Ok(0)
|
||||
}
|
||||
CliCommand::LocalizedRollback => {
|
||||
run_localized_rollback(&options)?;
|
||||
Ok(0)
|
||||
}
|
||||
CliCommand::ScheduleList => {
|
||||
run_schedule_list(&options)?;
|
||||
Ok(0)
|
||||
@@ -385,6 +391,7 @@ struct CliOptions {
|
||||
translation_text_file: Option<PathBuf>,
|
||||
translation_failure_reason: Option<String>,
|
||||
translation_provider_run_id: Option<String>,
|
||||
translation_from_worker: bool,
|
||||
translation_provider: Option<String>,
|
||||
translation_fixture: Option<PathBuf>,
|
||||
worker_concurrency: usize,
|
||||
@@ -479,6 +486,7 @@ impl Default for CliOptions {
|
||||
translation_text_file: None,
|
||||
translation_failure_reason: None,
|
||||
translation_provider_run_id: None,
|
||||
translation_from_worker: false,
|
||||
translation_provider: None,
|
||||
translation_fixture: None,
|
||||
worker_concurrency: DEFAULT_TRANSLATION_CONCURRENCY,
|
||||
@@ -575,6 +583,7 @@ enum CliCommand {
|
||||
TranslationProofread,
|
||||
Repack,
|
||||
PublishLocalized,
|
||||
LocalizedRollback,
|
||||
ScheduleList,
|
||||
ScheduleAdd,
|
||||
ScheduleUpdate,
|
||||
@@ -648,6 +657,7 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
|
||||
registry,
|
||||
queue: task_tx,
|
||||
base_config: options.config.clone(),
|
||||
sync_lock: Arc::clone(&sync_lock),
|
||||
restart_controller: spawn_daemon_restart_controller,
|
||||
};
|
||||
let server =
|
||||
@@ -1023,6 +1033,35 @@ struct DaemonRpcAck {
|
||||
force: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct LocalizedPublishRpcParams {
|
||||
#[serde(default, alias = "workbench", alias = "workbench_path")]
|
||||
translation_file: Option<PathBuf>,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "translation_from_worker",
|
||||
alias = "from_worker_results"
|
||||
)]
|
||||
from_worker: bool,
|
||||
#[serde(default, alias = "release_id")]
|
||||
localized_release_id: Option<String>,
|
||||
#[serde(default)]
|
||||
force: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct LocalizedRollbackRpcParams {
|
||||
#[serde(
|
||||
default,
|
||||
alias = "release_id",
|
||||
alias = "expected_release_id",
|
||||
alias = "expected_localized_release_id"
|
||||
)]
|
||||
localized_release_id: Option<String>,
|
||||
}
|
||||
|
||||
// 规范方法名采用国际惯例的 `<namespace>.<action>`。`bat.*` 保留为向后兼容别名。
|
||||
const RPC_METHOD_STATUS: &str = "daemon.status";
|
||||
const RPC_METHOD_STOP: &str = "daemon.stop";
|
||||
@@ -1053,6 +1092,8 @@ const RPC_METHOD_TRANSLATION_TASK_UPDATE: &str = "translation.task.update";
|
||||
const RPC_METHOD_TRANSLATION_PROOFREAD: &str = "translation.proofread";
|
||||
const RPC_METHOD_TRANSLATION_WORKER_RUN: &str = "translation.worker.run";
|
||||
const RPC_METHOD_LOCALIZED_STATUS: &str = "localized.status";
|
||||
const RPC_METHOD_LOCALIZED_PUBLISH: &str = "localized.publish";
|
||||
const RPC_METHOD_LOCALIZED_ROLLBACK: &str = "localized.rollback";
|
||||
const RPC_METHOD_CATALOG_STATUS: &str = "catalog.status";
|
||||
const RPC_METHOD_CATALOG_VERSIONS: &str = "catalog.versions";
|
||||
const RPC_METHOD_CATALOG_DIFF: &str = "catalog.diff";
|
||||
@@ -2048,12 +2089,18 @@ fn dispatch_rpc_method(
|
||||
"translation.task.update",
|
||||
update_translation_task_status_report(state_dir, request.params.as_ref()),
|
||||
),
|
||||
RPC_METHOD_TRANSLATION_PROOFREAD => rpc_envelope_from_result(
|
||||
request_id,
|
||||
"translation.proofread",
|
||||
mark_localized_manual_proofreading_report(state_dir, &tasks.base_config)
|
||||
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)),
|
||||
),
|
||||
RPC_METHOD_TRANSLATION_PROOFREAD => {
|
||||
let _sync_guard = tasks
|
||||
.sync_lock
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner());
|
||||
rpc_envelope_from_result(
|
||||
request_id,
|
||||
"translation.proofread",
|
||||
mark_localized_manual_proofreading_report(state_dir, &tasks.base_config)
|
||||
.and_then(|report| serde_json::to_value(report).map_err(anyhow::Error::from)),
|
||||
)
|
||||
}
|
||||
RPC_METHOD_TRANSLATION_WORKER_RUN => {
|
||||
let config = match rpc_translation_worker_config(request.params.as_ref()) {
|
||||
Ok(config) => config,
|
||||
@@ -2061,11 +2108,53 @@ fn dispatch_rpc_method(
|
||||
};
|
||||
enqueue_translation_worker_envelope(tasks, config, request_id)
|
||||
}
|
||||
RPC_METHOD_LOCALIZED_STATUS => rpc_envelope_from_result(
|
||||
request_id,
|
||||
"localized.status",
|
||||
build_localized_status_report(state_dir, &tasks.base_config),
|
||||
),
|
||||
RPC_METHOD_LOCALIZED_STATUS => {
|
||||
let _sync_guard = tasks
|
||||
.sync_lock
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner());
|
||||
rpc_envelope_from_result(
|
||||
request_id,
|
||||
"localized.status",
|
||||
build_localized_status_report(state_dir, &tasks.base_config),
|
||||
)
|
||||
}
|
||||
RPC_METHOD_LOCALIZED_PUBLISH => {
|
||||
let _sync_guard = tasks
|
||||
.sync_lock
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner());
|
||||
match localized_publish_rpc_report(
|
||||
state_dir,
|
||||
&tasks.base_config,
|
||||
request.params.as_ref(),
|
||||
) {
|
||||
Ok(report) => rpc_envelope_from_result(
|
||||
request_id,
|
||||
RPC_METHOD_LOCALIZED_PUBLISH,
|
||||
serde_json::to_value(report).map_err(anyhow::Error::from),
|
||||
),
|
||||
Err(error) => rpc_envelope_error(request_id, error),
|
||||
}
|
||||
}
|
||||
RPC_METHOD_LOCALIZED_ROLLBACK => {
|
||||
let _sync_guard = tasks
|
||||
.sync_lock
|
||||
.lock()
|
||||
.unwrap_or_else(|poison| poison.into_inner());
|
||||
match localized_rollback_rpc_report(
|
||||
state_dir,
|
||||
&tasks.base_config,
|
||||
request.params.as_ref(),
|
||||
) {
|
||||
Ok(report) => rpc_envelope_from_result(
|
||||
request_id,
|
||||
RPC_METHOD_LOCALIZED_ROLLBACK,
|
||||
serde_json::to_value(report).map_err(anyhow::Error::from),
|
||||
),
|
||||
Err(error) => rpc_envelope_error(request_id, error),
|
||||
}
|
||||
}
|
||||
RPC_METHOD_CATALOG_STATUS => rpc_envelope_from_result(
|
||||
request_id,
|
||||
"catalog.status",
|
||||
@@ -3150,6 +3239,98 @@ fn rpc_struct_params<T: DeserializeOwned>(
|
||||
})
|
||||
}
|
||||
|
||||
fn rpc_optional_struct_params<T: DeserializeOwned + Default>(
|
||||
params: Option<&serde_json::Value>,
|
||||
method: &'static str,
|
||||
) -> Result<T, ApiError> {
|
||||
let Some(params) = params else {
|
||||
return Ok(T::default());
|
||||
};
|
||||
if params.is_null() {
|
||||
return Ok(T::default());
|
||||
}
|
||||
if !params.is_object() {
|
||||
return Err(ApiError::new(
|
||||
ErrorCode::RPC_INVALID_PARAMS,
|
||||
method,
|
||||
"params 必须是 JSON object",
|
||||
));
|
||||
}
|
||||
serde_json::from_value(params.clone()).map_err(|error| {
|
||||
ApiError::new(
|
||||
ErrorCode::RPC_INVALID_PARAMS,
|
||||
method,
|
||||
format!("params 无效:{error}"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn localized_publish_rpc_report(
|
||||
state_dir: &Path,
|
||||
base_config: &OfficialUpdateConfig,
|
||||
params: Option<&serde_json::Value>,
|
||||
) -> Result<LocalizedPatchReport, ApiError> {
|
||||
let params: LocalizedPublishRpcParams =
|
||||
rpc_optional_struct_params(params, RPC_METHOD_LOCALIZED_PUBLISH)?;
|
||||
let translation_file = params
|
||||
.translation_file
|
||||
.filter(|path| !path.as_os_str().is_empty());
|
||||
if translation_file.is_some() == params.from_worker {
|
||||
return Err(ApiError::new(
|
||||
ErrorCode::RPC_INVALID_PARAMS,
|
||||
RPC_METHOD_LOCALIZED_PUBLISH,
|
||||
"localized.publish 必须且只能指定 translation_file 或 from_worker",
|
||||
));
|
||||
}
|
||||
let mut config = base_config.clone();
|
||||
config.force = params.force;
|
||||
let options = CliOptions {
|
||||
command: CliCommand::PublishLocalized,
|
||||
config,
|
||||
state_dir: state_dir.to_path_buf(),
|
||||
translation_file,
|
||||
translation_from_worker: params.from_worker,
|
||||
localized_release_id: normalize_optional_rpc_string(params.localized_release_id),
|
||||
..CliOptions::default()
|
||||
};
|
||||
publish_localized_report(&options).map_err(|error| {
|
||||
ApiError::new(
|
||||
ErrorCode::INTERNAL,
|
||||
RPC_METHOD_LOCALIZED_PUBLISH,
|
||||
error.to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn localized_rollback_rpc_report(
|
||||
state_dir: &Path,
|
||||
base_config: &OfficialUpdateConfig,
|
||||
params: Option<&serde_json::Value>,
|
||||
) -> Result<LocalizedRollbackReport, ApiError> {
|
||||
let params: LocalizedRollbackRpcParams =
|
||||
rpc_optional_struct_params(params, RPC_METHOD_LOCALIZED_ROLLBACK)?;
|
||||
let options = CliOptions {
|
||||
command: CliCommand::LocalizedRollback,
|
||||
config: base_config.clone(),
|
||||
state_dir: state_dir.to_path_buf(),
|
||||
localized_release_id: normalize_optional_rpc_string(params.localized_release_id),
|
||||
..CliOptions::default()
|
||||
};
|
||||
localized_rollback_report(&options).map_err(|error| {
|
||||
ApiError::new(
|
||||
ErrorCode::INTERNAL,
|
||||
RPC_METHOD_LOCALIZED_ROLLBACK,
|
||||
error.to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_optional_rpc_string(value: Option<String>) -> Option<String> {
|
||||
value
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn rpc_translation_worker_config(
|
||||
params: Option<&serde_json::Value>,
|
||||
) -> Result<TranslationWorkerConfig, ApiError> {
|
||||
@@ -4484,6 +4665,24 @@ impl HumanReport for LocalizedPatchReport {
|
||||
}
|
||||
}
|
||||
|
||||
impl HumanReport for LocalizedRollbackReport {
|
||||
fn print_human(&self) -> anyhow::Result<()> {
|
||||
print_title("汉化 release 回滚");
|
||||
print_field("命令", self.command);
|
||||
print_field("状态", self.status);
|
||||
print_field("回滚 release", &self.rolled_back_release_id);
|
||||
print_optional_field("恢复 release", self.restored_release_id.as_deref());
|
||||
print_path_field("汉化输出目录", &self.localized_output_root);
|
||||
print_path_field("current", &self.current_path);
|
||||
print_path_field("状态文件", &self.state_path);
|
||||
print_path_field("删除版本目录", &self.removed_version_path);
|
||||
print_optional_path_field("恢复 current 目标", self.restored_current_target.as_ref());
|
||||
print_field("新状态", &self.state.status);
|
||||
print_optional_field("当前 release", self.state.current_release_id.as_deref());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl HumanReport for bat_infrastructure::LocalizedTranslationWorkflowReport {
|
||||
fn print_human(&self) -> anyhow::Result<()> {
|
||||
print_title("汉化工作流状态");
|
||||
@@ -6971,6 +7170,10 @@ fn parse_args_with_env(
|
||||
ensure_command_not_set(options.command, "localized-status")?;
|
||||
options.command = CliCommand::LocalizedStatus;
|
||||
}
|
||||
"localized-rollback" => {
|
||||
ensure_command_not_set(options.command, "localized-rollback")?;
|
||||
options.command = CliCommand::LocalizedRollback;
|
||||
}
|
||||
"resource-index" => {
|
||||
ensure_command_not_set(options.command, "resource-index")?;
|
||||
options.command = CliCommand::ResourceIndex;
|
||||
@@ -7069,6 +7272,9 @@ fn parse_args_with_env(
|
||||
options.translation_text_file =
|
||||
Some(PathBuf::from(next_option_value(&mut args, &flag)?));
|
||||
}
|
||||
"--from-worker" | "--translation-from-worker" => {
|
||||
options.translation_from_worker = true;
|
||||
}
|
||||
"--failure-reason" | "--reason" => {
|
||||
options.translation_failure_reason = Some(next_option_value(&mut args, &flag)?);
|
||||
}
|
||||
@@ -7589,6 +7795,9 @@ fn parse_args_with_env(
|
||||
{
|
||||
return Err(anyhow::anyhow!("翻译 worker 参数只适用于 i18n worker run"));
|
||||
}
|
||||
if options.command != CliCommand::PublishLocalized && options.translation_from_worker {
|
||||
return Err(anyhow::anyhow!("--from-worker 只适用于 i18n publish"));
|
||||
}
|
||||
|
||||
match options.command {
|
||||
CliCommand::Status | CliCommand::Stop | CliCommand::Logs => {
|
||||
@@ -7671,6 +7880,13 @@ fn parse_args_with_env(
|
||||
"parse/translate/publish-localized 不能使用 --dry-run"
|
||||
));
|
||||
}
|
||||
if matches!(options.command, CliCommand::PublishLocalized)
|
||||
&& options.translation_file.is_some() == options.translation_from_worker
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"i18n publish 必须且只能指定 --translation-file 或 --from-worker"
|
||||
));
|
||||
}
|
||||
}
|
||||
CliCommand::ParseClearCache => {
|
||||
if options.watch || options.daemon || options.daemon_child {
|
||||
@@ -7693,9 +7909,13 @@ fn parse_args_with_env(
|
||||
if options.watch || options.daemon || options.daemon_child {
|
||||
return Err(anyhow::anyhow!("i18n validate 只支持单次执行"));
|
||||
}
|
||||
if options.config.force || options.config.dry_run || options.run_count.is_some() {
|
||||
if options.config.force
|
||||
|| options.config.dry_run
|
||||
|| options.run_count.is_some()
|
||||
|| options.translation_from_worker
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"i18n validate 不支持 --force、--dry-run 或 --run-count"
|
||||
"i18n validate 不支持 --force、--dry-run、--run-count 或 --from-worker"
|
||||
));
|
||||
}
|
||||
options.progress = false;
|
||||
@@ -7737,6 +7957,7 @@ fn parse_args_with_env(
|
||||
|| options.schedule_option_explicit
|
||||
|| options.translation_failure_reason.is_some()
|
||||
|| options.translation_provider_run_id.is_some()
|
||||
|| options.translation_from_worker
|
||||
|| options.proxy_option_explicit
|
||||
|| tools_are_non_default(&options.config, &options.env_baseline_config)
|
||||
{
|
||||
@@ -7751,7 +7972,11 @@ fn parse_args_with_env(
|
||||
if options.watch || options.daemon || options.daemon_child {
|
||||
return Err(anyhow::anyhow!("repack 只支持单次执行"));
|
||||
}
|
||||
if options.config.force || options.sync_option_explicit || options.run_count.is_some() {
|
||||
if options.config.force
|
||||
|| options.sync_option_explicit
|
||||
|| options.run_count.is_some()
|
||||
|| options.translation_from_worker
|
||||
{
|
||||
return Err(anyhow::anyhow!("repack 不接受资源同步选项"));
|
||||
}
|
||||
options.progress = false;
|
||||
@@ -7778,6 +8003,7 @@ fn parse_args_with_env(
|
||||
|| options.translation_id.is_some()
|
||||
|| options.translation_text.is_some()
|
||||
|| options.translation_text_file.is_some()
|
||||
|| options.translation_from_worker
|
||||
|| options.localized_release_id.is_some()
|
||||
|| options.repack_spec.is_some()
|
||||
|| options.query_offset != 0
|
||||
@@ -7828,6 +8054,7 @@ fn parse_args_with_env(
|
||||
|| options.translation_text_file.is_some()
|
||||
|| options.translation_failure_reason.is_some()
|
||||
|| options.translation_provider_run_id.is_some()
|
||||
|| options.translation_from_worker
|
||||
|| options.localized_release_id.is_some()
|
||||
|| options.repack_spec.is_some()
|
||||
|| options.proxy_option_explicit
|
||||
@@ -7882,6 +8109,7 @@ fn parse_args_with_env(
|
||||
|| options.translation_text_file.is_some()
|
||||
|| options.translation_failure_reason.is_some()
|
||||
|| options.translation_provider_run_id.is_some()
|
||||
|| options.translation_from_worker
|
||||
|| options.localized_release_id.is_some()
|
||||
|| options.repack_spec.is_some()
|
||||
|| options.query_option_explicit
|
||||
@@ -7894,6 +8122,41 @@ fn parse_args_with_env(
|
||||
options.progress = false;
|
||||
options.banner = false;
|
||||
}
|
||||
CliCommand::LocalizedRollback => {
|
||||
if options.watch || options.daemon || options.daemon_child {
|
||||
return Err(anyhow::anyhow!("i18n rollback 只支持单次执行或 RPC 调用"));
|
||||
}
|
||||
if options.config.force
|
||||
|| options.config.dry_run
|
||||
|| options.run_count.is_some()
|
||||
|| options.sync_option_explicit
|
||||
|| options.proxy_option_explicit
|
||||
|| tools_are_non_default(&options.config, &options.env_baseline_config)
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"i18n rollback 只接受 --output、--localized-output、--state-dir、--localized-release-id 和 --json/--human"
|
||||
));
|
||||
}
|
||||
if options.resource_root.is_some()
|
||||
|| options.translation_file.is_some()
|
||||
|| options.translation_id.is_some()
|
||||
|| options.translation_text.is_some()
|
||||
|| options.translation_text_file.is_some()
|
||||
|| options.translation_failure_reason.is_some()
|
||||
|| options.translation_provider_run_id.is_some()
|
||||
|| options.translation_from_worker
|
||||
|| options.repack_spec.is_some()
|
||||
|| options.query_option_explicit
|
||||
|| options.schedule_option_explicit
|
||||
|| options.translation_worker_option_explicit
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"i18n rollback 不接受工作台、任务、查询、调度、repack 或 worker 参数"
|
||||
));
|
||||
}
|
||||
options.progress = false;
|
||||
options.banner = false;
|
||||
}
|
||||
CliCommand::ScheduleList
|
||||
| CliCommand::ScheduleAdd
|
||||
| CliCommand::ScheduleUpdate
|
||||
@@ -8103,6 +8366,7 @@ fn parse_translation_command(
|
||||
"unset" | "clear" => CliCommand::TranslationUnset,
|
||||
"validate" => CliCommand::TranslationValidate,
|
||||
"publish" => CliCommand::PublishLocalized,
|
||||
"rollback" => CliCommand::LocalizedRollback,
|
||||
"proofread" => CliCommand::TranslationProofread,
|
||||
"tasks" => CliCommand::TranslationTasks,
|
||||
"handoff" => CliCommand::TranslationHandoff,
|
||||
@@ -8333,7 +8597,8 @@ fn print_usage(binary: &str) {
|
||||
eprintln!(" i18n handoff Query current translation handoff");
|
||||
eprintln!(" i18n status Show localized release status for current official release");
|
||||
eprintln!(" i18n task update Update one provider worker task status");
|
||||
eprintln!(" i18n publish Publish a localized release");
|
||||
eprintln!(" i18n publish Publish a localized release from a workbench or worker results");
|
||||
eprintln!(" i18n rollback Roll back the current 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");
|
||||
@@ -8344,6 +8609,7 @@ fn print_usage(binary: &str) {
|
||||
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!(" localized-rollback Roll back the current localized release");
|
||||
eprintln!(" resource-index Query CAS + ResourceRepository index");
|
||||
eprintln!(" patch-apply Apply a Binary/JSON/Text patch file");
|
||||
eprintln!(" unityfs-patch-text-asset Patch one UnityFS TextAsset object");
|
||||
@@ -8381,6 +8647,8 @@ fn print_usage(binary: &str) {
|
||||
eprintln!(" {binary} i18n handoff --json");
|
||||
eprintln!(" {binary} i18n status --json");
|
||||
eprintln!(" {binary} i18n publish --translation-file /tmp/bat-workbench.json --force");
|
||||
eprintln!(" {binary} i18n publish --from-worker --localized-release-id release-manual-1");
|
||||
eprintln!(" {binary} i18n rollback --localized-release-id release-manual-1");
|
||||
eprintln!(" {binary} status");
|
||||
eprintln!(" {binary} refresh --force --json");
|
||||
eprintln!();
|
||||
@@ -8429,6 +8697,7 @@ fn print_usage(binary: &str) {
|
||||
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!(" --from-worker Build publish input from completed provider worker results");
|
||||
eprintln!(" --failure-reason <TEXT> Provider failure reason for i18n task update");
|
||||
eprintln!(" --provider-run-id <ID> Provider run ID for i18n task update");
|
||||
eprintln!(" --translation-provider <NAME> / --provider <NAME> Provider for i18n worker run (mock/crowdin)");
|
||||
|
||||
@@ -299,6 +299,27 @@ fn translation_worker_rpc_params_are_strict_and_accept_aliases() {
|
||||
assert!(rpc_translation_worker_config(Some(&serde_json::json!({"max_tasks": 0}))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn localized_publish_rpc_params_require_one_input_source() {
|
||||
let config = OfficialUpdateConfig::default();
|
||||
assert!(localized_publish_rpc_report(Path::new("/tmp/bat-state"), &config, None,).is_err());
|
||||
assert!(localized_publish_rpc_report(
|
||||
Path::new("/tmp/bat-state"),
|
||||
&config,
|
||||
Some(&serde_json::json!({
|
||||
"translation_file": "/tmp/workbench.json",
|
||||
"from_worker": true
|
||||
})),
|
||||
)
|
||||
.is_err());
|
||||
assert!(localized_publish_rpc_report(
|
||||
Path::new("/tmp/bat-state"),
|
||||
&config,
|
||||
Some(&serde_json::json!({"unexpected": true})),
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouped_workflow_commands_use_short_top_level_aliases() {
|
||||
let options = parse(&[
|
||||
@@ -388,6 +409,38 @@ fn grouped_workflow_commands_use_short_top_level_aliases() {
|
||||
.unwrap();
|
||||
assert_eq!(options.command, CliCommand::TranslationValidate);
|
||||
|
||||
let options = parse(&[
|
||||
"bat",
|
||||
"i18n",
|
||||
"publish",
|
||||
"--from-worker",
|
||||
"--localized-release-id",
|
||||
"localized-1",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(options.command, CliCommand::PublishLocalized);
|
||||
assert!(options.translation_from_worker);
|
||||
assert_eq!(options.localized_release_id.as_deref(), Some("localized-1"));
|
||||
assert!(parse(&[
|
||||
"bat",
|
||||
"i18n",
|
||||
"publish",
|
||||
"--from-worker",
|
||||
"--translation-file",
|
||||
"/tmp/workbench.json",
|
||||
])
|
||||
.is_err());
|
||||
|
||||
let options = parse(&[
|
||||
"bat",
|
||||
"i18n",
|
||||
"rollback",
|
||||
"--localized-release-id",
|
||||
"localized-1",
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(options.command, CliCommand::LocalizedRollback);
|
||||
|
||||
let options = parse(&[
|
||||
"bat",
|
||||
"i18n",
|
||||
@@ -548,8 +601,13 @@ fn translation_workbench_commands_read_update_and_clear_entries() {
|
||||
serialized_file: Some("CAB-test".to_string()),
|
||||
path_id: Some(7),
|
||||
asset_name: Some("Story".to_string()),
|
||||
field_path: None,
|
||||
source_text: "原文".to_string(),
|
||||
translated_text: None,
|
||||
translation_provider: None,
|
||||
provider_run_id: None,
|
||||
translated_unix_seconds: None,
|
||||
review_status: None,
|
||||
format: Some("plain".to_string()),
|
||||
text_source_kind: Some("text_asset".to_string()),
|
||||
}],
|
||||
@@ -2048,6 +2106,7 @@ fn test_task_context_with_config(base_config: OfficialUpdateConfig) -> DaemonTas
|
||||
registry: TaskRegistry::new(),
|
||||
queue,
|
||||
base_config,
|
||||
sync_lock: Arc::new(Mutex::new(())),
|
||||
restart_controller: test_restart_controller,
|
||||
}
|
||||
}
|
||||
@@ -2217,6 +2276,7 @@ fn dispatch_daemon_doctor_returns_report() {
|
||||
registry: TaskRegistry::new(),
|
||||
queue,
|
||||
base_config,
|
||||
sync_lock: Arc::new(Mutex::new(())),
|
||||
restart_controller: test_restart_controller,
|
||||
};
|
||||
|
||||
@@ -2246,6 +2306,7 @@ fn dispatch_resource_sync_enqueues_task() {
|
||||
registry: TaskRegistry::new(),
|
||||
queue,
|
||||
base_config: OfficialUpdateConfig::default(),
|
||||
sync_lock: Arc::new(Mutex::new(())),
|
||||
restart_controller: test_restart_controller,
|
||||
};
|
||||
|
||||
@@ -2306,6 +2367,7 @@ fn dispatch_resource_repair_enqueues_repair_task() {
|
||||
registry: TaskRegistry::new(),
|
||||
queue,
|
||||
base_config,
|
||||
sync_lock: Arc::new(Mutex::new(())),
|
||||
restart_controller: test_restart_controller,
|
||||
};
|
||||
|
||||
@@ -3597,6 +3659,7 @@ fn dispatch_catalog_refresh_enqueues_task() {
|
||||
registry: TaskRegistry::new(),
|
||||
queue,
|
||||
base_config: OfficialUpdateConfig::default(),
|
||||
sync_lock: Arc::new(Mutex::new(())),
|
||||
restart_controller: test_restart_controller,
|
||||
};
|
||||
let envelope = dispatch_rpc_method(
|
||||
@@ -4508,6 +4571,111 @@ fn dispatch_translation_proofread_marks_state_for_dashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn dispatch_localized_rollback_restores_manifest_previous_release() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let state_dir = temp.path().join("state");
|
||||
let output_root = temp.path().join("output");
|
||||
let localized_root = temp.path().join("localized");
|
||||
write_catalog_fixture_with_localized(
|
||||
&state_dir,
|
||||
&output_root,
|
||||
&localized_root,
|
||||
"bundle-b2",
|
||||
None,
|
||||
);
|
||||
let previous = localized_root
|
||||
.join(LOCALIZED_VERSIONS_DIR)
|
||||
.join("release-1");
|
||||
let current = localized_root
|
||||
.join(LOCALIZED_VERSIONS_DIR)
|
||||
.join("release-2");
|
||||
fs::create_dir_all(&previous).unwrap();
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(
|
||||
previous.join(LOCALIZED_PATCH_MANIFEST_FILE),
|
||||
serde_json::to_vec(&bat_infrastructure::LocalizedPatchManifest {
|
||||
manifest_version: bat_infrastructure::LOCALIZED_PATCH_MANIFEST_VERSION,
|
||||
official_release_id: "v-current".to_string(),
|
||||
localized_release_id: "release-1".to_string(),
|
||||
generated_unix_seconds: 123,
|
||||
file_count: 0,
|
||||
text_asset_operation_count: 0,
|
||||
files: Vec::new(),
|
||||
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
|
||||
previous_current_target: None,
|
||||
remove_version_path: previous.clone(),
|
||||
},
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
current.join(LOCALIZED_PATCH_MANIFEST_FILE),
|
||||
serde_json::to_vec(&bat_infrastructure::LocalizedPatchManifest {
|
||||
manifest_version: bat_infrastructure::LOCALIZED_PATCH_MANIFEST_VERSION,
|
||||
official_release_id: "v-current".to_string(),
|
||||
localized_release_id: "release-2".to_string(),
|
||||
generated_unix_seconds: 124,
|
||||
file_count: 0,
|
||||
text_asset_operation_count: 0,
|
||||
files: Vec::new(),
|
||||
rollback: bat_infrastructure::LocalizedPatchRollbackInfo {
|
||||
previous_current_target: Some(PathBuf::from("versions/release-1")),
|
||||
remove_version_path: current.clone(),
|
||||
},
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
symlink(
|
||||
Path::new(LOCALIZED_VERSIONS_DIR).join("release-2"),
|
||||
localized_root.join(LOCALIZED_CURRENT_LINK),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
localized_root.join(LOCALIZED_VERSION_STATE_FILE),
|
||||
serde_json::to_vec(&bat_infrastructure::LocalizedVersionState {
|
||||
state_version: bat_infrastructure::LOCALIZED_VERSION_STATE_VERSION,
|
||||
official_release_id: "v-current".to_string(),
|
||||
current_release_id: Some("release-2".to_string()),
|
||||
status: "localized".to_string(),
|
||||
translation_workflow_status: None,
|
||||
updated_unix_seconds: 124,
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let tasks = test_task_context_with_config(OfficialUpdateConfig {
|
||||
localized_output_root: localized_root.clone(),
|
||||
..OfficialUpdateConfig::default()
|
||||
});
|
||||
|
||||
let envelope = dispatch_rpc_method(
|
||||
&rpc_request(
|
||||
"localized.rollback",
|
||||
Some(serde_json::json!({"localized_release_id": "release-2"})),
|
||||
),
|
||||
&state_dir,
|
||||
&new_daemon_control(),
|
||||
&tasks,
|
||||
"req-localized-rollback-1".to_string(),
|
||||
);
|
||||
let value = serde_json::to_value(&envelope).unwrap();
|
||||
assert_eq!(value["ok"], true);
|
||||
assert_eq!(value["data"]["status"], "rolled_back");
|
||||
assert_eq!(value["data"]["rolled_back_release_id"], "release-2");
|
||||
assert_eq!(value["data"]["restored_release_id"], "release-1");
|
||||
assert!(!current.exists());
|
||||
assert_eq!(
|
||||
fs::read_link(localized_root.join(LOCALIZED_CURRENT_LINK)).unwrap(),
|
||||
PathBuf::from("versions/release-1")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn localized_status_keeps_published_release_during_manual_proofreading() {
|
||||
|
||||
@@ -515,6 +515,8 @@ pub(super) struct DaemonTaskContext {
|
||||
pub(super) registry: TaskRegistry,
|
||||
pub(super) queue: mpsc::Sender<TaskJob>,
|
||||
pub(super) base_config: OfficialUpdateConfig,
|
||||
/// 串行化会读取或修改已发布资源状态的 daemon 操作。
|
||||
pub(super) sync_lock: Arc<Mutex<()>>,
|
||||
pub(super) restart_controller: DaemonRestartController,
|
||||
}
|
||||
|
||||
|
||||
@@ -299,13 +299,25 @@ pub(super) fn run_repack(options: &CliOptions) -> anyhow::Result<()> {
|
||||
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"))?;
|
||||
pub(super) fn publish_localized_report(
|
||||
options: &CliOptions,
|
||||
) -> anyhow::Result<LocalizedPatchReport> {
|
||||
let (resource_root, official_release_id) = current_official_release(options)?;
|
||||
let workbench = read_translation_workbench(translation_file)?;
|
||||
let workbench = if options.translation_from_worker {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
runtime.block_on(completed_worker_translation_workbench(
|
||||
&resource_root,
|
||||
&official_release_id,
|
||||
))?
|
||||
} else {
|
||||
let translation_file = options
|
||||
.translation_file
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("publish-localized 必须指定 --translation-file"))?;
|
||||
read_translation_workbench(translation_file)?
|
||||
};
|
||||
if workbench.official_release_id != official_release_id {
|
||||
return Err(anyhow::anyhow!(
|
||||
"翻译工作台 release={} 与当前官方 release={} 不一致;请重新导出",
|
||||
@@ -319,7 +331,7 @@ pub(super) fn run_publish_localized(options: &CliOptions) -> anyhow::Result<()>
|
||||
"翻译工作台资源根目录与当前 release 不一致;请重新导出"
|
||||
));
|
||||
}
|
||||
let patches = localized_text_asset_patches(&resource_root, &workbench)?;
|
||||
let operations = localized_patch_operations(&resource_root, &workbench)?;
|
||||
let localized_release_id = options.localized_release_id.clone().or_else(|| {
|
||||
options
|
||||
.config
|
||||
@@ -330,17 +342,36 @@ pub(super) fn run_publish_localized(options: &CliOptions) -> anyhow::Result<()>
|
||||
resource_root,
|
||||
options.config.localized_output_root.clone(),
|
||||
official_release_id,
|
||||
patches,
|
||||
Vec::new(),
|
||||
)
|
||||
.with_operations(operations)
|
||||
.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)?;
|
||||
LocalizedPatchService::new().publish(&config)
|
||||
}
|
||||
|
||||
pub(super) fn run_publish_localized(options: &CliOptions) -> anyhow::Result<()> {
|
||||
let report = publish_localized_report(options)?;
|
||||
print_report(options.output_format, &report)
|
||||
}
|
||||
|
||||
fn current_official_release(options: &CliOptions) -> anyhow::Result<(PathBuf, String)> {
|
||||
pub(super) fn localized_rollback_report(
|
||||
options: &CliOptions,
|
||||
) -> anyhow::Result<LocalizedRollbackReport> {
|
||||
LocalizedPatchService::new().rollback(
|
||||
&options.config.localized_output_root,
|
||||
options.localized_release_id.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn run_localized_rollback(options: &CliOptions) -> anyhow::Result<()> {
|
||||
let report = localized_rollback_report(options)?;
|
||||
print_report(options.output_format, &report)
|
||||
}
|
||||
|
||||
pub(super) 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)?
|
||||
|
||||
@@ -48,12 +48,14 @@ pub use import::{
|
||||
};
|
||||
pub use localized_patch::{
|
||||
mark_localized_manual_proofreading, read_localized_patch_manifest_at,
|
||||
read_localized_version_state, write_localized_version_state, LocalizedPatchConfig,
|
||||
LocalizedPatchFile, LocalizedPatchIntegrity, LocalizedPatchManifest, LocalizedPatchOperation,
|
||||
read_localized_version_state, write_localized_version_state, LocalizedFieldPatch,
|
||||
LocalizedPatchConfig, LocalizedPatchFile, LocalizedPatchInput, LocalizedPatchIntegrity,
|
||||
LocalizedPatchManifest, LocalizedPatchOperation, LocalizedPatchOperationMetadata,
|
||||
LocalizedPatchReport, LocalizedPatchRollbackInfo, LocalizedPatchService,
|
||||
LocalizedTextAssetPatch, LocalizedTranslationWorkflowReport, LocalizedVersionState,
|
||||
LOCALIZED_CURRENT_LINK, LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_PATCH_MANIFEST_VERSION,
|
||||
LOCALIZED_STAGING_DIR, LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING,
|
||||
LocalizedRollbackReport, LocalizedStringFieldPatch, LocalizedTextAssetPatch,
|
||||
LocalizedTranslationWorkflowReport, LocalizedVersionState, LOCALIZED_CURRENT_LINK,
|
||||
LOCALIZED_PATCH_MANIFEST_FILE, LOCALIZED_PATCH_MANIFEST_VERSION, LOCALIZED_STAGING_DIR,
|
||||
LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING,
|
||||
LOCALIZED_TRANSLATION_STATUS_MANUAL_PROOFREADING_LABEL, LOCALIZED_VERSIONS_DIR,
|
||||
LOCALIZED_VERSION_STATE_FILE, LOCALIZED_VERSION_STATE_VERSION,
|
||||
};
|
||||
@@ -155,10 +157,11 @@ pub use translation_worker::{
|
||||
MOCK_TRANSLATION_FIXTURE_VERSION,
|
||||
};
|
||||
pub use translation_workflow::{
|
||||
export_translation_workbench, get_translation_entry, localized_text_asset_patches,
|
||||
read_translation_workbench, repack_bundle, set_translation, unset_translation,
|
||||
validate_translation_workbench, write_translation_workbench, RepackOperation, RepackReport,
|
||||
RepackSpec, TranslationWorkbench, TranslationWorkbenchEntry,
|
||||
completed_worker_translation_workbench, export_completed_worker_translation_workbench,
|
||||
export_translation_workbench, get_translation_entry, localized_patch_operations,
|
||||
localized_text_asset_patches, read_translation_workbench, repack_bundle, set_translation,
|
||||
unset_translation, validate_translation_workbench, write_translation_workbench,
|
||||
RepackOperation, RepackReport, RepackSpec, TranslationWorkbench, TranslationWorkbenchEntry,
|
||||
TranslationWorkbenchValidationReport, REPACK_SPEC_VERSION, TRANSLATION_WORKBENCH_VERSION,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! Localized release publishing for verified TextAsset patches.
|
||||
//! Localized release publishing for verified UnityFS text patches.
|
||||
|
||||
use bat_assetbundle::{patch_unityfs_text_asset, TextAssetPatch};
|
||||
use bat_assetbundle::{
|
||||
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch,
|
||||
StringFieldPatch, TextAssetPatch,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
@@ -37,6 +41,58 @@ pub struct LocalizedTextAssetPatch {
|
||||
pub bundle_path: String,
|
||||
/// TextAsset replacement inside the bundle.
|
||||
pub text_asset: TextAssetPatch,
|
||||
/// TextUnit/provider metadata recorded in the localized manifest.
|
||||
pub metadata: Option<LocalizedPatchOperationMetadata>,
|
||||
}
|
||||
|
||||
/// One TypeTree string patch operation against a bundle in an official release.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LocalizedStringFieldPatch {
|
||||
/// Relative path of the UnityFS bundle under the official release.
|
||||
pub bundle_path: String,
|
||||
/// String field replacement inside the bundle.
|
||||
pub string_field: StringFieldPatch,
|
||||
/// TextUnit/provider metadata recorded in the localized manifest.
|
||||
pub metadata: Option<LocalizedPatchOperationMetadata>,
|
||||
}
|
||||
|
||||
/// One semantic TypeTree patch operation against a bundle in an official release.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct LocalizedFieldPatch {
|
||||
/// Relative path of the UnityFS bundle under the official release.
|
||||
pub bundle_path: String,
|
||||
/// Semantic field replacement inside the bundle.
|
||||
pub field: FieldPatch,
|
||||
/// TextUnit/provider metadata recorded in the localized manifest.
|
||||
pub metadata: Option<LocalizedPatchOperationMetadata>,
|
||||
}
|
||||
|
||||
/// Supported localized UnityFS patch operation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LocalizedPatchInput {
|
||||
/// Replace a TextAsset payload.
|
||||
TextAsset(LocalizedTextAssetPatch),
|
||||
/// Replace a TypeTree string field.
|
||||
StringField(LocalizedStringFieldPatch),
|
||||
/// Replace a supported semantic TypeTree field.
|
||||
Field(LocalizedFieldPatch),
|
||||
}
|
||||
|
||||
/// Trace metadata for one localized patch operation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LocalizedPatchOperationMetadata {
|
||||
/// Stable TextUnit ID from the official TextUnit index.
|
||||
pub text_unit_id: String,
|
||||
/// BLAKE3 of the source text validated before publication.
|
||||
pub source_text_blake3: String,
|
||||
/// Translation provider that produced the text, if applicable.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub translation_provider: Option<String>,
|
||||
/// Provider run ID that produced the text, if applicable.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provider_run_id: Option<String>,
|
||||
/// Review state used by the publication input.
|
||||
pub review_status: String,
|
||||
}
|
||||
|
||||
/// Configuration for one localized release publication.
|
||||
@@ -57,6 +113,8 @@ pub struct LocalizedPatchConfig {
|
||||
pub force: bool,
|
||||
/// Patch operations to apply.
|
||||
pub patches: Vec<LocalizedTextAssetPatch>,
|
||||
/// General UnityFS text/field operations to apply.
|
||||
pub operations: Vec<LocalizedPatchInput>,
|
||||
}
|
||||
|
||||
impl LocalizedPatchConfig {
|
||||
@@ -74,6 +132,7 @@ impl LocalizedPatchConfig {
|
||||
localized_release_id: None,
|
||||
force: false,
|
||||
patches,
|
||||
operations: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,11 +148,67 @@ impl LocalizedPatchConfig {
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets general localized patch operations.
|
||||
pub fn with_operations(mut self, operations: Vec<LocalizedPatchInput>) -> Self {
|
||||
self.operations = operations;
|
||||
self
|
||||
}
|
||||
|
||||
fn published_release_id(&self) -> &str {
|
||||
self.localized_release_id
|
||||
.as_deref()
|
||||
.unwrap_or(&self.release_id)
|
||||
}
|
||||
|
||||
fn patch_operations(&self) -> Vec<LocalizedPatchInput> {
|
||||
let mut operations = self
|
||||
.patches
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(LocalizedPatchInput::TextAsset)
|
||||
.collect::<Vec<_>>();
|
||||
operations.extend(self.operations.iter().cloned());
|
||||
operations
|
||||
}
|
||||
}
|
||||
|
||||
impl LocalizedPatchInput {
|
||||
fn bundle_path(&self) -> &str {
|
||||
match self {
|
||||
Self::TextAsset(operation) => &operation.bundle_path,
|
||||
Self::StringField(operation) => &operation.bundle_path,
|
||||
Self::Field(operation) => &operation.bundle_path,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(&self, input: &[u8]) -> anyhow::Result<Vec<u8>> {
|
||||
match self {
|
||||
Self::TextAsset(operation) => {
|
||||
Ok(patch_unityfs_text_asset(input, &operation.text_asset)?)
|
||||
}
|
||||
Self::StringField(operation) => {
|
||||
Ok(patch_unityfs_string_field(input, &operation.string_field)?)
|
||||
}
|
||||
Self::Field(operation) => Ok(patch_unityfs_field(input, &operation.field)?),
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_operation(&self) -> anyhow::Result<LocalizedPatchOperation> {
|
||||
match self {
|
||||
Self::TextAsset(operation) => Ok(LocalizedPatchOperation::from_text_asset_patch(
|
||||
&operation.text_asset,
|
||||
operation.metadata.as_ref(),
|
||||
)),
|
||||
Self::StringField(operation) => Ok(LocalizedPatchOperation::from_string_field_patch(
|
||||
&operation.string_field,
|
||||
operation.metadata.as_ref(),
|
||||
)),
|
||||
Self::Field(operation) => LocalizedPatchOperation::from_field_patch(
|
||||
&operation.field,
|
||||
operation.metadata.as_ref(),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persisted localized release state.
|
||||
@@ -187,16 +302,38 @@ pub struct LocalizedPatchFile {
|
||||
/// One TextAsset patch operation recorded in the localized patch manifest.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LocalizedPatchOperation {
|
||||
/// Patch operation kind, for example `unityfs_text_asset`.
|
||||
#[serde(default = "default_patch_operation_kind")]
|
||||
pub patch_kind: String,
|
||||
/// UnityFS directory path of the serialized file.
|
||||
pub serialized_file_path: String,
|
||||
/// Unity object path ID.
|
||||
pub path_id: i64,
|
||||
/// TypeTree field path for field-level patches.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub field_path: Option<String>,
|
||||
/// Expected TextAsset name, when provided.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub expected_name: Option<String>,
|
||||
/// Replacement payload size.
|
||||
pub replacement_bytes: u64,
|
||||
/// BLAKE3 of the replacement payload.
|
||||
pub replacement_blake3: String,
|
||||
/// Stable TextUnit ID from the official TextUnit index.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub text_unit_id: Option<String>,
|
||||
/// BLAKE3 of the source text validated before publication.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_text_blake3: Option<String>,
|
||||
/// Translation provider that produced the text, if applicable.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub translation_provider: Option<String>,
|
||||
/// Provider run ID that produced the text, if applicable.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provider_run_id: Option<String>,
|
||||
/// Review state used by the publication input.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub review_status: Option<String>,
|
||||
}
|
||||
|
||||
/// Rollback information recorded for a localized publication.
|
||||
@@ -288,7 +425,34 @@ pub struct LocalizedPatchReport {
|
||||
pub integrity: LocalizedPatchIntegrity,
|
||||
}
|
||||
|
||||
/// Applies TextAsset patches and atomically publishes a localized release.
|
||||
/// Result of rolling back the current localized release.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct LocalizedRollbackReport {
|
||||
/// Stable command name.
|
||||
pub command: &'static str,
|
||||
/// Operation status.
|
||||
pub status: &'static str,
|
||||
/// Localized output root.
|
||||
pub localized_output_root: PathBuf,
|
||||
/// Atomic current pointer.
|
||||
pub current_path: PathBuf,
|
||||
/// Version state path.
|
||||
pub state_path: PathBuf,
|
||||
/// Localized release that was current before rollback.
|
||||
pub rolled_back_release_id: String,
|
||||
/// Removed current version directory.
|
||||
pub removed_version_path: PathBuf,
|
||||
/// Restored localized release ID, if a previous release existed.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub restored_release_id: Option<String>,
|
||||
/// Restored current symlink target, if a previous release existed.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub restored_current_target: Option<PathBuf>,
|
||||
/// New persisted localized version state.
|
||||
pub state: LocalizedVersionState,
|
||||
}
|
||||
|
||||
/// Applies supported UnityFS text patches and atomically publishes a localized release.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct LocalizedPatchService;
|
||||
|
||||
@@ -310,7 +474,11 @@ impl LocalizedPatchService {
|
||||
.join(LOCALIZED_VERSIONS_DIR)
|
||||
.join(&published_release_id);
|
||||
let current_path = config.localized_output_root.join(LOCALIZED_CURRENT_LINK);
|
||||
let previous_current_target = current_symlink_target(¤t_path).ok().flatten();
|
||||
validate_config(config).map_err(anyhow::Error::msg)?;
|
||||
let previous_current_target = current_symlink_target(¤t_path)?;
|
||||
if let Some(target) = previous_current_target.as_deref() {
|
||||
validate_previous_current_target(&config.localized_output_root, target)?;
|
||||
}
|
||||
let version_existed_before = version_path.exists();
|
||||
match self.publish_inner(config, previous_current_target.clone()) {
|
||||
Ok(report) => Ok(report),
|
||||
@@ -332,6 +500,159 @@ impl LocalizedPatchService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rolls back the current localized release to the manifest-recorded
|
||||
/// previous current target.
|
||||
pub fn rollback(
|
||||
&self,
|
||||
localized_output_root: &Path,
|
||||
expected_release_id: Option<&str>,
|
||||
) -> anyhow::Result<LocalizedRollbackReport> {
|
||||
ensure_safe_directory_path(localized_output_root, "汉化输出目录")
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let versions_root = localized_output_root.join(LOCALIZED_VERSIONS_DIR);
|
||||
ensure_safe_directory_path(&versions_root, "汉化 versions 目录")
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let current_path = localized_output_root.join(LOCALIZED_CURRENT_LINK);
|
||||
let state_path = localized_output_root.join(LOCALIZED_VERSION_STATE_FILE);
|
||||
let state = read_localized_version_state(localized_output_root)?.ok_or_else(|| {
|
||||
anyhow::anyhow!("缺少汉化版本状态,无法 rollback:{}", state_path.display())
|
||||
})?;
|
||||
let current_release_id = state
|
||||
.current_release_id
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("当前没有已发布汉化 release,无法 rollback"))?;
|
||||
if let Some(expected) = expected_release_id {
|
||||
if expected != current_release_id {
|
||||
return Err(anyhow::anyhow!(
|
||||
"rollback 目标 release 不一致:当前={} 请求={}",
|
||||
current_release_id,
|
||||
expected
|
||||
));
|
||||
}
|
||||
}
|
||||
let version_path = localized_output_root
|
||||
.join(LOCALIZED_VERSIONS_DIR)
|
||||
.join(¤t_release_id);
|
||||
ensure_safe_directory_path(&version_path, "当前汉化 release")
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
if !current_points_to_version(¤t_path, &version_path)? {
|
||||
return Err(anyhow::anyhow!(
|
||||
"汉化 current 未指向当前状态 release:current={} version={}",
|
||||
current_path.display(),
|
||||
version_path.display()
|
||||
));
|
||||
}
|
||||
let manifest = read_localized_patch_manifest_at(&version_path)?.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"缺少当前汉化 release manifest:{}",
|
||||
version_path.join(LOCALIZED_PATCH_MANIFEST_FILE).display()
|
||||
)
|
||||
})?;
|
||||
if manifest.localized_release_id != current_release_id {
|
||||
return Err(anyhow::anyhow!(
|
||||
"manifest release={} 与当前状态 release={} 不一致",
|
||||
manifest.localized_release_id,
|
||||
current_release_id
|
||||
));
|
||||
}
|
||||
if manifest.official_release_id != state.official_release_id {
|
||||
return Err(anyhow::anyhow!(
|
||||
"manifest 官方 release={} 与状态 release={} 不一致",
|
||||
manifest.official_release_id,
|
||||
state.official_release_id
|
||||
));
|
||||
}
|
||||
let remove_version_path = manifest.rollback.remove_version_path.clone();
|
||||
let expected_version_path = localized_output_root
|
||||
.join(LOCALIZED_VERSIONS_DIR)
|
||||
.join(¤t_release_id);
|
||||
if lexical_absolute(&remove_version_path).map_err(anyhow::Error::msg)?
|
||||
!= lexical_absolute(&expected_version_path).map_err(anyhow::Error::msg)?
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"rollback manifest 删除路径必须指向当前 release:{}",
|
||||
current_release_id
|
||||
));
|
||||
}
|
||||
ensure_path_within_root(localized_output_root, &remove_version_path)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let restored_release_id =
|
||||
match manifest.rollback.previous_current_target.as_deref() {
|
||||
None => None,
|
||||
Some(target) => Some(release_id_from_current_target(target).ok_or_else(|| {
|
||||
anyhow::anyhow!("rollback manifest 上一 current 目标格式无效")
|
||||
})?),
|
||||
};
|
||||
let mut restored_manifest = None;
|
||||
if let Some(previous_target) = manifest.rollback.previous_current_target.as_deref() {
|
||||
let previous_path = localized_output_root.join(previous_target);
|
||||
ensure_path_within_root(localized_output_root, &previous_path)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
ensure_safe_directory_path(&previous_path, "上一汉化 release")
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
if !previous_path.is_dir() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"rollback 记录的上一汉化 release 不存在:{}",
|
||||
previous_path.display()
|
||||
));
|
||||
}
|
||||
restored_manifest = Some(
|
||||
read_localized_patch_manifest_at(&previous_path)?.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"rollback 记录的上一汉化 release 缺少 manifest:{}",
|
||||
previous_path.display()
|
||||
)
|
||||
})?,
|
||||
);
|
||||
}
|
||||
|
||||
restore_current_symlink(
|
||||
localized_output_root,
|
||||
¤t_path,
|
||||
manifest.rollback.previous_current_target.as_ref(),
|
||||
)?;
|
||||
remove_owned_path(&remove_version_path)?;
|
||||
|
||||
let previous_official_release_id = state.official_release_id;
|
||||
let previous_workflow_status = state.translation_workflow_status;
|
||||
let restored_official_release_id = restored_manifest
|
||||
.as_ref()
|
||||
.map(|manifest| manifest.official_release_id.clone())
|
||||
.unwrap_or_else(|| previous_official_release_id.clone());
|
||||
let translation_workflow_status =
|
||||
if restored_official_release_id == previous_official_release_id {
|
||||
previous_workflow_status
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let new_state = LocalizedVersionState {
|
||||
state_version: LOCALIZED_VERSION_STATE_VERSION,
|
||||
official_release_id: restored_official_release_id,
|
||||
current_release_id: restored_release_id.clone(),
|
||||
status: if restored_release_id.is_some() {
|
||||
"localized".to_string()
|
||||
} else {
|
||||
"not_localized".to_string()
|
||||
},
|
||||
translation_workflow_status,
|
||||
updated_unix_seconds: unix_seconds_now(),
|
||||
};
|
||||
write_localized_version_state(localized_output_root, &new_state)?;
|
||||
|
||||
Ok(LocalizedRollbackReport {
|
||||
command: "localized.rollback",
|
||||
status: "rolled_back",
|
||||
localized_output_root: localized_output_root.to_path_buf(),
|
||||
current_path,
|
||||
state_path,
|
||||
rolled_back_release_id: current_release_id,
|
||||
removed_version_path: remove_version_path,
|
||||
restored_release_id,
|
||||
restored_current_target: manifest.rollback.previous_current_target,
|
||||
state: new_state,
|
||||
})
|
||||
}
|
||||
|
||||
fn publish_inner(
|
||||
&self,
|
||||
config: &LocalizedPatchConfig,
|
||||
@@ -350,53 +671,85 @@ impl LocalizedPatchService {
|
||||
let state_path = config
|
||||
.localized_output_root
|
||||
.join(LOCALIZED_VERSION_STATE_FILE);
|
||||
let versions_root = config.localized_output_root.join(LOCALIZED_VERSIONS_DIR);
|
||||
let staging_root = config.localized_output_root.join(LOCALIZED_STAGING_DIR);
|
||||
ensure_safe_directory_path(&versions_root, "汉化 versions 目录")
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
ensure_safe_directory_path(&staging_root, "汉化 staging 目录")
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let patch_manifest_path = version_path.join(LOCALIZED_PATCH_MANIFEST_FILE);
|
||||
let previous_state = read_localized_version_state(&config.localized_output_root)?;
|
||||
let translation_workflow_status = previous_state
|
||||
.filter(|state| state.official_release_id == config.release_id)
|
||||
.and_then(|state| state.translation_workflow_status);
|
||||
|
||||
if version_path.exists() {
|
||||
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()));
|
||||
match fs::symlink_metadata(&version_path) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"汉化 release 目标不能是 symlink:{}",
|
||||
version_path.display()
|
||||
));
|
||||
}
|
||||
Ok(metadata) if !metadata.is_dir() => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"汉化 release 目标已存在但不是目录:{}",
|
||||
version_path.display()
|
||||
));
|
||||
}
|
||||
Ok(_) => {
|
||||
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()));
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
remove_owned_staging(&staging)?;
|
||||
fs::create_dir_all(&staging)?;
|
||||
copy_tree(&config.official_release_root, &staging)?;
|
||||
|
||||
let mut changed_files = Vec::with_capacity(config.patches.len());
|
||||
for operation in &config.patches {
|
||||
let target = staging.join(Path::new(&operation.bundle_path));
|
||||
let operations = config.patch_operations();
|
||||
let mut grouped = BTreeMap::<String, Vec<LocalizedPatchInput>>::new();
|
||||
for operation in operations {
|
||||
grouped
|
||||
.entry(operation.bundle_path().to_string())
|
||||
.or_default()
|
||||
.push(operation);
|
||||
}
|
||||
|
||||
let mut changed_files = Vec::with_capacity(grouped.len());
|
||||
for (bundle_path, operations) in grouped {
|
||||
let target = staging.join(Path::new(&bundle_path));
|
||||
ensure_path_within_root(&staging, &target).map_err(anyhow::Error::msg)?;
|
||||
ensure_safe_file_target(&staging, &target, "汉化 patch 输入")
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let original = fs::read(&target)?;
|
||||
let patched = patch_unityfs_text_asset(&original, &operation.text_asset)
|
||||
.map_err(|error| anyhow::anyhow!("{}: {error}", operation.bundle_path))?;
|
||||
let mut patched = original.clone();
|
||||
let mut manifest_operations = Vec::with_capacity(operations.len());
|
||||
for operation in operations {
|
||||
patched = operation
|
||||
.apply(&patched)
|
||||
.map_err(|error| anyhow::anyhow!("{bundle_path}: {error}"))?;
|
||||
manifest_operations.push(operation.manifest_operation()?);
|
||||
}
|
||||
if original == patched {
|
||||
return Err(anyhow::anyhow!(
|
||||
"patch produced no change: {}",
|
||||
operation.bundle_path
|
||||
));
|
||||
return Err(anyhow::anyhow!("patch produced no change: {bundle_path}"));
|
||||
}
|
||||
write_file_atomic(&target, &patched, STATE_FILE_MODE, "汉化 patch 输出")
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let original_blake3 = blake3::hash(&original).to_hex().to_string();
|
||||
let localized_blake3 = blake3::hash(&patched).to_hex().to_string();
|
||||
changed_files.push(LocalizedPatchFile {
|
||||
path: operation.bundle_path.clone(),
|
||||
path: bundle_path,
|
||||
original_blake3,
|
||||
localized_blake3,
|
||||
original_bytes: original.len() as u64,
|
||||
localized_bytes: patched.len() as u64,
|
||||
byte_delta: patched.len() as i64 - original.len() as i64,
|
||||
text_asset_operations: vec![LocalizedPatchOperation::from_text_asset_patch(
|
||||
&operation.text_asset,
|
||||
)],
|
||||
text_asset_operations: manifest_operations,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -571,13 +924,112 @@ pub fn read_localized_patch_manifest_at(
|
||||
}
|
||||
|
||||
impl LocalizedPatchOperation {
|
||||
fn from_text_asset_patch(patch: &TextAssetPatch) -> Self {
|
||||
fn from_text_asset_patch(
|
||||
patch: &TextAssetPatch,
|
||||
metadata: Option<&LocalizedPatchOperationMetadata>,
|
||||
) -> Self {
|
||||
Self::with_metadata(
|
||||
Self {
|
||||
patch_kind: "unityfs_text_asset".to_string(),
|
||||
serialized_file_path: patch.serialized_file_path.clone(),
|
||||
path_id: patch.path_id,
|
||||
field_path: None,
|
||||
expected_name: patch.expected_name.clone(),
|
||||
replacement_bytes: patch.replacement.len() as u64,
|
||||
replacement_blake3: blake3::hash(&patch.replacement).to_hex().to_string(),
|
||||
text_unit_id: None,
|
||||
source_text_blake3: None,
|
||||
translation_provider: None,
|
||||
provider_run_id: None,
|
||||
review_status: None,
|
||||
},
|
||||
metadata,
|
||||
)
|
||||
}
|
||||
|
||||
fn from_string_field_patch(
|
||||
patch: &StringFieldPatch,
|
||||
metadata: Option<&LocalizedPatchOperationMetadata>,
|
||||
) -> Self {
|
||||
Self::with_metadata(
|
||||
Self {
|
||||
patch_kind: "unityfs_string_field".to_string(),
|
||||
serialized_file_path: patch.serialized_file_path.clone(),
|
||||
path_id: patch.path_id,
|
||||
field_path: Some(patch.field_path.clone()),
|
||||
expected_name: None,
|
||||
replacement_bytes: patch.replacement.len() as u64,
|
||||
replacement_blake3: blake3::hash(patch.replacement.as_bytes())
|
||||
.to_hex()
|
||||
.to_string(),
|
||||
text_unit_id: None,
|
||||
source_text_blake3: None,
|
||||
translation_provider: None,
|
||||
provider_run_id: None,
|
||||
review_status: None,
|
||||
},
|
||||
metadata,
|
||||
)
|
||||
}
|
||||
|
||||
fn from_field_patch(
|
||||
patch: &FieldPatch,
|
||||
metadata: Option<&LocalizedPatchOperationMetadata>,
|
||||
) -> anyhow::Result<Self> {
|
||||
let replacement = serde_json::to_vec(&patch.replacement)?;
|
||||
Ok(Self::with_metadata(
|
||||
Self {
|
||||
patch_kind: "unityfs_field".to_string(),
|
||||
serialized_file_path: patch.serialized_file_path.clone(),
|
||||
path_id: patch.path_id,
|
||||
field_path: Some(patch.field_path.clone()),
|
||||
expected_name: None,
|
||||
replacement_bytes: replacement.len() as u64,
|
||||
replacement_blake3: blake3::hash(&replacement).to_hex().to_string(),
|
||||
text_unit_id: None,
|
||||
source_text_blake3: None,
|
||||
translation_provider: None,
|
||||
provider_run_id: None,
|
||||
review_status: None,
|
||||
},
|
||||
metadata,
|
||||
))
|
||||
}
|
||||
|
||||
fn with_metadata(
|
||||
mut operation: Self,
|
||||
metadata: Option<&LocalizedPatchOperationMetadata>,
|
||||
) -> Self {
|
||||
if let Some(metadata) = metadata {
|
||||
operation.text_unit_id = Some(metadata.text_unit_id.clone());
|
||||
operation.source_text_blake3 = Some(metadata.source_text_blake3.clone());
|
||||
operation.translation_provider = metadata.translation_provider.clone();
|
||||
operation.provider_run_id = metadata.provider_run_id.clone();
|
||||
operation.review_status = Some(metadata.review_status.clone());
|
||||
}
|
||||
operation
|
||||
}
|
||||
}
|
||||
|
||||
fn default_patch_operation_kind() -> String {
|
||||
"unityfs_text_asset".to_string()
|
||||
}
|
||||
|
||||
impl Default for LocalizedPatchOperation {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
serialized_file_path: patch.serialized_file_path.clone(),
|
||||
path_id: patch.path_id,
|
||||
expected_name: patch.expected_name.clone(),
|
||||
replacement_bytes: patch.replacement.len() as u64,
|
||||
replacement_blake3: blake3::hash(&patch.replacement).to_hex().to_string(),
|
||||
patch_kind: default_patch_operation_kind(),
|
||||
serialized_file_path: String::new(),
|
||||
path_id: 0,
|
||||
field_path: None,
|
||||
expected_name: None,
|
||||
replacement_bytes: 0,
|
||||
replacement_blake3: String::new(),
|
||||
text_unit_id: None,
|
||||
source_text_blake3: None,
|
||||
translation_provider: None,
|
||||
provider_run_id: None,
|
||||
review_status: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -690,6 +1142,25 @@ fn current_symlink_target(current_path: &Path) -> anyhow::Result<Option<PathBuf>
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_previous_current_target(root: &Path, target: &Path) -> anyhow::Result<()> {
|
||||
if release_id_from_current_target(target).is_none() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"汉化 current 目标不是受支持的 versions/<release> 路径:{}",
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
let target_path = root.join(target);
|
||||
ensure_path_within_root(root, &target_path).map_err(anyhow::Error::msg)?;
|
||||
ensure_safe_directory_path(&target_path, "汉化 current 目标").map_err(anyhow::Error::msg)?;
|
||||
if !target_path.is_dir() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"汉化 current 目标 release 不存在:{}",
|
||||
target_path.display()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn current_points_to_version(current_path: &Path, version_path: &Path) -> anyhow::Result<bool> {
|
||||
let metadata = fs::symlink_metadata(current_path)?;
|
||||
if !metadata.file_type().is_symlink() {
|
||||
@@ -698,6 +1169,18 @@ fn current_points_to_version(current_path: &Path, version_path: &Path) -> anyhow
|
||||
Ok(fs::canonicalize(current_path)? == fs::canonicalize(version_path)?)
|
||||
}
|
||||
|
||||
fn release_id_from_current_target(target: &Path) -> Option<String> {
|
||||
let mut components = target.components();
|
||||
match (components.next(), components.next(), components.next()) {
|
||||
(
|
||||
Some(std::path::Component::Normal(root)),
|
||||
Some(std::path::Component::Normal(release_id)),
|
||||
None,
|
||||
) if root == LOCALIZED_VERSIONS_DIR => release_id.to_str().map(str::to_string),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rollback_failed_publish(
|
||||
localized_output_root: &Path,
|
||||
staging: &Path,
|
||||
@@ -907,11 +1390,18 @@ mod tests {
|
||||
localized_bytes: target.len() as u64,
|
||||
byte_delta: target.len() as i64 - source.len() as i64,
|
||||
text_asset_operations: vec![LocalizedPatchOperation {
|
||||
patch_kind: "unityfs_text_asset".to_string(),
|
||||
serialized_file_path: "CAB-asset".to_string(),
|
||||
path_id: 1,
|
||||
field_path: None,
|
||||
expected_name: Some("Text".to_string()),
|
||||
replacement_bytes: target.len() as u64,
|
||||
replacement_blake3: blake3::hash(target).to_hex().to_string(),
|
||||
text_unit_id: Some("unit-1".to_string()),
|
||||
source_text_blake3: Some(blake3::hash(source).to_hex().to_string()),
|
||||
translation_provider: Some("mock".to_string()),
|
||||
provider_run_id: Some("mock:unit-1:attempt-1".to_string()),
|
||||
review_status: Some("provider_completed".to_string()),
|
||||
}],
|
||||
}],
|
||||
rollback: LocalizedPatchRollbackInfo {
|
||||
@@ -987,6 +1477,85 @@ mod tests {
|
||||
assert_eq!(manifest.rollback.previous_current_target, None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rollback_restores_previous_localized_release() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let official = temp.path().join("official-release");
|
||||
let localized = temp.path().join("localized");
|
||||
fs::create_dir_all(official.join("TableBundles")).unwrap();
|
||||
fs::write(
|
||||
official.join("TableBundles/TableCatalog.bytes"),
|
||||
b"official",
|
||||
)
|
||||
.unwrap();
|
||||
let service = LocalizedPatchService::new();
|
||||
|
||||
service
|
||||
.publish(&LocalizedPatchConfig::new(
|
||||
&official,
|
||||
&localized,
|
||||
"release-1",
|
||||
Vec::new(),
|
||||
))
|
||||
.unwrap();
|
||||
service
|
||||
.publish(
|
||||
&LocalizedPatchConfig::new(&official, &localized, "release-1", Vec::new())
|
||||
.with_localized_release_id("release-2"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report = service.rollback(&localized, Some("release-2")).unwrap();
|
||||
|
||||
assert_eq!(report.rolled_back_release_id, "release-2");
|
||||
assert_eq!(report.restored_release_id.as_deref(), Some("release-1"));
|
||||
assert!(!localized
|
||||
.join(LOCALIZED_VERSIONS_DIR)
|
||||
.join("release-2")
|
||||
.exists());
|
||||
assert_eq!(
|
||||
fs::read_link(localized.join(LOCALIZED_CURRENT_LINK)).unwrap(),
|
||||
PathBuf::from("versions/release-1")
|
||||
);
|
||||
let state = read_localized_version_state(&localized).unwrap().unwrap();
|
||||
assert_eq!(state.status, "localized");
|
||||
assert_eq!(state.current_release_id.as_deref(), Some("release-1"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn publish_rejects_invalid_existing_current_target() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = TempDir::new().unwrap();
|
||||
let official = temp.path().join("official-release");
|
||||
let localized = temp.path().join("localized");
|
||||
fs::create_dir_all(official.join("TableBundles")).unwrap();
|
||||
fs::write(
|
||||
official.join("TableBundles/TableCatalog.bytes"),
|
||||
b"official",
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(localized.join(LOCALIZED_VERSIONS_DIR)).unwrap();
|
||||
symlink(
|
||||
temp.path().join("outside"),
|
||||
localized.join(LOCALIZED_CURRENT_LINK),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let error = LocalizedPatchService::new()
|
||||
.publish(&LocalizedPatchConfig::new(
|
||||
&official,
|
||||
&localized,
|
||||
"release-1",
|
||||
Vec::new(),
|
||||
))
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.to_string().contains("current 目标不是受支持"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn localized_state_reads_legacy_file_without_workflow_status() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
@@ -1060,6 +1629,7 @@ mod tests {
|
||||
vec![LocalizedTextAssetPatch {
|
||||
bundle_path: "Bundles/bad.bundle".to_string(),
|
||||
text_asset: TextAssetPatch::new("CAB-bad", 1, b"replacement".to_vec()),
|
||||
metadata: None,
|
||||
}],
|
||||
))
|
||||
.unwrap_err();
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
//! Manual translation workbench and controlled UnityFS repack workflows.
|
||||
|
||||
use crate::official_parse::{read_textunit_index_at, OfficialTextUnitIndexUnit};
|
||||
use crate::official_textunit_queue::OfficialTextUnitTaskQuery;
|
||||
use crate::path_security::{
|
||||
ensure_safe_file_target, lexical_absolute, read_file_no_symlink, write_file_atomic,
|
||||
STATE_FILE_MODE,
|
||||
};
|
||||
use crate::LocalizedTextAssetPatch;
|
||||
use crate::{
|
||||
LocalizedPatchInput, LocalizedPatchOperationMetadata, LocalizedStringFieldPatch,
|
||||
LocalizedTextAssetPatch, PersistedTranslationTask, SqliteTranslationTaskRepository,
|
||||
TranslationTaskStatus, TranslationTaskUnitResult,
|
||||
};
|
||||
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, HashMap};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -52,12 +57,27 @@ pub struct TranslationWorkbenchEntry {
|
||||
/// Unity TextAsset name, when known.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub asset_name: Option<String>,
|
||||
/// TypeTree field path, when the source is a field-level TextUnit.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub field_path: 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>,
|
||||
/// Provider that produced this translation, when imported from worker output.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub translation_provider: Option<String>,
|
||||
/// Provider run that produced this translation, when imported from worker output.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provider_run_id: Option<String>,
|
||||
/// Worker completion time for provider-produced text.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub translated_unix_seconds: Option<u64>,
|
||||
/// Review state used by publish manifest metadata.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub review_status: Option<String>,
|
||||
/// TextUnit format.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub format: Option<String>,
|
||||
@@ -83,7 +103,7 @@ pub struct TranslationWorkbenchValidationReport {
|
||||
pub changed_entries: usize,
|
||||
/// Changed direct TextAsset entries usable by `i18n publish`.
|
||||
pub publishable_entries: usize,
|
||||
/// Changed TypeTree or nested-archive entries requiring `parse repack`.
|
||||
/// Changed entries outside the direct localized publish support range.
|
||||
pub repack_entries: usize,
|
||||
}
|
||||
|
||||
@@ -116,6 +136,94 @@ pub fn export_translation_workbench(
|
||||
Ok(workbench)
|
||||
}
|
||||
|
||||
/// Builds a workbench from completed provider worker results.
|
||||
pub async fn completed_worker_translation_workbench(
|
||||
resource_root: &Path,
|
||||
official_release_id: &str,
|
||||
) -> anyhow::Result<TranslationWorkbench> {
|
||||
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::<HashMap<_, _>>();
|
||||
let repository_path = SqliteTranslationTaskRepository::repository_path(resource_root);
|
||||
let repository = SqliteTranslationTaskRepository::open(&repository_path)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("打开翻译任务状态库失败:{error}"))?;
|
||||
let tasks = repository
|
||||
.list(&OfficialTextUnitTaskQuery {
|
||||
official_release_id: Some(official_release_id.to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("读取翻译任务状态失败:{error}"))?;
|
||||
let mut result_by_unit = BTreeMap::new();
|
||||
for task in tasks
|
||||
.iter()
|
||||
.filter(|task| task.task_status == TranslationTaskStatus::Completed)
|
||||
{
|
||||
if task.task.official_release_id != official_release_id {
|
||||
return Err(anyhow::anyhow!(
|
||||
"worker 任务 {} 的官方 release={} 与当前 release={} 不一致",
|
||||
task.task.task_id,
|
||||
task.task.official_release_id,
|
||||
official_release_id
|
||||
));
|
||||
}
|
||||
for result in &task.translation_results {
|
||||
let unit = index_by_id.get(result.unit_id.as_str()).ok_or_else(|| {
|
||||
anyhow::anyhow!("worker 结果引用了未知 TextUnit:{}", result.unit_id)
|
||||
})?;
|
||||
validate_worker_result(task, unit, result)?;
|
||||
if result_by_unit
|
||||
.insert(result.unit_id.as_str(), (unit, task, result))
|
||||
.is_some()
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"worker 结果包含重复 TextUnit:{}",
|
||||
result.unit_id
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if result_by_unit.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"当前 release 没有 completed provider 翻译结果可发布"
|
||||
));
|
||||
}
|
||||
let entries = index
|
||||
.units
|
||||
.iter()
|
||||
.filter_map(|unit| {
|
||||
result_by_unit
|
||||
.get(unit.id.as_str())
|
||||
.map(|(_, task, result)| workbench_entry_from_worker_result(unit, task, result))
|
||||
})
|
||||
.collect();
|
||||
Ok(TranslationWorkbench {
|
||||
schema_version: TRANSLATION_WORKBENCH_VERSION,
|
||||
official_release_id: official_release_id.to_string(),
|
||||
official_resource_root: lexical_absolute(resource_root).map_err(anyhow::Error::msg)?,
|
||||
generated_unix_seconds: unix_seconds_now(),
|
||||
entries,
|
||||
})
|
||||
}
|
||||
|
||||
/// Exports completed provider worker results as an editable workbench.
|
||||
pub async fn export_completed_worker_translation_workbench(
|
||||
resource_root: &Path,
|
||||
official_release_id: &str,
|
||||
output_path: &Path,
|
||||
) -> anyhow::Result<TranslationWorkbench> {
|
||||
let workbench =
|
||||
completed_worker_translation_workbench(resource_root, official_release_id).await?;
|
||||
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, "翻译工作台")
|
||||
@@ -253,8 +361,12 @@ pub fn validate_translation_workbench(
|
||||
continue;
|
||||
}
|
||||
changed_entries += 1;
|
||||
let source_kind = normalized_text_source_kind(entry.text_source_kind.as_deref());
|
||||
let is_publishable = entry.archive_entry.is_none()
|
||||
&& entry.text_source_kind.as_deref() == Some("text_asset");
|
||||
&& matches!(
|
||||
source_kind.as_deref(),
|
||||
Some("textasset" | "typetreefield" | "managedreferencefield")
|
||||
);
|
||||
if is_publishable {
|
||||
let serialized_file = entry
|
||||
.serialized_file
|
||||
@@ -263,10 +375,21 @@ pub fn validate_translation_workbench(
|
||||
let path_id = entry
|
||||
.path_id
|
||||
.ok_or_else(|| anyhow::anyhow!("TextUnit {} 没有 path_id", entry.id))?;
|
||||
let field_path = if source_kind.as_deref() == Some("textasset") {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
entry
|
||||
.field_path
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("TextUnit {} 没有 field_path", entry.id))?,
|
||||
)
|
||||
};
|
||||
if !seen_patch_targets.insert((
|
||||
entry.destination.clone(),
|
||||
serialized_file.clone(),
|
||||
path_id,
|
||||
field_path,
|
||||
)) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"翻译工作台包含重复 patch 目标:{}",
|
||||
@@ -290,11 +413,122 @@ pub fn validate_translation_workbench(
|
||||
})
|
||||
}
|
||||
|
||||
/// Converts reviewed direct TextAsset entries to localized patch operations.
|
||||
/// Converts reviewed entries to localized patch operations supported by the
|
||||
/// current UnityFS write layer.
|
||||
///
|
||||
/// TypeTree fields and zip-inner bundles are intentionally rejected here.
|
||||
/// They need a different patch representation and must not silently become a
|
||||
/// TextAsset replacement.
|
||||
/// ZIP-inner bundles are intentionally rejected here because they require a
|
||||
/// separate archive rewrite boundary.
|
||||
pub fn localized_patch_operations(
|
||||
resource_root: &Path,
|
||||
workbench: &TranslationWorkbench,
|
||||
) -> anyhow::Result<Vec<LocalizedPatchInput>> {
|
||||
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 operations = 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
|
||||
));
|
||||
}
|
||||
let source_kind = normalized_text_source_kind(entry.text_source_kind.as_deref());
|
||||
let field_path = entry.field_path.clone();
|
||||
if !seen.insert((
|
||||
entry.destination.clone(),
|
||||
serialized_file.clone(),
|
||||
path_id,
|
||||
field_path.clone(),
|
||||
)) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"翻译工作台包含重复 patch 目标:{}",
|
||||
entry.id
|
||||
));
|
||||
}
|
||||
let metadata = Some(localized_patch_metadata(entry));
|
||||
match source_kind.as_deref() {
|
||||
Some("textasset") => {
|
||||
let mut patch = TextAssetPatch::new(
|
||||
serialized_file,
|
||||
path_id,
|
||||
translated_text.as_bytes().to_vec(),
|
||||
);
|
||||
patch.expected_name = entry.asset_name.clone();
|
||||
operations.push(LocalizedPatchInput::TextAsset(LocalizedTextAssetPatch {
|
||||
bundle_path: entry.destination.clone(),
|
||||
text_asset: patch,
|
||||
metadata,
|
||||
}));
|
||||
}
|
||||
Some("typetreefield" | "managedreferencefield") => {
|
||||
let field_path = field_path.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"TextUnit {} 没有 field_path,不能生成 TypeTree patch",
|
||||
entry.id
|
||||
)
|
||||
})?;
|
||||
operations.push(LocalizedPatchInput::StringField(
|
||||
LocalizedStringFieldPatch {
|
||||
bundle_path: entry.destination.clone(),
|
||||
string_field: StringFieldPatch {
|
||||
serialized_file_path: serialized_file,
|
||||
path_id,
|
||||
field_path,
|
||||
expected_value: Some(entry.source_text.clone()),
|
||||
replacement: translated_text.clone(),
|
||||
},
|
||||
metadata,
|
||||
},
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"TextUnit {} 的来源不在当前 localized publish 支持范围内",
|
||||
entry.id
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if operations.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"翻译工作台没有可发布的已修改 TextUnit;请先用 translation-set 调整文本或导入 worker 结果"
|
||||
));
|
||||
}
|
||||
Ok(operations)
|
||||
}
|
||||
|
||||
/// Converts reviewed direct TextAsset entries to localized patch operations.
|
||||
pub fn localized_text_asset_patches(
|
||||
resource_root: &Path,
|
||||
workbench: &TranslationWorkbench,
|
||||
@@ -339,7 +573,9 @@ pub fn localized_text_asset_patches(
|
||||
entry.id
|
||||
));
|
||||
}
|
||||
if entry.text_source_kind.as_deref() != Some("text_asset") {
|
||||
if normalized_text_source_kind(entry.text_source_kind.as_deref()).as_deref()
|
||||
!= Some("textasset")
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"TextUnit {} 的来源不是 TextAsset;请使用 repack spec 的 TypeTree 操作",
|
||||
entry.id
|
||||
@@ -360,6 +596,7 @@ pub fn localized_text_asset_patches(
|
||||
patches.push(LocalizedTextAssetPatch {
|
||||
bundle_path: entry.destination.clone(),
|
||||
text_asset: patch,
|
||||
metadata: Some(localized_patch_metadata(entry)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -371,6 +608,31 @@ pub fn localized_text_asset_patches(
|
||||
Ok(patches)
|
||||
}
|
||||
|
||||
fn localized_patch_metadata(entry: &TranslationWorkbenchEntry) -> LocalizedPatchOperationMetadata {
|
||||
LocalizedPatchOperationMetadata {
|
||||
text_unit_id: entry.id.clone(),
|
||||
source_text_blake3: blake3::hash(entry.source_text.as_bytes())
|
||||
.to_hex()
|
||||
.to_string(),
|
||||
translation_provider: entry.translation_provider.clone(),
|
||||
provider_run_id: entry.provider_run_id.clone(),
|
||||
review_status: entry
|
||||
.review_status
|
||||
.clone()
|
||||
.unwrap_or_else(|| "manual_reviewed".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_text_source_kind(value: Option<&str>) -> Option<String> {
|
||||
value.map(|value| {
|
||||
value
|
||||
.chars()
|
||||
.filter(|ch| !matches!(ch, '_' | '-' | ' '))
|
||||
.collect::<String>()
|
||||
.to_ascii_lowercase()
|
||||
})
|
||||
}
|
||||
|
||||
/// Batch UnityFS repack specification.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RepackSpec {
|
||||
@@ -597,6 +859,49 @@ fn read_text_replacement(
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_worker_result(
|
||||
task: &PersistedTranslationTask,
|
||||
unit: &OfficialTextUnitIndexUnit,
|
||||
result: &TranslationTaskUnitResult,
|
||||
) -> anyhow::Result<()> {
|
||||
if task.task.destination != unit.destination || task.task.archive_entry != unit.archive_entry {
|
||||
return Err(anyhow::anyhow!(
|
||||
"worker 任务 {} 与 TextUnit {} 的 destination/archive entry 不一致",
|
||||
task.task.task_id,
|
||||
unit.id
|
||||
));
|
||||
}
|
||||
if result.source_text != unit.source_text {
|
||||
return Err(anyhow::anyhow!(
|
||||
"worker 结果 {} 的 source_text 与当前 TextUnit 索引不一致",
|
||||
result.unit_id
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn workbench_entry_from_worker_result(
|
||||
unit: &OfficialTextUnitIndexUnit,
|
||||
task: &PersistedTranslationTask,
|
||||
result: &TranslationTaskUnitResult,
|
||||
) -> TranslationWorkbenchEntry {
|
||||
let mut entry = TranslationWorkbenchEntry::from_index(unit);
|
||||
entry.translated_text = Some(result.translated_text.clone());
|
||||
entry.translation_provider = task
|
||||
.provider
|
||||
.clone()
|
||||
.or_else(|| Some(result.provider.clone()))
|
||||
.filter(|provider| !provider.trim().is_empty());
|
||||
entry.provider_run_id = task
|
||||
.provider_run_id
|
||||
.clone()
|
||||
.or_else(|| Some(result.provider_run_id.clone()))
|
||||
.filter(|provider_run_id| !provider_run_id.trim().is_empty());
|
||||
entry.translated_unix_seconds = Some(result.translated_unix_seconds);
|
||||
entry.review_status = Some("provider_completed".to_string());
|
||||
entry
|
||||
}
|
||||
|
||||
fn validate_workbench_entry(
|
||||
entry: &TranslationWorkbenchEntry,
|
||||
current: &OfficialTextUnitIndexUnit,
|
||||
@@ -607,6 +912,7 @@ fn validate_workbench_entry(
|
||||
|| entry.serialized_file != current.serialized_file
|
||||
|| entry.path_id != current.path_id
|
||||
|| entry.asset_name != current.asset_name
|
||||
|| entry.field_path != current.field_path
|
||||
|| entry.format != current.format
|
||||
|| entry.text_source_kind != current.text_source_kind
|
||||
{
|
||||
@@ -627,8 +933,13 @@ impl TranslationWorkbenchEntry {
|
||||
serialized_file: unit.serialized_file.clone(),
|
||||
path_id: unit.path_id,
|
||||
asset_name: unit.asset_name.clone(),
|
||||
field_path: unit.field_path.clone(),
|
||||
source_text: unit.source_text.clone(),
|
||||
translated_text: None,
|
||||
translation_provider: None,
|
||||
provider_run_id: None,
|
||||
translated_unix_seconds: None,
|
||||
review_status: None,
|
||||
format: unit.format.clone(),
|
||||
text_source_kind: unit.text_source_kind.clone(),
|
||||
}
|
||||
@@ -659,8 +970,13 @@ mod tests {
|
||||
serialized_file: Some("CAB-test".to_string()),
|
||||
path_id: Some(7),
|
||||
asset_name: Some("Story".to_string()),
|
||||
field_path: None,
|
||||
source_text: "原文".to_string(),
|
||||
translated_text: None,
|
||||
translation_provider: None,
|
||||
provider_run_id: None,
|
||||
translated_unix_seconds: None,
|
||||
review_status: None,
|
||||
format: Some("plain".to_string()),
|
||||
text_source_kind: Some("text_asset".to_string()),
|
||||
}],
|
||||
@@ -743,4 +1059,63 @@ mod tests {
|
||||
assert_eq!(report.publishable_entries, 1);
|
||||
assert_eq!(report.unreviewed_entries, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn localized_operations_preserve_type_tree_field_traceability() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let index = crate::official_parse::OfficialTextUnitIndex {
|
||||
version: crate::official_parse::OFFICIAL_TEXTUNIT_INDEX_VERSION,
|
||||
generated_unix_seconds: 1,
|
||||
resource_root: temp.path().to_path_buf(),
|
||||
summary: Default::default(),
|
||||
units: vec![OfficialTextUnitIndexUnit {
|
||||
id: "unit-1".to_string(),
|
||||
parse_entry_key: "bundle".to_string(),
|
||||
source_url: "https://example.invalid/bundle".to_string(),
|
||||
destination: "bundles/test.bundle".to_string(),
|
||||
archive_entry: None,
|
||||
source_kind: crate::official_parse::OfficialParseSourceKind::DirectBundle,
|
||||
unity_version: None,
|
||||
source_text: "原文".to_string(),
|
||||
serialized_file: Some("CAB-test".to_string()),
|
||||
path_id: Some(7),
|
||||
class_id: Some(114),
|
||||
field_path: Some("Scenario.Message".to_string()),
|
||||
field_offset: Some(16),
|
||||
field_byte_size: Some(8),
|
||||
format: Some("plain".to_string()),
|
||||
text_source_kind: Some("TypeTreeField".to_string()),
|
||||
asset_name: None,
|
||||
context: Default::default(),
|
||||
}],
|
||||
errors: Vec::new(),
|
||||
};
|
||||
crate::official_parse::write_textunit_index_at(temp.path(), &index).unwrap();
|
||||
let mut workbench = workbench(temp.path());
|
||||
let entry = &mut workbench.entries[0];
|
||||
entry.asset_name = None;
|
||||
entry.field_path = Some("Scenario.Message".to_string());
|
||||
entry.text_source_kind = Some("TypeTreeField".to_string());
|
||||
entry.translated_text = Some("译文".to_string());
|
||||
entry.translation_provider = Some("mock".to_string());
|
||||
entry.provider_run_id = Some("run-1".to_string());
|
||||
|
||||
let operations = localized_patch_operations(temp.path(), &workbench).unwrap();
|
||||
assert_eq!(operations.len(), 1);
|
||||
match &operations[0] {
|
||||
LocalizedPatchInput::StringField(operation) => {
|
||||
assert_eq!(operation.string_field.field_path, "Scenario.Message");
|
||||
assert_eq!(
|
||||
operation.string_field.expected_value.as_deref(),
|
||||
Some("原文")
|
||||
);
|
||||
assert_eq!(operation.string_field.replacement, "译文");
|
||||
let metadata = operation.metadata.as_ref().unwrap();
|
||||
assert_eq!(metadata.text_unit_id, "unit-1");
|
||||
assert_eq!(metadata.translation_provider.as_deref(), Some("mock"));
|
||||
assert_eq!(metadata.provider_run_id.as_deref(), Some("run-1"));
|
||||
}
|
||||
other => panic!("unexpected localized operation: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user