feat(i18n): translate the Path Analyzer panel (stage 4)

~90 keys across path-analyzer.html and path-analyzer.js. Catalog now 333 keys,
pl at 100%.

Covers the four view buttons, the whole filter bar, all four table layouts with
their explanatory column tooltips, the map sidebar, and the per-view counters.

The counters were the fiddly part: four of them, each combining a filtered and an
unfiltered form, and each mixing two independently pluralised nouns —
"12 repeaters (340 of 977 messages)". They are now composed from tn() parts, so
Polish gets "12 repeaterów (340 z 977 wiadomości)" with the right form on both.

Not translated, deliberately: message text, sender names, channel names, packet
and repeater hashes, SNR figures, and the hop chains — all device or user data.
The diff tool's exclusions for this page name the four table bodies that carry it.

The diff caught four filter options I had skipped (1-byte / 2-byte / 3-byte /
2/3-byte) because my template edit only replaced the "Any HB" line above them.
Its one remaining hit is "1 hop", which is correct: Polish "one" is identical to
the English singular here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-07-31 19:15:25 +02:00
parent 92117668fb
commit fa0e3894d5
4 changed files with 347 additions and 101 deletions
+38 -36
View File
@@ -196,15 +196,15 @@ function paFormatTime(msg) {
function paCopyText(text, label) {
navigator.clipboard.writeText(text).then(
() => showNotification(`${label} copied to clipboard`, 'success'),
() => showNotification(`Failed to copy ${label}`, 'danger')
() => showNotification(t('pa.toast.copied', { what: label }), 'success'),
() => showNotification(t('pa.toast.copy_failed', { what: label }), 'danger')
);
}
function paBuildEchoLine(msg, echo, echoIdx) {
const line = document.createElement('div');
line.className = 'pa-echo-line';
line.title = 'Click to copy route';
line.title = t('pa.copy_route_title');
const dirBadge = document.createElement('span');
dirBadge.className = 'badge ' + (echo.direction === 'outgoing' ? 'text-bg-primary' : 'text-bg-secondary');
@@ -214,7 +214,7 @@ function paBuildEchoLine(msg, echo, echoIdx) {
if (echo.hops === 0) {
const direct = document.createElement('span');
direct.className = 'pa-direct';
direct.textContent = 'Direct (flood, 0 hops)';
direct.textContent = t('pa.direct_flood');
line.appendChild(direct);
} else {
echo.tokens.forEach((tok, i) => {
@@ -229,7 +229,7 @@ function paBuildEchoLine(msg, echo, echoIdx) {
chip.title = `Copy repeater hash ${tok}`;
chip.addEventListener('click', (e) => {
e.stopPropagation();
paCopyText(tok, 'Repeater hash');
paCopyText(tok, t('pa.repeater_hash'));
});
line.appendChild(chip);
});
@@ -244,7 +244,7 @@ function paBuildEchoLine(msg, echo, echoIdx) {
if (echo.hops > 0) {
const mapBtn = document.createElement('i');
mapBtn.className = 'bi bi-map pa-echo-mapbtn';
mapBtn.title = 'Show this path on the map';
mapBtn.title = t('pa.show_on_map_title');
mapBtn.addEventListener('click', (e) => {
e.stopPropagation();
paMapSelection = { msgId: msg.id, echoIdx: echoIdx };
@@ -286,7 +286,9 @@ function paRenderTable() {
tr.appendChild(tdChannel);
const tdSender = document.createElement('td');
tdSender.textContent = msg.is_own ? `${msg.sender || 'Me'} (own)` : (msg.sender || '—');
tdSender.textContent = msg.is_own
? t('pa.sender_own', { name: msg.sender || t('pa.me') })
: (msg.sender || '—');
tr.appendChild(tdSender);
const tdContent = document.createElement('td');
@@ -303,14 +305,14 @@ function paRenderTable() {
const span = document.createElement('span');
span.className = 'pa-hash';
span.textContent = msg.packet_hash;
span.title = 'Click to copy';
span.title = t('pa.copy_title');
span.addEventListener('click', (e) => {
e.stopPropagation();
paCopyText(msg.packet_hash, 'Packet hash');
paCopyText(msg.packet_hash, t('pa.packet_hash'));
});
tdHash.appendChild(span);
} else {
tdHash.innerHTML = '<span class="text-muted small">no path data</span>';
tdHash.innerHTML = `<span class="text-muted small">${tHtml('pa.no_path_data')}</span>`;
}
tr.appendChild(tdHash);
@@ -345,10 +347,10 @@ function paRenderTable() {
const hashSpan = document.createElement('span');
hashSpan.className = 'pa-hash';
hashSpan.textContent = msg.packet_hash;
hashSpan.title = 'Click to copy';
hashSpan.title = t('pa.copy_title');
hashSpan.addEventListener('click', (e) => {
e.stopPropagation();
paCopyText(msg.packet_hash, 'Packet hash');
paCopyText(msg.packet_hash, t('pa.packet_hash'));
});
hashLine.append('Hash: ');
hashLine.appendChild(hashSpan);
@@ -370,13 +372,13 @@ function paRenderTable() {
const counter = document.getElementById('paCounter');
counter.textContent = paFiltersActive()
? `${filtered.length} of ${paMessages.length} messages`
: `${paMessages.length} message${paMessages.length === 1 ? '' : 's'}`;
? t('pa.counter.filtered', { shown: filtered.length, total: paMessages.length })
: tn('pa.counter.messages', paMessages.length);
// Empty state when filters exclude everything
if (paMessages.length > 0) {
if (filtered.length === 0) {
document.getElementById('paEmptyText').textContent = 'No messages match the current filters.';
document.getElementById('paEmptyText').textContent = t('pa.empty_filtered');
paSetView('empty');
} else {
paSetView('table');
@@ -495,13 +497,13 @@ function paRenderStats() {
const counter = document.getElementById('paCounter');
counter.textContent = paFiltersActive()
? `${rows.length} repeaters (${filtered.length} of ${paMessages.length} messages)`
: `${rows.length} repeaters (${paMessages.length} messages)`;
? `${tn('pa.counter.repeaters', rows.length)} (${t('pa.counter.filtered', { shown: filtered.length, total: paMessages.length })})`
: `${tn('pa.counter.repeaters', rows.length)} (${tn('pa.counter.messages', paMessages.length)})`;
if (rows.length === 0) {
document.getElementById('paEmptyText').textContent =
filtered.length === 0 ? 'No messages match the current filters.'
: 'No routed echoes in the current selection.';
filtered.length === 0 ? t('pa.empty_filtered')
: t('pa.empty_no_echoes');
paSetView('empty');
} else {
paSetView('stats');
@@ -561,7 +563,7 @@ function paRenderRoutes() {
for (const r of rows) {
const tr = document.createElement('tr');
tr.className = 'pa-stats-row';
tr.title = 'Filter messages by this segment';
tr.title = t('pa.filter_by_segment_title');
const tdRoute = document.createElement('td');
const hashLine = document.createElement('div');
@@ -602,12 +604,12 @@ function paRenderRoutes() {
const counter = document.getElementById('paCounter');
counter.textContent = paFiltersActive()
? `${rows.length} segments (${filtered.length} of ${paMessages.length} messages)`
: `${rows.length} segments (${paMessages.length} messages)`;
? `${tn('pa.counter.segments', rows.length)} (${t('pa.counter.filtered', { shown: filtered.length, total: paMessages.length })})`
: `${tn('pa.counter.segments', rows.length)} (${tn('pa.counter.messages', paMessages.length)})`;
if (rows.length === 0) {
document.getElementById('paEmptyText').textContent =
filtered.length === 0 ? 'No messages match the current filters.'
filtered.length === 0 ? t('pa.empty_filtered')
: `No paths with at least ${n} hops in the current selection.`;
paSetView('empty');
} else {
@@ -850,8 +852,8 @@ function paBuildLegend(echo) {
resetRow.className = 'text-end';
const resetBtn = document.createElement('button');
resetBtn.className = 'btn btn-sm btn-outline-warning py-0 pa-reset-picks';
resetBtn.innerHTML = '<i class="bi bi-arrow-counterclockwise"></i> Reset picks';
resetBtn.title = 'Clear all manual repeater assignments on this path';
resetBtn.innerHTML = `<i class="bi bi-arrow-counterclockwise"></i> ${tHtml('pa.reset_picks')}`;
resetBtn.title = t('pa.reset_picks_title');
resetBtn.addEventListener('click', (e) => {
e.stopPropagation();
echo.tokens.forEach(tok => { delete paPicks[tok]; });
@@ -876,7 +878,7 @@ function paBuildLegend(echo) {
// Manually assigned - allow undoing just this hop
const undo = document.createElement('i');
undo.className = 'bi bi-x-circle pa-unpick';
undo.title = 'Undo this manual assignment';
undo.title = t('pa.undo_pick_title');
undo.addEventListener('click', (e) => {
e.stopPropagation();
delete paPicks[tok];
@@ -887,13 +889,13 @@ function paBuildLegend(echo) {
} else if (candidates.length > 1) {
const amb = document.createElement('span');
amb.className = 'pa-ambiguous';
amb.textContent = `${candidates.length} candidates:`;
amb.textContent = '— ' + tn('pa.candidates', candidates.length) + ':';
row.appendChild(amb);
candidates.forEach(c => {
const chip = document.createElement('span');
chip.className = 'pa-pick-chip' + (paPicks[tok] === c.public_key ? ' pa-picked' : '');
chip.textContent = c.name || c.public_key.slice(0, 8);
chip.title = 'Use this contact for the hop';
chip.title = t('pa.use_contact_title');
chip.addEventListener('click', (e) => {
e.stopPropagation();
paPicks[tok] = c.public_key;
@@ -904,7 +906,7 @@ function paBuildLegend(echo) {
} else {
const unk = document.createElement('span');
unk.className = 'pa-ambiguous';
unk.textContent = '— unknown / no position';
unk.textContent = t('pa.unknown_position');
row.appendChild(unk);
}
legend.appendChild(row);
@@ -944,7 +946,7 @@ function paRenderMapView(fit = true) {
}
if (filtered.length === 0) {
list.innerHTML = '<div class="small text-muted p-2">No messages with routed echoes match the current filters.</div>';
list.innerHTML = `<div class="small text-muted p-2">${tHtml('pa.map.no_routed')}</div>`;
}
for (const msg of filtered) {
@@ -1032,8 +1034,8 @@ function paRenderMapView(fit = true) {
const counter = document.getElementById('paCounter');
counter.textContent = paFiltersActive()
? `${filtered.length} of ${paMessages.length} messages`
: `${filtered.length} routed message${filtered.length === 1 ? '' : 's'}`;
? t('pa.counter.filtered', { shown: filtered.length, total: paMessages.length })
: tn('pa.counter.routed', filtered.length);
}
function paClearMapSelection() {
@@ -1086,12 +1088,12 @@ async function paLoadMessages() {
});
} catch (e) {
console.error('Failed to load messages:', e);
showNotification(`Failed to load messages: ${e.message}`, 'danger');
showNotification(t('pa.toast.load_failed', { error: e.message }), 'danger');
paMessages = [];
}
if (paMessages.length === 0) {
document.getElementById('paEmptyText').textContent = 'No messages in the selected time range.';
document.getElementById('paEmptyText').textContent = t('pa.empty_range');
paSetView('empty');
} else {
paRender();
@@ -1118,7 +1120,7 @@ async function paApplyDeepLink() {
return;
}
paDeepLink = null;
showNotification('This message is no longer in the analyzer data (max 7 days).', 'warning');
showNotification(t('pa.toast.msg_gone'), 'warning');
return;
}
@@ -1129,7 +1131,7 @@ async function paApplyDeepLink() {
e.hops > 0 && (e.path || '').toLowerCase() === (dl.path || '').toLowerCase());
if (echoIdx === -1) echoIdx = paShortestEchoIdx(msg);
if (echoIdx === null) {
showNotification('This message has no routed echoes to draw.', 'warning');
showNotification(t('pa.toast.no_echoes'), 'warning');
return;
}
+65 -65
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>Path Analyzer - mc-webui</title>
<title>{{ t('pa.title') }} - mc-webui</title>
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
<script>
@@ -550,64 +550,64 @@
<body>
<!-- Toolbar -->
<div class="pa-toolbar">
<div class="btn-group btn-group-sm" role="group" aria-label="View">
<button id="paViewMessagesBtn" class="btn btn-primary" title="Message list">
<i class="bi bi-list-ul"></i><span class="d-none d-sm-inline"> Messages</span>
<div class="btn-group btn-group-sm" role="group" aria-label="{{ t('pa.view_aria') }}">
<button id="paViewMessagesBtn" class="btn btn-primary" title="{{ t('pa.view.messages_title') }}">
<i class="bi bi-list-ul"></i><span class="d-none d-sm-inline"> {{ t('pa.view.messages') }}</span>
</button>
<button id="paViewStatsBtn" class="btn btn-outline-primary" title="Per-repeater statistics">
<i class="bi bi-bar-chart"></i><span class="d-none d-sm-inline"> Repeaters</span>
<button id="paViewStatsBtn" class="btn btn-outline-primary" title="{{ t('pa.view.repeaters_title') }}">
<i class="bi bi-bar-chart"></i><span class="d-none d-sm-inline"> {{ t('pa.view.repeaters') }}</span>
</button>
<button id="paViewRoutesBtn" class="btn btn-outline-primary" title="Route segment statistics">
<i class="bi bi-signpost-2"></i><span class="d-none d-sm-inline"> Routes</span>
<button id="paViewRoutesBtn" class="btn btn-outline-primary" title="{{ t('pa.view.routes_title') }}">
<i class="bi bi-signpost-2"></i><span class="d-none d-sm-inline"> {{ t('pa.view.routes') }}</span>
</button>
<button id="paViewMapBtn" class="btn btn-outline-primary" title="Map view">
<i class="bi bi-map"></i><span class="d-none d-sm-inline"> Map</span>
<button id="paViewMapBtn" class="btn btn-outline-primary" title="{{ t('pa.view.map_title') }}">
<i class="bi bi-map"></i><span class="d-none d-sm-inline"> {{ t('pa.view.map') }}</span>
</button>
</div>
<button id="paFiltersToggleBtn" class="btn btn-sm btn-outline-secondary d-md-none" type="button"
data-bs-toggle="collapse" data-bs-target="#paFiltersPanel"
aria-expanded="false" aria-controls="paFiltersPanel" title="Show/hide filters">
<i class="bi bi-funnel"></i> Filters
aria-expanded="false" aria-controls="paFiltersPanel" title="{{ t('pa.filters_toggle_title') }}">
<i class="bi bi-funnel"></i> {{ t('pa.filters') }}
<span id="paFiltersBadge" class="badge text-bg-primary d-none"></span>
</button>
<div id="paFiltersPanel" class="pa-filters collapse">
<div class="vr d-none d-sm-block"></div>
<label for="paDaysSelect" class="form-label mb-0 small text-muted">Time range:</label>
<label for="paDaysSelect" class="form-label mb-0 small text-muted">{{ t('pa.time_range') }}</label>
<select id="paDaysSelect" class="form-select form-select-sm" style="width: auto;">
<option value="1">Last 1 day</option>
<option value="3" selected>Last 3 days</option>
<option value="5">Last 5 days</option>
<option value="7">Last 7 days</option>
<option value="1">{{ tn('pa.last_days', 1) }}</option>
<option value="3" selected>{{ tn('pa.last_days', 3) }}</option>
<option value="5">{{ tn('pa.last_days', 5) }}</option>
<option value="7">{{ tn('pa.last_days', 7) }}</option>
</select>
<button id="paRefreshBtn" class="btn btn-sm btn-outline-secondary" title="Reload">
<button id="paRefreshBtn" class="btn btn-sm btn-outline-secondary" title="{{ t('pa.reload_title') }}">
<i class="bi bi-arrow-clockwise"></i>
</button>
<div class="vr d-none d-sm-block"></div>
<select id="paHopsFilter" class="form-select form-select-sm" style="width: auto;" title="Filter by hop count (any echo)">
<option value="any" selected>Any hops</option>
<option value="0">0 hops</option>
<option value="1">1 hop</option>
<option value="2">2 hops</option>
<option value="3">3 hops</option>
<option value="4+">4+ hops</option>
<select id="paHopsFilter" class="form-select form-select-sm" style="width: auto;" title="{{ t('pa.hops_filter_title') }}">
<option value="any" selected>{{ t('pa.any_hops') }}</option>
<option value="0">{{ tn('pa.n_hops', 0) }}</option>
<option value="1">{{ tn('pa.n_hops', 1) }}</option>
<option value="2">{{ tn('pa.n_hops', 2) }}</option>
<option value="3">{{ tn('pa.n_hops', 3) }}</option>
<option value="4+">{{ t('pa.hops_4plus') }}</option>
</select>
<select id="paHashSizeFilter" class="form-select form-select-sm" style="width: auto;"
title="Filter by path hash size (bytes per hop, any routed echo)">
<option value="any" selected>Any HB</option>
<option value="1">1-byte</option>
<option value="2">2-byte</option>
<option value="3">3-byte</option>
<option value="2,3">2/3-byte</option>
title="{{ t('pa.hb_filter_title') }}">
<option value="any" selected>{{ t('pa.any_hb') }}</option>
<option value="1">{{ tn('pa.n_byte', 1) }}</option>
<option value="2">{{ tn('pa.n_byte', 2) }}</option>
<option value="3">{{ tn('pa.n_byte', 3) }}</option>
<option value="2,3">{{ t('pa.byte_2_3') }}</option>
</select>
<input id="paTokenFilter" type="text" class="form-control form-control-sm" style="width: 10.5rem;"
placeholder="Repeater (3B or name)"
title="Match a repeater anywhere in a path: hash prefix (hex) or contact name. Chain with '>' for a consecutive sequence, e.g. AFE6>6E9A">
placeholder="{{ t('pa.token_ph') }}"
title="{{ t('pa.token_title') }}">
<input id="paSenderFilter" type="text" class="form-control form-control-sm" style="width: 9.5rem;"
placeholder="Sender" title="Filter by sender name">
placeholder="{{ t('pa.sender_ph') }}" title="{{ t('pa.sender_title') }}">
<input id="paContentFilter" type="text" class="form-control form-control-sm" style="width: 11rem;"
placeholder="Message text" title="Filter by message content">
<button id="paClearFiltersBtn" class="btn btn-sm btn-outline-secondary d-none" title="Clear filters">
<i class="bi bi-x-lg"></i> Clear
placeholder="{{ t('pa.content_ph') }}" title="{{ t('pa.content_title') }}">
<button id="paClearFiltersBtn" class="btn btn-sm btn-outline-secondary d-none" title="{{ t('pa.clear_filters_title') }}">
<i class="bi bi-x-lg"></i> {{ t('pa.clear') }}
</button>
</div>
<span id="paCounter" class="small text-muted ms-auto"></span>
@@ -617,25 +617,25 @@
<div class="pa-content">
<div id="paLoading" class="empty-state">
<div class="spinner-border text-secondary" role="status"></div>
<div class="mt-2">Loading messages…</div>
<div class="mt-2">{{ t('pa.loading') }}</div>
</div>
<div id="paEmpty" class="empty-state d-none">
<i class="bi bi-signpost-split"></i>
<span id="paEmptyText">No messages in the selected time range.</span>
<span id="paEmptyText">{{ t('pa.empty_range') }}</span>
</div>
<div id="paTableWrap" class="pa-table-wrap d-none">
<table class="table table-hover pa-table mb-0">
<thead>
<tr>
<th style="width: 1.5rem;"></th>
<th>Time</th>
<th>Channel</th>
<th>Sender</th>
<th>Message</th>
<th class="pa-col-hash">Hash</th>
<th class="text-end">Hops</th>
<th class="text-end pa-col-hb" title="Path hash size in bytes per hop (from routed echoes)">HB</th>
<th class="text-end pa-col-echoes">Echoes</th>
<th>{{ t('pa.col.time') }}</th>
<th>{{ t('pa.col.channel') }}</th>
<th>{{ t('pa.col.sender') }}</th>
<th>{{ t('pa.col.message') }}</th>
<th class="pa-col-hash">{{ t('pa.col.hash') }}</th>
<th class="text-end">{{ t('pa.col.hops') }}</th>
<th class="text-end pa-col-hb" title="{{ t('pa.col.hb_title') }}">{{ t('pa.col.hb') }}</th>
<th class="text-end pa-col-echoes">{{ t('pa.col.echoes') }}</th>
</tr>
</thead>
<tbody id="paTableBody"></tbody>
@@ -645,16 +645,16 @@
<table class="table table-hover pa-table mb-0">
<thead>
<tr>
<th>Repeater</th>
<th>Contact</th>
<th>{{ t('pa.col.repeater') }}</th>
<th>{{ t('pa.col.contact') }}</th>
<th class="text-end pa-sortable" data-sort="relayed"
title="Echoes whose path contains this hash (anywhere)">Relayed</th>
title="{{ t('pa.col.relayed_title') }}">{{ t('pa.col.relayed') }}</th>
<th class="text-end pa-sortable pa-col-msgs" data-sort="messages"
title="Distinct messages relayed through this hash">Messages</th>
title="{{ t('pa.col.msgs_hash_title') }}">{{ t('pa.col.message_plural') }}</th>
<th class="text-end pa-sortable pa-col-lasthop" data-sort="lastHop"
title="Echoes where this hash is the final hop (heard directly by us)">As last hop</th>
title="{{ t('pa.col.lasthop_title') }}">{{ t('pa.col.lasthop') }}</th>
<th class="text-end pa-sortable" data-sort="avgSnr"
title="Average echo SNR, counted only when this hash is the final hop - SNR is measured at our receiver for the last hop">Avg SNR (last hop)</th>
title="{{ t('pa.col.avgsnr_title') }}">{{ t('pa.col.avgsnr') }}</th>
</tr>
</thead>
<tbody id="paStatsBody"></tbody>
@@ -662,25 +662,25 @@
</div>
<div id="paRoutesWrap" class="d-none">
<div class="d-flex align-items-center gap-2 mb-2">
<label for="paSegLenSelect" class="form-label small text-muted mb-0">Segment length:</label>
<label for="paSegLenSelect" class="form-label small text-muted mb-0">{{ t('pa.segment_length') }}</label>
<select id="paSegLenSelect" class="form-select form-select-sm" style="width: auto;"
title="Number of consecutive hops per counted segment">
<option value="2" selected>2 hops</option>
<option value="3">3 hops</option>
<option value="4">4 hops</option>
title="{{ t('pa.seglen_title') }}">
<option value="2" selected>{{ tn('pa.n_hops', 2) }}</option>
<option value="3">{{ tn('pa.n_hops', 3) }}</option>
<option value="4">{{ tn('pa.n_hops', 4) }}</option>
</select>
</div>
<div class="pa-table-wrap">
<table class="table table-hover pa-table mb-0">
<thead>
<tr>
<th>Route</th>
<th>{{ t('pa.col.route') }}</th>
<th class="text-end pa-sortable" data-sort="echoes"
title="Echoes whose path contains this segment (anywhere)">Echoes</th>
title="{{ t('pa.col.echoes_seg_title') }}">{{ t('pa.col.echoes') }}</th>
<th class="text-end pa-sortable pa-col-msgs" data-sort="messages"
title="Distinct messages whose path contains this segment">Messages</th>
title="{{ t('pa.col.msgs_seg_title') }}">{{ t('pa.col.message_plural') }}</th>
<th class="text-end pa-sortable" data-sort="pathEnd"
title="Echoes where this segment is the end of the path - the route the message took to reach us">As path end</th>
title="{{ t('pa.col.pathend_title') }}">{{ t('pa.col.pathend') }}</th>
</tr>
</thead>
<tbody id="paRoutesBody"></tbody>
@@ -690,8 +690,8 @@
<div id="paMapWrap" class="d-none">
<div id="paMapSidebar">
<div class="d-flex align-items-center justify-content-between mb-1">
<span class="small text-muted">Click a message, then an echo to draw its path.</span>
<button id="paMapClearBtn" class="btn btn-sm btn-outline-secondary d-none" title="Clear drawn path">
<span class="small text-muted">{{ t('pa.map.hint') }}</span>
<button id="paMapClearBtn" class="btn btn-sm btn-outline-secondary d-none" title="{{ t('pa.map.clear_title') }}">
<i class="bi bi-eraser"></i>
</button>
</div>
@@ -705,7 +705,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">Path Analyzer</strong>
<strong class="me-auto">{{ t('pa.title') }}</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
</div>
<div class="toast-body"></div>
+114
View File
@@ -51,6 +51,120 @@
"meta.language_english_name": "English",
"meta.language_name": "English",
"meta.translator": "mc-webui",
"pa.any_hb": "Any HB",
"pa.any_hops": "Any hops",
"pa.byte_2_3": "2/3-byte",
"pa.candidates": {
"one": "{count} candidate",
"other": "{count} candidates"
},
"pa.clear": "Clear",
"pa.clear_filters_title": "Clear filters",
"pa.col.avgsnr": "Avg SNR (last hop)",
"pa.col.avgsnr_title": "Average echo SNR, counted only when this hash is the final hop - SNR is measured at our receiver for the last hop",
"pa.col.channel": "Channel",
"pa.col.contact": "Contact",
"pa.col.echoes": "Echoes",
"pa.col.echoes_seg_title": "Echoes whose path contains this segment (anywhere)",
"pa.col.hash": "Hash",
"pa.col.hb": "HB",
"pa.col.hb_title": "Path hash size in bytes per hop (from routed echoes)",
"pa.col.hops": "Hops",
"pa.col.lasthop": "As last hop",
"pa.col.lasthop_title": "Echoes where this hash is the final hop (heard directly by us)",
"pa.col.message": "Message",
"pa.col.message_plural": "Messages",
"pa.col.msgs_hash_title": "Distinct messages relayed through this hash",
"pa.col.msgs_seg_title": "Distinct messages whose path contains this segment",
"pa.col.pathend": "As path end",
"pa.col.pathend_title": "Echoes where this segment is the end of the path - the route the message took to reach us",
"pa.col.relayed": "Relayed",
"pa.col.relayed_title": "Echoes whose path contains this hash (anywhere)",
"pa.col.repeater": "Repeater",
"pa.col.route": "Route",
"pa.col.sender": "Sender",
"pa.col.time": "Time",
"pa.content_ph": "Message text",
"pa.content_title": "Filter by message content",
"pa.copy_route_title": "Click to copy route",
"pa.copy_title": "Click to copy",
"pa.counter.filtered": "{shown} of {total} messages",
"pa.counter.messages": {
"one": "{count} message",
"other": "{count} messages"
},
"pa.counter.repeaters": {
"one": "{count} repeater",
"other": "{count} repeaters"
},
"pa.counter.routed": {
"one": "{count} routed message",
"other": "{count} routed messages"
},
"pa.counter.segments": {
"one": "{count} segment",
"other": "{count} segments"
},
"pa.direct_flood": "Direct (flood, 0 hops)",
"pa.empty_filtered": "No messages match the current filters.",
"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.filters": "Filters",
"pa.filters_toggle_title": "Show/hide filters",
"pa.hb_filter_title": "Filter by path hash size (bytes per hop, any routed echo)",
"pa.hops_4plus": "4+ hops",
"pa.hops_filter_title": "Filter by hop count (any echo)",
"pa.last_days": {
"one": "Last {count} day",
"other": "Last {count} days"
},
"pa.loading": "Loading messages…",
"pa.map.clear_title": "Clear drawn path",
"pa.map.hint": "Click a message, then an echo to draw its path.",
"pa.map.no_routed": "No messages with routed echoes match the current filters.",
"pa.me": "Me",
"pa.n_byte": {
"one": "{count}-byte",
"other": "{count}-byte"
},
"pa.n_hops": {
"one": "{count} hop",
"other": "{count} hops"
},
"pa.no_path_data": "no path data",
"pa.packet_hash": "Packet hash",
"pa.reload_title": "Reload",
"pa.repeater_hash": "Repeater hash",
"pa.reset_picks": "Reset picks",
"pa.reset_picks_title": "Clear all manual repeater assignments on this path",
"pa.seglen_title": "Number of consecutive hops per counted segment",
"pa.segment_length": "Segment length:",
"pa.sender_own": "{name} (own)",
"pa.sender_ph": "Sender",
"pa.sender_title": "Filter by sender name",
"pa.show_on_map_title": "Show this path on the map",
"pa.time_range": "Time range:",
"pa.title": "Path Analyzer",
"pa.toast.copied": "{what} copied to clipboard",
"pa.toast.copy_failed": "Failed to copy {what}",
"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.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",
"pa.unknown_position": "— unknown / no position",
"pa.use_contact_title": "Use this contact for the hop",
"pa.view.map": "Map",
"pa.view.map_title": "Map view",
"pa.view.messages": "Messages",
"pa.view.messages_title": "Message list",
"pa.view.repeaters": "Repeaters",
"pa.view.repeaters_title": "Per-repeater statistics",
"pa.view.routes": "Routes",
"pa.view.routes_title": "Route segment statistics",
"pa.view_aria": "View",
"repeaters.add": "Add repeater",
"repeaters.add_loading": "Loading device contacts...",
"repeaters.add_modal_title": "Add Repeater",
+130
View File
@@ -57,6 +57,136 @@
"meta.language_english_name": "Polish",
"meta.language_name": "Polski",
"meta.translator": "mc-webui",
"pa.any_hb": "Dowolne HB",
"pa.any_hops": "Dowolne hopy",
"pa.byte_2_3": "2/3 bajty",
"pa.candidates": {
"one": "{count} kandydat",
"few": "{count} kandydaci",
"many": "{count} kandydatów",
"other": "{count} kandydata"
},
"pa.clear": "Wyczyść",
"pa.clear_filters_title": "Wyczyść filtry",
"pa.col.avgsnr": "Śr. SNR (ostatni hop)",
"pa.col.avgsnr_title": "Średni SNR echa, liczony tylko gdy ten hash jest ostatnim hopem — SNR mierzymy na naszym odbiorniku dla ostatniego hopa",
"pa.col.channel": "Kanał",
"pa.col.contact": "Kontakt",
"pa.col.echoes": "Echa",
"pa.col.echoes_seg_title": "Echa, których ścieżka zawiera ten segment (w dowolnym miejscu)",
"pa.col.hash": "Hash",
"pa.col.hb": "HB",
"pa.col.hb_title": "Rozmiar hasha ścieżki w bajtach na hop (z ech z trasą)",
"pa.col.hops": "Hopy",
"pa.col.lasthop": "Jako ostatni hop",
"pa.col.lasthop_title": "Echa, w których ten hash jest ostatnim hopem (usłyszane bezpośrednio przez nas)",
"pa.col.message": "Wiadomość",
"pa.col.message_plural": "Wiadomości",
"pa.col.msgs_hash_title": "Odrębne wiadomości przekazane przez ten hash",
"pa.col.msgs_seg_title": "Odrębne wiadomości, których ścieżka zawiera ten segment",
"pa.col.pathend": "Jako koniec ścieżki",
"pa.col.pathend_title": "Echa, w których ten segment kończy ścieżkę — trasa, którą wiadomość do nas dotarła",
"pa.col.relayed": "Przekazane",
"pa.col.relayed_title": "Echa, których ścieżka zawiera ten hash (w dowolnym miejscu)",
"pa.col.repeater": "Repeater",
"pa.col.route": "Trasa",
"pa.col.sender": "Nadawca",
"pa.col.time": "Czas",
"pa.content_ph": "Treść wiadomości",
"pa.content_title": "Filtruj po treści wiadomości",
"pa.copy_route_title": "Kliknij, aby skopiować trasę",
"pa.copy_title": "Kliknij, aby skopiować",
"pa.counter.filtered": "{shown} z {total} wiadomości",
"pa.counter.messages": {
"one": "{count} wiadomość",
"few": "{count} wiadomości",
"many": "{count} wiadomości",
"other": "{count} wiadomości"
},
"pa.counter.repeaters": {
"one": "{count} repeater",
"few": "{count} repeatery",
"many": "{count} repeaterów",
"other": "{count} repeatera"
},
"pa.counter.routed": {
"one": "{count} wiadomość z trasą",
"few": "{count} wiadomości z trasą",
"many": "{count} wiadomości z trasą",
"other": "{count} wiadomości z trasą"
},
"pa.counter.segments": {
"one": "{count} segment",
"few": "{count} segmenty",
"many": "{count} segmentów",
"other": "{count} segmentu"
},
"pa.direct_flood": "Bezpośrednio (flood, 0 hopów)",
"pa.empty_filtered": "Żadna wiadomość nie pasuje do bieżących filtrów.",
"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.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ą)",
"pa.hops_4plus": "4+ hopów",
"pa.hops_filter_title": "Filtruj po liczbie hopów (dowolne echo)",
"pa.last_days": {
"one": "Ostatni {count} dzień",
"few": "Ostatnie {count} dni",
"many": "Ostatnie {count} dni",
"other": "Ostatnie {count} dnia"
},
"pa.loading": "Wczytywanie wiadomości…",
"pa.map.clear_title": "Wyczyść narysowaną ścieżkę",
"pa.map.hint": "Kliknij wiadomość, a potem echo, aby narysować jej ścieżkę.",
"pa.map.no_routed": "Żadna wiadomość z echami z trasą nie pasuje do bieżących filtrów.",
"pa.me": "Ja",
"pa.n_byte": {
"one": "{count} bajt",
"few": "{count} bajty",
"many": "{count} bajtów",
"other": "{count} bajta"
},
"pa.n_hops": {
"one": "{count} hop",
"few": "{count} hopy",
"many": "{count} hopów",
"other": "{count} hopa"
},
"pa.no_path_data": "brak danych o ścieżce",
"pa.packet_hash": "Hash pakietu",
"pa.reload_title": "Odśwież",
"pa.repeater_hash": "Hash repeatera",
"pa.reset_picks": "Resetuj wybory",
"pa.reset_picks_title": "Wyczyść wszystkie ręczne przypisania repeaterów na tej ścieżce",
"pa.seglen_title": "Liczba kolejnych hopów w liczonym segmencie",
"pa.segment_length": "Długość segmentu:",
"pa.sender_own": "{name} (własna)",
"pa.sender_ph": "Nadawca",
"pa.sender_title": "Filtruj po nazwie nadawcy",
"pa.show_on_map_title": "Pokaż tę ścieżkę na mapie",
"pa.time_range": "Zakres czasu:",
"pa.title": "Analizator ścieżek",
"pa.toast.copied": "Skopiowano do schowka: {what}",
"pa.toast.copy_failed": "Nie udało się skopiować: {what}",
"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.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",
"pa.unknown_position": "— nieznany / brak pozycji",
"pa.use_contact_title": "Użyj tego kontaktu dla hopa",
"pa.view.map": "Mapa",
"pa.view.map_title": "Widok mapy",
"pa.view.messages": "Wiadomości",
"pa.view.messages_title": "Lista wiadomości",
"pa.view.repeaters": "Repeatery",
"pa.view.repeaters_title": "Statystyki per repeater",
"pa.view.routes": "Trasy",
"pa.view.routes_title": "Statystyki segmentów tras",
"pa.view_aria": "Widok",
"repeaters.add": "Dodaj repeater",
"repeaters.add_loading": "Wczytywanie kontaktów z urządzenia...",
"repeaters.add_modal_title": "Dodaj repeater",