Draw the mockup with the screens' own words, compile every text while building, and send the language with the live updates
- The page takes the screens' words its mockup draws from their own texts: screen.ha (Home Assistant's words, filled from Home Assistant), screen.time, screen.date, screen.number and screen.tile.page, per language next to _meta and editor. The copies under editor.mockup go; the rest of `screen` and all of `addon` stay out of the build. - Numbers in the mockup follow the screens' language (screen.number, with its minimum grouping) or the chosen style, as the firmware writes them. - @intlify/unplugin-vue-i18n compiles every text at build time, so the page runs vue-i18n's runtime-only build without the message compiler; every language's _meta comes with the page. Empty texts stay out while building. - The live updates carry the editor's language (api/events?language=), as an EventSource sends no headers. - web/ needs Node 22.13 or newer: vue-i18n 11.4, Vite 7 and the unplugin ask for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
const e={name:"Deutsch",english:"German",script:"latin",plural:"one_other",clock:"24",checked:!1},t={},c={_meta:e,editor:t};export{e as _meta,c as default,t as editor};
|
||||
@@ -1 +0,0 @@
|
||||
const e={name:"English (UK)",english:"English (UK)",script:"latin",plural:"one_other",clock:"24",checked:!0},n={_meta:e};export{e as _meta,n as default};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
const e={name:"Español",english:"Spanish",script:"latin",plural:"one_other",clock:"24",checked:!1},s={},t={_meta:e,editor:s};export{e as _meta,t as default,s as editor};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
const e={name:"Français",english:"French",script:"latin",plural:"one_upto_1",clock:"24",checked:!1},t={},a={_meta:e,editor:t};export{e as _meta,a as default,t as editor};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
const t={name:"Italiano",english:"Italian",script:"latin",plural:"one_other",clock:"24",checked:!1},a={},e={_meta:t,editor:a};export{t as _meta,e as default,a as editor};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
const e={name:"Nederlands",english:"Dutch",script:"latin",plural:"one_other",clock:"24",checked:!1},t={},c={_meta:e,editor:t};export{e as _meta,c as default,t as editor};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
const l={name:"Polski",english:"Polish",script:"latin",plural:"slavic_pl",clock:"24",checked:!1},s={},c={_meta:l,editor:s};export{l as _meta,c as default,s as editor};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
const t={name:"Português",english:"Portuguese",script:"latin",plural:"one_upto_1",clock:"24",checked:!1},e={},o={_meta:t,editor:e};export{t as _meta,o as default,e as editor};
|
||||
File diff suppressed because one or more lines are too long
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>ESP Screens</title>
|
||||
<script type="module" crossorigin src="./assets/index-CUv9rdNM.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-Ci37AxLI.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-C6Sfd-UM.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1236,72 +1236,8 @@
|
||||
"cleared": "The override will be empty after you save."
|
||||
},
|
||||
"mockup": {
|
||||
"date": {
|
||||
"weekdays_min": [
|
||||
"Su",
|
||||
"Mo",
|
||||
"Tu",
|
||||
"We",
|
||||
"Th",
|
||||
"Fr",
|
||||
"Sa"
|
||||
],
|
||||
"months_short": [
|
||||
"Jan",
|
||||
"Feb",
|
||||
"Mar",
|
||||
"Apr",
|
||||
"May",
|
||||
"Jun",
|
||||
"Jul",
|
||||
"Aug",
|
||||
"Sep",
|
||||
"Oct",
|
||||
"Nov",
|
||||
"Dec"
|
||||
],
|
||||
"top_bar": "{weekday} {day} {month}"
|
||||
},
|
||||
"time": {
|
||||
"just_now": "Just now",
|
||||
"minutes_ago": "{n} min ago",
|
||||
"hours_ago": "1 hour ago | {n} hours ago",
|
||||
"yesterday": "Yesterday",
|
||||
"days_ago": "1 day ago | {n} days ago",
|
||||
"weeks_ago": "1 week ago | {n} weeks ago",
|
||||
"months_ago": "1 month ago | {n} months ago",
|
||||
"years_ago": "1 year ago | {n} years ago",
|
||||
"in_minutes": "In {n} min",
|
||||
"in_hours": "In 1 hour | In {n} hours",
|
||||
"tomorrow": "Tomorrow",
|
||||
"in_days": "In 1 day | In {n} days"
|
||||
},
|
||||
"home": "Home",
|
||||
"page_link": "Page {page} ›",
|
||||
"unknown": "Unknown",
|
||||
"unavailable": "Unavailable",
|
||||
"run": {
|
||||
"scene": "Activate",
|
||||
"script": "Run",
|
||||
"press": "Press"
|
||||
},
|
||||
"weather": {
|
||||
"clear_night": "Clear, night",
|
||||
"cloudy": "Cloudy",
|
||||
"exceptional": "Exceptional",
|
||||
"fog": "Fog",
|
||||
"hail": "Hail",
|
||||
"lightning": "Lightning",
|
||||
"lightning_rainy": "Lightning, rainy",
|
||||
"partlycloudy": "Partly cloudy",
|
||||
"pouring": "Pouring",
|
||||
"rainy": "Rainy",
|
||||
"snowy": "Snowy",
|
||||
"snowy_rainy": "Snowy, rainy",
|
||||
"sunny": "Sunny",
|
||||
"windy": "Windy",
|
||||
"windy_variant": "Windy"
|
||||
}
|
||||
"unknown": "Unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,8 +22,6 @@ FIRMWARE = (ROOT / 'components/smart_display/header_bar.h').read_text()
|
||||
FIRMWARE += json.dumps(json.loads((ROOT / 'screen_manager/translations/en.json').read_text(encoding='utf-8'))['screen']['time'])
|
||||
TILES = (ROOT / 'components/smart_display/runtime_tiles.h').read_text()
|
||||
EDITOR = (ROOT / 'web/src/model/topbar.ts').read_text()
|
||||
# The mockup's words, in the editor's part of the translations since app 0.2.90.
|
||||
EDITOR_WORDS = json.loads((ROOT / 'screen_manager/translations/en.json').read_text(encoding='utf-8'))['editor']['mockup']
|
||||
PROFILES = ('guition-4848s040.yaml', 'home-like-2432s028.yaml')
|
||||
|
||||
def state(value, last_changed='2026-09-13T14:00:00+00:00', **attributes):
|
||||
@@ -209,7 +207,9 @@ class ParityTests(unittest.TestCase):
|
||||
for phrase in ('Just now', ' min ago', ' hour ago', ' hours ago', 'Yesterday', ' days ago', '1 week ago', ' weeks ago',
|
||||
'1 month ago', ' months ago', ' year ago', ' years ago', 'In ', 'Tomorrow'):
|
||||
self.assertIn(phrase, FIRMWARE)
|
||||
self.assertIn(phrase, json.dumps(EDITOR_WORDS['time']))
|
||||
# The editor's mockup draws the screens' own words since app 0.2.90: screen.time and screen.date of the translations.
|
||||
for key in ('screen.time.${key}', 'screen.date.top_bar', 'screen.date.weekdays_min.${now.getDay()}', 'screen.date.months_short.${now.getMonth()}'):
|
||||
self.assertIn(key, EDITOR)
|
||||
for threshold in ('3600', '86400', '172800', '604800', '2592000', '31536000'):
|
||||
self.assertIn(threshold, FIRMWARE)
|
||||
self.assertIn(threshold, EDITOR)
|
||||
@@ -217,7 +217,8 @@ class ParityTests(unittest.TestCase):
|
||||
self.assertIn('Math.max(2, Math.floor((cap * 4 + 5) / 10)), item: Math.max(6, Math.floor((cap * 125 + 50) / 100)), name: Math.max(8, Math.floor((cap * 16 + 5) / 10))', EDITOR)
|
||||
self.assertIn('width * 35 / 100', FIRMWARE)
|
||||
self.assertIn('(metrics.width * 35) / 100', EDITOR)
|
||||
self.assertEqual(header_bar.MONTHS, tuple(EDITOR_WORDS['date']['months_short']))
|
||||
english = json.loads((ROOT / 'screen_manager/translations/en.json').read_text(encoding='utf-8'))['screen']
|
||||
self.assertEqual(header_bar.MONTHS, tuple(english['date']['months_short']))
|
||||
|
||||
def test_profiles_draw_the_bar_from_the_runtime(self):
|
||||
for name in PROFILES:
|
||||
|
||||
Vendored
+5
@@ -4,3 +4,8 @@ declare module "*.vue" {
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
// Every language's _meta by its code, from screen_manager/translations (vite.config.ts, app 0.2.90).
|
||||
declare module "virtual:esp-screens-languages" {
|
||||
const languages: Record<string, { name: string; english: string; script?: string; plural: string; clock?: string; checked: boolean }>;
|
||||
export default languages;
|
||||
}
|
||||
|
||||
Generated
+2181
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -4,7 +4,7 @@
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"devEngines": {
|
||||
"runtime": { "name": "node", "version": "^20.19.0 || >=22.12.0", "onFail": "error" }
|
||||
"runtime": { "name": "node", "version": ">=22.13.0", "onFail": "error" }
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -19,6 +19,7 @@
|
||||
"vue-i18n": "^11.4.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@intlify/unplugin-vue-i18n": "^11.2.5",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/test-utils": "^2.5.1",
|
||||
"jsdom": "^26.1.0",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
// Everything around the screens: firmware updates, language and region, alerts, Claude.
|
||||
import { computed, ref } from "vue";
|
||||
import { haProfile, matchLanguage, numberText, type NumberStyle, t } from "../i18n";
|
||||
import { anyUpdating, go, installClaudeSkill, runUpdateAll, saveLanguage, setAutoUpdate, state, updateProgress } from "../store";
|
||||
import { haProfile, matchLanguage, numberText, type NumberMarks, type NumberStyle, STYLE_MARKS, t } from "../i18n";
|
||||
import { anyUpdating, go, installClaudeSkill, runUpdateAll, saveLanguage, screenLanguageMarks, setAutoUpdate, state, updateProgress } from "../store";
|
||||
|
||||
const u = computed(() => state.inventory.updates);
|
||||
const outdated = computed(() => state.inventory.screens.filter((s) => s.update?.available).length);
|
||||
@@ -42,7 +42,7 @@ const haName = computed(() => {
|
||||
});
|
||||
const unchecked = computed(() => lang.value?.languages.find((own) => own.code === lang.value!.effective && !own.checked));
|
||||
const clockName = (clock: string | undefined) => t(clock === "12" ? "editor.settings.language.clock_12" : "editor.settings.language.clock_24");
|
||||
const example = (style: NumberStyle) => numberText("1234.5", style);
|
||||
const example = (marks: NumberMarks) => numberText("1234.5", marks);
|
||||
// The user's own Home Assistant profile, where it names a clock or a number format that the screens don't use.
|
||||
const profile = haProfile();
|
||||
const different = computed(() => {
|
||||
@@ -53,7 +53,7 @@ const different = computed(() => {
|
||||
...(profile.numbers && profile.numbers !== (l.numbers_effective || "point") ? { numbers: profile.numbers } : {}),
|
||||
};
|
||||
});
|
||||
const profileText = computed(() => [different.value.clock && clockName(different.value.clock), different.value.numbers && example(different.value.numbers)]
|
||||
const profileText = computed(() => [different.value.clock && clockName(different.value.clock), different.value.numbers && example(STYLE_MARKS[different.value.numbers])]
|
||||
.filter(Boolean).join(" · "));
|
||||
const saving = ref(false);
|
||||
async function choose(field: "setting" | "clock" | "numbers", event: Event) {
|
||||
@@ -133,8 +133,8 @@ async function useProfile() {
|
||||
<div class="field">
|
||||
<label class="f-label" for="number-format">{{ t("editor.settings.language.numbers") }}</label>
|
||||
<select id="number-format" :value="lang.numbers || 'auto'" :disabled="saving" @change="choose('numbers', $event)">
|
||||
<option value="auto">{{ t("editor.settings.language.numbers_auto", { example: example(lang.numbers_effective || "point") }) }}</option>
|
||||
<option v-for="style in STYLES" :key="style" :value="style">{{ example(style) }}</option>
|
||||
<option value="auto">{{ t("editor.settings.language.numbers_auto", { example: example(screenLanguageMarks) }) }}</option>
|
||||
<option v-for="style in STYLES" :key="style" :value="style">{{ example(STYLE_MARKS[style]) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="profileText" class="actions" id="language-profile">
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
// dragged, drawn where it will land.
|
||||
import { computed, nextTick } from "vue";
|
||||
import { vDrag } from "../drag";
|
||||
import { numberText, t } from "../i18n";
|
||||
import { numberText, t, te } from "../i18n";
|
||||
import { displayName, effectiveControls, isFull, isWide, pageOf, pageTarget, SLOTS_PER_PAGE } from "../model/layout";
|
||||
import { clockText, glyph } from "../model/topbar";
|
||||
import { clock24, entityName, isSelected, liveOf, numberStyle, openTile, placeTile, removeTile, screenText, state, tileIconCp } from "../store";
|
||||
import { clock24, entityName, isSelected, liveOf, numberMarks, openTile, placeTile, removeTile, screenText, state, tileIconCp } from "../store";
|
||||
import type { Tile } from "../types";
|
||||
|
||||
const props = defineProps<{ tile: Tile; slot: number; placeholder?: boolean }>();
|
||||
@@ -37,25 +37,33 @@ const isOn = computed(() => ["light", "switch", "input_boolean", "fan"].includes
|
||||
const unit = computed(() => current.value?.a?.unit_of_measurement as string | undefined);
|
||||
const capital = (text: string) => text.charAt(0).toUpperCase() + text.slice(1).replace(/_/g, " ");
|
||||
// Numbers as the screens write them, "1,234.5" or "1.234,5" (app 0.2.90).
|
||||
const num = (value: unknown) => numberText(value as string | number, numberStyle.value);
|
||||
// Home Assistant's words for the weather states, for a Home Assistant that hands us none (editor.mockup.weather).
|
||||
const WEATHER = ["clear-night", "cloudy", "exceptional", "fog", "hail", "lightning", "lightning-rainy", "partlycloudy", "pouring", "rainy", "snowy", "snowy-rainy", "sunny", "windy", "windy-variant"];
|
||||
const weatherWord = (value: string) => (WEATHER.includes(value) ? screenText(`editor.mockup.weather.${value.replace(/-/g, "_")}`) : "");
|
||||
const num = (value: unknown) => numberText(value as string | number, numberMarks.value);
|
||||
// The screens' own words for a state where Home Assistant hands us none (screen.ha, Home Assistant's words in the
|
||||
// screens' language, app 0.2.90): a binary sensor's by its device class, on and off, and the states of the domains
|
||||
// the screen names itself. A weather's windy-variant is windy there too.
|
||||
const HA_WORDS: Record<string, string> = { climate: "climate", cover: "cover", media_player: "media", person: "person", sun: "sun", vacuum: "vacuum", weather: "weather" };
|
||||
function haWord(c: { state: string; a: Record<string, any> }) {
|
||||
const key = (path: string) => (te(`screen.ha.${path}`) ? screenText(`screen.ha.${path}`) : "");
|
||||
const value = c.state === "windy-variant" ? "windy" : c.state.replace(/-/g, "_");
|
||||
if (domain.value === "binary_sensor" && ["on", "off"].includes(value)) return key(`binary.${c.a?.device_class}_${value}`) || key(value);
|
||||
if (HA_WORDS[domain.value]) return key(`${HA_WORDS[domain.value]}.${value}`) || (["on", "off"].includes(value) ? key(value) : "");
|
||||
return ["on", "off"].includes(value) ? key(value) : "";
|
||||
}
|
||||
// A scene, script or button has no state worth a word: its state is the moment it last ran.
|
||||
const NO_STATUS = ["scene", "script", "button", "input_button"];
|
||||
// The text under the name: Home Assistant's word where it has one, the value with its unit for a sensor.
|
||||
const status = computed(() => {
|
||||
const c = current.value;
|
||||
if (!c || NO_STATUS.includes(domain.value)) return note.value;
|
||||
if (gone.value) return screenText(c.state === "unknown" ? "editor.mockup.unknown" : "editor.mockup.unavailable");
|
||||
if (gone.value) return screenText(c.state === "unknown" ? "editor.mockup.unknown" : "screen.ha.unavailable");
|
||||
const a = c.a || {};
|
||||
if (domain.value === "climate") return `${a.current_temperature !== undefined ? `${num(a.current_temperature)}° · ` : ""}${c.word || capital(c.state)}`;
|
||||
if (domain.value === "weather") return `${c.word || weatherWord(c.state) || capital(c.state)}${a.temperature !== undefined ? ` · ${num(a.temperature)}°` : ""}`;
|
||||
if (domain.value === "cover" && a.current_position !== undefined && a.current_position > 0 && a.current_position < 100) return `${c.word || capital(c.state)} · ${a.current_position} %`;
|
||||
if (domain.value === "media_player" && a.media_title) return `${c.word || capital(c.state)} · ${a.media_title}`;
|
||||
const word = c.word || haWord(c) || capital(c.state);
|
||||
if (domain.value === "climate") return `${a.current_temperature !== undefined ? `${num(a.current_temperature)}° · ` : ""}${word}`;
|
||||
if (domain.value === "weather") return `${word}${a.temperature !== undefined ? ` · ${num(a.temperature)}°` : ""}`;
|
||||
if (domain.value === "cover" && a.current_position !== undefined && a.current_position > 0 && a.current_position < 100) return `${word} · ${a.current_position} %`;
|
||||
if (domain.value === "media_player" && a.media_title) return `${word} · ${a.media_title}`;
|
||||
if (domain.value === "sensor") return `${num(c.state)}${unit.value ? ` ${unit.value}` : ""}`;
|
||||
if (domain.value === "timer") return c.word || capital(c.state);
|
||||
return c.word || capital(c.state);
|
||||
return word;
|
||||
});
|
||||
const bigValue = computed(() => (current.value && !gone.value ? num(current.value.state) : "—"));
|
||||
// The small slider's fill, from what the entity reports; off is empty, like the screen's grey fill.
|
||||
@@ -74,8 +82,9 @@ const fill = computed(() => {
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
// The key on a scene, script or button, as the screen labels it.
|
||||
const runText = computed(() => screenText(`editor.mockup.run.${["scene", "script"].includes(domain.value) ? domain.value : "press"}`));
|
||||
// The key on a scene, script or button, and the page a navigation tile opens, as the screen labels them.
|
||||
const runText = computed(() => screenText(`screen.ha.button.${({ scene: "activate", script: "run" } as Record<string, string>)[domain.value] || "press"}`));
|
||||
const pageLink = computed(() => `${screenText("screen.tile.page", { n: goesTo.value })} ›`);
|
||||
const sliderStyle = computed(() => ({ background: `linear-gradient(to right, ${fill.value ? "#ffbf38" : "#c9ccd1"} ${fill.value}%, ${fill.value ? "#fff1d3" : "#e6e8ec"} ${fill.value}%)` }));
|
||||
const volumeStyle = computed(() => ({ background: `linear-gradient(to right, #2196f3 ${fill.value}%, #d3e8fb ${fill.value}%)` }));
|
||||
const setpoint = computed(() => {
|
||||
@@ -119,7 +128,7 @@ async function onKey(e: KeyboardEvent) {
|
||||
<span class="ic mdi" :class="{ lit: isOn }">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="lead">
|
||||
<span class="nm">{{ name }}</span>
|
||||
<span v-if="goesTo" class="goto">{{ screenText("editor.mockup.page_link", { page: goesTo }) }}</span>
|
||||
<span v-if="goesTo" class="goto">{{ pageLink }}</span>
|
||||
<span v-else-if="display === 'watch'" class="big">{{ bigValue }}<small v-if="unit && !gone">{{ unit }}</small></span>
|
||||
<span v-else-if="status" class="st" :class="{ off: gone }">{{ status }}</span>
|
||||
</span>
|
||||
@@ -139,7 +148,7 @@ async function onKey(e: KeyboardEvent) {
|
||||
<span class="ic mdi" :class="{ lit: isOn }">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="tx">
|
||||
<span class="nm">{{ name }}</span>
|
||||
<span v-if="goesTo" class="goto">{{ screenText("editor.mockup.page_link", { page: goesTo }) }}</span>
|
||||
<span v-if="goesTo" class="goto">{{ pageLink }}</span>
|
||||
<span v-else-if="display === 'watch'" class="big">{{ bigValue }}<small v-if="unit && !gone">{{ unit }}</small></span>
|
||||
<span v-else-if="status" class="st" :class="{ off: gone }">{{ status }}</span>
|
||||
</span>
|
||||
@@ -167,7 +176,7 @@ async function onKey(e: KeyboardEvent) {
|
||||
<span class="ic mdi" :class="{ lit: isOn }">{{ glyph(tileIconCp(tile)) }}</span>
|
||||
<span class="tx">
|
||||
<span class="nm">{{ name }}</span>
|
||||
<span v-if="goesTo" class="goto">{{ screenText("editor.mockup.page_link", { page: goesTo }) }}</span>
|
||||
<span v-if="goesTo" class="goto">{{ pageLink }}</span>
|
||||
<span v-else-if="display !== 'watch' && status" class="st" :class="{ off: gone }">{{ status }}</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
+40
-52
@@ -1,12 +1,16 @@
|
||||
// The editor's texts (app 0.2.90). Every language is one file in screen_manager/translations, the same file the firmware
|
||||
// and the add-on read (docs/TRANSLATING.md); the editor uses its `editor` section and `_meta`, and the build leaves the
|
||||
// other sections out (vite.config.ts). English is the source and comes with the page. Another language loads the first
|
||||
// time the editor or a mockup needs it, and a text it doesn't have yet shows in English.
|
||||
// and the add-on read (docs/TRANSLATING.md). The page takes its `editor` section, and for the mockup the words the screens
|
||||
// themselves show (`screen.ha`, `time`, `date`, `number`, `tile.page`); the build leaves the rest out and compiles every
|
||||
// text (vite.config.ts). English is the source and comes with the page. Another language loads the first time the
|
||||
// editor or a mockup needs it, and a text it doesn't have yet shows in English.
|
||||
import metas from "virtual:esp-screens-languages";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import en from "../../screen_manager/translations/en.json";
|
||||
import english from "../../screen_manager/translations/en.json";
|
||||
|
||||
export type LanguageMeta = { name: string; english: string; script?: string; plural: string; checked: boolean };
|
||||
type Texts = { _meta: LanguageMeta; editor: Record<string, unknown> };
|
||||
export type LanguageMeta = { name: string; english: string; script?: string; plural: string; clock?: string; checked: boolean };
|
||||
// A language's texts as the build hands them over: compiled, and only the page's part (not the file's own shape).
|
||||
type Texts = Record<string, unknown>;
|
||||
const en = english as unknown as Texts;
|
||||
|
||||
// Which form of a plural text ("1 hour ago | {n} hours ago") fits n, by the file's `_meta.plural`: the same rules as the
|
||||
// screens (components/smart_display/screen_text_gen.py).
|
||||
@@ -19,26 +23,27 @@ export const PLURAL_RULES: Record<string, (n: number) => number> = {
|
||||
none: () => 0,
|
||||
};
|
||||
|
||||
// Every other language by its code, as a chunk of its own.
|
||||
// Every other language by its code, as a chunk of its own; every language's _meta comes with the page.
|
||||
const files = import.meta.glob<Texts>(
|
||||
["../../screen_manager/translations/*.json", "!../../screen_manager/translations/en.json"], { import: "default" });
|
||||
const LOADERS: Record<string, () => Promise<Texts>> = Object.fromEntries(
|
||||
Object.entries(files).map(([path, load]) => [path.slice(path.lastIndexOf("/") + 1, -".json".length), load]));
|
||||
const META: Record<string, LanguageMeta> = { en: en._meta as LanguageMeta };
|
||||
const META: Record<string, LanguageMeta> = { ...metas };
|
||||
const LOADED = new Set(["en"]);
|
||||
// A text with fewer forms than its language's rule uses its last one, as on the screens.
|
||||
const RULES: Record<string, (choice: number, forms: number) => number> = {};
|
||||
const addRule = (code: string) =>
|
||||
(RULES[code] = (choice, forms) => Math.min((PLURAL_RULES[META[code]?.plural] || PLURAL_RULES.one_other)(choice), forms - 1));
|
||||
|
||||
/** Every language the editor has, English first. */
|
||||
export const languages = () => ["en", ...Object.keys(LOADERS).sort(), ...Object.keys(META).filter((code) => code !== "en" && !LOADERS[code])];
|
||||
export const languages = () => ["en", ...Object.keys(META).filter((code) => code !== "en").sort()];
|
||||
languages().forEach(addRule);
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: "en",
|
||||
fallbackLocale: "en",
|
||||
messages: { en: { editor: en.editor } } as Record<string, any>,
|
||||
messages: { en } as Record<string, any>,
|
||||
pluralRules: RULES,
|
||||
// A language without a text falls back to English quietly. Only a key English lacks is a mistake, told while developing.
|
||||
missingWarn: false,
|
||||
@@ -48,8 +53,10 @@ export const i18n = createI18n({
|
||||
},
|
||||
});
|
||||
export const t = i18n.global.t;
|
||||
/** Whether a text exists, in English when no language is named. */
|
||||
export const te = (key: string, locale = "en"): boolean => (i18n.global.te as (key: string, locale?: string) => boolean)(key, locale);
|
||||
export const editorLanguage = () => i18n.global.locale.value;
|
||||
/** The `_meta` of a language once it is loaded. */
|
||||
/** A language's `_meta`: its own name, its name in English, its plural rule, whether it is checked. */
|
||||
export const languageMeta = (code: string): LanguageMeta | undefined => META[code];
|
||||
|
||||
// Home Assistant opens the page in its ingress iframe, on its own origin, and sets its <html lang> to the language of
|
||||
@@ -73,45 +80,17 @@ export function matchLanguage(wanted: string | null | undefined, available = lan
|
||||
}
|
||||
export const pickLanguage = (wanted: string | null | undefined, available = languages()) => matchLanguage(wanted, available) || "en";
|
||||
|
||||
// A text a translator left empty shows in English, as on the screens. So does one vue-i18n can't read, such as a stray
|
||||
// @ or {: it would stop the page from drawing.
|
||||
function filled(node: unknown): unknown {
|
||||
if (typeof node === "string") return node.trim() ? node : undefined;
|
||||
if (Array.isArray(node)) return node.every((item) => typeof item === "string" && item.trim()) ? node : undefined;
|
||||
if (!node || typeof node !== "object") return undefined;
|
||||
const kept = Object.entries(node).map(([key, value]) => [key, filled(value)]).filter(([, value]) => value !== undefined);
|
||||
return kept.length ? Object.fromEntries(kept) : undefined;
|
||||
}
|
||||
function leaves(node: unknown, path: string): string[] {
|
||||
if (typeof node === "string") return [path];
|
||||
if (!node || typeof node !== "object") return [];
|
||||
return Object.entries(node).flatMap(([key, value]) => leaves(value, `${path}.${key}`));
|
||||
}
|
||||
/** A language's texts, as its file holds them. */
|
||||
export function addLanguage(code: string, texts: Texts) {
|
||||
META[code] = texts._meta;
|
||||
/** A language's texts, as the build hands them over; `meta` for a language without a file (the tests). */
|
||||
export function addLanguage(code: string, texts: Texts, meta: LanguageMeta | undefined = META[code]) {
|
||||
if (meta) META[code] = meta;
|
||||
if (!RULES[code]) addRule(code);
|
||||
const editor = (filled(texts.editor) || {}) as Record<string, any>;
|
||||
i18n.global.setLocaleMessage(code, { editor });
|
||||
const unreadable = leaves(editor, "editor").filter((key) => {
|
||||
try {
|
||||
t(key, {}, { locale: code });
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
if (!unreadable.length) return;
|
||||
for (const key of unreadable) {
|
||||
const path = key.split(".").slice(1), last = path.pop()!;
|
||||
delete path.reduce((node, part) => node[part], editor)[last];
|
||||
}
|
||||
i18n.global.setLocaleMessage(code, { editor });
|
||||
i18n.global.setLocaleMessage(code, texts as any);
|
||||
LOADED.add(code);
|
||||
}
|
||||
const loading: Record<string, Promise<boolean>> = {};
|
||||
/** Loads a language once; true when the editor has it. */
|
||||
export function loadLanguage(code: string): Promise<boolean> {
|
||||
if (META[code]) return Promise.resolve(true);
|
||||
if (LOADED.has(code)) return Promise.resolve(true);
|
||||
const load = LOADERS[code];
|
||||
if (!load) return Promise.resolve(false);
|
||||
return (loading[code] ??= load().then(
|
||||
@@ -127,16 +106,25 @@ export async function setEditorLanguage(code = pickLanguage(requestedLanguage())
|
||||
return chosen;
|
||||
}
|
||||
|
||||
// How the screens write a number (app 0.2.90), as the firmware does (history_view::number): "1,234.5", "1.234,5" or
|
||||
// "1 234,5". Anything that isn't a plain number stays as it is.
|
||||
// How the screens write a number (app 0.2.90), as the firmware does (screen_text::localize): the decimal mark, the mark
|
||||
// between thousands, and from how many digits a whole number gets them. The choice under Language & region is one of
|
||||
// three styles; "auto" writes numbers as the screens' language does (its screen.number).
|
||||
export type NumberStyle = "point" | "comma" | "space";
|
||||
const MARKS: Record<NumberStyle, [string, string]> = { point: [".", ","], comma: [",", "."], space: [",", " "] };
|
||||
export function numberText(value: string | number, style: NumberStyle) {
|
||||
export type NumberMarks = { decimal: string; group: string; from: number };
|
||||
export const STYLE_MARKS: Record<NumberStyle, NumberMarks> = {
|
||||
point: { decimal: ".", group: ",", from: 4 }, comma: { decimal: ",", group: ".", from: 4 }, space: { decimal: ",", group: " ", from: 4 },
|
||||
};
|
||||
/** The marks of a language's own numbers: "1.234,5" in Dutch, 1234 but 12.345 in Italian (CLDR's minimum grouping). */
|
||||
export function languageMarks(locale: string): NumberMarks {
|
||||
const say = (key: string) => t(`screen.number.${key}`, {}, { locale });
|
||||
return { decimal: say("decimal"), group: say("group"), from: Number(say("group_min")) >= 2 ? 5 : 4 };
|
||||
}
|
||||
/** A state as Home Assistant sends a number ("1234.5", "-3"), written with these marks; any other text as it is. */
|
||||
export function numberText(value: string | number, marks: NumberMarks) {
|
||||
const text = String(value), plain = /^(-?)(\d+)(?:\.(\d+))?$/.exec(text);
|
||||
if (!plain) return text;
|
||||
const [decimal, group] = MARKS[style] || MARKS.point;
|
||||
const whole = plain[2].replace(/\B(?=(\d{3})+$)/g, group);
|
||||
return `${plain[1]}${whole}${plain[3] !== undefined ? decimal + plain[3] : ""}`;
|
||||
const whole = plain[2].length >= marks.from ? plain[2].replace(/\B(?=(\d{3})+$)/g, marks.group) : plain[2];
|
||||
return `${plain[1]}${whole}${plain[3] !== undefined ? marks.decimal + plain[3] : ""}`;
|
||||
}
|
||||
|
||||
// The time and number format of the user's own Home Assistant profile, where it names one: a 12- or 24-hour clock, and
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
import { t } from "../i18n";
|
||||
import type { HeaderItem } from "../types";
|
||||
|
||||
// The words are the screens' (app 0.2.90): editor.mockup.date and editor.mockup.time, in the language the screens have,
|
||||
// which `locale` names; English where none is given.
|
||||
// The words are the screens' own (app 0.2.90): screen.date and screen.time of the translations, in the language the
|
||||
// screens have, which `locale` names; English where none is given.
|
||||
export const BUILTIN_ICONS: Record<string, string> = { clock: "clock-outline", analog: "clock-outline", date: "calendar" };
|
||||
export const itemKey = (item: HeaderItem) => JSON.stringify(item);
|
||||
export const glyph = (cp: string) => String.fromCodePoint(parseInt(cp, 16));
|
||||
@@ -18,15 +18,15 @@ export function clockText(clock24: boolean, now = new Date()) {
|
||||
if (!clock24) hours = hours % 12 || 12;
|
||||
return `${String(hours).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}`;
|
||||
}
|
||||
export const dateText = (now = new Date(), locale = "en") => t("editor.mockup.date.top_bar", {
|
||||
weekday: t(`editor.mockup.date.weekdays_min.${now.getDay()}`, {}, { locale }),
|
||||
export const dateText = (now = new Date(), locale = "en") => t("screen.date.top_bar", {
|
||||
weekday: t(`screen.date.weekdays_min.${now.getDay()}`, {}, { locale }),
|
||||
day: now.getDate(),
|
||||
month: t(`editor.mockup.date.months_short.${now.getMonth()}`, {}, { locale }),
|
||||
month: t(`screen.date.months_short.${now.getMonth()}`, {}, { locale }),
|
||||
}, { locale });
|
||||
// Same thresholds as header_bar::ago_text() in the firmware, and the same words.
|
||||
export function agoText(then: number, now = Math.floor(Date.now() / 1000), locale = "en") {
|
||||
const seconds = now - then, span = Math.abs(seconds), per = (unit: number) => Math.floor(span / unit);
|
||||
const say = (key: string, n?: number) => (n === undefined ? t(`editor.mockup.time.${key}`, {}, { locale }) : t(`editor.mockup.time.${key}`, n, { locale }));
|
||||
const say = (key: string, n?: number) => (n === undefined ? t(`screen.time.${key}`, {}, { locale }) : t(`screen.time.${key}`, n, { locale }));
|
||||
if (seconds < 0) {
|
||||
if (span < 3600) return say("in_minutes", Math.max(1, per(60)));
|
||||
if (span < 86400) return say("in_hours", per(3600));
|
||||
|
||||
+10
-4
@@ -2,7 +2,7 @@
|
||||
// former app.js state and its calls, with the DOM work moved into the components.
|
||||
import { computed, reactive, toRaw, watch } from "vue";
|
||||
import { api, getJson, send, setCsrf } from "./api";
|
||||
import { andList, languageMeta, loadLanguage, type NumberStyle, pickLanguage, t } from "./i18n";
|
||||
import { andList, editorLanguage, languageMarks, languageMeta, loadLanguage, type NumberMarks, pickLanguage, STYLE_MARKS, t } from "./i18n";
|
||||
import {
|
||||
arrange, cellsOf, entriesOf, firstFree, fits, isFull, isWide, MAX_PAGES, nearestFree, newTile, normalize, occupied, pageCount, pageOf,
|
||||
pageTarget, rowStart, sizeOf, SLOTS_PER_PAGE, strandedPages, supportsFirmware as supportsVersion, tileLimit as limitFor,
|
||||
@@ -832,9 +832,14 @@ export const languageName = (code: string | null | undefined) =>
|
||||
export const needsUpdate = (screen: Screen) => Boolean(screen.update?.available || screen.update?.language);
|
||||
export const newLanguageText = () => t("editor.update.new_language", { name: languageName(state.inventory.language?.effective) });
|
||||
// Time and number format, for every screen at once under Settings → Language & region: a 24-hour clock and "1,234.5"
|
||||
// until the add-on says otherwise. The mockup's clocks and numbers follow them.
|
||||
// until the add-on says otherwise. The mockup's clocks and numbers follow them; "auto" numbers are the screens'
|
||||
// language's own (screen.number), as the firmware writes them.
|
||||
export const clock24 = computed(() => state.inventory.language?.clock_effective !== "12");
|
||||
export const numberStyle = computed<NumberStyle>(() => state.inventory.language?.numbers_effective || "point");
|
||||
export const screenLanguageMarks = computed<NumberMarks>(() => languageMarks(screenLanguage.value));
|
||||
export const numberMarks = computed<NumberMarks>(() => {
|
||||
const choice = state.inventory.language ? state.inventory.language.numbers || "auto" : "point";
|
||||
return choice === "auto" ? screenLanguageMarks.value : STYLE_MARKS[choice];
|
||||
});
|
||||
/** Saves any of the screen language, the time format and the number format. */
|
||||
export async function saveLanguage(changes: { setting?: string; clock?: string; numbers?: string }) {
|
||||
try {
|
||||
@@ -875,7 +880,8 @@ function applyLive(data: Partial<Inventory>) {
|
||||
let pollTimer = 0, lastFull = Date.now(), live = false, stream: EventSource | null = null;
|
||||
function listen() {
|
||||
if (stream || typeof EventSource === "undefined") return;
|
||||
stream = new EventSource("api/events");
|
||||
// An EventSource sends no headers of its own: the editor's language goes along in the address (app 0.2.90).
|
||||
stream = new EventSource(`api/events?language=${encodeURIComponent(editorLanguage())}`);
|
||||
stream.onopen = () => { live = true; poll(); };
|
||||
stream.onmessage = (e) => { if (!document.hidden) applyLive(JSON.parse(e.data)); };
|
||||
stream.onerror = () => { live = false; poll(); };
|
||||
|
||||
+111
-80
@@ -8,19 +8,23 @@ import en from "../../screen_manager/translations/en.json";
|
||||
import { send } from "../src/api";
|
||||
import TileCard from "../src/components/TileCard.vue";
|
||||
import {
|
||||
addLanguage, andList, haProfile, i18n, languages, loadLanguage, matchLanguage, numberText, pickLanguage, PLURAL_RULES, requestedLanguage,
|
||||
setEditorLanguage, t,
|
||||
addLanguage, andList, haProfile, i18n, languageMarks, languageMeta, languages, loadLanguage, matchLanguage, numberText, pickLanguage,
|
||||
PLURAL_RULES, requestedLanguage, setEditorLanguage, STYLE_MARKS, t,
|
||||
} from "../src/i18n";
|
||||
import { agoText, dateText } from "../src/model/topbar";
|
||||
import { state } from "../src/store";
|
||||
import { pageTexts, TRANSLATIONS } from "../translations";
|
||||
|
||||
const meta = (plural: string) => ({ name: "Test", english: "Test", script: "latin", plural, checked: false });
|
||||
// Every text of a section as [key, text]; a list's items get .0, .1, ... as in tools/i18n.py.
|
||||
// Every text of a part as [key, text]; a list's items get .0, .1, ... as in tools/i18n.py.
|
||||
function flat(node: unknown, path: string): [string, unknown][] {
|
||||
if (node && typeof node === "object") return Object.entries(node).flatMap(([key, value]) => flat(value, `${path}.${key}`));
|
||||
return [[path, node]];
|
||||
}
|
||||
const english = flat(en.editor, "editor");
|
||||
// en.json as it is, and the part of it the page takes.
|
||||
const file = JSON.parse(readFileSync(join(TRANSLATIONS, "en.json"), "utf8"));
|
||||
const page = pageTexts(file);
|
||||
const texts = [...flat(page.editor, "editor"), ...flat(page.screen, "screen")];
|
||||
|
||||
afterEach(() => {
|
||||
i18n.global.locale.value = "en";
|
||||
@@ -28,14 +32,27 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("English", () => {
|
||||
it("comes with the page, as the language itself and the editor's texts only", () => {
|
||||
// The build keeps the screens' and the add-on's sections out (vite.config.ts); the tests import it the same way.
|
||||
expect(Object.keys(en)).toEqual(["_meta", "editor"]);
|
||||
expect(en._meta.plural).toBe("one_other");
|
||||
it("comes with the page: the editor's texts and the screens' words the mockup draws, and every language's _meta", () => {
|
||||
// The build keeps the rest of the screens' texts and the add-on's out (vite.config.ts); the tests import it the same way.
|
||||
expect(Object.keys(en).sort()).toEqual(["editor", "screen"]);
|
||||
expect(Object.keys((en as any).screen).sort()).toEqual(["date", "ha", "number", "tile", "time"]);
|
||||
expect(Object.keys((en as any).screen.tile)).toEqual(["page"]);
|
||||
expect(languageMeta("en")?.plural).toBe("one_other");
|
||||
expect(languages()[0]).toBe("en");
|
||||
// Every file in the folder is a language the editor can load.
|
||||
const files = readdirSync(TRANSLATIONS).filter((name) => name.endsWith(".json")).map((name) => name.slice(0, -5)).sort();
|
||||
expect([...languages()].sort()).toEqual(files);
|
||||
});
|
||||
it("leaves out empty texts, keeps a space, and takes nothing else from a file", () => {
|
||||
const part = pageTexts({
|
||||
_meta: meta("one_other"), addon: { x: "y" },
|
||||
screen: { number: { decimal: ",", group: " ", group_min: "" }, status: { home: "Thuis" }, tile: { page: "Pagina {n}", refused: "Geweigerd" }, time: { just_now: "" } },
|
||||
editor: { common: { close: "Sluiten", cancel: "" }, empty: {} },
|
||||
});
|
||||
expect(part).toEqual({ editor: { common: { close: "Sluiten" } }, screen: { number: { decimal: ",", group: " " }, tile: { page: "Pagina {n}" } } });
|
||||
});
|
||||
it("has a readable text for every key, and no text vue-i18n reads as something else", () => {
|
||||
for (const [key, text] of english) {
|
||||
for (const [key, text] of texts) {
|
||||
expect(typeof text, key).toBe("string");
|
||||
expect(t(key), key).not.toBe(key);
|
||||
// @ links another text and | separates plural forms: a text uses them only as that.
|
||||
@@ -43,20 +60,22 @@ describe("English", () => {
|
||||
if ((text as string).includes("|")) expect(text as string, key).toMatch(/ \| /);
|
||||
}
|
||||
});
|
||||
it("has every key the code asks for, and no key the code doesn't use", () => {
|
||||
const root = join(__dirname, "..", "src");
|
||||
it("has every key the code asks for, and no editor key the code doesn't use", () => {
|
||||
const files = (dir: string): string[] => readdirSync(dir).flatMap((name) => {
|
||||
const path = join(dir, name);
|
||||
return statSync(path).isDirectory() ? files(path) : /\.(ts|vue)$/.test(name) ? [path] : [];
|
||||
});
|
||||
const code = files(root).map((path) => readFileSync(path, "utf8")).join("\n");
|
||||
const keys = new Set([...code.matchAll(/["'`](editor\.[a-z0-9_.]+[a-z0-9_])["'`]/g)].map((m) => m[1]));
|
||||
const code = files(join(__dirname, "..", "src")).map((path) => readFileSync(path, "utf8")).join("\n");
|
||||
const key = String.raw`(?:editor|screen\.(?:ha|time|date|number|tile))\.[a-z0-9_.]*`;
|
||||
const keys = new Set([...code.matchAll(new RegExp(`["'\`](${key}[a-z0-9_])["'\`]`, "g"))].map((m) => m[1]));
|
||||
// A key built at run time, such as `editor.library.filters.${value}`, counts for every text under its start.
|
||||
const starts = new Set([...code.matchAll(/`(editor\.[a-z0-9_.]+)\$\{/g)].map((m) => m[1]));
|
||||
const known = new Set(english.map(([key]) => key));
|
||||
for (const key of keys) expect(known.has(key) || english.some(([other]) => other.startsWith(`${key}.`)), key).toBe(true);
|
||||
for (const start of starts) expect(english.some(([key]) => key.startsWith(start)), start).toBe(true);
|
||||
const unused = english.map(([key]) => key).filter((key) => !keys.has(key) && ![...starts].some((start) => key.startsWith(start)));
|
||||
const starts = new Set([...code.matchAll(new RegExp(`\`(${key})\\$\\{`, "g"))].map((m) => m[1]));
|
||||
const known = new Set(texts.map(([key]) => key));
|
||||
for (const key of keys) expect(known.has(key) || texts.some(([other]) => other.startsWith(`${key}.`)), key).toBe(true);
|
||||
for (const start of starts) expect(texts.some(([key]) => key.startsWith(start)), start).toBe(true);
|
||||
// The screens' words come as whole parts (screen.ha, time, date, number); the editor's own texts are all in use.
|
||||
const unused = texts.map(([key]) => key)
|
||||
.filter((key) => key.startsWith("editor.") && !keys.has(key) && ![...starts].some((start) => key.startsWith(start)));
|
||||
expect(unused).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -88,16 +107,19 @@ describe("choosing a language", () => {
|
||||
expect(requestedLanguage(foreign)).toBe("de-DE");
|
||||
frame.remove();
|
||||
});
|
||||
it("sets the page's own language, and keeps English for a language it doesn't have", async () => {
|
||||
addLanguage("nl", { _meta: meta("one_other"), editor: { common: { close: "Sluiten" } } });
|
||||
expect(await setEditorLanguage("nl")).toBe("nl");
|
||||
expect(document.documentElement.lang).toBe("nl");
|
||||
it("sets the page's own language, loads a language from its file, and keeps English for one it doesn't have", async () => {
|
||||
addLanguage("tt", { editor: { common: { close: "Sluiten" } } }, meta("one_other"));
|
||||
expect(await setEditorLanguage("tt")).toBe("tt");
|
||||
expect(document.documentElement.lang).toBe("tt");
|
||||
expect(t("editor.common.close")).toBe("Sluiten");
|
||||
// A text Dutch doesn't have yet shows in English.
|
||||
// A text the language doesn't have yet shows in English.
|
||||
expect(t("editor.common.cancel")).toBe("Cancel");
|
||||
expect(await setEditorLanguage("xx")).toBe("en");
|
||||
expect(document.documentElement.lang).toBe("en");
|
||||
expect(await loadLanguage("xx")).toBe(false);
|
||||
// A language with a file comes from its own chunk, compiled like English.
|
||||
expect(await loadLanguage("nl")).toBe(true);
|
||||
expect(t("screen.tile.page", { n: 2 }, { locale: "nl" })).toBe("Pagina 2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,27 +135,16 @@ describe("plural forms", () => {
|
||||
it("pick the form of a text by its language's rule, with {n}, and the last form when a text has fewer", () => {
|
||||
expect(t("editor.library.entities", 1)).toBe("1 entity");
|
||||
expect(t("editor.library.entities", 5)).toBe("5 entities");
|
||||
addLanguage("pl", {
|
||||
_meta: meta("slavic_pl"),
|
||||
editor: { library: { entities: "{n} encja | {n} encje | {n} encji" }, common: { results: "{n} wynik | {n} wyników" } },
|
||||
});
|
||||
const pl = (key: string, n: number) => t(key, n, { locale: "pl" });
|
||||
expect([1, 3, 5, 22].map((n) => pl("editor.library.entities", n))).toEqual(["1 encja", "3 encje", "5 encji", "22 encje"]);
|
||||
expect(pl("editor.common.results", 3)).toBe("3 wyników");
|
||||
// Chinese has one form: every number takes it.
|
||||
addLanguage("zh-Hans", { _meta: meta("none"), editor: { library: { entities: "{n} 个实体" } } });
|
||||
expect(t("editor.library.entities", 7, { locale: "zh-Hans" })).toBe("7 个实体");
|
||||
});
|
||||
it("leave out a text that is empty or that vue-i18n can't read, and show English for it", () => {
|
||||
addLanguage("fr", {
|
||||
_meta: meta("one_upto_1"),
|
||||
editor: { common: { close: "Fermer", cancel: " ", back: "Retour @ la page" }, library: { entities: "{n} entité | {n} entités" } },
|
||||
});
|
||||
const fr = (key: string, n?: number) => (n === undefined ? t(key, {}, { locale: "fr" }) : t(key, n, { locale: "fr" }));
|
||||
expect(fr("editor.common.close")).toBe("Fermer");
|
||||
expect(fr("editor.common.cancel")).toBe("Cancel");
|
||||
expect(fr("editor.common.back")).toBe("← Back");
|
||||
expect([0, 1, 2].map((n) => fr("editor.library.entities", n))).toEqual(["0 entité", "1 entité", "2 entités"]);
|
||||
addLanguage("tp", { editor: { library: { entities: "{n} encja | {n} encje | {n} encji" }, common: { results: "{n} wynik | {n} wyników" } } }, meta("slavic_pl"));
|
||||
const tp = (key: string, n: number) => t(key, n, { locale: "tp" });
|
||||
expect([1, 3, 5, 22].map((n) => tp("editor.library.entities", n))).toEqual(["1 encja", "3 encje", "5 encji", "22 encje"]);
|
||||
expect(tp("editor.common.results", 3)).toBe("3 wyników");
|
||||
// One form: every number takes it.
|
||||
addLanguage("tz", { editor: { library: { entities: "{n} 个实体" } } }, meta("none"));
|
||||
expect(t("editor.library.entities", 7, { locale: "tz" })).toBe("7 个实体");
|
||||
// French counts 0 as one.
|
||||
addLanguage("tf", { editor: { library: { entities: "{n} entité | {n} entités" } } }, meta("one_upto_1"));
|
||||
expect([0, 1, 2].map((n) => t("editor.library.entities", n, { locale: "tf" }))).toEqual(["0 entité", "1 entité", "2 entités"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,62 +153,82 @@ describe("the add-on hears the editor's language", () => {
|
||||
const fetch = vi.fn(async () => new Response("", { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetch);
|
||||
await send("screens/x/identify", "POST");
|
||||
addLanguage("nl", { _meta: meta("one_other"), editor: {} });
|
||||
i18n.global.locale.value = "nl";
|
||||
addLanguage("tt", { editor: {} }, meta("one_other"));
|
||||
i18n.global.locale.value = "tt";
|
||||
await send("inventory", "GET");
|
||||
const headers = fetch.mock.calls.map((call: any[]) => call[1].headers["X-ESP-Screens-Language"]);
|
||||
expect(headers).toEqual(["en", "nl"]);
|
||||
expect(headers).toEqual(["en", "tt"]);
|
||||
});
|
||||
it("and a failed answer without the add-on's own words says so in the editor's language", async () => {
|
||||
addLanguage("nl", { _meta: meta("one_other"), editor: { api: { failed: "Dat lukte niet." } } });
|
||||
i18n.global.locale.value = "nl";
|
||||
addLanguage("tu", { editor: { api: { failed: "Dat lukte niet." } } }, meta("one_other"));
|
||||
i18n.global.locale.value = "tu";
|
||||
vi.stubGlobal("fetch", vi.fn(async () => new Response("<html>502</html>", { status: 502 })));
|
||||
await expect(send("screens/x", "PUT", {})).rejects.toThrow("Dat lukte niet.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the mockup speaks the screens' language", () => {
|
||||
it("draws the screens' words in their language while the editor keeps its own", async () => {
|
||||
addLanguage("de", {
|
||||
_meta: meta("one_other"),
|
||||
editor: {
|
||||
mockup: {
|
||||
unavailable: "Nicht verfügbar", page_link: "Seite {page} ›",
|
||||
time: { hours_ago: "Vor 1 Stunde | Vor {n} Stunden", just_now: "Gerade eben" },
|
||||
date: { weekdays_min: ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"], months_short: ["Jan.", "Feb.", "März", "Apr.", "Mai", "Juni", "Juli", "Aug.", "Sept.", "Okt.", "Nov.", "Dez."], top_bar: "{weekday}., {day}. {month}" },
|
||||
},
|
||||
tile_card: { remove: "Kachel entfernen" },
|
||||
it("draws the screens' own words in their language while the editor keeps its own", async () => {
|
||||
addLanguage("td", {
|
||||
editor: { tile_card: { remove: "Kachel entfernen" } },
|
||||
screen: {
|
||||
ha: { unavailable: "Nicht verfügbar", on: "An", weather: { windy: "Windig" }, binary: { door_on: "Offen" }, button: { run: "Ausführen" } },
|
||||
tile: { page: "Seite {n}" },
|
||||
time: { hours_ago: "Vor 1 Stunde | Vor {n} Stunden", just_now: "Gerade eben" },
|
||||
date: { weekdays_min: ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"], months_short: ["Jan.", "Feb.", "März", "Apr.", "Mai", "Juni", "Juli", "Aug.", "Sept.", "Okt.", "Nov.", "Dez."], top_bar: "{weekday}., {day}. {month}" },
|
||||
number: { decimal: ",", group: ".", group_min: "2" },
|
||||
},
|
||||
});
|
||||
state.inventory = { screens: [], entities: [{ id: "light.b", name: "Lamp B", state: "unavailable" }], language: { setting: "de", effective: "de", ha: "en", languages: [] } } as any;
|
||||
}, meta("one_other"));
|
||||
state.inventory = {
|
||||
screens: [], entities: [{ id: "light.b", name: "Lamp B", state: "unavailable" }],
|
||||
language: { setting: "td", effective: "td", ha: "en", languages: [], numbers: "auto" },
|
||||
controls: { script: { default: "run", choices: [{ key: "run", label: "Run" }, { key: "none", label: "None" }] } },
|
||||
} as any;
|
||||
state.layout = { title: "Living room", tiles: [] };
|
||||
state.liveStates = {};
|
||||
await Promise.resolve();
|
||||
const gone = { entity: "light.b", name: "", slot: 0 };
|
||||
state.layout.tiles.push(gone);
|
||||
const card = mount(TileCard, { props: { tile: gone, slot: 0 } });
|
||||
expect(card.find(".st").text()).toBe("Nicht verfügbar");
|
||||
state.liveStates = {
|
||||
"light.c": { state: "on", word: null, a: {} },
|
||||
"weather.home": { state: "windy-variant", word: null, a: { temperature: 12.5 } },
|
||||
"binary_sensor.door": { state: "on", word: null, a: { device_class: "door" } },
|
||||
"sensor.power": { state: "1249", word: null, a: { unit_of_measurement: "W" } },
|
||||
"sensor.energy": { state: "12345.5", word: null, a: { unit_of_measurement: "kWh" } },
|
||||
};
|
||||
const card = (entity: string, slot: number, options?: Record<string, unknown>) => {
|
||||
const tile = { entity, name: "", slot, ...(options ? { options } : {}) };
|
||||
state.layout!.tiles.push(tile);
|
||||
return mount(TileCard, { props: { tile, slot } });
|
||||
};
|
||||
const gone = card("light.b", 0);
|
||||
expect(gone.find(".st").text()).toBe("Nicht verfügbar");
|
||||
// The editor's own words stay in the editor's language.
|
||||
expect(card.find(".remove").attributes("title")).toBe("Remove tile");
|
||||
const page = { entity: "screen.page_2", name: "Go to page 2", slot: 1 };
|
||||
state.layout.tiles.push(page);
|
||||
expect(mount(TileCard, { props: { tile: page, slot: 1 } }).find(".goto").text()).toBe("Seite 2 ›");
|
||||
expect(agoText(1_000_000 - 7200, 1_000_000, "de")).toBe("Vor 2 Stunden");
|
||||
expect(gone.find(".remove").attributes("title")).toBe("Remove tile");
|
||||
expect(card("screen.page_2", 1).find(".goto").text()).toBe("Seite 2 ›");
|
||||
expect(card("light.c", 2).find(".st").text()).toBe("An");
|
||||
expect(card("weather.home", 3).find(".st").text()).toBe("Windig · 12,5°");
|
||||
expect(card("binary_sensor.door", 4).find(".st").text()).toBe("Offen");
|
||||
expect(card("script.movie", 5, { size: "wide", controls: "run" }).find(".run").text()).toBe("Ausführen");
|
||||
// 1249 has too few digits for a mark in this language; 12345 gets one, as the screen writes them.
|
||||
expect(card("sensor.power", 6).find(".st").text()).toBe("1249 W");
|
||||
expect(card("sensor.energy", 7).find(".st").text()).toBe("12.345,5 kWh");
|
||||
expect(agoText(1_000_000 - 7200, 1_000_000, "td")).toBe("Vor 2 Stunden");
|
||||
expect(agoText(1_000_000 - 7200, 1_000_000)).toBe("2 hours ago");
|
||||
expect(dateText(new Date(2026, 8, 15), "de")).toBe("Di., 15. Sept.");
|
||||
// A word German doesn't have yet is English, as on the screen.
|
||||
expect(agoText(1_000_000 - 90000, 1_000_000, "de")).toBe("Yesterday");
|
||||
expect(dateText(new Date(2026, 8, 15), "td")).toBe("Di., 15. Sept.");
|
||||
// A word the language doesn't have yet is English, as on the screen.
|
||||
expect(agoText(1_000_000 - 90000, 1_000_000, "td")).toBe("Yesterday");
|
||||
});
|
||||
});
|
||||
|
||||
describe("numbers, lists and the profile", () => {
|
||||
it("writes numbers as the screens do", () => {
|
||||
expect(["1249", "21.4", "-1234567.25", "0", "999"].map((n) => numberText(n, "point"))).toEqual(["1,249", "21.4", "-1,234,567.25", "0", "999"]);
|
||||
expect(["1249", "21.4", "-1234567.25"].map((n) => numberText(n, "comma"))).toEqual(["1.249", "21,4", "-1.234.567,25"]);
|
||||
expect(["1249", "21.4"].map((n) => numberText(n, "space"))).toEqual(["1 249", "21,4"]);
|
||||
expect(["1249", "21.4", "-1234567.25", "0", "999"].map((n) => numberText(n, STYLE_MARKS.point))).toEqual(["1,249", "21.4", "-1,234,567.25", "0", "999"]);
|
||||
expect(["1249", "21.4", "-1234567.25"].map((n) => numberText(n, STYLE_MARKS.comma))).toEqual(["1.249", "21,4", "-1.234.567,25"]);
|
||||
expect(["1249", "21.4"].map((n) => numberText(n, STYLE_MARKS.space))).toEqual(["1 249", "21,4"]);
|
||||
// Anything that isn't a plain number stays as it is.
|
||||
expect(["on", "1e5", "12:30", ""].map((n) => numberText(n, "comma"))).toEqual(["on", "1e5", "12:30", ""]);
|
||||
expect(numberText(1234.5, "comma")).toBe("1.234,5");
|
||||
expect(["on", "1e5", "12:30", "", ".5", "5."].map((n) => numberText(n, STYLE_MARKS.comma))).toEqual(["on", "1e5", "12:30", "", ".5", "5."]);
|
||||
expect(numberText(1234.5, STYLE_MARKS.comma)).toBe("1.234,5");
|
||||
// A language's own marks: English and Dutch from four digits, Italian from five.
|
||||
expect(numberText("1234.5", languageMarks("en"))).toBe("1,234.5");
|
||||
addLanguage("ti", { screen: { number: { decimal: ",", group: ".", group_min: "2" } } }, meta("one_other"));
|
||||
expect(["1234.5", "12345.5"].map((n) => numberText(n, languageMarks("ti")))).toEqual(["1234,5", "12.345,5"]);
|
||||
});
|
||||
it("writes a list the way the language does, the English one without a comma before and", () => {
|
||||
expect(andList([1, 2, 3])).toBe("1, 2 and 3");
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// Language & region (app 0.2.90): one place for every screen's language, clock and numbers, the update it takes, and
|
||||
// the top bar that points there.
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AppSettingsView from "../src/components/AppSettingsView.vue";
|
||||
import Sidebar from "../src/components/Sidebar.vue";
|
||||
import TopbarInspector from "../src/components/TopbarInspector.vue";
|
||||
import { loadLanguage } from "../src/i18n";
|
||||
import { state } from "../src/store";
|
||||
import type { Languages } from "../src/types";
|
||||
|
||||
@@ -33,6 +34,8 @@ function addOn(answer: (body: any) => Response) {
|
||||
}
|
||||
const options = (view: ReturnType<typeof mount>, id: string) => view.findAll(`#${id} option`).map((o) => o.text());
|
||||
|
||||
// The screens' language here is Dutch: its own number marks come with its file.
|
||||
beforeAll(() => loadLanguage("nl"));
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
state.inventory = { screens: [], entities: [], updates: { target: "0.2.80", pending: 0 }, language: language() } as any;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// What the page takes from the translations (app 0.2.90), for vite.config.ts and the tests. Every language is one file
|
||||
// in screen_manager/translations, shared with the firmware and the add-on.
|
||||
import { join } from "node:path";
|
||||
|
||||
export const TRANSLATIONS = join(__dirname, "..", "screen_manager", "translations");
|
||||
// Of the screens' texts the page takes the ones its mockup draws: Home Assistant's words, times, dates, numbers and the
|
||||
// page label. The rest of `screen` and all of `addon` stay out.
|
||||
const SCREEN_PARTS = ["ha", "time", "date", "number"];
|
||||
|
||||
// An empty text is one the language doesn't have yet: it stays out, so English shows, as on the screens. A space is
|
||||
// a text (French writes 1 234,5).
|
||||
function filled(node: unknown): unknown {
|
||||
if (typeof node === "string") return node === "" ? undefined : node;
|
||||
if (Array.isArray(node)) return node.every((item) => typeof item === "string" && item !== "") ? node : undefined;
|
||||
if (!node || typeof node !== "object") return undefined;
|
||||
const kept = Object.entries(node).map(([key, value]) => [key, filled(value)]).filter(([, value]) => value !== undefined);
|
||||
return kept.length ? Object.fromEntries(kept) : undefined;
|
||||
}
|
||||
/** The part of a language's file the page takes: the editor's texts and the screens' words its mockup draws. */
|
||||
export function pageTexts(data: any) {
|
||||
const screen = data?.screen || {};
|
||||
const words: Record<string, unknown> = Object.fromEntries(SCREEN_PARTS.filter((part) => part in screen).map((part) => [part, screen[part]]));
|
||||
if (screen.tile?.page !== undefined) words.tile = { page: screen.tile.page };
|
||||
return (filled({ editor: data?.editor, screen: words }) || {}) as Record<string, unknown>;
|
||||
}
|
||||
+27
-13
@@ -1,20 +1,39 @@
|
||||
import VueI18n from "@intlify/unplugin-vue-i18n/vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
import { pageTexts, TRANSLATIONS } from "./translations";
|
||||
|
||||
// Every language is one file in screen_manager/translations (app 0.2.90), shared with the firmware and the add-on. The
|
||||
// page needs only the language itself (_meta) and the editor's texts: the screens' and the add-on's texts stay out of
|
||||
// the build. Before Vite's own JSON step, so the modules it makes hold these two sections only.
|
||||
// Before vue-i18n's plugin compiles a file into the page's messages: only its page part goes in. Every language's
|
||||
// `_meta` (its name, plural rule, whether it is checked) comes with the page as virtual:esp-screens-languages, so the
|
||||
// editor knows the languages before it loads one.
|
||||
const LANGUAGES_ID = "virtual:esp-screens-languages";
|
||||
export function editorTexts(): Plugin {
|
||||
return {
|
||||
name: "esp-screens-editor-texts",
|
||||
enforce: "pre",
|
||||
resolveId: (id) => (id === LANGUAGES_ID ? `\0${LANGUAGES_ID}` : null),
|
||||
load(id) {
|
||||
if (id !== `\0${LANGUAGES_ID}`) return null;
|
||||
const languages: Record<string, unknown> = {};
|
||||
for (const name of readdirSync(TRANSLATIONS).filter((file) => file.endsWith(".json")).sort()) {
|
||||
this.addWatchFile(join(TRANSLATIONS, name));
|
||||
languages[name.slice(0, -".json".length)] = JSON.parse(readFileSync(join(TRANSLATIONS, name), "utf8"))._meta;
|
||||
}
|
||||
return `export default ${JSON.stringify(languages)};`;
|
||||
},
|
||||
transform(code, id) {
|
||||
if (!/[\\/]screen_manager[\\/]translations[\\/][^\\/?]+\.json$/.test(id)) return null;
|
||||
const { _meta, editor } = JSON.parse(code);
|
||||
return { code: JSON.stringify({ _meta, editor }), map: null };
|
||||
return { code: JSON.stringify(pageTexts(JSON.parse(code))), map: null };
|
||||
},
|
||||
};
|
||||
}
|
||||
// vue-i18n compiles every text while building (app 0.2.90), so the page runs its runtime-only build without the message
|
||||
// compiler, and no text is compiled in the browser. A text it can't read stops the build (tools/i18n.py check says so
|
||||
// first). `runtimeOnly: false` for the tests, which add texts of their own.
|
||||
export const translations = (runtimeOnly = true) =>
|
||||
VueI18n({ include: [join(TRANSLATIONS, "*.json")], runtimeOnly, compositionOnly: true, fullInstall: true, dropMessageCompiler: runtimeOnly });
|
||||
|
||||
// The add-on serves the built page from screen_manager/app/static behind Home Assistant ingress,
|
||||
// which lives on a sub path with a token. Everything is relative so the page works there and on
|
||||
@@ -22,14 +41,9 @@ export function editorTexts(): Plugin {
|
||||
// files change. `npm run dev` proxies /api to a running server (the demo home or SCREEN_DEV).
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
plugins: [editorTexts(), vue()],
|
||||
// vue-i18n's switches (app 0.2.90): the Composition API only, texts compiled in the page, no devtools in the build.
|
||||
define: {
|
||||
__VUE_I18N_FULL_INSTALL__: true,
|
||||
__VUE_I18N_LEGACY_API__: false,
|
||||
__INTLIFY_PROD_DEVTOOLS__: false,
|
||||
__INTLIFY_DROP_MESSAGE_COMPILER__: false,
|
||||
},
|
||||
plugins: [editorTexts(), translations(), vue()],
|
||||
// No vue-i18n devtools in the build; its other switches come from the plugin above.
|
||||
define: { __INTLIFY_PROD_DEVTOOLS__: false },
|
||||
build: {
|
||||
outDir: "../screen_manager/app/static",
|
||||
emptyOutDir: false,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { editorTexts } from "./vite.config";
|
||||
import { editorTexts, translations } from "./vite.config";
|
||||
|
||||
// Unit and component tests of the editor: the grid rules, the top bar rules, the store and the components,
|
||||
// in jsdom. `npm test` runs them; `npm run test:watch` while developing. The components mount with the
|
||||
// editor's texts (tests/setup.ts), in English.
|
||||
// editor's texts (tests/setup.ts), in English, compiled as for the page; vue-i18n keeps its compiler here, for the
|
||||
// texts the tests add themselves.
|
||||
export default defineConfig({
|
||||
plugins: [editorTexts(), vue()],
|
||||
plugins: [editorTexts(), translations(false), vue()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
include: ["tests/**/*.spec.ts"],
|
||||
|
||||
Reference in New Issue
Block a user