//! 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::{ LocalizedPatchInput, LocalizedPatchOperationMetadata, LocalizedStringFieldPatch, LocalizedTextAssetPatch, PersistedTranslationTask, SqliteTranslationTaskRepository, TranslationTaskResultSourceKind, 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::{BTreeMap, BTreeSet, HashMap}; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; /// Current manual translation workbench schema. pub const TRANSLATION_WORKBENCH_VERSION: u32 = 1; /// A manually editable translation file for one official release. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TranslationWorkbench { /// Workbench schema version. pub schema_version: u32, /// Official release consumed by this workbench. pub official_release_id: String, /// Official release root used to generate the entries. pub official_resource_root: PathBuf, /// Workbench generation time. pub generated_unix_seconds: u64, /// TextUnit entries in stable parse-index order. pub entries: Vec, } /// One manually editable TextUnit translation entry. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TranslationWorkbenchEntry { /// Stable TextUnit ID. pub id: String, /// Relative official resource destination. pub destination: String, /// Archive entry, when the source is nested in a zip. #[serde(default, skip_serializing_if = "Option::is_none")] pub archive_entry: Option, /// Unity serialized file path. #[serde(default, skip_serializing_if = "Option::is_none")] pub serialized_file: Option, /// Unity object path ID. #[serde(default, skip_serializing_if = "Option::is_none")] pub path_id: Option, /// Unity TextAsset name, when known. #[serde(default, skip_serializing_if = "Option::is_none")] pub asset_name: Option, /// TypeTree field path, when the source is a field-level TextUnit. #[serde(default, skip_serializing_if = "Option::is_none")] pub field_path: Option, /// 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, /// Provider that produced this translation, when imported from worker output. #[serde(default, skip_serializing_if = "Option::is_none")] pub translation_provider: Option, /// Provider run that produced this translation, when imported from worker output. #[serde(default, skip_serializing_if = "Option::is_none")] pub provider_run_id: Option, /// Source of the worker result (`provider`, `manual`, or `translation_memory`). #[serde(default, skip_serializing_if = "Option::is_none")] pub translation_source_kind: Option, /// Trusted Translation Memory record used for this translation, when applicable. #[serde(default, skip_serializing_if = "Option::is_none")] pub translation_memory_record_id: Option, /// Worker completion time for provider-produced text. #[serde(default, skip_serializing_if = "Option::is_none")] pub translated_unix_seconds: Option, /// Review state used by publish manifest metadata. #[serde(default, skip_serializing_if = "Option::is_none")] pub review_status: Option, /// TextUnit format. #[serde(default, skip_serializing_if = "Option::is_none")] pub format: Option, /// Extraction source kind such as TextAsset or TypeTreeField. #[serde(default, skip_serializing_if = "Option::is_none")] pub text_source_kind: Option, } /// Summary produced by `i18n validate`. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct TranslationWorkbenchValidationReport { /// Stable command name. pub command: &'static str, /// Validation status. pub status: &'static str, /// Number of entries in the workbench. pub total_entries: usize, /// Entries without a human translation. pub unreviewed_entries: usize, /// Entries whose translation equals the source text. pub unchanged_entries: usize, /// Entries with a changed translation. pub changed_entries: usize, /// Changed direct TextAsset entries usable by `i18n publish`. pub publishable_entries: usize, /// Changed entries outside the direct localized publish support range. pub repack_entries: usize, } /// Exports the current official TextUnit index as an editable workbench. pub fn export_translation_workbench( resource_root: &Path, official_release_id: impl Into, output_path: &Path, ) -> anyhow::Result { let index = read_textunit_index_at(resource_root) .map_err(anyhow::Error::msg)? .ok_or_else(|| { anyhow::anyhow!( "缺少官方 TextUnit 索引,无法导出翻译工作台:{}", resource_root.display() ) })?; let workbench = TranslationWorkbench { schema_version: TRANSLATION_WORKBENCH_VERSION, official_release_id: official_release_id.into(), official_resource_root: lexical_absolute(resource_root).map_err(anyhow::Error::msg)?, generated_unix_seconds: unix_seconds_now(), entries: index .units .iter() .map(TranslationWorkbenchEntry::from_index) .collect(), }; write_translation_workbench(output_path, &workbench)?; Ok(workbench) } /// Builds a workbench from completed provider worker results. pub async fn completed_worker_translation_workbench( resource_root: &Path, official_release_id: &str, ) -> anyhow::Result { 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::>(); 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 { 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 { let bytes = read_file_no_symlink(path, "翻译工作台") .map_err(anyhow::Error::msg)? .ok_or_else(|| anyhow::anyhow!("翻译工作台不存在:{}", path.display()))?; let workbench: TranslationWorkbench = serde_json::from_slice(&bytes)?; if workbench.schema_version != TRANSLATION_WORKBENCH_VERSION { return Err(anyhow::anyhow!( "不支持的翻译工作台 schema:{},当前版本={}", workbench.schema_version, TRANSLATION_WORKBENCH_VERSION )); } Ok(workbench) } /// Writes a translation workbench atomically. pub fn write_translation_workbench( path: &Path, workbench: &TranslationWorkbench, ) -> anyhow::Result<()> { let path = lexical_absolute(path).map_err(anyhow::Error::msg)?; let parent = path .parent() .ok_or_else(|| anyhow::anyhow!("翻译工作台缺少父目录:{}", path.display()))?; ensure_safe_file_target(parent, &path, "翻译工作台").map_err(anyhow::Error::msg)?; let bytes = serde_json::to_vec_pretty(workbench)?; write_file_atomic(&path, &bytes, STATE_FILE_MODE, "翻译工作台").map_err(anyhow::Error::msg)?; Ok(()) } /// Updates one translation entry and writes the workbench atomically. pub fn set_translation( workbench_path: &Path, entry_id: &str, translated_text: String, ) -> anyhow::Result { let mut workbench = read_translation_workbench(workbench_path)?; let entry = workbench .entries .iter_mut() .find(|entry| entry.id == entry_id) .ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit:{entry_id}"))?; entry.translated_text = Some(translated_text); let updated = entry.clone(); workbench.generated_unix_seconds = unix_seconds_now(); write_translation_workbench(workbench_path, &workbench)?; Ok(updated) } /// Reads one translation entry from a workbench. pub fn get_translation_entry( workbench_path: &Path, entry_id: &str, ) -> anyhow::Result { let workbench = read_translation_workbench(workbench_path)?; workbench .entries .into_iter() .find(|entry| entry.id == entry_id) .ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit:{entry_id}")) } /// Clears one reviewed translation and writes the workbench atomically. pub fn unset_translation( workbench_path: &Path, entry_id: &str, ) -> anyhow::Result { let mut workbench = read_translation_workbench(workbench_path)?; let entry = workbench .entries .iter_mut() .find(|entry| entry.id == entry_id) .ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit:{entry_id}"))?; entry.translated_text = None; let updated = entry.clone(); workbench.generated_unix_seconds = unix_seconds_now(); write_translation_workbench(workbench_path, &workbench)?; Ok(updated) } /// Validates a workbench against the current official TextUnit index. /// /// This checks the release identity and every stored source/target location /// before a publish operation. Unsupported patch targets are reported as /// `repack_entries` so reviewers can choose the appropriate command. pub fn validate_translation_workbench( resource_root: &Path, official_release_id: &str, workbench: &TranslationWorkbench, ) -> anyhow::Result { let expected_root = lexical_absolute(resource_root).map_err(anyhow::Error::msg)?; if workbench.official_release_id != official_release_id { return Err(anyhow::anyhow!( "翻译工作台 release={} 与当前官方 release={} 不一致;请重新导出", workbench.official_release_id, official_release_id )); } if workbench.official_resource_root != expected_root { return Err(anyhow::anyhow!( "翻译工作台资源根目录与当前 release 不一致;请重新导出" )); } 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::>(); let mut seen_ids = BTreeSet::new(); let mut seen_patch_targets = BTreeSet::new(); let mut unreviewed_entries = 0; let mut unchanged_entries = 0; let mut changed_entries = 0; let mut publishable_entries = 0; let mut repack_entries = 0; for entry in &workbench.entries { if !seen_ids.insert(entry.id.as_str()) { return Err(anyhow::anyhow!("翻译工作台包含重复 TextUnit:{}", entry.id)); } let current = index_by_id .get(entry.id.as_str()) .ok_or_else(|| anyhow::anyhow!("翻译工作台条目不属于当前 release:{}", entry.id))?; validate_workbench_entry(entry, current)?; let Some(translated_text) = entry.translated_text.as_ref() else { unreviewed_entries += 1; continue; }; if translated_text == &entry.source_text { unchanged_entries += 1; 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() && matches!( source_kind.as_deref(), Some("textasset" | "typetreefield" | "managedreferencefield") ); if is_publishable { let serialized_file = entry .serialized_file .as_ref() .ok_or_else(|| anyhow::anyhow!("TextUnit {} 没有 serialized_file", entry.id))?; 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 目标:{}", entry.id )); } publishable_entries += 1; } else { repack_entries += 1; } } Ok(TranslationWorkbenchValidationReport { command: "translation-validate", status: "valid", total_entries: workbench.entries.len(), unreviewed_entries, unchanged_entries, changed_entries, publishable_entries, repack_entries, }) } /// Converts reviewed entries to localized patch operations supported by the /// current UnityFS write layer. /// /// 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> { 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::>(); 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, ) -> anyhow::Result> { 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::>(); let mut seen = BTreeSet::new(); let mut patches = Vec::new(); for entry in &workbench.entries { let Some(translated_text) = entry.translated_text.as_ref() else { continue; }; let current = index_by_id .get(entry.id.as_str()) .ok_or_else(|| anyhow::anyhow!("翻译工作台条目不属于当前 release:{}", entry.id))?; validate_workbench_entry(entry, current)?; if translated_text == &entry.source_text { continue; } let Some(serialized_file) = entry.serialized_file.clone() else { return Err(anyhow::anyhow!( "TextUnit {} 没有 serialized_file,当前不能生成重打包 patch", entry.id )); }; let Some(path_id) = entry.path_id else { return Err(anyhow::anyhow!( "TextUnit {} 没有 path_id,当前不能生成重打包 patch", entry.id )); }; if entry.archive_entry.is_some() { return Err(anyhow::anyhow!( "TextUnit {} 位于 zip archive entry,当前 publish-localized 不支持直接修改 zip 内 bundle", entry.id )); } if 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 )); } if !seen.insert((entry.destination.clone(), serialized_file.clone(), path_id)) { return Err(anyhow::anyhow!( "翻译工作台包含重复 patch 目标:{}", entry.id )); } let mut patch = TextAssetPatch::new( serialized_file, path_id, translated_text.as_bytes().to_vec(), ); patch.expected_name = entry.asset_name.clone(); patches.push(LocalizedTextAssetPatch { bundle_path: entry.destination.clone(), text_asset: patch, metadata: Some(localized_patch_metadata(entry)), }); } if patches.is_empty() { return Err(anyhow::anyhow!( "翻译工作台没有可发布的已修改 TextAsset;请先用 translation-set 调整文本" )); } 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(), translation_source_kind: entry.translation_source_kind.clone(), translation_memory_record_id: entry.translation_memory_record_id.clone(), review_status: entry .review_status .clone() .unwrap_or_else(|| "manual_reviewed".to_string()), } } fn normalized_text_source_kind(value: Option<&str>) -> Option { value.map(|value| { value .chars() .filter(|ch| !matches!(ch, '_' | '-' | ' ')) .collect::() .to_ascii_lowercase() }) } /// Batch UnityFS repack specification. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RepackSpec { /// Repack specification schema. pub schema_version: u32, /// Source bundle file. pub source_bundle: PathBuf, /// Atomically written target bundle file. pub target_bundle: PathBuf, /// Ordered operations applied to the source bytes. pub operations: Vec, } /// Current batch repack schema. pub const REPACK_SPEC_VERSION: u32 = 1; /// One ordered UnityFS repack operation. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum RepackOperation { /// Replace one TextAsset payload. TextAsset { /// Unity serialized file path. serialized_file: String, /// Unity object path ID. path_id: i64, /// Optional expected TextAsset name. #[serde(default)] expected_name: Option, /// Inline replacement UTF-8 text. #[serde(default)] replacement_text: Option, /// File containing replacement bytes. #[serde(default)] replacement_file: Option, }, /// Replace one TypeTree string field. StringField { /// Unity serialized file path. serialized_file: String, /// Unity object path ID. path_id: i64, /// TypeTree field path. field_path: String, /// Optional expected source string. #[serde(default)] expected_value: Option, /// Inline replacement UTF-8 text. #[serde(default)] replacement_text: Option, /// File containing replacement UTF-8 text. #[serde(default)] replacement_file: Option, }, /// Replace one supported semantic TypeTree field. Field { /// Unity serialized file path. serialized_file: String, /// Unity object path ID. path_id: i64, /// TypeTree field path. field_path: String, /// Replacement semantic value. replacement: UnitySerializedReplacementValue, /// Optional expected semantic source value. #[serde(default)] expected_value: Option, }, } /// Result of a batch repack. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct RepackReport { /// Stable command name. pub command: &'static str, /// Operation status. pub status: &'static str, /// Absolute source bundle path. pub source_bundle: PathBuf, /// Absolute target bundle path. pub target_bundle: PathBuf, /// Number of operations applied. pub operation_count: usize, /// Source BLAKE3. pub source_blake3: String, /// Target BLAKE3. pub target_blake3: String, /// Source size. pub source_bytes: u64, /// Target size. pub target_bytes: u64, } /// Applies an ordered repack specification and verifies each rebuild through /// the underlying UnityFS patch implementation. pub fn repack_bundle(spec_path: &Path) -> anyhow::Result { let spec_bytes = read_file_no_symlink(spec_path, "UnityFS repack spec") .map_err(anyhow::Error::msg)? .ok_or_else(|| anyhow::anyhow!("UnityFS repack spec 不存在:{}", spec_path.display()))?; let spec: RepackSpec = serde_json::from_slice(&spec_bytes)?; if spec.schema_version != REPACK_SPEC_VERSION { return Err(anyhow::anyhow!( "不支持的 UnityFS repack spec schema:{},当前版本={}", spec.schema_version, REPACK_SPEC_VERSION )); } if spec.operations.is_empty() { return Err(anyhow::anyhow!( "UnityFS repack spec 至少需要一个 operation" )); } let source_path = lexical_absolute(&spec.source_bundle).map_err(anyhow::Error::msg)?; let target_path = lexical_absolute(&spec.target_bundle).map_err(anyhow::Error::msg)?; if source_path == target_path { return Err(anyhow::anyhow!( "repack target_bundle 不能与 source_bundle 相同" )); } let source = read_file_no_symlink(&source_path, "UnityFS source bundle") .map_err(anyhow::Error::msg)? .ok_or_else(|| { anyhow::anyhow!("UnityFS source bundle 不存在:{}", source_path.display()) })?; let mut current = source.clone(); for (index, operation) in spec.operations.iter().enumerate() { current = apply_repack_operation(¤t, operation) .map_err(|error| anyhow::anyhow!("repack operation {} 失败:{error}", index + 1))?; } let parent = target_path .parent() .ok_or_else(|| anyhow::anyhow!("repack target_bundle 缺少父目录"))?; ensure_safe_file_target(parent, &target_path, "UnityFS repack target") .map_err(anyhow::Error::msg)?; write_file_atomic( &target_path, ¤t, STATE_FILE_MODE, "UnityFS repack target", ) .map_err(anyhow::Error::msg)?; Ok(RepackReport { command: "repack", status: "repacked", source_bundle: source_path, target_bundle: target_path, operation_count: spec.operations.len(), source_blake3: blake3::hash(&source).to_hex().to_string(), target_blake3: blake3::hash(¤t).to_hex().to_string(), source_bytes: source.len() as u64, target_bytes: current.len() as u64, }) } fn apply_repack_operation(input: &[u8], operation: &RepackOperation) -> anyhow::Result> { match operation { RepackOperation::TextAsset { serialized_file, path_id, expected_name, replacement_text, replacement_file, } => { let replacement = read_text_replacement(replacement_text, replacement_file)?; let mut patch = TextAssetPatch::new(serialized_file, *path_id, replacement); patch.expected_name = expected_name.clone(); Ok(patch_unityfs_text_asset(input, &patch)?) } RepackOperation::StringField { serialized_file, path_id, field_path, expected_value, replacement_text, replacement_file, } => { let replacement = String::from_utf8(read_text_replacement(replacement_text, replacement_file)?)?; Ok(patch_unityfs_string_field( input, &StringFieldPatch { serialized_file_path: serialized_file.clone(), path_id: *path_id, field_path: field_path.clone(), expected_value: expected_value.clone(), replacement, }, )?) } RepackOperation::Field { serialized_file, path_id, field_path, replacement, expected_value, } => Ok(patch_unityfs_field( input, &FieldPatch { serialized_file_path: serialized_file.clone(), path_id: *path_id, field_path: field_path.clone(), expected_value: expected_value.clone(), replacement: replacement.clone(), }, )?), } } fn read_text_replacement( replacement_text: &Option, replacement_file: &Option, ) -> anyhow::Result> { match (replacement_text, replacement_file) { (Some(_), Some(_)) => Err(anyhow::anyhow!( "replacement_text 与 replacement_file 只能指定一个" )), (Some(text), None) => Ok(text.as_bytes().to_vec()), (None, Some(path)) => read_file_no_symlink(path, "repack replacement file") .map_err(anyhow::Error::msg)? .ok_or_else(|| anyhow::anyhow!("repack replacement file 不存在:{}", path.display())), (None, None) => Err(anyhow::anyhow!( "必须指定 replacement_text 或 replacement_file" )), } } fn validate_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 = match result.source_kind { TranslationTaskResultSourceKind::TranslationMemory => None, TranslationTaskResultSourceKind::Provider | TranslationTaskResultSourceKind::Manual => 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.translation_source_kind = Some(result.source_kind.as_str().to_string()); entry.translation_memory_record_id = result.translation_memory_record_id.clone(); entry.translated_unix_seconds = Some(result.translated_unix_seconds); entry.review_status = Some( match result.source_kind { TranslationTaskResultSourceKind::Provider => "provider_completed", TranslationTaskResultSourceKind::Manual => "manual_submitted", TranslationTaskResultSourceKind::TranslationMemory => "translation_memory_reused", } .to_string(), ); entry } fn validate_workbench_entry( entry: &TranslationWorkbenchEntry, current: &OfficialTextUnitIndexUnit, ) -> anyhow::Result<()> { if entry.source_text != current.source_text || entry.destination != current.destination || entry.archive_entry != current.archive_entry || entry.serialized_file != current.serialized_file || entry.path_id != current.path_id || entry.asset_name != current.asset_name || entry.field_path != current.field_path || entry.format != current.format || entry.text_source_kind != current.text_source_kind { return Err(anyhow::anyhow!( "翻译工作台条目 {} 与当前 TextUnit 索引不一致,请重新 translation-export", entry.id )); } Ok(()) } impl TranslationWorkbenchEntry { fn from_index(unit: &OfficialTextUnitIndexUnit) -> Self { Self { id: unit.id.clone(), destination: unit.destination.clone(), archive_entry: unit.archive_entry.clone(), serialized_file: unit.serialized_file.clone(), path_id: unit.path_id, asset_name: unit.asset_name.clone(), field_path: unit.field_path.clone(), source_text: unit.source_text.clone(), translated_text: None, translation_provider: None, provider_run_id: None, translation_source_kind: None, translation_memory_record_id: None, translated_unix_seconds: None, review_status: None, format: unit.format.clone(), text_source_kind: unit.text_source_kind.clone(), } } } fn unix_seconds_now() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() .as_secs() } #[cfg(test)] mod tests { use super::*; fn workbench(path: &Path) -> TranslationWorkbench { TranslationWorkbench { schema_version: TRANSLATION_WORKBENCH_VERSION, official_release_id: "release-1".to_string(), official_resource_root: path.to_path_buf(), generated_unix_seconds: 1, entries: vec![TranslationWorkbenchEntry { id: "unit-1".to_string(), destination: "bundles/test.bundle".to_string(), archive_entry: None, serialized_file: Some("CAB-test".to_string()), path_id: Some(7), asset_name: Some("Story".to_string()), field_path: None, source_text: "原文".to_string(), translated_text: None, translation_provider: None, provider_run_id: None, translation_source_kind: None, translation_memory_record_id: None, translated_unix_seconds: None, review_status: None, format: Some("plain".to_string()), text_source_kind: Some("text_asset".to_string()), }], } } #[test] fn translation_set_round_trips_atomically() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join("workbench.json"); write_translation_workbench(&path, &workbench(temp.path())).unwrap(); let updated = set_translation(&path, "unit-1", "译文".to_string()).unwrap(); assert_eq!(updated.translated_text.as_deref(), Some("译文")); let loaded = read_translation_workbench(&path).unwrap(); assert_eq!(loaded.entries[0].translated_text.as_deref(), Some("译文")); } #[test] fn translation_set_rejects_unknown_unit() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join("workbench.json"); write_translation_workbench(&path, &workbench(temp.path())).unwrap(); let error = set_translation(&path, "missing", "译文".to_string()).unwrap_err(); assert!(error.to_string().contains("不存在 TextUnit")); } #[test] fn translation_get_and_unset_round_trip_atomically() { let temp = tempfile::TempDir::new().unwrap(); let path = temp.path().join("workbench.json"); let mut workbench = workbench(temp.path()); workbench.entries[0].translated_text = Some("译文".to_string()); write_translation_workbench(&path, &workbench).unwrap(); let entry = get_translation_entry(&path, "unit-1").unwrap(); assert_eq!(entry.translated_text.as_deref(), Some("译文")); let updated = unset_translation(&path, "unit-1").unwrap(); assert_eq!(updated.translated_text, None); let loaded = read_translation_workbench(&path).unwrap(); assert_eq!(loaded.entries[0].translated_text, None); } #[test] fn validation_reports_publishable_and_unreviewed_entries() { 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(49), field_path: None, field_offset: None, field_byte_size: None, format: Some("plain".to_string()), text_source_kind: Some("text_asset".to_string()), asset_name: Some("Story".to_string()), context: Default::default(), }], errors: Vec::new(), }; crate::official_parse::write_textunit_index_at(temp.path(), &index).unwrap(); let mut wb = workbench(temp.path()); wb.entries[0].translated_text = Some("译文".to_string()); let report = validate_translation_workbench(temp.path(), "release-1", &wb).unwrap(); assert_eq!(report.total_entries, 1); assert_eq!(report.changed_entries, 1); 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:?}"), } } }