From b0de64d3c3685e8731898e41d9c303b4e07049bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 19:36:23 +0000 Subject: [PATCH] i18n: seven languages, a live language setting, strings handed to pages and apps; 0.4.117 Catalogues for en fr de ru zh pl sk under src/locales, read through src/i18n.py (plural forms by CLDR rule, English fallback, Accept-Language negotiated to a bound). The language setting is live; the console serves /i18n.js to every page, and AppHost hands the language to the apps. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0194znU5X7AzhxHW4wQoKm58 --- AGENTS.md | 8 + Docs/AppAPI/guide | 7 + Docs/Architecture/README.md | 1 + Docs/I18n/guide | 86 +++++++ Docs/Setup/guide | 7 + Docs/WebConsole/design | 7 + pyproject.toml | 2 +- scripts/nmesh_node.py | 3 +- src/app_registry.py | 30 ++- src/config.py | 32 ++- src/control/modules/settings.py | 14 +- src/i18n.py | 191 ++++++++++++++++ src/locales/de.json | 47 ++++ src/locales/en.json | 47 ++++ src/locales/fr.json | 47 ++++ src/locales/pl.json | 53 +++++ src/locales/ru.json | 53 +++++ src/locales/sk.json | 53 +++++ src/locales/zh.json | 44 ++++ src/version.py | 2 +- src/webassets/chat.py | 1 + src/webassets/console.py | 17 +- src/webassets/fleet.py | 1 + src/webassets/nodeview.py | 1 + src/webassets/packages.py | 1 + src/webassets/terminal.py | 1 + src/webassets/ui.py | 35 +++ src/webconsole.py | 21 ++ tests/test_i18n.py | 393 ++++++++++++++++++++++++++++++++ 29 files changed, 1196 insertions(+), 9 deletions(-) create mode 100644 Docs/I18n/guide create mode 100644 src/i18n.py create mode 100644 src/locales/de.json create mode 100644 src/locales/en.json create mode 100644 src/locales/fr.json create mode 100644 src/locales/pl.json create mode 100644 src/locales/ru.json create mode 100644 src/locales/sk.json create mode 100644 src/locales/zh.json create mode 100644 tests/test_i18n.py diff --git a/AGENTS.md b/AGENTS.md index 64393d9b..11e3b8f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -235,6 +235,14 @@ whole pass that grouped those very links by node. - Change the *cause*, not the symptom: a wrong number on a page is fixed in the thing that computes it, once, not corrected at each place it is displayed. +### Every interface string goes through the catalogue + +Seven languages — French, English, German, Russian, Chinese, Polish, Slovak — and +the language the operator chose is handed to the apps. A sentence a person reads +is a key in `src/locales/*.json`, asked for with `t()` (`Docs/I18n/guide`); it is +never written into a page, an app or a message. A new key is added to **all +seven** files in the same commit, plural forms included. + ### No emoji in an interface Emoji make a product look cheap, render differently on every platform, and carry diff --git a/Docs/AppAPI/guide b/Docs/AppAPI/guide index 2ba051b0..07b4cf9d 100644 --- a/Docs/AppAPI/guide +++ b/Docs/AppAPI/guide @@ -171,6 +171,13 @@ the operator chooses a window or a tab (Settings → This browser), and chat has its own choice for where node details appear. Both live in `localStorage`, like the theme: they are preferences about one screen, not properties of the node. +## The language an app speaks + +The operator's language is handed to the apps, not guessed by them: +`AppHost.language` answers it (`""` = follow the browser), and an app or bridge +defining `set_language(code)` is told at start and whenever it changes. Pages +get the strings from `/i18n.js`. See [`../I18n/guide`](../I18n/guide). + ## Apps outside the node declare operations too An app attached to the data connector declares its operations in its manifest's diff --git a/Docs/Architecture/README.md b/Docs/Architecture/README.md index 74b55de6..0f6ef9f9 100644 --- a/Docs/Architecture/README.md +++ b/Docs/Architecture/README.md @@ -52,6 +52,7 @@ is therefore safe to accept from strangers. | `pkg_dir.py` | **The package directory**: one signed sentence a node says about itself — *I hold this release and I serve it* — filed under the node id, under the release, and under the package name's prefixes. So "what does this machine offer?", "who can serve this release?" and "who offers something called this?" are one lookup with three keys, and **recommending is holding**: the records under a release are the machines that can hand it over. A second signature by the key that signed the release turns holding into publishing, and it names the node, so it cannot be lifted. Also the source digest that answers "do these publishers agree on the *code*?" without downloading anything. | | `subscriptions.py` | What this node watches for a new version — **a package**, never a publisher — and how many signing keys the operator chose (endorsed, or the pin a release sits under) must agree on the code before one installs itself. A subscription is named by the package, which is why nothing may look one up by the id of a record. Local, deliberate, never writable from the network — like the pins beside it. | | `app_dht.py` | Per-app DHT (overlay): a namespace per `app_id`, entries public (in the clear) or private (AES-256-GCM under a key the app supplies). | +| `i18n.py` / `locales/` | **Languages**: seven catalogues (`en fr de ru zh pl sk`), a flat map of dotted keys to a string or to CLDR plural forms, read once and refused whole if malformed; `t()` with regex-filled `{params}` (never `str.format`), English as the fallback, `Accept-Language` negotiated to a bound. The `language` setting is live and read from the file; `AppHost.language`/`set_language` hand it to the apps; the console serves `/i18n.js` to every page. Imports nothing of its own. See [`Docs/I18n/guide`](../I18n/guide). | | `pseudo.py` | The one canonical form of a pseudo (NFC, no invisible or directional characters, at most 50). Deterministic, so a receiver can re-derive it and call a mismatch a lie. Also `key_terms`: the terms a name is filed under, so a directory can answer half a name — shared with the package directory, because a name is a name. | | `pseudo_dir.py` | Signed name claims (bound to the public key, so a claim can only name its own author) and the bounded book that holds them — indexed by node id *and* by every key the name is filed under (the whole name and each word's prefixes), so it answers "what is this called?", "who is called this?" and "who is called something starting with this?". Keeps the proof when one node signs two names for one instant. | | `transports/` (`contract.py`, `medium.py`, `manager.py`, `registry.py`, `tcp.py`, `udp.py`, `spool.py`, `bundle.py`) | **The transports**: the `BaseTransport`/`BaseServer` interfaces, the one place the core asks a medium a question (and checks the answer), a registry by URL scheme, the one file naming the schemes the node ships (`registry.py`), and the three media written to be read. The old flat paths (`transport.py`, `udp_transport.py`, …) are permanent aliases. | diff --git a/Docs/I18n/guide b/Docs/I18n/guide new file mode 100644 index 00000000..c861ab57 --- /dev/null +++ b/Docs/I18n/guide @@ -0,0 +1,86 @@ +# Languages — how a sentence reaches a person + +`src/i18n.py`, `src/locales/*.json` + +NMesh speaks **French, English, German, Russian, Chinese, Polish and Slovak**. +No interface string is written in a page or an app: it is a key in a catalogue, +and the catalogue is the only place a sentence lives. English is the reference — +the language a key is written in first, and the one a gap falls back to. + +## Which language + +1. The operator's `language` setting (`nmesh.conf`, or Settings in the console). + One of `en fr de ru zh pl sk`; **empty means follow the browser**. It applies + **live**: the next page load speaks it, and every running app is told. +2. Otherwise the browser's `Accept-Language`, among the languages we ship. +3. Otherwise English. + +Nothing a peer sends can change it, and a header is read to a bound (512 +characters, 32 entries) because a stranger's browser writes it. + +## The catalogues + +One JSON file per language, a flat map of dotted keys: + +```json +{ + "common.save": "Enregistrer", + "count.node": { "one": "{count} nœud", "other": "{count} nœuds" } +} +``` + +* A key is `area.name` — lower case, digits and `_`, at least one dot. +* A value is a string, or a map of **plural forms** (`zero one two few many other`) + that always has `other`. A plural is chosen by the `count` parameter, by the + language's CLDR rule: Russian and Polish have `one few many`, Slovak + `one few other`, French `one other` (0 and 1 are `one`), Chinese only `other`. +* `{name}` is a parameter. It is filled from a regular expression, never + `str.format`: a catalogue is data, and `{a.b}` in data must not read an + attribute of what the caller passed. +* A catalogue that is not exactly this shape is refused whole (`i18n.load` + answers `{}`): that language falls back to English, the node does not stop. + +`tests/test_i18n.py` holds every translation to English's keys and parameters, +and every plural entry to the forms its language uses. **Adding a key means +adding it in all seven files**, or the suite is red. + +## In a page + +`/i18n.js` is loaded by every page right after `/theme.js`. It defines +`NMESH_LANG` and `NMESH_I18N` (the language laid over English, so a missing +translation is still a sentence). `ui.JS` gives pages: + +```js +t("common.save") // a sentence +t("count.node", {count: 3}) // a plural, chosen by Intl.PluralRules +``` + +and markup that carries its words as data, translated in one pass at load: + +```html + + +``` + +A key nobody defined shows as itself — ugly on purpose. `applyI18n(root)` +translates markup a page built later. + +## In an app + +* **Python**: `from src import i18n` then `i18n.t("common.saved", "de")`. +* **The language the operator chose**: `AppHost.language` (`""` when the + browser decides). An app or its bridge that defines `set_language(code)` is + told at start and on every change; an app that raises costs only itself. +* **An app's own page** is served by the same console and loads the same + `/i18n.js`, so `t()` and `data-i18n` work there as they do in the console. +* An app ships its own sentences in its own catalogues under the same rule; + the shared ones (`common.*`, `state.*`, `count.*`, `language.*`) are for + everybody. + +## Adding a language + +Add the code to `NAMES` in `src/i18n.py` **and** `LANGUAGES` in `src/config.py` +(it imports nothing of its own, so the list is spelled twice and a test holds +them together), the plural rule to `plural_form` and `REQUIRED_FORMS` in the +test, then `src/locales/.json` with every key, including +`language.name.` in every file. diff --git a/Docs/Setup/guide b/Docs/Setup/guide index d5d3043f..281c20c5 100644 --- a/Docs/Setup/guide +++ b/Docs/Setup/guide @@ -648,6 +648,13 @@ Empty by default: the published GitHub releases. Set to a branch name, the check reads `src/version.py` at that branch instead and *Install* takes its tree. See [Following a branch instead of the releases](#following-a-branch-instead-of-the-releases). +### `language` — what the console and the apps speak + +Empty by default: the browser decides (`Accept-Language`, among `en fr de ru zh +pl sk`, else English). Set to one of those codes it wins over the browser, for +every page and every app. It applies **live** and is read from the file on every +page load. See [`../I18n/guide`](../I18n/guide). + ### `update_check_minutes` / `update_when_active` — when the node goes looking `update_check_minutes` (5) is how often the node sweeps for an installable diff --git a/Docs/WebConsole/design b/Docs/WebConsole/design index 64c1dc36..d7365aaa 100644 --- a/Docs/WebConsole/design +++ b/Docs/WebConsole/design @@ -334,6 +334,13 @@ that has since changed is thrown as `StaleContext` rather than painted, and an HTTP 401 means *this* console's session — a managed node's expiring arrives as a refusal, not as a sign-out. +## Language + +Every page loads `/i18n.js` right after `/theme.js`; `ui.JS` then offers `t(key, +params)` and `applyI18n(root)` (`data-i18n`, `data-i18n-attr`). A page writes +**no sentence of its own**: it asks for a key. See +[`../I18n/guide`](../I18n/guide). + ## Constraints you cannot see - **No external resource.** No CDN, no web font, no remote image. The console diff --git a/pyproject.toml b/pyproject.toml index 4ac1811a..9cbce975 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.backends.legacy:build" [project] name = "nmesh" -version = "0.4.116" +version = "0.4.117" license = "GPL-3.0-only" [tool.pytest.ini_options] diff --git a/scripts/nmesh_node.py b/scripts/nmesh_node.py index f69252f7..044de43a 100644 --- a/scripts/nmesh_node.py +++ b/scripts/nmesh_node.py @@ -593,7 +593,8 @@ async def main() -> None: local_console = fleet_console.LocalConsole() if connector is not None: - host = AppHost(registry, app_storage=node.app_storage) + host = AppHost(registry, app_storage=node.app_storage, + language=lambda: config.language(config_path)) host.register("chat", _chat_factory(node, connector)) host.register("fleet", _fleet_factory(node, connector, args.data, local_console)) diff --git a/src/app_registry.py b/src/app_registry.py index 15f431d6..a037097b 100644 --- a/src/app_registry.py +++ b/src/app_registry.py @@ -348,9 +348,11 @@ class AppHost: Every method is a coroutine driven from the event loop; the console marshals its calls onto that loop like every other node interaction.""" - def __init__(self, registry: AppRegistry, *, app_storage=None) -> None: + def __init__(self, registry: AppRegistry, *, app_storage=None, + language=None) -> None: self._registry = registry self._storage = app_storage + self._language = language self._factories: dict[str, object] = {} self._running: dict[str, tuple] = {} # name -> (app, bridge) self._loop: asyncio.AbstractEventLoop | None = None @@ -365,6 +367,31 @@ def register(self, name: str, factory) -> None: if self._registry.known(name): self._factories[name] = factory + @property + def language(self) -> str: + """The language the operator chose, or ``""`` to follow the browser. + + What an app answers in when no browser is asking (a notification, a + message it composes itself). An app that wants to be told when it + changes defines ``set_language(code)`` on itself or its bridge.""" + try: + value = self._language() if self._language else "" + except Exception: + return "" + return value if isinstance(value, str) else "" + + def set_language(self, code: str) -> None: + """Tell every running app the language changed. One that fails costs + only itself.""" + for name, (app, bridge) in list(self._running.items()): + for target in (app, bridge): + hook = getattr(target, "set_language", None) + if callable(hook): + try: + hook(code) + except Exception as exc: # noqa: BLE001 + faults.note(f"app {name} language", exc) + def bridge(self, name: str): entry = self._running.get(name) return entry[1] if entry else None @@ -425,6 +452,7 @@ async def _start(self, name: str) -> bool: except Exception as exc: # noqa: BLE001 faults.note(f"app {name} bridge", exc) self._running[name] = (app, bridge) + self.set_language(self.language) return True async def _stop(self, name: str) -> bool: diff --git a/src/config.py b/src/config.py index 13f53b3d..f27223e0 100644 --- a/src/config.py +++ b/src/config.py @@ -273,6 +273,19 @@ def _as_link_idle(raw: str) -> int: return value +# The languages the product ships, spelled out here because this module imports +# nothing of its own; `tests/test_i18n.py` holds the list to `src/i18n.py`. +LANGUAGES = ("en", "fr", "de", "ru", "zh", "pl", "sk") + + +def _as_language(raw: str) -> str: + """A shipped language, or empty to follow the browser.""" + value = raw.strip().lower() + if value and value not in LANGUAGES: + raise ConfigError("not a shipped language (%s)" % ", ".join(LANGUAGES)) + return value + + def _as_branch(raw: str) -> str: """A branch to follow instead of the published releases. Empty = releases. @@ -471,6 +484,9 @@ def _as_path(raw: str): "Branch whose src/version.py says what the latest " "version is, instead of the published GitHub releases " "(empty = releases)"), + "language": (_as_language, "", True, + "Language of the console and of the apps (empty = " + "follow the browser): " + ", ".join(LANGUAGES)), "no_chat": (_as_bool, False, True, "Disable the built-in chat app"), "fleet": (_as_bool, False, True, "Enable the fleet app (remote management, can open a shell)"), @@ -497,7 +513,7 @@ def _as_path(raw: str): "keepalive_slow_min_ms", "keepalive_slow_max_ms", "pseudo", "no_abuse_gossip", "release_quorum", "release_auto_publish", "update_check_minutes", "update_when_active", "recommend_version", - "update_branch", + "update_branch", "language", }) # Keys the launcher takes on the command line, spelled as CLI flags. @@ -604,6 +620,17 @@ def load(path: str) -> tuple[dict, list]: return parse(text) +def language(path: str | None) -> str: + """The ``language`` stored in the file at ``path``, or ``""`` (follow the + browser). Read each time, like ``updater.update_branch``: saving the file is + the whole of applying it.""" + if not path: + return "" + values, _problems = load(path) + value = values.get("language", "") + return value if value in LANGUAGES else "" + + _HEADER = """\ # NMesh — node configuration. # @@ -727,7 +754,7 @@ def apply_edits(current: dict, edits) -> tuple[dict, list]: return merged, rejected -_KINDS = {_as_bool: "bool", _as_port: "int", _as_optional_port: "int", +_KINDS = {_as_language: "choice", _as_bool: "bool", _as_port: "int", _as_optional_port: "int", _as_store_mb: "int", _as_score: "text", _as_seconds: "text", _as_quorum: "int"} @@ -754,5 +781,6 @@ def public(values: dict) -> list: "live": name in LIVE, "help": help_text, "flag": CLI_NAMES[name], + **({"choices": [""] + list(LANGUAGES)} if name == "language" else {}), }) return shown diff --git a/src/control/modules/settings.py b/src/control/modules/settings.py index 32696887..7c20c06c 100644 --- a/src/control/modules/settings.py +++ b/src/control/modules/settings.py @@ -54,7 +54,7 @@ "recommend_version": "recommend"} # Read from the file each time it is needed (`updater.update_branch`), so # writing the file is the whole of applying it. -_FROM_FILE = ("update_branch",) +_FROM_FILE = ("update_branch", "language") LIVE_GROUPS = (_KEEPALIVE, _MLO, tuple(_UPDATES), _FROM_FILE, ("punch_keepalive",), ("lan_discovery",), ("transport_balance",), ("dynamic_address",), @@ -243,6 +243,16 @@ def op_profile(self, step: int) -> dict: answer["profile"] = power.match(merged) return answer + def _tell_apps(self, code: str) -> None: + """Hand the apps the language just chosen; they hear it as the file now + says it. An app that cannot take it must not fail the save.""" + host = self._context.host() + if host is not None: + try: + host.set_language(code) + except Exception: + pass + def _apply(self, merged: dict, changed): return self._context.ask( apply_live(self._context.node, merged, changed), _LIVE) @@ -258,6 +268,8 @@ def _commit(self, path: str, before: dict, merged: dict) -> dict: if live: adjusted, problems = self._apply(merged, live) merged.update(adjusted) + if "language" in live: + self._tell_apps(merged.get("language", "")) try: node_config.save(path, merged) except OSError as exc: diff --git a/src/i18n.py b/src/i18n.py new file mode 100644 index 00000000..c4e23802 --- /dev/null +++ b/src/i18n.py @@ -0,0 +1,191 @@ +""" +The one way a string reaches a person. + +Every sentence the product shows lives in a catalogue under ``src/locales/``, +one JSON file per language, and is asked for by key. Nothing here knows what a +page or an app is: a page asks the browser's ``t()`` (``webassets/ui.py``), an +app asks :func:`t` or is handed the language it was started under. + +**The catalogues are data, and data is checked.** A file is read once, bounded +in size and in shape (a flat map of keys to a string or a map of plural forms), +and anything that is not that is refused whole rather than half-used — a +catalogue a person can edit is a catalogue that can be wrong. English is the +reference: a key missing in another language falls back to it, and a key +missing everywhere is shown as itself, which is ugly on purpose. + +**A language is chosen, never guessed from the network.** The operator's +``language`` setting wins; with none, the browser's ``Accept-Language`` picks +among the languages we ship; with neither, English. Nothing a peer sends can +change it. + +The module imports nothing of its own: ``config.py`` is loaded from its file by +the installer, and the web console and the apps load this one the same way. +""" +from __future__ import annotations + +import json +import os +import re + +DEFAULT = "en" + +# Code -> the language's name in itself, so a person who cannot read the current +# language can still find their own in a list. +NAMES = { + "en": "English", + "fr": "Français", + "de": "Deutsch", + "ru": "Русский", + "zh": "中文", + "pl": "Polski", + "sk": "Slovenčina", +} +LANGUAGES = tuple(NAMES) + +_DIRECTORY = os.path.join(os.path.dirname(os.path.abspath(__file__)), "locales") +_MAX_FILE = 512 * 1024 +_MAX_KEYS = 20000 +_MAX_VALUE = 4000 +_KEY_RE = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$") +_PARAM_RE = re.compile(r"\{([a-z][a-z0-9_]*)\}") +_FORMS = ("zero", "one", "two", "few", "many", "other") + +_cache: dict[str, dict] = {} + + +class CatalogueError(ValueError): + """A catalogue that is not the shape we read.""" + + +def _check(data) -> dict: + if not isinstance(data, dict) or len(data) > _MAX_KEYS: + raise CatalogueError("a catalogue is a bounded map of keys") + for key, value in data.items(): + if not isinstance(key, str) or not _KEY_RE.match(key): + raise CatalogueError("not a usable key: %r" % (key,)) + forms = value if isinstance(value, dict) else {"other": value} + if "other" not in forms or set(forms) - set(_FORMS): + raise CatalogueError("%s: plural forms need 'other' and no stranger" % key) + for text in forms.values(): + if not isinstance(text, str) or len(text) > _MAX_VALUE: + raise CatalogueError("%s: a form is a short string" % key) + return data + + +def load(language: str, directory: str | None = None) -> dict: + """The catalogue of ``language`` as written, or ``{}`` when there is none. + + Never raises: a missing or broken file costs that language its strings (the + reader falls back to English), not the page or the node.""" + if language not in NAMES: + return {} + if directory is None and language in _cache: + return _cache[language] + path = os.path.join(directory or _DIRECTORY, language + ".json") + try: + if os.path.getsize(path) > _MAX_FILE: + raise CatalogueError("too large") + with open(path, "r", encoding="utf-8") as handle: + data = _check(json.load(handle)) + except (OSError, ValueError): + data = {} + if directory is None: + _cache[language] = data + return data + + +def plural_form(language: str, count) -> str: + """The CLDR plural category of ``count`` in ``language``. + + Only the categories our languages use. The browser asks ``Intl.PluralRules`` + for the same answer; ``tests/test_i18n.py`` holds the two in step.""" + try: + number = float(count) + except (TypeError, ValueError): + return "other" + whole = number.is_integer() + n = int(number) if whole else 0 + if language in ("en", "de"): + return "one" if whole and n == 1 else "other" + if language == "fr": + return "one" if number >= 0 and number < 2 else "other" + if language in ("ru", "pl", "sk"): + if not whole: + return "many" if language == "sk" else "other" + if language == "sk": + if n == 1: + return "one" + return "few" if 2 <= n <= 4 else "other" + if language == "pl" and n == 1: + return "one" + if language == "ru" and n % 10 == 1 and n % 100 != 11: + return "one" + if 2 <= n % 10 <= 4 and not 12 <= n % 100 <= 14: + return "few" + return "many" + return "other" + + +def _fill(text: str, params: dict) -> str: + """``{name}`` replaced from ``params``; a name nobody gave stays as written. + + A regex, not ``str.format``: a catalogue is data, and ``{a.b}`` or ``{0!r}`` + in data must not be a way to read attributes of what the caller passed.""" + return _PARAM_RE.sub( + lambda found: str(params[found.group(1)]) if found.group(1) in params + else found.group(0), text) + + +def t(key: str, language: str = DEFAULT, **params) -> str: + """The sentence for ``key`` in ``language``, parameters filled in. + + A plural entry is chosen by the ``count`` parameter.""" + for code in (language, DEFAULT): + value = load(code).get(key) + if value is None: + continue + if isinstance(value, dict): + form = plural_form(code, params.get("count")) + value = value.get(form, value["other"]) + return _fill(value, params) + return key + + +def negotiate(header: str | None) -> str | None: + """The shipped language an ``Accept-Language`` header prefers, or ``None``. + + Bounded: a header is read to a length and a count of entries, since it is + the one input here that a stranger's browser writes.""" + if not isinstance(header, str): + return None + ranked = [] + for index, part in enumerate(header[:512].split(",")[:32]): + tag, _, rest = part.strip().partition(";") + weight = 1.0 + match = re.match(r"\s*q=([0-9.]+)", rest) + if match: + try: + weight = float(match.group(1)) + except ValueError: + continue + primary = tag.strip().lower().split("-")[0] + if primary in NAMES and weight > 0: + ranked.append((-weight, index, primary)) + return min(ranked)[2] if ranked else None + + +def effective(setting: str | None, header: str | None = None) -> str: + """The language to speak: the operator's choice, else the browser's, else + English.""" + if setting in NAMES: + return setting + return negotiate(header) or DEFAULT + + +def bundle(language: str) -> dict: + """What a page needs: the language's strings laid over English, so a key + missing in a translation is still a sentence in the browser.""" + merged = dict(load(DEFAULT)) + if language != DEFAULT: + merged.update(load(language)) + return merged diff --git a/src/locales/de.json b/src/locales/de.json new file mode 100644 index 00000000..c6555a4c --- /dev/null +++ b/src/locales/de.json @@ -0,0 +1,47 @@ +{ + "common.add": "Hinzufügen", + "common.back": "Zurück", + "common.cancel": "Abbrechen", + "common.close": "Schließen", + "common.copy": "Kopieren", + "common.delete": "Löschen", + "common.edit": "Bearbeiten", + "common.failed": "Etwas ist schiefgelaufen", + "common.loading": "Wird geladen…", + "common.none": "Keine", + "common.refresh": "Aktualisieren", + "common.remove": "Entfernen", + "common.retry": "Erneut versuchen", + "common.save": "Speichern", + "common.saved": "Gespeichert", + "common.search": "Suchen", + "common.send": "Senden", + "common.unknown": "Unbekannt", + "config.saved": "Konfiguration gespeichert", + "count.link": { + "one": "{count} Verbindung", + "other": "{count} Verbindungen" + }, + "count.message": { + "one": "{count} Nachricht", + "other": "{count} Nachrichten" + }, + "count.node": { + "one": "{count} Knoten", + "other": "{count} Knoten" + }, + "language.auto": "Automatisch (Browser folgen)", + "language.help": "Sprache der Konsole und der Apps. Automatisch folgt dem Browser.", + "language.label": "Sprache", + "language.name.de": "Deutsch", + "language.name.en": "English", + "language.name.fr": "Français", + "language.name.pl": "Polski", + "language.name.ru": "Русский", + "language.name.sk": "Slovenčina", + "language.name.zh": "中文", + "state.connected": "Verbunden", + "state.disconnected": "Getrennt", + "state.offline": "Offline", + "state.online": "Online" +} diff --git a/src/locales/en.json b/src/locales/en.json new file mode 100644 index 00000000..a731524f --- /dev/null +++ b/src/locales/en.json @@ -0,0 +1,47 @@ +{ + "common.add": "Add", + "common.back": "Back", + "common.cancel": "Cancel", + "common.close": "Close", + "common.copy": "Copy", + "common.delete": "Delete", + "common.edit": "Edit", + "common.failed": "Something went wrong", + "common.loading": "Loading…", + "common.none": "None", + "common.refresh": "Refresh", + "common.remove": "Remove", + "common.retry": "Retry", + "common.save": "Save", + "common.saved": "Saved", + "common.search": "Search", + "common.send": "Send", + "common.unknown": "Unknown", + "config.saved": "Configuration saved", + "count.link": { + "one": "{count} link", + "other": "{count} links" + }, + "count.message": { + "one": "{count} message", + "other": "{count} messages" + }, + "count.node": { + "one": "{count} node", + "other": "{count} nodes" + }, + "language.auto": "Automatic (follow the browser)", + "language.help": "Language of the console and of the apps. Automatic follows the browser.", + "language.label": "Language", + "language.name.de": "Deutsch", + "language.name.en": "English", + "language.name.fr": "Français", + "language.name.pl": "Polski", + "language.name.ru": "Русский", + "language.name.sk": "Slovenčina", + "language.name.zh": "中文", + "state.connected": "Connected", + "state.disconnected": "Disconnected", + "state.offline": "Offline", + "state.online": "Online" +} diff --git a/src/locales/fr.json b/src/locales/fr.json new file mode 100644 index 00000000..b6ed67a2 --- /dev/null +++ b/src/locales/fr.json @@ -0,0 +1,47 @@ +{ + "common.add": "Ajouter", + "common.back": "Retour", + "common.cancel": "Annuler", + "common.close": "Fermer", + "common.copy": "Copier", + "common.delete": "Supprimer", + "common.edit": "Modifier", + "common.failed": "Une erreur est survenue", + "common.loading": "Chargement…", + "common.none": "Aucun", + "common.refresh": "Actualiser", + "common.remove": "Retirer", + "common.retry": "Réessayer", + "common.save": "Enregistrer", + "common.saved": "Enregistré", + "common.search": "Rechercher", + "common.send": "Envoyer", + "common.unknown": "Inconnu", + "config.saved": "Configuration enregistrée", + "count.link": { + "one": "{count} lien", + "other": "{count} liens" + }, + "count.message": { + "one": "{count} message", + "other": "{count} messages" + }, + "count.node": { + "one": "{count} nœud", + "other": "{count} nœuds" + }, + "language.auto": "Automatique (suivre le navigateur)", + "language.help": "Langue de la console et des apps. Automatique suit le navigateur.", + "language.label": "Langue", + "language.name.de": "Deutsch", + "language.name.en": "English", + "language.name.fr": "Français", + "language.name.pl": "Polski", + "language.name.ru": "Русский", + "language.name.sk": "Slovenčina", + "language.name.zh": "中文", + "state.connected": "Connecté", + "state.disconnected": "Déconnecté", + "state.offline": "Hors ligne", + "state.online": "En ligne" +} diff --git a/src/locales/pl.json b/src/locales/pl.json new file mode 100644 index 00000000..2e647107 --- /dev/null +++ b/src/locales/pl.json @@ -0,0 +1,53 @@ +{ + "common.add": "Dodaj", + "common.back": "Wstecz", + "common.cancel": "Anuluj", + "common.close": "Zamknij", + "common.copy": "Kopiuj", + "common.delete": "Usuń", + "common.edit": "Edytuj", + "common.failed": "Coś poszło nie tak", + "common.loading": "Wczytywanie…", + "common.none": "Brak", + "common.refresh": "Odśwież", + "common.remove": "Usuń", + "common.retry": "Spróbuj ponownie", + "common.save": "Zapisz", + "common.saved": "Zapisano", + "common.search": "Szukaj", + "common.send": "Wyślij", + "common.unknown": "Nieznane", + "config.saved": "Konfiguracja zapisana", + "count.link": { + "one": "{count} łącze", + "few": "{count} łącza", + "many": "{count} łączy", + "other": "{count} łącza" + }, + "count.message": { + "one": "{count} wiadomość", + "few": "{count} wiadomości", + "many": "{count} wiadomości", + "other": "{count} wiadomości" + }, + "count.node": { + "one": "{count} węzeł", + "few": "{count} węzły", + "many": "{count} węzłów", + "other": "{count} węzła" + }, + "language.auto": "Automatycznie (jak przeglądarka)", + "language.help": "Język konsoli i aplikacji. Automatycznie podąża za przeglądarką.", + "language.label": "Język", + "language.name.de": "Deutsch", + "language.name.en": "English", + "language.name.fr": "Français", + "language.name.pl": "Polski", + "language.name.ru": "Русский", + "language.name.sk": "Slovenčina", + "language.name.zh": "中文", + "state.connected": "Połączono", + "state.disconnected": "Rozłączono", + "state.offline": "Offline", + "state.online": "Online" +} diff --git a/src/locales/ru.json b/src/locales/ru.json new file mode 100644 index 00000000..3b023d3a --- /dev/null +++ b/src/locales/ru.json @@ -0,0 +1,53 @@ +{ + "common.add": "Добавить", + "common.back": "Назад", + "common.cancel": "Отмена", + "common.close": "Закрыть", + "common.copy": "Копировать", + "common.delete": "Удалить", + "common.edit": "Изменить", + "common.failed": "Что-то пошло не так", + "common.loading": "Загрузка…", + "common.none": "Нет", + "common.refresh": "Обновить", + "common.remove": "Убрать", + "common.retry": "Повторить", + "common.save": "Сохранить", + "common.saved": "Сохранено", + "common.search": "Поиск", + "common.send": "Отправить", + "common.unknown": "Неизвестно", + "config.saved": "Конфигурация сохранена", + "count.link": { + "one": "{count} соединение", + "few": "{count} соединения", + "many": "{count} соединений", + "other": "{count} соединения" + }, + "count.message": { + "one": "{count} сообщение", + "few": "{count} сообщения", + "many": "{count} сообщений", + "other": "{count} сообщения" + }, + "count.node": { + "one": "{count} узел", + "few": "{count} узла", + "many": "{count} узлов", + "other": "{count} узла" + }, + "language.auto": "Автоматически (как в браузере)", + "language.help": "Язык консоли и приложений. Автоматический режим следует языку браузера.", + "language.label": "Язык", + "language.name.de": "Deutsch", + "language.name.en": "English", + "language.name.fr": "Français", + "language.name.pl": "Polski", + "language.name.ru": "Русский", + "language.name.sk": "Slovenčina", + "language.name.zh": "中文", + "state.connected": "Подключено", + "state.disconnected": "Отключено", + "state.offline": "Не в сети", + "state.online": "В сети" +} diff --git a/src/locales/sk.json b/src/locales/sk.json new file mode 100644 index 00000000..2649b557 --- /dev/null +++ b/src/locales/sk.json @@ -0,0 +1,53 @@ +{ + "common.add": "Pridať", + "common.back": "Späť", + "common.cancel": "Zrušiť", + "common.close": "Zavrieť", + "common.copy": "Kopírovať", + "common.delete": "Odstrániť", + "common.edit": "Upraviť", + "common.failed": "Niečo sa pokazilo", + "common.loading": "Načítava sa…", + "common.none": "Žiadne", + "common.refresh": "Obnoviť", + "common.remove": "Odstrániť", + "common.retry": "Skúsiť znova", + "common.save": "Uložiť", + "common.saved": "Uložené", + "common.search": "Hľadať", + "common.send": "Odoslať", + "common.unknown": "Neznáme", + "config.saved": "Konfigurácia uložená", + "count.link": { + "one": "{count} spojenie", + "few": "{count} spojenia", + "other": "{count} spojení", + "many": "{count} spojenia" + }, + "count.message": { + "one": "{count} správa", + "few": "{count} správy", + "other": "{count} správ", + "many": "{count} správy" + }, + "count.node": { + "one": "{count} uzol", + "few": "{count} uzly", + "other": "{count} uzlov", + "many": "{count} uzla" + }, + "language.auto": "Automaticky (podľa prehliadača)", + "language.help": "Jazyk konzoly a aplikácií. Automaticky sleduje prehliadač.", + "language.label": "Jazyk", + "language.name.de": "Deutsch", + "language.name.en": "English", + "language.name.fr": "Français", + "language.name.pl": "Polski", + "language.name.ru": "Русский", + "language.name.sk": "Slovenčina", + "language.name.zh": "中文", + "state.connected": "Pripojené", + "state.disconnected": "Odpojené", + "state.offline": "Offline", + "state.online": "Online" +} diff --git a/src/locales/zh.json b/src/locales/zh.json new file mode 100644 index 00000000..40563e7d --- /dev/null +++ b/src/locales/zh.json @@ -0,0 +1,44 @@ +{ + "common.add": "添加", + "common.back": "返回", + "common.cancel": "取消", + "common.close": "关闭", + "common.copy": "复制", + "common.delete": "删除", + "common.edit": "编辑", + "common.failed": "出了点问题", + "common.loading": "加载中…", + "common.none": "无", + "common.refresh": "刷新", + "common.remove": "移除", + "common.retry": "重试", + "common.save": "保存", + "common.saved": "已保存", + "common.search": "搜索", + "common.send": "发送", + "common.unknown": "未知", + "config.saved": "配置已保存", + "count.link": { + "other": "{count} 条链路" + }, + "count.message": { + "other": "{count} 条消息" + }, + "count.node": { + "other": "{count} 个节点" + }, + "language.auto": "自动(跟随浏览器)", + "language.help": "控制台和应用使用的语言。自动模式跟随浏览器。", + "language.label": "语言", + "language.name.de": "Deutsch", + "language.name.en": "English", + "language.name.fr": "Français", + "language.name.pl": "Polski", + "language.name.ru": "Русский", + "language.name.sk": "Slovenčina", + "language.name.zh": "中文", + "state.connected": "已连接", + "state.disconnected": "已断开", + "state.offline": "离线", + "state.online": "在线" +} diff --git a/src/version.py b/src/version.py index f61d8a51..a59b1930 100644 --- a/src/version.py +++ b/src/version.py @@ -10,7 +10,7 @@ import re -__version__ = "0.4.116" +__version__ = "0.4.117" # Releases are tagged ``vX.Y.Z`` (see .github/workflows/release.yml). Anything # after the numbers (``-rc1``, ``+build``) is kept but only used to break ties diff --git a/src/webassets/chat.py b/src/webassets/chat.py index b08a4672..d08ccd15 100644 --- a/src/webassets/chat.py +++ b/src/webassets/chat.py @@ -28,6 +28,7 @@ NMesh Chat + diff --git a/src/webassets/console.py b/src/webassets/console.py index 354959af..d2b9d6e9 100644 --- a/src/webassets/console.py +++ b/src/webassets/console.py @@ -18,6 +18,7 @@ NMesh Console + @@ -4603,7 +4604,8 @@ const label = document.createElement("label"); label.className = "field"; const title = document.createElement("span"); - title.textContent = setting.name.replace(/_/g, " ") + (setting.editable ? "" : " (file only)"); + title.textContent = (setting.name === "language" ? t("language.label") + : setting.name.replace(/_/g, " ")) + (setting.editable ? "" : " (file only)"); // The bar above moves these; a hand edit here makes the node "custom". if(profiled.has(setting.name)){ const mark = document.createElement("span"); @@ -4627,6 +4629,16 @@ input.className = "mono"; input.rows = 3; input.value = (setting.value || []).join("\n"); label.appendChild(input); + }else if(setting.kind === "choice"){ + input = document.createElement("select"); + for(const choice of setting.choices || []){ + const option = document.createElement("option"); + option.value = choice; + option.textContent = choice ? t("language.name." + choice) : t("language.auto"); + option.selected = choice === (setting.value || ""); + input.appendChild(option); + } + label.appendChild(input); }else{ input = document.createElement("input"); input.type = setting.kind === "int" ? "number" : "text"; @@ -4643,7 +4655,8 @@ if(!setting.editable) input.disabled = true; const help = document.createElement("small"); help.className = "hint"; - help.textContent = setting.help + (setting.flag ? " · " + setting.flag : ""); + help.textContent = (setting.name === "language" ? t("language.help") : setting.help) + + (setting.flag ? " · " + setting.flag : ""); (setting.kind === "bool" ? label.lastChild : label).appendChild(help); $(setting.live ? "config-fields" : "config-fields-restart").appendChild(label); } diff --git a/src/webassets/fleet.py b/src/webassets/fleet.py index 18eaa19a..ebc3547b 100644 --- a/src/webassets/fleet.py +++ b/src/webassets/fleet.py @@ -21,6 +21,7 @@ NMesh Fleet + diff --git a/src/webassets/nodeview.py b/src/webassets/nodeview.py index f9ab2a9e..b47d44e1 100644 --- a/src/webassets/nodeview.py +++ b/src/webassets/nodeview.py @@ -1274,6 +1274,7 @@ NMesh node + diff --git a/src/webassets/packages.py b/src/webassets/packages.py index 3bcf6ebb..92b5c4d8 100644 --- a/src/webassets/packages.py +++ b/src/webassets/packages.py @@ -561,6 +561,7 @@ NMesh package + diff --git a/src/webassets/terminal.py b/src/webassets/terminal.py index 9698149b..5af06231 100644 --- a/src/webassets/terminal.py +++ b/src/webassets/terminal.py @@ -1908,6 +1908,7 @@ NMesh Terminal + diff --git a/src/webassets/ui.py b/src/webassets/ui.py index 50123266..319f8a2c 100644 --- a/src/webassets/ui.py +++ b/src/webassets/ui.py @@ -967,6 +967,41 @@ def ctx_bar(leave: bool = True) -> str: // same one, and a lookalike costs an attacker nothing to register. So the id // always travels with it — never a name on its own. const nodeLabel = (id, pseudo) => pseudo ? pseudo + " · " + shortId(id) : shortId(id); +// ---- language -------------------------------------------------------------- +// /i18n.js (served by the node, loaded before this file) defines NMESH_LANG and +// NMESH_I18N. A page asks for a sentence by key and never writes one: t() for a +// sentence, with {name} filled from `params`; a plural entry is chosen by +// `params.count`. A key nobody defined is shown as itself, on purpose. +const LANG = (typeof NMESH_LANG === "string" && NMESH_LANG) || "en"; +const STRINGS = (typeof NMESH_I18N === "object" && NMESH_I18N) || {}; +const PLURALS = (() => { try{ return new Intl.PluralRules(LANG); }catch(_){ return null; } })(); +const t = (key, params) => { + let text = STRINGS[key]; + if(text == null) return key; + const values = params || {}; + if(typeof text === "object"){ + const form = PLURALS ? PLURALS.select(Number(values.count)) : "other"; + text = text[form] != null ? text[form] : text.other; + } + return String(text).replace(/\{([a-z][a-z0-9_]*)\}/g, + (whole, name) => Object.prototype.hasOwnProperty.call(values, name) + ? String(values[name]) : whole); +}; +// Markup carries its words as data: data-i18n="key" for the text, +// data-i18n-attr="title:key;placeholder:key" for attributes. One pass names +// every static sentence of a page; what JS builds calls t() itself. +function applyI18n(root){ + const scope = root || document; + for(const node of scope.querySelectorAll("[data-i18n]")) + node.textContent = t(node.getAttribute("data-i18n")); + for(const node of scope.querySelectorAll("[data-i18n-attr]")) + for(const pair of node.getAttribute("data-i18n-attr").split(";")){ + const [attribute, key] = pair.split(":"); + if(attribute && key) node.setAttribute(attribute.trim(), t(key.trim())); + } +} +document.documentElement.lang = LANG; +applyI18n(); // ---- icons ----------------------------------------------------------------- // One set for the whole product. Paths only: the wrapper is written once by // `icon()`, so every icon shares a stroke weight, a box and a baseline. diff --git a/src/webconsole.py b/src/webconsole.py index 6a1336b6..14f6d6cc 100644 --- a/src/webconsole.py +++ b/src/webconsole.py @@ -40,6 +40,8 @@ from urllib.parse import parse_qs from . import app_api +from . import config as node_config +from . import i18n from . import app_web from . import control from .control import listing @@ -682,6 +684,19 @@ def _update_branch(self) -> str: elsewhere must not be told what some other file says.""" return updater.update_branch(self._config_path) + def _i18n_script(self, accept_language: str | None) -> str: + """The strings a page speaks with, as the one script every page loads. + + The language is the operator's saved choice, else the browser's, else + English — read from the file on every request, so a change is in force + on the next page load. Strings are laid over English so a gap in a + translation shows a sentence rather than a key.""" + language = i18n.effective(node_config.language(self._config_path), + accept_language) + return ("window.NMESH_LANG=%s;window.NMESH_I18N=%s;" % ( + json.dumps(language), json.dumps(i18n.bundle(language), + ensure_ascii=False))) + @property def _api(self) -> "app_api.AppAPI": """The app API surface, over whatever is running right now. @@ -1334,6 +1349,12 @@ def _get(self) -> None: if path == "/api/events": self._stream_changes() return + if path == "/i18n.js": + self._send(200, "application/javascript; charset=utf-8", + console._i18n_script( + self.headers.get("Accept-Language")).encode("utf-8"), + [("Cache-Control", "no-store")]) + return if path in _STATIC: ctype, text = _STATIC[path] self._send(200, ctype, text.encode("utf-8")) diff --git a/tests/test_i18n.py b/tests/test_i18n.py new file mode 100644 index 00000000..1219074e --- /dev/null +++ b/tests/test_i18n.py @@ -0,0 +1,393 @@ +"""Every sentence reaches a person through one catalogue per language. + +The catalogues are data a person can edit, so the checks are on the data: the +same keys everywhere, the same parameters in every translation, the plural +forms each language needs. The browser reads the same files, so its plural rule +and its parameter filling are held to the Python ones by running both. +""" +import json +import os +import random +import re +import shutil +import subprocess + +import pytest + +from src import config, i18n +from src.app_registry import AppHost, AppRegistry +from src.webassets import ui + +NODE = shutil.which("node") +LOCALES = os.path.join(os.path.dirname(i18n.__file__), "locales") +PARAMS = re.compile(r"\{([a-z][a-z0-9_]*)\}") + +# What each language must be able to say for a plural entry (CLDR categories +# for integers; "other" is always required). +REQUIRED_FORMS = { + "en": {"one", "other"}, "de": {"one", "other"}, "fr": {"one", "other"}, + "zh": {"other"}, + "ru": {"one", "few", "many", "other"}, + "pl": {"one", "few", "many", "other"}, + "sk": {"one", "few", "other"}, +} + + +def _raw(language): + with open(os.path.join(LOCALES, language + ".json"), encoding="utf-8") as handle: + return json.load(handle) + + +def _forms(value): + return value if isinstance(value, dict) else {"other": value} + + +class TestTheLanguagesWeShip: + def test_the_seven_languages(self): + assert set(i18n.LANGUAGES) == {"en", "fr", "de", "ru", "zh", "pl", "sk"} + + def test_the_config_names_the_same_languages(self): + assert tuple(config.LANGUAGES) == tuple(i18n.LANGUAGES) + + @pytest.mark.parametrize("language", i18n.LANGUAGES) + def test_the_catalogue_is_the_shape_we_read(self, language): + i18n._check(_raw(language)) + assert i18n.load(language) == _raw(language) + + @pytest.mark.parametrize("language", [l for l in i18n.LANGUAGES if l != "en"]) + def test_a_translation_has_exactly_the_keys_of_english(self, language): + english, other = _raw("en"), _raw(language) + assert sorted(other) == sorted(english), ( + sorted(set(english) ^ set(other))) + + @pytest.mark.parametrize("language", [l for l in i18n.LANGUAGES if l != "en"]) + def test_a_translation_keeps_the_parameters_of_english(self, language): + english, other = _raw("en"), _raw(language) + for key, value in english.items(): + wanted = set(PARAMS.findall(" ".join(_forms(value).values()))) + for form, text in _forms(other[key]).items(): + assert set(PARAMS.findall(text)) <= wanted, (language, key, form) + # every form of a plural carries the count it is chosen by + if isinstance(other[key], dict): + for form, text in other[key].items(): + assert "{count}" in text, (language, key, form) + + @pytest.mark.parametrize("language", i18n.LANGUAGES) + def test_a_plural_entry_has_the_forms_its_language_uses(self, language): + for key, value in _raw(language).items(): + if isinstance(value, dict): + assert REQUIRED_FORMS[language] <= set(value), (language, key) + + @pytest.mark.parametrize("language", i18n.LANGUAGES) + def test_no_value_is_empty(self, language): + for key, value in _raw(language).items(): + for text in _forms(value).values(): + assert text.strip(), (language, key) + + def test_every_language_names_every_language_in_itself(self): + for language in i18n.LANGUAGES: + for code, name in i18n.NAMES.items(): + assert _raw(language)["language.name." + code] == name + + +class TestPlurals: + @pytest.mark.parametrize("language,count,form", [ + ("en", 1, "one"), ("en", 0, "other"), ("en", 2, "other"), + ("fr", 0, "one"), ("fr", 1, "one"), ("fr", 2, "other"), + ("de", 1, "one"), ("de", 5, "other"), + ("zh", 1, "other"), ("zh", 0, "other"), + ("ru", 1, "one"), ("ru", 21, "one"), ("ru", 11, "many"), + ("ru", 2, "few"), ("ru", 24, "few"), ("ru", 12, "many"), ("ru", 0, "many"), + ("pl", 1, "one"), ("pl", 21, "many"), ("pl", 2, "few"), + ("pl", 12, "many"), ("pl", 22, "few"), ("pl", 5, "many"), + ("sk", 1, "one"), ("sk", 3, "few"), ("sk", 5, "other"), ("sk", 0, "other"), + ("sk", 1.5, "many"), + ]) + def test_the_form_of_a_count(self, language, count, form): + assert i18n.plural_form(language, count) == form + + def test_a_count_that_is_not_a_number_is_other(self): + for bad in (None, "x", [], {}, float("nan")): + assert i18n.plural_form("ru", bad) == "other" + + @pytest.mark.parametrize("language", i18n.LANGUAGES) + def test_every_catalogue_sentence_with_a_count_reads_right(self, language): + for count in (0, 1, 2, 5, 11, 21, 22, 100): + text = i18n.t("count.node", language, count=count) + assert str(count) in text and "{" not in text + + @pytest.mark.skipif(NODE is None, reason="node is needed to ask Intl") + @pytest.mark.parametrize("language", i18n.LANGUAGES) + def test_the_browser_gives_the_same_form_for_a_whole_number(self, language): + script = ("const r = new Intl.PluralRules(%r); const out = [];" + "for (let n = 0; n <= 300; n++) out.push(r.select(n));" + "console.log(JSON.stringify(out));" % language) + result = subprocess.run([NODE, "-e", script], capture_output=True, + text=True, timeout=60) + assert result.returncode == 0, result.stderr + browser = json.loads(result.stdout) + assert browser == [i18n.plural_form(language, n) for n in range(301)] + + +class TestAsking: + def test_a_sentence_in_the_language_asked(self): + assert i18n.t("common.save", "fr") == "Enregistrer" + assert i18n.t("common.save", "de") == "Speichern" + + def test_a_language_we_do_not_ship_is_english(self): + assert i18n.t("common.save", "xx") == "Save" + + def test_a_missing_key_shows_as_itself(self): + assert i18n.t("no.such.key", "fr") == "no.such.key" + + def test_a_key_missing_in_a_translation_falls_back_to_english(self, tmp_path): + (tmp_path / "en.json").write_text('{"a.b": "hello"}', encoding="utf-8") + (tmp_path / "fr.json").write_text("{}", encoding="utf-8") + english = i18n.load("en", str(tmp_path)) + assert english == {"a.b": "hello"} and i18n.load("fr", str(tmp_path)) == {} + + def test_parameters_are_filled_and_an_unknown_one_stays_written(self): + assert i18n._fill("{count} x {other}", {"count": 3}) == "3 x {other}" + + def test_a_catalogue_cannot_reach_into_what_it_is_given(self): + class Secret: + token = "hunter2" + for template in ("{x.token}", "{x!r}", "{0}", "{x[0]}", "{{x}}"): + assert "hunter2" not in i18n._fill(template, {"x": Secret()}) + + def test_a_plural_chosen_by_count(self): + assert i18n.t("count.node", "ru", count=1) == "1 узел" + assert i18n.t("count.node", "ru", count=3) == "3 узла" + assert i18n.t("count.node", "ru", count=7) == "7 узлов" + assert i18n.t("count.node", "zh", count=7) == "7 个节点" + + def test_a_plural_without_a_count_is_other(self): + assert i18n.t("count.node", "en") == "{count} nodes" + + +class TestACatalogueThatIsWrong: + @pytest.mark.parametrize("body", [ + "not json", "[]", '"x"', '{"Bad Key": "x"}', '{"nodots": "x"}', + '{"a.b": 3}', '{"a.b": {"one": "x"}}', '{"a.b": {"other": "x", "weird": "y"}}', + '{"a.b": "' + "x" * 5000 + '"}', + ]) + def test_it_is_refused_whole_and_costs_only_its_language(self, tmp_path, body): + (tmp_path / "fr.json").write_text(body, encoding="utf-8") + assert i18n.load("fr", str(tmp_path)) == {} + + def test_a_file_that_is_not_there(self, tmp_path): + assert i18n.load("fr", str(tmp_path)) == {} + + def test_a_language_that_is_not_one_of_ours_reads_nothing(self, tmp_path): + assert i18n.load("../x", str(tmp_path)) == {} + + +class TestChoosing: + def test_the_operators_choice_wins(self): + assert i18n.effective("de", "fr-FR,fr;q=0.9") == "de" + + def test_without_one_the_browser_picks(self): + assert i18n.effective("", "fr-FR,fr;q=0.9,en;q=0.5") == "fr" + assert i18n.effective(None, "zh-CN") == "zh" + + def test_a_weight_orders_the_choice(self): + assert i18n.negotiate("en;q=0.3, sk;q=0.9, de;q=0.5") == "sk" + + def test_q_zero_is_a_refusal(self): + assert i18n.negotiate("fr;q=0, de;q=0.2") == "de" + + def test_nothing_we_ship_is_english(self): + assert i18n.effective("", "ja,ko;q=0.9") == "en" + assert i18n.effective("", None) == "en" + assert i18n.effective("klingon", "") == "en" + + def test_a_hostile_header_never_raises(self): + rng = random.Random(7) + alphabet = "abcdefghijklmnopqrstuvwxyz-;=,.q0123456789 \x00‮{}" + for _ in range(2000): + header = "".join(rng.choice(alphabet) for _ in range(rng.randint(0, 900))) + result = i18n.effective("", header) + assert result in i18n.NAMES + for bad in (b"fr", 3, [], {}): + assert i18n.negotiate(bad) is None + + def test_a_flood_of_entries_is_read_to_a_bound(self): + header = ",".join(["xx"] * 100000) + ",fr" + assert i18n.negotiate(header) is None + + def test_the_bundle_is_the_language_laid_over_english(self): + merged = i18n.bundle("pl") + assert set(merged) == set(i18n.load("en")) + assert merged["common.save"] == "Zapisz" + + +class TestTheSetting: + def test_it_takes_a_shipped_language_or_nothing(self): + parser = config.SETTINGS["language"][0] + assert parser("de") == "de" and parser(" FR ") == "fr" and parser("") == "" + for bad in ("xx", "english", "fr-FR", "../fr"): + with pytest.raises(config.ConfigError): + parser(bad) + + def test_the_console_is_offered_a_choice(self): + row = {r["name"]: r for r in config.public(config.defaults())}["language"] + assert row["kind"] == "choice" + assert row["choices"] == [""] + list(i18n.LANGUAGES) + assert row["live"] is True and row["editable"] is True + + def test_it_is_read_back_from_the_file(self, tmp_path): + path = str(tmp_path / "nmesh.conf") + assert config.language(path) == "" + values = config.defaults() + values["language"] = "ru" + config.save(path, values) + assert config.language(path) == "ru" + assert config.language(None) == "" + + def test_a_bad_value_in_the_file_is_no_language(self, tmp_path): + path = tmp_path / "nmesh.conf" + path.write_text("language = klingon\n", encoding="utf-8") + assert config.language(str(path)) == "" + + +class TestWhatTheAppsAreHanded: + class _App: + def __init__(self): + self.heard = [] + + async def start(self): + pass + + async def stop(self): + pass + + def set_language(self, code): + self.heard.append(code) + + def _host(self, language, tmp_path): + registry = AppRegistry(str(tmp_path)) + return AppHost(registry, language=language) + + def test_the_host_answers_the_choice(self, tmp_path): + assert self._host(lambda: "de", tmp_path).language == "de" + + def test_without_a_source_it_follows_the_browser(self, tmp_path): + assert self._host(None, tmp_path).language == "" + + def test_a_source_that_fails_is_no_language(self, tmp_path): + def broken(): + raise RuntimeError("x") + assert self._host(broken, tmp_path).language == "" + assert self._host(lambda: 7, tmp_path).language == "" + + def test_a_running_app_hears_a_change_and_a_failing_one_costs_only_itself( + self, tmp_path): + host = self._host(lambda: "pl", tmp_path) + good, bad = self._App(), self._App() + + def refuse(code): + raise RuntimeError("no") + bad.set_language = refuse + host._running["good"] = (good, None) + host._running["bad"] = (bad, None) + host._running["bridge"] = (object(), good) + host.set_language("sk") + assert good.heard == ["sk", "sk"] + + async def test_an_app_is_told_the_language_when_it_starts(self, tmp_path): + registry = AppRegistry(str(tmp_path)) + host = AppHost(registry, language=lambda: "fr") + app = self._App() + + async def factory(): + return app, None + name = registry.overview(set())[0]["id"] + host.register(name, factory) + registry.set_installed(name, True) + registry.set_enabled(name, True) + await host.apply() + assert app.heard == ["fr"] + + +@pytest.mark.skipif(NODE is None, reason="node is needed to run the page script") +class TestThePageScript: + @staticmethod + def _run(lang, strings, body): + start = ui.JS.index("// ---- language") + end = ui.JS.index("// ---- icons") + script = ( + "const NMESH_LANG = %s; const NMESH_I18N = %s;" + "const document = {documentElement: {}, querySelectorAll: () => []};" + "%s\n%s" % (json.dumps(lang), json.dumps(strings), + ui.JS[start:end], body)) + result = subprocess.run([NODE, "-e", script], capture_output=True, + text=True, timeout=60) + assert result.returncode == 0, result.stderr + return result.stdout.strip() + + def test_it_speaks_the_catalogue(self): + bundle = i18n.bundle("ru") + out = self._run("ru", bundle, 'console.log(t("count.node", {count: 3}) + "|" ' + '+ t("common.save") + "|" + document.documentElement.lang)') + assert out == "3 узла|Сохранить|ru" + + def test_a_key_nobody_defined_shows_as_itself(self): + assert self._run("en", {}, 'console.log(t("a.b"))') == "a.b" + + def test_a_parameter_is_filled_and_an_unknown_one_stays(self): + out = self._run("en", {"x.y": "{a} and {b}"}, + 'console.log(t("x.y", {a: 1}))') + assert out == "1 and {b}" + + def test_a_catalogue_cannot_inject_markup_through_a_parameter_name(self): + out = self._run("en", {"x.y": "{__proto__} {constructor}"}, + 'console.log(t("x.y", {}))') + assert out == "{__proto__} {constructor}" + + +class TestTheConsoleServesIt: + def test_every_page_loads_the_script_after_the_theme(self): + from src import webassets + for html in (webassets.INDEX_HTML, webassets.CHAT_HTML, webassets.FLEET_HTML, + webassets.NODE_HTML, webassets.PKG_HTML, webassets.TERM_HTML): + assert html.index('