From 92117668fbf46db98b1547840af6e026f43746a2 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Fri, 31 Jul 2026 19:00:34 +0200 Subject: [PATCH] feat(i18n): translate the Repeater Management panel (stage 3b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ~110 keys across repeater-manage.html and repeater-manage.js, finishing stage 3. Catalog is now 243 keys, pl at 100%. The interesting part is the three declarative schemas — TOOLS, SETTINGS_SECTIONS and REPEATER_ACTIONS — whose title/label/help/note/desc fields held English prose. Those fields now hold catalog KEYS, resolved with t() at each render site, with a comment on each array saying so. The firmware `key` next to them (radio.rxgain, advert.interval) is untouched: it is a CLI parameter, not text. That created a checker gap worth naming: a key reached as t(f.label) is invisible to a scanner looking for t('literal'), so all 57 of them would have been reported unused. The scanner now also treats a bare string literal that exactly matches a catalog key as a reference. It only relaxes the unused-key warning — missing-key errors and the markup lint still come from real call sites, so a typo in a schema key still surfaces, just at runtime rather than in the checker. Trimmed the do-not-translate glossary from 19 terms to 10. It fired four warnings on this slice and three were wrong: "direct neighbours" is an adjective, not the Direct mode; "telemetria" and "czujniki" are simply the Polish words. A warning you are supposed to ignore teaches you to ignore all of them, so the list now holds only jargon whose native translation would stop an operator matching the UI against firmware output — flood, hop, advert, ACK, RSSI, SNR, LoRa, MQTT, pubkey, repeater. The rest stayed as guidance in the translator doc. "flood advert" kept the word in Polish for that reason. Bulk edits were applied by a script that asserts an exact hit count per replacement and writes nothing if any count is off — which caught one case where I had miscounted 7 occurrences as 6. Verified in the browser: all 57 schema-resolved keys resolve in both languages (none falls back to its own key), and the page title and toolbar render Polish. Co-Authored-By: Claude Opus 5 --- app/static/js/repeater-manage.js | 221 +++++++++++++++-------------- app/templates/repeater-manage.html | 46 +++--- app/translations/en.json | 133 +++++++++++++++-- app/translations/pl.json | 135 ++++++++++++++++-- scripts/i18n_check.py | 38 +++-- 5 files changed, 403 insertions(+), 170 deletions(-) diff --git a/app/static/js/repeater-manage.js b/app/static/js/repeater-manage.js index 6f84e7a..f14479d 100644 --- a/app/static/js/repeater-manage.js +++ b/app/static/js/repeater-manage.js @@ -88,19 +88,20 @@ function esc(s) { // Tools configuration // ================================================================ +// title/desc hold catalog KEYS, resolved with t() at render time. const TOOLS = [ - { key: 'status', icon: 'bi-bar-chart-line', title: 'Status', - desc: 'Battery, radio and packet statistics', adminOnly: false }, - { key: 'telemetry', icon: 'bi-activity', title: 'Telemetry', - desc: 'Sensor channels (Cayenne LPP)', adminOnly: false }, - { key: 'neighbors', icon: 'bi-people', title: 'Neighbors', - desc: 'Zero-hop repeaters heard', adminOnly: false }, - { key: 'cli', icon: 'bi-terminal', title: 'CLI', - desc: 'Send text commands to the repeater', adminOnly: true }, - { key: 'settings', icon: 'bi-gear', title: 'Settings', - desc: 'Configure repeater parameters', adminOnly: true }, - { key: 'actions', icon: 'bi-lightning', title: 'Actions', - desc: 'Advert, clock sync, reboot', adminOnly: true }, + { key: 'status', icon: 'bi-bar-chart-line', title: 'rptmgmt.tools.status', + desc: 'rptmgmt.tools.status_desc', adminOnly: false }, + { key: 'telemetry', icon: 'bi-activity', title: 'rptmgmt.tools.telemetry', + desc: 'rptmgmt.tools.telemetry_desc', adminOnly: false }, + { key: 'neighbors', icon: 'bi-people', title: 'rptmgmt.tools.neighbors', + desc: 'rptmgmt.tools.neighbors_desc', adminOnly: false }, + { key: 'cli', icon: 'bi-terminal', title: 'rptmgmt.tools.cli', + desc: 'rptmgmt.tools.cli_desc', adminOnly: true }, + { key: 'settings', icon: 'bi-gear', title: 'rptmgmt.tools.settings', + desc: 'rptmgmt.tools.settings_desc', adminOnly: true }, + { key: 'actions', icon: 'bi-lightning', title: 'rptmgmt.tools.actions', + desc: 'rptmgmt.tools.actions_desc', adminOnly: true }, ]; // ================================================================ @@ -118,7 +119,7 @@ let _passwordModal = null; function showLoading(text) { document.getElementById('loadingState').style.display = ''; - document.getElementById('loadingText').textContent = text || 'Loading…'; + document.getElementById('loadingText').textContent = text || t('common.loading'); document.getElementById('errorState').style.display = 'none'; document.getElementById('panelContent').style.display = 'none'; } @@ -126,7 +127,7 @@ function showLoading(text) { function showError(text) { document.getElementById('loadingState').style.display = 'none'; document.getElementById('errorState').style.display = ''; - document.getElementById('errorText').textContent = text || 'Something went wrong.'; + document.getElementById('errorText').textContent = text || t('rptmgmt.error_generic'); document.getElementById('panelContent').style.display = 'none'; } @@ -188,8 +189,8 @@ function renderTools() { ${locked ? 'title="Admin login required"' : ''}>
-
${esc(tool.title)}${locked ? ' ' : ''}
-

${esc(tool.desc)}

+
${esc(t(tool.title))}${locked ? ' ' : ''}
+

${esc(t(tool.desc))}

@@ -197,7 +198,7 @@ function renderTools() { const tile = col.querySelector('.tool-tile'); tile.addEventListener('click', () => { if (locked) { - showNotification('Admin login required for this tool', 'warning'); + showNotification(t('rptmgmt.toast.admin_required'), 'warning'); return; } openToolPane(tool); @@ -226,7 +227,7 @@ function openToolPane(tool) { icon.style.height = '32px'; icon.style.fontSize = '1rem'; icon.innerHTML = ``; - document.getElementById('paneTitle').textContent = tool.title; + document.getElementById('paneTitle').textContent = t(tool.title); const body = document.getElementById('paneBody'); if (tool.key === 'status') { @@ -256,7 +257,7 @@ function openToolPane(tool) { body.innerHTML = `
-

The ${esc(tool.title)} tool is coming in a later stage.

+

${tHtml('rptmgmt.tool_coming', { tool: t(tool.title) })}

`; } @@ -322,7 +323,7 @@ function setStatusUpdatedLabel() { const label = document.getElementById('statusUpdated'); if (!label) { clearInterval(_statusTimer); _statusTimer = null; return; } const secs = Math.floor((Date.now() - _statusUpdatedAt) / 1000); - if (secs < 2) label.textContent = 'Updated just now'; + if (secs < 2) label.textContent = t('rptmgmt.updated_just_now'); else if (secs < 60) label.textContent = `Updated ${secs}s ago`; else label.textContent = `Updated ${fmtDuration(secs)} ago`; }; @@ -347,7 +348,7 @@ async function loadStatus() { const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/status`); data = await resp.json(); } catch (e) { - data = { success: false, error: 'Request failed' }; + data = { success: false, error: t('rptmgmt.request_failed') }; } if (btn) btn.disabled = false; @@ -355,7 +356,7 @@ async function loadStatus() { container.innerHTML = `
-

${esc((data && data.error) || 'Failed to get status')}

+

${esc((data && data.error) || t('rptmgmt.status_failed'))}

@@ -494,7 +495,7 @@ function setTelemetryUpdatedLabel() { const label = document.getElementById('telemetryUpdated'); if (!label) { clearInterval(_telemetryTimer); _telemetryTimer = null; return; } const secs = Math.floor((Date.now() - _telemetryUpdatedAt) / 1000); - if (secs < 2) label.textContent = 'Updated just now'; + if (secs < 2) label.textContent = t('rptmgmt.updated_just_now'); else if (secs < 60) label.textContent = `Updated ${secs}s ago`; else label.textContent = `Updated ${fmtDuration(secs)} ago`; }; @@ -520,7 +521,7 @@ async function loadTelemetry() { const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/telemetry`); data = await resp.json(); } catch (e) { - data = { success: false, error: 'Request failed' }; + data = { success: false, error: t('rptmgmt.request_failed') }; } if (btn) btn.disabled = false; @@ -528,7 +529,7 @@ async function loadTelemetry() { container.innerHTML = `
-

${esc((data && data.error) || 'Failed to get telemetry')}

+

${esc((data && data.error) || t('rptmgmt.telemetry_failed'))}

@@ -546,7 +547,7 @@ async function loadTelemetry() { function renderTelemetryCards(container, lpp) { if (!lpp.length) { - container.innerHTML = '
No telemetry data reported.
'; + container.innerHTML = `
${tHtml('rptmgmt.telemetry_none')}
`; return; } @@ -710,7 +711,7 @@ async function sendCliCommand(raw) { }); data = await resp.json(); } catch (e) { - data = { success: false, error: 'Request failed' }; + data = { success: false, error: t('rptmgmt.request_failed') }; } if (pendingLine) pendingLine.remove(); @@ -720,7 +721,7 @@ async function sendCliCommand(raw) { cliAppend('meta', `(${(data.elapsed_ms / 1000).toFixed(1)} s)`); } } else { - cliAppend('error', (data && data.error) || 'Command failed'); + cliAppend('error', (data && data.error) || t('rptmgmt.command_failed')); } _cliPending = false; @@ -786,7 +787,7 @@ async function loadNeighbors() { const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/neighbours`); data = await resp.json(); } catch (e) { - data = { success: false, error: 'Request failed' }; + data = { success: false, error: t('rptmgmt.request_failed') }; } if (btn) btn.disabled = false; @@ -796,7 +797,7 @@ async function loadNeighbors() { container.innerHTML = `
-

${esc((data && data.error) || 'Failed to get neighbours')}

+

${esc((data && data.error) || t('rptmgmt.neighbours_failed'))}

@@ -835,7 +836,7 @@ function renderNeighborsList() { if (toggle) toggle.style.display = mappable ? '' : 'none'; if (!entries.length) { - container.innerHTML = '
No neighbours reported yet.
Neighbours are learned from zero-hop repeater adverts.
'; + container.innerHTML = `
${tHtml('rptmgmt.neighbours_none')}
${tHtml('rptmgmt.neighbours_none_hint')}
`; return; } @@ -852,8 +853,8 @@ function renderNeighborsList() { container.innerHTML = ` - - + + ${rows} @@ -910,7 +911,7 @@ function renderNeighborsMap() { opacity: 1, fillOpacity: 0.9 }).addTo(_nbMapLayers); - centerMarker.bindPopup(`${esc(_repeater.name || 'This repeater')}
managed repeater`); + centerMarker.bindPopup(`${esc(_repeater.name || t('rptmgmt.this_repeater'))}
${tHtml('rptmgmt.managed_repeater')}`); bounds.push(center); } @@ -997,52 +998,54 @@ async function loadClock() { // Sections and fields mirror _REPEATER_SETTINGS_FIELDS in api.py. // Values travel as raw CLI strings (`get X` → `> value`, `set X value`). +// title/label/help/note hold catalog KEYS, resolved with t() at render time. +// `key` is the firmware CLI parameter and is never translated. const SETTINGS_SECTIONS = [ - { key: 'basic', title: 'Basic', icon: 'bi-tag', fields: [ - { key: 'name', label: 'Repeater name', type: 'text' }, - { key: 'password', label: 'Admin password', type: 'password', writeOnly: true, - help: 'Write-only — the current password is never shown. Changing it does not log out already active sessions.' }, - { key: 'guest.password', label: 'Guest password', type: 'text', - help: 'Password for read-only guest logins.' }, + { key: 'basic', title: 'rptmgmt.set.basic', icon: 'bi-tag', fields: [ + { key: 'name', label: 'rptmgmt.set.name', type: 'text' }, + { key: 'password', label: 'rptmgmt.set.password', type: 'password', writeOnly: true, + help: 'rptmgmt.set.password_help' }, + { key: 'guest.password', label: 'rptmgmt.set.guest_password', type: 'text', + help: 'rptmgmt.set.guest_password_help' }, ]}, - { key: 'radio', title: 'Radio', icon: 'bi-broadcast', - note: 'Frequency, bandwidth, SF and CR are applied after a reboot. TX power applies immediately.', + { key: 'radio', title: 'rptmgmt.set.radio', icon: 'bi-broadcast', + note: 'rptmgmt.set.radio_note', fields: [ - { key: 'radio', label: 'Radio parameters', type: 'radio4' }, - { key: 'tx', label: 'TX power (dBm)', type: 'number', min: 0, max: 30, step: 1 }, - { key: 'radio.rxgain', label: 'RX boosted gain', type: 'onoff' }, + { key: 'radio', label: 'rptmgmt.set.radio_params', type: 'radio4' }, + { key: 'tx', label: 'rptmgmt.set.tx', type: 'number', min: 0, max: 30, step: 1 }, + { key: 'radio.rxgain', label: 'rptmgmt.set.rxgain', type: 'onoff' }, ]}, - { key: 'location', title: 'Location', icon: 'bi-geo-alt', fields: [ - { key: 'lat', label: 'Latitude', type: 'text' }, - { key: 'lon', label: 'Longitude', type: 'text' }, + { key: 'location', title: 'rptmgmt.set.location', icon: 'bi-geo-alt', fields: [ + { key: 'lat', label: 'rptmgmt.set.lat', type: 'text' }, + { key: 'lon', label: 'rptmgmt.set.lon', type: 'text' }, ]}, - { key: 'features', title: 'Features', icon: 'bi-toggles', fields: [ - { key: 'repeat', label: 'Repeat packets', type: 'onoff' }, - { key: 'allow.read.only', label: 'Allow read-only (guest) access', type: 'onoff' }, - { key: 'multi.acks', label: 'Send multiple ACKs', type: 'zeroone' }, + { key: 'features', title: 'rptmgmt.set.features', icon: 'bi-toggles', fields: [ + { key: 'repeat', label: 'rptmgmt.set.repeat', type: 'onoff' }, + { key: 'allow.read.only', label: 'rptmgmt.set.allow_read_only', type: 'onoff' }, + { key: 'multi.acks', label: 'rptmgmt.set.multi_acks', type: 'zeroone' }, ]}, - { key: 'network', title: 'Network health', icon: 'bi-heart-pulse', fields: [ - { key: 'loop.detect', label: 'Loop detection', type: 'select', + { key: 'network', title: 'rptmgmt.set.network', icon: 'bi-heart-pulse', fields: [ + { key: 'loop.detect', label: 'rptmgmt.set.loop_detect', type: 'select', options: ['off', 'minimal', 'moderate', 'strict'] }, - { key: 'dutycycle', label: 'Duty cycle (%)', type: 'number', min: 1, max: 100, step: 1 }, + { key: 'dutycycle', label: 'rptmgmt.set.dutycycle', type: 'number', min: 1, max: 100, step: 1 }, ]}, - { key: 'advert', title: 'Advertisement', icon: 'bi-megaphone', fields: [ - { key: 'advert.interval', label: 'Local advert interval (minutes)', type: 'number', min: 0, max: 240, step: 1, - help: 'Firmware accepts 60–240 minutes, or 0 to disable.' }, - { key: 'flood.advert.interval', label: 'Flood advert interval (hours)', type: 'number', min: 0, step: 1 }, - { key: 'flood.max', label: 'Max flood hops', type: 'number', min: 0, max: 64, step: 1 }, + { key: 'advert', title: 'rptmgmt.set.advert', icon: 'bi-megaphone', fields: [ + { key: 'advert.interval', label: 'rptmgmt.set.advert_interval', type: 'number', min: 0, max: 240, step: 1, + help: 'rptmgmt.set.advert_interval_help' }, + { key: 'flood.advert.interval', label: 'rptmgmt.set.flood_advert_interval', type: 'number', min: 0, step: 1 }, + { key: 'flood.max', label: 'rptmgmt.set.flood_max', type: 'number', min: 0, max: 64, step: 1 }, ]}, - { key: 'operator', title: 'Operator info', icon: 'bi-person-vcard', fields: [ - { key: 'owner.info', label: 'Owner info', type: 'textarea', - help: 'Free-form operator / contact info shown to clients. Multiple lines allowed.' }, + { key: 'operator', title: 'rptmgmt.set.operator', icon: 'bi-person-vcard', fields: [ + { key: 'owner.info', label: 'rptmgmt.set.owner_info', type: 'textarea', + help: 'rptmgmt.set.owner_info_help' }, ]}, - { key: 'advanced', title: 'Advanced', icon: 'bi-sliders', fields: [ - { key: 'path.hash.mode', label: 'Path hash mode (0–2)', type: 'number', min: 0, max: 2, step: 1 }, - { key: 'txdelay', label: 'TX delay factor (0–2)', type: 'number', min: 0, max: 2, step: 'any' }, - { key: 'direct.txdelay', label: 'Direct TX delay factor (0–2)', type: 'number', min: 0, max: 2, step: 'any' }, - { key: 'int.thresh', label: 'Interference threshold', type: 'number', step: 1 }, - { key: 'agc.reset.interval', label: 'AGC reset interval', type: 'number', min: 0, step: 4, - help: 'Multiple of 4; 0 disables periodic AGC resets.' }, + { key: 'advanced', title: 'rptmgmt.set.advanced', icon: 'bi-sliders', fields: [ + { key: 'path.hash.mode', label: 'rptmgmt.set.path_hash_mode', type: 'number', min: 0, max: 2, step: 1 }, + { key: 'txdelay', label: 'rptmgmt.set.txdelay', type: 'number', min: 0, max: 2, step: 'any' }, + { key: 'direct.txdelay', label: 'rptmgmt.set.direct_txdelay', type: 'number', min: 0, max: 2, step: 'any' }, + { key: 'int.thresh', label: 'rptmgmt.set.int_thresh', type: 'number', step: 1 }, + { key: 'agc.reset.interval', label: 'rptmgmt.set.agc_reset', type: 'number', min: 0, step: 4, + help: 'rptmgmt.set.agc_reset_help' }, ]}, ]; @@ -1104,10 +1107,10 @@ function settingsControlHtml(f) { function settingsFieldHtml(f) { return `
- + ${settingsControlHtml(f)}
- ${f.help ? `
${esc(f.help)}
` : ''} + ${f.help ? `
${esc(t(f.help))}
` : ''}
`; } @@ -1120,22 +1123,22 @@ function renderSettingsPane(body) {

- ${sec.note ? `
${esc(sec.note)}
` : ''} + ${sec.note ? `
${esc(t(sec.note))}
` : ''}
- Some changes take effect after a reboot — use Actions → Reboot. + ${tHtml('rptmgmt.set.reboot_note')}
${sec.fields.map(settingsFieldHtml).join('')}
${sec.key === 'location' ? ` ` : ''}
`; } @@ -1609,17 +1613,16 @@ function renderActionsPane(body) {
- Danger zone + ${tHtml('rptmgmt.danger_zone')}
${actionRowHtml({ key: 'reboot', icon: 'bi-arrow-clockwise', iconClass: 'text-danger', - title: 'Reboot repeater', btn: 'Reboot', btnClass: 'btn-danger', - desc: 'The repeater drops off the mesh for a few seconds while it restarts.', + title: 'rptmgmt.act.reboot', btn: 'rptmgmt.act.reboot_btn', btnClass: 'btn-danger', + desc: 'rptmgmt.act.reboot_desc', })}
- Erase file system is not available over the mesh — - the firmware only accepts it on the USB serial console (use the MeshCore flasher instead). + ${tHtml('rptmgmt.erase_fs_note')}
@@ -1657,7 +1660,7 @@ async function runRepeaterAction(action) { }); data = await resp.json(); } catch (e) { - data = { success: false, error: 'Request failed' }; + data = { success: false, error: t('rptmgmt.request_failed') }; } _actionPending = false; @@ -1669,14 +1672,14 @@ async function runRepeaterAction(action) { if (data && data.success) { resultEl.classList.add(data.ok ? 'text-success' : 'text-danger'); const elapsed = data.elapsed_ms != null ? ` (${(data.elapsed_ms / 1000).toFixed(1)} s)` : ''; - resultEl.textContent = (data.reply || 'Done') + elapsed; + resultEl.textContent = (data.reply || t('rptmgmt.done')) + elapsed; } else { resultEl.classList.add('text-danger'); - resultEl.textContent = (data && data.error) || 'Action failed'; + resultEl.textContent = (data && data.error) || t('rptmgmt.action_failed'); } } if (!data || !data.success) { - showNotification((data && data.error) || 'Action failed', 'danger'); + showNotification((data && data.error) || t('rptmgmt.action_failed'), 'danger'); } } @@ -1688,7 +1691,7 @@ async function fetchRepeater() { const response = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}`); const data = await response.json(); if (!data.success) { - throw new Error(data.error || 'Failed to load repeater'); + throw new Error(data.error || t('rptmgmt.load_failed')); } _repeater = data.repeater; _session = data.session; @@ -1713,7 +1716,7 @@ async function doLogin(password, save) { data = await response.json(); } catch (e) { console.error('Login request failed:', e); - data = { success: false, error: 'Login request failed' }; + data = { success: false, error: t('rptmgmt.login_request_failed') }; } if (data && data.success) { diff --git a/app/templates/repeater-manage.html b/app/templates/repeater-manage.html index be4417d..c3fa9ea 100644 --- a/app/templates/repeater-manage.html +++ b/app/templates/repeater-manage.html @@ -3,7 +3,7 @@ - Repeater Management - mc-webui + {{ t('rptmgmt.title') }} - mc-webui
RepeaterHeard${tHtml('rptmgmt.neigh.repeater')}${tHtml('rptmgmt.neigh.heard')} SNR