//! Bundled English, German, Spanish, Chinese, Brazilian Portuguese and Italian message catalog. use serde::{Deserialize, Deserializer, Serialize}; use std::{collections::BTreeMap, sync::OnceLock}; use time::OffsetDateTime; type Messages = BTreeMap>; fn messages() -> &'static Messages { static CATALOG: OnceLock = OnceLock::new(); CATALOG.get_or_init(|| { serde_json::from_str(include_str!("../assets/messages.json")) .expect("Bundled translations must be valid") }) } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "lowercase")] pub enum Language { #[default] En, De, Zh, Es, Pt, It, } impl Language { pub const ALL: [Self; 6] = [Self::En, Self::De, Self::Zh, Self::Es, Self::Pt, Self::It]; pub fn code(self) -> &'static str { match self { Self::En => "en", Self::De => "de", Self::Zh => "zh", Self::Es => "es", Self::Pt => "pt", Self::It => "it", } } pub fn name(self) -> &'static str { match self { Self::En => "English", Self::De => "Deutsch", Self::Zh => "简体中文", Self::Es => "Español", Self::Pt => "Português (Brasil)", Self::It => "Italiano", } } pub fn index(self) -> usize { match self { Self::En => 0, Self::De => 1, Self::Zh => 2, Self::Es => 3, Self::Pt => 4, Self::It => 5, } } pub fn t(self, key: &str) -> &str { messages() .get(key) .and_then(|entry| entry.get(self.code()).or_else(|| entry.get("en"))) .map(String::as_str) .unwrap_or(key) } pub fn format(self, key: &str, values: &[(&str, &str)]) -> String { let mut value = self.t(key).to_owned(); for (name, replacement) in values { value = value.replace(&format!("{{{name}}}"), replacement); } value } pub fn category(self, value: &str) -> &str { match value { "Streaming" => self.t("categoryStreaming"), "Music" => self.t("categoryMusic"), "Gaming" => self.t("categoryGaming"), "Utilities" => self.t("categoryUtilities"), _ => self.t("categoryUtilities"), } } pub fn wallpaper(self, id: &str) -> &str { self.t(match id { "dunes" => "monumentValley", "san-francisco" => "sanFrancisco", "northern-lights" => "northernLights", _ => id, }) } pub fn greeting(self, hour: u8, name: &str) -> String { let key = match hour { 0..=4 => "greetingNight", 5..=11 => "morning", 12..=17 => "afternoon", _ => "evening", }; let name = name.trim(); if name.is_empty() { self.t(key).to_owned() } else { self.format(&format!("{key}Named"), &[("name", name)]) } } pub fn date(self, now: OffsetDateTime) -> String { let weekday = now.weekday().number_days_from_monday() as usize; let month = now.month() as usize - 1; match self { Self::En => format!( "{}, {} {}", [ "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday" ][weekday], [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December" ][month], now.day() ) .to_uppercase(), Self::De => format!( "{}, {}. {}", [ "Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag" ][weekday], now.day(), [ "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember" ][month] ) .to_uppercase(), Self::Zh => format!( "{}月{}日星期{}", now.month() as u8, now.day(), ["一", "二", "三", "四", "五", "六", "日"][weekday] ), Self::Es => format!( "{}, {} de {}", [ "lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo" ][weekday], now.day(), [ "enero", "febrero", "marzo", "abril", "mayo", "junio", "julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre" ][month] ) .to_uppercase(), Self::Pt => format!( "{}, {} de {}", [ "segunda-feira", "terça-feira", "quarta-feira", "quinta-feira", "sexta-feira", "sábado", "domingo" ][weekday], now.day(), [ "janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro" ][month] ) .to_uppercase(), Self::It => format!( "{} {} {}", [ "lunedì", "martedì", "mercoledì", "giovedì", "venerdì", "sabato", "domenica" ][weekday], now.day(), [ "gennaio", "febbraio", "marzo", "aprile", "maggio", "giugno", "luglio", "agosto", "settembre", "ottobre", "novembre", "dicembre" ][month] ) .to_uppercase(), } } pub fn clock(self, now: OffsetDateTime, hour24: bool, seconds: bool) -> String { let base = self.clock_base(now, hour24); if !seconds { return base; } let tail = format!(":{:02}", now.second()); match base.find(' ') { Some(space) => format!("{}{tail}{}", &base[..space], &base[space..]), None => format!("{base}{tail}"), } } fn clock_base(self, now: OffsetDateTime, hour24: bool) -> String { if hour24 { return if self == Self::En { format!("{:02}:{:02}", now.hour(), now.minute()) } else { format!("{}:{:02}", now.hour(), now.minute()) }; } let hour = match now.hour() % 12 { 0 => 12, value => value, }; if self == Self::Zh { format!( "{}{hour}:{:02}", if now.hour() >= 12 { "下午" } else { "上午" }, now.minute() ) } else { format!( "{hour}:{:02} {}", now.minute(), if now.hour() >= 12 { "PM" } else { "AM" } ) } } } pub fn deserialize_language<'de, D: Deserializer<'de>>( deserializer: D, ) -> Result { Ok( match serde_json::Value::deserialize(deserializer)?.as_str() { Some("de") => Language::De, Some("zh") => Language::Zh, Some("es") => Language::Es, Some("pt") => Language::Pt, Some("it") => Language::It, _ => Language::En, }, ) } #[cfg(test)] mod tests { use super::*; #[test] fn every_message_has_all_languages_and_no_empty_values() { for (key, entry) in messages() { for language in Language::ALL { assert!( !entry .get(language.code()) .unwrap_or_else(|| panic!("Missing {key} / {}", language.code())) .is_empty() ); } } } #[test] fn every_translation_preserves_the_english_message_placeholders() { fn placeholders(message: &str) -> std::collections::BTreeSet<&str> { message .split('{') .skip(1) .map(|part| { part.split_once('}') .expect("Unclosed message placeholder") .0 }) .collect() } for (key, entry) in messages() { let expected = placeholders(&entry["en"]); for language in Language::ALL { assert_eq!( placeholders(&entry[language.code()]), expected, "Mismatched placeholders in {key} / {}", language.code() ); } } } #[test] fn localization_keeps_user_provided_names_intact() { assert_eq!( Language::De.format("favoriteAdded", &[("app", "Netflix")]), "Netflix zu Favoriten hinzugefügt." ); assert_eq!( Language::Es.format("favoriteAdded", &[("app", "Netflix")]), "Netflix añadida a favoritos." ); assert_eq!( Language::Pt.format("favoriteAdded", &[("app", "Netflix")]), "Netflix adicionado aos favoritos." ); assert_eq!( Language::Zh.format("hidden", &[("count", "3")]), "已隐藏(3)" ); assert_eq!(Language::De.category("Gaming"), "Spiele"); assert_eq!(Language::En.greeting(15, ""), "Good afternoon."); assert_eq!(Language::En.greeting(15, " Ana "), "Good afternoon, Ana."); assert_eq!(Language::Es.greeting(2, "Ana"), "¿Aún despiertos, Ana?"); assert_eq!(Language::Zh.greeting(20, "小明"), "小明,晚上好。"); } #[test] fn clock_and_date_follow_the_selected_locale() { let now = OffsetDateTime::parse( "2026-10-21T20:41:00+02:00", &time::format_description::well_known::Rfc3339, ) .unwrap(); assert_eq!(Language::De.date(now), "MITTWOCH, 21. OKTOBER"); assert_eq!(Language::Es.date(now), "MIÉRCOLES, 21 DE OCTUBRE"); assert_eq!(Language::Pt.date(now), "QUARTA-FEIRA, 21 DE OUTUBRO"); assert_eq!(Language::Zh.date(now), "10月21日星期三"); assert_eq!(Language::Zh.clock(now, false, false), "下午8:41"); assert_eq!(Language::Zh.clock(now, false, true), "下午8:41:00"); assert_eq!(Language::En.clock(now, true, false), "20:41"); assert_eq!(Language::En.clock(now, true, true), "20:41:00"); assert_eq!(Language::En.clock(now, false, true), "8:41:00 PM"); } #[test] fn portuguese_dates_include_accented_names_and_handle_leap_days() { for (date, expected) in [ ("2024-02-29T00:05:00-03:00", "QUINTA-FEIRA, 29 DE FEVEREIRO"), ("2026-03-03T12:00:00-03:00", "TERÇA-FEIRA, 3 DE MARÇO"), ("2026-10-24T20:41:00-03:00", "SÁBADO, 24 DE OUTUBRO"), ] { let now = OffsetDateTime::parse(date, &time::format_description::well_known::Rfc3339) .unwrap(); assert_eq!(Language::Pt.date(now), expected); } } }