Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions Docs/AppAPI/guide
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions Docs/Architecture/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
86 changes: 86 additions & 0 deletions Docs/I18n/guide
Original file line number Diff line number Diff line change
@@ -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
<button data-i18n="common.save"></button>
<input data-i18n-attr="placeholder:common.search;title:common.search">
```

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/<code>.json` with every key, including
`language.name.<code>` in every file.
7 changes: 7 additions & 0 deletions Docs/Setup/guide
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions Docs/WebConsole/design
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
3 changes: 2 additions & 1 deletion scripts/nmesh_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
30 changes: 29 additions & 1 deletion src/app_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down
32 changes: 30 additions & 2 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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)"),
Expand All @@ -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.
Expand Down Expand Up @@ -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.
#
Expand Down Expand Up @@ -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"}

Expand All @@ -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
14 changes: 13 additions & 1 deletion src/control/modules/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",),
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down
Loading
Loading