mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 10:04:55 +08:00
feat(bat): 补全工作流校验与调度过滤
新增 parse clear-cache 和 i18n validate,补齐 schedule 的作用域过滤与单轮执行上限,并将列表过滤参数暴露给 bat-api dashboard。同步 RPC、OpenAPI、用户文档和回归测试。 Refs #43
This commit is contained in:
@@ -11,7 +11,7 @@ use bat_assetbundle::{
|
||||
StringFieldPatch, TextAssetPatch, UnitySerializedReplacementValue,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -66,6 +66,27 @@ pub struct TranslationWorkbenchEntry {
|
||||
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 TypeTree or nested-archive entries requiring `parse repack`.
|
||||
pub repack_entries: usize,
|
||||
}
|
||||
|
||||
/// Exports the current official TextUnit index as an editable workbench.
|
||||
pub fn export_translation_workbench(
|
||||
resource_root: &Path,
|
||||
@@ -145,6 +166,99 @@ pub fn set_translation(
|
||||
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 is_publishable = entry.archive_entry.is_none()
|
||||
&& entry.text_source_kind.as_deref() == Some("text_asset");
|
||||
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))?;
|
||||
if !seen_patch_targets.insert((
|
||||
entry.destination.clone(),
|
||||
serialized_file.clone(),
|
||||
path_id,
|
||||
)) {
|
||||
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 direct TextAsset entries to localized patch operations.
|
||||
///
|
||||
/// TypeTree fields and zip-inner bundles are intentionally rejected here.
|
||||
@@ -541,4 +655,44 @@ mod tests {
|
||||
let error = set_translation(&path, "missing", "译文".to_string()).unwrap_err();
|
||||
assert!(error.to_string().contains("不存在 TextUnit"));
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user