diff --git a/APPS_README.md b/APPS_README.md index c835ab7..813cc68 100644 --- a/APPS_README.md +++ b/APPS_README.md @@ -204,6 +204,100 @@ Use these keys to synchronize related fields: } ``` +## Optional: Localization & Shared Helpers + +`fetch()` can opt into runtime-injected helpers by **declaring extra parameters**. +The runtime inspects your signature and only passes a helper if you name it, so the +classic four-argument `fetch(settings, format_lines, get_rows, get_cols)` keeps +working unchanged — every helper below is purely opt-in and backward compatible. + +### Localization (`i18n`) + +If your app shows words (day/month names, status labels), add an `i18n=None` +parameter. The runtime binds it to the global **Language** setting; on a host +without it (or with `babel` not installed) `i18n` is `None` and you fall back to +English. + +```python +def fetch(settings, format_lines, get_rows, get_cols, i18n=None): + from datetime import datetime + now = datetime.now() + weekday = i18n.weekday(now) if i18n else now.strftime("%A").upper() + label = i18n.t("SUNRISE") if i18n else "SUNRISE" + return [format_lines(weekday, label)] +``` + +- `i18n.weekday(dt, short=False)` / `i18n.month(dt, short=False)` — CLDR-correct, + UPPERCASE day/month names for every language. +- `i18n.date(dt, short=False, year=False)` — day + month (and optional year) in the + locale's **own order and wording**: `JULY 9` in English but `9 JUILLET` (fr), + `9. JULI` (de). Don't hand-assemble `month + " " + day` — order is language-specific. +- `i18n.time(dt, seconds=False)` — wall-clock time: `3:48 PM` in English, `15:48` + elsewhere. `i18n.is_24h` exposes the same decision if you branch yourself. +- `i18n.unit("D")` — localized compact duration suffix for `D`/`H`/`M`/`S`, so `175D` + becomes `175J` in French (jour), `175T` in German (Tag). +- `i18n.number(value, decimals=2, grouping=True)` — a number with the locale's own + separators: `1,234.50` (en) vs `1.234,50` (de) vs `1 234,50` (fr). Use it for any + price/rate/percent — never hardcode `f"{v:,.2f}"`. +- `i18n.base_currency()` / `i18n.country()` — the currency / country a language-region + implies (a sensible default; prefer `get_location` below for geography). +- `i18n.t("ENGLISH LABEL")` — a translated UI word; with no translation it returns the + English key, so nothing ever breaks. All data lives in `server/i18n_data.json` (the + language list, translations, and per-language default currency/country). Add keys or + languages there rather than in your app, keep them short (modules are narrow), and + give each key a generic `context` note so translators know what the word means. A + regional variant like `pt-BR` automatically inherits every `pt` translation. + +Once your app adapts to the language, add `"i18n": true` to its `manifest.json`. When +internationalization is enabled the Apps grid shows a 🌐 badge on those cards, and the +app automatically gets a per-app **Language** override in its settings (blank = follow +the global Language). Only Western-European (Windows-1252) languages are offered — the +modules can't display Greek, Cyrillic, CJK, etc. + +Internationalization is **off by default**: unless the user turns on *Enable +internationalization* in Settings, `i18n` is not injected and your app runs its +English `i18n=None` fallback — so always keep that fallback correct. The toggle exists +because accented glyphs only render on modules whose firmware/character map supports +them. + +### Shared current weather (`get_weather`) + +If your app shows the weather, don't hardcode a provider — add a `get_weather=None` +parameter and call it: + +```python +def fetch(settings, format_lines, get_rows, get_cols, get_weather=None): + if get_weather is None: # host without the helper + return [format_lines("NO WEATHER")] + w = get_weather() # uses the *global* provider + key + location + if not w["ok"]: + return [format_lines("WEATHER", "UNAVAILABLE")] + return [format_lines(w["city"], f'{w["temp_f"]}F {w["desc"]}')] +``` + +`get_weather()` returns a dict: `ok`, `city`, `temp_f`, `feels_like_f`, `hi_f`, +`lo_f`, `desc`, `humidity`, `wind_mph`, `cloud_cover`, `provider`, `lat`, `lon` +(temps in °F; `ok` is `False` with an `error` key on failure). The default provider +is keyless Open-Meteo, so weather works with **no API key**. + +### Location → country / currency (`get_location`) + +Anything tied to *geography* — which currency, which country's holidays — should key +off the configured **Location**, not the language. Declare a `get_location` +parameter for the shared resolver: + +```python +def fetch(settings, format_lines, get_rows, get_cols, get_location=None): + loc = get_location() if get_location else {} + country = loc.get("country") # ISO 3166-1 alpha-2, e.g. "CA" + subdivision = loc.get("subdivision") # ISO 3166-2, e.g. "CA-QC"; may be None + currency = loc.get("currency") # ISO 4217, e.g. "CAD" (None if unknown) +``` + +It reverse-geocodes the global Location once (cached) and is keyless. Declaring +`get_location` also gives the app an automatic per-app **Location** override in its +settings. Helpers compose: `def fetch(..., get_weather=None, get_location=None, i18n=None)`. + ## Final Checklist - Confirm your app directory contains `manifest.json` and `app.py`. diff --git a/server/app.py b/server/app.py index d6ad81c..52717d4 100644 --- a/server/app.py +++ b/server/app.py @@ -11,11 +11,15 @@ import unicodedata import yfinance as yf import importlib.util +import inspect import urllib.request import shutil from datetime import datetime from flask import Flask, render_template, request, jsonify from tuning import build_tuning_adjust_commands +import i18n +import location +import weather from hardware.universal_firmware import ( UniversalFirmwareError, UniversalFirmwareManager, @@ -207,6 +211,9 @@ def load_settings(): "location_lon": "", "location_name": "", "timezone": sys_tz, + "i18n_enabled": False, + "language": "en-US", + "weather_provider": "openmeteo", "weather_api_key": "", "mbta_stop": "", "mbta_route": "", @@ -1369,6 +1376,69 @@ def _load_functional_module(app_id, app_dir): logging.error(f"Plugin {app_id}: error importing app.py: {e}") +def _fetch_accepts(fn, name): + """True if an app's fetch() declares a parameter called ``name`` (how an app + opts into an injected helper like ``i18n`` / ``get_weather`` / ``get_location``), + or accepts arbitrary keywords. Classic 4-arg apps accept neither and are called + unchanged, so this whole mechanism is fully backward compatible.""" + try: + params = inspect.signature(fn).parameters + except (TypeError, ValueError): + return False + return name in params or any(p.kind == p.VAR_KEYWORD for p in params.values()) + + +def _i18n_enabled(): + """Master switch for internationalization. Off by default: the display stays + English-only and looks exactly as it did before i18n existed. Should only be + turned on for modules whose firmware/character map can render accented glyphs.""" + return bool(settings.get("i18n_enabled", False)) + + +def _resolve_app_language(app_id): + """The Language an app should render in: a per-app override + (``plugin__language``) when set, otherwise the global Language. A blank or + unset override means "follow the global Language".""" + override = settings.get(f"plugin_{app_id}_language") + return override or settings.get("language", "en-US") + + +def _apply_location_override(plugin_settings, value): + """Fold a per-app Location override (a ``/location_search`` chip value, format + ``"lat,lon|name"``) into the ``location_*`` keys the location/weather helpers + read, so a per-app override is honored without touching the global location. + A blank/unset value leaves the global location in place.""" + if not value: + return + try: + coords, _, name = str(value).partition("|") + lat, _, lon = coords.partition(",") + lat, lon = lat.strip(), lon.strip() + if lat and lon: + plugin_settings["location_lat"] = lat + plugin_settings["location_lon"] = lon + plugin_settings["location_name"] = name.strip() or plugin_settings.get("location_name", "") + except Exception: + pass + + +def _plugin_fetch_kwargs(app_id, fetch_fn, plugin_settings): + """Optional keyword args a plugin's fetch() opted into by declaring them. Only + the helpers the signature names are built, so nothing is injected into apps that + don't ask for it.""" + kwargs = {} + if _fetch_accepts(fetch_fn, "get_weather"): + kwargs["get_weather"] = lambda s=None: weather.fetch_current( + s if s is not None else plugin_settings) + if _fetch_accepts(fetch_fn, "get_location"): + kwargs["get_location"] = lambda: location.resolve(plugin_settings) + # Only bind a Localizer when i18n is enabled; otherwise the app's i18n=None + # fallback runs and it renders exactly as it did before i18n existed (English). + if _i18n_enabled() and _fetch_accepts(fetch_fn, "i18n"): + kwargs["i18n"] = i18n.Localizer(_resolve_app_language(app_id)) + return kwargs + + def get_plugin_pages(app_id): manifest = _plugin_registry.get(app_id) if not manifest: @@ -1404,7 +1474,13 @@ def get_plugin_pages(app_id): else: key = f"plugin_{app_id}_{s['key']}" plugin_settings[s["key"]] = settings.get(key, s.get("default", "")) - pages = mod.fetch(plugin_settings, format_lines, get_rows, get_cols) + # A per-app Location override wins over the global location for this + # app's injected get_location / get_weather helpers (blank = follow global). + _apply_location_override(plugin_settings, settings.get(f"plugin_{app_id}_location")) + # Optional helpers (i18n / get_weather / get_location) are injected only + # when fetch() declares the matching parameter; 4-arg apps are unchanged. + kwargs = _plugin_fetch_kwargs(app_id, mod.fetch, plugin_settings) + pages = mod.fetch(plugin_settings, format_lines, get_rows, get_cols, **kwargs) if not isinstance(pages, list): pages = [str(pages)] _plugin_caches[app_id] = {"pages": pages, "fetched_at": now} @@ -1433,6 +1509,8 @@ def get_plugin_app_list(): "desc": manifest.get("description", "")[:30], "plugin": True, "plugin_id": app_id, + # 🌐 badge: the app adapts to Language AND i18n is globally enabled. + "i18n": bool(manifest.get("i18n")) and _i18n_enabled(), } if "min_rows" in manifest: entry["min_rows"] = manifest["min_rows"] @@ -1443,6 +1521,13 @@ def get_plugin_app_list(): return entries +# The languages an app can be localized into (the global Language select and every +# per-app Language override read from here). Defined once in server/i18n_data.json — +# only Windows-1252 (Western/Latin-1) languages, since the modules can't display +# Greek, Cyrillic, CJK, etc. +LANGUAGE_OPTIONS = i18n.LANGUAGE_OPTIONS + + _SETTING_PASSTHROUGH_KEYS = ( "size", "ph", @@ -1560,6 +1645,42 @@ def map_related_key(raw_related_key): return field +def _app_uses_location(app_id): + """True if the app's fetch() opts into the injected ``get_location`` helper.""" + mod = _plugin_modules.get(app_id) + return bool(mod) and _fetch_accepts(mod.fetch, "get_location") + + +def _auto_setting_fields(app_id, manifest, manifest_settings): + """Per-app override fields the runtime adds automatically (not in the manifest): + a Language override for any i18n app, and a Location override for any app that + reads the injected location. Each is stored under its own ``plugin__*`` key, + and a blank value means "follow the global setting".""" + declared = {s["key"] for s in manifest_settings} + fields = [] + if manifest.get("i18n") and _i18n_enabled() and "language" not in declared: + fields.append({ + "key": f"plugin_{app_id}_language", + "label": "Language", + "type": "select", + "opts": [{"value": "", "label": "Follow global"}] + LANGUAGE_OPTIONS, + "ph": "", + "note": "Override the global Language for this app only.", + }) + if _app_uses_location(app_id) and "location" not in declared: + fields.append({ + "key": f"plugin_{app_id}_location", + "label": "Location", + "type": "search_chips", + "searchUrl": "/location_search", + "resultKey": "results", + "maxItems": 1, + "ph": "", + "note": "Override the global Location for this app only (place search).", + }) + return fields + + def get_plugin_settings_config(): configs = {} for app_id, manifest in _plugin_registry.items(): @@ -1569,6 +1690,7 @@ def get_plugin_settings_config(): _build_plugin_setting_field(app_id, setting, resolved_keys) for setting in manifest_settings ] + fields += _auto_setting_fields(app_id, manifest, manifest_settings) configs[f"plugin_{app_id}"] = { "title": f"{manifest.get('icon', '🧩')} {manifest.get('name', app_id)}", @@ -2080,7 +2202,7 @@ def _trigger_loop(): @app.route('/') def index(): version = _read_version() - return render_template('index.html', version=version) + return render_template('index.html', version=version, language_options=LANGUAGE_OPTIONS) @app.route('/current_state') def current_state(): diff --git a/server/gateway_transport.py b/server/gateway_transport.py index 0bb1b3f..1634c98 100644 --- a/server/gateway_transport.py +++ b/server/gateway_transport.py @@ -151,7 +151,12 @@ def _on_message(self, client, userdata, msg): return # Re-append the newline the gateway stripped so downstream parsers that # look for '\n' terminators behave exactly as with raw serial. - data = frame.encode("utf-8", errors="ignore") + b"\n" + # Encode with latin-1 (not utf-8): the bus is single-byte cp1252, so a + # frame may carry raw high bytes (e.g. a module's A-command char map with + # 0xE9/0x80). latin-1 maps every code point 0x00-0xFF back to that exact + # byte, so downstream .decode('cp1252') sees the original bytes. utf-8 + # here would drop lone high bytes (errors="ignore") and corrupt the map. + data = frame.encode("latin-1", errors="ignore") + b"\n" with self._rx_lock: self._rx_buf.extend(data) @@ -162,8 +167,13 @@ def _extract_frame(payload): Accepts the gateway's JSON form ``{"command":"..."}`` and also a bare plain-text frame, for robustness against future gateway changes. """ + # Decode with latin-1, a transparent byte<->code-point codec: the bus is + # single-byte cp1252 and a frame (e.g. an A-command char-map response) can + # contain raw high bytes that are invalid standalone utf-8. latin-1 never + # raises and preserves every byte, so json.loads still parses the ASCII + # structure and the raw bytes survive to the caller (re-encoded latin-1). try: - text = payload.decode("utf-8", errors="ignore") + text = payload.decode("latin-1") except Exception: return None text = text.strip() diff --git a/server/i18n.py b/server/i18n.py new file mode 100644 index 0000000..e2807fc --- /dev/null +++ b/server/i18n.py @@ -0,0 +1,292 @@ +""" +i18n.py — shared localization for apps. + +Two pieces: + * CLDR-correct day / month names via babel — authoritative for every language. + * A curated table of the UI words apps show (SUNRISE, FULL MOON, DAYS …), + translated into the supported languages. Anything without an entry falls back to + the English key, so nothing breaks. + +All data — the selectable ``LANGUAGE_OPTIONS`` list, translations, and the default +currency/country per language — is loaded from ``i18n_data.json`` (see that file's +per-key context notes). Regional variants inherit their base language: ``pt-BR`` +reuses every ``pt`` translation but resolves to Brazil / BRL. + +The runtime binds this to the global Language setting and injects it into any app +whose ``fetch()`` declares an ``i18n`` parameter (like ``get_weather``); on a host +without the helper the app just falls back to English. Translations stay in one +place instead of being copied into every self-contained app. +""" + +from __future__ import annotations + +import json +import os + +# All localization data — the selectable language list, curated UI strings, +# duration-unit suffixes, holiday-name translations, and the default currency/country +# a language implies — lives in i18n_data.json so it can be edited, corrected, or +# extended with a whole new language without touching this module. A missing or +# invalid file degrades gracefully, so the lookups below just return their +# English/None defaults and apps keep working in English. +_DATA_PATH = os.path.join(os.path.dirname(__file__), "i18n_data.json") + +# Minimal fallback so the Language selector is never empty if the data file is +# missing/broken (the runtime still works, just English-only). +_DEFAULT_LANGUAGES = [{"value": "en-US", "label": "English (US)"}] + + +def _translations(section): + """Flatten a data section into the runtime ``{key: {lang: value}}`` lookup. Each + entry in the file is ``{"context": "...", "translations": {lang: value}}`` (the + context note is for translators only); a bare ``{lang: value}`` is also accepted.""" + out = {} + for key, entry in (section or {}).items(): + if isinstance(entry, dict) and "translations" in entry: + out[key] = entry["translations"] + elif isinstance(entry, dict): + out[key] = entry + return out + + +def _translations_by_domain(section): + """The ``strings`` section is grouped by context/domain + (``{domain: {NAME: {context, translations}}}``); flatten each domain to + ``{domain: {NAME: {lang: value}}}`` for lookup.""" + return {domain: _translations(entries) for domain, entries in (section or {}).items()} + + +def _load_i18n_data(): + try: + with open(_DATA_PATH, encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, ValueError): + data = {} + return (_translations_by_domain(data.get("strings")), + _translations(data.get("holidays")), + data.get("base_currency", {}), + data.get("country", {}), + data.get("languages") or _DEFAULT_LANGUAGES) + + +(_STRINGS, _HOLIDAYS, _BASE_CURRENCY, _COUNTRY, + LANGUAGE_OPTIONS) = _load_i18n_data() + +# The compact duration-unit suffixes (D/H/M/S/Y) live in the shared 'time' domain, +# alongside the full-word forms (DAYS, HOURS, WEEKS, …) — one duration vocabulary. +_DURATION_UNITS = _STRINGS.get("time", {}) + + +def _base_lang(lang): + """The base language subtag: ``pt-BR`` / ``pt_BR`` -> ``pt``, ``de`` -> ``de``.""" + return str(lang or "").replace("_", "-").split("-")[0].lower() + + +def _localized(table, key, lang, default): + """Look up ``key`` in a ``{lang: value}`` table, trying the full language code + first and then its base subtag, so a regional variant (``pt-BR``) inherits the + base language (``pt``) wherever it has no entry of its own.""" + variants = table.get(key) + if not variants: + return default + code = str(lang or "").lower() + if code in variants: + return variants[code] + base = _base_lang(lang) + if base in variants: + return variants[base] + return default + + +def translate(text, lang, ctx="common"): + """English UI string -> localized for a given context/domain (``ctx``), like + gettext's ``pgettext``. The same English text can differ per context (weather + ``HIGH`` = a level, tides ``HIGH`` = high tide). Resolution order: the given + domain, then the shared ``common`` domain, then the English text itself — so an + unknown or English language always returns ``text`` and nothing breaks.""" + if not lang or _base_lang(lang) == "en": + return text + hit = _localized(_STRINGS.get(ctx) or {}, text, lang, None) + if hit is None and ctx != "common": + hit = _localized(_STRINGS.get("common") or {}, text, lang, None) + return text if hit is None else hit + + +def _babel_locale(lang): + """Our language code -> a babel locale id. English carries a region that changes + date order (US 'July 9' vs UK/AU '9 July'): 'en-GB' -> 'en_GB', 'en'/'en-US' -> + 'en_US'. Other languages just use their base code ('fr', 'de', …).""" + if not lang: + return "en_US" + parts = str(lang).replace("-", "_").split("_") + base = parts[0].lower() + if base == "en": + region = parts[1].upper() if len(parts) > 1 and parts[1] else "US" + return f"en_{region}" + return base + + +def _cldr(dt, fmt, lang): + try: + from babel.dates import format_date + return format_date(dt, fmt, locale=_babel_locale(lang)).upper() + except Exception: + return None + + +def weekday(dt, lang, short=False): + return _cldr(dt, "EEE" if short else "EEEE", lang) or dt.strftime("%a" if short else "%A").upper() + + +def month(dt, lang, short=False): + return _cldr(dt, "MMM" if short else "MMMM", lang) or dt.strftime("%b" if short else "%B").upper() + + +def date(dt, lang, short=False, year=False): + """Day + month (optionally year) in the locale's own order and wording: + ``JULY 9`` (en) but ``9 JUILLET`` (fr), ``9. JULI`` (de), ``9 DE JULIO`` (es).""" + skeleton = ("MMM" if short else "MMMM") + "d" + ("y" if year else "") + try: + from babel.dates import format_skeleton + return format_skeleton(skeleton, dt, locale=_babel_locale(lang)).upper() + except Exception: + base = f"{month(dt, lang, short)} {dt.day}" + return f"{base} {dt.year}" if year else base + + +def duration_unit(key, lang): + """Localized compact duration suffix (D/H/M/S/Y) -> e.g. J/H/M/S in French. The + duration vocabulary (abbreviations and full words) lives in the 'time' domain.""" + return translate(key, lang, "time") + + +def uses_24h(lang): + """AM/PM is essentially an English-language convention; everyone else is 24h.""" + return bool(lang) and _base_lang(lang) != "en" + + +# Non-ASCII group separators CLDR uses (French narrow/no-break spaces) — the flap +# display only speaks Windows-1252, so we fold them back to a plain space. +_GROUP_SPACES = ("\u202f", "\u00a0", "\u2009") + + +def number(value, lang, decimals=2, grouping=True): + """Format a number with the locale's own separators: 1,234.5 (en) vs 1.234,5 + (de/es/it/…) vs 1 234,5 (fr). Falls back to English grouping without babel.""" + try: + from babel.numbers import format_decimal + pattern = ("#,##0" if grouping else "0") + ("." + "0" * decimals if decimals > 0 else "") + s = format_decimal(float(value), format=pattern, locale=_babel_locale(lang)) + except Exception: + try: + s = f"{float(value):,.{decimals}f}" if grouping else f"{float(value):.{decimals}f}" + except Exception: + return str(value) + for ch in _GROUP_SPACES: + s = s.replace(ch, " ") + return s + + +def base_currency(lang): + """The "home" currency a language/region implies (the default base for FX): US -> + USD, UK -> GBP, Brazil -> BRL, the rest of Western Europe -> EUR. From + i18n_data.json; a regional variant (``pt-BR``) wins over its base (``pt``), and + unknown languages default to USD. Users can override explicitly.""" + code = str(lang or "en").lower() + return _BASE_CURRENCY.get(code) or _BASE_CURRENCY.get(_base_lang(lang or "en"), "USD") + + +def currency_symbol(code): + """Compact symbol for an ISO 4217 currency code ('EUR' -> '€', 'USD' -> '$', + 'GBP' -> '£') from babel, using a neutral (English) locale so it's the short + universal form — not a locale-disambiguated one like '$US'/'£GB'. Falls back to + the ISO code when the symbol has characters the flap display (Windows-1252) can't + render — ₹, ₩, ฿ -> 'INR'/'KRW'/'THB'.""" + if not code: + return "" + code = code.upper() + try: + from babel.numbers import get_currency_symbol + sym = get_currency_symbol(code, locale="en") or code + except Exception: + sym = {"USD": "$", "EUR": "€", "GBP": "£", "JPY": "¥"}.get(code, code) + try: + sym.encode("cp1252") + except UnicodeEncodeError: + return code + return sym + + +def country(lang): + """The country a language/region implies — for holidays (which calendar to show) + and other country-scoped data. Regional variants split by country (``pt-BR`` -> + BR, ``pt`` -> PT); from i18n_data.json, unknown languages default to US.""" + code = str(lang or "en").lower() + return _COUNTRY.get(code) or _COUNTRY.get(_base_lang(lang or "en"), "US") + + +def holiday(name, lang): + """Localized public-holiday name, or None if we have no translation (caller + then keeps the source's native name).""" + if not lang or not name: + return None + return _localized(_HOLIDAYS, str(name).strip().lower(), lang, None) + + +def clock(dt, lang, seconds=False, ampm_space=True): + """Locale-appropriate wall-clock time: ``3:48 PM`` in English, ``15:48`` elsewhere.""" + if uses_24h(lang): + return dt.strftime("%H:%M:%S" if seconds else "%H:%M") + body = dt.strftime("%I:%M:%S" if seconds else "%I:%M").lstrip("0") + sep = " " if ampm_space else "" + return f"{body}{sep}{dt.strftime('%p')}" + + +class Localizer: + """Language-bound convenience wrapper handed to apps as ``i18n``.""" + + def __init__(self, lang): + self.lang = (lang or "en").lower() + + @property + def is_24h(self): + return uses_24h(self.lang) + + def t(self, text, ctx="common"): + return translate(text, self.lang, ctx) + + def weekday(self, dt, short=False): + return weekday(dt, self.lang, short) + + def month(self, dt, short=False): + return month(dt, self.lang, short) + + def date(self, dt, short=False, year=False): + return date(dt, self.lang, short, year) + + def time(self, dt, seconds=False, ampm_space=True): + return clock(dt, self.lang, seconds, ampm_space) + + def unit(self, key): + return duration_unit(key, self.lang) + + def number(self, value, decimals=2, grouping=True): + return number(value, self.lang, decimals, grouping) + + def base_currency(self): + return base_currency(self.lang) + + def currency_symbol(self, code): + return currency_symbol(code) + + def country(self): + return country(self.lang) + + def holiday(self, name): + return holiday(name, self.lang_base) + + @property + def lang_base(self): + """The 2-letter language without region ('en-GB' -> 'en'), for APIs that + want a plain language code (Wikipedia editions, weather providers, …).""" + return self.lang.split("-")[0] diff --git a/server/i18n_data.json b/server/i18n_data.json new file mode 100644 index 0000000..ff03329 --- /dev/null +++ b/server/i18n_data.json @@ -0,0 +1,2628 @@ +{ + "_comment": "Localization data for i18n.py, loaded at import. 'languages' is the Language selector list. 'strings' groups translations by SEMANTIC context/domain (like gettext msgctxt): strings[domain][NAME] has a generic 'context' note and a language->text map. A domain is a MEANING, not an app — a word with one meaning lives in one domain (e.g. the shared 'time' vocab), and is duplicated only when its meaning differs by context (weather HIGH = a level, tides HIGH = high tide). Apps translate via the relevant domain; the 'common' domain (OFFLINE, ERROR, …) backs every context. A missing (domain, NAME, language) falls back to the English NAME, and a regional variant (pt-BR) inherits its base language (pt). 'duration_units' (D/H/M/S symbols) and 'holidays' stay flat; 'base_currency'/'country' are the default a language implies when no Location is set. Only Windows-1252 (Western/Latin-1) languages. Edit here to fix/add translations.", + "languages": [ + { + "value": "en-US", + "label": "English (US)" + }, + { + "value": "en-GB", + "label": "English (UK)" + }, + { + "value": "en-AU", + "label": "English (Australia)" + }, + { + "value": "en-CA", + "label": "English (Canada)" + }, + { + "value": "fr", + "label": "Français (French)" + }, + { + "value": "fr-CA", + "label": "Français (Canada)" + }, + { + "value": "fr-BE", + "label": "Français (Belgique)" + }, + { + "value": "fr-CH", + "label": "Français (Suisse)" + }, + { + "value": "de", + "label": "Deutsch (German)" + }, + { + "value": "de-AT", + "label": "Deutsch (Österreich)" + }, + { + "value": "de-CH", + "label": "Deutsch (Schweiz)" + }, + { + "value": "es", + "label": "Español (Spanish)" + }, + { + "value": "es-MX", + "label": "Español (México)" + }, + { + "value": "es-AR", + "label": "Español (Argentina)" + }, + { + "value": "it", + "label": "Italiano (Italian)" + }, + { + "value": "it-CH", + "label": "Italiano (Svizzera)" + }, + { + "value": "pt", + "label": "Português (Portugal)" + }, + { + "value": "pt-BR", + "label": "Português (Brasil)" + }, + { + "value": "nl", + "label": "Nederlands (Dutch)" + }, + { + "value": "nl-BE", + "label": "Nederlands (België)" + }, + { + "value": "da", + "label": "Dansk (Danish)" + }, + { + "value": "no", + "label": "Norsk (Norwegian)" + }, + { + "value": "sv", + "label": "Svenska (Swedish)" + }, + { + "value": "fi", + "label": "Suomi (Finnish)" + }, + { + "value": "is", + "label": "Íslenska (Icelandic)" + }, + { + "value": "ga", + "label": "Gaeilge (Irish)" + }, + { + "value": "ca", + "label": "Català (Catalan)" + }, + { + "value": "gl", + "label": "Galego (Galician)" + }, + { + "value": "eu", + "label": "Euskara (Basque)" + }, + { + "value": "et", + "label": "Eesti (Estonian)" + }, + { + "value": "af", + "label": "Afrikaans" + }, + { + "value": "id", + "label": "Bahasa Indonesia" + }, + { + "value": "ms", + "label": "Bahasa Melayu (Malay)" + }, + { + "value": "sw", + "label": "Kiswahili (Swahili)" + } + ], + "strings": { + "common": { + "OFFLINE": { + "context": "Status: the device/service is offline.", + "translations": { + "fr": "HORS LIGNE", + "de": "OFFLINE", + "es": "SIN CONEXION", + "it": "OFFLINE", + "pt": "OFFLINE", + "nl": "OFFLINE", + "da": "OFFLINE", + "no": "OFFLINE", + "sv": "OFFLINE" + } + }, + "NO DATA": { + "context": "Status: no data was returned.", + "translations": { + "fr": "PAS DE DONNEES", + "de": "KEINE DATEN", + "es": "SIN DATOS", + "it": "NESSUN DATO", + "pt": "SEM DADOS", + "nl": "GEEN DATA", + "da": "INGEN DATA", + "no": "INGEN DATA", + "sv": "INGEN DATA" + } + }, + "ERROR": { + "context": "Status: a generic error occurred.", + "translations": { + "fr": "ERREUR", + "de": "FEHLER", + "es": "ERROR", + "it": "ERRORE", + "pt": "ERRO", + "nl": "FOUT", + "da": "FEJL", + "no": "FEIL", + "sv": "FEL" + } + }, + "UNKNOWN": { + "context": "Value unknown / unavailable.", + "translations": { + "fr": "INCONNU", + "de": "UNBEKANNT", + "es": "DESCON.", + "it": "SCONOSC.", + "pt": "DESCON.", + "nl": "ONBEKEND", + "da": "UKENDT", + "no": "UKJENT", + "sv": "OKÄND" + } + }, + "INVALID DATE": { + "context": "A configured date could not be parsed.", + "translations": { + "fr": "DATE INVALIDE", + "de": "UNGULT. DATUM", + "es": "FECHA INVALIDA", + "it": "DATA NON VALIDA", + "pt": "DATA INVALIDA", + "nl": "ONGELDIGE DATUM" + } + }, + "API FAIL": { + "context": "Status: an upstream API call failed.", + "translations": { + "fr": "ECHEC API", + "de": "API-FEHLER", + "es": "FALLO API", + "it": "ERRORE API", + "pt": "FALHA API", + "nl": "API-FOUT", + "da": "API-FEJL", + "no": "API-FEIL", + "sv": "API-FEL" + } + }, + "CONFIGURE": { + "context": "Prompt to configure the app in Settings.", + "translations": { + "fr": "CONFIGURER", + "de": "EINRICHTEN", + "es": "CONFIGURAR", + "it": "CONFIGURA", + "pt": "CONFIGURAR", + "nl": "INSTELLEN", + "da": "OPSÆT", + "no": "SETT OPP", + "sv": "KONFIGURERA" + } + } + }, + "time": { + "DAYS": { + "context": "Duration unit word (plural): days.", + "translations": { + "fr": "JOURS", + "de": "TAGE", + "es": "DÍAS", + "it": "GIORNI", + "pt": "DIAS", + "nl": "DAGEN", + "da": "DAGE", + "no": "DAGER", + "sv": "DAGAR" + } + }, + "HOURS": { + "context": "Duration unit word (plural): hours.", + "translations": { + "fr": "HEURES", + "de": "STUNDEN", + "es": "HORAS", + "it": "ORE", + "pt": "HORAS", + "nl": "UREN", + "da": "TIMER", + "no": "TIMER", + "sv": "TIMMAR" + } + }, + "MINS": { + "context": "Time unit: minutes (abbreviated).", + "translations": { + "fr": "MIN", + "de": "MIN", + "es": "MIN", + "it": "MIN", + "pt": "MIN", + "nl": "MIN", + "da": "MIN", + "no": "MIN", + "sv": "MIN" + } + }, + "LEFT": { + "context": "Follows a remaining duration ('3D LEFT').", + "translations": { + "fr": "RESTE", + "de": "UBRIG", + "es": "QUEDAN", + "it": "MANCA", + "pt": "FALTAM", + "nl": "OVER", + "da": "TILBAGE", + "no": "IGJEN", + "sv": "KVAR" + } + }, + "REMAINING": { + "context": "Follows a duration to mean time remaining ('3D REMAINING').", + "translations": { + "fr": "RESTANT", + "de": "VERBLEIB.", + "es": "RESTANTE", + "it": "RIMANENTE", + "pt": "RESTANTE", + "nl": "RESTEREND", + "da": "TILBAGE", + "no": "GJENSTÅR", + "sv": "ÅTERSTÅR" + } + }, + "ARRIVED": { + "context": "Shown when an awaited moment has arrived.", + "translations": { + "fr": "ARRIVE", + "de": "DA", + "es": "LLEGO", + "it": "ARRIVATO", + "pt": "CHEGOU", + "nl": "AANGEKOMEN", + "da": "ANKOMMET", + "no": "ANKOMMET", + "sv": "ANLÄNT" + } + }, + "HERE": { + "context": "Short 'it's here' when an awaited moment arrives.", + "translations": { + "fr": "ICI", + "de": "HIER", + "es": "AQUI", + "it": "QUI", + "pt": "AQUI", + "nl": "HIER", + "da": "HER", + "no": "HER", + "sv": "HÄR" + } + }, + "CELEBRATE": { + "context": "Celebratory word shown when an event arrives.", + "translations": { + "fr": "FETEZ", + "de": "FEIERN", + "es": "CELEBRA", + "it": "FESTA", + "pt": "FESTA", + "nl": "VIER", + "da": "FEJR", + "no": "FEIRE", + "sv": "FIRA" + } + }, + "PARTY": { + "context": "Celebratory word shown when an event arrives.", + "translations": { + "fr": "FETE", + "de": "PARTY", + "es": "FIESTA", + "it": "FESTA", + "pt": "FESTA", + "nl": "FEEST", + "da": "FEST", + "no": "FEST", + "sv": "FEST" + } + }, + "TODAY": { + "context": "The word 'today'.", + "translations": { + "fr": "AUJOURDHUI", + "de": "HEUTE", + "es": "HOY", + "it": "OGGI", + "pt": "HOJE", + "nl": "VANDAAG", + "da": "I DAG", + "no": "I DAG", + "sv": "IDAG" + } + }, + "NOW": { + "context": "The word 'now'.", + "translations": { + "fr": "MAINTENANT", + "de": "JETZT", + "es": "AHORA", + "it": "ORA", + "pt": "AGORA", + "nl": "NU", + "da": "NU", + "no": "NÅ", + "sv": "NU" + } + }, + "IN": { + "context": "Connector before a duration: 'IN 3 DAYS'.", + "translations": { + "fr": "DANS", + "de": "IN", + "es": "EN", + "it": "TRA", + "pt": "EM", + "nl": "OVER", + "da": "OM", + "no": "OM", + "sv": "OM" + } + }, + "Y": { + "context": "Compact duration suffix for years (after a number, e.g. '2A').", + "translations": { + "fr": "A", + "de": "J", + "es": "A", + "it": "A", + "pt": "A", + "nl": "J", + "da": "Å", + "no": "Å", + "sv": "Å" + } + }, + "D": { + "context": "Compact duration suffix for days (e.g. '175D').", + "translations": { + "fr": "J", + "de": "T", + "es": "D", + "it": "G", + "pt": "D", + "nl": "D" + } + }, + "H": { + "context": "Compact duration suffix for hours.", + "translations": { + "fr": "H", + "de": "H", + "es": "H", + "it": "H", + "pt": "H", + "nl": "U", + "da": "T", + "no": "T", + "sv": "T" + } + }, + "M": { + "context": "Compact duration suffix for minutes.", + "translations": { + "fr": "M", + "de": "M", + "es": "M", + "it": "M", + "pt": "M", + "nl": "M" + } + }, + "S": { + "context": "Compact duration suffix for seconds.", + "translations": { + "fr": "S", + "de": "S", + "es": "S", + "it": "S", + "pt": "S", + "nl": "S" + } + }, + "YEARS": { + "context": "Duration unit word (plural): years.", + "translations": { + "fr": "ANS", + "de": "JAHRE", + "es": "AÑOS", + "it": "ANNI", + "pt": "ANOS", + "nl": "JAREN", + "da": "ÅR", + "no": "ÅR", + "sv": "ÅR" + } + }, + "MONTHS": { + "context": "Duration unit word (plural): months.", + "translations": { + "fr": "MOIS", + "de": "MONATE", + "es": "MESES", + "it": "MESI", + "pt": "MESES", + "nl": "MAANDEN", + "da": "MÅNEDER", + "no": "MÅNEDER", + "sv": "MÅNADER" + } + }, + "WEEKS": { + "context": "Duration unit word (plural): weeks.", + "translations": { + "fr": "SEMAINES", + "de": "WOCHEN", + "es": "SEMANAS", + "it": "SETTIMANE", + "pt": "SEMANAS", + "nl": "WEKEN", + "da": "UGER", + "no": "UKER", + "sv": "VECKOR" + } + }, + "MINUTES": { + "context": "Duration unit word (plural): minutes.", + "translations": { + "fr": "MINUTES", + "de": "MINUTEN", + "es": "MINUTOS", + "it": "MINUTI", + "pt": "MINUTOS", + "nl": "MINUTEN", + "da": "MINUTTER", + "no": "MINUTTER", + "sv": "MINUTER" + } + }, + "SECONDS": { + "context": "Duration unit word (plural): seconds.", + "translations": { + "fr": "SECONDES", + "de": "SEKUNDEN", + "es": "SEGUNDOS", + "it": "SECONDI", + "pt": "SEGUNDOS", + "nl": "SECONDEN", + "da": "SEKUNDER", + "no": "SEKUNDER", + "sv": "SEKUNDER" + } + }, + "TIME SINCE": { + "context": "'Time since ' label.", + "translations": { + "fr": "DEPUIS", + "de": "SEIT", + "es": "DESDE", + "it": "DA", + "pt": "DESDE", + "nl": "SINDS", + "da": "SIDEN", + "no": "SIDEN", + "sv": "SEDAN" + } + }, + "NOT YET": { + "context": "An awaited event is still in the future.", + "translations": { + "fr": "PAS ENCORE", + "de": "NOCH NICHT", + "es": "AUN NO", + "it": "NON ANCORA", + "pt": "AINDA NAO", + "nl": "NOG NIET", + "da": "IKKE ENDNU", + "no": "IKKE ENNÅ", + "sv": "INTE ÄNNU" + } + }, + "STARTED": { + "context": "An event has started / passed.", + "translations": { + "fr": "COMMENCE", + "de": "GESTARTET", + "es": "EMPEZADO", + "it": "INIZIATO", + "pt": "INICIADO", + "nl": "GESTART", + "da": "STARTET", + "no": "STARTET", + "sv": "STARTAT" + } + } + }, + "sun": { + "SUNRISE": { + "context": "The time the sun rises.", + "translations": { + "fr": "LEVER", + "de": "AUFGANG", + "es": "AMANECER", + "it": "ALBA", + "pt": "NASCER", + "nl": "OPKOMST", + "da": "SOLOPGANG", + "no": "SOLOPPGANG", + "sv": "SOLUPPGÅNG" + } + }, + "SUNSET": { + "context": "The time the sun sets.", + "translations": { + "fr": "COUCHER", + "de": "UNTERGANG", + "es": "OCASO", + "it": "TRAMONTO", + "pt": "OCASO", + "nl": "ONDERGANG", + "da": "SOLNEDGANG", + "no": "SOLNEDGANG", + "sv": "SOLNEDGÅNG" + } + }, + "DAYLIGHT": { + "context": "Total hours of daylight.", + "translations": { + "fr": "JOUR", + "de": "TAGESLICHT", + "es": "LUZ DIA", + "it": "LUCE", + "pt": "LUZ DIA", + "nl": "DAGLICHT", + "da": "DAGSLYS", + "no": "DAGSLYS", + "sv": "DAGSLJUS" + } + }, + "UP": { + "context": "Very short label for sunrise (sun is up).", + "translations": { + "fr": "LEV", + "de": "AUF", + "es": "SAL", + "it": "ALBA", + "pt": "NASC", + "nl": "OP" + } + }, + "DN": { + "context": "Very short label for sunset (sun is down).", + "translations": { + "fr": "COU", + "de": "UNT", + "es": "OCA", + "it": "TRAM", + "pt": "OCA", + "nl": "OND" + } + } + }, + "moon": { + "NEW MOON": { + "context": "Lunar phase: new moon.", + "translations": { + "fr": "NOUVELLE LUNE", + "de": "NEUMOND", + "es": "LUNA NUEVA", + "it": "LUNA NUOVA", + "pt": "LUA NOVA", + "nl": "NIEUWE MAAN", + "da": "NYMÅNE", + "no": "NYMÅNE", + "sv": "NYMÅNE" + } + }, + "FULL MOON": { + "context": "Lunar phase: full moon.", + "translations": { + "fr": "PLEINE LUNE", + "de": "VOLLMOND", + "es": "LUNA LLENA", + "it": "LUNA PIENA", + "pt": "LUA CHEIA", + "nl": "VOLLE MAAN", + "da": "FULDMÅNE", + "no": "FULLMÅNE", + "sv": "FULLMÅNE" + } + }, + "FIRST QUARTER": { + "context": "Lunar phase: first quarter (keep short).", + "translations": { + "fr": "1ER QUARTIER", + "de": "1. VIERTEL", + "es": "CUARTO CREC.", + "it": "PRIMO QUARTO", + "pt": "QUARTO CRESC.", + "nl": "EERSTE KWART." + } + }, + "LAST QUARTER": { + "context": "Lunar phase: last quarter (keep short).", + "translations": { + "fr": "DERN. QUARTIER", + "de": "LETZT. VIERTEL", + "es": "CUARTO MENG.", + "it": "ULTIMO QUARTO", + "pt": "QUARTO MING.", + "nl": "LAATSTE KWART." + } + }, + "WAXING CRESCENT": { + "context": "Lunar phase: waxing crescent (growing).", + "translations": { + "fr": "1ER CROISSANT", + "de": "ZUN. SICHEL", + "es": "CRECIENTE", + "it": "CRESCENTE", + "pt": "CRESCENTE", + "nl": "WASSEND" + } + }, + "WANING CRESCENT": { + "context": "Lunar phase: waning crescent (shrinking).", + "translations": { + "fr": "DERN. CROISSANT", + "de": "ABN. SICHEL", + "es": "MENGUANTE", + "it": "CALANTE", + "pt": "MINGUANTE", + "nl": "AFNEMEND" + } + }, + "WAXING GIBBOUS": { + "context": "Lunar phase: waxing gibbous (growing).", + "translations": { + "fr": "GIBBEUSE CROIS.", + "de": "ZUN. MOND", + "es": "GIBOSA CREC.", + "it": "GIBBOSA CRESC.", + "pt": "GIBOSA CRESC.", + "nl": "WASSEND" + } + }, + "WANING GIBBOUS": { + "context": "Lunar phase: waning gibbous (shrinking).", + "translations": { + "fr": "GIBBEUSE DECR.", + "de": "ABN. MOND", + "es": "GIBOSA MENG.", + "it": "GIBBOSA CAL.", + "pt": "GIBOSA MING.", + "nl": "AFNEMEND" + } + }, + "LIT": { + "context": "Precedes the percentage of a surface that is illuminated, e.g. '16% LIT'.", + "translations": { + "fr": "ECLAIRE", + "de": "HELL", + "es": "ILUM.", + "it": "ILLUM.", + "pt": "ILUM.", + "nl": "VERL.", + "da": "OPLYST", + "no": "OPPLYST", + "sv": "UPPLYST" + } + }, + "FULL IN": { + "context": "'FULL IN 3D' = time until something is full (e.g. the moon).", + "translations": { + "fr": "PLEINE DANS", + "de": "VOLL IN", + "es": "LLENA EN", + "it": "PIENA IN", + "pt": "CHEIA EM", + "nl": "VOL OVER", + "da": "FULD OM", + "no": "FULL OM", + "sv": "FULL OM" + } + }, + "NEW IN": { + "context": "'NEW IN 3D' = time until something is new (e.g. the moon).", + "translations": { + "fr": "NOUV. DANS", + "de": "NEU IN", + "es": "NUEVA EN", + "it": "NUOVA IN", + "pt": "NOVA EM", + "nl": "NIEUW OVER", + "da": "NY OM", + "no": "NY OM", + "sv": "NY OM" + } + } + }, + "weather": { + "CLEAR": { + "context": "Weather condition: clear sky.", + "translations": { + "fr": "DEGAGE", + "de": "KLAR", + "es": "DESPEJADO", + "it": "SERENO", + "pt": "LIMPO", + "nl": "HELDER", + "da": "KLART", + "no": "KLART", + "sv": "KLART" + } + }, + "MAINLY CLEAR": { + "context": "Weather condition: mostly clear sky.", + "translations": { + "fr": "PLUTOT CLAIR", + "de": "MEIST KLAR", + "es": "MAYORM. CLARO", + "it": "POCO NUVOLOSO", + "pt": "QUASE LIMPO", + "nl": "VNL. HELDER" + } + }, + "PARTLY CLOUDY": { + "context": "Weather condition: partly cloudy.", + "translations": { + "fr": "NUAGEUX", + "de": "TEILS WOLKIG", + "es": "PARC. NUBLADO", + "it": "POCO NUVOLOSO", + "pt": "PARC. NUBLADO", + "nl": "HALF BEWOLKT", + "da": "DELVIS SKYET", + "no": "DELVIS SKYET", + "sv": "DELVIS MOLNIGT" + } + }, + "OVERCAST": { + "context": "Weather condition: fully clouded over.", + "translations": { + "fr": "COUVERT", + "de": "BEDECKT", + "es": "CUBIERTO", + "it": "COPERTO", + "pt": "ENCOBERTO", + "nl": "BEWOLKT", + "da": "OVERSKYET", + "no": "OVERSKYET", + "sv": "MULET" + } + }, + "FOG": { + "context": "Weather condition: fog.", + "translations": { + "fr": "BROUILLARD", + "de": "NEBEL", + "es": "NIEBLA", + "it": "NEBBIA", + "pt": "NEVOEIRO", + "nl": "MIST", + "da": "TÅGE", + "no": "TÅKE", + "sv": "DIMMA" + } + }, + "RIME FOG": { + "context": "Weather condition: freezing/rime fog.", + "translations": { + "fr": "BRUME GIVRANTE", + "de": "RAUREIF", + "es": "NIEBLA HELADA", + "it": "NEBBIA GHIAC.", + "pt": "NEVOA GELADA", + "nl": "RIJP-MIST" + } + }, + "LIGHT DRIZZLE": { + "context": "Weather condition: light drizzle.", + "translations": { + "fr": "BRUINE LEGERE", + "de": "LEICHT NIESEL", + "es": "LLOVIZNA LEVE", + "it": "PIOVIGGINE", + "pt": "CHUVISCO FRACO", + "nl": "LICHTE MOTREG" + } + }, + "DRIZZLE": { + "context": "Weather condition: drizzle.", + "translations": { + "fr": "BRUINE", + "de": "NIESELN", + "es": "LLOVIZNA", + "it": "PIOVIGGINE", + "pt": "CHUVISCO", + "nl": "MOTREGEN" + } + }, + "HEAVY DRIZZLE": { + "context": "Weather condition: heavy drizzle.", + "translations": { + "fr": "BRUINE FORTE", + "de": "STARK. NIESEL", + "es": "LLOVIZNA FTE", + "it": "PIOVIGGINE FT", + "pt": "CHUVISCO FORTE", + "nl": "ZWARE MOTREG" + } + }, + "LIGHT RAIN": { + "context": "Weather condition: light rain.", + "translations": { + "fr": "PLUIE LEGERE", + "de": "LEICHT. REGEN", + "es": "LLUVIA LEVE", + "it": "PIOGGIA LEGG.", + "pt": "CHUVA FRACA", + "nl": "LICHTE REGEN", + "da": "LET REGN", + "no": "LETT REGN", + "sv": "LÄTT REGN" + } + }, + "RAIN": { + "context": "Weather condition: rain.", + "translations": { + "fr": "PLUIE", + "de": "REGEN", + "es": "LLUVIA", + "it": "PIOGGIA", + "pt": "CHUVA", + "nl": "REGEN", + "da": "REGN", + "no": "REGN", + "sv": "REGN" + } + }, + "HEAVY RAIN": { + "context": "Weather condition: heavy rain.", + "translations": { + "fr": "PLUIE FORTE", + "de": "STARKREGEN", + "es": "LLUVIA FUERTE", + "it": "PIOGGIA FORTE", + "pt": "CHUVA FORTE", + "nl": "ZWARE REGEN", + "da": "KRAFTIG REGN", + "no": "KRAFTIG REGN", + "sv": "KRAFTIGT REGN" + } + }, + "LIGHT FREEZING RAIN": { + "context": "Weather condition: light freezing rain.", + "translations": { + "fr": "PLUIE VERGLAC.", + "de": "L. GEFR. REGEN", + "es": "LLUVIA HELADA", + "it": "PIOGGIA GELATA", + "pt": "CHUVA GELADA", + "nl": "LICHTE IJZEL" + } + }, + "FREEZING RAIN": { + "context": "Weather condition: freezing rain.", + "translations": { + "fr": "PLUIE VERGLAC.", + "de": "GEFRIER-REGEN", + "es": "LLUVIA HELADA", + "it": "PIOGGIA GELATA", + "pt": "CHUVA GELADA", + "nl": "IJZEL" + } + }, + "LIGHT SNOW": { + "context": "Weather condition: light snow.", + "translations": { + "fr": "NEIGE LEGERE", + "de": "LEICHT SCHNEE", + "es": "NIEVE LEVE", + "it": "NEVE LEGGERA", + "pt": "NEVE FRACA", + "nl": "LICHTE SNEEUW", + "da": "LET SNE", + "no": "LETT SNØ", + "sv": "LÄTT SNÖ" + } + }, + "SNOW": { + "context": "Weather condition: snow.", + "translations": { + "fr": "NEIGE", + "de": "SCHNEE", + "es": "NIEVE", + "it": "NEVE", + "pt": "NEVE", + "nl": "SNEEUW", + "da": "SNE", + "no": "SNØ", + "sv": "SNÖ" + } + }, + "HEAVY SNOW": { + "context": "Weather condition: heavy snow.", + "translations": { + "fr": "NEIGE FORTE", + "de": "STARK. SCHNEE", + "es": "NIEVE FUERTE", + "it": "NEVE FORTE", + "pt": "NEVE FORTE", + "nl": "ZWARE SNEEUW", + "da": "KRAFTIG SNE", + "no": "KRAFTIG SNØ", + "sv": "KRAFTIGT SNÖ" + } + }, + "SNOW GRAINS": { + "context": "Weather condition: snow grains.", + "translations": { + "fr": "GRAINS NEIGE", + "de": "SCHNEEGRIESEL", + "es": "GRANIZO NIEVE", + "it": "NEVE GRANUL.", + "pt": "NEVE GRANULAR", + "nl": "KORRELSNEEUW" + } + }, + "RAIN SHOWERS": { + "context": "Weather condition: rain showers.", + "translations": { + "fr": "AVERSES", + "de": "REGENSCHAUER", + "es": "CHUBASCOS", + "it": "ROVESCI", + "pt": "AGUACEIROS", + "nl": "REGENBUIEN" + } + }, + "HEAVY SHOWERS": { + "context": "Weather condition: heavy rain showers.", + "translations": { + "fr": "AVERSES FORTES", + "de": "STARK. SCHAUER", + "es": "CHUBASCOS FTES", + "it": "ROVESCI FORTI", + "pt": "AGUAC. FORTES", + "nl": "ZWARE BUIEN" + } + }, + "SNOW SHOWERS": { + "context": "Weather condition: snow showers.", + "translations": { + "fr": "AVERSES NEIGE", + "de": "SCHNEESCHAUER", + "es": "CHUBASCOS NIEVE", + "it": "ROVESCI NEVE", + "pt": "AGUAC. NEVE", + "nl": "SNEEUWBUIEN" + } + }, + "HEAVY SNOW SHOWERS": { + "context": "Weather condition: heavy snow showers.", + "translations": { + "fr": "AVERSES NEIGE", + "de": "SCHNEESCHAUER", + "es": "CHUBASCOS NIEVE", + "it": "ROVESCI NEVE", + "pt": "AGUAC. NEVE", + "nl": "SNEEUWBUIEN" + } + }, + "THUNDERSTORM": { + "context": "Weather condition: thunderstorm.", + "translations": { + "fr": "ORAGE", + "de": "GEWITTER", + "es": "TORMENTA", + "it": "TEMPORALE", + "pt": "TROVOADA", + "nl": "ONWEER", + "da": "TORDENVEJR", + "no": "TORDENVÆR", + "sv": "ÅSKA" + } + }, + "THUNDER HAIL": { + "context": "Weather condition: thunderstorm with hail.", + "translations": { + "fr": "ORAGE GRELE", + "de": "GEWITTER HAGEL", + "es": "TORM. GRANIZO", + "it": "TEMP. GRANDINE", + "pt": "TROV. GRANIZO", + "nl": "ONWEER HAGEL" + } + }, + "SEVERE TSTORM": { + "context": "Weather condition: severe thunderstorm (abbreviated).", + "translations": { + "fr": "ORAGE VIOLENT", + "de": "SCHW. GEWITTER", + "es": "TORM. FUERTE", + "it": "TEMP. VIOLENTO", + "pt": "TROV. FORTE", + "nl": "ZWAAR ONWEER" + } + }, + "CURRENT CONDITIONS": { + "context": "Generic label for present weather conditions.", + "translations": { + "fr": "CONDITIONS", + "de": "WETTER", + "es": "TIEMPO", + "it": "METEO", + "pt": "TEMPO", + "nl": "WEER", + "da": "VEJR", + "no": "VÆR", + "sv": "VÄDER" + } + }, + "FEELS": { + "context": "Precedes the 'feels like' temperature.", + "translations": { + "fr": "RESSENTI", + "de": "GEFUHLT", + "es": "SENSAC.", + "it": "PERCEP.", + "pt": "SENSAC.", + "nl": "GEVOELD", + "da": "FØLES", + "no": "FØLES", + "sv": "KÄNNS" + } + }, + "FLS": { + "context": "Very short 'feels like' label.", + "translations": { + "fr": "RES", + "de": "GEF", + "es": "SEN", + "it": "PER", + "pt": "SEN", + "nl": "GVL" + } + }, + "AIR QUALITY": { + "context": "Air-quality label/heading.", + "translations": { + "fr": "QUALITE AIR", + "de": "LUFTQUALITAT", + "es": "CALIDAD AIRE", + "it": "QUALITA ARIA", + "pt": "QUALID. AR", + "nl": "LUCHTKWAL.", + "da": "LUFTKVALITET", + "no": "LUFTKVALITET", + "sv": "LUFTKVALITET" + } + }, + "SUN EXPOSURE": { + "context": "UV / sun-exposure label/heading.", + "translations": { + "fr": "EXPO SOLEIL", + "de": "UV-BELASTUNG", + "es": "EXPO SOLAR", + "it": "ESPOS. SOLE", + "pt": "EXPO SOLAR", + "nl": "ZONKRACHT" + } + }, + "SUN UV": { + "context": "Short 'sun UV' label.", + "translations": { + "fr": "UV SOLEIL", + "de": "SONNE UV", + "es": "UV SOLAR", + "it": "UV SOLE", + "pt": "UV SOLAR", + "nl": "ZON UV" + } + }, + "POLLEN": { + "context": "Pollen label/heading.", + "translations": { + "fr": "POLLEN", + "de": "POLLEN", + "es": "POLEN", + "it": "POLLINE", + "pt": "POLEN", + "nl": "POLLEN", + "da": "POLLEN", + "no": "POLLEN", + "sv": "POLLEN" + } + }, + "OVERALL": { + "context": "The 'overall' level in a breakdown.", + "translations": { + "fr": "GLOBAL", + "de": "GESAMT", + "es": "TOTAL", + "it": "TOTALE", + "pt": "GERAL", + "nl": "TOTAAL", + "da": "SAMLET", + "no": "SAMLET", + "sv": "TOTALT" + } + }, + "OVR": { + "context": "Very short 'overall'.", + "translations": { + "fr": "GLB", + "de": "GES", + "es": "TOT", + "it": "TOT", + "pt": "GER", + "nl": "TOT" + } + }, + "GRASS": { + "context": "Grass (e.g. grass pollen).", + "translations": { + "fr": "HERBE", + "de": "GRAS", + "es": "HIERBA", + "it": "ERBA", + "pt": "RELVA", + "nl": "GRAS", + "da": "GRÆS", + "no": "GRESS", + "sv": "GRÄS" + } + }, + "GRS": { + "context": "Very short 'grass'.", + "translations": { + "fr": "HRB", + "de": "GRA", + "es": "HIE", + "it": "ERB", + "pt": "REL", + "nl": "GRS" + } + }, + "TREE": { + "context": "Tree (e.g. tree pollen).", + "translations": { + "fr": "ARBRE", + "de": "BAUM", + "es": "ARBOL", + "it": "ALBERO", + "pt": "ARVORE", + "nl": "BOOM", + "da": "TRÆ", + "no": "TRE", + "sv": "TRÄD" + } + }, + "TRE": { + "context": "Very short 'tree'.", + "translations": { + "fr": "ARB", + "de": "BAU", + "es": "ARB", + "it": "ALB", + "pt": "ARV", + "nl": "BOM" + } + }, + "WEED": { + "context": "Weed (e.g. weed pollen).", + "translations": { + "fr": "HERBACEE", + "de": "UNKRAUT", + "es": "MALEZA", + "it": "ERBACCE", + "pt": "ERVAS", + "nl": "ONKRUID", + "da": "UKRUDT", + "no": "UGRESS", + "sv": "OGRÄS" + } + }, + "WED": { + "context": "Very short 'weed'.", + "translations": { + "fr": "HER", + "de": "UNK", + "es": "MAL", + "it": "ERB", + "pt": "ERV", + "nl": "ONK" + } + }, + "PROV": { + "context": "'Source/provider' label (which data provider).", + "translations": { + "fr": "SRCE", + "de": "QUELLE", + "es": "FUENTE", + "it": "FONTE", + "pt": "FONTE", + "nl": "BRON" + } + }, + "PRV": { + "context": "Very short 'provider'.", + "translations": { + "fr": "SRC", + "de": "QLE", + "es": "FTE", + "it": "FNT", + "pt": "FNT", + "nl": "BRN" + } + }, + "GOOD": { + "context": "Qualitative level: good.", + "translations": { + "fr": "BON", + "de": "GUT", + "es": "BUENA", + "it": "BUONA", + "pt": "BOA", + "nl": "GOED", + "da": "GOD", + "no": "GOD", + "sv": "BRA" + } + }, + "FAIR": { + "context": "Qualitative level: fair (between good and moderate).", + "translations": { + "fr": "CORRECT", + "de": "MASSIG", + "es": "ACEPTABLE", + "it": "DISCRETA", + "pt": "RAZOAVEL", + "nl": "REDELIJK" + } + }, + "MODERATE": { + "context": "Qualitative level: moderate.", + "translations": { + "fr": "MODERE", + "de": "MASSIG", + "es": "MODERADA", + "it": "MODERATA", + "pt": "MODERADA", + "nl": "MATIG", + "da": "MODERAT", + "no": "MODERAT", + "sv": "MÅTTLIG" + } + }, + "POOR": { + "context": "Qualitative level: poor.", + "translations": { + "fr": "MAUVAIS", + "de": "SCHLECHT", + "es": "MALA", + "it": "SCARSA", + "pt": "FRACA", + "nl": "SLECHT", + "da": "DÅRLIG", + "no": "DÅRLIG", + "sv": "DÅLIG" + } + }, + "V.POOR": { + "context": "Qualitative level: very poor (abbreviated).", + "translations": { + "fr": "TRES MAUVAIS", + "de": "SEHR SCHL.", + "es": "MUY MALA", + "it": "PESSIMA", + "pt": "MUITO FRACA", + "nl": "ZEER SLECHT" + } + }, + "MOD": { + "context": "Qualitative level: moderate (abbreviated).", + "translations": { + "fr": "MOYEN", + "de": "MITTEL", + "es": "MODER.", + "it": "MEDIO", + "pt": "MODER.", + "nl": "MATIG" + } + }, + "USG": { + "context": "Air quality 'unhealthy for sensitive groups' (abbreviated).", + "translations": { + "fr": "SENSIBLES", + "de": "EMPFINDL.", + "es": "SENSIBLES", + "it": "SENSIBILI", + "pt": "SENSIVEIS", + "nl": "GEVOELIG" + } + }, + "UNHEALTHY": { + "context": "Qualitative level: unhealthy.", + "translations": { + "fr": "MALSAIN", + "de": "UNGESUND", + "es": "INSALUBRE", + "it": "MALSANO", + "pt": "INSALUBRE", + "nl": "ONGEZOND" + } + }, + "V.UNHLTHY": { + "context": "Qualitative level: very unhealthy (abbreviated).", + "translations": { + "fr": "TRES MALSAIN", + "de": "SEHR UNGESUND", + "es": "MUY INSALUBRE", + "it": "MOLTO MALSANO", + "pt": "MUITO INSALUB", + "nl": "ZEER ONGEZOND" + } + }, + "HAZARDOUS": { + "context": "Qualitative level: hazardous (worst).", + "translations": { + "fr": "DANGEREUX", + "de": "GEFAHRLICH", + "es": "PELIGROSA", + "it": "PERICOLOSA", + "pt": "PERIGOSA", + "nl": "GEVAARLIJK" + } + }, + "LOW": { + "context": "Qualitative level: low.", + "translations": { + "fr": "FAIBLE", + "de": "NIEDRIG", + "es": "BAJO", + "it": "BASSO", + "pt": "BAIXO", + "nl": "LAAG", + "da": "LAV", + "no": "LAV", + "sv": "LÅG" + } + }, + "HIGH": { + "context": "Qualitative level: high.", + "translations": { + "fr": "ELEVE", + "de": "HOCH", + "es": "ALTO", + "it": "ALTO", + "pt": "ALTO", + "nl": "HOOG", + "da": "HØJ", + "no": "HØY", + "sv": "HÖG" + } + }, + "V.HIGH": { + "context": "Qualitative level: very high (abbreviated).", + "translations": { + "fr": "TRES ELEVE", + "de": "SEHR HOCH", + "es": "MUY ALTO", + "it": "MOLTO ALTO", + "pt": "MUITO ALTO", + "nl": "ZEER HOOG" + } + }, + "EXTREME": { + "context": "Qualitative level: extreme.", + "translations": { + "fr": "EXTREME", + "de": "EXTREM", + "es": "EXTREMO", + "it": "ESTREMO", + "pt": "EXTREMO", + "nl": "EXTREEM", + "da": "EKSTREM", + "no": "EKSTREM", + "sv": "EXTREM" + } + }, + "NONE": { + "context": "Level 'none' / zero.", + "translations": { + "fr": "AUCUN", + "de": "KEIN", + "es": "NINGUNO", + "it": "NESSUNO", + "pt": "NENHUM", + "nl": "GEEN", + "da": "INGEN", + "no": "INGEN", + "sv": "INGEN" + } + } + }, + "metals": { + "GOLD": { + "context": "Precious metal: gold.", + "translations": { + "fr": "OR", + "de": "GOLD", + "es": "ORO", + "it": "ORO", + "pt": "OURO", + "nl": "GOUD", + "da": "GULD", + "no": "GULL", + "sv": "GULD" + } + }, + "SILVER": { + "context": "Precious metal: silver.", + "translations": { + "fr": "ARGENT", + "de": "SILBER", + "es": "PLATA", + "it": "ARGENTO", + "pt": "PRATA", + "nl": "ZILVER", + "da": "SØLV", + "no": "SØLV", + "sv": "SILVER" + } + }, + "PLATINUM": { + "context": "Precious metal: platinum.", + "translations": { + "fr": "PLATINE", + "de": "PLATIN", + "es": "PLATINO", + "it": "PLATINO", + "pt": "PLATINA", + "nl": "PLATINA", + "da": "PLATIN", + "no": "PLATINA", + "sv": "PLATINA" + } + }, + "PALLADIUM": { + "context": "Precious metal: palladium.", + "translations": { + "fr": "PALLADIUM", + "de": "PALLADIUM", + "es": "PALADIO", + "it": "PALLADIO", + "pt": "PALADIO", + "nl": "PALLADIUM", + "da": "PALLADIUM", + "no": "PALLADIUM", + "sv": "PALLADIUM" + } + }, + "SPOT PRICE": { + "context": "Label for a current spot (market) price.", + "translations": { + "fr": "COURS", + "de": "KURS", + "es": "PRECIO", + "it": "PREZZO", + "pt": "PRECO", + "nl": "KOERS", + "da": "SPOTPRIS", + "no": "SPOTPRIS", + "sv": "SPOTPRIS" + } + } + }, + "tides": { + "TIDES": { + "context": "Tides heading.", + "translations": { + "fr": "MAREES", + "de": "GEZEITEN", + "es": "MAREAS", + "it": "MAREE", + "pt": "MARES", + "nl": "GETIJDEN", + "da": "TIDEVAND", + "no": "TIDEVANN", + "sv": "TIDVATTEN" + } + }, + "HIGH TIDE": { + "context": "High tide.", + "translations": { + "fr": "MAREE HAUTE", + "de": "FLUT", + "es": "PLEAMAR", + "it": "ALTA MAREA", + "pt": "PREIA-MAR", + "nl": "VLOED", + "da": "HØJVANDE", + "no": "HØYVANN", + "sv": "HÖGVATTEN" + } + }, + "LOW TIDE": { + "context": "Low tide.", + "translations": { + "fr": "MAREE BASSE", + "de": "EBBE", + "es": "BAJAMAR", + "it": "BASSA MAREA", + "pt": "BAIXA-MAR", + "nl": "EB", + "da": "LAVVANDE", + "no": "LAVVANN", + "sv": "LÅGVATTEN" + } + }, + "CHECK STATION": { + "context": "Prompt to pick/verify a measurement station.", + "translations": { + "fr": "VERIF STATION", + "de": "STATION PRUEF", + "es": "VER ESTACION", + "it": "VERIF STAZIONE", + "pt": "VER ESTACAO", + "nl": "STATION CHECK" + } + }, + "HIGH": { + "context": "High tide (short label).", + "translations": { + "fr": "HAUTE", + "de": "HOCH", + "es": "ALTA", + "it": "ALTA", + "pt": "ALTA", + "nl": "HOOG", + "da": "HØJ", + "no": "HØY", + "sv": "HÖG" + } + }, + "LOW": { + "context": "Low tide (short label).", + "translations": { + "fr": "BASSE", + "de": "NIEDRIG", + "es": "BAJA", + "it": "BASSA", + "pt": "BAIXA", + "nl": "LAAG", + "da": "LAV", + "no": "LAV", + "sv": "LÅG" + } + } + }, + "aurora": { + "AURORA": { + "context": "Aurora (northern/southern lights) heading.", + "translations": { + "fr": "AURORE", + "de": "POLARLICHT", + "es": "AURORA", + "it": "AURORA", + "pt": "AURORA", + "nl": "NOORDERLICHT" + } + }, + "QUIET": { + "context": "Activity level: quiet/calm.", + "translations": { + "fr": "CALME", + "de": "RUHIG", + "es": "TRANQUILO", + "it": "QUIETE", + "pt": "CALMO", + "nl": "RUSTIG" + } + }, + "UNSETTLED": { + "context": "Activity level: unsettled.", + "translations": { + "fr": "AGITE", + "de": "UNRUHIG", + "es": "INESTABLE", + "it": "INSTABILE", + "pt": "INSTAVEL", + "nl": "ONRUSTIG" + } + }, + "MINOR STORM": { + "context": "Storm level: minor.", + "translations": { + "fr": "ORAGE MINEUR", + "de": "KL. STURM", + "es": "TORM. MENOR", + "it": "TEMP. MINORE", + "pt": "TORM. MENOR", + "nl": "KL. STORM" + } + }, + "STRONG STORM": { + "context": "Storm level: strong.", + "translations": { + "fr": "ORAGE FORT", + "de": "STARK. STURM", + "es": "TORM. FUERTE", + "it": "TEMP. FORTE", + "pt": "TORM. FORTE", + "nl": "ZWARE STORM" + } + }, + "SEVERE STORM": { + "context": "Storm level: severe.", + "translations": { + "fr": "ORAGE SEVERE", + "de": "SCHW. STURM", + "es": "TORM. SEVERA", + "it": "TEMP. SEVERO", + "pt": "TORM. SEVERA", + "nl": "HEVIGE STORM" + } + } + }, + "space": { + "NEXT LAUNCH": { + "context": "'Next launch' label.", + "translations": { + "fr": "PROCH. LANCEMENT", + "de": "NAECHST. START", + "es": "PROX. LANZAMIENTO", + "it": "PROSS. LANCIO", + "pt": "PROX. LANCAMENTO", + "nl": "VOLGENDE START" + } + }, + "MISSION": { + "context": "'Mission' label (e.g. a mission name).", + "translations": { + "fr": "MISSION", + "de": "MISSION", + "es": "MISION", + "it": "MISSIONE", + "pt": "MISSAO", + "nl": "MISSIE", + "da": "MISSION", + "no": "OPPDRAG", + "sv": "UPPDRAG" + } + }, + "IMMINENT": { + "context": "About to happen (very soon).", + "translations": { + "fr": "IMMINENT", + "de": "UNMITTELBAR", + "es": "INMINENTE", + "it": "IMMINENTE", + "pt": "IMINENTE", + "nl": "OP KOMST", + "da": "SNART", + "no": "SNART", + "sv": "SNART" + } + }, + "SCHEDULED": { + "context": "Planned for a set time.", + "translations": { + "fr": "PREVU", + "de": "GEPLANT", + "es": "PROGRAMADO", + "it": "PREVISTO", + "pt": "AGENDADO", + "nl": "GEPLAND", + "da": "PLANLAGT", + "no": "PLANLAGT", + "sv": "PLANERAD" + } + }, + "NONE": { + "context": "No launch scheduled.", + "translations": { + "fr": "AUCUN", + "de": "KEIN", + "es": "NINGUNO", + "it": "NESSUNO", + "pt": "NENHUM", + "nl": "GEEN", + "da": "INGEN", + "no": "INGEN", + "sv": "INGEN" + } + }, + "IN SPACE": { + "context": "Suffix after a count: 'N people in space'.", + "translations": { + "fr": "DANS L'ESPACE", + "de": "IM WELTALL", + "es": "EN EL ESPACIO", + "it": "NELLO SPAZIO", + "pt": "NO ESPAÇO", + "nl": "IN DE RUIMTE", + "da": "I RUMMET", + "no": "I ROMMET", + "sv": "I RYMDEN" + } + } + }, + "sports": { + "NEXT GP": { + "context": "'Next Grand Prix' (short).", + "translations": { + "fr": "PROCHAIN GP", + "de": "NAECHSTER GP", + "es": "PROXIMO GP", + "it": "PROSSIMO GP", + "pt": "PROXIMO GP", + "nl": "VOLGENDE GP" + } + }, + "NEXT GRAND PRIX": { + "context": "'Next Grand Prix'.", + "translations": { + "fr": "PROCHAIN GP", + "de": "NAECHSTER GP", + "es": "PROXIMO GP", + "it": "PROSSIMO GP", + "pt": "PROXIMO GP", + "nl": "VOLGENDE GP" + } + }, + "RACE WEEKEND": { + "context": "'Race weekend' label.", + "translations": { + "fr": "WEEKEND COURSE", + "de": "RENN-WOCHENENDE", + "es": "FIN DE SEMANA", + "it": "WEEKEND GARA", + "pt": "FIM DE SEMANA", + "nl": "RACEWEEKEND" + } + }, + "SEASON": { + "context": "'Season' label (e.g. a sports season).", + "translations": { + "fr": "SAISON", + "de": "SAISON", + "es": "TEMPORADA", + "it": "STAGIONE", + "pt": "TEMPORADA", + "nl": "SEIZOEN", + "da": "SÆSON", + "no": "SESONG", + "sv": "SÄSONG" + } + }, + "OVER": { + "context": "Something has ended / is over.", + "translations": { + "fr": "TERMINEE", + "de": "VORBEI", + "es": "TERMINADA", + "it": "FINITA", + "pt": "TERMINADA", + "nl": "VOORBIJ", + "da": "SLUT", + "no": "OVER", + "sv": "SLUT" + } + }, + "CHAMPIONSHIP": { + "context": "'Championship' label.", + "translations": { + "fr": "CHAMPIONNAT", + "de": "MEISTERSCHAFT", + "es": "CAMPEONATO", + "it": "CAMPIONATO", + "pt": "CAMPEONATO", + "nl": "KAMPIOENSCHAP" + } + }, + "LEADER": { + "context": "The leader / person in first place.", + "translations": { + "fr": "LEADER", + "de": "FUEHRENDER", + "es": "LIDER", + "it": "LEADER", + "pt": "LIDER", + "nl": "LEIDER", + "da": "FØRER", + "no": "LEDER", + "sv": "LEDARE" + } + }, + "POINTS": { + "context": "'Points' (a score).", + "translations": { + "fr": "POINTS", + "de": "PUNKTE", + "es": "PUNTOS", + "it": "PUNTI", + "pt": "PONTOS", + "nl": "PUNTEN", + "da": "POINT", + "no": "POENG", + "sv": "POÄNG" + } + }, + "PTS": { + "context": "Abbreviation of 'points'.", + "translations": { + "fr": "PTS", + "de": "PKT", + "es": "PTS", + "it": "PTI", + "pt": "PTS", + "nl": "PNT" + } + }, + "FINAL": { + "context": "A finished game (final score).", + "translations": { + "fr": "TERMINÉ", + "de": "BEENDET", + "es": "FINAL", + "it": "FINALE", + "pt": "FINAL", + "nl": "AFGELOPEN", + "da": "SLUT", + "no": "SLUTT", + "sv": "SLUT" + } + }, + "LIVE": { + "context": "A game in progress.", + "translations": { + "fr": "EN DIRECT", + "de": "LIVE", + "es": "EN VIVO", + "it": "IN DIRETTA", + "pt": "AO VIVO", + "nl": "LIVE", + "da": "LIVE", + "no": "DIREKTE", + "sv": "DIREKT" + } + }, + "UPCOMING": { + "context": "A game not yet started.", + "translations": { + "fr": "À VENIR", + "de": "DEMNÄCHST", + "es": "PRÓXIMO", + "it": "IN ARRIVO", + "pt": "EM BREVE", + "nl": "BINNENKORT", + "da": "KOMMENDE", + "no": "KOMMENDE", + "sv": "KOMMANDE" + } + }, + "SCHEDULED": { + "context": "A game/event scheduled for later.", + "translations": { + "fr": "PRÉVU", + "de": "GEPLANT", + "es": "PROGRAMADO", + "it": "PREVISTO", + "pt": "AGENDADO", + "nl": "GEPLAND", + "da": "PLANLAGT", + "no": "PLANLAGT", + "sv": "PLANERAD" + } + }, + "NO GAMES": { + "context": "No games found.", + "translations": { + "fr": "AUCUN MATCH", + "de": "KEINE SPIELE", + "es": "SIN PARTIDOS", + "it": "NESSUNA PARTITA", + "pt": "SEM JOGOS", + "nl": "GEEN DUELS", + "da": "INGEN KAMPE", + "no": "INGEN KAMPER", + "sv": "INGA MATCHER" + } + }, + "NO TEAMS": { + "context": "No teams configured.", + "translations": { + "fr": "AUCUNE ÉQUIPE", + "de": "KEINE TEAMS", + "es": "SIN EQUIPOS", + "it": "NESSUNA SQUADRA", + "pt": "SEM EQUIPES", + "nl": "GEEN TEAMS", + "da": "INGEN HOLD", + "no": "INGEN LAG", + "sv": "INGA LAG" + } + }, + "NO EVENT": { + "context": "No event this week.", + "translations": { + "fr": "AUCUN EVENT", + "de": "KEIN EVENT", + "es": "SIN EVENTO", + "it": "NESSUN EVENTO", + "pt": "SEM EVENTO", + "nl": "GEEN EVENT", + "da": "INGEN EVENT", + "no": "INGEN EVENT", + "sv": "INGET EVENT" + } + }, + "NO LEADERS": { + "context": "No leaderboard available.", + "translations": { + "fr": "AUCUN LEADER", + "de": "KEINE FÜHRENDEN", + "es": "SIN LÍDERES", + "it": "NESSUN LEADER", + "pt": "SEM LÍDERES", + "nl": "GEEN LEIDERS", + "da": "INGEN LEDERE", + "no": "INGEN LEDERE", + "sv": "INGA LEDARE" + } + }, + "NO FIGHTS": { + "context": "No fights scheduled.", + "translations": { + "fr": "AUCUN COMBAT", + "de": "KEINE KÄMPFE", + "es": "SIN COMBATES", + "it": "NESSUN INCONTRO", + "pt": "SEM LUTAS", + "nl": "GEEN GEVECHTEN", + "da": "INGEN KAMPE", + "no": "INGEN KAMPER", + "sv": "INGA MATCHER" + } + }, + "CONFIGURED": { + "context": "'... configured' (paired with NO TEAMS).", + "translations": { + "fr": "CONFIGURÉE", + "de": "EINGERICHTET", + "es": "CONFIGURADO", + "it": "CONFIGURATO", + "pt": "CONFIGURADO", + "nl": "INGESTELD", + "da": "OPSAT", + "no": "SATT OPP", + "sv": "KONFIG." + } + }, + "FOUND": { + "context": "'... found' (paired with NO GAMES).", + "translations": { + "fr": "TROUVÉ", + "de": "GEFUNDEN", + "es": "ENCONTRADO", + "it": "TROVATO", + "pt": "ENCONTRADO", + "nl": "GEVONDEN", + "da": "FUNDET", + "no": "FUNNET", + "sv": "HITTAT" + } + }, + "THIS WEEK": { + "context": "'this week' timeframe.", + "translations": { + "fr": "CETTE SEMAINE", + "de": "DIESE WOCHE", + "es": "ESTA SEMANA", + "it": "QUESTA SETT.", + "pt": "ESTA SEMANA", + "nl": "DEZE WEEK", + "da": "DENNE UGE", + "no": "DENNE UKEN", + "sv": "DENNA VECKA" + } + }, + "ALL": { + "context": "Filter label: all games.", + "translations": { + "fr": "TOUS", + "de": "ALLE", + "es": "TODOS", + "it": "TUTTI", + "pt": "TODOS", + "nl": "ALLE", + "da": "ALLE", + "no": "ALLE", + "sv": "ALLA" + } + }, + "NOTHING": { + "context": "'nothing followed' empty state.", + "translations": { + "fr": "RIEN", + "de": "NICHTS", + "es": "NADA", + "it": "NIENTE", + "pt": "NADA", + "nl": "NIETS", + "da": "INTET", + "no": "INGENTING", + "sv": "INGET" + } + }, + "FOLLOWED": { + "context": "'... followed' (paired with NOTHING).", + "translations": { + "fr": "SUIVI", + "de": "GEFOLGT", + "es": "SEGUIDO", + "it": "SEGUITO", + "pt": "SEGUIDO", + "nl": "GEVOLGD", + "da": "FULGT", + "no": "FULGT", + "sv": "FÖLJT" + } + } + }, + "sentiment": { + "FEAR": { + "context": "Sentiment/index classification: fear.", + "translations": { + "fr": "PEUR", + "de": "ANGST", + "es": "MIEDO", + "it": "PAURA", + "pt": "MEDO", + "nl": "ANGST", + "da": "FRYGT", + "no": "FRYKT", + "sv": "RÄDSLA" + } + }, + "GREED": { + "context": "Sentiment/index classification: greed.", + "translations": { + "fr": "AVIDITE", + "de": "GIER", + "es": "CODICIA", + "it": "AVIDITA", + "pt": "GANANCIA", + "nl": "HEBZUCHT", + "da": "GRÅDIGHED", + "no": "GRÅDIGHET", + "sv": "GIRIGHET" + } + }, + "NEUTRAL": { + "context": "Sentiment/index classification: neutral.", + "translations": { + "fr": "NEUTRE", + "de": "NEUTRAL", + "es": "NEUTRAL", + "it": "NEUTRO", + "pt": "NEUTRO", + "nl": "NEUTRAAL", + "da": "NEUTRAL", + "no": "NØYTRAL", + "sv": "NEUTRAL" + } + }, + "EXTREME FEAR": { + "context": "Sentiment/index classification: extreme fear.", + "translations": { + "fr": "PEUR EXTREME", + "de": "EXTR. ANGST", + "es": "MIEDO EXTREMO", + "it": "PAURA ESTREMA", + "pt": "MEDO EXTREMO", + "nl": "EXTR. ANGST" + } + }, + "EXTREME GREED": { + "context": "Sentiment/index classification: extreme greed.", + "translations": { + "fr": "AVIDITE EXTREME", + "de": "EXTREME GIER", + "es": "CODICIA EXTREMA", + "it": "AVIDITA ESTREMA", + "pt": "GANANCIA EXTREMA", + "nl": "EXTREME HEBZUCHT" + } + } + }, + "crypto": { + "NO COINS": { + "context": "No cryptocurrencies configured.", + "translations": { + "fr": "AUCUNE CRYPTO", + "de": "KEINE COINS", + "es": "SIN MONEDAS", + "it": "NESSUNA CRIPTO", + "pt": "SEM MOEDAS", + "nl": "GEEN MUNTEN", + "da": "INGEN MØNTER", + "no": "INGEN MYNTER", + "sv": "INGA MYNT" + } + } + }, + "stocks": { + "NO TICKERS": { + "context": "No stock tickers configured.", + "translations": { + "fr": "AUCUN TITRE", + "de": "KEINE AKTIEN", + "es": "SIN VALORES", + "it": "NESSUN TITOLO", + "pt": "SEM AÇÕES", + "nl": "GEEN AANDELEN", + "da": "INGEN AKTIER", + "no": "INGEN AKSJER", + "sv": "INGA AKTIER" + } + } + }, + "holidays": { + "NEXT HOLIDAY": { + "context": "'Next holiday' label.", + "translations": { + "fr": "PROCHAIN CONGE", + "de": "NAECHSTER FEIERTAG", + "es": "PROX. FESTIVO", + "it": "PROSS. FESTA", + "pt": "PROX. FERIADO", + "nl": "VOLGENDE FEESTDAG", + "da": "NÆSTE HELLIGDAG", + "no": "NESTE HELLIGDAG", + "sv": "NÄSTA HELGDAG" + } + } + }, + "content": { + "FEATURED": { + "context": "'Featured' item label (e.g. a featured article).", + "translations": { + "fr": "A LA UNE", + "de": "ARTIKEL", + "es": "DESTACADO", + "it": "IN VETRINA", + "pt": "DESTAQUE", + "nl": "UITGELICHT", + "da": "FREMHÆVET", + "no": "FREMHEVET", + "sv": "UTVALD" + } + }, + "MOST READ": { + "context": "'Most read' item label.", + "translations": { + "fr": "TOP LUS", + "de": "MEISTGELESEN", + "es": "MAS LEIDO", + "it": "PIU LETTI", + "pt": "MAIS LIDO", + "nl": "MEEST GELEZEN", + "da": "MEST LÆST", + "no": "MEST LEST", + "sv": "MEST LÄST" + } + } + }, + "vocab": { + "WORD OF THE DAY": { + "context": "'Word of the day' heading.", + "translations": { + "fr": "MOT DU JOUR", + "de": "WORT DES TAGES", + "es": "PALABRA DEL DIA", + "it": "PAROLA DEL GIORNO", + "pt": "PALAVRA DO DIA", + "nl": "WOORD VAN DE DAG", + "da": "DAGENS ORD", + "no": "DAGENS ORD", + "sv": "DAGENS ORD" + } + } + } + }, + "holidays": { + "new year's day": { + "context": "Public-holiday name; shown when the source only provides English ('new year's day').", + "translations": { + "fr": "Jour de l'An", + "de": "Neujahr", + "es": "Año Nuevo", + "it": "Capodanno", + "pt": "Ano Novo", + "nl": "Nieuwjaar", + "da": "Nytårsdag", + "no": "Første nyttårsdag", + "sv": "Nyårsdagen" + } + }, + "epiphany": { + "context": "Public-holiday name; shown when the source only provides English ('epiphany').", + "translations": { + "fr": "Épiphanie", + "de": "Heilige Drei Könige", + "es": "Epifanía", + "it": "Epifania", + "pt": "Dia de Reis", + "nl": "Driekoningen", + "da": "Helligtrekonger", + "no": "Helligtrekongersdag", + "sv": "Trettondedag jul" + } + }, + "good friday": { + "context": "Public-holiday name; shown when the source only provides English ('good friday').", + "translations": { + "fr": "Vendredi Saint", + "de": "Karfreitag", + "es": "Viernes Santo", + "it": "Venerdì Santo", + "pt": "Sexta-feira Santa", + "nl": "Goede Vrijdag", + "da": "Langfredag", + "no": "Langfredag", + "sv": "Långfredagen" + } + }, + "easter sunday": { + "context": "Public-holiday name; shown when the source only provides English ('easter sunday').", + "translations": { + "fr": "Pâques", + "de": "Ostersonntag", + "es": "Domingo de Pascua", + "it": "Pasqua", + "pt": "Páscoa", + "nl": "Eerste Paasdag", + "da": "Påskedag", + "no": "Første påskedag", + "sv": "Påskdagen" + } + }, + "easter monday": { + "context": "Public-holiday name; shown when the source only provides English ('easter monday').", + "translations": { + "fr": "Lundi de Pâques", + "de": "Ostermontag", + "es": "Lunes de Pascua", + "it": "Lunedì dell'Angelo", + "pt": "Segunda de Páscoa", + "nl": "Tweede Paasdag", + "da": "2. påskedag", + "no": "Andre påskedag", + "sv": "Annandag påsk" + } + }, + "labour day": { + "context": "Public-holiday name; shown when the source only provides English ('labour day').", + "translations": { + "fr": "Fête du Travail", + "de": "Tag der Arbeit", + "es": "Día del Trabajo", + "it": "Festa del Lavoro", + "pt": "Dia do Trabalhador", + "nl": "Dag van de Arbeid", + "da": "1. maj", + "no": "Arbeidernes dag", + "sv": "Första maj" + } + }, + "labor day": { + "context": "Public-holiday name; shown when the source only provides English ('labor day').", + "translations": { + "fr": "Fête du Travail", + "de": "Tag der Arbeit", + "es": "Día del Trabajo", + "it": "Festa del Lavoro", + "pt": "Dia do Trabalhador", + "nl": "Dag van de Arbeid", + "da": "1. maj", + "no": "Arbeidernes dag", + "sv": "Första maj" + } + }, + "ascension day": { + "context": "Public-holiday name; shown when the source only provides English ('ascension day').", + "translations": { + "fr": "Ascension", + "de": "Christi Himmelfahrt", + "es": "Ascensión", + "it": "Ascensione", + "pt": "Ascensão", + "nl": "Hemelvaart", + "da": "Kristi himmelfartsdag", + "no": "Kristi himmelfartsdag", + "sv": "Kristi himmelsfärdsdag" + } + }, + "whit sunday": { + "context": "Public-holiday name; shown when the source only provides English ('whit sunday').", + "translations": { + "fr": "Pentecôte", + "de": "Pfingstsonntag", + "es": "Pentecostés", + "it": "Pentecoste", + "pt": "Pentecostes", + "nl": "Eerste Pinksterdag", + "da": "Pinsedag", + "no": "Første pinsedag", + "sv": "Pingstdagen" + } + }, + "whit monday": { + "context": "Public-holiday name; shown when the source only provides English ('whit monday').", + "translations": { + "fr": "Lundi de Pentecôte", + "de": "Pfingstmontag", + "es": "Lunes de Pentecostés", + "it": "Lunedì di Pentecoste", + "pt": "Segunda de Pentecostes", + "nl": "Tweede Pinksterdag", + "da": "2. pinsedag", + "no": "Andre pinsedag", + "sv": "Annandag pingst" + } + }, + "corpus christi": { + "context": "Public-holiday name; shown when the source only provides English ('corpus christi').", + "translations": { + "fr": "Fête-Dieu", + "de": "Fronleichnam", + "es": "Corpus Christi", + "it": "Corpus Domini", + "pt": "Corpo de Deus", + "nl": "Sacramentsdag" + } + }, + "assumption day": { + "context": "Public-holiday name; shown when the source only provides English ('assumption day').", + "translations": { + "fr": "Assomption", + "de": "Mariä Himmelfahrt", + "es": "Asunción", + "it": "Assunzione", + "pt": "Assunção", + "nl": "Maria-Tenhemelopneming" + } + }, + "all saints' day": { + "context": "Public-holiday name; shown when the source only provides English ('all saints' day').", + "translations": { + "fr": "Toussaint", + "de": "Allerheiligen", + "es": "Todos los Santos", + "it": "Ognissanti", + "pt": "Todos os Santos", + "nl": "Allerheiligen" + } + }, + "christmas day": { + "context": "Public-holiday name; shown when the source only provides English ('christmas day').", + "translations": { + "fr": "Noël", + "de": "Weihnachten", + "es": "Navidad", + "it": "Natale", + "pt": "Natal", + "nl": "Kerstmis", + "da": "Juledag", + "no": "Første juledag", + "sv": "Juldagen" + } + }, + "st. stephen's day": { + "context": "Public-holiday name; shown when the source only provides English ('st. stephen's day').", + "translations": { + "fr": "Saint-Étienne", + "de": "Stefanitag", + "es": "San Esteban", + "it": "Santo Stefano", + "pt": "Dia de Santo Estêvão", + "nl": "Tweede Kerstdag", + "da": "2. juledag", + "no": "Andre juledag", + "sv": "Annandag jul" + } + }, + "boxing day": { + "context": "Public-holiday name; shown when the source only provides English ('boxing day').", + "translations": { + "fr": "Lendemain de Noël", + "de": "Zweiter Weihnachtsfeiertag", + "es": "San Esteban", + "it": "Santo Stefano", + "pt": "Boxing Day", + "nl": "Tweede Kerstdag", + "da": "2. juledag", + "no": "Andre juledag", + "sv": "Annandag jul" + } + }, + "canada day": { + "context": "Public-holiday name; shown when the source only provides English ('canada day').", + "translations": { + "fr": "Fête du Canada" + } + }, + "victoria day": { + "context": "Public-holiday name; shown when the source only provides English ('victoria day').", + "translations": { + "fr": "Journée des Patriotes" + } + }, + "thanksgiving": { + "context": "Public-holiday name; shown when the source only provides English ('thanksgiving').", + "translations": { + "fr": "Action de grâce" + } + }, + "remembrance day": { + "context": "Public-holiday name; shown when the source only provides English ('remembrance day').", + "translations": { + "fr": "Jour du Souvenir" + } + }, + "family day": { + "context": "Public-holiday name; shown when the source only provides English ('family day').", + "translations": { + "fr": "Jour de la famille" + } + }, + "civic holiday": { + "context": "Public-holiday name; shown when the source only provides English ('civic holiday').", + "translations": { + "fr": "Congé civique" + } + }, + "national holiday": { + "context": "Public-holiday name; shown when the source only provides English ('national holiday').", + "translations": { + "fr": "Fête nationale" + } + }, + "national day for truth and reconciliation": { + "context": "Public-holiday name; shown when the source only provides English ('national day for truth and reconciliation').", + "translations": { + "fr": "Journée de la vérité et réconciliation" + } + }, + "saint-jean-baptiste day": { + "context": "Public-holiday name; shown when the source only provides English ('saint-jean-baptiste day').", + "translations": { + "fr": "Fête nationale du Québec" + } + } + }, + "base_currency": { + "en": "USD", + "en-us": "USD", + "en-gb": "GBP", + "en-au": "AUD", + "fr": "EUR", + "de": "EUR", + "es": "EUR", + "it": "EUR", + "pt": "EUR", + "nl": "EUR", + "da": "DKK", + "no": "NOK", + "sv": "SEK", + "fi": "EUR", + "is": "ISK", + "ga": "EUR", + "ca": "EUR", + "gl": "EUR", + "eu": "EUR", + "et": "EUR", + "af": "ZAR", + "id": "IDR", + "ms": "MYR", + "sw": "KES", + "en-ca": "CAD", + "fr-ca": "CAD", + "fr-be": "EUR", + "fr-ch": "CHF", + "de-at": "EUR", + "de-ch": "CHF", + "es-mx": "MXN", + "es-ar": "ARS", + "it-ch": "CHF", + "pt-br": "BRL", + "nl-be": "EUR" + }, + "country": { + "en": "US", + "en-us": "US", + "en-gb": "GB", + "en-au": "AU", + "fr": "FR", + "de": "DE", + "es": "ES", + "it": "IT", + "pt": "PT", + "nl": "NL", + "da": "DK", + "no": "NO", + "sv": "SE", + "fi": "FI", + "is": "IS", + "ga": "IE", + "ca": "ES", + "gl": "ES", + "eu": "ES", + "et": "EE", + "af": "ZA", + "id": "ID", + "ms": "MY", + "sw": "KE", + "en-ca": "CA", + "fr-ca": "CA", + "fr-be": "BE", + "fr-ch": "CH", + "de-at": "AT", + "de-ch": "CH", + "es-mx": "MX", + "es-ar": "AR", + "it-ch": "CH", + "pt-br": "BR", + "nl-be": "BE" + } +} diff --git a/server/location.py b/server/location.py new file mode 100644 index 0000000..c42599d --- /dev/null +++ b/server/location.py @@ -0,0 +1,111 @@ +"""Resolve the configured global location to coordinates, country and currency +(keyless: Nominatim). + +This is the single place the app geocodes the configured location. Both the weather +helper and the ``get_location`` helper need the *same* location, so both go through +``coordinates()`` here — cached, one Nominatim lookup instead of one per caller. It +lets currency/holiday apps key off *where you are* rather than your language (the +language can't tell France (EUR) from Canada (CAD) or Switzerland (CHF)). +""" + +import re + +# Catalog globals this helper draws on (named in the app dialog's "also uses" hint). +GLOBAL_KEYS = ["location_precise", "zip_code"] + +_UA = {"User-Agent": "splitflap-os/1.0"} +_geo_cache: dict = {} # rounded (lat, lon) -> {"country", "subdivision"} +_coord_cache: dict = {} # geocode query string -> (lat, lon, CITY) + + +def _currency_for(country): + """ISO 4217 currency for an ISO 3166 country, from babel's CLDR data (a project + dependency). Returns None if unknown — callers then fall back to the language's + default currency (i18n.base_currency).""" + if not country: + return None + try: + from babel.numbers import get_territory_currencies + cur = get_territory_currencies(country.upper()) # current tender per CLDR + return cur[0] if cur else None + except Exception: + return None + + +def coordinates(settings): + """``(lat, lon, CITY)`` for the configured location: the precise coordinates if + set, else a geocode of the ZIP/postcode/city. Returns ``None`` when nothing is + configured. The forward geocode is cached, so weather and get_location share it.""" + lat = str(settings.get("location_lat", "") or "").strip() + lon = str(settings.get("location_lon", "") or "").strip() + name = str(settings.get("location_name", "") or "").strip() + if lat and lon: + try: + city = name.split(",")[0].strip().upper() if name else "LOCATION" + return float(lat), float(lon), city + except ValueError: + pass + query = str(settings.get("zip_code", "") or "").strip() + if not query: + return None + if query in _coord_cache: + return _coord_cache[query] + try: + import requests + params = {"q": query, "format": "json", "limit": 1, "addressdetails": 1} + if re.fullmatch(r"\d{5}", query): # a US ZIP — 02118 also exists abroad + params["countrycodes"] = "us" + geo = requests.get("https://nominatim.openstreetmap.org/search", + params=params, headers=_UA, timeout=6).json() + if geo: + addr = geo[0].get("address", {}) + city = (addr.get("city") or addr.get("town") or addr.get("village") + or addr.get("municipality") or addr.get("county") + or geo[0].get("display_name", query).split(",")[0]).strip().upper() + result = (float(geo[0]["lat"]), float(geo[0]["lon"]), city) + _coord_cache[query] = result + return result + except Exception: + pass + return None + + +def _geo(settings): + """Reverse-geocode the configured location to ``{country, subdivision}``, cached. + subdivision is the ISO 3166-2 code (e.g. 'CA-QC' for Quebec) or None.""" + coords = coordinates(settings) + if not coords: + return {"country": None, "subdivision": None} + lat, lon, _city = coords + key = (round(lat, 2), round(lon, 2)) + if key in _geo_cache: + return _geo_cache[key] + out = {"country": None, "subdivision": None} + try: + import requests + r = requests.get("https://nominatim.openstreetmap.org/reverse", + params={"lat": lat, "lon": lon, "format": "json", "zoom": 5}, + headers=_UA, timeout=6).json() + addr = r.get("address") or {} + out["country"] = str(addr.get("country_code") or "").upper()[:2] or None + sub = str(addr.get("ISO3166-2-lvl4") or addr.get("ISO3166-2-lvl6") or "").upper() + out["subdivision"] = sub or None + if out["country"]: + _geo_cache[key] = out + except Exception: + pass + return out + + +def country(settings): + """ISO country code for the configured location (reverse-geocoded, cached).""" + return _geo(settings).get("country") + + +def resolve(settings) -> dict: + """{ok, country, subdivision, currency} for the configured location. ok is False + (values None) when there's no location set or the lookup failed.""" + g = _geo(settings) + cc = g.get("country") + return {"ok": bool(cc), "country": cc, "subdivision": g.get("subdivision"), + "currency": _currency_for(cc)} diff --git a/server/requirements.txt b/server/requirements.txt index 7904288..165f9de 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -4,3 +4,4 @@ requests pytz yfinance paho-mqtt +babel diff --git a/server/static/app.js b/server/static/app.js index d4d64e8..08d27a3 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -1052,6 +1052,7 @@ function buildAppCard(a, isPlugin) { ${hasCfg && compatible ? `` : ''} ${removable ? `` : ''} ${hasTrigger && compatible ? `` : ''} + ${a.i18n ? `🌐` : ''} ${icon} ${a.name} ${compatible ? a.desc : incompatibleReason}`; @@ -2072,6 +2073,11 @@ function loadSettingsData(){ // Currency symbol const currencyEl = document.getElementById('currencySymbol'); if(currencyEl) currencyEl.value = data.currency_symbol || '$'; + // Internationalization master toggle + display language + const i18nEl = document.getElementById('i18nEnabled'); + if(i18nEl) i18nEl.checked = !!data.i18n_enabled; + const langEl = document.getElementById('globalLanguage'); + if(langEl){ langEl.value = data.language || 'en-US'; langEl.disabled = !(i18nEl && i18nEl.checked); } // Character map const charMapEl = document.getElementById('charMapInput'); if(charMapEl) charMapEl.value = data.char_map || CHAR_MAP; @@ -2856,6 +2862,13 @@ document.addEventListener('DOMContentLoaded', ()=>{ }); }); +function onI18nToggle(){ + const on = document.getElementById('i18nEnabled').checked; + const langEl = document.getElementById('globalLanguage'); + if(langEl) langEl.disabled = !on; + setSettingsDirty(true); +} + function saveGlobal(){ const rows = parseInt(document.getElementById('simRows').value) || 3; const cols = parseInt(document.getElementById('simCols').value) || 15; @@ -2875,6 +2888,8 @@ function saveGlobal(){ sim_rows:rows, sim_cols:cols, global_loop_delay: globalDelay, timezone: tz, + i18n_enabled: document.getElementById('i18nEnabled')?.checked || false, + language: document.getElementById('globalLanguage')?.value || 'en-US', location_lat: locLat, location_lon: locLon, location_name: locName, diff --git a/server/templates/index.html b/server/templates/index.html index cd40ceb..06ca3ce 100644 --- a/server/templates/index.html +++ b/server/templates/index.html @@ -328,6 +328,20 @@

Settings

+
+
+ + Enable internationalization (non-English languages) +
+

⚠ Only turn this on if your modules run the Universal Firmware — a version with configurable character maps — and you've loaded a character map that includes the accented characters (é, ñ, ö, å, …) these languages use. Standard A–Z / 0–9 modules can't display those, so translated text would appear as blank or wrong flaps. Leave it off to keep everything in English; the display and app list stay exactly as they are today.

+
+
+ + +

Display language for apps that support it (look for the 🌐 badge): translates their words and sets date order, number format and 12h/24h. Any app can override this in its own settings. Only Western-European (Windows-1252) languages are listed — the modules can't show others.

+

Transition Style

diff --git a/server/weather.py b/server/weather.py new file mode 100644 index 0000000..d5d6fc1 --- /dev/null +++ b/server/weather.py @@ -0,0 +1,208 @@ +""" +weather.py — a shared current-conditions helper. + +Several apps want "the weather right now". Rather than each hardcoding a provider +and key, this resolves the *global* weather settings (provider + API key + +location) and returns one normalized current-conditions dict. The plugin runtime +injects it into any app whose ``fetch()`` opts in with a ``get_weather`` +parameter (see app.py); the app calls ``get_weather()`` and renders the +result, so switching providers is a global setting, not an app edit. + +Providers: Open-Meteo (keyless — the default, so weather works with no API key), +OpenWeather, WeatherAPI and QWeather (each keyed via the global weather_api_key). +Temperatures are normalized to Fahrenheit; the caller formats/converts. + +Uses the blocking ``requests`` library (already a project dependency); a small +client shim gives every request a default timeout. +""" + +from __future__ import annotations + +import logging +import time + +import requests + +import location + +log = logging.getLogger("splitflap.weather") + +# Short-TTL cache so the provider is hit at most once per location per window, +# regardless of how many apps call get_weather() or how often they refresh. +_CACHE_TTL = 600 # seconds +_cache: dict = {} # (provider, key, lat, lon) -> (fetched_at, data) + +# Global settings the helper consumes — so the UI can credit weather-using apps +# under these settings even though they read them via get_weather, not directly. +GLOBAL_KEYS = ("weather_provider", "weather_api_key", "zip_code", "location_precise") + +# Compact Open-Meteo WMO weather-code → text map (current conditions only). +_OPENMETEO_CODES = { + 0: "CLEAR", 1: "MAINLY CLEAR", 2: "PARTLY CLOUDY", 3: "OVERCAST", + 45: "FOG", 48: "RIME FOG", 51: "LIGHT DRIZZLE", 53: "DRIZZLE", 55: "HEAVY DRIZZLE", + 56: "FREEZING DRIZZLE", 57: "FREEZING DRIZZLE", 61: "LIGHT RAIN", 63: "RAIN", + 65: "HEAVY RAIN", 66: "FREEZING RAIN", 67: "FREEZING RAIN", 71: "LIGHT SNOW", + 73: "SNOW", 75: "HEAVY SNOW", 77: "SNOW GRAINS", 80: "RAIN SHOWERS", + 81: "RAIN SHOWERS", 82: "HEAVY SHOWERS", 85: "SNOW SHOWERS", 86: "HEAVY SNOW SHOWERS", + 95: "THUNDERSTORM", 96: "THUNDER HAIL", 99: "SEVERE TSTORM", +} + + +class _Client: + """Minimal requests-backed stand-in for httpx.Client: a session that applies a + default timeout to every ``get()`` so the provider helpers stay unchanged.""" + + def __init__(self, timeout=8.0): + self._session = requests.Session() + self._timeout = timeout + + def get(self, url, params=None, headers=None): + return self._session.get(url, params=params, headers=headers, timeout=self._timeout) + + def close(self): + self._session.close() + + def __enter__(self): + return self + + def __exit__(self, *exc): + self.close() + + +def _i(v): + try: + return int(round(float(v))) + except (TypeError, ValueError): + return None + + +# Boston, so weather always has something to show if no location is configured. +_FALLBACK_LOCATION = (42.3496, -71.0783, "BOSTON") + + +def _resolve_location(settings): + """(lat, lon, city) for the configured location via the shared geocoder in + location.py (cached, one Nominatim lookup for weather + get_location), with a + Boston fallback so weather is never blank.""" + return location.coordinates(settings) or _FALLBACK_LOCATION + + +def _openmeteo(client, lat, lon, city, _key): + d = client.get("https://api.open-meteo.com/v1/forecast", params={ + "latitude": lat, "longitude": lon, + "current": "temperature_2m,apparent_temperature,weather_code," + "relative_humidity_2m,wind_speed_10m,cloud_cover", + "daily": "temperature_2m_max,temperature_2m_min", + "temperature_unit": "fahrenheit", "wind_speed_unit": "mph", + "timezone": "auto", "forecast_days": 1, + }).json() + cur = d.get("current", {}) + daily = d.get("daily", {}) + hi = (daily.get("temperature_2m_max") or [cur.get("temperature_2m")])[0] + lo = (daily.get("temperature_2m_min") or [cur.get("temperature_2m")])[0] + code = cur.get("weather_code") + return { + "city": city, "temp_f": _i(cur.get("temperature_2m")), + "feels_like_f": _i(cur.get("apparent_temperature")), + "hi_f": _i(hi), "lo_f": _i(lo), + "desc": _OPENMETEO_CODES.get(code, "CURRENT CONDITIONS"), "code": code, + "humidity": _i(cur.get("relative_humidity_2m")), + "wind_mph": cur.get("wind_speed_10m"), "cloud_cover": _i(cur.get("cloud_cover")), + } + + +def _openweather(client, lat, lon, city, key): + d = client.get("https://api.openweathermap.org/data/2.5/weather", params={ + "lat": lat, "lon": lon, "appid": key, "units": "imperial", + }).json() + main = d.get("main", {}) + return { + "city": str(d.get("name", city)).upper(), "temp_f": _i(main.get("temp")), + "feels_like_f": _i(main.get("feels_like")), + "hi_f": _i(main.get("temp_max")), "lo_f": _i(main.get("temp_min")), + "desc": str((d.get("weather") or [{}])[0].get("main", "CURRENT CONDITIONS")).upper(), + "code": None, "humidity": _i(main.get("humidity")), + "wind_mph": (d.get("wind") or {}).get("speed"), + "cloud_cover": _i((d.get("clouds") or {}).get("all")), + } + + +def _weatherapi(client, lat, lon, city, key): + d = client.get("https://api.weatherapi.com/v1/forecast.json", params={ + "key": key, "q": f"{lat},{lon}", "days": 1, + }).json() + cur = d.get("current", {}) + day = ((d.get("forecast") or {}).get("forecastday") or [{}])[0].get("day", {}) + return { + "city": str((d.get("location") or {}).get("name", city)).upper(), + "temp_f": _i(cur.get("temp_f")), "feels_like_f": _i(cur.get("feelslike_f")), + "hi_f": _i(day.get("maxtemp_f")), "lo_f": _i(day.get("mintemp_f")), + "desc": str((cur.get("condition") or {}).get("text", "CURRENT CONDITIONS")).upper(), + "code": None, "humidity": _i(cur.get("humidity")), + "wind_mph": cur.get("wind_mph"), "cloud_cover": _i(cur.get("cloud")), + } + + +def _qweather(client, lat, lon, city, key): + loc = f"{lon:.2f},{lat:.2f}" + headers = {"Authorization": f"Bearer {key}"} + now = client.get("https://devapi.qweather.com/v7/weather/now", + params={"location": loc, "lang": "en", "unit": "i"}, + headers=headers).json().get("now", {}) + day = (client.get("https://devapi.qweather.com/v7/weather/3d", + params={"location": loc, "lang": "en", "unit": "i"}, + headers=headers).json().get("daily") or [{}])[0] + return { + "city": city, "temp_f": _i(now.get("temp")), + "feels_like_f": _i(now.get("feelsLike")), + "hi_f": _i(day.get("tempMax")), "lo_f": _i(day.get("tempMin")), + "desc": str(now.get("text", "CURRENT CONDITIONS")).upper(), "code": None, + "humidity": _i(now.get("humidity")), "wind_mph": now.get("windSpeed"), + "cloud_cover": _i(now.get("cloud")), + } + + +_PROVIDERS = { + "openmeteo": _openmeteo, "openweather": _openweather, + "weatherapi": _weatherapi, "qweather": _qweather, +} + + +def fetch_current(settings) -> dict: + """Current conditions for the global location via the global provider. Falls + back to keyless Open-Meteo when a keyed provider is missing its key OR fails / + returns an error body (bad key, rate-limited, outage). Returns + ``{ok: False, error, provider}`` only if Open-Meteo itself fails; never raises.""" + provider = str(settings.get("weather_provider", "openmeteo") or "openmeteo").lower() + key = str(settings.get("weather_api_key", "") or "").strip() + if provider not in _PROVIDERS or (provider != "openmeteo" and not key): + provider = "openmeteo" # keyless default — weather works with no key + lat, lon, city = _resolve_location(settings) + cache_key = (provider, key, round(lat, 3), round(lon, 3)) + hit = _cache.get(cache_key) + if hit and (time.time() - hit[0]) < _CACHE_TTL: + return hit[1] + try: + with _Client(timeout=8.0) as client: + data = None + if provider != "openmeteo": + try: + got = _PROVIDERS[provider](client, lat, lon, city, key) + # A keyed provider that 401s/429s still returns 200-ish JSON + # with no temperature: treat a missing temp as a failure. + if got.get("temp_f") is not None: + data = got + else: + log.warning("weather provider %s returned no data; using open-meteo", provider) + except Exception as e: # noqa: BLE001 + log.warning("weather provider %s failed (%s); using open-meteo", provider, e) + if data is None: + provider = "openmeteo" + if data is None: + data = _openmeteo(client, lat, lon, city, key) + data.update(ok=True, provider=provider, lat=lat, lon=lon) + _cache[cache_key] = (time.time(), data) # cache only successful fetches + return data + except Exception as e: # noqa: BLE001 + log.warning("weather fetch failed: %s", e) + return {"ok": False, "error": str(e), "provider": provider} diff --git a/tests/test_gateway_transport.py b/tests/test_gateway_transport.py new file mode 100644 index 0000000..eb949b9 --- /dev/null +++ b/tests/test_gateway_transport.py @@ -0,0 +1,65 @@ +"""Regression tests for GatewayTransport frame decoding. + +The RS485 bus is single-byte Windows-1252 (cp1252): one displayed glyph is one +byte. A frame read back from the gateway -- most importantly a module's +``A``-command character-map response -- can therefore carry raw high bytes +(0x80-0xFF) that are *invalid* standalone UTF-8. The RX path must preserve those +bytes verbatim so the downstream ``.decode('cp1252')`` in app.get_module_char_map +recovers the real characters. These tests lock in that byte-transparent behaviour +(previously the code decoded/re-encoded as UTF-8 with errors="ignore", which +silently dropped every extended character). +""" + +import pathlib +import sys +import unittest + + +SERVER_DIR = pathlib.Path(__file__).resolve().parents[1] / "server" +sys.path.insert(0, str(SERVER_DIR)) + +from gateway_transport import GatewayTransport # noqa: E402 + + +# A char map with real cp1252 extended glyphs (no " or \\, which the physical +# reel aliases to 'q' anyway and which would need JSON escaping). +CHAR_MAP = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789éöüàçñ€£¥" +FRAME = "m05A:64:" + CHAR_MAP # module's A-command response frame +FRAME_BYTES = FRAME.encode("cp1252") # the exact bytes on the bus + + +class ExtractFrameTests(unittest.TestCase): + def _recover(self, frame): + # Mirrors _on_message: what lands in the RX buffer is the frame encoded + # latin-1; get_module_char_map later slices it and decodes cp1252. + self.assertIsNotNone(frame) + return frame.encode("latin-1") + + def test_json_form_preserves_extended_bytes(self): + # The gateway's {"command": "..."} form with raw high bytes in the string. + payload = b'{"command": "' + FRAME_BYTES + b'"}' + recovered = self._recover(GatewayTransport._extract_frame(payload)) + self.assertEqual(recovered, FRAME_BYTES) + # Round-trips to the original characters via the downstream cp1252 decode. + self.assertEqual(recovered.decode("cp1252"), FRAME) + + def test_bare_frame_preserves_extended_bytes(self): + # Plain-text fallback (no JSON wrapper). + recovered = self._recover(GatewayTransport._extract_frame(FRAME_BYTES)) + self.assertEqual(recovered.decode("cp1252"), FRAME) + + def test_plain_ascii_unaffected(self): + payload = b'{"command": "m00-A"}' + self.assertEqual(GatewayTransport._extract_frame(payload), "m00-A") + + def test_utf8_would_have_corrupted(self): + # Documents the bug being fixed: the old utf-8/errors=ignore path drops + # the lone high bytes, losing every extended glyph. + lossy = FRAME_BYTES.decode("utf-8", errors="ignore") + self.assertLess(len(lossy), len(FRAME)) + self.assertNotIn("é", lossy) + self.assertNotIn("€", lossy) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_i18n.py b/tests/test_i18n.py new file mode 100644 index 0000000..8215d21 --- /dev/null +++ b/tests/test_i18n.py @@ -0,0 +1,289 @@ +import inspect +import pathlib +import sys +import unittest +from datetime import datetime + + +SERVER_DIR = pathlib.Path(__file__).resolve().parents[1] / "server" +sys.path.insert(0, str(SERVER_DIR)) + +import i18n # noqa: E402 + +try: + import babel # noqa: F401 + HAS_BABEL = True +except ImportError: + HAS_BABEL = False + +# A fixed date so assertions never depend on "today". 2026-07-10 is a Friday. +D = datetime(2026, 7, 10, 15, 48, 5) + + +class TranslateTests(unittest.TestCase): + def test_english_is_passthrough(self): + # No language, English, and any en-* region all return the text unchanged. + for lang in (None, "", "en", "en-US", "en-GB"): + self.assertEqual(i18n.translate("SUNSET", lang, "sun"), "SUNSET") + + def test_known_translation(self): + self.assertEqual(i18n.translate("SUNSET", "fr", "sun"), "COUCHER") + self.assertEqual(i18n.translate("FULL MOON", "de", "moon"), "VOLLMOND") + self.assertEqual(i18n.translate("GOLD", "nl", "metals"), "GOUD") + + def test_unknown_key_falls_back_to_english(self): + self.assertEqual(i18n.translate("TOTALLY UNKNOWN", "fr", "sun"), "TOTALLY UNKNOWN") + + def test_unknown_language_falls_back_to_english_key(self): + # A language we don't have a translation for keeps the English text. + self.assertEqual(i18n.translate("SUNSET", "sw", "sun"), "SUNSET") + + def test_wrong_context_does_not_leak(self): + # SUNSET lives in the 'sun' domain, so asking a different domain must not + # find it (it's not a 'common' word) -> English text. + self.assertEqual(i18n.translate("SUNSET", "fr", "weather"), "SUNSET") + + +class ContextKeyTests(unittest.TestCase): + """The same English word carries different translations per context/domain + (gettext msgctxt), and a shared 'common' domain backs every context.""" + + def test_homograph_splits_by_context(self): + # HIGH: a weather level vs a tide height -> different French words. + self.assertEqual(i18n.translate("HIGH", "fr", "weather"), "ELEVE") + self.assertEqual(i18n.translate("HIGH", "fr", "tides"), "HAUTE") + self.assertNotEqual(i18n.translate("HIGH", "fr", "weather"), + i18n.translate("HIGH", "fr", "tides")) + self.assertEqual(i18n.translate("LOW", "fr", "tides"), "BASSE") + + def test_shared_time_vocabulary_is_one_domain(self): + # DAYS/IN are one meaning shared by many apps -> a single 'time' domain, + # not duplicated per app. countdown/holidays/f1/rocket all use ctx='time'. + self.assertEqual(i18n.translate("DAYS", "fr", "time"), "JOURS") + self.assertEqual(i18n.translate("IN", "fr", "time"), "DANS") + self.assertEqual(i18n.translate("NOW", "de", "time"), "JETZT") + + def test_common_domain_fallback(self): + # 'OFFLINE' lives only in 'common'; any domain resolves it via fallback. + for ctx in ("aurora", "sentiment", "weather"): + self.assertEqual(i18n.translate("OFFLINE", "fr", ctx), "HORS LIGNE") + + def test_default_context_is_common(self): + self.assertEqual(i18n.translate("OFFLINE", "fr"), "HORS LIGNE") + # A domain-specific word is NOT found under the default (common) context. + self.assertEqual(i18n.translate("SUNSET", "fr"), "SUNSET") + + +class DurationAndClockTests(unittest.TestCase): + def test_duration_unit_localizes_day(self): + self.assertEqual(i18n.duration_unit("D", "fr"), "J") # jour + self.assertEqual(i18n.duration_unit("D", "de"), "T") # Tag + self.assertEqual(i18n.duration_unit("D", "it"), "G") # giorno + + def test_duration_unit_passthrough(self): + self.assertEqual(i18n.duration_unit("D", "en-US"), "D") + self.assertEqual(i18n.duration_unit("H", None), "H") + self.assertEqual(i18n.duration_unit("Z", "fr"), "Z") # unknown key + + def test_duration_lives_in_time_domain(self): + # Abbreviations and full-word forms are one duration vocabulary in 'time'. + self.assertEqual(i18n.duration_unit("D", "fr"), i18n.translate("D", "fr", "time")) + self.assertEqual(i18n.translate("WEEKS", "fr", "time"), "SEMAINES") + self.assertEqual(i18n.translate("YEARS", "de", "time"), "JAHRE") + self.assertEqual(i18n.translate("SECONDS", "es", "time"), "SEGUNDOS") + + def test_uses_24h(self): + self.assertFalse(i18n.uses_24h("en-US")) + self.assertFalse(i18n.uses_24h("en-GB")) + self.assertFalse(i18n.uses_24h(None)) + self.assertTrue(i18n.uses_24h("fr")) + self.assertTrue(i18n.uses_24h("de")) + + def test_clock_format_follows_language(self): + self.assertEqual(i18n.clock(D, "fr"), "15:48") + self.assertEqual(i18n.clock(D, "en-US"), "3:48 PM") + + +class CurrencyCountryHolidayTests(unittest.TestCase): + def test_base_currency(self): + self.assertEqual(i18n.base_currency("en-US"), "USD") + self.assertEqual(i18n.base_currency("en-GB"), "GBP") + self.assertEqual(i18n.base_currency("en-AU"), "AUD") + self.assertEqual(i18n.base_currency("fr"), "EUR") + self.assertEqual(i18n.base_currency("zz"), "USD") # unknown default + + def test_country(self): + self.assertEqual(i18n.country("nl"), "NL") + self.assertEqual(i18n.country("en-AU"), "AU") + self.assertEqual(i18n.country("zz"), "US") # unknown default + + def test_holiday_localization(self): + self.assertEqual(i18n.holiday("Christmas Day", "fr"), "Noël") + self.assertIsNone(i18n.holiday("Some Local Fete", "fr")) # no translation + self.assertIsNone(i18n.holiday("Christmas Day", None)) # no language + + +class LocalizerTests(unittest.TestCase): + def test_translate_and_flags(self): + fr = i18n.Localizer("fr") + self.assertEqual(fr.t("SUNSET", "sun"), "COUCHER") + self.assertTrue(fr.is_24h) + self.assertEqual(fr.unit("D"), "J") + + def test_english_localizer_is_noop(self): + en = i18n.Localizer("en-US") + self.assertEqual(en.t("SUNSET", "sun"), "SUNSET") + self.assertFalse(en.is_24h) + + def test_lang_base_strips_region(self): + self.assertEqual(i18n.Localizer("en-GB").lang_base, "en") + self.assertEqual(i18n.Localizer("fr").lang_base, "fr") + + def test_country_and_base_currency(self): + self.assertEqual(i18n.Localizer("en-GB").base_currency(), "GBP") + self.assertEqual(i18n.Localizer("nl").country(), "NL") + + +class BabelBackedTests(unittest.TestCase): + """Date/month/number use babel for CLDR output; every helper degrades to an + English-ish fallback without it, so we assert shape always and exact values + only when babel is installed (the production dependency).""" + + def test_weekday_month_are_uppercase_nonempty(self): + for lang in ("en-US", "fr", "de"): + self.assertTrue(i18n.weekday(D, lang).isupper()) + self.assertTrue(i18n.month(D, lang)) + + def test_date_order_is_locale_specific(self): + en = i18n.date(D, "en-US") + self.assertIn("JULY", en) + self.assertIn("10", en) + if HAS_BABEL: + # Romance/Germanic order: day precedes month. + self.assertEqual(i18n.date(D, "fr"), "10 JUILLET") + self.assertEqual(i18n.date(D, "en-US"), "JULY 10") + + def test_number_separators(self): + if HAS_BABEL: + self.assertEqual(i18n.number(1234.5, "en-US"), "1,234.50") + self.assertEqual(i18n.number(1234.5, "de"), "1.234,50") + self.assertEqual(i18n.number(1234.5, "fr"), "1 234,50") + else: + # Fallback still produces a parseable, non-empty string. + self.assertTrue(i18n.number(1234.5, "de")) + + +class LocalizationDataFileTests(unittest.TestCase): + """All localization data lives in i18n_data.json (loaded at import), not in code. + Lock that contract: the file is valid and populated, the loader fills the module + tables, and a missing file degrades to defaults instead of crashing.""" + + def test_data_file_is_valid_and_populated(self): + import json + path = pathlib.Path(i18n.__file__).with_name("i18n_data.json") + self.assertTrue(path.is_file(), "i18n_data.json must ship next to i18n.py") + data = json.loads(path.read_text(encoding="utf-8")) + for section in ("languages", "strings", "holidays", + "base_currency", "country"): + self.assertIn(section, data) + self.assertTrue(data[section], f"{section} should not be empty") + # 'strings' is grouped by context/domain; each entry uses the + # {context, translations} schema, with a translator context note. + for domain, entries in data["strings"].items(): + for key, entry in entries.items(): + self.assertIn("context", entry, f"{domain}.{key} missing context note") + self.assertIn("translations", entry) + self.assertEqual(data["strings"]["sun"]["SUNSET"]["translations"]["fr"], "COUCHER") + # The same word differs by domain (weather level vs tide height). + self.assertEqual(data["strings"]["weather"]["HIGH"]["translations"]["fr"], "ELEVE") + self.assertEqual(data["strings"]["tides"]["HIGH"]["translations"]["fr"], "HAUTE") + self.assertEqual(data["holidays"]["christmas day"]["translations"]["fr"], "Noël") + self.assertEqual(data["base_currency"]["en-gb"], "GBP") + self.assertEqual(data["country"]["nl"], "NL") + + def test_loader_populates_module_tables(self): + self.assertTrue(all([i18n._STRINGS, i18n._DURATION_UNITS, i18n._HOLIDAYS, + i18n._BASE_CURRENCY, i18n._COUNTRY, i18n.LANGUAGE_OPTIONS])) + # The loader flattens into {domain: {NAME: {lang: value}}}. + self.assertEqual(i18n._STRINGS["sun"]["SUNSET"]["fr"], "COUCHER") + + def test_missing_file_degrades_gracefully(self): + original = i18n._DATA_PATH + try: + i18n._DATA_PATH = original + ".does-not-exist" + strings, holidays, cur, country, langs = i18n._load_i18n_data() + self.assertEqual((strings, holidays, cur, country), ({}, {}, {}, {})) + # ...but the Language list still has at least English so the UI works. + self.assertTrue(langs and langs[0]["value"].startswith("en")) + finally: + i18n._DATA_PATH = original + + +class LanguageOptionsTests(unittest.TestCase): + def test_language_options_shape(self): + self.assertTrue(i18n.LANGUAGE_OPTIONS) + for opt in i18n.LANGUAGE_OPTIONS: + self.assertIn("value", opt) + self.assertIn("label", opt) + values = {o["value"] for o in i18n.LANGUAGE_OPTIONS} + self.assertIn("en-US", values) + self.assertIn("pt-BR", values) # a non-European regional variant + + +class RegionalVariantTests(unittest.TestCase): + """A region-tagged language inherits its base language's translations but keeps + its own currency/country (pt-BR reuses pt words but resolves to Brazil / BRL).""" + + def test_variant_inherits_base_translations(self): + self.assertEqual(i18n.translate("SUNSET", "pt-BR", "sun"), + i18n.translate("SUNSET", "pt", "sun")) + self.assertEqual(i18n.duration_unit("D", "pt-BR"), i18n.duration_unit("D", "pt")) + + def test_variant_currency_and_country_override_base(self): + self.assertEqual(i18n.base_currency("pt-BR"), "BRL") + self.assertEqual(i18n.country("pt-BR"), "BR") + self.assertEqual(i18n.base_currency("pt"), "EUR") # base unchanged + self.assertEqual(i18n.base_currency("es-MX"), "MXN") + self.assertEqual(i18n.country("fr-CA"), "CA") + self.assertEqual(i18n.base_currency("de-CH"), "CHF") + + def test_expanded_base_languages(self): + self.assertEqual(i18n.base_currency("da"), "DKK") + self.assertEqual(i18n.country("sv"), "SE") + self.assertEqual(i18n.base_currency("af"), "ZAR") + + def test_scandinavian_strings_present(self): + self.assertEqual(i18n.translate("SUNSET", "sv", "sun"), "SOLNEDGÅNG") + self.assertEqual(i18n.translate("SNOW", "da", "weather"), "SNE") + + +def _accepts(fn, name): + """The exact predicate app.py._fetch_accepts uses to decide whether to inject a + helper: the parameter is named, or the function takes **kwargs. Kept in lockstep + here so a Python signature-inspection change would surface in tests.""" + params = inspect.signature(fn).parameters + return name in params or any(p.kind == p.VAR_KEYWORD for p in params.values()) + + +class InjectionContractTests(unittest.TestCase): + def test_classic_four_arg_app_opts_into_nothing(self): + def fetch(settings, format_lines, get_rows, get_cols): + return [] + for helper in ("i18n", "get_weather", "get_location"): + self.assertFalse(_accepts(fetch, helper)) + + def test_declared_parameter_opts_in(self): + def fetch(settings, format_lines, get_rows, get_cols, i18n=None): + return [] + self.assertTrue(_accepts(fetch, "i18n")) + self.assertFalse(_accepts(fetch, "get_weather")) + + def test_var_keyword_opts_into_everything(self): + def fetch(settings, format_lines, get_rows, get_cols, **kw): + return [] + for helper in ("i18n", "get_weather", "get_location"): + self.assertTrue(_accepts(fetch, helper)) + + +if __name__ == "__main__": + unittest.main()