mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
1146 lines
44 KiB
Rust
1146 lines
44 KiB
Rust
//! 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(¤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<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:?}"),
|
||
}
|
||
}
|
||
}
|