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}