Skip to main content

sora_data/
localization.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::Serialize;
4use sora_diagnostics::{Result, SoraError};
5use sora_ir::model::{ConfigIr, FieldIr, TypeIr, UnionIr};
6
7use crate::model::{ConfigData, LocalizationData, LocalizationSourceData, Value};
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
10pub struct LocaleCatalog {
11    pub locales: Vec<String>,
12    pub default_locale: String,
13    pub fallback_locale: Option<String>,
14    pub entries: BTreeMap<String, BTreeMap<String, String>>,
15}
16
17impl LocaleCatalog {
18    pub fn for_locale(&self, locale: &str) -> Result<BTreeMap<String, String>> {
19        if !self.locales.iter().any(|candidate| candidate == locale) {
20            return Err(SoraError::InvalidSchema(format!(
21                "unknown localization locale `{locale}`"
22            )));
23        }
24        Ok(self
25            .entries
26            .iter()
27            .filter_map(|(key, values)| {
28                values.get(locale).map(|value| (key.clone(), value.clone()))
29            })
30            .collect())
31    }
32}
33
34pub fn build_locale_catalog(
35    ir: &ConfigIr,
36    config_data: &ConfigData,
37    localization_data: &LocalizationData,
38) -> Result<Option<LocaleCatalog>> {
39    let Some(localization) = &ir.localization else {
40        return Ok(None);
41    };
42
43    let mut entries = BTreeMap::<String, BTreeMap<String, String>>::new();
44    for source in &localization.sources {
45        let source_data = localization_data
46            .sources
47            .iter()
48            .find(|source_data| source_data.name == source.name)
49            .ok_or_else(|| {
50                SoraError::InvalidSchema(format!(
51                    "missing localization source data `{}`",
52                    source.name
53                ))
54            })?;
55
56        validate_source_columns(source_data, &source.key, &localization.locales)?;
57
58        for row in &source_data.rows {
59            let key = row.values.get(&source.key).ok_or_else(|| {
60                SoraError::InvalidSchema(format!(
61                    "localization source `{}` has a row without key field `{}`",
62                    source.name, source.key
63                ))
64            })?;
65            if entries.contains_key(key) {
66                return Err(SoraError::InvalidSchema(format!(
67                    "duplicate localization key `{key}` in source `{}`",
68                    source.name
69                )));
70            }
71            let mut values = BTreeMap::new();
72            for locale in &localization.locales {
73                let value = row.values.get(locale).ok_or_else(|| {
74                    SoraError::InvalidSchema(format!(
75                        "localization key `{key}` in source `{}` is missing locale `{locale}`",
76                        source.name
77                    ))
78                })?;
79                if value.is_empty() {
80                    return Err(SoraError::InvalidSchema(format!(
81                        "localization key `{key}` in source `{}` has empty `{locale}` text",
82                        source.name
83                    )));
84                }
85                values.insert(locale.clone(), value.clone());
86            }
87            entries.insert(key.clone(), values);
88        }
89    }
90
91    validate_text_references(ir, config_data, &entries)?;
92
93    Ok(Some(LocaleCatalog {
94        locales: localization.locales.clone(),
95        default_locale: localization.default_locale.clone(),
96        fallback_locale: localization.fallback_locale.clone(),
97        entries,
98    }))
99}
100
101fn validate_source_columns(
102    source: &LocalizationSourceData,
103    key_field: &str,
104    locales: &[String],
105) -> Result<()> {
106    if !source.columns.iter().any(|column| column == key_field) {
107        return Err(SoraError::InvalidSchema(format!(
108            "localization source `{}` is missing key column `{key_field}`",
109            source.name
110        )));
111    }
112    for locale in locales {
113        if !source.columns.iter().any(|column| column == locale) {
114            return Err(SoraError::InvalidSchema(format!(
115                "localization source `{}` is missing locale column `{locale}`",
116                source.name
117            )));
118        }
119    }
120    Ok(())
121}
122
123fn validate_text_references(
124    ir: &ConfigIr,
125    data: &ConfigData,
126    entries: &BTreeMap<String, BTreeMap<String, String>>,
127) -> Result<()> {
128    let mut missing = BTreeSet::new();
129    for table in &ir.tables {
130        let Some(table_data) = data
131            .tables
132            .iter()
133            .find(|candidate| candidate.name == table.name)
134        else {
135            continue;
136        };
137        for row in &table_data.rows {
138            for field in &table.fields {
139                if let Some(value) = row.values.get(&field.name) {
140                    collect_missing_text_keys(ir, &field.ty, value, &mut missing, entries);
141                }
142            }
143        }
144    }
145    if let Some(key) = missing.into_iter().next() {
146        return Err(SoraError::InvalidSchema(format!(
147            "text key `{key}` is not present in localization catalog"
148        )));
149    }
150    Ok(())
151}
152
153fn collect_missing_text_keys(
154    ir: &ConfigIr,
155    ty: &TypeIr,
156    value: &Value,
157    missing: &mut BTreeSet<String>,
158    entries: &BTreeMap<String, BTreeMap<String, String>>,
159) {
160    match ty {
161        TypeIr::Text => {
162            if let Value::String(key) = value
163                && !entries.contains_key(key)
164            {
165                missing.insert(key.clone());
166            }
167        }
168        TypeIr::Optional(inner) => {
169            if !matches!(value, Value::Null) {
170                collect_missing_text_keys(ir, inner, value, missing, entries);
171            }
172        }
173        TypeIr::Struct(name) => {
174            let Some(struct_ir) = ir.structs.iter().find(|item| item.name == *name) else {
175                return;
176            };
177            let Value::Object(values) = value else {
178                return;
179            };
180            collect_object_text_keys(ir, &struct_ir.fields, values, missing, entries);
181        }
182        TypeIr::Union(name) => collect_union_text_keys(ir, name, value, missing, entries),
183        TypeIr::List(element) | TypeIr::Set(element) | TypeIr::Array { element, .. } => {
184            if let Value::List(values) = value {
185                for value in values {
186                    collect_missing_text_keys(ir, element, value, missing, entries);
187                }
188            }
189        }
190        TypeIr::Map {
191            key,
192            value: element,
193        } => {
194            if let Value::List(values) = value {
195                for entry in values {
196                    let Value::List(pair) = entry else {
197                        continue;
198                    };
199                    if pair.len() == 2 {
200                        collect_missing_text_keys(ir, key, &pair[0], missing, entries);
201                        collect_missing_text_keys(ir, element, &pair[1], missing, entries);
202                    }
203                }
204            }
205        }
206        TypeIr::Ref { table, field } => {
207            if let Some(target) = ir
208                .tables
209                .iter()
210                .find(|candidate| candidate.name == *table)
211                .and_then(|table| {
212                    table
213                        .fields
214                        .iter()
215                        .find(|candidate| candidate.name == *field)
216                })
217            {
218                collect_missing_text_keys(ir, &target.ty, value, missing, entries);
219            }
220        }
221        TypeIr::Bool
222        | TypeIr::I8
223        | TypeIr::U8
224        | TypeIr::I16
225        | TypeIr::U16
226        | TypeIr::I32
227        | TypeIr::U32
228        | TypeIr::I64
229        | TypeIr::Duration
230        | TypeIr::F32
231        | TypeIr::F64
232        | TypeIr::String
233        | TypeIr::Enum(_) => {}
234    }
235}
236
237fn collect_object_text_keys(
238    ir: &ConfigIr,
239    fields: &[FieldIr],
240    values: &BTreeMap<String, Value>,
241    missing: &mut BTreeSet<String>,
242    entries: &BTreeMap<String, BTreeMap<String, String>>,
243) {
244    for field in fields {
245        if let Some(value) = values.get(&field.name) {
246            collect_missing_text_keys(ir, &field.ty, value, missing, entries);
247        }
248    }
249}
250
251fn collect_union_text_keys(
252    ir: &ConfigIr,
253    name: &str,
254    value: &Value,
255    missing: &mut BTreeSet<String>,
256    entries: &BTreeMap<String, BTreeMap<String, String>>,
257) {
258    let Some(union_ir): Option<&UnionIr> = ir.unions.iter().find(|item| item.name == name) else {
259        return;
260    };
261    let Value::Object(values) = value else {
262        return;
263    };
264    let Some(Value::String(variant_name)) = values.get(&union_ir.tag) else {
265        return;
266    };
267    let Some(variant) = union_ir
268        .variants
269        .iter()
270        .find(|item| item.name == *variant_name)
271    else {
272        return;
273    };
274    collect_object_text_keys(ir, &variant.fields, values, missing, entries);
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use crate::model::{ConfigData, LocalizationRowData, RowData, TableData};
281    use sora_ir::{normalize::normalize_schema, validate::validate_config_ir};
282    use sora_schema::model::SchemaFile;
283
284    #[test]
285    fn builds_catalog_from_multiple_sources() {
286        let ir = example_ir();
287        let data = ConfigData {
288            tables: vec![TableData {
289                name: "Quest".to_owned(),
290                rows: vec![RowData {
291                    values: BTreeMap::from([
292                        ("id".to_owned(), Value::Integer(1)),
293                        ("title".to_owned(), Value::String("quest.title".to_owned())),
294                    ]),
295                }],
296            }],
297        };
298        let localization_data = LocalizationData {
299            sources: vec![
300                locale_source("UiText", "ui.ok", "确认", "OK"),
301                locale_source("QuestText", "quest.title", "任务", "Quest"),
302            ],
303        };
304
305        let catalog = build_locale_catalog(&ir, &data, &localization_data)
306            .unwrap()
307            .unwrap();
308        assert_eq!(catalog.entries.len(), 2);
309        assert_eq!(catalog.for_locale("zh_cn").unwrap()["quest.title"], "任务");
310    }
311
312    #[test]
313    fn rejects_missing_text_key() {
314        let ir = example_ir();
315        let data = ConfigData {
316            tables: vec![TableData {
317                name: "Quest".to_owned(),
318                rows: vec![RowData {
319                    values: BTreeMap::from([
320                        ("id".to_owned(), Value::Integer(1)),
321                        ("title".to_owned(), Value::String("missing".to_owned())),
322                    ]),
323                }],
324            }],
325        };
326        let localization_data = LocalizationData {
327            sources: vec![
328                locale_source("UiText", "ui.ok", "确认", "OK"),
329                locale_source("QuestText", "quest.title", "任务", "Quest"),
330            ],
331        };
332
333        assert!(build_locale_catalog(&ir, &data, &localization_data).is_err());
334    }
335
336    #[test]
337    fn rejects_empty_translation() {
338        let ir = example_ir();
339        let data = ConfigData {
340            tables: vec![TableData {
341                name: "Quest".to_owned(),
342                rows: vec![RowData {
343                    values: BTreeMap::from([
344                        ("id".to_owned(), Value::Integer(1)),
345                        ("title".to_owned(), Value::String("quest.title".to_owned())),
346                    ]),
347                }],
348            }],
349        };
350        let localization_data = LocalizationData {
351            sources: vec![locale_source("QuestText", "quest.title", "", "Quest")],
352        };
353
354        assert!(build_locale_catalog(&ir, &data, &localization_data).is_err());
355    }
356
357    fn locale_source(name: &str, key: &str, zh_cn: &str, en_us: &str) -> LocalizationSourceData {
358        LocalizationSourceData {
359            name: name.to_owned(),
360            columns: vec!["key".to_owned(), "zh_cn".to_owned(), "en_us".to_owned()],
361            rows: vec![LocalizationRowData {
362                values: BTreeMap::from([
363                    ("key".to_owned(), key.to_owned()),
364                    ("zh_cn".to_owned(), zh_cn.to_owned()),
365                    ("en_us".to_owned(), en_us.to_owned()),
366                ]),
367            }],
368        }
369    }
370
371    fn example_ir() -> ConfigIr {
372        let schema: SchemaFile = toml::from_str(
373            r#"
374package = "game_config"
375
376[localization]
377locales = ["zh_cn", "en_us"]
378default_locale = "zh_cn"
379fallback_locale = "en_us"
380
381[[localization.sources]]
382name = "UiText"
383file = "Core.xlsx"
384
385[[localization.sources]]
386name = "QuestText"
387file = "Quest.xlsx"
388
389[[tables]]
390name = "Quest"
391mode = "map"
392key = "id"
393
394[[tables.fields]]
395name = "id"
396type = "i32"
397
398[[tables.fields]]
399name = "title"
400type = "text"
401"#,
402        )
403        .unwrap();
404        let ir = normalize_schema(schema).unwrap();
405        validate_config_ir(&ir).unwrap();
406        ir
407    }
408}