mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-08-07 09:12:57 +02:00
feat(i18n): translate the System Log panel (stage 1)
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>
This commit is contained in:
@@ -81,7 +81,7 @@
|
||||
renderAll();
|
||||
})
|
||||
.catch(err => {
|
||||
if (loadingMsg) loadingMsg.textContent = 'Failed to load logs';
|
||||
if (loadingMsg) loadingMsg.textContent = t('logs.load_failed');
|
||||
console.error('Failed to load logs:', err);
|
||||
});
|
||||
}
|
||||
@@ -203,8 +203,8 @@
|
||||
const total = entries.length;
|
||||
const shown = logEntries.children.length;
|
||||
logCount.textContent = shown === total
|
||||
? `${total} entries`
|
||||
: `${shown} / ${total} entries`;
|
||||
? tn('logs.entries', total)
|
||||
: tn('logs.entries_filtered', total, { shown });
|
||||
}
|
||||
|
||||
function updateLoggerOptions() {
|
||||
@@ -212,7 +212,7 @@
|
||||
// Group loggers by top-level module
|
||||
const sorted = Array.from(knownLoggers).sort();
|
||||
|
||||
loggerFilter.innerHTML = '<option value="">All modules</option>';
|
||||
loggerFilter.innerHTML = `<option value="">${tHtml('logs.filter.all_modules')}</option>`;
|
||||
for (const name of sorted) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = name;
|
||||
|
||||
+10
-9
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>System Log - mc-webui</title>
|
||||
<title>{{ t('logs.title') }} - mc-webui</title>
|
||||
|
||||
{% include "_head_i18n.html" %}
|
||||
|
||||
@@ -154,14 +154,14 @@
|
||||
<!-- Header -->
|
||||
<div class="log-header d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="log-count" id="logCount">0 entries</span>
|
||||
<span class="log-count" id="logCount">{{ tn('logs.entries', 0) }}</span>
|
||||
<span class="status-indicator" id="statusDot"></span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button class="btn btn-sm btn-log" id="pauseBtn" title="Pause/Resume">
|
||||
<button class="btn btn-sm btn-log" id="pauseBtn" title="{{ t('logs.pause_title') }}">
|
||||
<i class="bi bi-pause-fill" id="pauseIcon"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-log" id="clearBtn" title="Clear display">
|
||||
<button class="btn btn-sm btn-log" id="clearBtn" title="{{ t('logs.clear_title') }}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -171,18 +171,19 @@
|
||||
<div class="log-filters">
|
||||
<div class="d-flex gap-2 flex-wrap align-items-center">
|
||||
<select class="form-select form-select-sm filter-select" id="levelFilter" style="width: auto; min-width: 90px;">
|
||||
<option value="">All levels</option>
|
||||
<option value="">{{ t('logs.filter.all_levels') }}</option>
|
||||
{# Level names are protocol-side identifiers — never translated. #}
|
||||
<option value="DEBUG">DEBUG</option>
|
||||
<option value="INFO" selected>INFO</option>
|
||||
<option value="WARNING">WARNING</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
</select>
|
||||
<select class="form-select form-select-sm filter-select" id="loggerFilter" style="width: auto; min-width: 120px;">
|
||||
<option value="">All modules</option>
|
||||
<option value="">{{ t('logs.filter.all_modules') }}</option>
|
||||
</select>
|
||||
<input type="text" class="form-control form-control-sm filter-input" id="searchFilter"
|
||||
placeholder="Search..." style="width: auto; min-width: 150px; flex: 1;">
|
||||
<button class="btn btn-sm btn-log" id="resetFilters" title="Reset filters">
|
||||
placeholder="{{ t('logs.filter.search_ph') }}" style="width: auto; min-width: 150px; flex: 1;">
|
||||
<button class="btn btn-sm btn-log" id="resetFilters" title="{{ t('logs.filter.reset_title') }}">
|
||||
<i class="bi bi-x-circle"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -190,7 +191,7 @@
|
||||
|
||||
<!-- Log entries -->
|
||||
<div class="log-entries" id="logEntries">
|
||||
<div class="text-muted text-center py-3" id="loadingMsg">Loading logs...</div>
|
||||
<div class="text-muted text-center py-3" id="loadingMsg">{{ t('logs.loading') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,6 +8,24 @@
|
||||
"common.minutes_ago": "{count} min ago",
|
||||
"common.yesterday": "Yesterday",
|
||||
|
||||
"logs.clear_title": "Clear display",
|
||||
"logs.entries": {
|
||||
"one": "{count} entry",
|
||||
"other": "{count} entries"
|
||||
},
|
||||
"logs.entries_filtered": {
|
||||
"one": "{shown} / {count} entry",
|
||||
"other": "{shown} / {count} entries"
|
||||
},
|
||||
"logs.filter.all_levels": "All levels",
|
||||
"logs.filter.all_modules": "All modules",
|
||||
"logs.filter.reset_title": "Reset filters",
|
||||
"logs.filter.search_ph": "Search...",
|
||||
"logs.load_failed": "Failed to load logs",
|
||||
"logs.loading": "Loading logs...",
|
||||
"logs.pause_title": "Pause/Resume",
|
||||
"logs.title": "System Log",
|
||||
|
||||
"meta.language_english_name": "English",
|
||||
"meta.language_name": "English",
|
||||
"meta.translator": "mc-webui"
|
||||
|
||||
@@ -10,6 +10,28 @@
|
||||
"common.minutes_ago": "{count} min temu",
|
||||
"common.yesterday": "Wczoraj",
|
||||
|
||||
"logs.clear_title": "Wyczyść widok",
|
||||
"logs.entries": {
|
||||
"one": "{count} wpis",
|
||||
"few": "{count} wpisy",
|
||||
"many": "{count} wpisów",
|
||||
"other": "{count} wpisu"
|
||||
},
|
||||
"logs.entries_filtered": {
|
||||
"one": "{shown} / {count} wpis",
|
||||
"few": "{shown} / {count} wpisy",
|
||||
"many": "{shown} / {count} wpisów",
|
||||
"other": "{shown} / {count} wpisu"
|
||||
},
|
||||
"logs.filter.all_levels": "Wszystkie poziomy",
|
||||
"logs.filter.all_modules": "Wszystkie moduły",
|
||||
"logs.filter.reset_title": "Wyczyść filtry",
|
||||
"logs.filter.search_ph": "Szukaj...",
|
||||
"logs.load_failed": "Nie udało się wczytać dziennika",
|
||||
"logs.loading": "Ładowanie dziennika...",
|
||||
"logs.pause_title": "Wstrzymaj/Wznów",
|
||||
"logs.title": "Dziennik systemowy",
|
||||
|
||||
"meta.language_english_name": "Polish",
|
||||
"meta.language_name": "Polski",
|
||||
"meta.translator": "mc-webui"
|
||||
|
||||
@@ -10,6 +10,12 @@ For deep technical notes, see [architecture.md](architecture.md). For the full g
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Features
|
||||
|
||||
- **The interface can be translated, and Polish has started.** mc-webui was written English-only, with every label and message baked into the code. There is now a translation system behind it, and a **Language** setting at the top of Settings → Appearance. This first step covers the groundwork plus the **System Log** panel; the remaining panels follow one at a time, so for now most of the interface is still English whichever language you pick. Your choice applies to the browser you set it in and also becomes the default for anyone else opening the server without a preference of their own.
|
||||
- **You can add a language yourself, without waiting for a release.** A language is a single file. Copy `en.json` from `app/translations/`, translate the values, and drop it into a `translations` folder inside your config directory — the same place the database lives. Refresh the page and it appears in the Language list, named however you named it, with no rebuild and no restart. Anything you leave untranslated falls back to English, so a half-finished translation is perfectly usable. A file you drop in also overrides one that ships with the app, so you can correct the built-in Polish on your own server. English and Polish ship in the box; everything else is open to whoever wants to write it. See [translations.md](translations.md), which explains the format and — importantly — which words to leave alone: mesh terms like flood, hop, advert, RSSI and the repeater roles stay English in every language, because that is what the firmware, the CLI and the forums all use. The Console and the log lines themselves stay English for the same reason.
|
||||
- **Times and dates behave the same in every language.** Only the words are translated — "Yesterday" becomes "Wczoraj", "5 min ago" becomes "5 min temu". The clock and the number formatting keep following your browser's own settings, so switching the menus to English will not suddenly turn your 24-hour clock into "9:53 AM". One display of large numbers on the repeater statistics page had been hard-coded to American thousands separators; it now follows your locale like everything else.
|
||||
|
||||
---
|
||||
|
||||
## 2.4.2 — 2026-07-31
|
||||
|
||||
+15
-9
@@ -20,6 +20,14 @@ import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
# Catalog values and the --missing worklist contain non-ASCII text. Windows consoles
|
||||
# default to a legacy code page, which would mangle them and corrupt a redirected file.
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_stream.reconfigure(encoding='utf-8')
|
||||
except (AttributeError, OSError):
|
||||
pass
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
TRANSLATIONS = REPO / 'app' / 'translations'
|
||||
SCAN_GLOBS = ['app/templates/**/*.html', 'app/static/js/*.js']
|
||||
@@ -129,19 +137,17 @@ def check_usage(en: dict, kinds: dict[str, set[str]], sites: dict[str, list[str]
|
||||
warn(f'en.json: unused key {key!r}')
|
||||
|
||||
# Markup in a catalog value only survives through the _html variants.
|
||||
#
|
||||
# Only flagged in this direction. The reverse (_html used on a markup-free value) is
|
||||
# not a problem and must not be warned about: what _html buys at an innerHTML sink is
|
||||
# param escaping, not markup support. Warning there would push people toward t() in
|
||||
# exactly the place where t() is the XSS hazard.
|
||||
for key, value in sorted(en.items()):
|
||||
if key not in kinds:
|
||||
continue
|
||||
has_markup = any('<' in text for text in value_strings(value))
|
||||
used_plain = bool(kinds[key] & {'t', 'tn'})
|
||||
used_html = bool(kinds[key] & {'tHtml', 't_html'})
|
||||
|
||||
if has_markup and used_plain:
|
||||
err(f'{sites[key][0]}: {key!r} contains markup but is used via t()/tn() — '
|
||||
if any('<' in text for text in value_strings(value)) and kinds[key] & {'t', 'tn'}:
|
||||
err(f'{sites[key][0]}: {key!r} contains markup but is used via t()/tn() - '
|
||||
f'use tHtml()/t_html()')
|
||||
if not has_markup and used_html:
|
||||
warn(f'{sites[key][0]}: {key!r} has no markup but is used via the _html '
|
||||
f'variant — t() is enough')
|
||||
|
||||
|
||||
def check_language(lang: str, en: dict, catalog: dict) -> float:
|
||||
|
||||
Reference in New Issue
Block a user