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