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.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
470pub 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 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
507fn has_ranges(text: &str) -> bool {
509 text.split('|')
510 .any(|part| part.trim_start().starts_with(['{', '[']))
511}
512
513fn 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 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#[derive(Debug, Clone)]
555pub(crate) struct RequestLocale(pub String);
556
557#[derive(Debug, Clone)]
560struct ContextLocale(String);
561
562thread_local! {
563 static OVERRIDE: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
565}
566
567pub 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
577pub 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
593pub fn set_current_locale(locale: &str) {
596 crate::context::set(ContextLocale(locale.to_owned()));
597}
598
599pub fn remember_locale(session: &Session, locale: &str) -> Result {
601 session.put(SESSION_KEY, locale)
602}
603
604pub(crate) async fn middleware(
607 State(state): State<AppState>,
608 mut req: Request,
609 next: Next,
610) -> Response {
611 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
643fn 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 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#[derive(Clone)]
684pub struct Lang {
685 pub locale: String,
687 pub(crate) state: AppState,
688}
689
690impl Lang {
691 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 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), ¶ms, None)
708 }
709
710 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), ¶ms, 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 pub fn lang(&self, locale: &str) -> Lang {
727 Lang::of(self, locale)
728 }
729
730 pub fn current_lang(&self) -> Lang {
732 Lang::of(self, ¤t_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 #[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 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 assert_eq!(format(":count item", &[], Some(3)), "3 item");
813 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 assert_eq!(format("{1} one|{2} two", &[], Some(7)), "two");
825 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 #[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 #[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}