mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 10:04:55 +08:00
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user