mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 14:14:53 +08:00
feat(glossary): 实现 Rust Glossary V1
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
//! Manual translation workbench and controlled UnityFS repack workflows.
|
||||
|
||||
use crate::glossary::SqliteGlossaryRepository;
|
||||
use crate::official_parse::{read_textunit_index_at, OfficialTextUnitIndexUnit};
|
||||
use crate::official_textunit_queue::OfficialTextUnitTaskQuery;
|
||||
use crate::path_security::{
|
||||
@@ -15,6 +16,7 @@ use bat_assetbundle::{
|
||||
patch_unityfs_field, patch_unityfs_string_field, patch_unityfs_text_asset, FieldPatch,
|
||||
StringFieldPatch, TextAssetPatch, UnitySerializedReplacementValue,
|
||||
};
|
||||
use bat_core::domain::{GlossaryOverride, GlossaryQaReport};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -90,6 +92,12 @@ pub struct TranslationWorkbenchEntry {
|
||||
/// Extraction source kind such as TextAsset or TypeTreeField.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub text_source_kind: Option<String>,
|
||||
/// Deterministic Glossary QA for the current translation.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub glossary_qa: Option<GlossaryQaReport>,
|
||||
/// Explicit human confirmation for a blocking Glossary deviation.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub glossary_override: Option<GlossaryOverride>,
|
||||
}
|
||||
|
||||
/// Summary produced by `i18n validate`.
|
||||
@@ -274,6 +282,73 @@ pub fn set_translation(
|
||||
.find(|entry| entry.id == entry_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit:{entry_id}"))?;
|
||||
entry.translated_text = Some(translated_text);
|
||||
entry.glossary_qa = None;
|
||||
entry.glossary_override = None;
|
||||
let updated = entry.clone();
|
||||
workbench.generated_unix_seconds = unix_seconds_now();
|
||||
write_translation_workbench(workbench_path, &workbench)?;
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// Updates one translation and evaluates the project Glossary.
|
||||
pub fn set_translation_checked(
|
||||
resource_root: &Path,
|
||||
workbench_path: &Path,
|
||||
entry_id: &str,
|
||||
translated_text: String,
|
||||
glossary_override: Option<GlossaryOverride>,
|
||||
) -> anyhow::Result<TranslationWorkbenchEntry> {
|
||||
set_translation_checked_with_glossary_path(
|
||||
resource_root,
|
||||
workbench_path,
|
||||
entry_id,
|
||||
translated_text,
|
||||
glossary_override,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Updates one translation using an optional configured Glossary path.
|
||||
pub fn set_translation_checked_with_glossary_path(
|
||||
resource_root: &Path,
|
||||
workbench_path: &Path,
|
||||
entry_id: &str,
|
||||
translated_text: String,
|
||||
glossary_override: Option<GlossaryOverride>,
|
||||
configured_glossary_path: Option<&Path>,
|
||||
) -> anyhow::Result<TranslationWorkbenchEntry> {
|
||||
let mut workbench = read_translation_workbench(workbench_path)?;
|
||||
let current = read_textunit_index_at(resource_root)
|
||||
.map_err(anyhow::Error::msg)?
|
||||
.ok_or_else(|| anyhow::anyhow!("缺少当前官方 TextUnit 索引"))?
|
||||
.units
|
||||
.into_iter()
|
||||
.find(|unit| unit.id == entry_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("当前 release 不存在 TextUnit:{entry_id}"))?;
|
||||
let glossary_path = configured_glossary_path
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| SqliteGlossaryRepository::repository_path(resource_root));
|
||||
let glossary = open_glossary_if_present(&glossary_path)?;
|
||||
let qa = glossary
|
||||
.as_ref()
|
||||
.map(|glossary| evaluate_glossary_entry(glossary, ¤t, &translated_text))
|
||||
.transpose()?;
|
||||
if let Some(qa) = qa.as_ref().filter(|qa| qa.status.is_blocked()) {
|
||||
validate_glossary_override(glossary_override.as_ref())?;
|
||||
let _ = qa;
|
||||
} else if glossary_override.is_some() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Glossary override 只能用于存在 blocking QA 的译文"
|
||||
));
|
||||
}
|
||||
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);
|
||||
entry.glossary_qa = qa;
|
||||
entry.glossary_override = glossary_override;
|
||||
let updated = entry.clone();
|
||||
workbench.generated_unix_seconds = unix_seconds_now();
|
||||
write_translation_workbench(workbench_path, &workbench)?;
|
||||
@@ -305,6 +380,8 @@ pub fn unset_translation(
|
||||
.find(|entry| entry.id == entry_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("翻译工作台中不存在 TextUnit:{entry_id}"))?;
|
||||
entry.translated_text = None;
|
||||
entry.glossary_qa = None;
|
||||
entry.glossary_override = None;
|
||||
let updated = entry.clone();
|
||||
workbench.generated_unix_seconds = unix_seconds_now();
|
||||
write_translation_workbench(workbench_path, &workbench)?;
|
||||
@@ -320,6 +397,21 @@ pub fn validate_translation_workbench(
|
||||
resource_root: &Path,
|
||||
official_release_id: &str,
|
||||
workbench: &TranslationWorkbench,
|
||||
) -> anyhow::Result<TranslationWorkbenchValidationReport> {
|
||||
validate_translation_workbench_with_glossary_path(
|
||||
resource_root,
|
||||
official_release_id,
|
||||
workbench,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
/// Validates a workbench using an optional configured Glossary path.
|
||||
pub fn validate_translation_workbench_with_glossary_path(
|
||||
resource_root: &Path,
|
||||
official_release_id: &str,
|
||||
workbench: &TranslationWorkbench,
|
||||
configured_glossary_path: Option<&Path>,
|
||||
) -> anyhow::Result<TranslationWorkbenchValidationReport> {
|
||||
let expected_root = lexical_absolute(resource_root).map_err(anyhow::Error::msg)?;
|
||||
if workbench.official_release_id != official_release_id {
|
||||
@@ -349,6 +441,10 @@ pub fn validate_translation_workbench(
|
||||
let mut changed_entries = 0;
|
||||
let mut publishable_entries = 0;
|
||||
let mut repack_entries = 0;
|
||||
let glossary_path = configured_glossary_path
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| SqliteGlossaryRepository::repository_path(resource_root));
|
||||
let glossary = open_glossary_if_present(&glossary_path)?;
|
||||
|
||||
for entry in &workbench.entries {
|
||||
if !seen_ids.insert(entry.id.as_str()) {
|
||||
@@ -366,6 +462,12 @@ pub fn validate_translation_workbench(
|
||||
unchanged_entries += 1;
|
||||
continue;
|
||||
}
|
||||
if let Some(glossary) = glossary.as_ref() {
|
||||
let qa = evaluate_glossary_entry(glossary, current, translated_text)?;
|
||||
if qa.status.is_blocked() {
|
||||
validate_glossary_override(entry.glossary_override.as_ref())?;
|
||||
}
|
||||
}
|
||||
changed_entries += 1;
|
||||
let source_kind = normalized_text_source_kind(entry.text_source_kind.as_deref());
|
||||
let is_publishable = entry.archive_entry.is_none()
|
||||
@@ -419,6 +521,63 @@ pub fn validate_translation_workbench(
|
||||
})
|
||||
}
|
||||
|
||||
fn open_glossary_if_present(path: &Path) -> anyhow::Result<Option<SqliteGlossaryRepository>> {
|
||||
if std::fs::symlink_metadata(path).is_err() {
|
||||
return Ok(None);
|
||||
}
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
runtime
|
||||
.block_on(SqliteGlossaryRepository::open(path))
|
||||
.map(Some)
|
||||
.map_err(|error| anyhow::anyhow!("打开 Glossary 数据库失败:{error}"))
|
||||
}
|
||||
|
||||
fn evaluate_glossary_entry(
|
||||
glossary: &SqliteGlossaryRepository,
|
||||
unit: &OfficialTextUnitIndexUnit,
|
||||
translated_text: &str,
|
||||
) -> anyhow::Result<GlossaryQaReport> {
|
||||
let context = crate::translation_memory::translation_memory_context(
|
||||
&unit.destination,
|
||||
unit.archive_entry.as_deref(),
|
||||
unit.serialized_file.as_deref(),
|
||||
unit.path_id,
|
||||
unit.class_id,
|
||||
unit.field_path.as_deref(),
|
||||
unit.format.as_deref(),
|
||||
unit.asset_name.as_deref(),
|
||||
unit.text_source_kind.as_deref(),
|
||||
&unit.context,
|
||||
);
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
runtime
|
||||
.block_on(glossary.diagnose(&unit.source_text, &context))
|
||||
.map(|evaluation| evaluation.check_translation(translated_text))
|
||||
.map_err(|error| anyhow::anyhow!("执行 Glossary QA 失败:{error}"))
|
||||
}
|
||||
|
||||
fn validate_glossary_override(glossary_override: Option<&GlossaryOverride>) -> anyhow::Result<()> {
|
||||
let Some(glossary_override) = glossary_override else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Glossary QA blocked;需要 reviewer、reason 和 provenance 显式确认"
|
||||
));
|
||||
};
|
||||
if glossary_override.reviewer.trim().is_empty()
|
||||
|| glossary_override.reason.trim().is_empty()
|
||||
|| glossary_override.provenance.trim().is_empty()
|
||||
|| glossary_override.confirmed_unix_seconds == 0
|
||||
{
|
||||
return Err(anyhow::anyhow!(
|
||||
"Glossary override 的 reviewer、reason、provenance 和 confirmed_unix_seconds 必须有效"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Converts reviewed entries to localized patch operations supported by the
|
||||
/// current UnityFS write layer.
|
||||
///
|
||||
@@ -628,6 +787,8 @@ fn localized_patch_metadata(entry: &TranslationWorkbenchEntry) -> LocalizedPatch
|
||||
.review_status
|
||||
.clone()
|
||||
.unwrap_or_else(|| "manual_reviewed".to_string()),
|
||||
glossary_qa: entry.glossary_qa.clone(),
|
||||
glossary_override: entry.glossary_override.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -911,6 +1072,8 @@ fn workbench_entry_from_worker_result(
|
||||
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.glossary_qa = result.glossary_qa.clone();
|
||||
entry.glossary_override = result.glossary_override.clone();
|
||||
entry.review_status = Some(
|
||||
match result.source_kind {
|
||||
TranslationTaskResultSourceKind::Provider => "provider_completed",
|
||||
@@ -964,6 +1127,8 @@ impl TranslationWorkbenchEntry {
|
||||
review_status: None,
|
||||
format: unit.format.clone(),
|
||||
text_source_kind: unit.text_source_kind.clone(),
|
||||
glossary_qa: None,
|
||||
glossary_override: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1003,6 +1168,8 @@ mod tests {
|
||||
review_status: None,
|
||||
format: Some("plain".to_string()),
|
||||
text_source_kind: Some("text_asset".to_string()),
|
||||
glossary_qa: None,
|
||||
glossary_override: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user