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.stat.vs_previous", "vs previous period"),
312            ("ui.period.label", "Period"),
313            ("ui.period.7d", "7 days"),
314            ("ui.period.30d", "30 days"),
315            ("ui.period.90d", "90 days"),
316            ("ui.period.12w", "12 weeks"),
317            ("ui.period.12m", "12 months"),
318            ("ui.period.mtd", "This month"),
319            ("ui.period.ytd", "This year"),
320            ("ui.period.custom", "Custom"),
321            ("ui.period.custom_label", "Custom range"),
322            ("ui.period.from", "From"),
323            ("ui.period.to", "To"),
324            ("ui.period.apply", "Apply"),
325            (
326                "ui.period.invalid",
327                "Choose a start date on or before the end date, at most three years apart.",
328            ),
329            ("ui.notifications.title", "Notifications"),
330            ("ui.notifications.bell", "Notifications"),
331            ("ui.notifications.unread", ":count unread"),
332            ("ui.notifications.unread_marker", "unread"),
333            ("ui.notifications.mark_all_read", "Mark all as read"),
334            ("ui.notifications.clear", "Clear all"),
335            ("ui.notifications.mark_read", "Mark as read"),
336            ("ui.notifications.mark_unread", "Mark as unread"),
337            ("ui.notifications.delete", "Delete"),
338            ("ui.notifications.empty", "No notifications"),
339            ("ui.notifications.empty_hint", "You're all caught up."),
340            ("ui.notifications.see_all", "See all"),
341            ("ui.notifications.older", "Older"),
342            ("ui.notifications.newest", "Newest"),
343            ("ui.notifications.open", "Open"),
344            ("ui.notifications.loading", "Loading…"),
345            ("ui.searching", "Searching…"),
346            ("ui.load_failed", "Couldn't load the options."),
347            ("ui.add_option", "Add “:value”"),
348            ("ui.edit", "Edit"),
349            ("ui.editing", "Editing “:value”: Enter saves, Esc cancels."),
350            ("ui.save_failed", "Couldn't save it."),
351            ("ui.grid.columns", "Columns"),
352            ("ui.grid.filter", "Filter"),
353            ("ui.grid.apply", "Apply"),
354            ("ui.grid.clear", "Clear"),
355            ("ui.grid.clear_all", "Clear filters"),
356            ("ui.grid.contains", "Contains"),
357            ("ui.grid.starts", "Starts with"),
358            ("ui.grid.ends", "Ends with"),
359            ("ui.grid.equals", "Equals"),
360            (
361                "ui.grid.pattern_hint",
362                "% matches anything: cof% starts with “cof”.",
363            ),
364            ("ui.grid.min", "From"),
365            ("ui.grid.max", "To"),
366            ("ui.grid.from", "From"),
367            ("ui.grid.to", "To"),
368            ("ui.grid.yes", "Yes"),
369            ("ui.grid.no", "No"),
370            ("ui.grid.rows", "Rows"),
371            ("ui.grid.row_word", "row|rows"),
372            ("ui.grid.of", "of"),
373            ("ui.grid.previous", "Previous page"),
374            ("ui.grid.next", "Next page"),
375            ("ui.grid.page", "Page"),
376            ("ui.grid.empty", "Nothing matches these filters."),
377            ("ui.grid.empty_all", "No rows yet."),
378            ("ui.grid.sort", "Sort"),
379            ("ui.grid.show", "Show on this screen"),
380            ("ui.grid.freeze", "Freeze"),
381            ("ui.grid.freeze_left", "Left"),
382            ("ui.grid.freeze_none", "None"),
383            ("ui.grid.freeze_right", "Right"),
384            ("ui.grid.move_up", "Move up"),
385            ("ui.grid.move_down", "Move down"),
386            ("ui.grid.reset", "Reset columns"),
387            ("ui.grid.filtered", "filtered"),
388            ("ui.grid.loading", "Loading…"),
389            ("ui.grid.advanced", "Advanced filter"),
390            ("ui.grid.match", "Show rows matching"),
391            ("ui.grid.match_all", "all of the rules"),
392            ("ui.grid.match_any", "any of the rules"),
393            ("ui.grid.add_rule", "Add a rule"),
394            ("ui.grid.remove_rule", "Remove the rule"),
395            ("ui.grid.rule_column", "Column"),
396            ("ui.grid.rule_op", "Condition"),
397            ("ui.grid.rule_value", "Value"),
398            ("ui.grid.rule_word", "rule|rules"),
399            ("ui.grid.op.contains", "contains"),
400            ("ui.grid.op.not_contains", "doesn't contain"),
401            ("ui.grid.op.equals", "is"),
402            ("ui.grid.op.not_equals", "isn't"),
403            ("ui.grid.op.starts", "starts with"),
404            ("ui.grid.op.ends", "ends with"),
405            ("ui.grid.op.empty", "is empty"),
406            ("ui.grid.op.not_empty", "isn't empty"),
407            ("ui.grid.op.eq", "="),
408            ("ui.grid.op.ne", "≠"),
409            ("ui.grid.op.gt", ">"),
410            ("ui.grid.op.gte", "≥"),
411            ("ui.grid.op.lt", "<"),
412            ("ui.grid.op.lte", "≤"),
413            ("ui.grid.op.on", "is on"),
414            ("ui.grid.op.before", "is before"),
415            ("ui.grid.op.after", "is after"),
416            ("ui.grid.op.is_true", "is yes"),
417            ("ui.grid.op.is_false", "is no"),
418            ("ui.grid.op.is", "is"),
419            ("ui.grid.op.is_not", "isn't"),
420            ("ui.grid.copy", "Copy"),
421            ("ui.grid.copied", "Copied"),
422            ("ui.grid.sum", "Total"),
423            ("ui.grid.average", "Average"),
424            ("ui.grid.range", "Range"),
425            ("ui.grid.count", "Count"),
426            ("ui.grid.group_by", "Group"),
427            ("ui.grid.no_groups", "No groups"),
428            ("ui.grid.all_rows", "All rows"),
429            ("ui.grid.group_rows", "This group"),
430            ("ui.grid.select_page", "Select every row on this page"),
431            ("ui.grid.select_row", "Select row"),
432            ("ui.grid.selected", "selected"),
433            ("ui.grid.select_matching", "Select all :total matching"),
434            ("ui.grid.select_none", "Clear selection"),
435            ("ui.grid.actions", "Actions"),
436            ("ui.grid.confirm", "Continue"),
437            ("ui.grid.all_matching", "all matching"),
438            ("ui.grid.search", "Search"),
439            ("ui.grid.active_filters", "Active filters"),
440            ("ui.grid.open", "Open"),
441            ("ui.grid.resize", "Column width"),
442            (
443                "ui.grid.resize_hint",
444                "Drag to resize, double-click for the automatic width; drag the heading to move the column",
445            ),
446            ("ui.grid.export", "Export"),
447            (
448                "ui.grid.export_hint",
449                "Every row the filters match, in your columns.",
450            ),
451            ("ui.grid.print", "Print or save as PDF"),
452            ("ui.grid.printed", "printed"),
453            ("ui.grid.back", "Back"),
454            ("ui.grid.created", "Created"),
455            ("ui.grid.updated", "Last updated"),
456            ("ui.grid.details", "Details"),
457            ("ui.grid.edit", "Edit row"),
458            ("ui.grid.save", "Save"),
459            ("ui.grid.move", "Drag to reorder (or use the arrow keys)"),
460            ("ui.grid.sort_to_move", "Sort by order to move rows"),
461            ("ui.grid.row_tools", "Row"),
462        ])
463    });
464    match locale {
465        "en" => Some(&EN),
466        _ => None,
467    }
468}
469
470/// Fills `:name` (and `:Name`, capitalised) placeholders, and picks the
471/// singular or plural side of `one|many` texts when `count` is given, or
472/// the matching range of `{0} none|[1,5] a few|[6,*] many` texts (Laravel's
473/// `trans_choice` ranges: `{n}` exactly, `[a,b]` from a to b, `*` open).
474pub fn format(text: &str, params: &[(&str, String)], count: Option<i64>) -> String {
475    let text = match (count, text.split_once('|')) {
476        (Some(count), Some(_)) if has_ranges(text) => choose_range(text, count),
477        (Some(1), Some((one, _))) => one.to_owned(),
478        (Some(_), Some((_, many))) => many.to_owned(),
479        _ => text.to_owned(),
480    };
481    let mut params: Vec<(String, String)> = params
482        .iter()
483        .map(|(k, v)| ((*k).to_owned(), v.clone()))
484        .collect();
485    if let Some(count) = count {
486        params.push(("count".into(), count.to_string()));
487    }
488    // Longest names first, so `:name` doesn't eat the start of `:names`.
489    params.sort_by_key(|(k, _)| std::cmp::Reverse(k.len()));
490    let mut out = text;
491    for (name, value) in params {
492        let mut capitalised = value.clone();
493        if let Some(first) = capitalised.get_mut(0..1) {
494            first.make_ascii_uppercase();
495        }
496        let mut upper_name = name.clone();
497        if let Some(first) = upper_name.get_mut(0..1) {
498            first.make_ascii_uppercase();
499        }
500        out = out
501            .replace(&format!(":{upper_name}"), &capitalised)
502            .replace(&format!(":{name}"), &value);
503    }
504    out
505}
506
507/// Whether a `|` text uses ranges (`{0} …|[1,*] …`).
508fn has_ranges(text: &str) -> bool {
509    text.split('|')
510        .any(|part| part.trim_start().starts_with(['{', '[']))
511}
512
513/// The part of a ranged text whose range holds `count`; the last part when
514/// none does. The range itself is left out.
515fn choose_range(text: &str, count: i64) -> String {
516    let parts: Vec<&str> = text.split('|').collect();
517    for part in &parts {
518        let part = part.trim_start();
519        let (range, rest) = if let Some(inner) = part.strip_prefix('{') {
520            match inner.split_once('}') {
521                Some((exact, rest)) => ((exact, exact), rest),
522                None => continue,
523            }
524        } else if let Some(inner) = part.strip_prefix('[') {
525            match inner.split_once(']').and_then(|(range, rest)| {
526                range.split_once(',').map(|(from, to)| ((from, to), rest))
527            }) {
528                Some(found) => found,
529                None => continue,
530            }
531        } else {
532            continue;
533        };
534        let bound = |text: &str, open: i64| match text.trim() {
535            "*" => Some(open),
536            number => number.parse::<i64>().ok(),
537        };
538        let (Some(from), Some(to)) = (bound(range.0, i64::MIN), bound(range.1, i64::MAX)) else {
539            continue;
540        };
541        if (from..=to).contains(&count) {
542            return rest.trim_start().to_owned();
543        }
544    }
545    let last = parts.last().copied().unwrap_or_default().trim_start();
546    // Without a matching range, the last text, minus its range.
547    match last.find([']', '}']) {
548        Some(end) if last.starts_with(['{', '[']) => last[end + 1..].trim_start().to_owned(),
549        _ => last.to_owned(),
550    }
551}
552
553/// The language chosen for the current request.
554#[derive(Debug, Clone)]
555pub(crate) struct RequestLocale(pub String);
556
557/// The request's language in [`crate::context`], for code without the
558/// request (mail views, notifications).
559#[derive(Debug, Clone)]
560struct ContextLocale(String);
561
562thread_local! {
563    /// Set while a notification builds its messages for a recipient.
564    static OVERRIDE: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
565}
566
567/// The language to use now: the recipient's while a notification is built,
568/// else the current request's (or the one a job set with
569/// [`with_locale`]), else `APP_LOCALE`.
570pub fn current_locale(state: &AppState) -> String {
571    OVERRIDE
572        .with(|o| o.borrow().clone())
573        .or_else(|| crate::context::get::<ContextLocale>().map(|l| l.0))
574        .unwrap_or_else(|| state.config.locale.clone())
575}
576
577/// Runs `f` (synchronous code, e.g. building a mail) in `locale`; `None`
578/// leaves the language as it is.
579pub fn with_locale<T>(locale: Option<&str>, f: impl FnOnce() -> T) -> T {
580    let Some(locale) = locale else { return f() };
581    let previous = OVERRIDE.with(|o| o.borrow_mut().replace(locale.to_owned()));
582    struct Restore(Option<String>);
583    impl Drop for Restore {
584        fn drop(&mut self) {
585            let previous = self.0.take();
586            OVERRIDE.with(|o| *o.borrow_mut() = previous);
587        }
588    }
589    let _restore = Restore(previous);
590    f()
591}
592
593/// Makes `locale` the language of the rest of this request, job or task
594/// (e.g. a job that mails a user in their language).
595pub fn set_current_locale(locale: &str) {
596    crate::context::set(ContextLocale(locale.to_owned()));
597}
598
599/// Makes `locale` this visitor's language from the next request on.
600pub fn remember_locale(session: &Session, locale: &str) -> Result {
601    session.put(SESSION_KEY, locale)
602}
603
604/// Picks the request's language: the session's choice if Renox knows the
605/// locale (a translation file or a built-in language), else `APP_LOCALE`.
606pub(crate) async fn middleware(
607    State(state): State<AppState>,
608    mut req: Request,
609    next: Next,
610) -> Response {
611    // In a block: nothing borrowing `req` may live across `next.run(req)` (§4.2).
612    let locale = {
613        let available =
614            |locale: &str| locale == "en" || state.translator.locales().iter().any(|l| l == locale);
615        let chosen = req
616            .extensions()
617            .get::<Session>()
618            .and_then(|s| s.get::<String>(SESSION_KEY))
619            .filter(|locale| available(locale));
620        let chosen = chosen.or_else(|| {
621            if !state.detect_locale {
622                return None;
623            }
624            req.headers()
625                .get(axum::http::header::ACCEPT_LANGUAGE)
626                .and_then(|v| v.to_str().ok())
627                .and_then(|header| from_accept_language(header, available))
628        });
629        chosen.unwrap_or_else(|| state.config.locale.clone())
630    };
631    set_current_locale(&locale);
632    req.extensions_mut().insert(RequestLocale(locale));
633    let mut res = next.run(req).await;
634    if state.detect_locale {
635        res.headers_mut().append(
636            axum::http::header::VARY,
637            axum::http::HeaderValue::from_static("Accept-Language"),
638        );
639    }
640    res
641}
642
643/// The first language of an `Accept-Language` header (`es-MX,es;q=0.9,
644/// en;q=0.8`) that `available` accepts, by quality: the whole tag
645/// (`pt-br`), then its language (`pt`).
646fn from_accept_language(header: &str, available: impl Fn(&str) -> bool) -> Option<String> {
647    let mut wanted: Vec<(f32, usize, String)> = header
648        .split(',')
649        .enumerate()
650        .filter_map(|(order, item)| {
651            let mut parts = item.split(';');
652            let tag = parts.next()?.trim().to_ascii_lowercase();
653            let quality = parts
654                .find_map(|p| p.trim().strip_prefix("q="))
655                .map_or(Some(1.0), |q| q.trim().parse::<f32>().ok())?;
656            (!tag.is_empty() && tag != "*" && quality > 0.0).then_some((quality, order, tag))
657        })
658        .collect();
659    // Highest quality first; the header's order among equals.
660    wanted.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.cmp(&b.1)));
661    wanted.into_iter().find_map(|(_, _, tag)| {
662        if available(&tag) {
663            return Some(tag);
664        }
665        let language = tag.split(['-', '_']).next()?;
666        available(language).then(|| language.to_owned())
667    })
668}
669
670pub(crate) fn request_locale(extensions: &axum::http::Extensions, state: &AppState) -> String {
671    extensions
672        .get::<RequestLocale>()
673        .map(|l| l.0.clone())
674        .unwrap_or_else(|| state.config.locale.clone())
675}
676
677/// The current request's language and its texts.
678///
679/// ```
680/// # use renox::prelude::*;
681/// async fn index(lang: Lang) -> String { lang.t("welcome", &[("name", &"Anna")]) }
682/// ```
683#[derive(Clone)]
684pub struct Lang {
685    /// The request's locale, e.g. `en`.
686    pub locale: String,
687    pub(crate) state: AppState,
688}
689
690impl Lang {
691    /// The request's language, for code that has the state but no request.
692    pub(crate) fn of(state: &AppState, locale: &str) -> Self {
693        Self {
694            locale: locale.to_owned(),
695            state: state.clone(),
696        }
697    }
698
699    pub(crate) fn texts(&self) -> Texts {
700        self.state.translator.texts(&self.locale)
701    }
702
703    /// Translates `key`, replacing `:name` placeholders with `params`; falls
704    /// back to `APP_FALLBACK_LOCALE`, then to the key itself.
705    pub fn t(&self, key: &str, params: &[(&str, &dyn Display)]) -> String {
706        let params: Vec<(&str, String)> = params.iter().map(|(k, v)| (*k, v.to_string())).collect();
707        format(&self.raw(key), &params, None)
708    }
709
710    /// Like `t`, choosing the `one|many` side by `count` and filling `:count`.
711    pub fn choice(&self, key: &str, count: i64, params: &[(&str, &dyn Display)]) -> String {
712        let params: Vec<(&str, String)> = params.iter().map(|(k, v)| (*k, v.to_string())).collect();
713        format(&self.raw(key), &params, Some(count))
714    }
715
716    fn raw(&self, key: &str) -> String {
717        self.state
718            .translator
719            .get(&self.locale, &self.state.config.fallback_locale, key)
720    }
721}
722
723impl AppState {
724    /// The texts of `locale`, for code without a request (a job, a mail
725    /// to someone in another language).
726    pub fn lang(&self, locale: &str) -> Lang {
727        Lang::of(self, locale)
728    }
729
730    /// The texts of the [`current_locale`].
731    pub fn current_lang(&self) -> Lang {
732        Lang::of(self, &current_locale(self))
733    }
734}
735
736impl<S: Send + Sync> FromRequestParts<S> for Lang {
737    type Rejection = Infallible;
738
739    async fn from_request_parts(parts: &mut Parts, _: &S) -> std::result::Result<Self, Infallible> {
740        let state = parts
741            .extensions
742            .get::<AppState>()
743            .cloned()
744            .expect("the auth middleware puts AppState in every request");
745        Ok(Self {
746            locale: request_locale(&parts.extensions, &state),
747            state,
748        })
749    }
750}
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755
756    /// Renox's own texts: English, English for a language without its own,
757    /// and the key itself for a text there isn't.
758    #[test]
759    fn builtin_texts_fall_back_to_english_then_the_key() {
760        assert_eq!(super::builtin_text("en", "ui.cancel"), "Cancel");
761        assert_eq!(super::builtin_text("xx", "ui.cancel"), "Cancel");
762        assert_eq!(
763            super::builtin_text("en", "ui.no-such-text"),
764            "ui.no-such-text"
765        );
766    }
767
768    #[test]
769    fn accept_language_picks_the_first_available() {
770        let ours = |l: &str| matches!(l, "en" | "es" | "pt-br");
771        assert_eq!(
772            from_accept_language("es-MX,es;q=0.9,en;q=0.8", ours).as_deref(),
773            Some("es")
774        );
775        assert_eq!(
776            from_accept_language("fr, en;q=0.5", ours).as_deref(),
777            Some("en")
778        );
779        assert_eq!(
780            from_accept_language("en;q=0.2, es;q=0.9", ours).as_deref(),
781            Some("es")
782        );
783        assert_eq!(
784            from_accept_language("PT-BR", ours).as_deref(),
785            Some("pt-br")
786        );
787        assert_eq!(from_accept_language("de, fr;q=0.8", ours), None);
788        assert_eq!(from_accept_language("es;q=0, *", ours), None);
789        assert_eq!(from_accept_language("", ours), None);
790        assert_eq!(from_accept_language("en;q=abc", ours), None);
791    }
792
793    #[test]
794    fn formats_placeholders_and_plurals() {
795        let p = [("name", "coffee".to_owned())];
796        assert_eq!(format(":Name, :name!", &p, None), "Coffee, coffee!");
797        assert_eq!(
798            format("One product|:count products", &[], Some(1)),
799            "One product"
800        );
801        assert_eq!(
802            format("One product|:count products", &[], Some(4)),
803            "4 products"
804        );
805        // Like Laravel: 0 and negative counts take the plural form.
806        assert_eq!(
807            format("One product|:count products", &[], Some(0)),
808            "0 products"
809        );
810        assert_eq!(format("one|:count many", &[], Some(-1)), "-1 many");
811        // Without a plural form the text is used as it is.
812        assert_eq!(format(":count item", &[], Some(3)), "3 item");
813        // Laravel's ranges.
814        let ranged = "{0} No messages yet|[1,5] :count messages|[6,*] Many messages (:count)";
815        assert_eq!(format(ranged, &[], Some(0)), "No messages yet");
816        assert_eq!(format(ranged, &[], Some(1)), "1 messages");
817        assert_eq!(format(ranged, &[], Some(5)), "5 messages");
818        assert_eq!(format(ranged, &[], Some(40)), "Many messages (40)");
819        assert_eq!(
820            format("[*,-1] minus|{0} zero|[1,*] :count", &[], Some(-3)),
821            "minus"
822        );
823        // No range matches: the last text.
824        assert_eq!(format("{1} one|{2} two", &[], Some(7)), "two");
825        // Without a count, ranged texts are left alone.
826        assert_eq!(format("{0} a|[1,*] b", &[], None), "{0} a|[1,*] b");
827        assert_eq!(
828            format(
829                ":name :names",
830                &[("name", "a".into()), ("names", "b".into())],
831                None
832            ),
833            "a b"
834        );
835    }
836
837    #[test]
838    fn flattens_nested_files() {
839        let json = serde_json::json!({ "a": { "b": "x", "n": 3 }, "c": "y" });
840        let mut out = HashMap::new();
841        flatten("", &json, &mut out);
842        assert_eq!(out["a.b"], "x");
843        assert_eq!(out["a.n"], "3");
844        assert_eq!(out["c"], "y");
845    }
846
847    /// Parts that aren't ranges, or whose ranges don't read, are skipped;
848    /// with no match the last part is used as it is.
849    #[test]
850    fn ranges_that_dont_read_are_skipped() {
851        let text = "{0 unclosed|[1 no end|plain|[a,b] letters|[1,2] few|the rest";
852        assert_eq!(choose_range(text, 1), "few");
853        assert_eq!(choose_range(text, 9), "the rest");
854    }
855
856    /// A lang file broken while the app runs (a half-saved edit): the
857    /// previous translations stay until the file reads again.
858    #[test]
859    fn a_lang_file_broken_while_reloading_keeps_the_previous_texts() {
860        let dir = tempfile::tempdir().unwrap();
861        let file = dir.path().join("es.json");
862        std::fs::write(&file, r#"{"hi": "Hola"}"#).unwrap();
863        let translator = Translator::load(dir.path(), true).unwrap();
864        assert_eq!(translator.get("es", "en", "hi"), "Hola");
865        std::fs::write(&file, r#"{"hi": "Hola de nue"#).unwrap();
866        std::thread::sleep(RELOAD_CHECK + Duration::from_millis(100));
867        assert_eq!(translator.get("es", "en", "hi"), "Hola");
868        std::fs::write(&file, r#"{"hi": "Hola de nuevo"}"#).unwrap();
869        std::thread::sleep(RELOAD_CHECK + Duration::from_millis(100));
870        assert_eq!(translator.get("es", "en", "hi"), "Hola de nuevo");
871    }
872}