Files
homeassistant_espscreen/screen_manager/app/i18n.py
T
2026-09-19 14:59:42 +02:00

236 lines
11 KiB
Python

"""The languages of ESP Screens (app 0.2.90).
Every text of the app, the editor and the screens lives in one JSON file per language (screen_manager/translations,
docs/TRANSLATING.md); English is the source, and a text a language lacks shows in English. The editor reads its own
section in the browser, the screens get theirs built into their firmware (components/smart_display/screen_text_gen.py),
and this module gives the app its `addon` texts and the `screen` words it sends to the screens itself.
`Region` is Settings -> Language & region, the one place for every screen: the language (Home Assistant's, or a chosen
one), the clock (12 or 24 hours) and how numbers are written. "auto" does what Home Assistant does for a user who never
changed their profile: the clock and the numbers follow the language.
"""
import contextvars
import json
import logging
import os
import re
from pathlib import Path
LOG = logging.getLogger('screen_manager')
HERE = Path(__file__).resolve().parent
# In the app's image the files sit next to the code (Dockerfile: COPY translations); in a checkout one folder up.
FOLDER = next((path for path in (HERE / 'translations', HERE.parent / 'translations') if path.is_dir()), HERE / 'translations')
CODE = re.compile(r'[a-z]{2,3}(-[A-Za-z0-9]{2,8})?')
CLOCKS = ('auto', '24', '12')
NUMBERS = ('auto', 'point', 'comma', 'space')
PLACEHOLDER = re.compile(r'\{([a-z_]+)\}')
# Which form of a plural text ("1 hour ago | {n} hours ago") fits n; the firmware (screen_text_gen.py) and the editor
# (web/src/i18n.ts) have the same rules, docs/TRANSLATING.md says which languages use which.
def _slavic(n, one):
t, h = n % 10, n % 100
if one:
return 0
return 1 if 2 <= t <= 4 and not 12 <= h <= 14 else 2
PLURAL = {
'one_other': lambda n: 0 if n == 1 else 1,
'one_upto_1': lambda n: 0 if abs(n) <= 1 else 1,
'slavic_pl': lambda n: _slavic(n, n == 1),
'east_slavic': lambda n: _slavic(n, n % 10 == 1 and n % 100 != 11),
'none': lambda n: 0,
}
# The language of an editor's request (its X-ESP-Screens-Language header), for the messages the app answers with.
REQUEST_LANGUAGE = contextvars.ContextVar('request_language', default='en')
def flatten(node, prefix=''):
"""(key, text) pairs in document order; a list's items get .0, .1, ... ."""
if isinstance(node, dict):
items = node.items()
elif isinstance(node, list):
items = ((str(index), value) for index, value in enumerate(node))
else:
return [(prefix, node)]
pairs = []
for key, value in items:
pairs.extend(flatten(value, f'{prefix}.{key}' if prefix else key))
return pairs
class Translations:
"""Every language file, read once."""
def __init__(self, folder=FOLDER):
self.data, self.flat = {}, {}
for path in sorted(Path(folder).glob('*.json')):
try:
data = json.loads(path.read_text(encoding='utf-8'))
except (OSError, ValueError) as error:
LOG.warning('Translation %s is unreadable (%s); it is left out', path.name, type(error).__name__)
continue
if CODE.fullmatch(path.stem) and isinstance(data, dict):
self.data[path.stem] = data
self.flat[path.stem] = {key: value for key, value in flatten({k: v for k, v in data.items() if k != '_meta'})
if isinstance(value, str)}
self.data.setdefault('en', {'_meta': {'name': 'English', 'english': 'English', 'plural': 'one_other'}})
self.flat.setdefault('en', {})
def resolve(self, code):
"""The language ESP Screens has for `code`: the code itself (in any case), else its base (pt-BR -> pt), else English."""
if not isinstance(code, str):
return 'en'
by_lower = {known.lower(): known for known in self.data}
for candidate in (code, code.split('-')[0]):
if candidate.lower() in by_lower:
return by_lower[candidate.lower()]
return 'en'
def meta(self, code):
return self.data.get(self.resolve(code), {}).get('_meta', {})
def languages(self):
"""Every language for the settings: English first, then by English name."""
found = [{'code': code, 'name': str(self.meta(code).get('name') or code),
'english': str(self.meta(code).get('english') or code), 'checked': self.meta(code).get('checked') is True}
for code in self.data]
return sorted(found, key=lambda item: (not item['code'].startswith('en'), item['code'] != 'en', item['english']))
def text(self, key, language='en', **params):
"""`key` in `language`, English where it has none, with {placeholders} filled; `n` picks a plural form."""
language = self.resolve(language)
text = self.flat.get(language, {}).get(key)
# Only an empty text is missing: a separator of one space (French thousands) is a text.
if not isinstance(text, str) or text == '':
text = self.flat['en'].get(key)
if not isinstance(text, str) or text == '':
return key
if '|' in text and 'n' in params:
forms = [form.strip() for form in text.split('|')]
rule = PLURAL.get(self.meta(language).get('plural'), PLURAL['one_other'])
try:
text = forms[min(rule(int(params['n'])), len(forms) - 1)]
except (TypeError, ValueError):
text = forms[-1]
return PLACEHOLDER.sub(lambda match: str(params[match.group(1)]) if match.group(1) in params else match.group(0), text)
def clock_24h(self, code):
"""Whether the language writes 24 hours by default (_meta.clock), as Home Assistant's "auto" does."""
return str(self.meta(code).get('clock', '24')) != '12'
def number_style(self, code):
"""How the language writes 1234.5: point (1,234.5), comma (1.234,5) or space (1 234,5)."""
decimal = self.text('screen.number.decimal', code)
group = self.text('screen.number.group', code)
if decimal == '.':
return 'point'
return 'space' if group.strip() == '' else 'comma'
TRANSLATIONS = Translations()
def t(key, **params):
"""An `addon` text in the language of the editor that asked (REQUEST_LANGUAGE)."""
return TRANSLATIONS.text(key, REQUEST_LANGUAGE.get(), **params)
# The screens' language and number format (Settings -> Language & region), for the words the app sends to the screens
# itself: tile names, the top bar, the history card. The manager keeps it current (Manager.language_changed).
SCREENS = {'language': 'en', 'numbers': 'point'}
def set_screens(language, numbers):
SCREENS.update(language=language, numbers=numbers)
def screen_t(key, **params):
"""A text in the screens' language, for what the app sends to them."""
return TRANSLATIONS.text(key, SCREENS['language'], **params)
def screen_number(text):
"""A number as Home Assistant sends it, written as the screens write numbers ("1.234,5" in Dutch)."""
return format_number(text, SCREENS['numbers'])
class Region:
"""Settings -> Language & region, kept in /data/language.json: one language, clock and number format for every
screen. A screen's firmware carries its language; the clock and the numbers travel in the layout message."""
def __init__(self, path, translations=TRANSLATIONS, ha_language=None):
self.path, self.tr = Path(path), translations
self.setting, self.clock, self.numbers = 'auto', 'auto', 'auto'
# Home Assistant's own language (get_config), once connected.
self.ha_language = ha_language or (lambda: None)
self.stored = self.path.exists()
if self.stored:
try:
raw = json.loads(self.path.read_text(encoding='utf-8'))
except (OSError, ValueError):
raw = {}
if raw.get('version') == 1:
self.setting = raw.get('language') if raw.get('language') == 'auto' or raw.get('language') in self.tr.data else 'auto'
self.clock = raw.get('clock') if raw.get('clock') in CLOCKS else 'auto'
self.numbers = raw.get('numbers') if raw.get('numbers') in NUMBERS else 'auto'
def save(self):
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.path.with_suffix('.tmp')
with open(temporary, 'w', encoding='utf-8') as handle:
os.chmod(temporary, 0o600)
json.dump({'version': 1, 'language': self.setting, 'clock': self.clock, 'numbers': self.numbers}, handle)
handle.flush()
os.fsync(handle.fileno())
temporary.replace(self.path)
self.stored = True
def change(self, data):
"""Apply {language?, clock?, numbers?} from the editor; ValueError (translated) for anything unknown."""
if not isinstance(data, dict):
raise ValueError(t('addon.errors.language_choice'))
setting = data.get('setting', self.setting)
clock = data.get('clock', self.clock)
numbers = data.get('numbers', self.numbers)
if setting != 'auto' and setting not in self.tr.data:
raise ValueError(t('addon.errors.language_choice'))
if clock not in CLOCKS or numbers not in NUMBERS:
raise ValueError(t('addon.errors.region_choice'))
self.setting, self.clock, self.numbers = setting, clock, numbers
self.save()
def language(self):
"""The language of every screen: the chosen one, else Home Assistant's, else English."""
return self.setting if self.setting != 'auto' else self.tr.resolve(self.ha_language() or 'en')
def clock_24h(self):
return self.tr.clock_24h(self.language()) if self.clock == 'auto' else self.clock == '24'
def number_style(self):
return self.tr.number_style(self.language()) if self.numbers == 'auto' else self.numbers
def view(self):
"""What the editor's Language & region card shows."""
return {'setting': self.setting, 'effective': self.language(),
'ha': self.ha_language(), 'languages': self.tr.languages(),
'clock': self.clock, 'clock_effective': '24' if self.clock_24h() else '12',
'numbers': self.numbers, 'numbers_effective': self.number_style()}
def screen_text(self, key, **params):
"""A `screen` or `addon` text in the screens' language, for what the app itself sends to them."""
return self.tr.text(key, self.language(), **params)
def format_number(text, style):
"""A number as Home Assistant sends it ("1234.5") written in `style` ("1.234,5" for comma); anything else unchanged."""
if not isinstance(text, str) or not re.fullmatch(r'-?\d+(\.\d+)?', text):
return text
sign, body = ('-', text[1:]) if text.startswith('-') else ('', text)
whole, _, rest = body.partition('.')
group, decimal = {'point': (',', '.'), 'comma': ('.', ','), 'space': (' ', ',')}.get(style, (',', '.'))
if len(whole) > 3:
head = len(whole) % 3 or 3
whole = whole[:head] + ''.join(group + whole[index:index + 3] for index in range(head, len(whole), 3))
return sign + whole + (decimal + rest if rest else '')