use std::collections::{BTreeMap, BTreeSet};
use serde::Serialize;
use sora_diagnostics::{Result, SoraError};
use sora_ir::model::{ConfigIr, FieldIr, TypeIr, UnionIr};
use crate::model::{ConfigData, LocalizationData, LocalizationSourceData, Value};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct LocaleCatalog {
pub locales: Vec<String>,
pub default_locale: String,
pub fallback_locale: Option<String>,
pub entries: BTreeMap<String, BTreeMap<String, String>>,
}
impl LocaleCatalog {
pub fn for_locale(&self, locale: &str) -> Result<BTreeMap<String, String>> {
if !self.locales.iter().any(|candidate| candidate == locale) {
return Err(SoraError::InvalidSchema(format!(
"unknown localization locale `{locale}`"
)));
}
Ok(self
.entries
.iter()
.filter_map(|(key, values)| {
values.get(locale).map(|value| (key.clone(), value.clone()))
})
.collect())
}
}
pub fn build_locale_catalog(
ir: &ConfigIr,
config_data: &ConfigData,
localization_data: &LocalizationData,
) -> Result<Option<LocaleCatalog>> {
let Some(localization) = &ir.localization else {
return Ok(None);
};
let mut entries = BTreeMap::<String, BTreeMap<String, String>>::new();
for source in &localization.sources {
let source_data = localization_data
.sources
.iter()
.find(|source_data| source_data.name == source.name)
.ok_or_else(|| {
SoraError::InvalidSchema(format!(
"missing localization source data `{}`",
source.name
))
})?;
validate_source_columns(source_data, &source.key, &localization.locales)?;
for row in &source_data.rows {
let key = row.values.get(&source.key).ok_or_else(|| {
SoraError::InvalidSchema(format!(
"localization source `{}` has a row without key field `{}`",
source.name, source.key
))
})?;
if entries.contains_key(key) {
return Err(SoraError::InvalidSchema(format!(
"duplicate localization key `{key}` in source `{}`",
source.name
)));
}
let mut values = BTreeMap::new();
for locale in &localization.locales {
let value = row.values.get(locale).ok_or_else(|| {
SoraError::InvalidSchema(format!(
"localization key `{key}` in source `{}` is missing locale `{locale}`",
source.name
))
})?;
if value.is_empty() {
return Err(SoraError::InvalidSchema(format!(
"localization key `{key}` in source `{}` has empty `{locale}` text",
source.name
)));
}
values.insert(locale.clone(), value.clone());
}
entries.insert(key.clone(), values);
}
}
validate_text_references(ir, config_data, &entries)?;
Ok(Some(LocaleCatalog {
locales: localization.locales.clone(),
default_locale: localization.default_locale.clone(),
fallback_locale: localization.fallback_locale.clone(),
entries,
}))
}
fn validate_source_columns(
source: &LocalizationSourceData,
key_field: &str,
locales: &[String],
) -> Result<()> {
if !source.columns.iter().any(|column| column == key_field) {
return Err(SoraError::InvalidSchema(format!(
"localization source `{}` is missing key column `{key_field}`",
source.name
)));
}
for locale in locales {
if !source.columns.iter().any(|column| column == locale) {
return Err(SoraError::InvalidSchema(format!(
"localization source `{}` is missing locale column `{locale}`",
source.name
)));
}
}
Ok(())
}
fn validate_text_references(
ir: &ConfigIr,
data: &ConfigData,
entries: &BTreeMap<String, BTreeMap<String, String>>,
) -> Result<()> {
let mut missing = BTreeSet::new();
for table in &ir.tables {
let Some(table_data) = data
.tables
.iter()
.find(|candidate| candidate.name == table.name)
else {
continue;
};
for row in &table_data.rows {
for field in &table.fields {
if let Some(value) = row.values.get(&field.name) {
collect_missing_text_keys(ir, &field.ty, value, &mut missing, entries);
}
}
}
}
if let Some(key) = missing.into_iter().next() {
return Err(SoraError::InvalidSchema(format!(
"text key `{key}` is not present in localization catalog"
)));
}
Ok(())
}
fn collect_missing_text_keys(
ir: &ConfigIr,
ty: &TypeIr,
value: &Value,
missing: &mut BTreeSet<String>,
entries: &BTreeMap<String, BTreeMap<String, String>>,
) {
match ty {
TypeIr::Text => {
if let Value::String(key) = value
&& !entries.contains_key(key)
{
missing.insert(key.clone());
}
}
TypeIr::Optional(inner) => {
if !matches!(value, Value::Null) {
collect_missing_text_keys(ir, inner, value, missing, entries);
}
}
TypeIr::Struct(name) => {
let Some(struct_ir) = ir.structs.iter().find(|item| item.name == *name) else {
return;
};
let Value::Object(values) = value else {
return;
};
collect_object_text_keys(ir, &struct_ir.fields, values, missing, entries);
}
TypeIr::Union(name) => collect_union_text_keys(ir, name, value, missing, entries),
TypeIr::List(element) | TypeIr::Set(element) | TypeIr::Array { element, .. } => {
if let Value::List(values) = value {
for value in values {
collect_missing_text_keys(ir, element, value, missing, entries);
}
}
}
TypeIr::Map {
key,
value: element,
} => {
if let Value::List(values) = value {
for entry in values {
let Value::List(pair) = entry else {
continue;
};
if pair.len() == 2 {
collect_missing_text_keys(ir, key, &pair[0], missing, entries);
collect_missing_text_keys(ir, element, &pair[1], missing, entries);
}
}
}
}
TypeIr::Ref { table, field } => {
if let Some(target) = ir
.tables
.iter()
.find(|candidate| candidate.name == *table)
.and_then(|table| {
table
.fields
.iter()
.find(|candidate| candidate.name == *field)
})
{
collect_missing_text_keys(ir, &target.ty, value, missing, entries);
}
}
TypeIr::Bool
| TypeIr::I8
| TypeIr::U8
| TypeIr::I16
| TypeIr::U16
| TypeIr::I32
| TypeIr::U32
| TypeIr::I64
| TypeIr::Duration
| TypeIr::F32
| TypeIr::F64
| TypeIr::String
| TypeIr::Enum(_) => {}
}
}
fn collect_object_text_keys(
ir: &ConfigIr,
fields: &[FieldIr],
values: &BTreeMap<String, Value>,
missing: &mut BTreeSet<String>,
entries: &BTreeMap<String, BTreeMap<String, String>>,
) {
for field in fields {
if let Some(value) = values.get(&field.name) {
collect_missing_text_keys(ir, &field.ty, value, missing, entries);
}
}
}
fn collect_union_text_keys(
ir: &ConfigIr,
name: &str,
value: &Value,
missing: &mut BTreeSet<String>,
entries: &BTreeMap<String, BTreeMap<String, String>>,
) {
let Some(union_ir): Option<&UnionIr> = ir.unions.iter().find(|item| item.name == name) else {
return;
};
let Value::Object(values) = value else {
return;
};
let Some(Value::String(variant_name)) = values.get(&union_ir.tag) else {
return;
};
let Some(variant) = union_ir
.variants
.iter()
.find(|item| item.name == *variant_name)
else {
return;
};
collect_object_text_keys(ir, &variant.fields, values, missing, entries);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{ConfigData, LocalizationRowData, RowData, TableData};
use sora_ir::{normalize::normalize_schema, validate::validate_config_ir};
use sora_schema::model::SchemaFile;
#[test]
fn builds_catalog_from_multiple_sources() {
let ir = example_ir();
let data = ConfigData {
tables: vec![TableData {
name: "Quest".to_owned(),
rows: vec![RowData {
values: BTreeMap::from([
("id".to_owned(), Value::Integer(1)),
("title".to_owned(), Value::String("quest.title".to_owned())),
]),
}],
}],
};
let localization_data = LocalizationData {
sources: vec![
locale_source("UiText", "ui.ok", "确认", "OK"),
locale_source("QuestText", "quest.title", "任务", "Quest"),
],
};
let catalog = build_locale_catalog(&ir, &data, &localization_data)
.unwrap()
.unwrap();
assert_eq!(catalog.entries.len(), 2);
assert_eq!(catalog.for_locale("zh_cn").unwrap()["quest.title"], "任务");
}
#[test]
fn rejects_missing_text_key() {
let ir = example_ir();
let data = ConfigData {
tables: vec![TableData {
name: "Quest".to_owned(),
rows: vec![RowData {
values: BTreeMap::from([
("id".to_owned(), Value::Integer(1)),
("title".to_owned(), Value::String("missing".to_owned())),
]),
}],
}],
};
let localization_data = LocalizationData {
sources: vec![
locale_source("UiText", "ui.ok", "确认", "OK"),
locale_source("QuestText", "quest.title", "任务", "Quest"),
],
};
assert!(build_locale_catalog(&ir, &data, &localization_data).is_err());
}
#[test]
fn rejects_empty_translation() {
let ir = example_ir();
let data = ConfigData {
tables: vec![TableData {
name: "Quest".to_owned(),
rows: vec![RowData {
values: BTreeMap::from([
("id".to_owned(), Value::Integer(1)),
("title".to_owned(), Value::String("quest.title".to_owned())),
]),
}],
}],
};
let localization_data = LocalizationData {
sources: vec![locale_source("QuestText", "quest.title", "", "Quest")],
};
assert!(build_locale_catalog(&ir, &data, &localization_data).is_err());
}
fn locale_source(name: &str, key: &str, zh_cn: &str, en_us: &str) -> LocalizationSourceData {
LocalizationSourceData {
name: name.to_owned(),
columns: vec!["key".to_owned(), "zh_cn".to_owned(), "en_us".to_owned()],
rows: vec![LocalizationRowData {
values: BTreeMap::from([
("key".to_owned(), key.to_owned()),
("zh_cn".to_owned(), zh_cn.to_owned()),
("en_us".to_owned(), en_us.to_owned()),
]),
}],
}
}
fn example_ir() -> ConfigIr {
let schema: SchemaFile = toml::from_str(
r#"
package = "game_config"
[localization]
locales = ["zh_cn", "en_us"]
default_locale = "zh_cn"
fallback_locale = "en_us"
[[localization.sources]]
name = "UiText"
file = "Core.xlsx"
[[localization.sources]]
name = "QuestText"
file = "Quest.xlsx"
[[tables]]
name = "Quest"
mode = "map"
key = "id"
[[tables.fields]]
name = "id"
type = "i32"
[[tables.fields]]
name = "title"
type = "text"
"#,
)
.unwrap();
let ir = normalize_schema(schema).unwrap();
validate_config_ir(&ir).unwrap();
ir
}
}