feat(i18n): translate the Repeater Management panel (stage 3b)

~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 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-07-31 19:00:34 +02:00
parent 93c74d150f
commit 92117668fb
5 changed files with 403 additions and 170 deletions
+112 -109
View File
@@ -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"' : ''}>
<div class="tool-icon ${tool.key}"><i class="bi ${tool.icon}"></i></div>
<div class="flex-grow-1" style="min-width: 0;">
<h6>${esc(tool.title)}${locked ? ' <i class="bi bi-lock-fill small text-muted"></i>' : ''}</h6>
<p class="tool-desc">${esc(tool.desc)}</p>
<h6>${esc(t(tool.title))}${locked ? ' <i class="bi bi-lock-fill small text-muted"></i>' : ''}</h6>
<p class="tool-desc">${esc(t(tool.desc))}</p>
</div>
<i class="bi bi-chevron-right text-muted"></i>
</div>
@@ -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 = `<i class="bi ${tool.icon}"></i>`;
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 = `
<div class="text-center text-muted py-4">
<i class="bi ${tool.icon}" style="font-size: 2rem;"></i>
<p class="mt-2 mb-0">The <strong>${esc(tool.title)}</strong> tool is coming in a later stage.</p>
<p class="mt-2 mb-0">${tHtml('rptmgmt.tool_coming', { tool: t(tool.title) })}</p>
</div>
`;
}
@@ -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 = `
<div class="text-center py-4">
<i class="bi bi-exclamation-triangle text-warning" style="font-size: 2rem;"></i>
<p class="mt-2 mb-2">${esc((data && data.error) || 'Failed to get status')}</p>
<p class="mt-2 mb-2">${esc((data && data.error) || t('rptmgmt.status_failed'))}</p>
<button type="button" class="btn btn-sm btn-primary" id="statusRetryBtn">
<i class="bi bi-arrow-clockwise"></i> Try again
</button>
@@ -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 = `
<div class="text-center py-4">
<i class="bi bi-exclamation-triangle text-warning" style="font-size: 2rem;"></i>
<p class="mt-2 mb-2">${esc((data && data.error) || 'Failed to get telemetry')}</p>
<p class="mt-2 mb-2">${esc((data && data.error) || t('rptmgmt.telemetry_failed'))}</p>
<button type="button" class="btn btn-sm btn-primary" id="telemetryRetryBtn">
<i class="bi bi-arrow-clockwise"></i> Try again
</button>
@@ -546,7 +547,7 @@ async function loadTelemetry() {
function renderTelemetryCards(container, lpp) {
if (!lpp.length) {
container.innerHTML = '<div class="text-muted text-center py-4">No telemetry data reported.</div>';
container.innerHTML = `<div class="text-muted text-center py-4">${tHtml('rptmgmt.telemetry_none')}</div>`;
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 = `
<div class="text-center py-4">
<i class="bi bi-exclamation-triangle text-warning" style="font-size: 2rem;"></i>
<p class="mt-2 mb-2">${esc((data && data.error) || 'Failed to get neighbours')}</p>
<p class="mt-2 mb-2">${esc((data && data.error) || t('rptmgmt.neighbours_failed'))}</p>
<button type="button" class="btn btn-sm btn-primary" id="neighborsRetryBtn">
<i class="bi bi-arrow-clockwise"></i> Try again
</button>
@@ -835,7 +836,7 @@ function renderNeighborsList() {
if (toggle) toggle.style.display = mappable ? '' : 'none';
if (!entries.length) {
container.innerHTML = '<div class="text-muted text-center py-4">No neighbours reported yet.<br><small>Neighbours are learned from zero-hop repeater adverts.</small></div>';
container.innerHTML = `<div class="text-muted text-center py-4">${tHtml('rptmgmt.neighbours_none')}<br><small>${tHtml('rptmgmt.neighbours_none_hint')}</small></div>`;
return;
}
@@ -852,8 +853,8 @@ function renderNeighborsList() {
container.innerHTML = `
<table class="table table-sm align-middle mb-0">
<thead><tr class="small text-muted">
<th class="fw-normal">Repeater</th>
<th class="fw-normal">Heard</th>
<th class="fw-normal">${tHtml('rptmgmt.neigh.repeater')}</th>
<th class="fw-normal">${tHtml('rptmgmt.neigh.heard')}</th>
<th class="fw-normal text-end">SNR</th>
</tr></thead>
<tbody>${rows}</tbody>
@@ -910,7 +911,7 @@ function renderNeighborsMap() {
opacity: 1,
fillOpacity: 0.9
}).addTo(_nbMapLayers);
centerMarker.bindPopup(`<b>${esc(_repeater.name || 'This repeater')}</b><br>managed repeater`);
centerMarker.bindPopup(`<b>${esc(_repeater.name || t('rptmgmt.this_repeater'))}</b><br>${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 60240 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 (02)', type: 'number', min: 0, max: 2, step: 1 },
{ key: 'txdelay', label: 'TX delay factor (02)', type: 'number', min: 0, max: 2, step: 'any' },
{ key: 'direct.txdelay', label: 'Direct TX delay factor (02)', 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 `
<div class="sf-row mb-3" data-field-row="${esc(f.key)}">
<label class="form-label small fw-semibold mb-1">${esc(f.label)} <span class="sf-badge ms-1"></span></label>
<label class="form-label small fw-semibold mb-1">${esc(t(f.label))} <span class="sf-badge ms-1"></span></label>
${settingsControlHtml(f)}
<div class="sf-msg small text-danger d-none"></div>
${f.help ? `<div class="form-text small mb-0">${esc(f.help)}</div>` : ''}
${f.help ? `<div class="form-text small mb-0">${esc(t(f.help))}</div>` : ''}
</div>`;
}
@@ -1120,22 +1123,22 @@ function renderSettingsPane(body) {
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#secCollapse-${sec.key}">
<i class="bi ${sec.icon} me-2"></i>${esc(sec.title)}
<i class="bi ${sec.icon} me-2"></i>${esc(t(sec.title))}
<span class="badge bg-warning text-dark ms-2 sec-dirty-badge d-none"></span>
</button>
</h2>
<div id="secCollapse-${sec.key}" class="accordion-collapse collapse">
<div class="accordion-body pt-2">
${sec.note ? `<div class="small text-muted mb-2"><i class="bi bi-info-circle me-1"></i>${esc(sec.note)}</div>` : ''}
${sec.note ? `<div class="small text-muted mb-2"><i class="bi bi-info-circle me-1"></i>${esc(t(sec.note))}</div>` : ''}
<div class="sec-status small text-muted mb-2 d-none"></div>
<div class="sec-reboot alert alert-warning py-1 px-2 small d-none mb-2">
<i class="bi bi-arrow-clockwise me-1"></i>Some changes take effect after a reboot — use Actions → Reboot.
<i class="bi bi-arrow-clockwise me-1"></i>${tHtml('rptmgmt.set.reboot_note')}
</div>
<div class="sec-fields">${sec.fields.map(settingsFieldHtml).join('')}</div>
${sec.key === 'location' ? `
<button type="button" class="btn btn-sm btn-outline-primary sec-map-pick" disabled
title="Refresh the section first, then pick a point">
<i class="bi bi-geo-alt"></i> Pick from map
title="${tHtml('rptmgmt.set.map_pick_title')}">
<i class="bi bi-geo-alt"></i> ${tHtml('rptmgmt.set.map_pick')}
</button>` : ''}
<div class="d-flex align-items-center gap-2 mt-3">
<button type="button" class="btn btn-sm btn-outline-secondary sec-refresh">
@@ -1241,7 +1244,7 @@ function setFieldBadge(item, fieldKey, kind, text) {
} else if (kind === 'ok') {
badge.innerHTML = '<i class="bi bi-check-circle-fill text-success"></i>';
} else if (kind === 'reboot') {
badge.innerHTML = '<span class="badge bg-warning text-dark">reboot required</span>';
badge.innerHTML = `<span class="badge bg-warning text-dark">${tHtml('rptmgmt.reboot_required')}</span>`;
} else if (kind === 'error') {
badge.innerHTML = '<i class="bi bi-exclamation-triangle-fill text-danger"></i>';
if (text) {
@@ -1310,7 +1313,7 @@ async function loadSettingsSection(secKey) {
const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/settings?section=${encodeURIComponent(secKey)}`);
data = await resp.json();
} catch (e) {
data = { success: false, error: 'Request failed' };
data = { success: false, error: t('rptmgmt.request_failed') };
}
st.loading = false;
@@ -1319,7 +1322,7 @@ async function loadSettingsSection(secKey) {
if (!data || !data.success) {
statusEl.classList.remove('text-muted');
statusEl.classList.add('text-danger');
statusEl.textContent = (data && data.error) || 'Failed to read settings';
statusEl.textContent = (data && data.error) || t('rptmgmt.settings_read_failed');
updateSectionDirty(secKey);
return;
}
@@ -1340,7 +1343,7 @@ async function loadSettingsSection(secKey) {
} else {
errCount++;
disableSettingsField(item, f, true);
setFieldBadge(item, f.key, 'error', errors[f.key] || 'Failed to read');
setFieldBadge(item, f.key, 'error', errors[f.key] || t('rptmgmt.read_failed'));
}
});
if (mapPickBtn) mapPickBtn.disabled = !('lat' in st.loaded && 'lon' in st.loaded);
@@ -1373,7 +1376,7 @@ async function applySettingsSection(secKey) {
const emptyKey = keys.find(k => dirty[k] === '' && k !== 'owner.info');
if (emptyKey) {
showNotification(`"${emptyKey}" cannot be empty`, 'warning');
showNotification(t('rptmgmt.toast.field_empty', { field: emptyKey }), 'warning');
return;
}
@@ -1398,7 +1401,7 @@ async function applySettingsSection(secKey) {
});
data = await resp.json();
} catch (e) {
data = { success: false, error: 'Request failed' };
data = { success: false, error: t('rptmgmt.request_failed') };
}
st.applying = false;
@@ -1406,7 +1409,7 @@ async function applySettingsSection(secKey) {
if (!data || !data.success) {
keys.forEach(k => setFieldBadge(item, k, 'error'));
showNotification((data && data.error) || 'Failed to apply settings', 'danger');
showNotification((data && data.error) || t('rptmgmt.settings_apply_failed'), 'danger');
updateSectionDirty(secKey);
return;
}
@@ -1415,7 +1418,7 @@ async function applySettingsSection(secKey) {
let okCount = 0, failCount = 0, rebootCount = 0;
for (const f of sec.fields) {
if (!(f.key in dirty)) continue;
const res = results[f.key] || { status: 'failed', error: 'No result' };
const res = results[f.key] || { status: 'failed', error: t('rptmgmt.no_result') };
if (res.status === 'ok' || res.status === 'reboot_required') {
if (res.status === 'reboot_required') {
rebootCount++;
@@ -1436,7 +1439,7 @@ async function applySettingsSection(secKey) {
}
} else {
failCount++;
setFieldBadge(item, f.key, 'error', res.error || res.reply || 'Failed');
setFieldBadge(item, f.key, 'error', res.error || res.reply || t('rptmgmt.failed'));
}
}
@@ -1444,9 +1447,9 @@ async function applySettingsSection(secKey) {
updateSectionDirty(secKey);
if (failCount === 0) {
showNotification(rebootCount ? 'Appliedreboot required for some changes' : 'Settings applied', 'success');
showNotification(rebootCount ? t('rptmgmt.toast.applied_reboot') : t('rptmgmt.toast.applied'), 'success');
} else {
showNotification(`${failCount} setting${failCount > 1 ? 's' : ''} failed to apply`, 'danger');
showNotification(tn('rptmgmt.toast.apply_failed_count', failCount), 'danger');
}
if (okCount) {
setTimeout(() => {
@@ -1472,7 +1475,7 @@ async function syncSavedPassword(newPassword) {
});
const data = await resp.json();
if (data && data.success) {
showNotification('Saved password updated to the new one', 'success');
showNotification(t('rptmgmt.toast.password_synced'), 'success');
}
} catch (e) {
console.error('Failed to update saved password:', e);
@@ -1570,16 +1573,17 @@ function applyLocationFromMap() {
// ================================================================
// Keys mirror _REPEATER_ACTIONS in api.py.
// title/desc/btn hold catalog KEYS, resolved with t() at render time.
const REPEATER_ACTIONS = [
{ key: 'zerohop_advert', icon: 'bi-megaphone', iconClass: 'text-primary',
title: 'Send zero-hop advert', btn: 'Send', btnClass: 'btn-outline-primary',
desc: 'Announce this repeater to its direct neighbours only.' },
title: 'rptmgmt.act.zerohop', btn: 'rptmgmt.act.send', btnClass: 'btn-outline-primary',
desc: 'rptmgmt.act.zerohop_desc' },
{ key: 'flood_advert', icon: 'bi-broadcast-pin', iconClass: 'text-warning',
title: 'Send flood advert', btn: 'Send', btnClass: 'btn-outline-warning',
desc: 'Not recommended — the advert is flooded across the whole mesh (high network load).' },
title: 'rptmgmt.act.flood', btn: 'rptmgmt.act.send', btnClass: 'btn-outline-warning',
desc: 'rptmgmt.act.flood_desc' },
{ key: 'clock_sync', icon: 'bi-clock-history', iconClass: 'text-primary',
title: 'Sync clock', btn: 'Sync', btnClass: 'btn-outline-primary',
desc: "Set the repeater's clock from this device's current time. The firmware refuses to move the clock backwards." },
title: 'rptmgmt.act.clock_sync', btn: 'rptmgmt.act.sync', btnClass: 'btn-outline-primary',
desc: 'rptmgmt.act.clock_sync_desc' },
];
let _actionPending = false;
@@ -1589,12 +1593,12 @@ function actionRowHtml(a) {
<div class="d-flex align-items-start gap-3 py-2 action-row" data-action="${a.key}">
<i class="bi ${a.icon} fs-4 ${a.iconClass || ''}"></i>
<div class="flex-grow-1" style="min-width: 0;">
<div class="fw-semibold">${esc(a.title)}</div>
<div class="small text-muted">${esc(a.desc)}</div>
<div class="fw-semibold">${esc(t(a.title))}</div>
<div class="small text-muted">${esc(t(a.desc))}</div>
<div class="small action-result d-none mt-1"></div>
</div>
<button type="button" class="btn btn-sm ${a.btnClass} action-btn" style="min-width: 84px;">
${esc(a.btn)}
${esc(t(a.btn))}
</button>
</div>`;
}
@@ -1609,17 +1613,16 @@ function renderActionsPane(body) {
</div>
<div class="card border-danger">
<div class="card-header py-2 text-danger">
<i class="bi bi-exclamation-octagon me-1"></i>Danger zone
<i class="bi bi-exclamation-octagon me-1"></i>${tHtml('rptmgmt.danger_zone')}
</div>
<div class="card-body py-2">
${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',
})}
<div class="small text-muted mt-2">
<i class="bi bi-info-circle me-1"></i>Erase file system is not available over the mesh —
the firmware only accepts it on the USB serial console (use the MeshCore flasher instead).
<i class="bi bi-info-circle me-1"></i>${tHtml('rptmgmt.erase_fs_note')}
</div>
</div>
</div>
@@ -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) {
+23 -23
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Repeater Management - mc-webui</title>
<title>{{ t('rptmgmt.title') }} - mc-webui</title>
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
<script>
@@ -274,12 +274,12 @@
<body>
<!-- Toolbar -->
<div class="manage-toolbar">
<button type="button" class="btn btn-sm btn-outline-secondary" id="backBtn" title="Back to My Repeaters">
<button type="button" class="btn btn-sm btn-outline-secondary" id="backBtn" title="{{ t('rptmgmt.back_title') }}">
<i class="bi bi-arrow-left"></i>
</button>
<span class="fw-semibold flex-grow-1">Repeater Management</span>
<button type="button" class="btn btn-sm btn-outline-secondary d-none" id="logoutBtn" title="Log out of this repeater">
<i class="bi bi-box-arrow-right"></i> Logout
<span class="fw-semibold flex-grow-1">{{ t('rptmgmt.title') }}</span>
<button type="button" class="btn btn-sm btn-outline-secondary d-none" id="logoutBtn" title="{{ t('rptmgmt.logout_title') }}">
<i class="bi bi-box-arrow-right"></i> {{ t('rptmgmt.logout') }}
</button>
</div>
@@ -287,19 +287,19 @@
<!-- Loading / logging-in state -->
<div class="state-screen" id="loadingState">
<div class="spinner-border text-success" role="status" style="width: 3rem; height: 3rem;"></div>
<p class="mt-3 mb-0" id="loadingText">Loading</p>
<p class="mt-3 mb-0" id="loadingText">{{ t('common.loading') }}</p>
</div>
<!-- Error state -->
<div class="state-screen" id="errorState" style="display: none;">
<i class="bi bi-exclamation-triangle text-warning"></i>
<p class="mb-3" id="errorText">Something went wrong.</p>
<p class="mb-3" id="errorText">{{ t('rptmgmt.error_generic') }}</p>
<div class="d-flex gap-2 justify-content-center">
<button type="button" class="btn btn-sm btn-outline-secondary" id="errorBackBtn">
<i class="bi bi-arrow-left"></i> Back to list
<i class="bi bi-arrow-left"></i> {{ t('rptmgmt.back_to_list') }}
</button>
<button type="button" class="btn btn-sm btn-primary" id="errorRetryBtn">
<i class="bi bi-arrow-clockwise"></i> Try again
<i class="bi bi-arrow-clockwise"></i> {{ t('common.try_again') }}
</button>
</div>
</div>
@@ -316,7 +316,7 @@
</div>
<div class="rpt-pubkey text-muted mt-1">
<span id="rptPubkey"></span>
<button type="button" class="btn btn-link btn-sm p-0 ms-1 align-baseline" id="copyPubkeyBtn" title="Copy full public key">
<button type="button" class="btn btn-link btn-sm p-0 ms-1 align-baseline" id="copyPubkeyBtn" title="{{ t('rptmgmt.copy_pubkey_title') }}">
<i class="bi bi-copy"></i>
</button>
</div>
@@ -329,14 +329,14 @@
<!-- Tools grid -->
<div id="toolsGrid">
<div class="text-muted small text-uppercase fw-bold mb-2">Management Tools</div>
<div class="text-muted small text-uppercase fw-bold mb-2">{{ t('rptmgmt.tools_heading') }}</div>
<div class="row g-3" id="toolsRow"></div>
</div>
<!-- Tool pane (shown instead of the grid when a tool is open) -->
<div id="toolPane" style="display: none;">
<div class="tool-pane-header">
<button type="button" class="btn btn-sm btn-outline-secondary" id="paneBackBtn" title="Back to tools">
<button type="button" class="btn btn-sm btn-outline-secondary" id="paneBackBtn" title="{{ t('rptmgmt.back_tools_title') }}">
<i class="bi bi-arrow-left"></i>
</button>
<span class="tool-icon" id="paneIcon" style="width: 32px; height: 32px; font-size: 1rem;"></span>
@@ -352,27 +352,27 @@
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-key"></i> <span id="passwordModalTitle">Log in</span></h6>
<h6 class="modal-title"><i class="bi bi-key"></i> <span id="passwordModalTitle">{{ t('repeaters.password.login_title') }}</span></h6>
</div>
<div class="modal-body">
<div class="mb-2 small text-muted" id="passwordModalInfo"></div>
<div class="input-group input-group-sm mb-2">
<input type="password" class="form-control" id="passwordInput"
placeholder="Repeater password" autocomplete="off">
<button type="button" class="btn btn-outline-secondary" id="togglePasswordBtn" title="Show/hide password">
placeholder="{{ t('repeaters.password.input_ph') }}" autocomplete="off">
<button type="button" class="btn btn-outline-secondary" id="togglePasswordBtn" title="{{ t('repeaters.password.toggle_title') }}">
<i class="bi bi-eye"></i>
</button>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="savePasswordCheck" checked>
<label class="form-check-label small" for="savePasswordCheck">
Remember password (stored in the app database)
{{ t('repeaters.password.remember') }}
</label>
</div>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-sm btn-secondary" id="passwordCancelBtn">Back to list</button>
<button type="button" class="btn btn-sm btn-primary" id="passwordSubmitBtn">Log in</button>
<button type="button" class="btn btn-sm btn-secondary" id="passwordCancelBtn">{{ t('rptmgmt.back_to_list') }}</button>
<button type="button" class="btn btn-sm btn-primary" id="passwordSubmitBtn">{{ t('repeaters.password.login_btn') }}</button>
</div>
</div>
</div>
@@ -383,19 +383,19 @@
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title"><i class="bi bi-geo-alt"></i> Pick Location</h6>
<h6 class="modal-title"><i class="bi bi-geo-alt"></i> {{ t('rptmgmt.loc.title') }}</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<div class="px-3 py-2 border-bottom small text-muted">
Click the map to set the repeater location.
{{ t('rptmgmt.loc.hint') }}
</div>
<div id="locLeafletMap" style="height: 400px; width: 100%;"></div>
</div>
<div class="modal-footer py-2">
<span class="me-auto small font-monospace" id="locMapSelected"></span>
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" id="locMapUseBtn" disabled>Use this location</button>
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{{ t('common.cancel') }}</button>
<button type="button" class="btn btn-sm btn-primary" id="locMapUseBtn" disabled>{{ t('rptmgmt.loc.use') }}</button>
</div>
</div>
</div>
@@ -405,7 +405,7 @@
<div class="toast-container position-fixed top-0 start-0 p-3" data-toast-container>
<div id="notificationToast" class="toast" role="alert">
<div class="toast-header">
<strong class="me-auto">Repeater Management</strong>
<strong class="me-auto">{{ t('rptmgmt.title') }}</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body"></div>
+119 -14
View File
@@ -1,23 +1,22 @@
{
"common.add": "Add",
"common.cancel": "Cancel",
"common.close": "Close",
"common.days_ago": {
"one": "{count}d ago",
"other": "{count}d ago"
},
"common.delete": "Delete",
"common.hours_ago": "{count}h ago",
"common.just_now": "just now",
"common.minutes_ago": "{count} min ago",
"common.yesterday": "Yesterday",
"common.add": "Add",
"common.cancel": "Cancel",
"common.close": "Close",
"common.delete": "Delete",
"common.loading": "Loading...",
"common.minutes_ago": "{count} min ago",
"common.move_down": "Move down",
"common.move_up": "Move up",
"common.remove": "Remove",
"common.save": "Save",
"common.try_again": "Try again",
"common.yesterday": "Yesterday",
"console.clear_title": "Clear output history",
"console.connect_failed": "Failed to connect: {error}",
"console.error_prefix": "Error: {error}",
@@ -32,7 +31,6 @@
"console.status.connecting": "Connecting...",
"console.status.disconnected": "Disconnected",
"console.title": "Console",
"logs.clear_title": "Clear display",
"logs.entries": {
"one": "{count} entry",
@@ -50,7 +48,9 @@
"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",
"repeaters.add": "Add repeater",
"repeaters.add_loading": "Loading device contacts...",
"repeaters.add_modal_title": "Add Repeater",
@@ -166,8 +166,113 @@
"repeaters.toast.removed": "Repeater removed from list",
"repeaters.toast.reset_failed": "Reset failed",
"repeaters.toast.reset_flood_done": "Device path reset to FLOOD",
"meta.language_english_name": "English",
"meta.language_name": "English",
"meta.translator": "mc-webui"
"rptmgmt.act.clock_sync": "Sync clock",
"rptmgmt.act.clock_sync_desc": "Set the repeater's clock from this device's current time. The firmware refuses to move the clock backwards.",
"rptmgmt.act.flood": "Send flood advert",
"rptmgmt.act.flood_desc": "Not recommended — the advert is flooded across the whole mesh (high network load).",
"rptmgmt.act.reboot": "Reboot repeater",
"rptmgmt.act.reboot_btn": "Reboot",
"rptmgmt.act.reboot_desc": "The repeater drops off the mesh for a few seconds while it restarts.",
"rptmgmt.act.send": "Send",
"rptmgmt.act.sync": "Sync",
"rptmgmt.act.zerohop": "Send zero-hop advert",
"rptmgmt.act.zerohop_desc": "Announce this repeater to its direct neighbours only.",
"rptmgmt.action_failed": "Action failed",
"rptmgmt.back_title": "Back to My Repeaters",
"rptmgmt.back_to_list": "Back to list",
"rptmgmt.back_tools_title": "Back to tools",
"rptmgmt.command_failed": "Command failed",
"rptmgmt.copy_pubkey_title": "Copy full public key",
"rptmgmt.danger_zone": "Danger zone",
"rptmgmt.done": "Done",
"rptmgmt.erase_fs_note": "Erase file system is not available over the mesh — the firmware only accepts it on the USB serial console (use the MeshCore flasher instead).",
"rptmgmt.error_generic": "Something went wrong.",
"rptmgmt.failed": "Failed",
"rptmgmt.load_failed": "Failed to load repeater",
"rptmgmt.loc.hint": "Click the map to set the repeater location.",
"rptmgmt.loc.title": "Pick Location",
"rptmgmt.loc.use": "Use this location",
"rptmgmt.login_request_failed": "Login request failed",
"rptmgmt.logout": "Logout",
"rptmgmt.logout_title": "Log out of this repeater",
"rptmgmt.managed_repeater": "managed repeater",
"rptmgmt.neigh.heard": "Heard",
"rptmgmt.neigh.repeater": "Repeater",
"rptmgmt.neighbours_failed": "Failed to get neighbours",
"rptmgmt.neighbours_none": "No neighbours reported yet.",
"rptmgmt.neighbours_none_hint": "Neighbours are learned from zero-hop repeater adverts.",
"rptmgmt.no_result": "No result",
"rptmgmt.read_failed": "Failed to read",
"rptmgmt.reboot_required": "reboot required",
"rptmgmt.request_failed": "Request failed",
"rptmgmt.set.advanced": "Advanced",
"rptmgmt.set.advert": "Advertisement",
"rptmgmt.set.advert_interval": "Local advert interval (minutes)",
"rptmgmt.set.advert_interval_help": "Firmware accepts 60240 minutes, or 0 to disable.",
"rptmgmt.set.agc_reset": "AGC reset interval",
"rptmgmt.set.agc_reset_help": "Multiple of 4; 0 disables periodic AGC resets.",
"rptmgmt.set.allow_read_only": "Allow read-only (guest) access",
"rptmgmt.set.basic": "Basic",
"rptmgmt.set.direct_txdelay": "Direct TX delay factor (02)",
"rptmgmt.set.dutycycle": "Duty cycle (%)",
"rptmgmt.set.features": "Features",
"rptmgmt.set.flood_advert_interval": "Flood advert interval (hours)",
"rptmgmt.set.flood_max": "Max flood hops",
"rptmgmt.set.guest_password": "Guest password",
"rptmgmt.set.guest_password_help": "Password for read-only guest logins.",
"rptmgmt.set.int_thresh": "Interference threshold",
"rptmgmt.set.lat": "Latitude",
"rptmgmt.set.location": "Location",
"rptmgmt.set.lon": "Longitude",
"rptmgmt.set.loop_detect": "Loop detection",
"rptmgmt.set.map_pick": "Pick from map",
"rptmgmt.set.map_pick_title": "Refresh the section first, then pick a point",
"rptmgmt.set.multi_acks": "Send multiple ACKs",
"rptmgmt.set.name": "Repeater name",
"rptmgmt.set.network": "Network health",
"rptmgmt.set.operator": "Operator info",
"rptmgmt.set.owner_info": "Owner info",
"rptmgmt.set.owner_info_help": "Free-form operator / contact info shown to clients. Multiple lines allowed.",
"rptmgmt.set.password": "Admin password",
"rptmgmt.set.password_help": "Write-only — the current password is never shown. Changing it does not log out already active sessions.",
"rptmgmt.set.path_hash_mode": "Path hash mode (02)",
"rptmgmt.set.radio": "Radio",
"rptmgmt.set.radio_note": "Frequency, bandwidth, SF and CR are applied after a reboot. TX power applies immediately.",
"rptmgmt.set.radio_params": "Radio parameters",
"rptmgmt.set.reboot_note": "Some changes take effect after a reboot — use Actions → Reboot.",
"rptmgmt.set.repeat": "Repeat packets",
"rptmgmt.set.rxgain": "RX boosted gain",
"rptmgmt.set.tx": "TX power (dBm)",
"rptmgmt.set.txdelay": "TX delay factor (02)",
"rptmgmt.settings_apply_failed": "Failed to apply settings",
"rptmgmt.settings_read_failed": "Failed to read settings",
"rptmgmt.status_failed": "Failed to get status",
"rptmgmt.telemetry_failed": "Failed to get telemetry",
"rptmgmt.telemetry_none": "No telemetry data reported.",
"rptmgmt.this_repeater": "This repeater",
"rptmgmt.title": "Repeater Management",
"rptmgmt.toast.admin_required": "Admin login required for this tool",
"rptmgmt.toast.applied": "Settings applied",
"rptmgmt.toast.applied_reboot": "Applied — reboot required for some changes",
"rptmgmt.toast.apply_failed_count": {
"one": "{count} setting failed to apply",
"other": "{count} settings failed to apply"
},
"rptmgmt.toast.field_empty": "\"{field}\" cannot be empty",
"rptmgmt.toast.password_synced": "Saved password updated to the new one",
"rptmgmt.tool_coming": "The <strong>{tool}</strong> tool is coming in a later stage.",
"rptmgmt.tools.actions": "Actions",
"rptmgmt.tools.actions_desc": "Advert, clock sync, reboot",
"rptmgmt.tools.cli": "CLI",
"rptmgmt.tools.cli_desc": "Send text commands to the repeater",
"rptmgmt.tools.neighbors": "Neighbors",
"rptmgmt.tools.neighbors_desc": "Zero-hop repeaters heard",
"rptmgmt.tools.settings": "Settings",
"rptmgmt.tools.settings_desc": "Configure repeater parameters",
"rptmgmt.tools.status": "Status",
"rptmgmt.tools.status_desc": "Battery, radio and packet statistics",
"rptmgmt.tools.telemetry": "Telemetry",
"rptmgmt.tools.telemetry_desc": "Sensor channels (Cayenne LPP)",
"rptmgmt.tools_heading": "Management Tools",
"rptmgmt.updated_just_now": "Updated just now"
}
+121 -14
View File
@@ -1,25 +1,24 @@
{
"common.add": "Dodaj",
"common.cancel": "Anuluj",
"common.close": "Zamknij",
"common.days_ago": {
"one": "{count} dzień temu",
"few": "{count} dni temu",
"many": "{count} dni temu",
"other": "{count} dnia temu"
},
"common.delete": "Usuń",
"common.hours_ago": "{count} godz. temu",
"common.just_now": "przed chwilą",
"common.minutes_ago": "{count} min temu",
"common.yesterday": "Wczoraj",
"common.add": "Dodaj",
"common.cancel": "Anuluj",
"common.close": "Zamknij",
"common.delete": "Usuń",
"common.loading": "Ładowanie...",
"common.minutes_ago": "{count} min temu",
"common.move_down": "Przesuń w dół",
"common.move_up": "Przesuń w górę",
"common.remove": "Usuń",
"common.save": "Zapisz",
"common.try_again": "Spróbuj ponownie",
"common.yesterday": "Wczoraj",
"console.clear_title": "Wyczyść zapis konsoli",
"console.connect_failed": "Nie udało się połączyć: {error}",
"console.error_prefix": "Błąd: {error}",
@@ -34,7 +33,6 @@
"console.status.connecting": "Łączenie...",
"console.status.disconnected": "Rozłączono",
"console.title": "Konsola",
"logs.clear_title": "Wyczyść widok",
"logs.entries": {
"one": "{count} wpis",
@@ -56,7 +54,9 @@
"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",
"repeaters.add": "Dodaj repeater",
"repeaters.add_loading": "Wczytywanie kontaktów z urządzenia...",
"repeaters.add_modal_title": "Dodaj repeater",
@@ -186,8 +186,115 @@
"repeaters.toast.removed": "Repeater usunięty z listy",
"repeaters.toast.reset_failed": "Przywracanie nie powiodło się",
"repeaters.toast.reset_flood_done": "Przywrócono na urządzeniu tryb FLOOD",
"meta.language_english_name": "Polish",
"meta.language_name": "Polski",
"meta.translator": "mc-webui"
"rptmgmt.act.clock_sync": "Synchronizuj zegar",
"rptmgmt.act.clock_sync_desc": "Ustaw zegar repeatera według bieżącego czasu tego urządzenia. Firmware nie pozwala cofnąć zegara.",
"rptmgmt.act.flood": "Wyślij flood advert",
"rptmgmt.act.flood_desc": "Niezalecane — advert jest rozsyłany trybem flood po całej sieci (duże obciążenie).",
"rptmgmt.act.reboot": "Restart repeatera",
"rptmgmt.act.reboot_btn": "Restart",
"rptmgmt.act.reboot_desc": "Repeater znika z sieci mesh na kilka sekund, gdy się restartuje.",
"rptmgmt.act.send": "Wyślij",
"rptmgmt.act.sync": "Synchronizuj",
"rptmgmt.act.zerohop": "Wyślij advert zero-hop",
"rptmgmt.act.zerohop_desc": "Ogłoś ten repeater wyłącznie bezpośrednim sąsiadom.",
"rptmgmt.action_failed": "Akcja nie powiodła się",
"rptmgmt.back_title": "Powrót do Moich repeaterów",
"rptmgmt.back_to_list": "Powrót do listy",
"rptmgmt.back_tools_title": "Powrót do narzędzi",
"rptmgmt.command_failed": "Polecenie nie powiodło się",
"rptmgmt.copy_pubkey_title": "Kopiuj pełny klucz publiczny",
"rptmgmt.danger_zone": "Strefa ryzyka",
"rptmgmt.done": "Gotowe",
"rptmgmt.erase_fs_note": "Kasowanie systemu plików nie jest dostępne przez sieć mesh — firmware przyjmuje je wyłącznie na konsoli szeregowej USB (użyj flashera MeshCore).",
"rptmgmt.error_generic": "Coś poszło nie tak.",
"rptmgmt.failed": "Niepowodzenie",
"rptmgmt.load_failed": "Nie udało się wczytać repeatera",
"rptmgmt.loc.hint": "Kliknij mapę, aby ustawić lokalizację repeatera.",
"rptmgmt.loc.title": "Wybierz lokalizację",
"rptmgmt.loc.use": "Użyj tej lokalizacji",
"rptmgmt.login_request_failed": "Żądanie logowania nie powiodło się",
"rptmgmt.logout": "Wyloguj",
"rptmgmt.logout_title": "Wyloguj się z tego repeatera",
"rptmgmt.managed_repeater": "zarządzany repeater",
"rptmgmt.neigh.heard": "Słyszany",
"rptmgmt.neigh.repeater": "Repeater",
"rptmgmt.neighbours_failed": "Nie udało się pobrać sąsiadów",
"rptmgmt.neighbours_none": "Nie zgłoszono jeszcze żadnych sąsiadów.",
"rptmgmt.neighbours_none_hint": "Sąsiedzi są poznawani z advertów repeaterów zero-hop.",
"rptmgmt.no_result": "Brak wyniku",
"rptmgmt.read_failed": "Nie udało się odczytać",
"rptmgmt.reboot_required": "wymagany restart",
"rptmgmt.request_failed": "Żądanie nie powiodło się",
"rptmgmt.set.advanced": "Zaawansowane",
"rptmgmt.set.advert": "Advert",
"rptmgmt.set.advert_interval": "Lokalny interwał advert (minuty)",
"rptmgmt.set.advert_interval_help": "Firmware przyjmuje 60240 minut lub 0, aby wyłączyć.",
"rptmgmt.set.agc_reset": "Interwał resetu AGC",
"rptmgmt.set.agc_reset_help": "Wielokrotność 4; 0 wyłącza okresowe resety AGC.",
"rptmgmt.set.allow_read_only": "Zezwól na dostęp gościa (tylko odczyt)",
"rptmgmt.set.basic": "Podstawowe",
"rptmgmt.set.direct_txdelay": "Współczynnik opóźnienia TX direct (02)",
"rptmgmt.set.dutycycle": "Duty cycle (%)",
"rptmgmt.set.features": "Funkcje",
"rptmgmt.set.flood_advert_interval": "Interwał flood advert (godziny)",
"rptmgmt.set.flood_max": "Maks. liczba hopów flood",
"rptmgmt.set.guest_password": "Hasło gościa",
"rptmgmt.set.guest_password_help": "Hasło do logowania gościa w trybie tylko do odczytu.",
"rptmgmt.set.int_thresh": "Próg zakłóceń",
"rptmgmt.set.lat": "Szerokość geogr.",
"rptmgmt.set.location": "Lokalizacja",
"rptmgmt.set.lon": "Długość geogr.",
"rptmgmt.set.loop_detect": "Wykrywanie pętli",
"rptmgmt.set.map_pick": "Wybierz z mapy",
"rptmgmt.set.map_pick_title": "Najpierw odśwież sekcję, potem wskaż punkt",
"rptmgmt.set.multi_acks": "Wysyłaj wiele ACK",
"rptmgmt.set.name": "Nazwa repeatera",
"rptmgmt.set.network": "Kondycja sieci",
"rptmgmt.set.operator": "Informacje o operatorze",
"rptmgmt.set.owner_info": "Dane właściciela",
"rptmgmt.set.owner_info_help": "Dowolny tekst z danymi operatora / kontaktem, widoczny dla klientów. Można użyć wielu linii.",
"rptmgmt.set.password": "Hasło administratora",
"rptmgmt.set.password_help": "Tylko do zapisu — bieżące hasło nigdy nie jest pokazywane. Zmiana nie wylogowuje aktywnych sesji.",
"rptmgmt.set.path_hash_mode": "Tryb hasha ścieżki (02)",
"rptmgmt.set.radio": "Radio",
"rptmgmt.set.radio_note": "Częstotliwość, bandwidth, SF i CR działają po restarcie. Moc TX działa natychmiast.",
"rptmgmt.set.radio_params": "Parametry radia",
"rptmgmt.set.reboot_note": "Część zmian działa dopiero po restarcie — użyj Akcje → Restart.",
"rptmgmt.set.repeat": "Przekazuj pakiety",
"rptmgmt.set.rxgain": "Wzmocnienie RX (boosted gain)",
"rptmgmt.set.tx": "Moc TX (dBm)",
"rptmgmt.set.txdelay": "Współczynnik opóźnienia TX (02)",
"rptmgmt.settings_apply_failed": "Nie udało się zastosować ustawień",
"rptmgmt.settings_read_failed": "Nie udało się odczytać ustawień",
"rptmgmt.status_failed": "Nie udało się pobrać statusu",
"rptmgmt.telemetry_failed": "Nie udało się pobrać telemetrii",
"rptmgmt.telemetry_none": "Brak danych telemetrycznych.",
"rptmgmt.this_repeater": "Ten repeater",
"rptmgmt.title": "Zarządzanie repeaterem",
"rptmgmt.toast.admin_required": "To narzędzie wymaga logowania jako administrator",
"rptmgmt.toast.applied": "Ustawienia zastosowane",
"rptmgmt.toast.applied_reboot": "Zastosowano — część zmian wymaga restartu",
"rptmgmt.toast.apply_failed_count": {
"one": "{count} ustawienia nie udało się zastosować",
"few": "{count} ustawień nie udało się zastosować",
"many": "{count} ustawień nie udało się zastosować",
"other": "{count} ustawienia nie udało się zastosować"
},
"rptmgmt.toast.field_empty": "„{field}” nie może być puste",
"rptmgmt.toast.password_synced": "Zapisane hasło zaktualizowane na nowe",
"rptmgmt.tool_coming": "Narzędzie <strong>{tool}</strong> pojawi się na późniejszym etapie.",
"rptmgmt.tools.actions": "Akcje",
"rptmgmt.tools.actions_desc": "Advert, synchronizacja zegara, restart",
"rptmgmt.tools.cli": "CLI",
"rptmgmt.tools.cli_desc": "Wysyłaj polecenia tekstowe do repeatera",
"rptmgmt.tools.neighbors": "Sąsiedzi",
"rptmgmt.tools.neighbors_desc": "Słyszane repeatery zero-hop",
"rptmgmt.tools.settings": "Ustawienia",
"rptmgmt.tools.settings_desc": "Konfiguruj parametry repeatera",
"rptmgmt.tools.status": "Status",
"rptmgmt.tools.status_desc": "Bateria, radio i statystyki pakietów",
"rptmgmt.tools.telemetry": "Telemetria",
"rptmgmt.tools.telemetry_desc": "Kanały czujników (Cayenne LPP)",
"rptmgmt.tools_heading": "Narzędzia zarządzania",
"rptmgmt.updated_just_now": "Zaktualizowano przed chwilą"
}
+28 -10
View File
@@ -39,10 +39,15 @@ META_KEYS = {'meta.language_name', 'meta.language_english_name', 'meta.translato
# Terms that must survive translation. Mesh operators use the English words regardless
# of UI language, and a translated "flood" matches no firmware doc or forum post.
# Canonical list — docs/translations.md points here.
# Kept deliberately tight: only jargon whose native translation would leave a mesh
# operator unable to match the UI against firmware output or a forum post. Words with a
# normal, unambiguous native equivalent — telemetry, sensor, broker, bandwidth — are
# guidance in docs/translations.md, not an automated check. They fired on correct Polish
# ("telemetria", "czujniki"), and a warning you are meant to ignore trains you to ignore
# all of them. "direct" is out for the same reason: it is a common adjective ("direct
# neighbours") far more often than the Direct mode value.
GLOSSARY = [
'flood', 'direct', 'hop', 'advert', 'ACK', 'RSSI', 'SNR', 'LoRa', 'MQTT',
'repeater', 'room server', 'companion', 'sensor', 'pubkey', 'telemetry', 'broker',
'spreading factor', 'bandwidth', 'coding rate',
'flood', 'hop', 'advert', 'ACK', 'RSSI', 'SNR', 'LoRa', 'MQTT', 'pubkey', 'repeater',
]
# t('key'), tn('key', n), tHtml('key', {...}), t_html('key', name=x).
@@ -90,10 +95,14 @@ def available_langs() -> list[str]:
return sorted(p.stem for p in TRANSLATIONS.glob('*.json'))
def scan_sources() -> tuple[dict[str, set[str]], dict[str, list[str]]]:
"""Return (key -> set of call kinds used, key -> list of 'file:line' sites)."""
STRING_LITERAL_RE = re.compile(r'''(['"])((?:(?!\1)[^\\])*)\1''')
def scan_sources() -> tuple[dict[str, set[str]], dict[str, list[str]], set[str]]:
"""Return (key -> call kinds, key -> 'file:line' sites, every string literal seen)."""
kinds: dict[str, set[str]] = defaultdict(set)
sites: dict[str, list[str]] = defaultdict(list)
literals: set[str] = set()
for pattern in SCAN_GLOBS:
for path in sorted(REPO.glob(pattern)):
@@ -108,6 +117,9 @@ def scan_sources() -> tuple[dict[str, set[str]], dict[str, list[str]]]:
kinds[key].add(func)
sites[key].append(f'{rel}:{lineno}')
for _, literal in STRING_LITERAL_RE.findall(line):
literals.add(literal)
# A t() result landing in innerHTML must be tHtml() — its params are
# escaped, so interpolated user data cannot inject markup.
if HTML_SINK_RE.search(line) and re.search(r'\$\{\s*t\s*\(', line):
@@ -117,7 +129,7 @@ def scan_sources() -> tuple[dict[str, set[str]], dict[str, list[str]]]:
err(f'{rel}:{lineno}: `t` is assigned here, shadowing the global '
f'translation helper — rename the variable')
return kinds, sites
return kinds, sites, literals
# ---------------------------------------------------------------------------
@@ -140,13 +152,19 @@ def placeholders(value) -> set[str]:
return out
def check_usage(en: dict, kinds: dict[str, set[str]], sites: dict[str, list[str]]):
def check_usage(en: dict, kinds: dict[str, set[str]], sites: dict[str, list[str]],
literals: set[str]):
for key in sorted(kinds):
if key not in en:
where = sites[key][0]
err(f'{where}: key not in en.json — {key!r}')
unused = set(en) - set(kinds) - META_KEYS
# Some keys are resolved dynamically — a declarative schema holding
# labelKey: 'repeaters.settings.name' and calling t(f.labelKey) at render time.
# A bare literal that exactly matches a catalog key counts as a reference, which
# keeps those out of the unused list. It only relaxes this warning: missing-key
# errors and the markup lint still come from real call sites.
unused = set(en) - set(kinds) - META_KEYS - literals
for key in sorted(unused):
warn(f'en.json: unused key {key!r}')
@@ -216,8 +234,8 @@ def main() -> int:
print(f'{key}\t{text}')
return 0
kinds, sites = scan_sources()
check_usage(en, kinds, sites)
kinds, sites, literals = scan_sources()
check_usage(en, kinds, sites, literals)
langs = [args.lang] if args.lang else [l for l in available_langs() if l != 'en']
coverage = {lang: check_language(lang, en, load_catalog(lang)) for lang in langs}