feat(bat): 完善工作流调度与 dashboard RPC
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

补全资源拉取、解析、翻译、重打包和本地化发布命令,支持单次、限定次数与周期调度。移除 TUI 计划并通过 schedule.* RPC 暴露给 bat-api dashboard。

Closes #43
This commit is contained in:
2026-08-03 22:18:52 +08:00
parent 3b103be8a9
commit 0784d5b532
27 changed files with 2931 additions and 58 deletions
+544
View File
@@ -0,0 +1,544 @@
//! Manual translation workbench and controlled UnityFS repack workflows.
use crate::official_parse::{read_textunit_index_at, OfficialTextUnitIndexUnit};
use crate::path_security::{
ensure_safe_file_target, lexical_absolute, read_file_no_symlink, write_file_atomic,
STATE_FILE_MODE,
};
use crate::LocalizedTextAssetPatch;
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;
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>,
/// 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>,
/// 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>,
}
/// 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)
}
/// 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)
}
/// Converts reviewed direct TextAsset entries to localized patch operations.
///
/// TypeTree fields and zip-inner bundles are intentionally rejected here.
/// They need a different patch representation and must not silently become a
/// TextAsset replacement.
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 entry.text_source_kind.as_deref() != Some("text_asset") {
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,
});
}
if patches.is_empty() {
return Err(anyhow::anyhow!(
"翻译工作台没有可发布的已修改 TextAsset;请先用 translation-set 调整文本"
));
}
Ok(patches)
}
/// 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_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.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(),
source_text: unit.source_text.clone(),
translated_text: 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()),
source_text: "原文".to_string(),
translated_text: 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"));
}
}