mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-08-08 09:42:55 +02:00
feat(i18n): translate the My Repeaters panel (stage 3a)
~100 keys across repeaters.html and repeaters.js: the list, the add picker, the
password/login flow, the path editor, the map picker, and every toast.
Boundary call made here and written into docs/translations.md: "path" is
translated ("ścieżka"), because it is a word the user reads, not a value the
device reports. The mode names it can hold — Flood, Direct, FLOOD — stay as-is,
as do hop hex, repeater names and login roles. The rule is now stated explicitly
in the translator guide, since "Skonfigurowane path" is exactly the kind of
half-translation that makes a UI worse than leaving it in English.
Removed a third copy of relative-time formatting. Stage 0b merged app.js and
dm.js's formatTime but I grepped for the wrong names and missed
formatRelativeTime; repeaters.js had its own, with a "Never" branch that was
unreachable because its one call site already guards a falsy timestamp. It now
uses the shared formatTimeAgo. contacts.js and dm.js still carry their own
copies — noted in place, folded in with stages 5 and 6.
Two checker fixes, both found by this slice:
- Keys chosen inline, tHtml(x ? 'a.b' : 'c.d'), were invisible to the scanner.
That produced false "unused key" warnings and would have silently hidden a
typo in either branch. Added a second pattern for that shape.
- The generic en/pl diff caught two real misses I had made — the page <title>
and the toast header, both still reading "My Repeaters". Swept every other
template for the same pattern; the remaining ones belong to later stages.
Verified: checker clean, and the diff now reports every comparable string on
/repeaters as changed between en and pl.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+81
-80
@@ -91,14 +91,10 @@ function esc(s) {
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp) {
|
||||
if (!timestamp) return 'Never';
|
||||
const diff = Math.floor(Date.now() / 1000) - timestamp;
|
||||
if (diff < 60) return 'Just now';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||
return `${Math.floor(diff / 86400)}d ago`;
|
||||
}
|
||||
// Relative time comes from datetime-utils.js as formatTimeAgo(). The local copy this
|
||||
// replaced also had a "Never" branch for a falsy timestamp, which was unreachable — the
|
||||
// single call site already guards it. contacts.js and dm.js still carry their own
|
||||
// copies; they get folded in with their own slices.
|
||||
|
||||
// ================================================================
|
||||
// State
|
||||
@@ -132,14 +128,14 @@ async function loadRepeaters(forceRefresh = false) {
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
listEl.innerHTML = `<div class="text-danger small">${esc(data.error || 'Failed to load repeaters')}</div>`;
|
||||
listEl.innerHTML = `<div class="text-danger small">${esc(data.error || t('repeaters.load_failed'))}</div>`;
|
||||
return;
|
||||
}
|
||||
myRepeaters = data.repeaters || [];
|
||||
renderRepeaterRows();
|
||||
} catch (e) {
|
||||
console.error('Failed to load repeaters:', e);
|
||||
listEl.innerHTML = '<div class="text-danger small">Failed to load repeaters</div>';
|
||||
listEl.innerHTML = `<div class="text-danger small">${tHtml('repeaters.load_failed')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,9 +145,7 @@ function renderRepeaterRows() {
|
||||
const countEl = document.getElementById('repeaterCount');
|
||||
|
||||
if (countEl) {
|
||||
countEl.textContent = myRepeaters.length
|
||||
? `${myRepeaters.length} repeater${myRepeaters.length === 1 ? '' : 's'}`
|
||||
: '';
|
||||
countEl.textContent = myRepeaters.length ? tn('repeaters.count', myRepeaters.length) : '';
|
||||
}
|
||||
|
||||
listEl.innerHTML = '';
|
||||
@@ -172,14 +166,14 @@ function renderRepeaterRows() {
|
||||
: '<i class="bi bi-diagram-3 text-success fs-4"></i>';
|
||||
|
||||
const statusLine = isLoggingIn
|
||||
? '<span class="text-primary">Logging in… (may take up to 60 s on flood paths)</span>'
|
||||
? `<span class="text-primary">${tHtml('repeaters.row.logging_in')}</span>`
|
||||
: (r.on_device
|
||||
? `<span class="rpt-path font-monospace">${esc(r.path_or_mode || '—')}</span>`
|
||||
: '<span class="text-warning">Not stored on the device</span>');
|
||||
: `<span class="text-warning">${tHtml('repeaters.row.not_on_device')}</span>`);
|
||||
// "last login" goes on its own line so a long path keeps the full row
|
||||
// width to itself (on narrow phones it otherwise gets truncated hard).
|
||||
const roleLine = (r.on_device && !isLoggingIn && r.last_login_at)
|
||||
? `<small class="text-muted d-block text-truncate">last login: ${esc(r.last_login_role || '?')}</small>`
|
||||
? `<small class="text-muted d-block text-truncate">${tHtml('repeaters.row.last_login', { role: r.last_login_role || '?' })}</small>`
|
||||
: '';
|
||||
|
||||
row.innerHTML = `
|
||||
@@ -192,14 +186,14 @@ function renderRepeaterRows() {
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm flex-shrink-0">
|
||||
<button type="button" class="btn btn-outline-secondary" data-action="path"
|
||||
title="Set path" ${r.on_device ? '' : 'disabled'}>
|
||||
title="${tHtml('repeaters.row.set_path_title')}" ${r.on_device ? '' : 'disabled'}>
|
||||
<i class="bi bi-signpost-split"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" data-action="password"
|
||||
title="${r.password_set ? 'Change password' : 'Set password'}">
|
||||
title="${tHtml(r.password_set ? 'repeaters.password.change_title' : 'repeaters.password.set_title')}">
|
||||
<i class="bi bi-key${r.password_set ? '-fill' : ''}"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger" data-action="remove" title="Remove from list">
|
||||
<button type="button" class="btn btn-outline-danger" data-action="remove" title="${tHtml('repeaters.row.remove_title')}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -232,11 +226,11 @@ function onRepeaterClick(pubkey) {
|
||||
const r = findRepeater(pubkey);
|
||||
if (!r) return;
|
||||
if (!r.on_device) {
|
||||
showNotification('This repeater is not stored on the device — it cannot be managed', 'warning');
|
||||
showNotification(t('repeaters.toast.not_on_device'), 'warning');
|
||||
return;
|
||||
}
|
||||
if (_loginPubkey) {
|
||||
showNotification('Another login is already in progress', 'warning');
|
||||
showNotification(t('repeaters.toast.login_in_progress'), 'warning');
|
||||
return;
|
||||
}
|
||||
if (!r.password_set) {
|
||||
@@ -275,12 +269,12 @@ async function doLogin(pubkey, password, save) {
|
||||
|
||||
if (data && data.success) {
|
||||
const role = data.is_admin ? 'ADMIN' : 'GUEST';
|
||||
showNotification(`Logged in to ${r.name || 'repeater'} as ${role}`, 'success');
|
||||
showNotification(t('repeaters.toast.logged_in', { name: r.name || t('repeaters.term'), role }), 'success');
|
||||
window.location.href = `/repeaters/manage?pubkey=${encodeURIComponent(pubkey)}`;
|
||||
return;
|
||||
} else {
|
||||
await loadRepeaters();
|
||||
const error = (data && data.error) || 'Login failed';
|
||||
const error = (data && data.error) || t('repeaters.toast.login_failed');
|
||||
showNotification(error, 'danger');
|
||||
// Offer a retry with password correction (wrong password and
|
||||
// unreachable repeater are indistinguishable at protocol level).
|
||||
@@ -306,16 +300,18 @@ function openPasswordModal(mode, pubkey, errorHint = '') {
|
||||
|
||||
const name = r.name || r.public_key.substring(0, 12);
|
||||
if (mode === 'set') {
|
||||
title.textContent = `Set password — ${name}`;
|
||||
info.textContent = 'The password is stored in the app database and used to log in to this repeater without asking again.';
|
||||
submitBtn.textContent = 'Save';
|
||||
title.textContent = `${t('repeaters.password.set_title')} — ${name}`;
|
||||
info.textContent = t('repeaters.password.set_info');
|
||||
submitBtn.textContent = t('common.save');
|
||||
saveWrap.style.display = 'none';
|
||||
} else {
|
||||
title.textContent = `Log in — ${name}`;
|
||||
title.textContent = `${t('repeaters.password.login_title')} — ${name}`;
|
||||
// errorHint comes from the backend and stays English; only the advice around it
|
||||
// is translated.
|
||||
info.innerHTML = errorHint
|
||||
? `<span class="text-danger">${esc(errorHint)}</span><br>Check the password and try again.`
|
||||
: 'Enter the repeater password to log in.';
|
||||
submitBtn.textContent = 'Log in';
|
||||
? `<span class="text-danger">${esc(errorHint)}</span><br>${tHtml('repeaters.password.retry_hint')}`
|
||||
: tHtml('repeaters.password.login_info');
|
||||
submitBtn.textContent = t('repeaters.password.login_btn');
|
||||
saveWrap.style.display = '';
|
||||
saveCheck.checked = true;
|
||||
}
|
||||
@@ -351,7 +347,7 @@ async function submitPasswordModal() {
|
||||
const password = input.value;
|
||||
|
||||
if (!password) {
|
||||
showNotification('Password cannot be empty', 'warning');
|
||||
showNotification(t('repeaters.toast.password_empty'), 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -366,13 +362,13 @@ async function submitPasswordModal() {
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showNotification('Password saved', 'success');
|
||||
showNotification(t('repeaters.toast.password_saved'), 'success');
|
||||
await loadRepeaters();
|
||||
} else {
|
||||
showNotification(data.error || 'Failed to save password', 'danger');
|
||||
showNotification(data.error || t('repeaters.toast.password_save_failed'), 'danger');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotification('Failed to save password', 'danger');
|
||||
showNotification(t('repeaters.toast.password_save_failed'), 'danger');
|
||||
}
|
||||
} else {
|
||||
doLogin(pubkey, password, saveCheck.checked);
|
||||
@@ -387,7 +383,9 @@ function openRemoveModal(pubkey) {
|
||||
const r = findRepeater(pubkey);
|
||||
if (!r) return;
|
||||
_removeCtx = { pubkey };
|
||||
document.getElementById('removeRepeaterName').textContent = r.name || r.public_key.substring(0, 12);
|
||||
document.getElementById('removeRepeaterQuestion').innerHTML = tHtml('repeaters.remove.question', {
|
||||
name: r.name || r.public_key.substring(0, 12)
|
||||
});
|
||||
_removeModal.show();
|
||||
}
|
||||
|
||||
@@ -399,13 +397,13 @@ async function confirmRemoveRepeater() {
|
||||
const response = await fetch(`/api/repeaters/${encodeURIComponent(pubkey)}`, { method: 'DELETE' });
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showNotification('Repeater removed from list', 'info');
|
||||
showNotification(t('repeaters.toast.removed'), 'info');
|
||||
await loadRepeaters();
|
||||
} else {
|
||||
showNotification(data.error || 'Failed to remove repeater', 'danger');
|
||||
showNotification(data.error || t('repeaters.toast.remove_failed'), 'danger');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotification('Failed to remove repeater', 'danger');
|
||||
showNotification(t('repeaters.toast.remove_failed'), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,14 +414,14 @@ async function confirmRemoveRepeater() {
|
||||
async function openAddRepeaterModal() {
|
||||
_addRepeaterModal.show();
|
||||
const listEl = document.getElementById('deviceRptList');
|
||||
listEl.innerHTML = '<div class="text-muted small p-3">Loading device contacts...</div>';
|
||||
listEl.innerHTML = `<div class="text-muted small p-3">${tHtml('repeaters.add_loading')}</div>`;
|
||||
document.getElementById('deviceRptSearch').value = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/contacts/detailed');
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
listEl.innerHTML = `<div class="text-danger small p-3">${esc(data.error || 'Failed to load device contacts')}</div>`;
|
||||
listEl.innerHTML = `<div class="text-danger small p-3">${esc(data.error || t('repeaters.toast.contacts_load_failed'))}</div>`;
|
||||
return;
|
||||
}
|
||||
_deviceRepeaters = (data.contacts || [])
|
||||
@@ -432,7 +430,7 @@ async function openAddRepeaterModal() {
|
||||
renderDeviceRepeaterList();
|
||||
} catch (e) {
|
||||
console.error('Failed to load device contacts:', e);
|
||||
listEl.innerHTML = '<div class="text-danger small p-3">Failed to load device contacts</div>';
|
||||
listEl.innerHTML = `<div class="text-danger small p-3">${tHtml('repeaters.toast.contacts_load_failed')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,10 +445,10 @@ function renderDeviceRepeaterList() {
|
||||
items = items.filter(c => (c.name || '').toLowerCase().includes(searchVal));
|
||||
}
|
||||
|
||||
if (countEl) countEl.textContent = `${(_deviceRepeaters || []).length} repeaters on device`;
|
||||
if (countEl) countEl.textContent = tn('repeaters.on_device_count', (_deviceRepeaters || []).length);
|
||||
|
||||
if (!items.length) {
|
||||
listEl.innerHTML = '<div class="text-muted small p-3">No repeaters found on the device.</div>';
|
||||
listEl.innerHTML = `<div class="text-muted small p-3">${tHtml('repeaters.add_none_found')}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -466,7 +464,7 @@ function renderDeviceRepeaterList() {
|
||||
<small class="text-muted font-monospace">${esc(c.public_key_prefix)} · ${esc(c.path_or_mode || '')}</small>
|
||||
</div>
|
||||
${added
|
||||
? '<span class="badge bg-secondary flex-shrink-0">Added</span>'
|
||||
? `<span class="badge bg-secondary flex-shrink-0">${tHtml('repeaters.added_badge')}</span>`
|
||||
: '<i class="bi bi-plus-circle text-success flex-shrink-0"></i>'}
|
||||
`;
|
||||
if (!added) {
|
||||
@@ -485,14 +483,14 @@ async function addRepeater(contact) {
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showNotification(`${contact.name || 'Repeater'} added`, 'success');
|
||||
showNotification(t('repeaters.toast.added', { name: contact.name || t('repeaters.term') }), 'success');
|
||||
await loadRepeaters();
|
||||
renderDeviceRepeaterList();
|
||||
} else {
|
||||
showNotification(data.error || 'Failed to add repeater', 'danger');
|
||||
showNotification(data.error || t('repeaters.toast.add_failed'), 'danger');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotification('Failed to add repeater', 'danger');
|
||||
showNotification(t('repeaters.toast.add_failed'), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -523,13 +521,13 @@ async function renderPathList(pubkey) {
|
||||
const listEl = document.getElementById('pathList');
|
||||
if (!listEl) return;
|
||||
|
||||
listEl.innerHTML = '<div class="text-muted small">Loading...</div>';
|
||||
listEl.innerHTML = `<div class="text-muted small">${tHtml('common.loading')}</div>`;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths`);
|
||||
const data = await response.json();
|
||||
if (!data.success || !data.paths.length) {
|
||||
listEl.innerHTML = '<div class="text-muted small mb-2">No paths configured. Use + to add.</div>';
|
||||
listEl.innerHTML = `<div class="text-muted small mb-2">${tHtml('repeaters.paths.none')}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -552,18 +550,18 @@ async function renderPathList(pubkey) {
|
||||
${path.label ? `<span class="path-label" title="${esc(path.label)}">${esc(path.label)}</span>` : ''}
|
||||
<span class="path-actions">
|
||||
<button class="btn btn-link p-0 ${path.is_primary ? 'text-warning' : 'text-muted'}"
|
||||
title="${path.is_primary ? 'Primary path' : 'Set as primary'}"
|
||||
title="${tHtml(path.is_primary ? 'repeaters.paths.is_primary_title' : 'repeaters.paths.set_primary_title')}"
|
||||
data-action="primary" data-id="${path.id}">
|
||||
<i class="bi bi-star${path.is_primary ? '-fill' : ''}"></i>
|
||||
</button>
|
||||
<button class="btn btn-link p-0 text-primary"
|
||||
title="Set as device path"
|
||||
title="${tHtml('repeaters.paths.apply_title')}"
|
||||
data-action="apply" data-id="${path.id}">
|
||||
<i class="bi bi-upload"></i>
|
||||
</button>
|
||||
${index > 0 ? `<button class="btn btn-link p-0 text-muted" title="Move up" data-action="up" data-id="${path.id}" data-index="${index}"><i class="bi bi-chevron-up"></i></button>` : ''}
|
||||
${index < data.paths.length - 1 ? `<button class="btn btn-link p-0 text-muted" title="Move down" data-action="down" data-id="${path.id}" data-index="${index}"><i class="bi bi-chevron-down"></i></button>` : ''}
|
||||
<button class="btn btn-link p-0 text-danger" title="Delete" data-action="delete" data-id="${path.id}">
|
||||
${index > 0 ? `<button class="btn btn-link p-0 text-muted" title="${tHtml('common.move_up')}" data-action="up" data-id="${path.id}" data-index="${index}"><i class="bi bi-chevron-up"></i></button>` : ''}
|
||||
${index < data.paths.length - 1 ? `<button class="btn btn-link p-0 text-muted" title="${tHtml('common.move_down')}" data-action="down" data-id="${path.id}" data-index="${index}"><i class="bi bi-chevron-down"></i></button>` : ''}
|
||||
<button class="btn btn-link p-0 text-danger" title="${tHtml('common.delete')}" data-action="delete" data-id="${path.id}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</span>
|
||||
@@ -589,7 +587,7 @@ async function renderPathList(pubkey) {
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
listEl.innerHTML = '<div class="text-danger small">Failed to load paths</div>';
|
||||
listEl.innerHTML = `<div class="text-danger small">${tHtml('repeaters.paths.load_failed')}</div>`;
|
||||
console.error('Failed to load paths:', e);
|
||||
}
|
||||
}
|
||||
@@ -615,14 +613,14 @@ async function applyPathToDevice(pubkey, pathId) {
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showNotification('Device path updated', 'info');
|
||||
showNotification(t('repeaters.toast.path_updated'), 'info');
|
||||
await refreshDevicePathDisplay();
|
||||
} else {
|
||||
showNotification(data.error || 'Failed to set device path', 'danger');
|
||||
showNotification(data.error || t('repeaters.toast.path_set_failed'), 'danger');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to apply path to device:', e);
|
||||
showNotification('Failed to set device path', 'danger');
|
||||
showNotification(t('repeaters.toast.path_set_failed'), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -674,7 +672,7 @@ async function saveNewPath() {
|
||||
const label = document.getElementById('pathLabelInput').value.trim();
|
||||
|
||||
if (!pathHex) {
|
||||
showNotification('Path hex is required', 'danger');
|
||||
showNotification(t('repeaters.toast.path_hex_required'), 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -686,13 +684,15 @@ async function saveNewPath() {
|
||||
if (hashSize === 1) {
|
||||
const adjDupes = hops.filter((h, i) => i > 0 && hops[i - 1] === h);
|
||||
if (adjDupes.length > 0) {
|
||||
showNotification(`Adjacent duplicate hop(s): ${[...new Set(adjDupes)].map(d => d.toUpperCase()).join(', ')}`, 'danger');
|
||||
showNotification(tn('repeaters.toast.adjacent_dupes', [...new Set(adjDupes)].length,
|
||||
{ hops: [...new Set(adjDupes)].map(d => d.toUpperCase()).join(', ') }), 'danger');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const dupes = hops.filter((h, i) => hops.indexOf(h) !== i);
|
||||
if (dupes.length > 0) {
|
||||
showNotification(`Duplicate hop(s): ${[...new Set(dupes)].map(d => d.toUpperCase()).join(', ')}`, 'danger');
|
||||
showNotification(tn('repeaters.toast.dupes', [...new Set(dupes)].length,
|
||||
{ hops: [...new Set(dupes)].map(d => d.toUpperCase()).join(', ') }), 'danger');
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -707,19 +707,19 @@ async function saveNewPath() {
|
||||
if (data.success) {
|
||||
_addPathModal.hide();
|
||||
await renderPathList(pubkey);
|
||||
showNotification('Path added', 'info');
|
||||
showNotification(t('repeaters.toast.path_added'), 'info');
|
||||
} else {
|
||||
showNotification(data.error || 'Failed to add path', 'danger');
|
||||
showNotification(data.error || t('repeaters.toast.path_add_failed'), 'danger');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotification('Failed to add path', 'danger');
|
||||
showNotification(t('repeaters.toast.path_add_failed'), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPathToFlood() {
|
||||
const pubkey = _pathPubkey;
|
||||
if (!pubkey) return;
|
||||
if (!confirm('Reset device path to FLOOD?\n\nThis resets the path on the device only. Your configured paths will be kept.')) {
|
||||
if (!confirm(t('repeaters.confirm.reset_flood'))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -728,20 +728,20 @@ async function resetPathToFlood() {
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showNotification('Device path reset to FLOOD', 'info');
|
||||
showNotification(t('repeaters.toast.reset_flood_done'), 'info');
|
||||
await refreshDevicePathDisplay();
|
||||
} else {
|
||||
showNotification(data.error || 'Reset failed', 'danger');
|
||||
showNotification(data.error || t('repeaters.toast.reset_failed'), 'danger');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotification('Reset failed', 'danger');
|
||||
showNotification(t('repeaters.toast.reset_failed'), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function clearAllPaths() {
|
||||
const pubkey = _pathPubkey;
|
||||
if (!pubkey) return;
|
||||
if (!confirm('Clear all configured paths?\n\nThis will delete all paths from the database. The device path will not be changed.')) {
|
||||
if (!confirm(t('repeaters.confirm.clear_paths'))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -751,12 +751,12 @@ async function clearAllPaths() {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
await renderPathList(pubkey);
|
||||
showNotification(`${data.paths_deleted || 0} path(s) cleared`, 'info');
|
||||
showNotification(tn('repeaters.toast.paths_cleared', data.paths_deleted || 0), 'info');
|
||||
} else {
|
||||
showNotification(data.error || 'Clear failed', 'danger');
|
||||
showNotification(data.error || t('repeaters.toast.clear_failed'), 'danger');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotification('Clear failed', 'danger');
|
||||
showNotification(t('repeaters.toast.clear_failed'), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -784,7 +784,7 @@ async function loadHopPicker() {
|
||||
_repeatersCache = data.repeaters;
|
||||
}
|
||||
} catch (e) {
|
||||
listEl.innerHTML = '<div class="text-danger small p-2">Failed to load repeaters</div>';
|
||||
listEl.innerHTML = `<div class="text-danger small p-2">${tHtml('repeaters.load_failed')}</div>`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -814,7 +814,7 @@ function renderHopPickerList(listEl, repeaters) {
|
||||
});
|
||||
|
||||
if (!filtered.length) {
|
||||
listEl.innerHTML = '<div class="text-muted small p-2">No repeaters found</div>';
|
||||
listEl.innerHTML = `<div class="text-muted small p-2">${tHtml('repeaters.picker.none')}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -830,7 +830,7 @@ function renderHopPickerList(listEl, repeaters) {
|
||||
item.innerHTML = `
|
||||
<span class="badge ${samePrefix > 1 ? 'bg-warning text-dark' : 'bg-success'}">${esc(prefix)}</span>
|
||||
<span class="flex-grow-1 text-truncate">${esc(rpt.name)}</span>
|
||||
${samePrefix > 1 ? '<i class="bi bi-exclamation-triangle text-warning" title="' + samePrefix + ' repeaters share this prefix"></i>' : ''}
|
||||
${samePrefix > 1 ? `<i class="bi bi-exclamation-triangle text-warning" title="${tHtml('repeaters.picker.shared_prefix', { count: samePrefix })}"></i>` : ''}
|
||||
`;
|
||||
item.addEventListener('click', () => {
|
||||
appendHopToPathInput(prefix.toLowerCase(), hashSize);
|
||||
@@ -855,12 +855,12 @@ function appendHopToPathInput(prefixLc, hashSize) {
|
||||
const existingHops = getCurrentPathHops(hashSize);
|
||||
if (hashSize === 1) {
|
||||
if (existingHops.length > 0 && existingHops[existingHops.length - 1] === prefixLc) {
|
||||
showNotification(`${prefixLc.toUpperCase()} cannot be adjacent to itself`, 'warning');
|
||||
showNotification(t('repeaters.toast.hop_adjacent_self', { hop: prefixLc.toUpperCase() }), 'warning');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
if (existingHops.includes(prefixLc)) {
|
||||
showNotification(`${prefixLc.toUpperCase()} is already in the path`, 'warning');
|
||||
showNotification(t('repeaters.toast.hop_already_used', { hop: prefixLc.toUpperCase() }), 'warning');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -901,7 +901,8 @@ function checkUniquenessWarning(repeaters, hashSize) {
|
||||
});
|
||||
|
||||
if (ambiguous.length > 0) {
|
||||
warningEl.textContent = `⚠ Ambiguous prefix(es): ${ambiguous.map(h => h.toUpperCase()).join(', ')}. Consider using a larger hash size.`;
|
||||
warningEl.textContent = tn('repeaters.toast.ambiguous_prefix', ambiguous.length,
|
||||
{ hops: ambiguous.map(h => h.toUpperCase()).join(', ') });
|
||||
warningEl.style.display = '';
|
||||
} else {
|
||||
warningEl.style.display = 'none';
|
||||
@@ -1018,7 +1019,7 @@ async function loadRepeaterMapMarkers() {
|
||||
|
||||
repeaters.forEach(rpt => {
|
||||
const prefix = rpt.public_key.substring(0, hashSize * 2).toUpperCase();
|
||||
const lastSeen = rpt.last_advert ? formatRelativeTime(rpt.last_advert) : '';
|
||||
const lastSeen = rpt.last_advert ? formatTimeAgo(rpt.last_advert) : '';
|
||||
|
||||
const marker = L.circleMarker([rpt.adv_lat, rpt.adv_lon], {
|
||||
radius: 10,
|
||||
@@ -1032,7 +1033,7 @@ async function loadRepeaterMapMarkers() {
|
||||
marker.bindPopup(
|
||||
`<b>${esc(rpt.name)}</b><br>` +
|
||||
`<code>${esc(prefix)}</code>` +
|
||||
(lastSeen ? `<br><small class="text-muted">Last seen: ${lastSeen}</small>` : '')
|
||||
(lastSeen ? `<br><small class="text-muted">${tHtml('repeaters.map.last_seen', { time: lastSeen })}</small>` : '')
|
||||
);
|
||||
|
||||
marker.on('click', () => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>My Repeaters - mc-webui</title>
|
||||
<title>{{ t('repeaters.title') }} - mc-webui</title>
|
||||
|
||||
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
|
||||
<script>
|
||||
@@ -194,11 +194,11 @@
|
||||
<!-- Toolbar -->
|
||||
<div class="repeaters-toolbar">
|
||||
<span class="text-muted small flex-grow-1" id="repeaterCount"></span>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="refreshBtn" title="Refresh list">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="refreshBtn" title="{{ t('repeaters.refresh_title') }}">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-success" id="addRepeaterBtn">
|
||||
<i class="bi bi-plus-lg"></i> Add repeater
|
||||
<i class="bi bi-plus-lg"></i> {{ t('repeaters.add') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -207,9 +207,9 @@
|
||||
<div id="repeaterList"></div>
|
||||
<div class="empty-state" id="emptyState" style="display: none;">
|
||||
<i class="bi bi-diagram-3"></i>
|
||||
<p class="mb-1">No repeaters yet.</p>
|
||||
<p class="small mb-0">Use <strong>Add repeater</strong> to pick repeaters stored on your device.<br>
|
||||
Only repeaters saved in the device contacts can be managed.</p>
|
||||
<p class="mb-1">{{ t('repeaters.empty') }}</p>
|
||||
<p class="small mb-0">{{ t_html('repeaters.empty_hint') }}<br>
|
||||
{{ t('repeaters.empty_note') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -218,21 +218,21 @@
|
||||
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header py-2">
|
||||
<h6 class="modal-title"><i class="bi bi-plus-circle"></i> Add Repeater</h6>
|
||||
<h6 class="modal-title"><i class="bi bi-plus-circle"></i> {{ t('repeaters.add_modal_title') }}</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-0 d-flex flex-column">
|
||||
<div class="p-2 border-bottom">
|
||||
<input type="text" class="form-control form-control-sm" id="deviceRptSearch"
|
||||
placeholder="Search repeaters on device..." autocomplete="off">
|
||||
placeholder="{{ t('repeaters.add_search_ph') }}" autocomplete="off">
|
||||
</div>
|
||||
<div id="deviceRptList" style="overflow-y: auto;">
|
||||
<div class="text-muted small p-3">Loading device contacts...</div>
|
||||
<div class="text-muted small p-3">{{ t('repeaters.add_loading') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer py-2">
|
||||
<span class="me-auto small text-muted" id="deviceRptCount"></span>
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{{ t('common.close') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -243,28 +243,28 @@
|
||||
<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">Set password</span></h6>
|
||||
<h6 class="modal-title"><i class="bi bi-key"></i> <span id="passwordModalTitle">{{ t('repeaters.password.set_title') }}</span></h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</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" id="savePasswordWrap">
|
||||
<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" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" id="passwordSubmitBtn">Save</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="passwordSubmitBtn">{{ t('common.save') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -275,33 +275,34 @@
|
||||
<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-signpost-split"></i> Paths — <span id="pathModalName"></span></h6>
|
||||
<h6 class="modal-title"><i class="bi bi-signpost-split"></i> {{ t('repeaters.paths.title') }} — <span id="pathModalName"></span></h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body pb-1">
|
||||
<div class="small text-muted mb-2">
|
||||
Device path: <span class="font-monospace" id="pathModalCurrent">—</span>
|
||||
{{ t('repeaters.paths.device_path') }} <span class="font-monospace" id="pathModalCurrent">—</span>
|
||||
</div>
|
||||
<div class="path-section-header">
|
||||
<h6 class="mb-0 small fw-bold">Configured paths</h6>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" id="addPathBtn" title="Add path">
|
||||
<h6 class="mb-0 small fw-bold">{{ t('repeaters.paths.configured') }}</h6>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" id="addPathBtn" title="{{ t('repeaters.paths.add_title') }}">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="pathList"></div>
|
||||
<div class="d-flex justify-content-end gap-2 mt-1 mb-2">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="clearPathsBtn"
|
||||
title="Delete all configured paths from database">
|
||||
<i class="bi bi-trash"></i> Clear Paths
|
||||
title="{{ t('repeaters.paths.clear_title') }}">
|
||||
<i class="bi bi-trash"></i> {{ t('repeaters.paths.clear') }}
|
||||
</button>
|
||||
{# FLOOD is the device's own mode name — kept as-is in every language. #}
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" id="resetFloodBtn"
|
||||
title="Reset device path to FLOOD mode">
|
||||
<i class="bi bi-broadcast"></i> Reset to FLOOD
|
||||
title="{{ t('repeaters.paths.reset_flood_title') }}">
|
||||
<i class="bi bi-broadcast"></i> {{ t('repeaters.paths.reset_flood') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer py-2">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{{ t('common.close') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,32 +313,32 @@
|
||||
<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-signpost-split"></i> Add Path</h6>
|
||||
<h6 class="modal-title"><i class="bi bi-signpost-split"></i> {{ t('repeaters.addpath.title') }}</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-1">Hash Size</label>
|
||||
<label class="form-label small mb-1">{{ t('repeaters.addpath.hash_size') }}</label>
|
||||
<div class="btn-group btn-group-sm w-100" role="group">
|
||||
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash1" value="1" checked>
|
||||
<label class="btn btn-outline-secondary" for="pathHash1">1B (max 64)</label>
|
||||
<label class="btn btn-outline-secondary" for="pathHash1">{{ t('repeaters.addpath.hash_1b') }}</label>
|
||||
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash2" value="2">
|
||||
<label class="btn btn-outline-secondary" for="pathHash2">2B (max 32)</label>
|
||||
<label class="btn btn-outline-secondary" for="pathHash2">{{ t('repeaters.addpath.hash_2b') }}</label>
|
||||
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash3" value="3">
|
||||
<label class="btn btn-outline-secondary" for="pathHash3">3B (max 21)</label>
|
||||
<label class="btn btn-outline-secondary" for="pathHash3">{{ t('repeaters.addpath.hash_3b') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-1">Path (hex)</label>
|
||||
<label class="form-label small mb-1">{{ t('repeaters.addpath.path_hex') }}</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="form-control font-monospace" id="pathHexInput"
|
||||
placeholder="e.g. 5e,e7 or 5e34,e761" autocomplete="off">
|
||||
placeholder="{{ t('repeaters.addpath.path_hex_ph') }}" autocomplete="off">
|
||||
<button type="button" class="btn btn-outline-secondary" id="pickRepeaterBtn"
|
||||
title="Pick repeater from list">
|
||||
title="{{ t('repeaters.addpath.pick_list_title') }}">
|
||||
<i class="bi bi-plus-circle"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" id="pickRepeaterMapBtn"
|
||||
title="Pick repeater from map">
|
||||
title="{{ t('repeaters.addpath.pick_map_title') }}">
|
||||
<i class="bi bi-geo-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -348,24 +349,24 @@
|
||||
<div class="d-flex border-bottom">
|
||||
<div class="btn-group btn-group-sm flex-shrink-0" role="group">
|
||||
<input type="radio" class="btn-check" name="repeaterSearchMode" id="rptSearchName" value="name" checked>
|
||||
<label class="btn btn-outline-secondary border-0 rounded-0" for="rptSearchName">Name</label>
|
||||
<label class="btn btn-outline-secondary border-0 rounded-0" for="rptSearchName">{{ t('repeaters.addpath.by_name') }}</label>
|
||||
<input type="radio" class="btn-check" name="repeaterSearchMode" id="rptSearchId" value="id">
|
||||
<label class="btn btn-outline-secondary border-0 rounded-0" for="rptSearchId">ID</label>
|
||||
<label class="btn btn-outline-secondary border-0 rounded-0" for="rptSearchId">{{ t('repeaters.addpath.by_id') }}</label>
|
||||
</div>
|
||||
<input type="text" class="form-control form-control-sm border-0"
|
||||
id="repeaterSearch" placeholder="Search by name..." autocomplete="off">
|
||||
id="repeaterSearch" placeholder="{{ t('repeaters.addpath.search_ph') }}" autocomplete="off">
|
||||
</div>
|
||||
<div id="repeaterList2" style="max-height: 180px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-1">Label (optional)</label>
|
||||
<label class="form-label small mb-1">{{ t('repeaters.addpath.label') }}</label>
|
||||
<input type="text" class="form-control form-control-sm" id="pathLabelInput"
|
||||
placeholder="e.g. via Mountain RPT" maxlength="50">
|
||||
placeholder="{{ t('repeaters.addpath.label_ph') }}" maxlength="50">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer py-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" id="savePathBtn">Add Path</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">{{ t('common.cancel') }}</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" id="savePathBtn">{{ t('repeaters.addpath.title') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -376,23 +377,23 @@
|
||||
<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> Select Repeater from Map</h6>
|
||||
<h6 class="modal-title"><i class="bi bi-geo-alt"></i> {{ t('repeaters.map.title') }}</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-0">
|
||||
<div class="d-flex align-items-center gap-2 px-3 py-2 border-bottom bg-light">
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="rptMapCachedSwitch">
|
||||
<label class="form-check-label small" for="rptMapCachedSwitch">Cached</label>
|
||||
<label class="form-check-label small" for="rptMapCachedSwitch">{{ t('repeaters.map.cached') }}</label>
|
||||
</div>
|
||||
<span class="text-muted small ms-auto" id="rptMapCount"></span>
|
||||
</div>
|
||||
<div id="rptLeafletMap" style="height: 400px; width: 100%;"></div>
|
||||
</div>
|
||||
<div class="modal-footer py-2">
|
||||
<span class="me-auto small text-muted" id="rptMapSelected">Click a repeater on the map</span>
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" id="rptMapAddBtn" disabled>Add</button>
|
||||
<span class="me-auto small text-muted" id="rptMapSelected">{{ t('repeaters.map.hint') }}</span>
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">{{ t('common.close') }}</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" id="rptMapAddBtn" disabled>{{ t('common.add') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -403,17 +404,17 @@
|
||||
<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-trash"></i> Remove Repeater</h6>
|
||||
<h6 class="modal-title"><i class="bi bi-trash"></i> {{ t('repeaters.remove.title') }}</h6>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-1">Remove <strong id="removeRepeaterName"></strong> from My Repeaters?</p>
|
||||
<p class="small text-muted mb-0">The saved password will be deleted.
|
||||
The contact on the device is not affected.</p>
|
||||
{# Filled by JS via tHtml() so the name can sit anywhere in the sentence. #}
|
||||
<p class="mb-1" id="removeRepeaterQuestion"></p>
|
||||
<p class="small text-muted mb-0">{{ t('repeaters.remove.note') }}</p>
|
||||
</div>
|
||||
<div class="modal-footer py-2">
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-sm btn-danger" id="removeRepeaterConfirmBtn">Remove</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-danger" id="removeRepeaterConfirmBtn">{{ t('common.remove') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -423,7 +424,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">My Repeaters</strong>
|
||||
<strong class="me-auto">{{ t('repeaters.title') }}</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
<div class="toast-body"></div>
|
||||
|
||||
@@ -8,6 +8,16 @@
|
||||
"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.move_down": "Move down",
|
||||
"common.move_up": "Move up",
|
||||
"common.remove": "Remove",
|
||||
"common.save": "Save",
|
||||
|
||||
"console.clear_title": "Clear output history",
|
||||
"console.connect_failed": "Failed to connect: {error}",
|
||||
"console.error_prefix": "Error: {error}",
|
||||
@@ -41,6 +51,122 @@
|
||||
"logs.pause_title": "Pause/Resume",
|
||||
"logs.title": "System Log",
|
||||
|
||||
"repeaters.add": "Add repeater",
|
||||
"repeaters.add_loading": "Loading device contacts...",
|
||||
"repeaters.add_modal_title": "Add Repeater",
|
||||
"repeaters.add_none_found": "No repeaters found on the device.",
|
||||
"repeaters.add_search_ph": "Search repeaters on device...",
|
||||
"repeaters.added_badge": "Added",
|
||||
"repeaters.addpath.by_id": "ID",
|
||||
"repeaters.addpath.by_name": "Name",
|
||||
"repeaters.addpath.hash_1b": "1B (max 64)",
|
||||
"repeaters.addpath.hash_2b": "2B (max 32)",
|
||||
"repeaters.addpath.hash_3b": "3B (max 21)",
|
||||
"repeaters.addpath.hash_size": "Hash Size",
|
||||
"repeaters.addpath.label": "Label (optional)",
|
||||
"repeaters.addpath.label_ph": "e.g. via Mountain RPT",
|
||||
"repeaters.addpath.path_hex": "Path (hex)",
|
||||
"repeaters.addpath.path_hex_ph": "e.g. 5e,e7 or 5e34,e761",
|
||||
"repeaters.addpath.pick_list_title": "Pick repeater from list",
|
||||
"repeaters.addpath.pick_map_title": "Pick repeater from map",
|
||||
"repeaters.addpath.search_ph": "Search by name...",
|
||||
"repeaters.addpath.title": "Add Path",
|
||||
"repeaters.confirm.clear_paths": "Clear all configured paths?\n\nThis will delete all paths from the database. The device path will not be changed.",
|
||||
"repeaters.confirm.reset_flood": "Reset device path to FLOOD?\n\nThis resets the path on the device only. Your configured paths will be kept.",
|
||||
"repeaters.count": {
|
||||
"one": "{count} repeater",
|
||||
"other": "{count} repeaters"
|
||||
},
|
||||
"repeaters.empty": "No repeaters yet.",
|
||||
"repeaters.empty_hint": "Use <strong>Add repeater</strong> to pick repeaters stored on your device.",
|
||||
"repeaters.empty_note": "Only repeaters saved in the device contacts can be managed.",
|
||||
"repeaters.load_failed": "Failed to load repeaters",
|
||||
"repeaters.map.cached": "Cached",
|
||||
"repeaters.map.hint": "Click a repeater on the map",
|
||||
"repeaters.map.last_seen": "Last seen: {time}",
|
||||
"repeaters.map.title": "Select Repeater from Map",
|
||||
"repeaters.on_device_count": {
|
||||
"one": "{count} repeater on device",
|
||||
"other": "{count} repeaters on device"
|
||||
},
|
||||
"repeaters.password.change_title": "Change password",
|
||||
"repeaters.password.input_ph": "Repeater password",
|
||||
"repeaters.password.login_btn": "Log in",
|
||||
"repeaters.password.login_info": "Enter the repeater password to log in.",
|
||||
"repeaters.password.login_title": "Log in",
|
||||
"repeaters.password.remember": "Remember password (stored in the app database)",
|
||||
"repeaters.password.retry_hint": "Check the password and try again.",
|
||||
"repeaters.password.set_info": "The password is stored in the app database and used to log in to this repeater without asking again.",
|
||||
"repeaters.password.set_title": "Set password",
|
||||
"repeaters.password.toggle_title": "Show/hide password",
|
||||
"repeaters.paths.add_title": "Add path",
|
||||
"repeaters.paths.apply_title": "Set as device path",
|
||||
"repeaters.paths.clear": "Clear Paths",
|
||||
"repeaters.paths.clear_title": "Delete all configured paths from database",
|
||||
"repeaters.paths.configured": "Configured paths",
|
||||
"repeaters.paths.device_path": "Device path:",
|
||||
"repeaters.paths.is_primary_title": "Primary path",
|
||||
"repeaters.paths.load_failed": "Failed to load paths",
|
||||
"repeaters.paths.none": "No paths configured. Use + to add.",
|
||||
"repeaters.paths.reset_flood": "Reset to FLOOD",
|
||||
"repeaters.paths.reset_flood_title": "Reset device path to FLOOD mode",
|
||||
"repeaters.paths.set_primary_title": "Set as primary",
|
||||
"repeaters.paths.title": "Paths",
|
||||
"repeaters.picker.none": "No repeaters found",
|
||||
"repeaters.picker.shared_prefix": {
|
||||
"one": "{count} repeater shares this prefix",
|
||||
"other": "{count} repeaters share this prefix"
|
||||
},
|
||||
"repeaters.refresh_title": "Refresh list",
|
||||
"repeaters.remove.note": "The saved password will be deleted. The contact on the device is not affected.",
|
||||
"repeaters.remove.question": "Remove <strong>{name}</strong> from My Repeaters?",
|
||||
"repeaters.remove.title": "Remove Repeater",
|
||||
"repeaters.row.last_login": "last login: {role}",
|
||||
"repeaters.row.logging_in": "Logging in… (may take up to 60 s on flood paths)",
|
||||
"repeaters.row.not_on_device": "Not stored on the device",
|
||||
"repeaters.row.remove_title": "Remove from list",
|
||||
"repeaters.row.set_path_title": "Set path",
|
||||
"repeaters.term": "repeater",
|
||||
"repeaters.title": "My Repeaters",
|
||||
"repeaters.toast.add_failed": "Failed to add repeater",
|
||||
"repeaters.toast.added": "{name} added",
|
||||
"repeaters.toast.adjacent_dupes": {
|
||||
"one": "Adjacent duplicate hop: {hops}",
|
||||
"other": "Adjacent duplicate hops: {hops}"
|
||||
},
|
||||
"repeaters.toast.ambiguous_prefix": {
|
||||
"one": "⚠ Ambiguous prefix: {hops}. Consider using a larger hash size.",
|
||||
"other": "⚠ Ambiguous prefixes: {hops}. Consider using a larger hash size."
|
||||
},
|
||||
"repeaters.toast.clear_failed": "Clear failed",
|
||||
"repeaters.toast.contacts_load_failed": "Failed to load device contacts",
|
||||
"repeaters.toast.dupes": {
|
||||
"one": "Duplicate hop: {hops}",
|
||||
"other": "Duplicate hops: {hops}"
|
||||
},
|
||||
"repeaters.toast.hop_adjacent_self": "{hop} cannot be adjacent to itself",
|
||||
"repeaters.toast.hop_already_used": "{hop} is already in the path",
|
||||
"repeaters.toast.logged_in": "Logged in to {name} as {role}",
|
||||
"repeaters.toast.login_failed": "Login failed",
|
||||
"repeaters.toast.login_in_progress": "Another login is already in progress",
|
||||
"repeaters.toast.not_on_device": "This repeater is not stored on the device — it cannot be managed",
|
||||
"repeaters.toast.password_empty": "Password cannot be empty",
|
||||
"repeaters.toast.password_save_failed": "Failed to save password",
|
||||
"repeaters.toast.password_saved": "Password saved",
|
||||
"repeaters.toast.path_add_failed": "Failed to add path",
|
||||
"repeaters.toast.path_added": "Path added",
|
||||
"repeaters.toast.path_hex_required": "Path hex is required",
|
||||
"repeaters.toast.path_set_failed": "Failed to set device path",
|
||||
"repeaters.toast.path_updated": "Device path updated",
|
||||
"repeaters.toast.paths_cleared": {
|
||||
"one": "{count} path cleared",
|
||||
"other": "{count} paths cleared"
|
||||
},
|
||||
"repeaters.toast.remove_failed": "Failed to remove repeater",
|
||||
"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"
|
||||
|
||||
@@ -10,6 +10,16 @@
|
||||
"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.move_down": "Przesuń w dół",
|
||||
"common.move_up": "Przesuń w górę",
|
||||
"common.remove": "Usuń",
|
||||
"common.save": "Zapisz",
|
||||
|
||||
"console.clear_title": "Wyczyść zapis konsoli",
|
||||
"console.connect_failed": "Nie udało się połączyć: {error}",
|
||||
"console.error_prefix": "Błąd: {error}",
|
||||
@@ -47,6 +57,136 @@
|
||||
"logs.pause_title": "Wstrzymaj/Wznów",
|
||||
"logs.title": "Dziennik systemowy",
|
||||
|
||||
"repeaters.add": "Dodaj repeater",
|
||||
"repeaters.add_loading": "Wczytywanie kontaktów z urządzenia...",
|
||||
"repeaters.add_modal_title": "Dodaj repeater",
|
||||
"repeaters.add_none_found": "Nie znaleziono repeaterów na urządzeniu.",
|
||||
"repeaters.add_search_ph": "Szukaj repeaterów na urządzeniu...",
|
||||
"repeaters.added_badge": "Dodany",
|
||||
"repeaters.addpath.by_id": "ID",
|
||||
"repeaters.addpath.by_name": "Nazwa",
|
||||
"repeaters.addpath.hash_1b": "1B (maks. 64)",
|
||||
"repeaters.addpath.hash_2b": "2B (maks. 32)",
|
||||
"repeaters.addpath.hash_3b": "3B (maks. 21)",
|
||||
"repeaters.addpath.hash_size": "Rozmiar hasha",
|
||||
"repeaters.addpath.label": "Etykieta (opcjonalnie)",
|
||||
"repeaters.addpath.label_ph": "np. przez Mountain RPT",
|
||||
"repeaters.addpath.path_hex": "Ścieżka (hex)",
|
||||
"repeaters.addpath.path_hex_ph": "np. 5e,e7 lub 5e34,e761",
|
||||
"repeaters.addpath.pick_list_title": "Wybierz repeater z listy",
|
||||
"repeaters.addpath.pick_map_title": "Wybierz repeater z mapy",
|
||||
"repeaters.addpath.search_ph": "Szukaj po nazwie...",
|
||||
"repeaters.addpath.title": "Dodaj ścieżkę",
|
||||
"repeaters.confirm.clear_paths": "Usunąć wszystkie skonfigurowane ścieżki?\n\nSpowoduje to usunięcie wszystkich ścieżek z bazy danych. Ścieżka na urządzeniu nie zostanie zmieniona.",
|
||||
"repeaters.confirm.reset_flood": "Przywrócić na urządzeniu tryb FLOOD?\n\nZmienia to tylko ścieżkę na urządzeniu. Twoje skonfigurowane ścieżki zostaną zachowane.",
|
||||
"repeaters.count": {
|
||||
"one": "{count} repeater",
|
||||
"few": "{count} repeatery",
|
||||
"many": "{count} repeaterów",
|
||||
"other": "{count} repeatera"
|
||||
},
|
||||
"repeaters.empty": "Brak repeaterów.",
|
||||
"repeaters.empty_hint": "Użyj <strong>Dodaj repeater</strong>, aby wybrać repeatery zapisane na Twoim urządzeniu.",
|
||||
"repeaters.empty_note": "Zarządzać można tylko repeaterami zapisanymi w kontaktach urządzenia.",
|
||||
"repeaters.load_failed": "Nie udało się wczytać repeaterów",
|
||||
"repeaters.map.cached": "Z pamięci",
|
||||
"repeaters.map.hint": "Kliknij repeater na mapie",
|
||||
"repeaters.map.last_seen": "Ostatnio widziany: {time}",
|
||||
"repeaters.map.title": "Wybierz repeater z mapy",
|
||||
"repeaters.on_device_count": {
|
||||
"one": "{count} repeater na urządzeniu",
|
||||
"few": "{count} repeatery na urządzeniu",
|
||||
"many": "{count} repeaterów na urządzeniu",
|
||||
"other": "{count} repeatera na urządzeniu"
|
||||
},
|
||||
"repeaters.password.change_title": "Zmień hasło",
|
||||
"repeaters.password.input_ph": "Hasło repeatera",
|
||||
"repeaters.password.login_btn": "Zaloguj",
|
||||
"repeaters.password.login_info": "Podaj hasło repeatera, aby się zalogować.",
|
||||
"repeaters.password.login_title": "Logowanie",
|
||||
"repeaters.password.remember": "Zapamiętaj hasło (przechowywane w bazie aplikacji)",
|
||||
"repeaters.password.retry_hint": "Sprawdź hasło i spróbuj ponownie.",
|
||||
"repeaters.password.set_info": "Hasło jest przechowywane w bazie aplikacji i służy do logowania się do tego repeatera bez ponownego pytania.",
|
||||
"repeaters.password.set_title": "Ustaw hasło",
|
||||
"repeaters.password.toggle_title": "Pokaż/ukryj hasło",
|
||||
"repeaters.paths.add_title": "Dodaj ścieżkę",
|
||||
"repeaters.paths.apply_title": "Ustaw jako ścieżkę urządzenia",
|
||||
"repeaters.paths.clear": "Wyczyść ścieżki",
|
||||
"repeaters.paths.clear_title": "Usuń z bazy wszystkie skonfigurowane ścieżki",
|
||||
"repeaters.paths.configured": "Skonfigurowane ścieżki",
|
||||
"repeaters.paths.device_path": "Ścieżka urządzenia:",
|
||||
"repeaters.paths.is_primary_title": "Ścieżka główna",
|
||||
"repeaters.paths.load_failed": "Nie udało się wczytać ścieżek",
|
||||
"repeaters.paths.none": "Brak skonfigurowanych ścieżek. Użyj +, aby dodać.",
|
||||
"repeaters.paths.reset_flood": "Przywróć FLOOD",
|
||||
"repeaters.paths.reset_flood_title": "Przywróć na urządzeniu tryb FLOOD",
|
||||
"repeaters.paths.set_primary_title": "Ustaw jako główną",
|
||||
"repeaters.paths.title": "Ścieżki",
|
||||
"repeaters.picker.none": "Nie znaleziono repeaterów",
|
||||
"repeaters.picker.shared_prefix": {
|
||||
"one": "{count} repeater ma ten prefiks",
|
||||
"few": "{count} repeatery mają ten prefiks",
|
||||
"many": "{count} repeaterów ma ten prefiks",
|
||||
"other": "{count} repeatera ma ten prefiks"
|
||||
},
|
||||
"repeaters.refresh_title": "Odśwież listę",
|
||||
"repeaters.remove.note": "Zapisane hasło zostanie usunięte. Kontakt na urządzeniu pozostanie bez zmian.",
|
||||
"repeaters.remove.question": "Usunąć <strong>{name}</strong> z Moich repeaterów?",
|
||||
"repeaters.remove.title": "Usuń repeater",
|
||||
"repeaters.row.last_login": "ostatnie logowanie: {role}",
|
||||
"repeaters.row.logging_in": "Logowanie… (na ścieżkach flood może potrwać do 60 s)",
|
||||
"repeaters.row.not_on_device": "Niezapisany na urządzeniu",
|
||||
"repeaters.row.remove_title": "Usuń z listy",
|
||||
"repeaters.row.set_path_title": "Ustaw ścieżkę",
|
||||
"repeaters.term": "repeater",
|
||||
"repeaters.title": "Moje repeatery",
|
||||
"repeaters.toast.add_failed": "Nie udało się dodać repeatera",
|
||||
"repeaters.toast.added": "Dodano {name}",
|
||||
"repeaters.toast.adjacent_dupes": {
|
||||
"one": "Sąsiadujący powtórzony hop: {hops}",
|
||||
"few": "Sąsiadujące powtórzone hopy: {hops}",
|
||||
"many": "Sąsiadujące powtórzone hopy: {hops}",
|
||||
"other": "Sąsiadujące powtórzone hopy: {hops}"
|
||||
},
|
||||
"repeaters.toast.ambiguous_prefix": {
|
||||
"one": "⚠ Niejednoznaczny prefiks: {hops}. Rozważ większy rozmiar hasha.",
|
||||
"few": "⚠ Niejednoznaczne prefiksy: {hops}. Rozważ większy rozmiar hasha.",
|
||||
"many": "⚠ Niejednoznaczne prefiksy: {hops}. Rozważ większy rozmiar hasha.",
|
||||
"other": "⚠ Niejednoznaczne prefiksy: {hops}. Rozważ większy rozmiar hasha."
|
||||
},
|
||||
"repeaters.toast.clear_failed": "Czyszczenie nie powiodło się",
|
||||
"repeaters.toast.contacts_load_failed": "Nie udało się wczytać kontaktów z urządzenia",
|
||||
"repeaters.toast.dupes": {
|
||||
"one": "Powtórzony hop: {hops}",
|
||||
"few": "Powtórzone hopy: {hops}",
|
||||
"many": "Powtórzone hopy: {hops}",
|
||||
"other": "Powtórzone hopy: {hops}"
|
||||
},
|
||||
"repeaters.toast.hop_adjacent_self": "{hop} nie może sąsiadować sam ze sobą",
|
||||
"repeaters.toast.hop_already_used": "{hop} jest już w ścieżce",
|
||||
"repeaters.toast.logged_in": "Zalogowano do {name} jako {role}",
|
||||
"repeaters.toast.login_failed": "Logowanie nie powiodło się",
|
||||
"repeaters.toast.login_in_progress": "Inne logowanie jest już w toku",
|
||||
"repeaters.toast.not_on_device": "Ten repeater nie jest zapisany na urządzeniu — nie można nim zarządzać",
|
||||
"repeaters.toast.password_empty": "Hasło nie może być puste",
|
||||
"repeaters.toast.password_save_failed": "Nie udało się zapisać hasła",
|
||||
"repeaters.toast.password_saved": "Hasło zapisane",
|
||||
"repeaters.toast.path_add_failed": "Nie udało się dodać ścieżki",
|
||||
"repeaters.toast.path_added": "Ścieżka dodana",
|
||||
"repeaters.toast.path_hex_required": "Ścieżka hex jest wymagana",
|
||||
"repeaters.toast.path_set_failed": "Nie udało się ustawić ścieżki urządzenia",
|
||||
"repeaters.toast.path_updated": "Ścieżka urządzenia zaktualizowana",
|
||||
"repeaters.toast.paths_cleared": {
|
||||
"one": "Wyczyszczono {count} ścieżkę",
|
||||
"few": "Wyczyszczono {count} ścieżki",
|
||||
"many": "Wyczyszczono {count} ścieżek",
|
||||
"other": "Wyczyszczono {count} ścieżki"
|
||||
},
|
||||
"repeaters.toast.remove_failed": "Nie udało się usunąć repeatera",
|
||||
"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"
|
||||
|
||||
@@ -23,8 +23,14 @@ forum post — it just makes the app harder to use.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Protocol and radio terms** | flood, direct, hop, path, advert, ACK, RSSI, SNR, LoRa, MQTT, broker, telemetry, pubkey, packet hash, spreading factor, bandwidth, coding rate |
|
||||
| **Mode names shown as values** | `Flood`, `Direct`, `FLOOD` — these are states the device reports, not descriptions |
|
||||
| **Protocol and radio terms** | hop, advert, ACK, RSSI, SNR, LoRa, MQTT, broker, telemetry, pubkey, hash, spreading factor, bandwidth, coding rate |
|
||||
| **Node roles** | Companion, Repeater, Room Server, Sensor — and the codes `COM`, `REP`, `ROOM`, `SENS` |
|
||||
|
||||
Ordinary words **are** translated even when they describe protocol things — "path" becomes
|
||||
"ścieżka", "label" becomes "etykieta". The rule is whether the word is a *value the device
|
||||
uses* or a *name the user reads*. `Flood` in a path field is a value; "Configured paths" is
|
||||
a heading.
|
||||
| **CLI surfaces** | everything the Console prints, its `help` screen, `Usage:` lines, command names |
|
||||
| **Log output** | log lines and the level names `DEBUG`, `INFO`, `WARNING`, `ERROR` |
|
||||
| **Device/firmware fields** | `radio.rxgain`, `advert.interval` and similar |
|
||||
|
||||
@@ -48,6 +48,15 @@ GLOSSARY = [
|
||||
# t('key'), tn('key', n), tHtml('key', {...}), t_html('key', name=x).
|
||||
# The lookbehind stops "format(" / ".at(" / "$t(" from matching.
|
||||
CALL_RE = re.compile(r'''(?<![\w.$])(t|tn|tHtml|t_html)\s*\(\s*(['"])((?:(?!\2).)*)\2''')
|
||||
|
||||
# The key is often chosen inline: tHtml(p.is_primary ? 'a.b' : 'c.d'). CALL_RE cannot see
|
||||
# those, because the first thing after "(" is not a quote. That produced false "unused
|
||||
# key" warnings and, worse, would have hidden a typo in either branch.
|
||||
TERNARY_CALL_RE = re.compile(
|
||||
r'''(?<![\w.$])(t|tn|tHtml|t_html)\s*\([^()'"]*\?\s*'''
|
||||
r'''(['"])((?:(?!\2).)*)\2\s*:\s*(['"])((?:(?!\4).)*)\4'''
|
||||
)
|
||||
|
||||
PARAM_RE = re.compile(r'\{(\w+)\}')
|
||||
SHADOW_RE = re.compile(r'(?:const|let|var)\s+t\s*=')
|
||||
|
||||
@@ -94,6 +103,11 @@ def scan_sources() -> tuple[dict[str, set[str]], dict[str, list[str]]]:
|
||||
kinds[key].add(func)
|
||||
sites[key].append(f'{rel}:{lineno}')
|
||||
|
||||
for func, _, key_true, _, key_false in TERNARY_CALL_RE.findall(line):
|
||||
for key in (key_true, key_false):
|
||||
kinds[key].add(func)
|
||||
sites[key].append(f'{rel}:{lineno}')
|
||||
|
||||
# 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):
|
||||
|
||||
Reference in New Issue
Block a user