1use 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";
45const 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
56pub 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 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 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 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 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 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
223pub(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
231fn 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
472pub 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 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
509fn has_ranges(text: &str) -> bool {
511 text.split('|')
512 .any(|part| part.trim_start().starts_with(['{', '[']))
513}
514
515fn 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 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#[derive(Debug, Clone, PartialEq, Eq)]
577#[non_exhaustive]
578pub struct RequestLocale(String);
579
580impl RequestLocale {
581 pub fn new(locale: impl Into<String>) -> Self {
583 Self(locale.into())
584 }
585
586 pub fn as_str(&self) -> &str {
588 &self.0
589 }
590
591 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#[derive(Debug, Clone)]
613struct ContextLocale(String);
614
615thread_local! {
616 static OVERRIDE: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
618}
619
620pub 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
630pub 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
646pub fn set_current_locale(locale: &str) {
649 crate::context::set(ContextLocale(locale.to_owned()));
650}
651
652pub fn remember_locale(session: &Session, locale: &str) -> Result {
654 session.put(SESSION_KEY, locale)
655}
656
657pub(crate) async fn middleware(
660 State(state): State<AppState>,
661 mut req: Request,
662 next: Next,
663) -> Response {
664 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
696fn 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 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#[derive(Clone)]
737pub struct Lang {
738 pub locale: String,
740 pub(crate) state: AppState,
741}
742
743impl Lang {
744 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 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), ¶ms, None)
761 }
762
763 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), ¶ms, 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 pub fn lang(&self, locale: &str) -> Lang {
780 Lang::of(self, locale)
781 }
782
783 pub fn current_lang(&self) -> Lang {
785 Lang::of(self, ¤t_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 #[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 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 assert_eq!(format(":count item", &[], Some(3)), "3 item");
866 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 assert_eq!(format("{1} one|{2} two", &[], Some(7)), "two");
878 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 #[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 #[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}