Files
homeassistant_espscreen/tools/i18n_cldr.mjs
T
Max Gramser 125346d94e Notation like Home Assistant, each screen in its own language, and the language in every build
From the end-to-end test on the bench boards and an independent review:
- Units spaced as Home Assistant does (54% / 54 % in German and French, 18°, 21.5 °C), in the firmware and the app.
- Grouping from five digits where the language does (1234 but 12.345 in Spanish, Polish, Portuguese): the layout
  message carries group_min and percent_space; an unknown number style no longer refuses a layout.
- Automatic follows Home Assistant's own language for the clock and numbers, also without a translation (Swedish:
  24 hours, 1 234,5; pt-BR: 1.234,5): regions.json from the CLDR for all 63 languages Home Assistant offers.
- 12-hour clock: the top bar says 3:48 PM, the clock card 3:48 (not 03:48), Last runs follow the clock.
- The climate setpoint font has the decimal comma; the value fonts have ², ³ and µ (m³ was a box).
- Weather days: at most three letters without the dot (mié, sáb, czw); the screen took three bytes.
- Texts fall back per key through the base language (pt-BR -> pt -> en) in the firmware and the app.
- Each screen gets the app's words in the language its firmware speaks; firmware from before the languages gets
  English and its own letters, not boxes.
- set_language re-reads what it writes (indentation, comments, quoted keys, CRLF, !include) and every build writes
  the language first; the 24-hour pin no longer hits a fresh install and waits for the old clock switches.
- Tests: region and notation, plural parity with the C++ rules, set_language edge cases, the pin, PUT /api/language.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 16:52:34 +02:00

55 lines
4.0 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Day and month names, date orders, the decimal mark and the clock of languages, from the Unicode CLDR that Node's Intl
// carries (app 0.2.90); tools/i18n.py cldr writes them into the translations. Prints JSON: {code: {...}}.
const codes = process.argv.slice(2);
const space = (text) => text.replace(/[   ]/g, ' ');
// 2026-03-01 is a Sunday: day i of that week is Sunday + i, as the screens count (ESPHome's day_of_week 1 is Sunday).
const day = (i) => new Date(Date.UTC(2026, 2, 1 + i, 12));
const month = (m) => new Date(Date.UTC(2026, m, 14, 12));
const pattern = (code, options, names) => {
const parts = new Intl.DateTimeFormat(code, { ...options, timeZone: 'UTC' }).formatToParts(new Date(Date.UTC(2026, 8, 14, 14, 5)));
let out = '';
for (const part of parts) {
if (names[part.type]) out += `{${names[part.type]}}`;
else if (part.type === 'literal') out += space(part.value);
}
return out.replace(/\{hour\}:\{minute\}/, '{time}').replace(/\{hour\}\.\{minute\}/, '{time}');
};
const result = {};
for (const code of codes) {
const short = new Intl.DateTimeFormat(code, { weekday: 'short', timeZone: 'UTC' });
const long = new Intl.DateTimeFormat(code, { weekday: 'long', timeZone: 'UTC' });
const monthLong = (m) => new Intl.DateTimeFormat(code, { day: 'numeric', month: 'long', timeZone: 'UTC' }).formatToParts(month(m)).find((p) => p.type === 'month').value;
const monthShort = (m) => new Intl.DateTimeFormat(code, { day: 'numeric', month: 'short', timeZone: 'UTC' }).formatToParts(month(m)).find((p) => p.type === 'month').value;
const numbers = new Intl.NumberFormat(code);
const parts = numbers.formatToParts(12345.5);
const decimal = parts.find((p) => p.type === 'decimal').value;
const group = space(parts.find((p) => p.type === 'group').value);
const period = (hour) => (new Intl.DateTimeFormat(code, { hour: 'numeric', hour12: true, timeZone: 'UTC' })
.formatToParts(new Date(Date.UTC(2026, 0, 1, hour))).find((p) => p.type === 'dayPeriod') || { value: hour < 12 ? 'AM' : 'PM' }).value;
const cycle = new Intl.DateTimeFormat(code, { hour: 'numeric', timeZone: 'UTC' }).resolvedOptions().hourCycle;
// A locale that writes a short date with the month as a number (Portugal: "14/09") gets the standalone short names
// and "day month" instead: the screens' calendar shows a month's name beside the big day.
const numericMonth = /^\d+$/.test(monthShort(8));
const standalone = (m) => space(new Intl.DateTimeFormat(code, { month: 'short', timeZone: 'UTC' }).format(month(m)));
const withNames = (text) => (numericMonth ? text.replace(/\{day\}\W*\{month\}/, '{day} {month}') : text);
result[code] = {
clock: cycle === 'h12' || cycle === 'h11' ? '12' : '24',
number: { decimal, group, group_min: numbers.format(1234).includes(parts.find((p) => p.type === 'group').value) ? '1' : '2' },
time: { am: space(period(9)), pm: space(period(21)) },
date: {
weekdays: [0, 1, 2, 3, 4, 5, 6].map((i) => long.format(day(i))),
weekdays_short: [0, 1, 2, 3, 4, 5, 6].map((i) => short.format(day(i))),
// The narrow columns of the weather card: the short name without its dot, at most three letters ("mié", "sáb",
// "czw", "dom"); a screen shows no more.
weekdays_min: [0, 1, 2, 3, 4, 5, 6].map((i) => [...short.format(day(i)).replace(/\.$/, '')].slice(0, 3).join('')),
months: [...Array(12).keys()].map(monthLong),
months_short: [...Array(12).keys()].map(numericMonth ? standalone : monthShort),
top_bar: withNames(pattern(code, { weekday: 'short', day: 'numeric', month: 'short' }, { weekday: 'weekday', day: 'day', month: 'month' })),
full: pattern(code, { weekday: 'long', day: 'numeric', month: 'long' }, { weekday: 'weekday', day: 'day', month: 'month' }),
day_month: withNames(pattern(code, { day: 'numeric', month: 'short' }, { day: 'day', month: 'month' })),
weekday_time: pattern(code, { weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }, { weekday: 'weekday', hour: 'hour', minute: 'minute' }),
},
};
}
console.log(JSON.stringify(result, null, 1));