Skip to main content

ravenrs_app_i18n/
lib.rs

1//! The languages and the words of a RavenRS business application, in one place.
2//!
3//! Every text the framework shows has a key, as `ui.save` or `report.show`. The texts that come
4//! with it are in English and in Brazilian Portuguese; a language without a key falls back to
5//! English, and a key nobody has shows as itself. An application or a module adds or replaces
6//! texts with a flat JSON of keys, without code of its own for the screens.
7//!
8//! ```
9//! use ravenrs_app_i18n::{Catalog, Language, Texts};
10//!
11//! let texts = Texts::new(Language::BrazilianPortuguese);
12//! assert_eq!(texts.get("ui.save"), "Salvar");
13//! assert_eq!(texts.format("rules.required", &["Nome"]), "Nome é obrigatório");
14//!
15//! let mut catalog = Catalog::builtin();
16//! catalog.merge_json("pt-BR", r#"{ "ui.save": "Gravar", "shop.checkout": "Fechar pedido" }"#)?;
17//! let texts = catalog.texts(Language::BrazilianPortuguese);
18//! assert_eq!(texts.get("ui.save"), "Gravar");
19//! assert_eq!(texts.get("shop.checkout"), "Fechar pedido");
20//! # Ok::<(), ravenrs_app_i18n::TextError>(())
21//! ```
22
23use std::collections::BTreeMap;
24use std::fmt;
25use std::sync::{Arc, OnceLock};
26
27pub mod model;
28
29/// The language everything falls back to.
30pub const FALLBACK: &str = "en";
31
32/// The texts that come with the framework, by language and by part, as flat JSON.
33const BUILTIN: &[(&str, &str)] = &[
34    ("en", include_str!("../texts/en/ai.json")),
35    ("en", include_str!("../texts/en/format.json")),
36    ("en", include_str!("../texts/en/movie.json")),
37    ("en", include_str!("../texts/en/pane.json")),
38    ("en", include_str!("../texts/en/report.json")),
39    ("en", include_str!("../texts/en/rules.json")),
40    ("en", include_str!("../texts/en/sqlite.json")),
41    ("en", include_str!("../texts/en/spreadsheet.json")),
42    ("en", include_str!("../texts/en/ui.json")),
43    ("en", include_str!("../texts/en/viewer.json")),
44    ("en", include_str!("../texts/en/widgets.json")),
45    ("pt-BR", include_str!("../texts/pt-BR/ai.json")),
46    ("pt-BR", include_str!("../texts/pt-BR/format.json")),
47    ("pt-BR", include_str!("../texts/pt-BR/movie.json")),
48    ("pt-BR", include_str!("../texts/pt-BR/pane.json")),
49    ("pt-BR", include_str!("../texts/pt-BR/report.json")),
50    ("pt-BR", include_str!("../texts/pt-BR/rules.json")),
51    ("pt-BR", include_str!("../texts/pt-BR/sqlite.json")),
52    ("pt-BR", include_str!("../texts/pt-BR/spreadsheet.json")),
53    ("pt-BR", include_str!("../texts/pt-BR/ui.json")),
54    ("pt-BR", include_str!("../texts/pt-BR/viewer.json")),
55    ("pt-BR", include_str!("../texts/pt-BR/widgets.json")),
56];
57
58/// A language the screens can be in.
59#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
60#[non_exhaustive]
61pub enum Language {
62    /// English.
63    #[default]
64    English,
65    /// Brazilian Portuguese.
66    BrazilianPortuguese,
67}
68
69impl Language {
70    /// Every language the framework comes with texts for.
71    pub const ALL: &'static [Language] = &[Self::English, Self::BrazilianPortuguese];
72
73    /// What texts and translations of the model are known by, as `en` or `pt-BR`.
74    pub fn code(self) -> &'static str {
75        match self {
76            Self::English => "en",
77            Self::BrazilianPortuguese => "pt-BR",
78        }
79    }
80
81    /// The language of a code, without minding case or `_` for `-`. A bare `pt` is Brazilian
82    /// Portuguese, and English is any `en`.
83    pub fn from_code(code: &str) -> Option<Self> {
84        let code = code.trim().replace('_', "-").to_ascii_lowercase();
85        match code.as_str() {
86            "pt" | "pt-br" => Some(Self::BrazilianPortuguese),
87            "en" => Some(Self::English),
88            _ if code.starts_with("en-") => Some(Self::English),
89            _ => None,
90        }
91    }
92
93    /// Its name, in itself.
94    pub fn name(self) -> &'static str {
95        match self {
96            Self::English => "English",
97            Self::BrazilianPortuguese => "Português (Brasil)",
98        }
99    }
100}
101
102impl fmt::Display for Language {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        f.write_str(self.name())
105    }
106}
107
108/// What is wrong with texts given as JSON.
109#[derive(Clone, Debug, PartialEq, Eq)]
110pub struct TextError(pub String);
111
112impl fmt::Display for TextError {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        f.write_str(&self.0)
115    }
116}
117
118impl std::error::Error for TextError {}
119
120/// Texts by language and by key.
121#[derive(Clone, Debug, Default, PartialEq, Eq)]
122pub struct Catalog {
123    languages: BTreeMap<String, BTreeMap<String, String>>,
124}
125
126impl Catalog {
127    /// With nothing in it.
128    pub fn new() -> Self {
129        Self::default()
130    }
131
132    /// With the texts that come with the framework.
133    pub fn builtin() -> Self {
134        static BUILT: OnceLock<Catalog> = OnceLock::new();
135        BUILT
136            .get_or_init(|| {
137                let mut catalog = Catalog::new();
138                for (language, json) in BUILTIN {
139                    // The tests of this crate read each of them, so this does not fail.
140                    let read = catalog.merge_json(language, json);
141                    debug_assert!(read.is_ok(), "{read:?}");
142                }
143                catalog
144            })
145            .clone()
146    }
147
148    /// Puts this text under this key, in this language, over what was there.
149    pub fn set(&mut self, language: &str, key: impl Into<String>, text: impl Into<String>) {
150        self.languages.entry(language.to_owned()).or_default().insert(key.into(), text.into());
151    }
152
153    /// The same, as a builder.
154    pub fn with(mut self, language: &str, key: impl Into<String>, text: impl Into<String>) -> Self {
155        self.set(language, key, text);
156        self
157    }
158
159    /// Puts the texts of a flat JSON object of keys, as `{ "ui.save": "Gravar" }`, over what was
160    /// there. Nothing is put when something in it is not text.
161    pub fn merge_json(&mut self, language: &str, json: &str) -> Result<(), TextError> {
162        let wrong = |detail: String| TextError(format!("the texts of {language}: {detail}"));
163        let value: serde_json::Value = serde_json::from_str(json).map_err(|e| wrong(e.to_string()))?;
164        let serde_json::Value::Object(entries) = value else {
165            return Err(wrong("they are not an object of keys".into()));
166        };
167        let mut read = Vec::with_capacity(entries.len());
168        for (key, text) in entries {
169            match text {
170                serde_json::Value::String(text) => read.push((key, text)),
171                _ => return Err(wrong(format!("`{key}` is not text"))),
172            }
173        }
174        let texts = self.languages.entry(language.to_owned()).or_default();
175        texts.extend(read);
176        Ok(())
177    }
178
179    /// Puts every text of the other over what is here.
180    pub fn merge(&mut self, other: &Catalog) {
181        for (language, texts) in &other.languages {
182            let mine = self.languages.entry(language.clone()).or_default();
183            mine.extend(texts.iter().map(|(key, text)| (key.clone(), text.clone())));
184        }
185    }
186
187    /// The text of a key in a language, or in English when the language does not have it.
188    pub fn text(&self, language: &str, key: &str) -> Option<&str> {
189        self.own_text(language, key).or_else(|| self.own_text(FALLBACK, key))
190    }
191
192    /// The text of a key in a language, and only in it.
193    pub fn own_text(&self, language: &str, key: &str) -> Option<&str> {
194        self.languages.get(language)?.get(key).map(String::as_str)
195    }
196
197    /// The languages it has texts in.
198    pub fn languages(&self) -> impl Iterator<Item = &str> {
199        self.languages.keys().map(String::as_str)
200    }
201
202    /// The keys it has in a language.
203    pub fn keys(&self, language: &str) -> impl Iterator<Item = &str> {
204        self.languages.get(language).into_iter().flat_map(|texts| texts.keys().map(String::as_str))
205    }
206
207    /// The keys English has and this language does not, for whoever translates.
208    pub fn missing(&self, language: &str) -> Vec<String> {
209        self.keys(FALLBACK).filter(|key| self.own_text(language, key).is_none()).map(str::to_owned).collect()
210    }
211
212    /// Its texts in this language.
213    pub fn texts(&self, language: Language) -> Texts {
214        Texts::of(self.clone(), language.code())
215    }
216}
217
218/// The texts of a catalog in one language.
219#[derive(Clone, Debug, PartialEq, Eq)]
220pub struct Texts {
221    catalog: Arc<Catalog>,
222    language: String,
223}
224
225impl Texts {
226    /// The texts that come with the framework, in this language.
227    pub fn new(language: Language) -> Self {
228        Catalog::builtin().texts(language)
229    }
230
231    /// Those of this catalog, in the language of this code, which may be one the framework does
232    /// not come with.
233    pub fn of(catalog: Catalog, language: impl Into<String>) -> Self {
234        Self { catalog: Arc::new(catalog), language: language.into() }
235    }
236
237    /// The code of its language.
238    pub fn language(&self) -> &str {
239        &self.language
240    }
241
242    /// The catalog it reads from.
243    pub fn catalog(&self) -> &Catalog {
244        &self.catalog
245    }
246
247    /// The text of a key, falling back to English, and to the key itself when nobody has it.
248    pub fn get(&self, key: &str) -> String {
249        self.catalog.text(&self.language, key).unwrap_or(key).to_owned()
250    }
251
252    /// The first character of the text of a key, or this when there is none.
253    pub fn char(&self, key: &str, otherwise: char) -> char {
254        self.catalog.text(&self.language, key).and_then(|text| text.chars().next()).unwrap_or(otherwise)
255    }
256
257    /// The texts under `key.0`, `key.1`... as many as there are.
258    pub fn list(&self, key: &str) -> Vec<String> {
259        (0..)
260            .map_while(|i| self.catalog.text(&self.language, &format!("{key}.{i}")).map(str::to_owned))
261            .collect()
262    }
263
264    /// The texts under `key.0` to `key.{N-1}`.
265    pub fn array<const N: usize>(&self, key: &str) -> [String; N] {
266        std::array::from_fn(|i| self.get(&format!("{key}.{i}")))
267    }
268
269    /// The texts under `key.0` and `key.1`.
270    pub fn pair(&self, key: &str) -> (String, String) {
271        (self.get(&format!("{key}.0")), self.get(&format!("{key}.1")))
272    }
273
274    /// The pairs under `key.0`, `key.1`... as many as there are, each with its `.0` and `.1`.
275    pub fn pair_list(&self, key: &str) -> Vec<(String, String)> {
276        (0..)
277            .map_while(|i| {
278                let at = format!("{key}.{i}");
279                self.catalog.text(&self.language, &format!("{at}.0")).map(|_| self.pair(&at))
280            })
281            .collect()
282    }
283
284    /// The pairs under `key.0` to `key.{N-1}`.
285    pub fn pairs<const N: usize>(&self, key: &str) -> [(String, String); N] {
286        std::array::from_fn(|i| self.pair(&format!("{key}.{i}")))
287    }
288
289    /// The text of a key with `{0}`, `{1}`... replaced by these.
290    pub fn format(&self, key: &str, values: &[&str]) -> String {
291        format(&self.get(key), values)
292    }
293}
294
295impl Default for Texts {
296    fn default() -> Self {
297        Self::new(Language::default())
298    }
299}
300
301/// A template with `{0}`, `{1}`... replaced by these. A number with nothing for it stays as it was.
302pub fn format(template: &str, values: &[&str]) -> String {
303    format_with(template, |index, out| match values.get(index) {
304        Some(value) => {
305            out.push_str(value);
306            true
307        }
308        None => false,
309    })
310}
311
312/// A template with each `{0}`, `{1}`... written by `value`, straight into what it gives, so nothing
313/// else is made for each value. `value` says `false` for a number it has nothing for, which then
314/// stays as it was.
315pub fn format_with(template: &str, mut value: impl FnMut(usize, &mut String) -> bool) -> String {
316    let mut out = String::with_capacity(template.len() + 8);
317    let mut rest = template;
318    while let Some(open) = rest.find('{') {
319        out.push_str(&rest[..open]);
320        let after = &rest[open + 1..];
321        let index =
322            after.find('}').and_then(|close| after[..close].parse::<usize>().ok().map(|i| (i, close)));
323        match index {
324            Some((index, close)) if value(index, &mut out) => rest = &after[close + 1..],
325            _ => {
326                out.push('{');
327                rest = after;
328            }
329        }
330    }
331    out.push_str(rest);
332    out
333}