Skip to main content

renox_core/
i18n.rs

1//! Translations for app texts, and the language of each request.
2//!
3//! Renox ships English texts; an app adds its own languages as
4//! `resources/lang/{locale}.json` files (`LANG_PATH`, e.g. `es.json`), nested or flat:
5//!
6//! ```json
7//! { "products": { "saved": ":name guardado", "count": "Un producto|:count productos" } }
8//! ```
9//!
10//! ```
11//! # use renox::prelude::*;
12//! // templates: {{ t('products.saved', name='Coffee') }}  {{ t('products.count', count=3) }}
13//! async fn store(lang: Lang, session: Session, back: Back) -> Result<Back> {
14//!     session.flash("status", lang.t("products.saved", &[("name", &"Coffee")]))?;
15//!     Ok(back)
16//! }
17//! # fn demo(session: &Session) -> Result {
18//! renox::i18n::remember_locale(&session, "en")?;   // this visitor's language from now on
19//! # Ok(()) }
20//! ```
21//!
22//! A request's language is the one stored with `remember_locale`, else `APP_LOCALE`.
23//! Missing keys fall back to `APP_FALLBACK_LOCALE`, then to the key itself.
24//! Renox's own texts can be replaced or translated from the same files:
25//! `renox.validation.required`, `renox.validation.attributes.email` and
26//! `renox.auth.login_title` (see the built-in pages for the other keys).
27
28use std::collections::HashMap;
29use std::convert::Infallible;
30use std::fmt::Display;
31use std::path::{Path, PathBuf};
32use std::sync::{Arc, RwLock};
33use std::time::{Duration, Instant, SystemTime};
34
35use anyhow::Context;
36use axum::extract::{FromRequestParts, Request, State};
37use axum::http::request::Parts;
38use axum::middleware::Next;
39use axum::response::Response;
40use serde_json::Value;
41
42use crate::{AppState, Result, Session};
43
44const SESSION_KEY: &str = "_locale";
45/// How often debug mode looks for changed translation files.
46const RELOAD_CHECK: Duration = Duration::from_secs(1);
47
48pub(crate) type Texts = Arc<HashMap<String, String>>;
49
50struct Loaded {
51    locales: HashMap<String, Texts>,
52    fingerprint: Vec<(PathBuf, SystemTime)>,
53    checked: Instant,
54}
55
56/// Every locale's texts, keyed by dotted path.
57pub struct Translator {
58    dir: PathBuf,
59    reload: bool,
60    loaded: RwLock<Loaded>,
61}
62
63fn fingerprint(dir: &Path) -> Vec<(PathBuf, SystemTime)> {
64    let mut files: Vec<_> = std::fs::read_dir(dir)
65        .into_iter()
66        .flatten()
67        .flatten()
68        .map(|e| e.path())
69        .filter(|p| p.extension().is_some_and(|e| e == "json"))
70        .map(|p| {
71            let modified = p
72                .metadata()
73                .and_then(|m| m.modified())
74                .unwrap_or(SystemTime::UNIX_EPOCH);
75            (p, modified)
76        })
77        .collect();
78    files.sort();
79    files
80}
81
82fn flatten(prefix: &str, value: &Value, out: &mut HashMap<String, String>) {
83    match value {
84        Value::Object(map) => {
85            for (key, value) in map {
86                let key = if prefix.is_empty() {
87                    key.clone()
88                } else {
89                    format!("{prefix}.{key}")
90                };
91                flatten(&key, value, out);
92            }
93        }
94        Value::String(text) => {
95            out.insert(prefix.to_owned(), text.clone());
96        }
97        Value::Null => {}
98        other => {
99            out.insert(prefix.to_owned(), other.to_string());
100        }
101    }
102}
103
104fn parse(locales: &mut HashMap<String, Texts>, file: &str, text: &str) -> anyhow::Result<()> {
105    let locale = file.strip_suffix(".json").unwrap_or(file).to_owned();
106    let json: Value =
107        serde_json::from_str(text).with_context(|| format!("{file} is not valid JSON"))?;
108    let mut flat = HashMap::new();
109    flatten("", &json, &mut flat);
110    locales.insert(locale, Arc::new(flat));
111    Ok(())
112}
113
114fn read(dir: &Path) -> anyhow::Result<HashMap<String, Texts>> {
115    let mut locales = HashMap::new();
116    for (path, _) in fingerprint(dir) {
117        let locale = path
118            .file_stem()
119            .and_then(|s| s.to_str())
120            .unwrap_or_default()
121            .to_owned();
122        let text = std::fs::read_to_string(&path)
123            .with_context(|| format!("could not read {}", path.display()))?;
124        let json: Value = serde_json::from_str(&text)
125            .with_context(|| format!("{} is not valid JSON", path.display()))?;
126        let mut flat = HashMap::new();
127        flatten("", &json, &mut flat);
128        locales.insert(locale, Arc::new(flat));
129    }
130    Ok(locales)
131}
132
133impl Translator {
134    /// Uses translation files compiled into the binary.
135    pub(crate) fn embedded(files: &[(&str, &str)]) -> anyhow::Result<Self> {
136        let mut locales = HashMap::new();
137        for (file, text) in files
138            .iter()
139            .filter(|(f, _)| f.ends_with(".json") && !f.contains('/'))
140        {
141            parse(&mut locales, file, text)?;
142        }
143        Ok(Self {
144            dir: PathBuf::new(),
145            reload: false,
146            loaded: RwLock::new(Loaded {
147                locales,
148                fingerprint: Vec::new(),
149                checked: Instant::now(),
150            }),
151        })
152    }
153
154    /// Loads `dir`; a file that isn't valid JSON is an error.
155    pub(crate) fn load(dir: &Path, reload: bool) -> anyhow::Result<Self> {
156        Ok(Self {
157            dir: dir.to_path_buf(),
158            reload,
159            loaded: RwLock::new(Loaded {
160                locales: read(dir)?,
161                fingerprint: fingerprint(dir),
162                checked: Instant::now(),
163            }),
164        })
165    }
166
167    fn refresh(&self) {
168        if !self.reload {
169            return;
170        }
171        {
172            let loaded = self.loaded.read().unwrap_or_else(|e| e.into_inner());
173            if loaded.checked.elapsed() < RELOAD_CHECK {
174                return;
175            }
176        }
177        let mut loaded = self.loaded.write().unwrap_or_else(|e| e.into_inner());
178        loaded.checked = Instant::now();
179        let now = fingerprint(&self.dir);
180        if now != loaded.fingerprint {
181            match read(&self.dir) {
182                Ok(locales) => loaded.locales = locales,
183                Err(err) => tracing::error!(error = ?err, "keeping the previous translations"),
184            }
185            loaded.fingerprint = now;
186        }
187    }
188
189    /// The texts of one locale (empty if there is no file for it).
190    pub(crate) fn texts(&self, locale: &str) -> Texts {
191        self.refresh();
192        let loaded = self.loaded.read().unwrap_or_else(|e| e.into_inner());
193        loaded.locales.get(locale).cloned().unwrap_or_default()
194    }
195
196    /// Locales with a translation file.
197    pub fn locales(&self) -> Vec<String> {
198        self.refresh();
199        let loaded = self.loaded.read().unwrap_or_else(|e| e.into_inner());
200        let mut locales: Vec<_> = loaded.locales.keys().cloned().collect();
201        locales.sort();
202        locales
203    }
204
205    /// The text for `key` in `locale`, else in `fallback`, else the key itself.
206    pub fn get(&self, locale: &str, fallback: &str, key: &str) -> String {
207        if let Some(text) = self.texts(locale).get(key) {
208            return text.clone();
209        }
210        self.texts(fallback)
211            .get(key)
212            .cloned()
213            .or_else(|| {
214                builtin(locale)
215                    .or_else(|| builtin(fallback))?
216                    .get(key)
217                    .map(|t| (*t).to_owned())
218            })
219            .unwrap_or_else(|| key.to_owned())
220    }
221}
222
223/// A built-in text of `locale` (English when it has none), else the key.
224pub(crate) fn builtin_text(locale: &str, key: &str) -> String {
225    builtin(locale)
226        .and_then(|texts| texts.get(key))
227        .or_else(|| builtin("en")?.get(key))
228        .map_or_else(|| key.to_owned(), |t| (*t).to_owned())
229}
230
231/// Texts Renox's own templates use (the UI kit), in English; an app's
232/// `lang/*.json` can change them or add other languages.
233fn builtin(locale: &str) -> Option<&'static HashMap<&'static str, &'static str>> {
234    static EN: std::sync::LazyLock<HashMap<&str, &str>> = std::sync::LazyLock::new(|| {
235        HashMap::from([
236            ("ui.optional", "optional"),
237            ("ui.cancel", "Cancel"),
238            ("ui.close", "Close"),
239            ("ui.dismiss", "Dismiss"),
240            ("ui.request_failed", "That didn't work. Try again."),
241            ("ui.more", "More"),
242            ("ui.skip", "Skip to content"),
243            ("ui.main_navigation", "Main"),
244            ("ui.sections", "Sections"),
245            ("ui.errors_title", "Please check the highlighted fields."),
246            ("ui.show_password", "Show password"),
247            ("ui.hide_password", "Hide password"),
248            ("ui.copy", "Copy"),
249            ("ui.copied", "Copied"),
250            ("ui.choose_file", "Choose a file or drop it here"),
251            ("ui.choose_files", "Choose files or drop them here"),
252            ("ui.current_file", "Current file"),
253            ("ui.choose_date", "Choose a date"),
254            (
255                "ui.date_unavailable",
256                "That day can't be chosen: pick another one.",
257            ),
258            ("ui.previous_month", "Previous month"),
259            ("ui.next_month", "Next month"),
260            ("ui.search", "Search"),
261            ("ui.no_results", "No matches"),
262            ("ui.remove", "Remove"),
263            ("ui.add_row", "Add row"),
264            ("ui.move_up", "Move up"),
265            ("ui.move_down", "Move down"),
266            ("ui.key", "Key"),
267            ("ui.value", "Value"),
268            ("ui.back", "Back"),
269            ("ui.actions", "Actions"),
270            ("ui.import.file", "CSV file"),
271            ("ui.import.columns", "Columns:"),
272            ("ui.import.template", "Download a template"),
273            ("ui.import.row", "Row"),
274            ("ui.import.problem", "Problem"),
275            ("ui.import.more", "…and :count more rows."),
276            ("ui.import.done", "One row imported.|:count rows imported."),
277            (
278                "ui.import.partial",
279                ":imported imported, one row left out.|:imported imported, :count rows left out.",
280            ),
281            (
282                "ui.import.nothing",
283                "Nothing was imported: one row has errors.|Nothing was imported: :count rows have errors.",
284            ),
285            ("ui.import.not_utf8", "The file must be CSV text (UTF-8)."),
286            ("ui.import.empty", "The file has no rows to import."),
287            ("ui.import.too_many", "The file has more than :count rows."),
288            (
289                "ui.import.duplicate",
290                "A value of this row must be unique, and is already taken.",
291            ),
292            ("ui.import.not_saved", "This row could not be saved."),
293            ("ui.next", "Next"),
294            ("ui.since.now", "just now"),
295            ("ui.since.past", ":time ago"),
296            ("ui.since.future", "in :time"),
297            ("ui.since.minutes", "a minute|:count minutes"),
298            ("ui.since.hours", "an hour|:count hours"),
299            ("ui.since.days", "a day|:count days"),
300            ("ui.since.months", "a month|:count months"),
301            ("ui.since.years", "a year|:count years"),
302            ("ui.show_more", "Show :count more"),
303            ("ui.yes", "Yes"),
304            ("ui.no", "No"),
305            ("ui.loading", "Loading…"),
306            ("ui.chart.show_data", "Show the data"),
307            ("ui.chart.other", "Other"),
308            ("ui.chart.series", "Series"),
309            ("ui.chart.size", "Size"),
310            ("ui.chart.axes", ":y by :x"),
311            ("ui.chart.less", "Less"),
312            ("ui.chart.more", "More"),
313            ("ui.stat.vs_previous", "vs previous period"),
314            ("ui.period.label", "Period"),
315            ("ui.period.7d", "7 days"),
316            ("ui.period.30d", "30 days"),
317            ("ui.period.90d", "90 days"),
318            ("ui.period.12w", "12 weeks"),
319            ("ui.period.12m", "12 months"),
320            ("ui.period.mtd", "This month"),
321            ("ui.period.ytd", "This year"),
322            ("ui.period.custom", "Custom"),
323            ("ui.period.custom_label", "Custom range"),
324            ("ui.period.from", "From"),
325            ("ui.period.to", "To"),
326            ("ui.period.apply", "Apply"),
327            (
328                "ui.period.invalid",
329                "Choose a start date on or before the end date, at most three years apart.",
330            ),
331            ("ui.notifications.title", "Notifications"),
332            ("ui.notifications.bell", "Notifications"),
333            ("ui.notifications.unread", ":count unread"),
334            ("ui.notifications.unread_marker", "unread"),
335            ("ui.notifications.mark_all_read", "Mark all as read"),
336            ("ui.notifications.clear", "Clear all"),
337            ("ui.notifications.mark_read", "Mark as read"),
338            ("ui.notifications.mark_unread", "Mark as unread"),
339            ("ui.notifications.delete", "Delete"),
340            ("ui.notifications.empty", "No notifications"),
341            ("ui.notifications.empty_hint", "You're all caught up."),
342            ("ui.notifications.see_all", "See all"),
343            ("ui.notifications.older", "Older"),
344            ("ui.notifications.newest", "Newest"),
345            ("ui.notifications.open", "Open"),
346            ("ui.notifications.loading", "Loading…"),
347            ("ui.searching", "Searching…"),
348            ("ui.load_failed", "Couldn't load the options."),
349            ("ui.add_option", "Add “:value”"),
350            ("ui.edit", "Edit"),
351            ("ui.editing", "Editing “:value”: Enter saves, Esc cancels."),
352            ("ui.save_failed", "Couldn't save it."),
353            ("ui.grid.columns", "Columns"),
354            ("ui.grid.filter", "Filter"),
355            ("ui.grid.apply", "Apply"),
356            ("ui.grid.clear", "Clear"),
357            ("ui.grid.clear_all", "Clear filters"),
358            ("ui.grid.contains", "Contains"),
359            ("ui.grid.starts", "Starts with"),
360            ("ui.grid.ends", "Ends with"),
361            ("ui.grid.equals", "Equals"),
362            (
363                "ui.grid.pattern_hint",
364                "% matches anything: cof% starts with “cof”.",
365            ),
366            ("ui.grid.min", "From"),
367            ("ui.grid.max", "To"),
368            ("ui.grid.from", "From"),
369            ("ui.grid.to", "To"),
370            ("ui.grid.yes", "Yes"),
371            ("ui.grid.no", "No"),
372            ("ui.grid.rows", "Rows"),
373            ("ui.grid.row_word", "row|rows"),
374            ("ui.grid.of", "of"),
375            ("ui.grid.previous", "Previous page"),
376            ("ui.grid.next", "Next page"),
377            ("ui.grid.page", "Page"),
378            ("ui.grid.empty", "Nothing matches these filters."),
379            ("ui.grid.empty_all", "No rows yet."),
380            ("ui.grid.sort", "Sort"),
381            ("ui.grid.show", "Show on this screen"),
382            ("ui.grid.freeze", "Freeze"),
383            ("ui.grid.freeze_left", "Left"),
384            ("ui.grid.freeze_none", "None"),
385            ("ui.grid.freeze_right", "Right"),
386            ("ui.grid.move_up", "Move up"),
387            ("ui.grid.move_down", "Move down"),
388            ("ui.grid.reset", "Reset columns"),
389            ("ui.grid.filtered", "filtered"),
390            ("ui.grid.loading", "Loading…"),
391            ("ui.grid.advanced", "Advanced filter"),
392            ("ui.grid.match", "Show rows matching"),
393            ("ui.grid.match_all", "all of the rules"),
394            ("ui.grid.match_any", "any of the rules"),
395            ("ui.grid.add_rule", "Add a rule"),
396            ("ui.grid.remove_rule", "Remove the rule"),
397            ("ui.grid.rule_column", "Column"),
398            ("ui.grid.rule_op", "Condition"),
399            ("ui.grid.rule_value", "Value"),
400            ("ui.grid.rule_word", "rule|rules"),
401            ("ui.grid.op.contains", "contains"),
402            ("ui.grid.op.not_contains", "doesn't contain"),
403            ("ui.grid.op.equals", "is"),
404            ("ui.grid.op.not_equals", "isn't"),
405            ("ui.grid.op.starts", "starts with"),
406            ("ui.grid.op.ends", "ends with"),
407            ("ui.grid.op.empty", "is empty"),
408            ("ui.grid.op.not_empty", "isn't empty"),
409            ("ui.grid.op.eq", "="),
410            ("ui.grid.op.ne", "≠"),
411            ("ui.grid.op.gt", ">"),
412            ("ui.grid.op.gte", "≥"),
413            ("ui.grid.op.lt", "<"),
414            ("ui.grid.op.lte", "≤"),
415            ("ui.grid.op.on", "is on"),
416            ("ui.grid.op.before", "is before"),
417            ("ui.grid.op.after", "is after"),
418            ("ui.grid.op.is_true", "is yes"),
419            ("ui.grid.op.is_false", "is no"),
420            ("ui.grid.op.is", "is"),
421            ("ui.grid.op.is_not", "isn't"),
422            ("ui.grid.copy", "Copy"),
423            ("ui.grid.copied", "Copied"),
424            ("ui.grid.sum", "Total"),
425            ("ui.grid.average", "Average"),
426            ("ui.grid.range", "Range"),
427            ("ui.grid.count", "Count"),
428            ("ui.grid.group_by", "Group"),
429            ("ui.grid.no_groups", "No groups"),
430            ("ui.grid.all_rows", "All rows"),
431            ("ui.grid.group_rows", "This group"),
432            ("ui.grid.select_page", "Select every row on this page"),
433            ("ui.grid.select_row", "Select row"),
434            ("ui.grid.selected", "selected"),
435            ("ui.grid.select_matching", "Select all :total matching"),
436            ("ui.grid.select_none", "Clear selection"),
437            ("ui.grid.actions", "Actions"),
438            ("ui.grid.confirm", "Continue"),
439            ("ui.grid.all_matching", "all matching"),
440            ("ui.grid.search", "Search"),
441            ("ui.grid.active_filters", "Active filters"),
442            ("ui.grid.open", "Open"),
443            ("ui.grid.resize", "Column width"),
444            (
445                "ui.grid.resize_hint",
446                "Drag to resize, double-click for the automatic width; drag the heading to move the column",
447            ),
448            ("ui.grid.export", "Export"),
449            (
450                "ui.grid.export_hint",
451                "Every row the filters match, in your columns.",
452            ),
453            ("ui.grid.print", "Print or save as PDF"),
454            ("ui.grid.printed", "printed"),
455            ("ui.grid.back", "Back"),
456            ("ui.grid.created", "Created"),
457            ("ui.grid.updated", "Last updated"),
458            ("ui.grid.details", "Details"),
459            ("ui.grid.edit", "Edit row"),
460            ("ui.grid.save", "Save"),
461            ("ui.grid.move", "Drag to reorder (or use the arrow keys)"),
462            ("ui.grid.sort_to_move", "Sort by order to move rows"),
463            ("ui.grid.row_tools", "Row"),
464        ])
465    });
466    match locale {
467        "en" => Some(&EN),
468        _ => None,
469    }
470}
471
472/// Fills `:name` (and `:Name`, capitalised) placeholders, and picks the
473/// singular or plural side of `one|many` texts when `count` is given, or
474/// the matching range of `{0} none|[1,5] a few|[6,*] many` texts (Laravel's
475/// `trans_choice` ranges: `{n}` exactly, `[a,b]` from a to b, `*` open).
476pub fn format(text: &str, params: &[(&str, String)], count: Option<i64>) -> String {
477    let text = match (count, text.split_once('|')) {
478        (Some(count), Some(_)) if has_ranges(text) => choose_range(text, count),
479        (Some(1), Some((one, _))) => one.to_owned(),
480        (Some(_), Some((_, many))) => many.to_owned(),
481        _ => text.to_owned(),
482    };
483    let mut params: Vec<(String, String)> = params
484        .iter()
485        .map(|(k, v)| ((*k).to_owned(), v.clone()))
486        .collect();
487    if let Some(count) = count {
488        params.push(("count".into(), count.to_string()));
489    }
490    // Longest names first, so `:name` doesn't eat the start of `:names`.
491    params.sort_by_key(|(k, _)| std::cmp::Reverse(k.len()));
492    let mut out = text;
493    for (name, value) in params {
494        let mut capitalised = value.clone();
495        if let Some(first) = capitalised.get_mut(0..1) {
496            first.make_ascii_uppercase();
497        }
498        let mut upper_name = name.clone();
499        if let Some(first) = upper_name.get_mut(0..1) {
500            first.make_ascii_uppercase();
501        }
502        out = out
503            .replace(&format!(":{upper_name}"), &capitalised)
504            .replace(&format!(":{name}"), &value);
505    }
506    out
507}
508
509/// Whether a `|` text uses ranges (`{0} …|[1,*] …`).
510fn has_ranges(text: &str) -> bool {
511    text.split('|')
512        .any(|part| part.trim_start().starts_with(['{', '[']))
513}
514
515/// The part of a ranged text whose range holds `count`; the last part when
516/// none does. The range itself is left out.
517fn choose_range(text: &str, count: i64) -> String {
518    let parts: Vec<&str> = text.split('|').collect();
519    for part in &parts {
520        let part = part.trim_start();
521        let (range, rest) = if let Some(inner) = part.strip_prefix('{') {
522            match inner.split_once('}') {
523                Some((exact, rest)) => ((exact, exact), rest),
524                None => continue,
525            }
526        } else if let Some(inner) = part.strip_prefix('[') {
527            match inner.split_once(']').and_then(|(range, rest)| {
528                range.split_once(',').map(|(from, to)| ((from, to), rest))
529            }) {
530                Some(found) => found,
531                None => continue,
532            }
533        } else {
534            continue;
535        };
536        let bound = |text: &str, open: i64| match text.trim() {
537            "*" => Some(open),
538            number => number.parse::<i64>().ok(),
539        };
540        let (Some(from), Some(to)) = (bound(range.0, i64::MIN), bound(range.1, i64::MAX)) else {
541            continue;
542        };
543        if (from..=to).contains(&count) {
544            return rest.trim_start().to_owned();
545        }
546    }
547    let last = parts.last().copied().unwrap_or_default().trim_start();
548    // Without a matching range, the last text, minus its range.
549    match last.find([']', '}']) {
550        Some(end) if last.starts_with(['{', '[']) => last[end + 1..].trim_start().to_owned(),
551        _ => last.to_owned(),
552    }
553}
554
555/// The language chosen for the current request, kept in the request's
556/// extensions by Renox's language middleware (session choice, then
557/// `Accept-Language` with `App::detect_locale`, then `APP_LOCALE`).
558///
559/// A layer added with `App::layer` runs after it and may change the language
560/// of the rest of the request, e.g. to the signed-in user's own:
561///
562/// ```
563/// # use renox::prelude::*;
564/// use renox::i18n::RequestLocale;
565/// use renox::axum::{extract::Request, middleware::Next};
566///
567/// async fn account_language(mut req: Request, next: Next) -> Response {
568///     if let Some(chosen) = req.extensions().get::<RequestLocale>() {
569///         println!("chosen so far: {}", chosen.as_str());
570///     }
571///     RequestLocale::switch(&mut req, "es");
572///     next.run(req).await
573/// }
574/// # let _ = App::new().layer(renox::axum::middleware::from_fn(account_language));
575/// ```
576#[derive(Debug, Clone, PartialEq, Eq)]
577#[non_exhaustive]
578pub struct RequestLocale(String);
579
580impl RequestLocale {
581    /// A locale value, e.g. `es`.
582    pub fn new(locale: impl Into<String>) -> Self {
583        Self(locale.into())
584    }
585
586    /// The locale, e.g. `en`.
587    pub fn as_str(&self) -> &str {
588        &self.0
589    }
590
591    /// Makes `locale` the language of the rest of this request: the `Lang`
592    /// extractor, validation messages, `t()` in views, mails and
593    /// [`current_locale`] all follow. Call it from a layer; it does not
594    /// check that the locale has a translation file (texts fall back to
595    /// `APP_FALLBACK_LOCALE`, then to the key).
596    pub fn switch(req: &mut Request, locale: &str) {
597        set_current_locale(locale);
598        req.extensions_mut().insert(RequestLocale::new(locale));
599    }
600}
601
602impl std::ops::Deref for RequestLocale {
603    type Target = str;
604
605    fn deref(&self) -> &str {
606        &self.0
607    }
608}
609
610/// The request's language in [`crate::context`], for code without the
611/// request (mail views, notifications).
612#[derive(Debug, Clone)]
613struct ContextLocale(String);
614
615thread_local! {
616    /// Set while a notification builds its messages for a recipient.
617    static OVERRIDE: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
618}
619
620/// The language to use now: the recipient's while a notification is built,
621/// else the current request's (or the one a job set with
622/// [`with_locale`]), else `APP_LOCALE`.
623pub fn current_locale(state: &AppState) -> String {
624    OVERRIDE
625        .with(|o| o.borrow().clone())
626        .or_else(|| crate::context::get::<ContextLocale>().map(|l| l.0))
627        .unwrap_or_else(|| state.config.locale.clone())
628}
629
630/// Runs `f` (synchronous code, e.g. building a mail) in `locale`; `None`
631/// leaves the language as it is.
632pub fn with_locale<T>(locale: Option<&str>, f: impl FnOnce() -> T) -> T {
633    let Some(locale) = locale else { return f() };
634    let previous = OVERRIDE.with(|o| o.borrow_mut().replace(locale.to_owned()));
635    struct Restore(Option<String>);
636    impl Drop for Restore {
637        fn drop(&mut self) {
638            let previous = self.0.take();
639            OVERRIDE.with(|o| *o.borrow_mut() = previous);
640        }
641    }
642    let _restore = Restore(previous);
643    f()
644}
645
646/// Makes `locale` the language of the rest of this request, job or task
647/// (e.g. a job that mails a user in their language).
648pub fn set_current_locale(locale: &str) {
649    crate::context::set(ContextLocale(locale.to_owned()));
650}
651
652/// Makes `locale` this visitor's language from the next request on.
653pub fn remember_locale(session: &Session, locale: &str) -> Result {
654    session.put(SESSION_KEY, locale)
655}
656
657/// Picks the request's language: the session's choice if Renox knows the
658/// locale (a translation file or a built-in language), else `APP_LOCALE`.
659pub(crate) async fn middleware(
660    State(state): State<AppState>,
661    mut req: Request,
662    next: Next,
663) -> Response {
664    // In a block: nothing borrowing `req` may live across `next.run(req)` (§4.2).
665    let locale = {
666        let available =
667            |locale: &str| locale == "en" || state.translator.locales().iter().any(|l| l == locale);
668        let chosen = req
669            .extensions()
670            .get::<Session>()
671            .and_then(|s| s.get::<String>(SESSION_KEY))
672            .filter(|locale| available(locale));
673        let chosen = chosen.or_else(|| {
674            if !state.detect_locale {
675                return None;
676            }
677            req.headers()
678                .get(axum::http::header::ACCEPT_LANGUAGE)
679                .and_then(|v| v.to_str().ok())
680                .and_then(|header| from_accept_language(header, available))
681        });
682        chosen.unwrap_or_else(|| state.config.locale.clone())
683    };
684    set_current_locale(&locale);
685    req.extensions_mut().insert(RequestLocale::new(locale));
686    let mut res = next.run(req).await;
687    if state.detect_locale {
688        res.headers_mut().append(
689            axum::http::header::VARY,
690            axum::http::HeaderValue::from_static("Accept-Language"),
691        );
692    }
693    res
694}
695
696/// The first language of an `Accept-Language` header (`es-MX,es;q=0.9,
697/// en;q=0.8`) that `available` accepts, by quality: the whole tag
698/// (`pt-br`), then its language (`pt`).
699fn from_accept_language(header: &str, available: impl Fn(&str) -> bool) -> Option<String> {
700    let mut wanted: Vec<(f32, usize, String)> = header
701        .split(',')
702        .enumerate()
703        .filter_map(|(order, item)| {
704            let mut parts = item.split(';');
705            let tag = parts.next()?.trim().to_ascii_lowercase();
706            let quality = parts
707                .find_map(|p| p.trim().strip_prefix("q="))
708                .map_or(Some(1.0), |q| q.trim().parse::<f32>().ok())?;
709            (!tag.is_empty() && tag != "*" && quality > 0.0).then_some((quality, order, tag))
710        })
711        .collect();
712    // Highest quality first; the header's order among equals.
713    wanted.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.cmp(&b.1)));
714    wanted.into_iter().find_map(|(_, _, tag)| {
715        if available(&tag) {
716            return Some(tag);
717        }
718        let language = tag.split(['-', '_']).next()?;
719        available(language).then(|| language.to_owned())
720    })
721}
722
723pub(crate) fn request_locale(extensions: &axum::http::Extensions, state: &AppState) -> String {
724    extensions
725        .get::<RequestLocale>()
726        .map(|l| l.0.clone())
727        .unwrap_or_else(|| state.config.locale.clone())
728}
729
730/// The current request's language and its texts.
731///
732/// ```
733/// # use renox::prelude::*;
734/// async fn index(lang: Lang) -> String { lang.t("welcome", &[("name", &"Anna")]) }
735/// ```
736#[derive(Clone)]
737pub struct Lang {
738    /// The request's locale, e.g. `en`.
739    pub locale: String,
740    pub(crate) state: AppState,
741}
742
743impl Lang {
744    /// The request's language, for code that has the state but no request.
745    pub(crate) fn of(state: &AppState, locale: &str) -> Self {
746        Self {
747            locale: locale.to_owned(),
748            state: state.clone(),
749        }
750    }
751
752    pub(crate) fn texts(&self) -> Texts {
753        self.state.translator.texts(&self.locale)
754    }
755
756    /// Translates `key`, replacing `:name` placeholders with `params`; falls
757    /// back to `APP_FALLBACK_LOCALE`, then to the key itself.
758    pub fn t(&self, key: &str, params: &[(&str, &dyn Display)]) -> String {
759        let params: Vec<(&str, String)> = params.iter().map(|(k, v)| (*k, v.to_string())).collect();
760        format(&self.raw(key), &params, None)
761    }
762
763    /// Like `t`, choosing the `one|many` side by `count` and filling `:count`.
764    pub fn choice(&self, key: &str, count: i64, params: &[(&str, &dyn Display)]) -> String {
765        let params: Vec<(&str, String)> = params.iter().map(|(k, v)| (*k, v.to_string())).collect();
766        format(&self.raw(key), &params, Some(count))
767    }
768
769    fn raw(&self, key: &str) -> String {
770        self.state
771            .translator
772            .get(&self.locale, &self.state.config.fallback_locale, key)
773    }
774}
775
776impl AppState {
777    /// The texts of `locale`, for code without a request (a job, a mail
778    /// to someone in another language).
779    pub fn lang(&self, locale: &str) -> Lang {
780        Lang::of(self, locale)
781    }
782
783    /// The texts of the [`current_locale`].
784    pub fn current_lang(&self) -> Lang {
785        Lang::of(self, &current_locale(self))
786    }
787}
788
789impl<S: Send + Sync> FromRequestParts<S> for Lang {
790    type Rejection = Infallible;
791
792    async fn from_request_parts(parts: &mut Parts, _: &S) -> std::result::Result<Self, Infallible> {
793        let state = parts
794            .extensions
795            .get::<AppState>()
796            .cloned()
797            .expect("the auth middleware puts AppState in every request");
798        Ok(Self {
799            locale: request_locale(&parts.extensions, &state),
800            state,
801        })
802    }
803}
804
805#[cfg(test)]
806mod tests {
807    use super::*;
808
809    /// Renox's own texts: English, English for a language without its own,
810    /// and the key itself for a text there isn't.
811    #[test]
812    fn builtin_texts_fall_back_to_english_then_the_key() {
813        assert_eq!(super::builtin_text("en", "ui.cancel"), "Cancel");
814        assert_eq!(super::builtin_text("xx", "ui.cancel"), "Cancel");
815        assert_eq!(
816            super::builtin_text("en", "ui.no-such-text"),
817            "ui.no-such-text"
818        );
819    }
820
821    #[test]
822    fn accept_language_picks_the_first_available() {
823        let ours = |l: &str| matches!(l, "en" | "es" | "pt-br");
824        assert_eq!(
825            from_accept_language("es-MX,es;q=0.9,en;q=0.8", ours).as_deref(),
826            Some("es")
827        );
828        assert_eq!(
829            from_accept_language("fr, en;q=0.5", ours).as_deref(),
830            Some("en")
831        );
832        assert_eq!(
833            from_accept_language("en;q=0.2, es;q=0.9", ours).as_deref(),
834            Some("es")
835        );
836        assert_eq!(
837            from_accept_language("PT-BR", ours).as_deref(),
838            Some("pt-br")
839        );
840        assert_eq!(from_accept_language("de, fr;q=0.8", ours), None);
841        assert_eq!(from_accept_language("es;q=0, *", ours), None);
842        assert_eq!(from_accept_language("", ours), None);
843        assert_eq!(from_accept_language("en;q=abc", ours), None);
844    }
845
846    #[test]
847    fn formats_placeholders_and_plurals() {
848        let p = [("name", "coffee".to_owned())];
849        assert_eq!(format(":Name, :name!", &p, None), "Coffee, coffee!");
850        assert_eq!(
851            format("One product|:count products", &[], Some(1)),
852            "One product"
853        );
854        assert_eq!(
855            format("One product|:count products", &[], Some(4)),
856            "4 products"
857        );
858        // Like Laravel: 0 and negative counts take the plural form.
859        assert_eq!(
860            format("One product|:count products", &[], Some(0)),
861            "0 products"
862        );
863        assert_eq!(format("one|:count many", &[], Some(-1)), "-1 many");
864        // Without a plural form the text is used as it is.
865        assert_eq!(format(":count item", &[], Some(3)), "3 item");
866        // Laravel's ranges.
867        let ranged = "{0} No messages yet|[1,5] :count messages|[6,*] Many messages (:count)";
868        assert_eq!(format(ranged, &[], Some(0)), "No messages yet");
869        assert_eq!(format(ranged, &[], Some(1)), "1 messages");
870        assert_eq!(format(ranged, &[], Some(5)), "5 messages");
871        assert_eq!(format(ranged, &[], Some(40)), "Many messages (40)");
872        assert_eq!(
873            format("[*,-1] minus|{0} zero|[1,*] :count", &[], Some(-3)),
874            "minus"
875        );
876        // No range matches: the last text.
877        assert_eq!(format("{1} one|{2} two", &[], Some(7)), "two");
878        // Without a count, ranged texts are left alone.
879        assert_eq!(format("{0} a|[1,*] b", &[], None), "{0} a|[1,*] b");
880        assert_eq!(
881            format(
882                ":name :names",
883                &[("name", "a".into()), ("names", "b".into())],
884                None
885            ),
886            "a b"
887        );
888    }
889
890    #[test]
891    fn flattens_nested_files() {
892        let json = serde_json::json!({ "a": { "b": "x", "n": 3 }, "c": "y" });
893        let mut out = HashMap::new();
894        flatten("", &json, &mut out);
895        assert_eq!(out["a.b"], "x");
896        assert_eq!(out["a.n"], "3");
897        assert_eq!(out["c"], "y");
898    }
899
900    /// Parts that aren't ranges, or whose ranges don't read, are skipped;
901    /// with no match the last part is used as it is.
902    #[test]
903    fn ranges_that_dont_read_are_skipped() {
904        let text = "{0 unclosed|[1 no end|plain|[a,b] letters|[1,2] few|the rest";
905        assert_eq!(choose_range(text, 1), "few");
906        assert_eq!(choose_range(text, 9), "the rest");
907    }
908
909    /// A lang file broken while the app runs (a half-saved edit): the
910    /// previous translations stay until the file reads again.
911    #[test]
912    fn a_lang_file_broken_while_reloading_keeps_the_previous_texts() {
913        let dir = tempfile::tempdir().unwrap();
914        let file = dir.path().join("es.json");
915        std::fs::write(&file, r#"{"hi": "Hola"}"#).unwrap();
916        let translator = Translator::load(dir.path(), true).unwrap();
917        assert_eq!(translator.get("es", "en", "hi"), "Hola");
918        std::fs::write(&file, r#"{"hi": "Hola de nue"#).unwrap();
919        std::thread::sleep(RELOAD_CHECK + Duration::from_millis(100));
920        assert_eq!(translator.get("es", "en", "hi"), "Hola");
921        std::fs::write(&file, r#"{"hi": "Hola de nuevo"}"#).unwrap();
922        std::thread::sleep(RELOAD_CHECK + Duration::from_millis(100));
923        assert_eq!(translator.get("es", "en", "hi"), "Hola de nuevo");
924    }
925}