Files
luma-home/src/i18n.rs
T
Kaloyan DanchevandClaude Opus 4.6 cbea5a322f
CI / Identify a merged version PR (push) Canceled after 0s
CI / Rust checks, rendering, and native package (push) Canceled after 0s
CI / Publish release and Homebrew feed (push) Canceled after 0s
Release PR / Maintain Version Packages PR (push) Canceled after 0s
Merge Luma launcher fork into monorepo
Move trampoline files (boot-hook, watcher, overlay) into trampoline/
subdirectory. Add upstream Luma v2.3.1 source at repo root with our
additions:

- Page::Ha (4th nav tab) with placeholder empty state
- HA tab config schema and default entity mapping (ha-home/)
- Nav keys renamed: "home" → "start", new "ha" tab between Apps and Settings
- All state.rs/view.rs/main.rs/tests.rs updated for 4-tab navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-24 11:52:55 +03:00

404 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<String, BTreeMap<String, String>>;
fn messages() -> &'static Messages {
static CATALOG: OnceLock<Messages> = 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<Language, D::Error> {
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);
}
}
}