Files
BlueArchiveToolkit/infrastructure/src/translation_workflow.rs
T

1146 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<TranslationWorkbenchEntry>,
}
/// One manually editable TextUnit translation entry.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TranslationWorkbenchEntry {
/// Stable TextUnit ID.
pub id: String,
/// Relative official resource destination.
pub destination: String,
/// Archive entry, when the source is nested in a zip.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub archive_entry: Option<String>,
/// Unity serialized file path.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub serialized_file: Option<String>,
/// Unity object path ID.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path_id: Option<i64>,
/// Unity TextAsset name, when known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_name: Option<String>,
/// 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>,
/// Source of the worker result (`provider`, `manual`, or `translation_memory`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_source_kind: Option<String>,
/// Trusted Translation Memory record used for this translation, when applicable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub translation_memory_record_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>,
/// Extraction source kind such as TextAsset or TypeTreeField.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text_source_kind: Option<String>,
}
/// 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<String>,
output_path: &Path,
) -> anyhow::Result<TranslationWorkbench> {
let index = read_textunit_index_at(resource_root)
.map_err(anyhow::Error::msg)?
.ok_or_else(|| {
anyhow::anyhow!(
"缺少官方 TextUnit 索引,无法导出翻译工作台:{}",
resource_root.display()
)
})?;
let workbench = TranslationWorkbench {
schema_version: TRANSLATION_WORKBENCH_VERSION,
official_release_id: official_release_id.into(),
official_resource_root: lexical_absolute(resource_root).map_err(anyhow::Error::msg)?,
generated_unix_seconds: unix_seconds_now(),
entries: index
.units
.iter()
.map(TranslationWorkbenchEntry::from_index)
.collect(),
};
write_translation_workbench(output_path, &workbench)?;
Ok(workbench)
}
/// 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, "翻译工作台")
.map_err(anyhow::Error::msg)?
.ok_or_else(|| anyhow::anyhow!("翻译工作台不存在:{}", path.display()))?;
let workbench: TranslationWorkbench = serde_json::from_slice(&bytes)?;
if workbench.schema_version != TRANSLATION_WORKBENCH_VERSION {
return Err(anyhow::anyhow!(
"不支持的翻译工作台 schema{},当前版本={}",
workbench.schema_version,
TRANSLATION_WORKBENCH_VERSION
));
}
Ok(workbench)
}
/// Writes a translation workbench atomically.
pub fn write_translation_workbench(
path: &Path,
workbench: &TranslationWorkbench,
) -> anyhow::Result<()> {
let path = lexical_absolute(path).map_err(anyhow::Error::msg)?;
let parent = path
.parent()
.ok_or_else(|| anyhow::anyhow!("翻译工作台缺少父目录:{}", path.display()))?;
ensure_safe_file_target(parent, &path, "翻译工作台").map_err(anyhow::Error::msg)?;
let bytes = serde_json::to_vec_pretty(workbench)?;
write_file_atomic(&path, &bytes, STATE_FILE_MODE, "翻译工作台").map_err(anyhow::Error::msg)?;
Ok(())
}
/// Updates one translation entry and writes the workbench atomically.
pub fn set_translation(
workbench_path: &Path,
entry_id: &str,
translated_text: String,
) -> anyhow::Result<TranslationWorkbenchEntry> {
let mut workbench = read_translation_workbench(workbench_path)?;
let entry = workbench
.entries
.iter_mut()
.find(|entry| entry.id == entry_id)
.ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit{entry_id}"))?;
entry.translated_text = Some(translated_text);
let updated = entry.clone();
workbench.generated_unix_seconds = unix_seconds_now();
write_translation_workbench(workbench_path, &workbench)?;
Ok(updated)
}
/// Reads one translation entry from a workbench.
pub fn get_translation_entry(
workbench_path: &Path,
entry_id: &str,
) -> anyhow::Result<TranslationWorkbenchEntry> {
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<TranslationWorkbenchEntry> {
let mut workbench = read_translation_workbench(workbench_path)?;
let entry = workbench
.entries
.iter_mut()
.find(|entry| entry.id == entry_id)
.ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit{entry_id}"))?;
entry.translated_text = 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<TranslationWorkbenchValidationReport> {
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::<HashMap<_, _>>();
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<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,
) -> anyhow::Result<Vec<LocalizedTextAssetPatch>> {
let index = read_textunit_index_at(resource_root)
.map_err(anyhow::Error::msg)?
.ok_or_else(|| anyhow::anyhow!("缺少当前官方 TextUnit 索引"))?;
let index_by_id = index
.units
.iter()
.map(|unit| (unit.id.as_str(), unit))
.collect::<std::collections::HashMap<_, _>>();
let mut seen = BTreeSet::new();
let mut patches = Vec::new();
for entry in &workbench.entries {
let Some(translated_text) = entry.translated_text.as_ref() else {
continue;
};
let current = index_by_id
.get(entry.id.as_str())
.ok_or_else(|| anyhow::anyhow!("翻译工作台条目不属于当前 release{}", entry.id))?;
validate_workbench_entry(entry, current)?;
if translated_text == &entry.source_text {
continue;
}
let Some(serialized_file) = entry.serialized_file.clone() else {
return Err(anyhow::anyhow!(
"TextUnit {} 没有 serialized_file,当前不能生成重打包 patch",
entry.id
));
};
let Some(path_id) = entry.path_id else {
return Err(anyhow::anyhow!(
"TextUnit {} 没有 path_id,当前不能生成重打包 patch",
entry.id
));
};
if entry.archive_entry.is_some() {
return Err(anyhow::anyhow!(
"TextUnit {} 位于 zip archive entry,当前 publish-localized 不支持直接修改 zip 内 bundle",
entry.id
));
}
if 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<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 {
/// Repack specification schema.
pub schema_version: u32,
/// Source bundle file.
pub source_bundle: PathBuf,
/// Atomically written target bundle file.
pub target_bundle: PathBuf,
/// Ordered operations applied to the source bytes.
pub operations: Vec<RepackOperation>,
}
/// Current batch repack schema.
pub const REPACK_SPEC_VERSION: u32 = 1;
/// One ordered UnityFS repack operation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RepackOperation {
/// Replace one TextAsset payload.
TextAsset {
/// Unity serialized file path.
serialized_file: String,
/// Unity object path ID.
path_id: i64,
/// Optional expected TextAsset name.
#[serde(default)]
expected_name: Option<String>,
/// Inline replacement UTF-8 text.
#[serde(default)]
replacement_text: Option<String>,
/// File containing replacement bytes.
#[serde(default)]
replacement_file: Option<PathBuf>,
},
/// Replace one TypeTree string field.
StringField {
/// Unity serialized file path.
serialized_file: String,
/// Unity object path ID.
path_id: i64,
/// TypeTree field path.
field_path: String,
/// Optional expected source string.
#[serde(default)]
expected_value: Option<String>,
/// Inline replacement UTF-8 text.
#[serde(default)]
replacement_text: Option<String>,
/// File containing replacement UTF-8 text.
#[serde(default)]
replacement_file: Option<PathBuf>,
},
/// Replace one supported semantic TypeTree field.
Field {
/// Unity serialized file path.
serialized_file: String,
/// Unity object path ID.
path_id: i64,
/// TypeTree field path.
field_path: String,
/// Replacement semantic value.
replacement: UnitySerializedReplacementValue,
/// Optional expected semantic source value.
#[serde(default)]
expected_value: Option<UnitySerializedReplacementValue>,
},
}
/// Result of a batch repack.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct RepackReport {
/// Stable command name.
pub command: &'static str,
/// Operation status.
pub status: &'static str,
/// Absolute source bundle path.
pub source_bundle: PathBuf,
/// Absolute target bundle path.
pub target_bundle: PathBuf,
/// Number of operations applied.
pub operation_count: usize,
/// Source BLAKE3.
pub source_blake3: String,
/// Target BLAKE3.
pub target_blake3: String,
/// Source size.
pub source_bytes: u64,
/// Target size.
pub target_bytes: u64,
}
/// Applies an ordered repack specification and verifies each rebuild through
/// the underlying UnityFS patch implementation.
pub fn repack_bundle(spec_path: &Path) -> anyhow::Result<RepackReport> {
let spec_bytes = read_file_no_symlink(spec_path, "UnityFS repack spec")
.map_err(anyhow::Error::msg)?
.ok_or_else(|| anyhow::anyhow!("UnityFS repack spec 不存在:{}", spec_path.display()))?;
let spec: RepackSpec = serde_json::from_slice(&spec_bytes)?;
if spec.schema_version != REPACK_SPEC_VERSION {
return Err(anyhow::anyhow!(
"不支持的 UnityFS repack spec schema{},当前版本={}",
spec.schema_version,
REPACK_SPEC_VERSION
));
}
if spec.operations.is_empty() {
return Err(anyhow::anyhow!(
"UnityFS repack spec 至少需要一个 operation"
));
}
let source_path = lexical_absolute(&spec.source_bundle).map_err(anyhow::Error::msg)?;
let target_path = lexical_absolute(&spec.target_bundle).map_err(anyhow::Error::msg)?;
if source_path == target_path {
return Err(anyhow::anyhow!(
"repack target_bundle 不能与 source_bundle 相同"
));
}
let source = read_file_no_symlink(&source_path, "UnityFS source bundle")
.map_err(anyhow::Error::msg)?
.ok_or_else(|| {
anyhow::anyhow!("UnityFS source bundle 不存在:{}", source_path.display())
})?;
let mut current = source.clone();
for (index, operation) in spec.operations.iter().enumerate() {
current = apply_repack_operation(&current, operation)
.map_err(|error| anyhow::anyhow!("repack operation {} 失败:{error}", index + 1))?;
}
let parent = target_path
.parent()
.ok_or_else(|| anyhow::anyhow!("repack target_bundle 缺少父目录"))?;
ensure_safe_file_target(parent, &target_path, "UnityFS repack target")
.map_err(anyhow::Error::msg)?;
write_file_atomic(
&target_path,
&current,
STATE_FILE_MODE,
"UnityFS repack target",
)
.map_err(anyhow::Error::msg)?;
Ok(RepackReport {
command: "repack",
status: "repacked",
source_bundle: source_path,
target_bundle: target_path,
operation_count: spec.operations.len(),
source_blake3: blake3::hash(&source).to_hex().to_string(),
target_blake3: blake3::hash(&current).to_hex().to_string(),
source_bytes: source.len() as u64,
target_bytes: current.len() as u64,
})
}
fn apply_repack_operation(input: &[u8], operation: &RepackOperation) -> anyhow::Result<Vec<u8>> {
match operation {
RepackOperation::TextAsset {
serialized_file,
path_id,
expected_name,
replacement_text,
replacement_file,
} => {
let replacement = read_text_replacement(replacement_text, replacement_file)?;
let mut patch = TextAssetPatch::new(serialized_file, *path_id, replacement);
patch.expected_name = expected_name.clone();
Ok(patch_unityfs_text_asset(input, &patch)?)
}
RepackOperation::StringField {
serialized_file,
path_id,
field_path,
expected_value,
replacement_text,
replacement_file,
} => {
let replacement =
String::from_utf8(read_text_replacement(replacement_text, replacement_file)?)?;
Ok(patch_unityfs_string_field(
input,
&StringFieldPatch {
serialized_file_path: serialized_file.clone(),
path_id: *path_id,
field_path: field_path.clone(),
expected_value: expected_value.clone(),
replacement,
},
)?)
}
RepackOperation::Field {
serialized_file,
path_id,
field_path,
replacement,
expected_value,
} => Ok(patch_unityfs_field(
input,
&FieldPatch {
serialized_file_path: serialized_file.clone(),
path_id: *path_id,
field_path: field_path.clone(),
expected_value: expected_value.clone(),
replacement: replacement.clone(),
},
)?),
}
}
fn read_text_replacement(
replacement_text: &Option<String>,
replacement_file: &Option<PathBuf>,
) -> anyhow::Result<Vec<u8>> {
match (replacement_text, replacement_file) {
(Some(_), Some(_)) => Err(anyhow::anyhow!(
"replacement_text 与 replacement_file 只能指定一个"
)),
(Some(text), None) => Ok(text.as_bytes().to_vec()),
(None, Some(path)) => read_file_no_symlink(path, "repack replacement file")
.map_err(anyhow::Error::msg)?
.ok_or_else(|| anyhow::anyhow!("repack replacement file 不存在:{}", path.display())),
(None, None) => Err(anyhow::anyhow!(
"必须指定 replacement_text 或 replacement_file"
)),
}
}
fn validate_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:?}"),
}
}
}