mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 08:14:55 +08:00
@@ -1,17 +1,22 @@
|
||||
//! Manual translation workbench and controlled UnityFS repack workflows.
|
||||
|
||||
use crate::official_parse::{read_textunit_index_at, OfficialTextUnitIndexUnit};
|
||||
use crate::official_textunit_queue::OfficialTextUnitTaskQuery;
|
||||
use crate::path_security::{
|
||||
ensure_safe_file_target, lexical_absolute, read_file_no_symlink, write_file_atomic,
|
||||
STATE_FILE_MODE,
|
||||
};
|
||||
use crate::LocalizedTextAssetPatch;
|
||||
use crate::{
|
||||
LocalizedPatchInput, LocalizedPatchOperationMetadata, LocalizedStringFieldPatch,
|
||||
LocalizedTextAssetPatch, PersistedTranslationTask, SqliteTranslationTaskRepository,
|
||||
TranslationTaskStatus, TranslationTaskUnitResult,
|
||||
};
|
||||
use bat_assetbundle::{
|
||||
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch,
|
||||
StringFieldPatch, TextAssetPatch, UnitySerializedReplacementValue,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -52,12 +57,27 @@ pub struct TranslationWorkbenchEntry {
|
||||
/// Unity TextAsset name, when known.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub asset_name: Option<String>,
|
||||
/// TypeTree field path, when the source is a field-level TextUnit.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub field_path: Option<String>,
|
||||
/// Extracted source text. This is checked again before publishing.
|
||||
pub source_text: String,
|
||||
/// Human translation. `null` means not reviewed yet; an empty string is
|
||||
/// an intentional empty translation.
|
||||
#[serde(default)]
|
||||
pub translated_text: Option<String>,
|
||||
/// Provider that produced this translation, when imported from worker output.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub translation_provider: Option<String>,
|
||||
/// Provider run that produced this translation, when imported from worker output.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub provider_run_id: Option<String>,
|
||||
/// Worker completion time for provider-produced text.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub translated_unix_seconds: Option<u64>,
|
||||
/// Review state used by publish manifest metadata.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub review_status: Option<String>,
|
||||
/// TextUnit format.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub format: Option<String>,
|
||||
@@ -83,7 +103,7 @@ pub struct TranslationWorkbenchValidationReport {
|
||||
pub changed_entries: usize,
|
||||
/// Changed direct TextAsset entries usable by `i18n publish`.
|
||||
pub publishable_entries: usize,
|
||||
/// Changed TypeTree or nested-archive entries requiring `parse repack`.
|
||||
/// Changed entries outside the direct localized publish support range.
|
||||
pub repack_entries: usize,
|
||||
}
|
||||
|
||||
@@ -116,6 +136,94 @@ pub fn export_translation_workbench(
|
||||
Ok(workbench)
|
||||
}
|
||||
|
||||
/// Builds a workbench from completed provider worker results.
|
||||
pub async fn completed_worker_translation_workbench(
|
||||
resource_root: &Path,
|
||||
official_release_id: &str,
|
||||
) -> anyhow::Result<TranslationWorkbench> {
|
||||
let index = read_textunit_index_at(resource_root)
|
||||
.map_err(anyhow::Error::msg)?
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少当前官方 TextUnit 索引"))?;
|
||||
let index_by_id = index
|
||||
.units
|
||||
.iter()
|
||||
.map(|unit| (unit.id.as_str(), unit))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let repository_path = SqliteTranslationTaskRepository::repository_path(resource_root);
|
||||
let repository = SqliteTranslationTaskRepository::open(&repository_path)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("打开翻译任务状态库失败:{error}"))?;
|
||||
let tasks = repository
|
||||
.list(&OfficialTextUnitTaskQuery {
|
||||
official_release_id: Some(official_release_id.to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("读取翻译任务状态失败:{error}"))?;
|
||||
let mut result_by_unit = BTreeMap::new();
|
||||
for task in tasks
|
||||
.iter()
|
||||
.filter(|task| task.task_status == TranslationTaskStatus::Completed)
|
||||
{
|
||||
if task.task.official_release_id != official_release_id {
|
||||
return Err(anyhow::anyhow!(
|
||||
"worker 任务 {} 的官方 release={} 与当前 release={} 不一致",
|
||||
task.task.task_id,
|
||||
task.task.official_release_id,
|
||||
official_release_id
|
||||
));
|
||||
}
|
||||
for result in &task.translation_results {
|
||||
let unit = index_by_id.get(result.unit_id.as_str()).ok_or_else(|| {
|
||||
anyhow::anyhow!("worker 结果引用了未知 TextUnit:{}", result.unit_id)
|
||||
})?;
|
||||
validate_worker_result(task, unit, result)?;
|
||||
if result_by_unit
|
||||
.insert(result.unit_id.as_str(), (unit, task, result))
|
||||
.is_some()
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"worker 结果包含重复 TextUnit:{}",
|
||||
result.unit_id
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if result_by_unit.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"当前 release 没有 completed provider 翻译结果可发布"
|
||||
));
|
||||
}
|
||||
let entries = index
|
||||
.units
|
||||
.iter()
|
||||
.filter_map(|unit| {
|
||||
result_by_unit
|
||||
.get(unit.id.as_str())
|
||||
.map(|(_, task, result)| workbench_entry_from_worker_result(unit, task, result))
|
||||
})
|
||||
.collect();
|
||||
Ok(TranslationWorkbench {
|
||||
schema_version: TRANSLATION_WORKBENCH_VERSION,
|
||||
official_release_id: official_release_id.to_string(),
|
||||
official_resource_root: lexical_absolute(resource_root).map_err(anyhow::Error::msg)?,
|
||||
generated_unix_seconds: unix_seconds_now(),
|
||||
entries,
|
||||
})
|
||||
}
|
||||
|
||||
/// Exports completed provider worker results as an editable workbench.
|
||||
pub async fn export_completed_worker_translation_workbench(
|
||||
resource_root: &Path,
|
||||
official_release_id: &str,
|
||||
output_path: &Path,
|
||||
) -> anyhow::Result<TranslationWorkbench> {
|
||||
let workbench =
|
||||
completed_worker_translation_workbench(resource_root, official_release_id).await?;
|
||||
write_translation_workbench(output_path, &workbench)?;
|
||||
Ok(workbench)
|
||||
}
|
||||
|
||||
/// Reads and validates a manual translation workbench.
|
||||
pub fn read_translation_workbench(path: &Path) -> anyhow::Result<TranslationWorkbench> {
|
||||
let bytes = read_file_no_symlink(path, "翻译工作台")
|
||||
@@ -253,8 +361,12 @@ pub fn validate_translation_workbench(
|
||||
continue;
|
||||
}
|
||||
changed_entries += 1;
|
||||
let source_kind = normalized_text_source_kind(entry.text_source_kind.as_deref());
|
||||
let is_publishable = entry.archive_entry.is_none()
|
||||
&& entry.text_source_kind.as_deref() == Some("text_asset");
|
||||
&& matches!(
|
||||
source_kind.as_deref(),
|
||||
Some("textasset" | "typetreefield" | "managedreferencefield")
|
||||
);
|
||||
if is_publishable {
|
||||
let serialized_file = entry
|
||||
.serialized_file
|
||||
@@ -263,10 +375,21 @@ pub fn validate_translation_workbench(
|
||||
let path_id = entry
|
||||
.path_id
|
||||
.ok_or_else(|| anyhow::anyhow!("TextUnit {} 没有 path_id", entry.id))?;
|
||||
let field_path = if source_kind.as_deref() == Some("textasset") {
|
||||
None
|
||||
} else {
|
||||
Some(
|
||||
entry
|
||||
.field_path
|
||||
.clone()
|
||||
.ok_or_else(|| anyhow::anyhow!("TextUnit {} 没有 field_path", entry.id))?,
|
||||
)
|
||||
};
|
||||
if !seen_patch_targets.insert((
|
||||
entry.destination.clone(),
|
||||
serialized_file.clone(),
|
||||
path_id,
|
||||
field_path,
|
||||
)) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"翻译工作台包含重复 patch 目标:{}",
|
||||
@@ -290,11 +413,122 @@ pub fn validate_translation_workbench(
|
||||
})
|
||||
}
|
||||
|
||||
/// Converts reviewed direct TextAsset entries to localized patch operations.
|
||||
/// Converts reviewed entries to localized patch operations supported by the
|
||||
/// current UnityFS write layer.
|
||||
///
|
||||
/// TypeTree fields and zip-inner bundles are intentionally rejected here.
|
||||
/// They need a different patch representation and must not silently become a
|
||||
/// TextAsset replacement.
|
||||
/// ZIP-inner bundles are intentionally rejected here because they require a
|
||||
/// separate archive rewrite boundary.
|
||||
pub fn localized_patch_operations(
|
||||
resource_root: &Path,
|
||||
workbench: &TranslationWorkbench,
|
||||
) -> anyhow::Result<Vec<LocalizedPatchInput>> {
|
||||
let index = read_textunit_index_at(resource_root)
|
||||
.map_err(anyhow::Error::msg)?
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少当前官方 TextUnit 索引"))?;
|
||||
let index_by_id = index
|
||||
.units
|
||||
.iter()
|
||||
.map(|unit| (unit.id.as_str(), unit))
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut operations = Vec::new();
|
||||
|
||||
for entry in &workbench.entries {
|
||||
let Some(translated_text) = entry.translated_text.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let current = index_by_id
|
||||
.get(entry.id.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("翻译工作台条目不属于当前 release:{}", entry.id))?;
|
||||
validate_workbench_entry(entry, current)?;
|
||||
if translated_text == &entry.source_text {
|
||||
continue;
|
||||
}
|
||||
let Some(serialized_file) = entry.serialized_file.clone() else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"TextUnit {} 没有 serialized_file,当前不能生成重打包 patch",
|
||||
entry.id
|
||||
));
|
||||
};
|
||||
let Some(path_id) = entry.path_id else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"TextUnit {} 没有 path_id,当前不能生成重打包 patch",
|
||||
entry.id
|
||||
));
|
||||
};
|
||||
if entry.archive_entry.is_some() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"TextUnit {} 位于 zip archive entry,当前 publish-localized 不支持直接修改 zip 内 bundle",
|
||||
entry.id
|
||||
));
|
||||
}
|
||||
let source_kind = normalized_text_source_kind(entry.text_source_kind.as_deref());
|
||||
let field_path = entry.field_path.clone();
|
||||
if !seen.insert((
|
||||
entry.destination.clone(),
|
||||
serialized_file.clone(),
|
||||
path_id,
|
||||
field_path.clone(),
|
||||
)) {
|
||||
return Err(anyhow::anyhow!(
|
||||
"翻译工作台包含重复 patch 目标:{}",
|
||||
entry.id
|
||||
));
|
||||
}
|
||||
let metadata = Some(localized_patch_metadata(entry));
|
||||
match source_kind.as_deref() {
|
||||
Some("textasset") => {
|
||||
let mut patch = TextAssetPatch::new(
|
||||
serialized_file,
|
||||
path_id,
|
||||
translated_text.as_bytes().to_vec(),
|
||||
);
|
||||
patch.expected_name = entry.asset_name.clone();
|
||||
operations.push(LocalizedPatchInput::TextAsset(LocalizedTextAssetPatch {
|
||||
bundle_path: entry.destination.clone(),
|
||||
text_asset: patch,
|
||||
metadata,
|
||||
}));
|
||||
}
|
||||
Some("typetreefield" | "managedreferencefield") => {
|
||||
let field_path = field_path.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"TextUnit {} 没有 field_path,不能生成 TypeTree patch",
|
||||
entry.id
|
||||
)
|
||||
})?;
|
||||
operations.push(LocalizedPatchInput::StringField(
|
||||
LocalizedStringFieldPatch {
|
||||
bundle_path: entry.destination.clone(),
|
||||
string_field: StringFieldPatch {
|
||||
serialized_file_path: serialized_file,
|
||||
path_id,
|
||||
field_path,
|
||||
expected_value: Some(entry.source_text.clone()),
|
||||
replacement: translated_text.clone(),
|
||||
},
|
||||
metadata,
|
||||
},
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"TextUnit {} 的来源不在当前 localized publish 支持范围内",
|
||||
entry.id
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if operations.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"翻译工作台没有可发布的已修改 TextUnit;请先用 translation-set 调整文本或导入 worker 结果"
|
||||
));
|
||||
}
|
||||
Ok(operations)
|
||||
}
|
||||
|
||||
/// Converts reviewed direct TextAsset entries to localized patch operations.
|
||||
pub fn localized_text_asset_patches(
|
||||
resource_root: &Path,
|
||||
workbench: &TranslationWorkbench,
|
||||
@@ -339,7 +573,9 @@ pub fn localized_text_asset_patches(
|
||||
entry.id
|
||||
));
|
||||
}
|
||||
if entry.text_source_kind.as_deref() != Some("text_asset") {
|
||||
if normalized_text_source_kind(entry.text_source_kind.as_deref()).as_deref()
|
||||
!= Some("textasset")
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"TextUnit {} 的来源不是 TextAsset;请使用 repack spec 的 TypeTree 操作",
|
||||
entry.id
|
||||
@@ -360,6 +596,7 @@ pub fn localized_text_asset_patches(
|
||||
patches.push(LocalizedTextAssetPatch {
|
||||
bundle_path: entry.destination.clone(),
|
||||
text_asset: patch,
|
||||
metadata: Some(localized_patch_metadata(entry)),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -371,6 +608,31 @@ pub fn localized_text_asset_patches(
|
||||
Ok(patches)
|
||||
}
|
||||
|
||||
fn localized_patch_metadata(entry: &TranslationWorkbenchEntry) -> LocalizedPatchOperationMetadata {
|
||||
LocalizedPatchOperationMetadata {
|
||||
text_unit_id: entry.id.clone(),
|
||||
source_text_blake3: blake3::hash(entry.source_text.as_bytes())
|
||||
.to_hex()
|
||||
.to_string(),
|
||||
translation_provider: entry.translation_provider.clone(),
|
||||
provider_run_id: entry.provider_run_id.clone(),
|
||||
review_status: entry
|
||||
.review_status
|
||||
.clone()
|
||||
.unwrap_or_else(|| "manual_reviewed".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized_text_source_kind(value: Option<&str>) -> Option<String> {
|
||||
value.map(|value| {
|
||||
value
|
||||
.chars()
|
||||
.filter(|ch| !matches!(ch, '_' | '-' | ' '))
|
||||
.collect::<String>()
|
||||
.to_ascii_lowercase()
|
||||
})
|
||||
}
|
||||
|
||||
/// Batch UnityFS repack specification.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RepackSpec {
|
||||
@@ -597,6 +859,49 @@ fn read_text_replacement(
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_worker_result(
|
||||
task: &PersistedTranslationTask,
|
||||
unit: &OfficialTextUnitIndexUnit,
|
||||
result: &TranslationTaskUnitResult,
|
||||
) -> anyhow::Result<()> {
|
||||
if task.task.destination != unit.destination || task.task.archive_entry != unit.archive_entry {
|
||||
return Err(anyhow::anyhow!(
|
||||
"worker 任务 {} 与 TextUnit {} 的 destination/archive entry 不一致",
|
||||
task.task.task_id,
|
||||
unit.id
|
||||
));
|
||||
}
|
||||
if result.source_text != unit.source_text {
|
||||
return Err(anyhow::anyhow!(
|
||||
"worker 结果 {} 的 source_text 与当前 TextUnit 索引不一致",
|
||||
result.unit_id
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn workbench_entry_from_worker_result(
|
||||
unit: &OfficialTextUnitIndexUnit,
|
||||
task: &PersistedTranslationTask,
|
||||
result: &TranslationTaskUnitResult,
|
||||
) -> TranslationWorkbenchEntry {
|
||||
let mut entry = TranslationWorkbenchEntry::from_index(unit);
|
||||
entry.translated_text = Some(result.translated_text.clone());
|
||||
entry.translation_provider = task
|
||||
.provider
|
||||
.clone()
|
||||
.or_else(|| Some(result.provider.clone()))
|
||||
.filter(|provider| !provider.trim().is_empty());
|
||||
entry.provider_run_id = task
|
||||
.provider_run_id
|
||||
.clone()
|
||||
.or_else(|| Some(result.provider_run_id.clone()))
|
||||
.filter(|provider_run_id| !provider_run_id.trim().is_empty());
|
||||
entry.translated_unix_seconds = Some(result.translated_unix_seconds);
|
||||
entry.review_status = Some("provider_completed".to_string());
|
||||
entry
|
||||
}
|
||||
|
||||
fn validate_workbench_entry(
|
||||
entry: &TranslationWorkbenchEntry,
|
||||
current: &OfficialTextUnitIndexUnit,
|
||||
@@ -607,6 +912,7 @@ fn validate_workbench_entry(
|
||||
|| entry.serialized_file != current.serialized_file
|
||||
|| entry.path_id != current.path_id
|
||||
|| entry.asset_name != current.asset_name
|
||||
|| entry.field_path != current.field_path
|
||||
|| entry.format != current.format
|
||||
|| entry.text_source_kind != current.text_source_kind
|
||||
{
|
||||
@@ -627,8 +933,13 @@ impl TranslationWorkbenchEntry {
|
||||
serialized_file: unit.serialized_file.clone(),
|
||||
path_id: unit.path_id,
|
||||
asset_name: unit.asset_name.clone(),
|
||||
field_path: unit.field_path.clone(),
|
||||
source_text: unit.source_text.clone(),
|
||||
translated_text: None,
|
||||
translation_provider: None,
|
||||
provider_run_id: None,
|
||||
translated_unix_seconds: None,
|
||||
review_status: None,
|
||||
format: unit.format.clone(),
|
||||
text_source_kind: unit.text_source_kind.clone(),
|
||||
}
|
||||
@@ -659,8 +970,13 @@ mod tests {
|
||||
serialized_file: Some("CAB-test".to_string()),
|
||||
path_id: Some(7),
|
||||
asset_name: Some("Story".to_string()),
|
||||
field_path: None,
|
||||
source_text: "原文".to_string(),
|
||||
translated_text: None,
|
||||
translation_provider: None,
|
||||
provider_run_id: None,
|
||||
translated_unix_seconds: None,
|
||||
review_status: None,
|
||||
format: Some("plain".to_string()),
|
||||
text_source_kind: Some("text_asset".to_string()),
|
||||
}],
|
||||
@@ -743,4 +1059,63 @@ mod tests {
|
||||
assert_eq!(report.publishable_entries, 1);
|
||||
assert_eq!(report.unreviewed_entries, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn localized_operations_preserve_type_tree_field_traceability() {
|
||||
let temp = tempfile::TempDir::new().unwrap();
|
||||
let index = crate::official_parse::OfficialTextUnitIndex {
|
||||
version: crate::official_parse::OFFICIAL_TEXTUNIT_INDEX_VERSION,
|
||||
generated_unix_seconds: 1,
|
||||
resource_root: temp.path().to_path_buf(),
|
||||
summary: Default::default(),
|
||||
units: vec![OfficialTextUnitIndexUnit {
|
||||
id: "unit-1".to_string(),
|
||||
parse_entry_key: "bundle".to_string(),
|
||||
source_url: "https://example.invalid/bundle".to_string(),
|
||||
destination: "bundles/test.bundle".to_string(),
|
||||
archive_entry: None,
|
||||
source_kind: crate::official_parse::OfficialParseSourceKind::DirectBundle,
|
||||
unity_version: None,
|
||||
source_text: "原文".to_string(),
|
||||
serialized_file: Some("CAB-test".to_string()),
|
||||
path_id: Some(7),
|
||||
class_id: Some(114),
|
||||
field_path: Some("Scenario.Message".to_string()),
|
||||
field_offset: Some(16),
|
||||
field_byte_size: Some(8),
|
||||
format: Some("plain".to_string()),
|
||||
text_source_kind: Some("TypeTreeField".to_string()),
|
||||
asset_name: None,
|
||||
context: Default::default(),
|
||||
}],
|
||||
errors: Vec::new(),
|
||||
};
|
||||
crate::official_parse::write_textunit_index_at(temp.path(), &index).unwrap();
|
||||
let mut workbench = workbench(temp.path());
|
||||
let entry = &mut workbench.entries[0];
|
||||
entry.asset_name = None;
|
||||
entry.field_path = Some("Scenario.Message".to_string());
|
||||
entry.text_source_kind = Some("TypeTreeField".to_string());
|
||||
entry.translated_text = Some("译文".to_string());
|
||||
entry.translation_provider = Some("mock".to_string());
|
||||
entry.provider_run_id = Some("run-1".to_string());
|
||||
|
||||
let operations = localized_patch_operations(temp.path(), &workbench).unwrap();
|
||||
assert_eq!(operations.len(), 1);
|
||||
match &operations[0] {
|
||||
LocalizedPatchInput::StringField(operation) => {
|
||||
assert_eq!(operation.string_field.field_path, "Scenario.Message");
|
||||
assert_eq!(
|
||||
operation.string_field.expected_value.as_deref(),
|
||||
Some("原文")
|
||||
);
|
||||
assert_eq!(operation.string_field.replacement, "译文");
|
||||
let metadata = operation.metadata.as_ref().unwrap();
|
||||
assert_eq!(metadata.text_unit_id, "unit-1");
|
||||
assert_eq!(metadata.translation_provider.as_deref(), Some("mock"));
|
||||
assert_eq!(metadata.provider_run_id.as_deref(), Some("run-1"));
|
||||
}
|
||||
other => panic!("unexpected localized operation: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user