First vertical slice: /logs chrome in English and Polish. Small on purpose —
~13 strings — but it exercises every part of the mechanism end to end: the head
include, the catalog route, the cookie, server-rendered t()/tn(), client-side
t()/tHtml()/tn(), a JS-rendered plural, and a rebuilt <select>.
Translated: title, pause/clear tooltips, the two "All ..." filter options, the
search placeholder, the reset tooltip, the loading and load-failed messages, and
the entry counter.
Deliberately NOT translated, per the boundary in docs/translations.md: the log
lines themselves and the DEBUG/INFO/WARNING/ERROR level names. Those are
protocol-side identifiers — the level filter also compares against them, so
translating the labels would be a step toward breaking the filter. Marked with a
comment in the template so the next person does not "fix" it.
The counter is the interesting case: English has two plural forms and Polish
four, so "100 entries" is "100 wpisów" while "2 entries" is "2 wpisy". Rendered
by tn() through Intl.PluralRules on the client and the matching rule table on
the server, which agree.
Also fixed in scripts/i18n_check.py: it warned when a markup-free value was used
via tHtml(), which is wrong and actively harmful advice. What tHtml() buys at an
innerHTML sink is param escaping, not markup support — the warning would have
pushed people toward t() in exactly the place where t() is the XSS hazard. The
error in the other direction (markup used via t()) stays. Output streams are now
forced to UTF-8 so `--missing pl > todo.txt` produces a usable file on Windows
rather than mojibake.
Verified in the browser: every chrome string differs between en and pl, level
names unchanged, no catalog key leaked into the page, the count stays translated
after filtering, and the filter bar wraps without overflow at 360px in Polish.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Groundwork for translating the interface. No strings are extracted yet — this
stage only adds the mechanism, so the UI is byte-identical in English.
Translations are one flat JSON file per language. An admin can add a language by
dropping <lang>.json into $MC_CONFIG_DIR/translations/ and picking it in
Settings > Appearance — no rebuild, no restart, no compile step. A drop-in file
overrides a built-in one of the same name. Built-ins: en (source) and pl.
How it works:
- app/i18n.py loads and merges catalogs over en.json, so per-key English
fallback is baked in server-side and the JS runtime needs no fallback logic.
Catalogs are fingerprinted with stat() per render, so a dropped-in file is
live on the next refresh rather than the next restart — which matters,
because restarting drops the device connection for up to 60s.
- Template text renders server-side via t()/t_html()/tn() from inject_globals(),
so the first paint is already correct. That context processor covers every
render_template() in the app, including the six standalone iframe pages, so
routes/views.py needed no changes.
- JS text comes from /i18n/<lang>.<hash8>.js, a blocking immutable script shared
by all 8 entry points. The hash is in the path, not a query string, so
intermediary caches and the service worker bust reliably.
- Language = per-browser mc_lang cookie over a server-wide DB default. The
cookie is what makes iframes work: they are same-origin, so they send it
automatically and the existing modal-open reload wiring needs no changes.
t() deliberately does not escape — Jinja autoescape handles that, and escaping
here would double-escape every French apostrophe. t_html()/tHtml() escape their
params but trust catalog markup.
Also fixed along the way:
- save_ui_settings() replaced the whole settings blob instead of merging, so a
language-only POST would have wiped the toast settings. It worked before only
because the one form always submitted every key.
- `const t = document.getElementById(...)` in populateUiSettingsForm shadowed
the global translation helper; same for `var t` in six theme IIFEs.
scripts/i18n_check.py now fails the build if that pattern comes back.
- Removed app/templates/contacts.html, dead since the contacts pages were split
(no render_template reference anywhere), and two stale CSS comments.
Verified locally: catalog route headers/security, drop-in without restart,
malformed catalogs logged and skipped rather than offered in the picker,
cookie propagation into iframes, and 360px layout. Python and JS agree on
interpolation, escaping and Polish plural categories.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Changed from target="_blank" link to fullscreen modal with iframe,
matching the pattern used by Console, DM, and Contacts modals.
Iframe loads on open and clears on close to manage WebSocket lifecycle.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
In-memory ring buffer (2000 entries) captures all Python log records.
New /logs page streams entries via WebSocket in real-time with:
- Level filter (DEBUG/INFO/WARNING/ERROR)
- Module filter (auto-populated from seen loggers)
- Text search with highlighting
- Auto-scroll with pause/resume
- Dark theme matching Console style
Menu entry added under Configuration section.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>