"""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) or self.flat['en'].get(key) if not isinstance(text, str) or not text.strip(): 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() class Text(str): """A text that keeps its key and params, so it can be written again in another language: a message made in one place and shown in another, such as a delivery status the sync loop sets or an update result kept in updates.json, shows in the language of whoever looks at it (app 0.2.90). Everywhere else it is the str it reads as.""" def __new__(cls, text, key=None, params=None): made = super().__new__(cls, text) made.key, made.params = key, dict(params or {}) return made def into(self, language): """This text in `language`; one without a key stays as it is.""" return _render(self.key, language, self.params) if self.key else str(self) def _render(key, language, params): """`key` in `language` with its params filled in; a param that is a Text itself goes into that language too.""" return TRANSLATIONS.text(key, language, **{name: value.into(language) if isinstance(value, Text) else value for name, value in params.items()}) def t(key, **params): """An `addon` text in the language of the editor that asked (REQUEST_LANGUAGE). It keeps its key, so the error it is raised with can still be written in another language (Updater.record).""" return Text(_render(key, REQUEST_LANGUAGE.get(), params), key, params) def english(key, **params): """A text in English that keeps its key, for a message made now and read later: a delivery status, an update result, why a screen can't show an alert. Logs read the English; `shown` writes it in the editor's language.""" return Text(_render(key, 'en', params), key, params) def shown(value): """A text made earlier in the language of the editor that asks now; anything but a Text as it is.""" return value.into(REQUEST_LANGUAGE.get()) if isinstance(value, Text) else value # 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 (and the notification it leaves in Home Assistant); a param that is a Text goes into that language too.""" return _render(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 '')