From fbd6820df0163b5ce7009a0fd2a65018aa595b2a Mon Sep 17 00:00:00 2001 From: MarekWo Date: Sat, 1 Aug 2026 15:20:00 +0200 Subject: [PATCH] fix(i18n): finish repeater management, contacts, path analyzer (stage 9d) An end-of-stage-9 sweep over every JS file found ~85 user-facing strings that stages 3-5 never extracted, because their browser diffs only ever opened the states those pages start in. Most of it is repeater-manage.js: the entire login flow (prompt, progress, failure hint, password modal), the reboot and radio-change confirmations, the "Updated Xs ago" labels and fmtHeardAgo, the Status/Telemetry/Neighbours/CLI tool chrome and the Status table's section headings and row labels. Plus contacts.js (auto-cleanup status, push/move confirmations, bulk progress counters, QR error), path-analyzer.js (map popups, table tooltips, the map toggles) and two strings in repeaters.js. Row labels in the Status table go through t(), not tHtml(): statusSection() already runs esc() over the title and every label, so tHtml would escape twice. Protocol terms stay English as before: flood/direct in the packet counters, the CLI quick commands and the Cayenne LPP sensor type names. Verified with F:\tmp\pwverify\i18n-stage9d.js. Two strings come back identical and both are correct: "Hop" is glossary, and "Repeater not in list" is backend error text, which is out of scope by decision. Co-Authored-By: Claude Opus 5 --- app/static/js/contacts.js | 32 +++--- app/static/js/path-analyzer.js | 20 ++-- app/static/js/repeater-manage.js | 180 ++++++++++++++++--------------- app/static/js/repeaters.js | 4 +- app/translations/en.json | 98 +++++++++++++++++ app/translations/pl.json | 102 ++++++++++++++++++ 6 files changed, 319 insertions(+), 117 deletions(-) diff --git a/app/static/js/contacts.js b/app/static/js/contacts.js index b722f0b..4f32a49 100644 --- a/app/static/js/contacts.js +++ b/app/static/js/contacts.js @@ -407,7 +407,7 @@ function applyCleanupSettingsToUI(settings) { if (statusText) { if (settings.enabled) { const hourStr = hour.toString().padStart(2, '0'); - statusText.textContent = `Enabled (runs daily at ${hourStr}:00 ${cleanupTimezone})`; + statusText.textContent = t('contacts.cleanup.auto_enabled', { time: `${hourStr}:00`, tz: cleanupTimezone }); statusText.classList.remove('text-muted'); statusText.classList.add('text-success'); } else { @@ -532,7 +532,7 @@ async function saveCleanupSettings(enabled) { if (data.settings.enabled) { const savedHour = data.settings.hour !== undefined ? data.settings.hour : 1; const hourStr = savedHour.toString().padStart(2, '0'); - statusText.textContent = `Enabled (runs daily at ${hourStr}:00 ${cleanupTimezone})`; + statusText.textContent = t('contacts.cleanup.auto_enabled', { time: `${hourStr}:00`, tz: cleanupTimezone }); statusText.classList.remove('text-muted'); statusText.classList.add('text-success'); } else { @@ -553,7 +553,7 @@ async function saveCleanupSettings(enabled) { if (autoCleanupSettings.enabled) { const prevHour = autoCleanupSettings.hour !== undefined ? autoCleanupSettings.hour : 1; const hourStr = prevHour.toString().padStart(2, '0'); - statusText.textContent = `Enabled (runs daily at ${hourStr}:00 ${cleanupTimezone})`; + statusText.textContent = t('contacts.cleanup.auto_enabled', { time: `${hourStr}:00`, tz: cleanupTimezone }); } else { statusText.textContent = t('common.disabled'); } @@ -740,9 +740,9 @@ async function handleCleanupConfirm() { if (modal) modal.hide(); // Show success message - let message = `Cleanup completed: ${data.deleted_count} deleted`; + let message = t('contacts.cleanup.completed', { count: data.deleted_count }); if (data.failed_count > 0) { - message += `, ${data.failed_count} failed`; + message += t('contacts.cleanup.completed_failed', { count: data.failed_count }); } showToast(message, data.failed_count > 0 ? 'warning' : 'success'); @@ -763,7 +763,7 @@ async function handleCleanupConfirm() { } } catch (error) { console.error('Error during cleanup:', error); - showToast('Network error during cleanup', 'danger'); + showToast(t('contacts.cleanup.network_error'), 'danger'); } finally { // Re-enable button confirmBtn.disabled = false; @@ -1272,8 +1272,8 @@ function renderPendingList(contacts) { emptyDiv.className = 'empty-state'; emptyDiv.innerHTML = ` -

No contacts match filters

- Try changing your filter criteria +

${tHtml('contacts.filter.no_match')}

+ ${tHtml('contacts.filter.no_match_hint')} `; listEl.appendChild(emptyDiv); return; @@ -1336,7 +1336,7 @@ function createContactCard(contact, index) { lastAdvertDiv = document.createElement('div'); lastAdvertDiv.className = 'text-muted small'; const relativeTime = formatRelativeTime(contact.last_advert); - lastAdvertDiv.textContent = `Last seen: ${relativeTime}`; + lastAdvertDiv.textContent = t('repeaters.map.last_seen', { time: relativeTime }); } // Action buttons @@ -1559,7 +1559,7 @@ async function batchApproveContacts() { // Update button with progress if (confirmBtn) { - confirmBtn.innerHTML = ` Approving ${i + 1}/${filteredPendingContacts.length}...`; + confirmBtn.innerHTML = ` ${tHtml('contacts.progress.approving', { done: i + 1, total: filteredPendingContacts.length })}`; } try { @@ -1667,7 +1667,7 @@ async function batchIgnoreContacts() { const contact = filteredPendingContacts[i]; if (confirmBtn) { - confirmBtn.innerHTML = ` Ignoring ${i + 1}/${filteredPendingContacts.length}...`; + confirmBtn.innerHTML = ` ${tHtml('contacts.progress.ignoring', { done: i + 1, total: filteredPendingContacts.length })}`; } try { @@ -2445,7 +2445,7 @@ async function confirmDelete() { // ============================================================================= async function pushContactToDevice(contact) { - if (!confirm(`Push "${contact.name}" to device?`)) return; + if (!confirm(t('contacts.confirm.push', { name: contact.name }))) return; try { const response = await fetch(`/api/contacts/${contact.public_key}/push-to-device`, { @@ -2454,7 +2454,7 @@ async function pushContactToDevice(contact) { }); const data = await response.json(); if (data.success) { - showToast(data.message || `${contact.name} pushed to device`, 'success'); + showToast(data.message || t('contacts.toast.pushed', { name: contact.name }), 'success'); setTimeout(() => loadExistingContacts(), 500); } else { showToast(data.error || t('contacts.toast.push_failed'), 'danger'); @@ -2465,7 +2465,7 @@ async function pushContactToDevice(contact) { } async function moveContactToCache(contact) { - if (!confirm(`Move "${contact.name}" from device to cache?`)) return; + if (!confirm(t('contacts.confirm.to_cache', { name: contact.name }))) return; try { const response = await fetch(`/api/contacts/${contact.public_key}/move-to-cache`, { @@ -2474,7 +2474,7 @@ async function moveContactToCache(contact) { }); const data = await response.json(); if (data.success) { - showToast(data.message || `${contact.name} moved to cache`, 'success'); + showToast(data.message || t('contacts.toast.to_cache', { name: contact.name }), 'success'); setTimeout(() => loadExistingContacts(), 500); } else { showToast(data.error || t('contacts.toast.move_failed'), 'danger'); @@ -2644,7 +2644,7 @@ function onQrCodeSuccess(decodedText) { return; } - showQrError('QR code does not contain a valid meshcore:// URI.'); + showQrError(t('contacts.add.qr_invalid_uri')); } function showQrError(msg) { diff --git a/app/static/js/path-analyzer.js b/app/static/js/path-analyzer.js index 4787b7b..7da2408 100644 --- a/app/static/js/path-analyzer.js +++ b/app/static/js/path-analyzer.js @@ -226,7 +226,7 @@ function paBuildEchoLine(msg, echo, echoIdx) { const chip = document.createElement('span'); chip.className = 'pa-chip'; chip.textContent = tok; - chip.title = `Copy repeater hash ${tok}`; + chip.title = t('pa.copy_hash_title', { hash: tok }); chip.addEventListener('click', (e) => { e.stopPropagation(); paCopyText(tok, t('pa.repeater_hash')); @@ -457,7 +457,7 @@ function paRenderStats() { for (const s of rows) { const tr = document.createElement('tr'); tr.className = 'pa-stats-row'; - tr.title = `Filter messages by ${s.token}`; + tr.title = t('pa.filter_by_title', { token: s.token }); const tdToken = document.createElement('td'); tdToken.innerHTML = `${s.token}`; @@ -576,7 +576,7 @@ function paRenderRoutes() { const names = r.tokens.map(tok => { const cands = paMatchContacts(tok); if (cands.length === 1) return cands[0].name || '—'; - return cands.length > 1 ? `ambiguous (${cands.length})` : '—'; + return cands.length > 1 ? t('pa.ambiguous', { count: cands.length }) : '—'; }); if (names.some(nm => nm !== '—')) { const nameLine = document.createElement('div'); @@ -610,7 +610,7 @@ function paRenderRoutes() { if (rows.length === 0) { document.getElementById('paEmptyText').textContent = filtered.length === 0 ? t('pa.empty_filtered') - : `No paths with at least ${n} hops in the current selection.`; + : t('pa.no_paths_min_hops', { count: n }); paSetView('empty'); } else { paSetView('routes'); @@ -674,8 +674,8 @@ function paAddMapToggles() { ctl.onAdd = () => { const box = L.DomUtil.create('div', 'pa-map-toggles'); box.innerHTML = - '' + - ''; + `` + + ``; L.DomEvent.disableClickPropagation(box); L.DomEvent.disableScrollPropagation(box); return box; @@ -747,7 +747,7 @@ function paDrawEcho(msg, echo, primary, seen, altColor) { if (primary) { L.circleMarker([origin.adv_lat, origin.adv_lon], { radius: 7, color: '#198754', weight: 2, fillOpacity: 0.8 - }).bindPopup(`${origin.name}
Origin (sender)`) + }).bindPopup(`${origin.name}
${tHtml('pa.map.origin')}`) .bindTooltip(origin.name, { permanent: true, direction: 'right', offset: [8, 0], className: 'pa-hop-label' }) .addTo(layer); } @@ -759,7 +759,7 @@ function paDrawEcho(msg, echo, primary, seen, altColor) { if (contact) { if (primary) { L.marker([contact.adv_lat, contact.adv_lon], { icon: paHopIcon(i + 1) }) - .bindPopup(`${contact.name}
Hop ${i + 1}: ${tok}`) + .bindPopup(`${contact.name}
${tHtml('pa.map.hop', { n: i + 1, token: `${tok}` })}`) .bindTooltip(contact.name, { permanent: true, direction: 'right', offset: [13, 0], className: 'pa-hop-label' }) .addTo(layer); } @@ -771,7 +771,7 @@ function paDrawEcho(msg, echo, primary, seen, altColor) { candidates.forEach(c => { L.circleMarker([c.adv_lat, c.adv_lon], { radius: 6, color: '#d39e00', weight: 2, fillOpacity: 0.5, dashArray: '3' - }).bindPopup(`${c.name}
Candidate for hop ${i + 1}: ${tok}`).addTo(layer); + }).bindPopup(`${c.name}
${tHtml('pa.map.candidate', { n: i + 1, token: `${tok}` })}`).addTo(layer); }); } gapPending = true; @@ -783,7 +783,7 @@ function paDrawEcho(msg, echo, primary, seen, altColor) { // only once - an alternative then shows exactly where it diverges instead of // hiding under the primary route. const snr = (echo.snr === null || echo.snr === undefined) ? '?' : `${Number(echo.snr).toFixed(1)} dB`; - const popup = `Alternative path
${echo.tokens.join(' → ')}
${snr}`; + const popup = `${tHtml('pa.map.alt_path')}
${echo.tokens.join(' → ')}
${snr}`; let drawn = 0; for (let i = 1; i < points.length; i++) { const key = [String(points[i - 1].latlng), String(points[i].latlng)].sort().join('|'); diff --git a/app/static/js/repeater-manage.js b/app/static/js/repeater-manage.js index f14479d..0c69f41 100644 --- a/app/static/js/repeater-manage.js +++ b/app/static/js/repeater-manage.js @@ -186,7 +186,7 @@ function renderTools() { col.className = 'col-12 col-sm-6 col-lg-4'; col.innerHTML = `
+ ${locked ? `title="${tHtml('rptmgmt.admin_required')}"` : ''}>
${esc(t(tool.title))}${locked ? ' ' : ''}
@@ -304,8 +304,8 @@ function renderStatusPane(body) { body.innerHTML = `
-
@@ -324,8 +324,8 @@ function setStatusUpdatedLabel() { if (!label) { clearInterval(_statusTimer); _statusTimer = null; return; } const secs = Math.floor((Date.now() - _statusUpdatedAt) / 1000); 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`; + else if (secs < 60) label.textContent = t('rptmgmt.updated_ago', { time: `${secs}s` }); + else label.textContent = t('rptmgmt.updated_ago', { time: fmtDuration(secs) }); }; tick(); _statusTimer = setInterval(tick, 5000); @@ -339,7 +339,7 @@ async function loadStatus() { container.innerHTML = `
-

Requesting status from the repeater…

+

${tHtml('rptmgmt.status.loading')}

`; @@ -358,7 +358,7 @@ async function loadStatus() {

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

`; @@ -393,26 +393,30 @@ function renderStatusTable(container, s) { ? (((s.airtime + s.rx_airtime) / s.uptime) * 100).toFixed(2) + '%' : '—'; - const system = statusSection('System Information', [ - ['Battery', batStr], - ['Uptime', fmtDuration(s.uptime)], - ['Clock', ''], - ['Queue length', fmtInt(s.tx_queue_len)], - ['Debug / error events', fmtInt(s.full_evts)], + // Row labels use t(), not tHtml(): statusSection() runs esc() over the + // title and every label, so a pre-escaped value would be escaped twice. + // "flood" / "direct" inside the values are protocol terms and stay + // English (see docs/translations.md). + const system = statusSection(t('rptmgmt.status.system'), [ + [t('rptmgmt.status.battery'), batStr], + [t('rptmgmt.status.uptime'), fmtDuration(s.uptime)], + [t('rptmgmt.status.clock'), ''], + [t('rptmgmt.status.queue'), fmtInt(s.tx_queue_len)], + [t('rptmgmt.status.events'), fmtInt(s.full_evts)], ]); - const radio = statusSection('Radio Statistics', [ - ['Last RSSI', s.last_rssi != null ? `${s.last_rssi} dBm` : '—'], - ['Last SNR', s.last_snr != null ? `${s.last_snr} dB` : '—'], - ['Noise floor', s.noise_floor != null ? `${s.noise_floor} dBm` : '—'], - ['TX airtime', fmtDuration(s.airtime)], - ['RX airtime', fmtDuration(s.rx_airtime)], + const radio = statusSection(t('rptmgmt.status.radio'), [ + [t('rptmgmt.status.last_rssi'), s.last_rssi != null ? `${s.last_rssi} dBm` : '—'], + [t('rptmgmt.status.last_snr'), s.last_snr != null ? `${s.last_snr} dB` : '—'], + [t('rptmgmt.status.noise'), s.noise_floor != null ? `${s.noise_floor} dBm` : '—'], + [t('rptmgmt.status.tx_airtime'), fmtDuration(s.airtime)], + [t('rptmgmt.status.rx_airtime'), fmtDuration(s.rx_airtime)], ]); - const packets = statusSection('Packet Statistics', [ - ['Sent', `${fmtInt(s.nb_sent)} (flood ${fmtInt(s.sent_flood)} · direct ${fmtInt(s.sent_direct)})`], - ['Received', `${fmtInt(s.nb_recv)} (flood ${fmtInt(s.recv_flood)} · direct ${fmtInt(s.recv_direct)})`], - ['Duplicates', `flood ${fmtInt(s.flood_dups)} · direct ${fmtInt(s.direct_dups)}`], - ...(s.recv_errors != null ? [['RX errors', fmtInt(s.recv_errors)]] : []), - ['Channel utilization', util], + const packets = statusSection(t('rptmgmt.status.packets'), [ + [t('rptmgmt.status.sent'), `${fmtInt(s.nb_sent)} (flood ${fmtInt(s.sent_flood)} · direct ${fmtInt(s.sent_direct)})`], + [t('rptmgmt.status.received'), `${fmtInt(s.nb_recv)} (flood ${fmtInt(s.recv_flood)} · direct ${fmtInt(s.recv_direct)})`], + [t('rptmgmt.status.duplicates'), `flood ${fmtInt(s.flood_dups)} · direct ${fmtInt(s.direct_dups)}`], + ...(s.recv_errors != null ? [[t('rptmgmt.status.rx_errors'), fmtInt(s.recv_errors)]] : []), + [t('rptmgmt.status.utilization'), util], ]); container.innerHTML = system + radio + packets; @@ -478,8 +482,8 @@ function renderTelemetryPane(body) { body.innerHTML = `
-
@@ -496,8 +500,8 @@ function setTelemetryUpdatedLabel() { if (!label) { clearInterval(_telemetryTimer); _telemetryTimer = null; return; } const secs = Math.floor((Date.now() - _telemetryUpdatedAt) / 1000); 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`; + else if (secs < 60) label.textContent = t('rptmgmt.updated_ago', { time: `${secs}s` }); + else label.textContent = t('rptmgmt.updated_ago', { time: fmtDuration(secs) }); }; tick(); _telemetryTimer = setInterval(tick, 5000); @@ -511,8 +515,8 @@ async function loadTelemetry() { container.innerHTML = `
-

Requesting telemetry from the repeater…
- Multi-hop paths can take up to a minute.

+

${tHtml('rptmgmt.tel.loading')}
+ ${tHtml('rptmgmt.tel.loading_hint')}

`; @@ -531,7 +535,7 @@ async function loadTelemetry() {

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

`; @@ -574,7 +578,9 @@ function renderTelemetryCards(container, lpp) { `; }).join(''); // Channel 1 carries the repeater's own vitals (battery, MCU temp) - const chLabel = ch === 1 ? `Channel ${ch} · device` : `Channel ${ch}`; + const chLabel = ch === 1 + ? `${tHtml('rptmgmt.tel.channel', { n: ch })} · ${tHtml('rptmgmt.tel.device_suffix')}` + : tHtml('rptmgmt.tel.channel', { n: ch }); html += `
@@ -626,12 +632,12 @@ function renderCliPane(body) { ).join(''); body.innerHTML = `
-
Commands go to ${esc(_repeater ? _repeater.name : 'the repeater')}. One command at a time — replies travel over the mesh.
+
${tHtml('rptmgmt.cli.intro', { name: _repeater ? _repeater.name : t('rptmgmt.cli.the_repeater') })}
${chips}
-
@@ -777,8 +783,8 @@ async function loadNeighbors() { container.innerHTML = `
-

Requesting neighbours from the repeater…
- Long lists are fetched in pages and can take a while.

+

${tHtml('rptmgmt.neigh.loading')}
+ ${tHtml('rptmgmt.neigh.loading_hint')}

`; @@ -799,7 +805,7 @@ async function loadNeighbors() {

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

`; @@ -825,8 +831,8 @@ function renderNeighborsList() { const entries = data.entries || []; if (countEl) { - let label = `${data.total} neighbor${data.total === 1 ? '' : 's'}`; - if (data.fetched < data.total) label += ` (showing ${data.fetched})`; + let label = tn('rptmgmt.neigh.count', data.total); + if (data.fetched < data.total) label += ` ${t('rptmgmt.neigh.showing', { count: data.fetched })}`; countEl.textContent = label; } @@ -844,7 +850,7 @@ function renderNeighborsList() { ${n.name ? esc(n.name) : `[${esc(n.pubkey_prefix)}]`} - ${n.lat != null ? '' : ''} + ${n.lat != null ? `` : ''} ${fmtHeardAgo(n.secs_ago)} ${n.snr != null ? n.snr + ' dB' : '—'} @@ -864,10 +870,10 @@ function renderNeighborsList() { function fmtHeardAgo(secs) { if (secs == null) return '—'; - if (secs < 60) return `${secs}s ago`; - if (secs < 3600) return `${Math.floor(secs / 60)}m ${secs % 60}s ago`; - if (secs < 86400) return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m ago`; - return `${Math.floor(secs / 86400)}d ${Math.floor((secs % 86400) / 3600)}h ago`; + if (secs < 60) return t('rptmgmt.ago_s', { s: secs }); + if (secs < 3600) return t('rptmgmt.ago_ms', { m: Math.floor(secs / 60), s: secs % 60 }); + if (secs < 86400) return t('rptmgmt.ago_hm', { h: Math.floor(secs / 3600), m: Math.floor((secs % 3600) / 60) }); + return t('rptmgmt.ago_dh', { d: Math.floor(secs / 86400), h: Math.floor((secs % 86400) / 3600) }); } function setNeighborsView(view) { @@ -931,7 +937,7 @@ function renderNeighborsMap() { marker.bindPopup( `${esc(neighborLabel(n))}
` + `SNR: ${n.snr != null ? n.snr + ' dB' : '—'}
` + - `Heard: ${fmtHeardAgo(n.secs_ago)}` + tHtml('rptmgmt.neigh.heard_at', { time: fmtHeardAgo(n.secs_ago) }) ); if (hasCenter) { const line = L.polyline([center, pos], { @@ -980,13 +986,13 @@ async function loadClock() { el.textContent = d.toLocaleString(); } else { el.className = ''; - el.innerHTML = ``; + el.innerHTML = ``; const b = document.getElementById('clockRetryBtn'); if (b) b.addEventListener('click', loadClock); } } catch (e) { el.className = ''; - el.innerHTML = ``; + el.innerHTML = ``; const b = document.getElementById('clockRetryBtn'); if (b) b.addEventListener('click', loadClock); } @@ -1080,14 +1086,14 @@ function settingsControlHtml(f) { if (f.type === 'password') { // Write-only: usable without loading, never prefilled return ``; + placeholder="${tHtml('rptmgmt.set.pw_unchanged_ph')}" autocomplete="new-password">`; } if (f.type === 'radio4') { const subs = [ - ['Frequency (MHz)', 'any'], - ['Bandwidth (kHz)', 'any'], - ['Spreading factor', '1'], - ['Coding rate', '1'], + [tHtml('settings.device.freq'), 'any'], + [tHtml('settings.device.bw'), 'any'], + [tHtml('settings.device.sf'), '1'], + [tHtml('settings.device.cr'), '1'], ].map(([lbl, step], i) => `
@@ -1142,10 +1148,10 @@ function renderSettingsPane(body) { ` : ''}
@@ -1154,10 +1160,7 @@ function renderSettingsPane(body) { }).join(''); body.innerHTML = ` -

- Settings are read live from the repeater. Expand a section to load it — - every field is one mesh round-trip, so a section can take a few seconds. -

+

${tHtml('rptmgmt.set.intro')}

${items}
`; @@ -1279,7 +1282,7 @@ function updateSectionDirty(secKey) { }); const applyBtn = item.querySelector('.sec-apply'); applyBtn.disabled = count === 0 || st.loading || st.applying; - applyBtn.innerHTML = ` Apply${count ? ` (${count})` : ''}`; + applyBtn.innerHTML = ` ${count ? tHtml('rptmgmt.set.apply_count', { count }) : tHtml('common.apply')}`; const badge = item.querySelector('.sec-dirty-badge'); badge.classList.toggle('d-none', count === 0); badge.textContent = count; @@ -1296,8 +1299,8 @@ async function loadSettingsSection(secKey) { const statusEl = item.querySelector('.sec-status'); statusEl.classList.remove('d-none', 'text-danger'); statusEl.classList.add('text-muted'); - statusEl.innerHTML = '' + - 'Reading from repeater… (one mesh round-trip per field)'; + statusEl.innerHTML = '' + + tHtml('rptmgmt.set.reading'); item.querySelector('.sec-refresh').disabled = true; item.querySelector('.sec-apply').disabled = true; const mapPickBtn = item.querySelector('.sec-map-pick'); @@ -1352,7 +1355,7 @@ async function loadSettingsSection(secKey) { if (errCount) { statusEl.classList.remove('text-muted'); statusEl.classList.add('text-danger'); - statusEl.textContent = `${errCount} field${errCount > 1 ? 's' : ''} failed to load — Refresh retries the whole section.`; + statusEl.textContent = tn('rptmgmt.set.load_errors', errCount); } else { statusEl.classList.add('d-none'); } @@ -1381,9 +1384,7 @@ async function applySettingsSection(secKey) { } if ('radio' in dirty && !window.confirm( - `Change radio parameters to ${dirty.radio}?\n\n` + - 'Wrong values can make the repeater unreachable over the mesh. ' + - 'The change takes effect after a reboot.')) { + t('rptmgmt.confirm.radio', { params: dirty.radio }))) { return; } @@ -1637,8 +1638,9 @@ function renderActionsPane(body) { async function runRepeaterAction(action) { if (_actionPending) return; if (action === 'reboot' && !window.confirm( - `Reboot ${(_repeater && _repeater.name) || 'this repeater'}?\n\n` + - 'It will drop off the mesh for a few seconds. The firmware does not reply to this command.')) { + t('rptmgmt.confirm.reboot', { + name: (_repeater && _repeater.name) || t('rptmgmt.this_repeater'), + }))) { return; } @@ -1698,8 +1700,8 @@ async function fetchRepeater() { } async function doLogin(password, save) { - const name = (_repeater && _repeater.name) || 'repeater'; - showLoading(`Logging in to ${name}… (may take up to 60 s on flood paths)`); + const name = (_repeater && _repeater.name) || t('rptmgmt.login.repeater_fallback'); + showLoading(t('rptmgmt.login.progress', { name })); let data = null; try { @@ -1726,24 +1728,24 @@ async function doLogin(password, save) { permissions: data.permissions }; const role = data.is_admin ? 'ADMIN' : 'GUEST'; - showNotification(`Logged in as ${role}`, 'success'); + showNotification(t('rptmgmt.login.logged_in', { role }), 'success'); showPanel(); } else { - const error = (data && data.error) || 'Login failed'; + const error = (data && data.error) || t('rptmgmt.login.failed'); openPasswordModal(error); } } function openPasswordModal(errorHint = '') { // Keep the loading screen behind the modal but stop the spinner text - showLoading('Waiting for password…'); + showLoading(t('rptmgmt.login.waiting')); const name = (_repeater && _repeater.name) || _pubkey.substring(0, 12); - document.getElementById('passwordModalTitle').textContent = `Log in — ${name}`; + document.getElementById('passwordModalTitle').textContent = t('rptmgmt.login.modal_title', { name }); const info = document.getElementById('passwordModalInfo'); info.innerHTML = errorHint - ? `${esc(errorHint)}
Check the password and try again.` - : 'Enter the repeater password to log in.'; + ? `${esc(errorHint)}
${tHtml('rptmgmt.login.retry_hint')}` + : tHtml('rptmgmt.login.prompt'); const input = document.getElementById('passwordInput'); input.value = ''; input.type = 'password'; @@ -1757,7 +1759,7 @@ async function submitPasswordModal() { const input = document.getElementById('passwordInput'); const password = input.value; if (!password) { - showNotification('Password cannot be empty', 'warning'); + showNotification(t('rptmgmt.login.empty_password'), 'warning'); return; } const save = document.getElementById('savePasswordCheck').checked; @@ -1772,7 +1774,7 @@ async function logout() { const response = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/logout`, { method: 'POST' }); const data = await response.json(); if (!data.success) { - showNotification(data.error || 'Logout failed', 'danger'); + showNotification(data.error || t('rptmgmt.logout_failed'), 'danger'); logoutBtn.disabled = false; return; } @@ -1790,11 +1792,11 @@ async function init() { const params = new URLSearchParams(window.location.search); _pubkey = (params.get('pubkey') || '').toLowerCase(); if (!/^[0-9a-f]{64}$/.test(_pubkey)) { - showError('Invalid repeater public key in URL.'); + showError(t('rptmgmt.invalid_pubkey')); return; } - showLoading('Loading…'); + showLoading(t('common.loading')); try { await fetchRepeater(); } catch (e) { @@ -1803,7 +1805,7 @@ async function init() { } if (!_repeater.on_device) { - showError('This repeater is not stored on the device — it cannot be managed.'); + showError(t('rptmgmt.not_on_device')); return; } @@ -1849,9 +1851,9 @@ document.addEventListener('DOMContentLoaded', () => { document.getElementById('copyPubkeyBtn').addEventListener('click', async () => { try { await navigator.clipboard.writeText(_repeater ? _repeater.public_key : _pubkey); - showNotification('Public key copied', 'info'); + showNotification(t('rptmgmt.pubkey_copied'), 'info'); } catch (e) { - showNotification('Copy failed', 'warning'); + showNotification(t('common.copy_failed'), 'warning'); } }); diff --git a/app/static/js/repeaters.js b/app/static/js/repeaters.js index bbb0141..e5bbe5b 100644 --- a/app/static/js/repeaters.js +++ b/app/static/js/repeaters.js @@ -262,7 +262,7 @@ async function doLogin(pubkey, 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') }; } _loginPubkey = null; @@ -989,7 +989,7 @@ async function loadRepeaterMapMarkers() { const data = await response.json(); if (data.success) _repeatersCache = data.repeaters; } catch (e) { - if (countEl) countEl.textContent = 'Failed to load'; + if (countEl) countEl.textContent = t('common.load_failed'); return; } } diff --git a/app/translations/en.json b/app/translations/en.json index 6b35688..f62303c 100644 --- a/app/translations/en.json +++ b/app/translations/en.json @@ -160,6 +160,7 @@ "chat.unnamed_channel": "Channel {index}", "chat.updated": "Updated: {time}", "common.add": "Add", + "common.apply": "Apply", "common.back": "Back", "common.cancel": "Cancel", "common.cannot_undo": "This action cannot be undone.", @@ -168,6 +169,7 @@ "common.connecting": "Connecting...", "common.copied": "Copied!", "common.copy": "Copy", + "common.copy_failed": "Copy failed", "common.copy_route": "Copy route", "common.days_ago": { "one": "{count}d ago", @@ -190,6 +192,7 @@ "other": "{count} hours ago" }, "common.just_now": "just now", + "common.load_failed": "Failed to load", "common.loading": "Loading...", "common.minutes_ago": "{count} min ago", "common.minutes_ago_long": { @@ -245,6 +248,7 @@ "contacts.add.preview": "Preview:", "contacts.add.pubkey": "Public Key (64 hex chars):", "contacts.add.pubkey_ph": "e.g. a1b2c3d4...", + "contacts.add.qr_invalid_uri": "QR code does not contain a valid meshcore:// URI.", "contacts.add.qr_label": "Or upload a QR code image:", "contacts.add.scanned": "Scanned:", "contacts.add.start_camera": "Start Camera", @@ -276,6 +280,9 @@ "contacts.card.add_desc": "Add from URI, QR code, or manual entry", "contacts.card.existing_desc": "Manage all stored contacts", "contacts.card.pending_desc": "Contacts awaiting manual approval", + "contacts.cleanup.auto_enabled": "Enabled (runs daily at {time} {tz})", + "contacts.cleanup.completed": "Cleanup completed: {count} deleted", + "contacts.cleanup.completed_failed": ", {count} failed", "contacts.cleanup.confirm": "Remove all contacts inactive for more than {hours} hours?", "contacts.cleanup.confirm_title": "Confirm Contact Cleanup", "contacts.cleanup.date_field": "Date Field:", @@ -291,6 +298,7 @@ "contacts.cleanup.last_modified": "Last Modified", "contacts.cleanup.name_filter": "Name Filter (optional):", "contacts.cleanup.name_filter_ph": "Enter partial name to search...", + "contacts.cleanup.network_error": "Network error during cleanup", "contacts.cleanup.preview": "Preview Cleanup", "contacts.cleanup.run_at": "Run at:", "contacts.cleanup.status_label": "Status:", @@ -299,6 +307,8 @@ "contacts.cleanup.types": "Contact Types:", "contacts.cleanup.undo_warning": "This action cannot be undone!", "contacts.confirm.block": "Block this contact? Their messages will be hidden from chat.", + "contacts.confirm.push": "Push \"{name}\" to device?", + "contacts.confirm.to_cache": "Move \"{name}\" from device to cache?", "contacts.copy_title": "Click to copy", "contacts.delete.confirm": "Delete Contact", "contacts.delete.question": "Are you sure you want to delete this contact?", @@ -315,6 +325,8 @@ "contacts.filter.blocked": "Blocked", "contacts.filter.cache_only": "Cache only", "contacts.filter.ignored": "Ignored", + "contacts.filter.no_match": "No contacts match filters", + "contacts.filter.no_match_hint": "Try changing your filter criteria", "contacts.filter.on_device": "On device", "contacts.hops": { "one": "{count} hop", @@ -339,6 +351,8 @@ "contacts.pending.none": "No pending requests", "contacts.pending.none_hint": "New contact requests will appear here when manual approval is enabled", "contacts.pending.title": "Pending Contacts", + "contacts.progress.approving": "Approving {done}/{total}...", + "contacts.progress.ignoring": "Ignoring {done}/{total}...", "contacts.protected_cannot_block": "Cannot block protected contact", "contacts.protected_cannot_delete": "Cannot delete protected contact", "contacts.protected_cannot_ignore": "Cannot ignore protected contact", @@ -383,9 +397,11 @@ "contacts.toast.protect_failed": "Failed to update protection: {error}", "contacts.toast.pubkey_copied": "Public key copied to clipboard", "contacts.toast.push_failed": "Failed to push contact", + "contacts.toast.pushed": "{name} pushed to device", "contacts.toast.settings_load_error": "Error loading settings", "contacts.toast.settings_network_error": "Network error saving settings", "contacts.toast.settings_save_failed": "Failed to save settings: {error}", + "contacts.toast.to_cache": "{name} moved to cache", "coord.confirm": "Confirm", "coord.hint": "Click on the map to select coordinates", "coord.title": "Pick Coordinates", @@ -582,6 +598,7 @@ "observer.use_tls": "Use TLS", "observer.username": "Username", "observer.verify_tls": "Verify TLS certificate", + "pa.ambiguous": "ambiguous ({count})", "pa.any_hb": "Any HB", "pa.any_hops": "Any hops", "pa.byte_2_3": "2/3-byte", @@ -617,6 +634,7 @@ "pa.col.time": "Time", "pa.content_ph": "Message text", "pa.content_title": "Filter by message content", + "pa.copy_hash_title": "Copy repeater hash {hash}", "pa.copy_route_title": "Click to copy route", "pa.copy_title": "Click to copy", "pa.counter.filtered": "{shown} of {total} messages", @@ -641,6 +659,7 @@ "pa.empty_no_echoes": "No routed echoes in the current selection.", "pa.empty_range": "No messages in the selected time range.", "pa.filter_by_segment_title": "Filter messages by this segment", + "pa.filter_by_title": "Filter messages by {token}", "pa.filters": "Filters", "pa.filters_toggle_title": "Show/hide filters", "pa.hb_filter_title": "Filter by path hash size (bytes per hop, any routed echo)", @@ -651,9 +670,13 @@ "other": "Last {count} days" }, "pa.loading": "Loading messages…", + "pa.map.alt_path": "Alternative path", + "pa.map.candidate": "Candidate for hop {n}: {token}", "pa.map.clear_title": "Clear drawn path", "pa.map.hint": "Click a message, then an echo to draw its path.", + "pa.map.hop": "Hop {n}: {token}", "pa.map.no_routed": "No messages with routed echoes match the current filters.", + "pa.map.origin": "Origin (sender)", "pa.me": "Me", "pa.n_byte": { "one": "{count}-byte", @@ -664,6 +687,7 @@ "other": "{count} hops" }, "pa.no_path_data": "no path data", + "pa.no_paths_min_hops": "No paths with at least {count} hops in the current selection.", "pa.packet_hash": "Packet hash", "pa.reload_title": "Reload", "pa.repeater_hash": "Repeater hash", @@ -682,6 +706,8 @@ "pa.toast.load_failed": "Failed to load messages: {error}", "pa.toast.msg_gone": "This message is no longer in the analyzer data (max 7 days).", "pa.toast.no_echoes": "This message has no routed echoes to draw.", + "pa.toggle_all_repeaters": "All repeaters", + "pa.toggle_alt_paths": "Alternative paths", "pa.token_ph": "Repeater (3B or name)", "pa.token_title": "Match a repeater anywhere in a path: hash prefix (hex) or contact name. Chain with '>' for a consecutive sequence, e.g. AFE6>6E9A", "pa.undo_pick_title": "Undo this manual assignment", @@ -828,30 +854,67 @@ "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.admin_required": "Admin login required", + "rptmgmt.ago_dh": "{d}d {h}h ago", + "rptmgmt.ago_hm": "{h}h {m}m ago", + "rptmgmt.ago_ms": "{m}m {s}s ago", + "rptmgmt.ago_s": "{s}s ago", "rptmgmt.back_title": "Back to My Repeaters", "rptmgmt.back_to_list": "Back to list", "rptmgmt.back_tools_title": "Back to tools", + "rptmgmt.cli.empty_reply": "(empty reply)", + "rptmgmt.cli.input_ph": "Enter command (e.g. get name)", + "rptmgmt.cli.intro": "Commands go to {name}. One command at a time — replies travel over the mesh.", + "rptmgmt.cli.the_repeater": "the repeater", + "rptmgmt.cli.waiting": "Waiting for reply…", "rptmgmt.command_failed": "Command failed", + "rptmgmt.confirm.radio": "Change radio parameters to {params}?\n\nWrong values can make the repeater unreachable over the mesh. The change takes effect after a reboot.", + "rptmgmt.confirm.reboot": "Reboot {name}?\n\nIt will drop off the mesh for a few seconds. The firmware does not reply to this command.", "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.invalid_pubkey": "Invalid repeater public key in URL.", "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.empty_password": "Password cannot be empty", + "rptmgmt.login.failed": "Login failed", + "rptmgmt.login.logged_in": "Logged in as {role}", + "rptmgmt.login.modal_title": "Log in — {name}", + "rptmgmt.login.progress": "Logging in to {name}… (may take up to 60 s on flood paths)", + "rptmgmt.login.prompt": "Enter the repeater password to log in.", + "rptmgmt.login.repeater_fallback": "repeater", + "rptmgmt.login.retry_hint": "Check the password and try again.", + "rptmgmt.login.waiting": "Waiting for password…", "rptmgmt.login_request_failed": "Login request failed", "rptmgmt.logout": "Logout", + "rptmgmt.logout_failed": "Logout failed", "rptmgmt.logout_title": "Log out of this repeater", "rptmgmt.managed_repeater": "managed repeater", + "rptmgmt.neigh.count": { + "one": "{count} neighbor", + "other": "{count} neighbors" + }, "rptmgmt.neigh.heard": "Heard", + "rptmgmt.neigh.heard_at": "Heard: {time}", + "rptmgmt.neigh.list": "List", + "rptmgmt.neigh.loading": "Requesting neighbours from the repeater…", + "rptmgmt.neigh.loading_hint": "Long lists are fetched in pages and can take a while.", + "rptmgmt.neigh.map": "Map", + "rptmgmt.neigh.position_title": "Position known", + "rptmgmt.neigh.refresh_title": "Refresh neighbours", "rptmgmt.neigh.repeater": "Repeater", + "rptmgmt.neigh.showing": "(showing {count})", "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.not_on_device": "This repeater is not stored on the device — it cannot be managed.", + "rptmgmt.pubkey_copied": "Public key copied", "rptmgmt.read_failed": "Failed to read", "rptmgmt.reboot_required": "reboot required", "rptmgmt.request_failed": "Request failed", @@ -862,6 +925,7 @@ "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.apply_count": "Apply ({count})", "rptmgmt.set.basic": "Basic", "rptmgmt.set.direct_txdelay": "Direct TX delay factor (0–2)", "rptmgmt.set.dutycycle": "Duty cycle (%)", @@ -871,7 +935,12 @@ "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.intro": "Settings are read live from the repeater. Expand a section to load it — every field is one mesh round-trip, so a section can take a few seconds.", "rptmgmt.set.lat": "Latitude", + "rptmgmt.set.load_errors": { + "one": "{count} field failed to load — Refresh retries the whole section.", + "other": "{count} fields failed to load — Refresh retries the whole section." + }, "rptmgmt.set.location": "Location", "rptmgmt.set.lon": "Longitude", "rptmgmt.set.loop_detect": "Loop detection", @@ -886,9 +955,11 @@ "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 (0–2)", + "rptmgmt.set.pw_unchanged_ph": "(unchanged)", "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.reading": "Reading from repeater… (one mesh round-trip per field)", "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", @@ -896,7 +967,33 @@ "rptmgmt.set.txdelay": "TX delay factor (0–2)", "rptmgmt.settings_apply_failed": "Failed to apply settings", "rptmgmt.settings_read_failed": "Failed to read settings", + "rptmgmt.status.battery": "Battery", + "rptmgmt.status.clock": "Clock", + "rptmgmt.status.clock_fetch": "fetch", + "rptmgmt.status.duplicates": "Duplicates", + "rptmgmt.status.events": "Debug / error events", + "rptmgmt.status.last_rssi": "Last RSSI", + "rptmgmt.status.last_snr": "Last SNR", + "rptmgmt.status.loading": "Requesting status from the repeater…", + "rptmgmt.status.noise": "Noise floor", + "rptmgmt.status.packets": "Packet Statistics", + "rptmgmt.status.queue": "Queue length", + "rptmgmt.status.radio": "Radio Statistics", + "rptmgmt.status.received": "Received", + "rptmgmt.status.refresh_title": "Refresh status", + "rptmgmt.status.rx_airtime": "RX airtime", + "rptmgmt.status.rx_errors": "RX errors", + "rptmgmt.status.sent": "Sent", + "rptmgmt.status.system": "System Information", + "rptmgmt.status.tx_airtime": "TX airtime", + "rptmgmt.status.uptime": "Uptime", + "rptmgmt.status.utilization": "Channel utilization", "rptmgmt.status_failed": "Failed to get status", + "rptmgmt.tel.channel": "Channel {n}", + "rptmgmt.tel.device_suffix": "device", + "rptmgmt.tel.loading": "Requesting telemetry from the repeater…", + "rptmgmt.tel.loading_hint": "Multi-hop paths can take up to a minute.", + "rptmgmt.tel.refresh_title": "Refresh telemetry", "rptmgmt.telemetry_failed": "Failed to get telemetry", "rptmgmt.telemetry_none": "No telemetry data reported.", "rptmgmt.this_repeater": "This repeater", @@ -924,6 +1021,7 @@ "rptmgmt.tools.telemetry": "Telemetry", "rptmgmt.tools.telemetry_desc": "Sensor channels (Cayenne LPP)", "rptmgmt.tools_heading": "Management Tools", + "rptmgmt.updated_ago": "Updated {time} ago", "rptmgmt.updated_just_now": "Updated just now", "search.count": { "one": "{count} result", diff --git a/app/translations/pl.json b/app/translations/pl.json index 975001c..5c51f9e 100644 --- a/app/translations/pl.json +++ b/app/translations/pl.json @@ -162,6 +162,7 @@ "chat.unnamed_channel": "Kanał {index}", "chat.updated": "Aktualizacja: {time}", "common.add": "Dodaj", + "common.apply": "Zastosuj", "common.back": "Wstecz", "common.cancel": "Anuluj", "common.cannot_undo": "Tej operacji nie można cofnąć.", @@ -170,6 +171,7 @@ "common.connecting": "Łączenie...", "common.copied": "Skopiowano!", "common.copy": "Kopiuj", + "common.copy_failed": "Kopiowanie nie powiodło się", "common.copy_route": "Kopiuj trasę", "common.days_ago": { "one": "{count} dzień temu", @@ -198,6 +200,7 @@ "other": "{count} godziny temu" }, "common.just_now": "przed chwilą", + "common.load_failed": "Nie udało się wczytać", "common.loading": "Ładowanie...", "common.minutes_ago": "{count} min temu", "common.minutes_ago_long": { @@ -259,6 +262,7 @@ "contacts.add.preview": "Podgląd:", "contacts.add.pubkey": "Klucz publiczny (64 znaki hex):", "contacts.add.pubkey_ph": "np. a1b2c3d4...", + "contacts.add.qr_invalid_uri": "Kod QR nie zawiera prawidłowego URI meshcore://.", "contacts.add.qr_label": "Albo wgraj obraz z kodem QR:", "contacts.add.scanned": "Zeskanowano:", "contacts.add.start_camera": "Włącz kamerę", @@ -290,6 +294,9 @@ "contacts.card.add_desc": "Dodaj z URI, kodu QR lub ręcznie", "contacts.card.existing_desc": "Zarządzaj wszystkimi zapisanymi kontaktami", "contacts.card.pending_desc": "Kontakty oczekujące na ręczne zatwierdzenie", + "contacts.cleanup.auto_enabled": "Włączone (codziennie o {time} {tz})", + "contacts.cleanup.completed": "Czyszczenie zakończone: usunięto {count}", + "contacts.cleanup.completed_failed": ", nieudanych: {count}", "contacts.cleanup.confirm": "Usunąć wszystkie kontakty nieaktywne dłużej niż {hours} godz.?", "contacts.cleanup.confirm_title": "Potwierdź czyszczenie kontaktów", "contacts.cleanup.date_field": "Pole daty:", @@ -305,6 +312,7 @@ "contacts.cleanup.last_modified": "Ostatnia zmiana", "contacts.cleanup.name_filter": "Filtr nazwy (opcjonalnie):", "contacts.cleanup.name_filter_ph": "Wpisz fragment nazwy do wyszukania...", + "contacts.cleanup.network_error": "Błąd sieci podczas czyszczenia", "contacts.cleanup.preview": "Podgląd czyszczenia", "contacts.cleanup.run_at": "Uruchom o:", "contacts.cleanup.status_label": "Status:", @@ -313,6 +321,8 @@ "contacts.cleanup.types": "Typy kontaktów:", "contacts.cleanup.undo_warning": "Tej operacji nie można cofnąć!", "contacts.confirm.block": "Zablokować ten kontakt? Jego wiadomości będą ukryte w czacie.", + "contacts.confirm.push": "Wysłać \"{name}\" na urządzenie?", + "contacts.confirm.to_cache": "Przenieść \"{name}\" z urządzenia do pamięci?", "contacts.copy_title": "Kliknij, aby skopiować", "contacts.delete.confirm": "Usuń kontakt", "contacts.delete.question": "Czy na pewno chcesz usunąć ten kontakt?", @@ -329,6 +339,8 @@ "contacts.filter.blocked": "Zablokowane", "contacts.filter.cache_only": "Tylko w pamięci", "contacts.filter.ignored": "Ignorowane", + "contacts.filter.no_match": "Żaden kontakt nie pasuje do filtrów", + "contacts.filter.no_match_hint": "Spróbuj zmienić kryteria filtrowania", "contacts.filter.on_device": "Na urządzeniu", "contacts.hops": { "one": "{count} hop", @@ -355,6 +367,8 @@ "contacts.pending.none": "Brak oczekujących zgłoszeń", "contacts.pending.none_hint": "Nowe zgłoszenia kontaktów pojawią się tutaj, gdy włączone jest ręczne zatwierdzanie", "contacts.pending.title": "Oczekujące kontakty", + "contacts.progress.approving": "Zatwierdzanie {done}/{total}...", + "contacts.progress.ignoring": "Ignorowanie {done}/{total}...", "contacts.protected_cannot_block": "Nie można zablokować chronionego kontaktu", "contacts.protected_cannot_delete": "Nie można usunąć chronionego kontaktu", "contacts.protected_cannot_ignore": "Nie można zignorować chronionego kontaktu", @@ -403,9 +417,11 @@ "contacts.toast.protect_failed": "Nie udało się zmienić ochrony: {error}", "contacts.toast.pubkey_copied": "Klucz publiczny skopiowany do schowka", "contacts.toast.push_failed": "Nie udało się wysłać kontaktu", + "contacts.toast.pushed": "{name} wysłany na urządzenie", "contacts.toast.settings_load_error": "Błąd wczytywania ustawień", "contacts.toast.settings_network_error": "Błąd sieci przy zapisie ustawień", "contacts.toast.settings_save_failed": "Nie udało się zapisać ustawień: {error}", + "contacts.toast.to_cache": "{name} przeniesiony do pamięci", "coord.confirm": "Zatwierdź", "coord.hint": "Kliknij na mapie, aby wybrać współrzędne", "coord.title": "Wybierz współrzędne", @@ -614,6 +630,7 @@ "observer.use_tls": "Użyj TLS", "observer.username": "Nazwa użytkownika", "observer.verify_tls": "Weryfikuj certyfikat TLS", + "pa.ambiguous": "niejednoznaczne ({count})", "pa.any_hb": "Dowolne HB", "pa.any_hops": "Dowolne hopy", "pa.byte_2_3": "2/3 bajty", @@ -651,6 +668,7 @@ "pa.col.time": "Czas", "pa.content_ph": "Treść wiadomości", "pa.content_title": "Filtruj po treści wiadomości", + "pa.copy_hash_title": "Kopiuj hash repeatera {hash}", "pa.copy_route_title": "Kliknij, aby skopiować trasę", "pa.copy_title": "Kliknij, aby skopiować", "pa.counter.filtered": "{shown} z {total} wiadomości", @@ -683,6 +701,7 @@ "pa.empty_no_echoes": "Brak ech z trasą w bieżącym wyborze.", "pa.empty_range": "Brak wiadomości w wybranym zakresie czasu.", "pa.filter_by_segment_title": "Filtruj wiadomości po tym segmencie", + "pa.filter_by_title": "Filtruj wiadomości według {token}", "pa.filters": "Filtry", "pa.filters_toggle_title": "Pokaż/ukryj filtry", "pa.hb_filter_title": "Filtruj po rozmiarze hasha ścieżki (bajtów na hop, dowolne echo z trasą)", @@ -695,9 +714,13 @@ "other": "Ostatnie {count} dnia" }, "pa.loading": "Wczytywanie wiadomości…", + "pa.map.alt_path": "Ścieżka alternatywna", + "pa.map.candidate": "Kandydat na hop {n}: {token}", "pa.map.clear_title": "Wyczyść narysowaną ścieżkę", "pa.map.hint": "Kliknij wiadomość, a potem echo, aby narysować jej ścieżkę.", + "pa.map.hop": "Hop {n}: {token}", "pa.map.no_routed": "Żadna wiadomość z echami z trasą nie pasuje do bieżących filtrów.", + "pa.map.origin": "Źródło (nadawca)", "pa.me": "Ja", "pa.n_byte": { "one": "{count} bajt", @@ -712,6 +735,7 @@ "other": "{count} hopa" }, "pa.no_path_data": "brak danych o ścieżce", + "pa.no_paths_min_hops": "Brak ścieżek o co najmniej {count} hopach w bieżącym wyborze.", "pa.packet_hash": "Hash pakietu", "pa.reload_title": "Odśwież", "pa.repeater_hash": "Hash repeatera", @@ -730,6 +754,8 @@ "pa.toast.load_failed": "Nie udało się wczytać wiadomości: {error}", "pa.toast.msg_gone": "Tej wiadomości nie ma już w danych analizatora (maks. 7 dni).", "pa.toast.no_echoes": "Ta wiadomość nie ma ech z trasą do narysowania.", + "pa.toggle_all_repeaters": "Wszystkie repeatery", + "pa.toggle_alt_paths": "Ścieżki alternatywne", "pa.token_ph": "Repeater (3B lub nazwa)", "pa.token_title": "Dopasuj repeater w dowolnym miejscu ścieżki: prefiks hasha (hex) lub nazwa kontaktu. Połącz znakiem '>', aby wskazać kolejność, np. AFE6>6E9A", "pa.undo_pick_title": "Cofnij to ręczne przypisanie", @@ -890,30 +916,69 @@ "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.admin_required": "Wymagane logowanie administratora", + "rptmgmt.ago_dh": "{d}d {h}h temu", + "rptmgmt.ago_hm": "{h}h {m}m temu", + "rptmgmt.ago_ms": "{m}m {s}s temu", + "rptmgmt.ago_s": "{s}s temu", "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.cli.empty_reply": "(pusta odpowiedź)", + "rptmgmt.cli.input_ph": "Wpisz polecenie (np. get name)", + "rptmgmt.cli.intro": "Polecenia trafiają do {name}. Jedno polecenie naraz — odpowiedzi wędrują przez sieć mesh.", + "rptmgmt.cli.the_repeater": "repeatera", + "rptmgmt.cli.waiting": "Czekam na odpowiedź…", "rptmgmt.command_failed": "Polecenie nie powiodło się", + "rptmgmt.confirm.radio": "Zmienić parametry radia na {params}?\n\nBłędne wartości mogą sprawić, że repeater stanie się nieosiągalny w sieci mesh. Zmiana działa po restarcie.", + "rptmgmt.confirm.reboot": "Zrestartować {name}?\n\nRepeater zniknie z sieci mesh na kilka sekund. Firmware nie odpowiada na to polecenie.", "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.invalid_pubkey": "Nieprawidłowy klucz publiczny repeatera w adresie URL.", "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.empty_password": "Hasło nie może być puste", + "rptmgmt.login.failed": "Logowanie nie powiodło się", + "rptmgmt.login.logged_in": "Zalogowano jako {role}", + "rptmgmt.login.modal_title": "Zaloguj się — {name}", + "rptmgmt.login.progress": "Logowanie do {name}… (na ścieżkach flood może potrwać do 60 s)", + "rptmgmt.login.prompt": "Podaj hasło repeatera, aby się zalogować.", + "rptmgmt.login.repeater_fallback": "repeatera", + "rptmgmt.login.retry_hint": "Sprawdź hasło i spróbuj ponownie.", + "rptmgmt.login.waiting": "Czekam na hasło…", "rptmgmt.login_request_failed": "Żądanie logowania nie powiodło się", "rptmgmt.logout": "Wyloguj", + "rptmgmt.logout_failed": "Wylogowanie nie powiodło się", "rptmgmt.logout_title": "Wyloguj się z tego repeatera", "rptmgmt.managed_repeater": "zarządzany repeater", + "rptmgmt.neigh.count": { + "one": "{count} sąsiad", + "few": "{count} sąsiadów", + "many": "{count} sąsiadów", + "other": "{count} sąsiada" + }, "rptmgmt.neigh.heard": "Słyszany", + "rptmgmt.neigh.heard_at": "Usłyszany: {time}", + "rptmgmt.neigh.list": "Lista", + "rptmgmt.neigh.loading": "Pobieranie sąsiadów z repeatera…", + "rptmgmt.neigh.loading_hint": "Długie listy są pobierane stronami i może to chwilę potrwać.", + "rptmgmt.neigh.map": "Mapa", + "rptmgmt.neigh.position_title": "Pozycja znana", + "rptmgmt.neigh.refresh_title": "Odśwież sąsiadów", "rptmgmt.neigh.repeater": "Repeater", + "rptmgmt.neigh.showing": "(pokazano {count})", "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.not_on_device": "Ten repeater nie jest zapisany na urządzeniu — nie można nim zarządzać.", + "rptmgmt.pubkey_copied": "Klucz publiczny skopiowany", "rptmgmt.read_failed": "Nie udało się odczytać", "rptmgmt.reboot_required": "wymagany restart", "rptmgmt.request_failed": "Żądanie nie powiodło się", @@ -924,6 +989,7 @@ "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.apply_count": "Zastosuj ({count})", "rptmgmt.set.basic": "Podstawowe", "rptmgmt.set.direct_txdelay": "Współczynnik opóźnienia TX direct (0–2)", "rptmgmt.set.dutycycle": "Duty cycle (%)", @@ -933,7 +999,14 @@ "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.intro": "Ustawienia są odczytywane na żywo z repeatera. Rozwiń sekcję, aby ją wczytać — każde pole to jedna podróż przez sieć mesh, więc sekcja może się ładować kilka sekund.", "rptmgmt.set.lat": "Szerokość geogr.", + "rptmgmt.set.load_errors": { + "one": "Nie udało się wczytać {count} pola — Odśwież ponawia całą sekcję.", + "few": "Nie udało się wczytać {count} pól — Odśwież ponawia całą sekcję.", + "many": "Nie udało się wczytać {count} pól — Odśwież ponawia całą sekcję.", + "other": "Nie udało się wczytać {count} pola — Odśwież ponawia całą sekcję." + }, "rptmgmt.set.location": "Lokalizacja", "rptmgmt.set.lon": "Długość geogr.", "rptmgmt.set.loop_detect": "Wykrywanie pętli", @@ -948,9 +1021,11 @@ "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 (0–2)", + "rptmgmt.set.pw_unchanged_ph": "(bez zmian)", "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.reading": "Odczyt z repeatera… (jedna podróż przez sieć na pole)", "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)", @@ -958,7 +1033,33 @@ "rptmgmt.set.txdelay": "Współczynnik opóźnienia TX (0–2)", "rptmgmt.settings_apply_failed": "Nie udało się zastosować ustawień", "rptmgmt.settings_read_failed": "Nie udało się odczytać ustawień", + "rptmgmt.status.battery": "Bateria", + "rptmgmt.status.clock": "Zegar", + "rptmgmt.status.clock_fetch": "pobierz", + "rptmgmt.status.duplicates": "Duplikaty", + "rptmgmt.status.events": "Zdarzenia debug / błędów", + "rptmgmt.status.last_rssi": "Ostatni RSSI", + "rptmgmt.status.last_snr": "Ostatni SNR", + "rptmgmt.status.loading": "Pobieranie statusu z repeatera…", + "rptmgmt.status.noise": "Poziom szumów", + "rptmgmt.status.packets": "Statystyki pakietów", + "rptmgmt.status.queue": "Długość kolejki", + "rptmgmt.status.radio": "Statystyki radia", + "rptmgmt.status.received": "Odebrane", + "rptmgmt.status.refresh_title": "Odśwież status", + "rptmgmt.status.rx_airtime": "Czas antenowy RX", + "rptmgmt.status.rx_errors": "Błędy RX", + "rptmgmt.status.sent": "Wysłane", + "rptmgmt.status.system": "Informacje systemowe", + "rptmgmt.status.tx_airtime": "Czas antenowy TX", + "rptmgmt.status.uptime": "Czas pracy", + "rptmgmt.status.utilization": "Wykorzystanie kanału", "rptmgmt.status_failed": "Nie udało się pobrać statusu", + "rptmgmt.tel.channel": "Kanał {n}", + "rptmgmt.tel.device_suffix": "urządzenie", + "rptmgmt.tel.loading": "Pobieranie telemetrii z repeatera…", + "rptmgmt.tel.loading_hint": "Ścieżki wielohopowe mogą zająć nawet minutę.", + "rptmgmt.tel.refresh_title": "Odśwież telemetrię", "rptmgmt.telemetry_failed": "Nie udało się pobrać telemetrii", "rptmgmt.telemetry_none": "Brak danych telemetrycznych.", "rptmgmt.this_repeater": "Ten repeater", @@ -988,6 +1089,7 @@ "rptmgmt.tools.telemetry": "Telemetria", "rptmgmt.tools.telemetry_desc": "Kanały czujników (Cayenne LPP)", "rptmgmt.tools_heading": "Narzędzia zarządzania", + "rptmgmt.updated_ago": "Zaktualizowano {time} temu", "rptmgmt.updated_just_now": "Zaktualizowano przed chwilą", "search.count": { "one": "{count} wynik",