1use std::sync::LazyLock;
8
9use indexmap::IndexMap;
10use serde_json::Value;
11
12use crate::generated::catalog as g;
13
14pub type StringTable = IndexMap<String, String>;
16
17#[derive(serde::Deserialize)]
18struct StringsFile {
19 strings: IndexMap<String, String>,
20}
21
22pub static DEFAULT_STRINGS: LazyLock<StringTable> =
24 LazyLock::new(|| serde_json::from_str::<StringsFile>(g::STRINGS_JSON).expect("strings.json").strings);
25
26pub fn parse_string_ref(value: &str) -> Option<&str> {
29 let id = value.strip_prefix("$string.")?;
30 let mut chars = id.chars();
31 let first = chars.next()?;
32 (first.is_ascii_alphabetic() && chars.all(|c| c.is_ascii_alphanumeric())).then_some(id)
33}
34
35pub fn is_string_ref(value: &str) -> bool {
37 parse_string_ref(value).is_some_and(|id| DEFAULT_STRINGS.contains_key(id))
38}
39
40pub fn string_table(overrides: &IndexMap<String, String>) -> StringTable {
42 let mut out = DEFAULT_STRINGS.clone();
43 for (k, v) in overrides {
44 out.insert(k.clone(), v.clone());
45 }
46 out
47}
48
49pub fn format_string(template: &str, params: &serde_json::Map<String, Value>) -> String {
51 let mut out = String::with_capacity(template.len());
52 let mut rest = template;
53 while let Some(open) = rest.find('{') {
54 out.push_str(&rest[..open]);
55 let after = &rest[open + 1..];
56 let name_len = after.char_indices().find(|(_, c)| !c.is_ascii_alphanumeric()).map(|(i, _)| i).unwrap_or(after.len());
57 let name = &after[..name_len];
58 let well_formed = name.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) && after[name_len..].starts_with('}');
59 if well_formed {
60 match params.get(name).filter(|v| !v.is_null()) {
61 Some(v) => out.push_str(&crate::json::to_js_string(v)),
62 None => out.push_str(&rest[open..open + 1 + name_len + 1]),
63 }
64 rest = &after[name_len + 1..];
65 } else {
66 out.push('{');
67 rest = after;
68 }
69 }
70 out.push_str(rest);
71 out
72}
73
74pub fn resolve_string<'a>(value: &'a str, table: &'a StringTable) -> &'a str {
77 match parse_string_ref(value) {
78 Some(id) => table.get(id).map(String::as_str).unwrap_or(id),
79 None => value,
80 }
81}
82
83pub fn default_string(id: &str) -> Option<&'static str> {
85 DEFAULT_STRINGS.get(id).map(String::as_str)
86}
87
88#[cfg(test)]
89mod tests {
90 use super::*;
91 use serde_json::json;
92
93 #[test]
94 fn overrides_formatting_resolution() {
95 assert_eq!(DEFAULT_STRINGS.keys().map(String::as_str).collect::<Vec<_>>(), g::STRING_IDS);
96 assert_eq!(DEFAULT_STRINGS.values().map(String::as_str).collect::<Vec<_>>(), g::STRING_DEFAULTS);
97 let mut over = IndexMap::new();
98 over.insert("search".to_string(), "Suchen…".to_string());
99 assert_eq!(string_table(&over)["search"], "Suchen…");
100 let params = json!({"page": 2, "total": 5}).as_object().unwrap().clone();
101 assert_eq!(format_string(&DEFAULT_STRINGS["pageOf"], ¶ms), "Page 2 of 5");
102 assert_eq!(format_string("{count} of {total}", json!({"count": 1}).as_object().unwrap()), "1 of {total}");
103 assert_eq!(format_string("{ x } {1a}", ¶ms), "{ x } {1a}");
104 let mut de = IndexMap::new();
105 de.insert("cancel".to_string(), "Abbrechen".to_string());
106 assert_eq!(resolve_string("$string.cancel", &string_table(&de)), "Abbrechen");
107 assert_eq!(resolve_string("plain", &DEFAULT_STRINGS), "plain");
108 assert_eq!(resolve_string("$string.nope", &DEFAULT_STRINGS), "nope");
109 assert!(is_string_ref("$string.confirm") && !is_string_ref("$string.nope") && parse_string_ref("$string.1x").is_none());
110 }
111}