1use crate::budget::{BudgetStatus, Period, ReserveStatus, Verdict};
14use crate::projects::{ProjectStore, RouteField, RouteMatch};
15use crate::money::Money;
16use crate::codes::CodeKind;
17use crate::porch::{Reason, Summary};
18use crate::trust::{Proof, Trust};
19use fluent_bundle::concurrent::FluentBundle;
20use fluent_bundle::{FluentArgs, FluentResource};
21use jiff::Zoned;
22use jiff::civil::Date;
23use unic_langid::LanguageIdentifier;
24
25pub const LANGUAGES: &[(&str, &str)] = &[
27 ("en", include_str!("../locales/en/sioul.ftl")),
28 ("fr", include_str!("../locales/fr/sioul.ftl")),
29];
30
31type Bundle = FluentBundle<FluentResource>;
32
33pub fn args() -> FluentArgs<'static> {
35 FluentArgs::new()
36}
37
38pub struct Translator {
40 language: String,
41 bundle: Bundle,
42 fallback: std::sync::OnceLock<Bundle>,
47}
48
49impl Translator {
50 pub fn new(requested: &str) -> Translator {
52 let wanted = base_language(requested);
53 let language = if LANGUAGES.iter().any(|(l, _)| *l == wanted) { wanted } else { "en".to_string() };
54 Translator { bundle: bundle(&language), fallback: std::sync::OnceLock::new(), language }
55 }
56
57 fn fallback(&self, id: &str) -> Option<&Bundle> {
60 if self.language == "en" {
61 return None;
62 }
63 if let Some(english) = self.fallback.get() {
64 return Some(english);
65 }
66 defined(LANGUAGES[0].1, id).then(|| self.fallback.get_or_init(|| bundle("en")))
67 }
68
69 pub fn language(&self) -> &str {
71 &self.language
72 }
73
74 pub fn text(&self, id: &str, args: Option<&FluentArgs>) -> String {
76 format(&self.bundle, id, None, args)
77 .or_else(|| self.fallback(id).and_then(|english| format(english, id, None, args)))
78 .unwrap_or_else(|| id.to_string())
79 }
80
81 pub fn attribute(&self, id: &str, attribute: &str) -> Option<String> {
84 format(&self.bundle, id, Some(attribute), None)
85 .or_else(|| self.fallback(id).and_then(|english| format(english, id, Some(attribute), None)))
86 }
87
88 pub fn count(&self, n: usize, capital: bool, feminine: bool) -> String {
90 let id = format!("count-{n}");
91 let wanted: &[&str] = match (capital, feminine) {
92 (true, true) => &["fcap", "cap"],
93 (true, false) => &["cap"],
94 (false, true) => &["f"],
95 (false, false) => &[],
96 };
97 wanted
98 .iter()
99 .find_map(|a| format(&self.bundle, &id, Some(a), None))
100 .or_else(|| format(&self.bundle, &id, None, None))
101 .unwrap_or_else(|| n.to_string())
102 }
103
104 pub fn counted(&self, n: usize) -> FluentArgs<'static> {
106 let mut args = FluentArgs::new();
107 args.set("n", n);
108 args.set("count", self.count(n, false, false));
109 args.set("Count", self.count(n, true, false));
110 args.set("countf", self.count(n, false, true));
111 args.set("Countf", self.count(n, true, true));
112 args
113 }
114
115 pub fn summary(&self, s: &Summary, projects: Option<&ProjectStore>) -> String {
117 if s.total == 0 {
118 let mut sentences = vec![self.text("summary-nothing", None)];
119 self.push_counted(&mut sentences, "summary-low", s.low);
120 return sentences.join(" ");
121 }
122 let mut sentences = vec![self.text("summary-total", Some(&self.counted(s.total)))];
123 self.push_counted(&mut sentences, "summary-codes", s.codes);
124 if !s.projects.is_empty() {
125 sentences.push(self.project_sentence(s, projects));
126 }
127 self.push_counted(&mut sentences, "summary-people", s.people);
128 self.push_counted(&mut sentences, "summary-screener", s.screener);
129 self.push_counted(&mut sentences, "summary-filed", s.filed);
130 self.push_counted(&mut sentences, "summary-set-aside", s.set_aside);
131 self.push_counted(&mut sentences, "summary-low", s.low);
132 sentences.join(" ")
133 }
134
135 fn push_counted(&self, sentences: &mut Vec<String>, id: &str, n: usize) {
136 if n > 0 {
137 sentences.push(self.text(id, Some(&self.counted(n))));
138 }
139 }
140
141 fn project_sentence(&self, s: &Summary, projects: Option<&ProjectStore>) -> String {
143 let n: usize = s.projects.iter().map(|(_, k)| k).sum();
144 let several = s.projects.len() > 1;
145 let parts: Vec<String> = s
146 .projects
147 .iter()
148 .map(|(id, k)| {
149 let title = project_title(id, projects);
150 if !several {
151 return title;
152 }
153 let mut args = self.counted(*k);
154 args.set("title", title);
155 self.text("summary-project-part", Some(&args))
156 })
157 .collect();
158 let mut args = self.counted(n);
159 args.set("k", s.projects.len());
160 args.set("projects", parts.join(", "));
161 self.text("summary-projects", Some(&args))
162 }
163
164 pub fn reason(&self, reason: &Reason, projects: Option<&ProjectStore>) -> String {
166 match reason {
167 Reason::Trust(proof) => self.text(proof_id(*proof), None),
168 Reason::Forged => self.text("reason-forged", None),
169 Reason::Spam { source, score } => self.spam_reason(source, *score),
170 Reason::LearnedSpam { .. } => self.text("reason-learned-spam", None),
172 Reason::Unsure { .. } => self.text("reason-unsure", None),
173 Reason::LearnedHam { .. } => self.text("reason-learned-ham", None),
174 Reason::MovedToJunk => self.text("reason-moved-to-junk", None),
175 Reason::Blocked => self.text("reason-blocked", None),
176 Reason::Impersonation { brand, domain } => {
177 let mut args = FluentArgs::new();
178 args.set("brand", brand.clone());
179 args.set("domain", domain.clone());
180 self.text("reason-impersonation", Some(&args))
181 }
182 Reason::ExpiresSoon(kind) => self.text("reason-expires-soon", Some(&self.kind_args(*kind))),
183 Reason::UnverifiedCode(kind) => self.text("reason-unverified-code", Some(&self.kind_args(*kind))),
184 Reason::Project { project_id, matched } => {
185 let mut args = FluentArgs::new();
186 args.set("title", project_title(project_id, projects));
187 args.set("why", matched.iter().map(|m| self.route_match(m)).collect::<Vec<_>>().join(", "));
188 self.text("reason-project", Some(&args))
189 }
190 Reason::Hostile => self.text("reason-hostile", None),
191 Reason::Public(topic) => {
192 let mut args = FluentArgs::new();
193 args.set("topic", self.topic(*topic));
194 self.text("reason-public", Some(&args))
195 }
196 Reason::Newsletter => self.text("reason-newsletter", None),
197 Reason::Automatic => self.text("reason-automatic", None),
198 Reason::FirstMessage => self.text("reason-first-message", None),
199 Reason::NotAuthenticated => self.text("reason-not-authenticated", None),
200 Reason::KnownPerson => self.text("reason-known", None),
201 Reason::FromYourself => self.text("reason-from-yourself", None),
202 Reason::LowPriority => self.text("reason-low-priority", None),
203 }
204 }
205
206 fn spam_reason(&self, source: &str, score: Option<f32>) -> String {
207 let mut args = FluentArgs::new();
208 args.set("source", source.to_string());
209 let id = score.map_or("reason-spam", |s| {
210 args.set("score", self.decimal(s));
211 "reason-spam-score"
212 });
213 self.text(id, Some(&args))
214 }
215
216 pub fn decimal(&self, value: f32) -> String {
218 format!("{value:.1}").replace('.', &self.text("decimal-separator", None))
219 }
220
221 fn route_match(&self, m: &RouteMatch) -> String {
222 let id = match m.field {
223 RouteField::SenderDomain => "route-sender-domain",
224 RouteField::Sender => "route-sender",
225 RouteField::Subject => "route-subject",
226 RouteField::Text => "route-text",
227 RouteField::Attachment => "route-attachment",
228 RouteField::Thread => "route-thread",
229 };
230 let mut args = FluentArgs::new();
231 args.set("value", m.value.clone());
232 self.text(id, Some(&args))
233 }
234
235 fn kind_args(&self, kind: CodeKind) -> FluentArgs<'static> {
237 let id = kind_id(kind);
238 let mut args = FluentArgs::new();
239 args.set("kind", self.text(id, None));
240 args.set("Kind", self.attribute(id, "cap").unwrap_or_else(|| self.text(id, None)));
241 args.set("akind", self.attribute(id, "a").unwrap_or_else(|| self.text(id, None)));
242 args
243 }
244
245 pub fn right_now(&self, kind: CodeKind, sender: &str, trust: Trust, code: Option<&str>, minutes: Option<u32>) -> String {
247 let mut args = self.kind_args(kind);
248 args.set("sender", sender.to_string());
249 args.set("trust", self.trust(trust));
250 let id = code.map_or("right-now-item", |c| {
251 args.set("code", c.to_string());
252 "right-now-item-code"
253 });
254 let line = self.text(id, Some(&args));
255 minutes.map_or(line.clone(), |m| format!("{line}, {}", self.validity(m)))
256 }
257
258 pub fn validity(&self, minutes: u32) -> String {
260 let mut args = FluentArgs::new();
261 let id = if minutes < 120 {
262 args.set("minutes", minutes);
263 "right-now-valid"
264 } else if minutes < 48 * 60 {
265 args.set("hours", minutes.div_ceil(60));
266 "right-now-valid-hours"
267 } else {
268 args.set("days", minutes.div_ceil(24 * 60));
269 "right-now-valid-days"
270 };
271 self.text(id, Some(&args))
272 }
273
274 pub fn trust(&self, trust: Trust) -> String {
276 let id = match trust {
277 Trust::Verified => "trust-verified",
278 Trust::Unverified => "trust-unverified",
279 Trust::Forged => "trust-forged",
280 };
281 self.text(id, None)
282 }
283
284 pub fn when(&self, at: &Zoned) -> String {
286 let mut args = FluentArgs::new();
287 args.set("date", self.date(at, false));
288 self.text("when-on", Some(&args))
289 }
290
291 pub fn date(&self, at: &Zoned, short: bool) -> String {
293 let weekday = format!("weekday-{}", at.weekday().to_monday_one_offset());
294 let month = format!("month-{}", at.month());
295 let name = |id: &str| if short { self.attribute(id, "short").unwrap_or_else(|| self.text(id, None)) } else { self.text(id, None) };
296 let mut args = FluentArgs::new();
297 args.set("weekday", name(&weekday));
298 args.set("day", at.day());
299 args.set("month", name(&month));
300 args.set("time", at.strftime("%H:%M").to_string());
301 self.text(if short { "date-short" } else { "date-long" }, Some(&args))
302 }
303}
304
305impl Translator {
306 pub fn money(&self, m: Money) -> String {
308 let mut args = FluentArgs::new();
309 args.set("value", self.digits(m));
310 self.text(if m.is_negative() { "money-negative" } else { "money-positive" }, Some(&args))
311 }
312
313 pub fn number(&self, m: Money) -> String {
315 let sign = if m.is_negative() { "−" } else { "" };
316 format!("{sign}{}", self.digits(m))
317 }
318
319 fn digits(&self, m: Money) -> String {
321 let (units, cents) = m.split();
322 let grouped = group_thousands(units, &self.text("thousands-separator", None));
323 if cents == 0 { grouped } else { format!("{grouped}{}{cents:02}", self.text("decimal-separator", None)) }
324 }
325
326 pub fn topic(&self, topic: crate::shield::Topic) -> String {
328 let id = match topic {
329 crate::shield::Topic::Work => "topic-work",
330 crate::shield::Topic::Support => "topic-support",
331 crate::shield::Topic::Press => "topic-press",
332 crate::shield::Topic::Thanks => "topic-thanks",
333 crate::shield::Topic::Donation => "topic-donation",
334 crate::shield::Topic::Other => "topic-other",
335 };
336 self.text(id, None)
337 }
338
339 pub fn day(&self, d: Date) -> String {
341 let mut args = FluentArgs::new();
342 args.set("weekday", self.text(&format!("weekday-{}", d.weekday().to_monday_one_offset()), None));
343 args.set("day", d.day());
344 args.set("month", self.text(&format!("month-{}", d.month()), None));
345 self.text("date-day", Some(&args))
346 }
347
348 pub fn day_in(&self, d: Date, today: Date) -> String {
350 if d.year() == today.year() { self.day(d) } else { format!("{} {}", self.day(d), d.year()) }
351 }
352
353 pub fn weekday_short(&self, d: Date) -> String {
355 let id = format!("weekday-{}", d.weekday().to_monday_one_offset());
356 self.attribute(&id, "short").unwrap_or_else(|| self.text(&id, None))
357 }
358
359 pub fn month_short(&self, d: Date) -> String {
361 let id = format!("month-{}", d.month());
362 self.attribute(&id, "short").unwrap_or_else(|| self.text(&id, None))
363 }
364
365 pub fn day_month(&self, d: Date) -> String {
367 let mut args = FluentArgs::new();
368 args.set("day", d.day());
369 args.set("month", self.text(&format!("month-{}", d.month()), None));
370 self.text("date-day-month", Some(&args))
371 }
372
373 pub fn month_year(&self, d: Date) -> String {
375 let mut args = FluentArgs::new();
376 args.set("month", self.text(&format!("month-{}", d.month()), None));
377 args.set("year", d.year().to_string());
378 self.text("month-year", Some(&args))
379 }
380
381 pub fn budget_lines(&self, title: &str, period: Period, s: &BudgetStatus, reserve_title: Option<&str>) -> Vec<String> {
383 let end = self.day_month(s.end);
384 let mut verdict_args = FluentArgs::new();
385 verdict_args.set("gap", self.money(s.gap.abs()));
386 let verdict = match s.verdict {
387 Verdict::Better => "verdict-better",
388 Verdict::AsPlanned => "verdict-as-planned",
389 Verdict::Short => "verdict-short",
390 };
391 let mut args = FluentArgs::new();
392 args.set("title", title.to_string());
393 args.set("period", self.text(if period == Period::Month { "period-month" } else { "period-year" }, None));
394 args.set("so_far", self.money(s.so_far));
395 args.set("projected", self.money(s.projected));
396 args.set("end", end.clone());
397 args.set("target", self.money(s.target));
398 args.set("verdict", self.text(verdict, Some(&verdict_args)));
399 let mut lines = vec![self.text("budget-line", Some(&args))];
400 if s.earmarked != Money::ZERO {
401 let mut args = FluentArgs::new();
402 args.set("amount", self.money(s.earmarked));
403 lines.push(self.text("budget-earmarked", Some(&args)));
404 }
405 if s.opening != Money::ZERO {
406 let mut args = FluentArgs::new();
407 args.set("opening", self.money(s.opening));
408 lines.push(self.text("budget-opening", Some(&args)));
409 }
410 if let (Some((_, amount)), Some(reserve)) = (&s.reserve_transfer, reserve_title) {
411 let mut args = FluentArgs::new();
412 args.set("reserve", reserve.to_string());
413 args.set("amount", self.money(amount.abs()));
414 args.set("end", end);
415 lines.push(self.text(if amount.is_negative() { "budget-swept" } else { "budget-drawn" }, Some(&args)));
416 }
417 lines
418 }
419
420 pub fn reserve_lines(&self, title: &str, s: &ReserveStatus, today: Date) -> Vec<String> {
422 let mut args = FluentArgs::new();
423 args.set("title", title.to_string());
424 args.set("balance", self.money(s.balance_now));
425 args.set("month_done", self.money(s.month_done));
426 args.set("month_planned", self.money(s.month_planned));
427 args.set("year_end_date", self.day_month(today.last_of_year()));
428 args.set("year_end", self.money(s.year_end));
429 let mut lines = vec![self.text("reserve-line", Some(&args))];
430 match s.months_left {
431 Some(months) => {
432 let mut args = FluentArgs::new();
434 args.set("months", months);
435 lines.push(self.text("reserve-lasts-months", Some(&args)));
436 }
437 None => lines.push(self.text("reserve-quiet", None)),
438 }
439 lines
440 }
441}
442
443fn group_thousands(n: i64, separator: &str) -> String {
445 let digits = n.to_string();
446 let mut out = String::new();
447 for (i, c) in digits.chars().enumerate() {
448 if i > 0 && (digits.len() - i) % 3 == 0 {
449 out.push_str(separator);
450 }
451 out.push(c);
452 }
453 out
454}
455
456fn proof_id(proof: Proof) -> &'static str {
457 match proof {
458 Proof::DmarcPass => "reason-dmarc-pass",
459 Proof::DmarcFail => "reason-dmarc-fail",
460 Proof::DmarcFailNoPolicy => "reason-dmarc-fail-no-policy",
461 Proof::DmarcFailList => "reason-dmarc-fail-list",
462 Proof::DkimPass => "reason-dkim-pass",
463 Proof::BothFailed => "reason-both-failed",
464 Proof::NothingProves => "reason-nothing-proves",
465 Proof::NoResults => "reason-no-results",
466 }
467}
468
469fn kind_id(kind: CodeKind) -> &'static str {
470 match kind {
471 CodeKind::Code => "code-kind-code",
472 CodeKind::Password => "code-kind-password",
473 CodeKind::PasswordReset => "code-kind-reset",
474 CodeKind::SignInLink => "code-kind-link",
475 CodeKind::Confirmation => "code-kind-confirm",
476 }
477}
478
479fn project_title(id: &str, projects: Option<&ProjectStore>) -> String {
480 projects.and_then(|c| c.get(id)).map_or_else(|| id.to_string(), |c| c.title.clone())
481}
482
483fn base_language(code: &str) -> String {
485 code.split(['_', '-', '.', '@']).next().unwrap_or("").trim().to_ascii_lowercase()
486}
487
488pub fn system_language() -> String {
490 ["LC_ALL", "LC_MESSAGES", "LANG"]
491 .iter()
492 .filter_map(|v| std::env::var(v).ok())
493 .map(|v| base_language(&v))
494 .find(|l| !l.is_empty() && l != "c" && l != "posix")
495 .unwrap_or_else(|| "en".to_string())
496}
497
498pub fn percent(p: f32) -> String {
501 let p = (f64::from(p) * 100.0 + 1e-4).floor().clamp(0.0, 100.0);
502 format!("{p:.0}")
503}
504
505fn bundle(language: &str) -> Bundle {
506 let id: LanguageIdentifier = language.parse().unwrap_or_default();
507 let mut bundle = Bundle::new_concurrent(vec![id]);
508 bundle.set_use_isolating(false);
511 let source = LANGUAGES.iter().find(|(l, _)| *l == language).map_or("", |(_, s)| *s);
512 let resource = FluentResource::try_new(source.to_string()).unwrap_or_else(|(partial, _)| partial);
513 let _ = bundle.add_resource(resource);
515 bundle
516}
517
518fn defined(source: &str, id: &str) -> bool {
521 source.match_indices(id).any(|(at, _)| {
522 (at == 0 || source.as_bytes()[at - 1] == b'\n') && source[at + id.len()..].trim_start_matches([' ', '\t']).starts_with('=')
523 })
524}
525
526fn format(bundle: &Bundle, id: &str, attribute: Option<&str>, args: Option<&FluentArgs>) -> Option<String> {
527 let message = bundle.get_message(id)?;
528 let pattern = match attribute {
529 Some(a) => message.get_attribute(a)?.value(),
530 None => message.value()?,
531 };
532 let mut errors = Vec::new();
533 Some(bundle.format_pattern(pattern, args, &mut errors).into_owned())
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539
540 #[test]
541 fn every_language_parses_and_has_every_message() {
542 for (language, source) in LANGUAGES {
543 let parsed = FluentResource::try_new(source.to_string());
544 assert!(parsed.is_ok(), "{language}: syntax errors");
545 }
546 let english = FluentResource::try_new(LANGUAGES[0].1.to_string()).unwrap();
547 let ids: Vec<String> = english
548 .entries()
549 .filter_map(|e| match e {
550 fluent_syntax::ast::Entry::Message(m) => Some(m.id.name.to_string()),
551 _ => None,
552 })
553 .collect();
554 for (language, _) in LANGUAGES {
555 let t = Translator::new(language);
556 for id in &ids {
557 assert!(t.bundle.has_message(id), "{language} lacks {id}");
558 }
559 }
560 }
561
562 #[test]
564 fn no_message_twice() {
565 for (language, source) in LANGUAGES {
566 let parsed = FluentResource::try_new(source.to_string()).unwrap();
567 let mut seen = std::collections::HashSet::new();
568 for entry in parsed.entries() {
569 if let fluent_syntax::ast::Entry::Message(m) = entry {
570 assert!(seen.insert(m.id.name.to_string()), "{language}: {} twice", m.id.name);
571 }
572 }
573 }
574 }
575
576 #[test]
579 fn english_parsed_only_when_needed() {
580 assert!(defined("a = 1\nab = 2\n", "ab"));
581 assert!(defined("ab=2", "ab"));
582 assert!(!defined("ab = 2\n", "b"));
583 assert!(!defined("a-b = 2\n", "a"));
584 assert!(!defined("# a = 1\n", "a"));
585 let fr = Translator::new("fr");
586 assert_eq!(fr.text("count-1", None), "un");
587 let past = format!("count-{}", 13);
589 assert_eq!(fr.text(&past, None), past);
590 assert!(fr.fallback.get().is_none());
591 assert!(Translator::new("en").fallback("count-1").is_none());
592 }
593
594 #[test]
595 fn counts_agree_with_their_noun() {
596 let fr = Translator::new("fr_FR.UTF-8");
597 assert_eq!(fr.language(), "fr");
598 assert_eq!(fr.count(1, true, true), "Une");
599 assert_eq!(fr.count(1, true, false), "Un");
600 assert_eq!(fr.count(2, true, true), "Deux");
601 assert_eq!(fr.count(20, false, false), "20");
602 assert_eq!(Translator::new("de").language(), "en");
603 }
604
605 #[test]
606 fn money_in_each_language() {
607 assert_eq!(Translator::new("en").money(Money(-123456)), "−€1,234.56");
608 assert_eq!(Translator::new("fr").money(Money(-123456)), "−1\u{202f}234,56\u{a0}€");
609 assert_eq!(Translator::new("fr").money(Money(25000)), "250\u{a0}€");
610 }
611
612 #[test]
613 fn months_short_and_counted() {
614 let june: Date = "2026-06-01".parse().unwrap();
615 let july: Date = "2026-07-01".parse().unwrap();
616 let fr = Translator::new("fr");
617 assert_ne!(fr.month_short(june), fr.month_short(july), "juin and juil. told apart");
618 let status = |months: i64| ReserveStatus { id: String::new(), balance_now: Money::ZERO, month_done: Money::ZERO, month_planned: Money::ZERO, year_done: Money::ZERO, year_planned: Money::ZERO, year_end: Money::ZERO, months_left: Some(months) };
619 let en = Translator::new("en");
620 assert_eq!(en.reserve_lines("Savings", &status(1), june)[1], "At this pace it lasts about a month.");
621 assert_eq!(en.reserve_lines("Savings", &status(16), june)[1], "At this pace it lasts about 16 months.");
622 assert_eq!(fr.reserve_lines("Livret", &status(0), june)[1], "À ce rythme, elle est déjà à son plancher.");
623 assert_eq!(fr.reserve_lines("Livret", &status(1), june)[1], "À ce rythme, elle tient environ un mois.");
624 }
625
626 #[test]
627 fn dates_in_each_language() {
628 let at: Zoned = "2026-10-06T10:00[Europe/Paris]".parse().unwrap();
629 assert_eq!(Translator::new("en").when(&at), "on Tuesday 6 October at 10:00");
630 assert_eq!(Translator::new("fr").when(&at), "le mardi 6 octobre à 10:00");
631 assert_eq!(Translator::new("fr").date(&at, true), "mar. 6 oct. 10:00");
632 let first = jiff::civil::Date::constant(2026, 11, 1);
634 assert_eq!(Translator::new("fr").day_month(first), "1er novembre");
635 assert_eq!(Translator::new("en").day_month(first), "1 November");
636 }
637}