From 3b34dd96460fd703d0a3d74d637ef4dc1124448e Mon Sep 17 00:00:00 2001 From: MarekWo Date: Fri, 31 Jul 2026 21:38:14 +0200 Subject: [PATCH] feat(i18n): translate the DM panel (stage 6) Covers app/templates/dm.html and app/static/js/dm.js: the sidebar, the searchable conversation selector, the composer, message bubbles and their delivery meta, the Contact Info modal, path management, and the repeater list/map pickers. The DM panel's path management and repeater pickers are the code the My Repeaters panel was adapted from, so its strings reuse the repeaters.* keys added in stage 3a rather than duplicating them. Shared chat chrome (filter bar, FAB titles, composer placeholder, status bar) goes to a new chat.* namespace so index.html and app.js can reuse it in stages 7 and 9, and console.status.* is promoted to common.connected/disconnected/ connecting now that two status bars want the same three words. formatRelativeTimeDm() was the last of the four local relative-time implementations; it is gone, folded into formatTimeAgo() from datetime-utils.js. The contact-info row uses the long form to match the contacts page, the map popup the short form to match My Repeaters. Also fixed along the way, both found by grepping this slice's shared strings against the already-translated panels: - repeaters.js reset the map picker label from JS in three places with a hardcoded English string, so the header translated on load and flipped back to English the moment the picker opened (missed in stage 3a). - tHtml() on a plural key always renders the "one" form, because only tn() passes the count to the resolver. That made the repeater picker's shared-prefix tooltip read "3 repeater ma ten prefiks". Fixed in both callers and now an error in scripts/i18n_check.py, which had no way to catch it. Left English on purpose: Flood/Direct/FLOOD and the SNR labels (glossary), and the 'Device path' label written to the DB on import - it is stored data, so translating it would freeze one language into the row. Catalog: 471 -> 526 keys, pl at 100%. Verified against the local container with i18n-diff.js plus a new i18n-stage6-dm.js that drives the panel through each modal, since the diff tool cannot see hidden ones. Co-Authored-By: Claude Opus 5 --- app/static/js/console.js | 8 +- app/static/js/dm.js | 234 ++++++++++++++++++------------------- app/static/js/repeaters.js | 12 +- app/templates/console.html | 2 +- app/templates/dm.html | 104 ++++++++--------- app/translations/en.json | 64 +++++++++- app/translations/pl.json | 66 ++++++++++- docs/whatsnew.md | 2 +- scripts/i18n_check.py | 10 ++ 9 files changed, 315 insertions(+), 187 deletions(-) diff --git a/app/static/js/console.js b/app/static/js/console.js index 782ec63..4ef48ae 100644 --- a/app/static/js/console.js +++ b/app/static/js/console.js @@ -60,7 +60,7 @@ function connectWebSocket() { updateStatus('disconnected'); enableInput(false); // Transient session event — show inline but don't persist to transcript - addMessage(t('console.status.disconnected'), 'error', false); + addMessage(t('common.disconnected'), 'error', false); // Clear pending command indicator if (pendingCommandDiv) { @@ -245,15 +245,15 @@ function updateStatus(status) { switch (status) { case 'connected': - text.textContent = t('console.status.connected'); + text.textContent = t('common.connected'); text.className = 'text-success'; break; case 'disconnected': - text.textContent = t('console.status.disconnected'); + text.textContent = t('common.disconnected'); text.className = 'text-danger'; break; case 'connecting': - text.textContent = t('console.status.connecting'); + text.textContent = t('common.connecting'); text.className = 'text-warning'; break; } diff --git a/app/static/js/dm.js b/app/static/js/dm.js index 55b6556..599af83 100644 --- a/app/static/js/dm.js +++ b/app/static/js/dm.js @@ -76,7 +76,7 @@ let chatSocket = null; // SocketIO connection to /chat namespace * Get display-friendly name (truncate full pubkeys to short prefix) */ function displayName(name) { - if (!name) return 'Unknown'; + if (!name) return t('common.unknown'); if (/^[0-9a-f]{12,64}$/i.test(name)) return name.substring(0, 8) + '...'; return name; } @@ -111,7 +111,7 @@ function resolveConversationName(conversationId) { // Fallback if (conversationId && conversationId.startsWith('name_')) return conversationId.substring(5); if (conversationId && conversationId.startsWith('pk_')) return conversationId.substring(3, 11) + '...'; - return 'Unknown'; + return t('common.unknown'); } let chatSocketEverConnected = false; @@ -196,8 +196,8 @@ function connectChatSocket() { statusEl.className = 'bi bi-check2 dm-status delivered'; const tooltip = []; if (data.snr != null) tooltip.push(`SNR: ${data.snr}`); - if (data.route_type) tooltip.push(`Route: ${data.route_type}`); - statusEl.title = tooltip.length > 0 ? tooltip.join(', ') : 'Delivered'; + if (data.route_type) tooltip.push(t('dm.route', { route: data.route_type })); + statusEl.title = tooltip.length > 0 ? tooltip.join(', ') : t('dm.status.delivered'); // Unwrap status icon from wrapper span const wrapper = statusEl.closest('[data-dm-id]'); if (wrapper) { @@ -211,7 +211,7 @@ function connectChatSocket() { chatSocket.on('dm_retry_status', (data) => { if (!data.dm_id) return; const info = document.querySelector(`.dm-retry-info[data-dm-id="${data.dm_id}"]`); - if (info) info.textContent = `Attempt ${data.attempt}/${data.max_attempts}`; + if (info) info.textContent = t('dm.attempt', { n: data.attempt, max: data.max_attempts }); }); // DM retry exhausted — mark as failed, show final attempt count @@ -223,7 +223,7 @@ function connectChatSocket() { const icon = wrapper.querySelector('.dm-status'); if (icon) { icon.className = 'bi bi-x-circle dm-status timeout'; - icon.title = 'Delivery failed — all retries exhausted'; + icon.title = t('dm.status.failed'); } wrapper.removeAttribute('onclick'); wrapper.classList.remove('dm-status-unknown'); @@ -232,7 +232,7 @@ function connectChatSocket() { const info = document.querySelector(`.dm-retry-info[data-dm-id="${data.dm_id}"]`); if (info) { if (data.attempt && data.max_attempts) { - info.textContent = `Attempt ${data.attempt}/${data.max_attempts}`; + info.textContent = t('dm.attempt', { n: data.attempt, max: data.max_attempts }); } else { info.textContent = ''; } @@ -250,9 +250,9 @@ function connectChatSocket() { if (!msgDiv) return; // Build delivery meta text const parts = []; - if (data.attempt && data.max_attempts) parts.push(`Attempt ${data.attempt}/${data.max_attempts}`); + if (data.attempt && data.max_attempts) parts.push(t('dm.attempt', { n: data.attempt, max: data.max_attempts })); const hexRoute = formatDmRoute(data.path, data.hash_size); - if (hexRoute) parts.push(`Route: ${hexRoute}`); + if (hexRoute) parts.push(t('dm.route', { route: hexRoute })); if (parts.length > 0) { let metaEl = msgDiv.querySelector('.dm-delivery-meta'); if (!metaEl) { @@ -660,7 +660,7 @@ function renderDropdownItems(query) { if (filteredConvs.length > 0) { const sep = document.createElement('div'); sep.className = 'dm-dropdown-separator'; - sep.textContent = 'Recent conversations'; + sep.textContent = t('dm.dropdown.recent'); dropdown.appendChild(sep); filteredConvs.forEach(item => { @@ -672,7 +672,7 @@ function renderDropdownItems(query) { if (filteredContacts.length > 0) { const sep = document.createElement('div'); sep.className = 'dm-dropdown-separator'; - sep.textContent = 'Contacts'; + sep.textContent = t('dm.dropdown.contacts'); dropdown.appendChild(sep); filteredContacts.forEach(contact => { @@ -686,7 +686,7 @@ function renderDropdownItems(query) { if (filteredConvs.length === 0 && filteredContacts.length === 0) { const empty = document.createElement('div'); empty.className = 'dm-dropdown-separator text-center'; - empty.textContent = q ? 'No matches' : 'No contacts available'; + empty.textContent = q ? t('dm.dropdown.no_matches') : t('dm.dropdown.no_contacts'); dropdown.appendChild(empty); } } @@ -747,7 +747,7 @@ function populateDmSidebar(query) { if (filteredConvs.length > 0) { const sep = document.createElement('div'); sep.className = 'dm-sidebar-separator'; - sep.textContent = 'Recent conversations'; + sep.textContent = t('dm.dropdown.recent'); list.appendChild(sep); filteredConvs.forEach(item => { @@ -759,7 +759,7 @@ function populateDmSidebar(query) { if (filteredContacts.length > 0) { const sep = document.createElement('div'); sep.className = 'dm-sidebar-separator'; - sep.textContent = 'Contacts'; + sep.textContent = t('dm.dropdown.contacts'); list.appendChild(sep); filteredContacts.forEach(contact => { @@ -773,7 +773,7 @@ function populateDmSidebar(query) { if (filteredConvs.length === 0 && filteredContacts.length === 0) { const empty = document.createElement('div'); empty.className = 'dm-sidebar-separator text-center'; - empty.textContent = q ? 'No matches' : 'No contacts available'; + empty.textContent = q ? t('dm.dropdown.no_matches') : t('dm.dropdown.no_contacts'); list.appendChild(empty); } } @@ -923,7 +923,7 @@ async function selectConversation(conversationId) { const sendBtn = document.getElementById('dmSendBtn'); if (input) { input.disabled = false; - input.placeholder = `Message ${displayName(currentRecipient)}...`; + input.placeholder = t('dm.msg_input_ph', { name: displayName(currentRecipient) }); } if (sendBtn) { sendBtn.disabled = false; @@ -960,7 +960,7 @@ function clearConversation() { const sendBtn = document.getElementById('dmSendBtn'); if (input) { input.disabled = true; - input.placeholder = 'Type a message...'; + input.placeholder = t('chat.input_ph'); input.value = ''; } if (sendBtn) { @@ -973,8 +973,8 @@ function clearConversation() { container.innerHTML = `
-

Select a conversation

- Choose from the list or start a new chat from channel messages +

${tHtml('dm.empty.select')}

+ ${tHtml('dm.empty.select_hint')}
`; } @@ -1013,17 +1013,9 @@ function findCurrentContact() { return findCurrentContactByConvId(currentConversationId); } -/** - * Minimal relative time formatter. - */ -function formatRelativeTimeDm(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(). This was the last of +// the four local copies. The contact-info row uses the long form to match the contacts +// page; the map popup uses the short form to match the My Repeaters map picker. /** * Populate the Contact Info modal body. @@ -1034,7 +1026,7 @@ function populateContactInfoModal() { const contact = findCurrentContact(); if (!contact) { - body.innerHTML = '

No contact information available.

'; + body.innerHTML = `

${tHtml('dm.no_contact_info')}

`; return; } @@ -1063,11 +1055,11 @@ function populateContactInfoModal() { keyDiv.className = 'text-muted small font-monospace mb-2'; keyDiv.style.cursor = 'pointer'; keyDiv.textContent = contact.public_key_prefix || contact.public_key?.substring(0, 12) || ''; - keyDiv.title = 'Click to copy full public key'; + keyDiv.title = t('dm.copy_pubkey_title'); keyDiv.onclick = () => { const pk = contact.public_key || contact.public_key_prefix || ''; navigator.clipboard.writeText(pk).then(() => { - showNotification('Public key copied', 'info'); + showNotification(t('contacts.toast.pubkey_copied'), 'info'); }).catch(() => {}); }; body.appendChild(keyDiv); @@ -1081,7 +1073,7 @@ function populateContactInfoModal() { else if (diff < 3600) icon = '🟡'; const div = document.createElement('div'); div.className = 'small mb-2'; - div.textContent = `${icon} Last advert: ${formatRelativeTimeDm(ts)}`; + div.textContent = `${icon} ${t('contacts.last_advert', { time: formatTimeAgo(ts, { long: true }) })}`; body.appendChild(div); } @@ -1103,9 +1095,9 @@ function populateContactInfoModal() { const pathHex = contact.out_path ? contact.out_path.substring(0, hopCount * hashSize * 2) : ''; div.innerHTML = ` - ${mode} (${hops} hops) + ${escapeHtml(mode)} (${tn('contacts.hops', hops)}) ${pathHex ? `` : ''} @@ -1125,6 +1117,9 @@ function populateContactInfoModal() { body: JSON.stringify({ path_hex: pathHex, hash_size: hashSize, + // Stored in the DB, not rendered chrome - it stays + // English so the row does not change meaning when + // the operator switches the UI language later. label: 'Device path', is_primary: true }) @@ -1132,12 +1127,12 @@ function populateContactInfoModal() { const data = await response.json(); if (data.success) { await renderPathList(pubkey); - showNotification('Device path imported', 'info'); + showNotification(t('dm.toast.device_path_imported'), 'info'); } else { - showNotification(data.error || 'Import failed', 'danger'); + showNotification(data.error || t('dm.toast.import_failed'), 'danger'); } } catch (e) { - showNotification('Import failed', 'danger'); + showNotification(t('dm.toast.import_failed'), 'danger'); } }); } @@ -1209,7 +1204,7 @@ async function loadMessages() { // Always update placeholder with best known name const msgInput = document.getElementById('dmMessageInput'); if (msgInput) { - msgInput.placeholder = `Message ${displayName(currentRecipient)}...`; + msgInput.placeholder = t('dm.msg_input_ph', { name: displayName(currentRecipient) }); } // Keep search input in sync const searchInput = document.getElementById('dmContactSearchInput'); @@ -1225,11 +1220,11 @@ async function loadMessages() { updateLastRefresh(); } else { - container.innerHTML = '
Error loading messages
'; + container.innerHTML = `
${tHtml('dm.load_messages_error')}
`; } } catch (error) { console.error('Error loading messages:', error); - container.innerHTML = '
Failed to load messages
'; + container.innerHTML = `
${tHtml('dm.load_messages_failed')}
`; } } @@ -1244,8 +1239,8 @@ function displayMessages(messages) { container.innerHTML = `
-

No messages yet

- Send a message to start the conversation +

${tHtml('dm.empty.no_messages')}

+ ${tHtml('dm.empty.no_messages_hint')}
`; lastMessageTimestamp = 0; @@ -1270,12 +1265,12 @@ function displayMessages(messages) { const ackAttr = msg.expected_ack ? ` data-ack="${msg.expected_ack}"` : ''; const dmIdAttr = msg.id ? ` data-dm-id="${msg.id}"` : ''; if (msg.status === 'delivered') { - let title = 'Delivered'; + let title = t('dm.status.delivered'); if (msg.delivery_attempt && msg.delivery_max_attempts) { title += ` (${msg.delivery_attempt}/${msg.delivery_max_attempts})`; } const route = formatDmRoute(msg.delivery_path, msg.delivery_path_hash_size || msg.path_hash_size); - if (route) title += `, Route: ${route}`; + if (route) title += `, ${t('dm.route', { route: route })}`; else if (msg.delivery_route) title += `, ${msg.delivery_route.replace('PATH_', '')}`; if (msg.delivery_snr !== null && msg.delivery_snr !== undefined) { title += `, SNR: ${msg.delivery_snr.toFixed(1)} dB`; @@ -1283,9 +1278,9 @@ function displayMessages(messages) { if (msg.delivery_route) title += ` (${msg.delivery_route})`; statusIcon = ``; } else if (msg.status === 'failed') { - statusIcon = ``; + statusIcon = ``; } else if (msg.status === 'pending') { - statusIcon = ``; + statusIcon = ``; } else { // No ACK received — show clickable "?" with retry counter statusIcon = ``; @@ -1310,7 +1305,7 @@ function displayMessages(messages) { && msg.delivery_attempt) { const parts = []; if (msg.delivery_attempt && msg.delivery_max_attempts) { - parts.push(`Attempt ${msg.delivery_attempt}/${msg.delivery_max_attempts}`); + parts.push(t('dm.attempt', { n: msg.delivery_attempt, max: msg.delivery_max_attempts })); } // Show route only for delivered messages (not failed) if (msg.status === 'delivered') { @@ -1328,7 +1323,7 @@ function displayMessages(messages) { let retryInfo = ''; if (msg.is_own) { const isPending = !msg.status || (msg.status !== 'delivered' && msg.status !== 'failed'); - const initialText = isPending && msg.expected_ack ? 'Sending...' : ''; + const initialText = isPending && msg.expected_ack ? t('dm.status.sending') : ''; retryInfo = `
${initialText}
`; } @@ -1339,7 +1334,7 @@ function displayMessages(messages) { // only raw resend (arrow-repeat). const resendBtn = msg.is_own ? `
-
@@ -1404,17 +1399,17 @@ async function sendMessage() { if (data.success) { input.value = ''; updateCharCounter(); - showNotification('Message sent', 'success'); + showNotification(t('dm.toast.sent'), 'success'); // Reload messages once to show sent message // ACK delivery updates arrive via SocketIO in real-time await loadMessages(); } else { - showNotification('Failed to send: ' + data.error, 'danger'); + showNotification(t('dm.toast.send_failed', { error: data.error }), 'danger'); } } catch (error) { console.error('Error sending message:', error); - showNotification('Failed to send message', 'danger'); + showNotification(t('dm.toast.send_error'), 'danger'); } finally { if (sendBtn) sendBtn.disabled = false; input.focus(); @@ -1547,10 +1542,10 @@ function buildDmRouteHtml(hexPath, hashSize) { const short = segments.length > 4 ? `${segments[0]}\u2192...\u2192${segments[segments.length - 1]}` : segments.join('\u2192'); - if (segments.length <= 4) return `Route: ${short}`; + if (segments.length <= 4) return tHtml('dm.route', { route: short }); const hs = hashSize || 1; const escaped = hexPath.replace(/'/g, "\\'"); - return `Route: ${short}`; + return `${tHtml('dm.route', { route: short })}`; } /** @@ -1569,13 +1564,13 @@ function showDmRoutePopup(element, hexPath, hashSize) { const entry = document.createElement('div'); entry.className = 'path-entry'; - entry.innerHTML = `${fullRoute}Hops: ${segments.length}`; - entry.title = 'Tap to copy route'; + entry.innerHTML = `${fullRoute}${tHtml('chat.route_hops', { count: segments.length })}`; + entry.title = t('chat.copy_route_title'); entry.addEventListener('click', (e) => { e.stopPropagation(); navigator.clipboard.writeText(commaRoute).then(() => { const orig = entry.innerHTML; - entry.innerHTML = 'Copied!'; + entry.innerHTML = `${tHtml('common.copied')}`; setTimeout(() => { entry.innerHTML = orig; }, 1000); }); }); @@ -1609,7 +1604,7 @@ function showDeliveryInfo(element) { const popup = document.createElement('div'); popup.className = 'dm-delivery-popup'; - popup.textContent = 'Delivery unknown \u2014 no ACK received. Message may still have been delivered.'; + popup.textContent = t('dm.delivery_unknown'); element.style.position = 'relative'; element.appendChild(popup); @@ -1764,9 +1759,9 @@ function updateStatus(status) { if (!statusEl) return; const icons = { - connected: ' Connected', - disconnected: ' Disconnected', - connecting: ' Connecting...' + connected: ` ${tHtml('common.connected')}`, + disconnected: ` ${tHtml('common.disconnected')}`, + connecting: ` ${tHtml('common.connecting')}` }; statusEl.innerHTML = icons[status] || icons.connecting; @@ -1778,7 +1773,7 @@ function updateStatus(status) { function updateLastRefresh() { const el = document.getElementById('dmLastRefresh'); if (el) { - el.textContent = `Updated: ${new Date().toLocaleTimeString()}`; + el.textContent = t('chat.updated', { time: new Date().toLocaleTimeString() }); } } @@ -1869,7 +1864,7 @@ function checkDmNotifications(conversations) { try { const notification = new Notification('mc-webui', { - body: `New private messages: ${delta}`, + body: tn('dm.notify.new_messages', delta), icon: '/static/images/android-chrome-192x192.png', badge: '/static/images/android-chrome-192x192.png', tag: 'mc-webui-dm', @@ -1909,13 +1904,13 @@ function initializeDmFabToggle() { // Restore collapsed state (shared with main chat) if (localStorage.getItem('mc-webui-fab-collapsed') === '1') { container.classList.add('collapsed'); - toggle.title = 'Show buttons'; + toggle.title = t('chat.fab.show'); } toggle.addEventListener('click', () => { container.classList.toggle('collapsed'); const isCollapsed = container.classList.contains('collapsed'); - toggle.title = isCollapsed ? 'Show buttons' : 'Hide buttons'; + toggle.title = isCollapsed ? t('chat.fab.show') : t('chat.fab.hide'); localStorage.setItem('mc-webui-fab-collapsed', isCollapsed ? '1' : '0'); }); @@ -2054,7 +2049,7 @@ function applyDmFilter(query) { noMatchesDiv.className = 'filter-no-matches'; noMatchesDiv.innerHTML = ` -

No messages match "${escapeHtml(currentDmFilterQuery)}"

+

${tHtml('chat.filter.no_matches', { query: currentDmFilterQuery })}

`; container.appendChild(noMatchesDiv); } @@ -2151,7 +2146,7 @@ async function loadAutoRetryConfig() { const data = await response.json(); if (data.success) { showNotification( - data.enabled ? 'Auto Retry enabled' : 'Auto Retry disabled', + data.enabled ? t('dm.toast.auto_retry_on') : t('dm.toast.auto_retry_off'), 'info' ); } @@ -2201,13 +2196,13 @@ async function renderPathList(pubkey) { const listEl = document.getElementById('dmPathList'); if (!listEl) return; - listEl.innerHTML = '
Loading...
'; + listEl.innerHTML = `
${tHtml('common.loading')}
`; try { const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths`); const data = await response.json(); if (!data.success || !data.paths.length) { - listEl.innerHTML = '
No paths configured. Use + to add.
'; + listEl.innerHTML = `
${tHtml('repeaters.paths.none')}
`; return; } @@ -2226,23 +2221,23 @@ async function renderPathList(pubkey) { const hashLabel = path.hash_size + 'B'; item.innerHTML = ` - ${pathDisplay} + ${pathDisplay} ${hashLabel} - ${path.label ? `${path.label}` : ''} + ${path.label ? `${escapeHtml(path.label)}` : ''} - ${index > 0 ? `` : ''} - ${index < data.paths.length - 1 ? `` : ''} - ` : ''} + ${index < data.paths.length - 1 ? `` : ''} + @@ -2269,7 +2264,7 @@ async function renderPathList(pubkey) { }); }); } catch (e) { - listEl.innerHTML = '
Failed to load paths
'; + listEl.innerHTML = `
${tHtml('repeaters.paths.load_failed')}
`; console.error('Failed to load paths:', e); } } @@ -2295,14 +2290,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 refreshContactInfoPath(); } 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'); } } @@ -2381,7 +2376,7 @@ function setupPathFormHandlers(pubkey) { const label = document.getElementById('dmPathLabelInput').value.trim(); if (!pathHex) { - showNotification('Path hex is required', 'danger'); + showNotification(t('repeaters.toast.path_hex_required'), 'danger'); return; } @@ -2395,14 +2390,18 @@ function setupPathFormHandlers(pubkey) { // 1B: only block adjacent duplicates 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'); + const uniqAdj = [...new Set(adjDupes)]; + showNotification(tn('repeaters.toast.adjacent_dupes', uniqAdj.length, + { hops: uniqAdj.map(d => d.toUpperCase()).join(', ') }), 'danger'); return; } } else { // 2B/3B: block any duplicate 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'); + const uniqDupes = [...new Set(dupes)]; + showNotification(tn('repeaters.toast.dupes', uniqDupes.length, + { hops: uniqDupes.map(d => d.toUpperCase()).join(', ') }), 'danger'); return; } } @@ -2417,12 +2416,12 @@ function setupPathFormHandlers(pubkey) { 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'); } }); @@ -2464,7 +2463,7 @@ function setupPathFormHandlers(pubkey) { const newResetBtn = resetFloodBtn.cloneNode(true); resetFloodBtn.parentNode.replaceChild(newResetBtn, resetFloodBtn); newResetBtn.addEventListener('click', async () => { - 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 { @@ -2473,13 +2472,13 @@ function setupPathFormHandlers(pubkey) { }); const data = await response.json(); if (data.success) { - showNotification('Device path reset to FLOOD', 'info'); + showNotification(t('repeaters.toast.reset_flood_done'), 'info'); await refreshContactInfoPath(); } 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'); } }); } @@ -2489,7 +2488,7 @@ function setupPathFormHandlers(pubkey) { const newClearBtn = clearPathsBtn.cloneNode(true); clearPathsBtn.parentNode.replaceChild(newClearBtn, clearPathsBtn); newClearBtn.addEventListener('click', async () => { - 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 { @@ -2499,12 +2498,12 @@ function setupPathFormHandlers(pubkey) { 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'); } }); } @@ -2525,7 +2524,7 @@ async function loadRepeaterPicker(pubkey) { _repeatersCache = data.repeaters; } } catch (e) { - listEl.innerHTML = '
Failed to load repeaters
'; + listEl.innerHTML = `
${tHtml('repeaters.load_failed')}
`; return; } } @@ -2556,7 +2555,7 @@ function renderRepeaterList(listEl, repeaters, pubkey) { }); if (!filtered.length) { - listEl.innerHTML = '
No repeaters found
'; + listEl.innerHTML = `
${tHtml('repeaters.picker.none')}
`; return; } @@ -2572,8 +2571,8 @@ function renderRepeaterList(listEl, repeaters, pubkey) { item.className = 'repeater-picker-item'; item.innerHTML = ` ${prefix} - ${rpt.name} - ${samePrefix > 1 ? '' : ''} + ${escapeHtml(rpt.name)} + ${samePrefix > 1 ? `` : ''} `; item.addEventListener('click', () => { // Check for duplicate hop @@ -2582,13 +2581,13 @@ function renderRepeaterList(listEl, repeaters, pubkey) { if (hashSize === 1) { // 1B: only block if same as last hop (adjacent duplicate) if (existingHops.length > 0 && existingHops[existingHops.length - 1] === prefixLc) { - showNotification(`${prefix} cannot be adjacent to itself`, 'warning'); + showNotification(t('repeaters.toast.hop_adjacent_self', { hop: prefix }), 'warning'); return; } } else { // 2B/3B: block any duplicate if (existingHops.includes(prefixLc)) { - showNotification(`${prefix} is already in the path`, 'warning'); + showNotification(t('repeaters.toast.hop_already_used', { hop: prefix }), 'warning'); return; } } @@ -2655,7 +2654,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'; @@ -2679,7 +2679,7 @@ function openRepeaterMapPicker() { const addBtn = document.getElementById('rptMapAddBtn'); const selectedLabel = document.getElementById('rptMapSelected'); if (addBtn) addBtn.disabled = true; - if (selectedLabel) selectedLabel.textContent = 'Click a repeater on the map'; + if (selectedLabel) selectedLabel.textContent = t('repeaters.map.hint'); const modal = new bootstrap.Modal(modalEl); @@ -2719,12 +2719,12 @@ function openRepeaterMapPicker() { const existingHops = getCurrentPathHops(hashSize); if (hashSize === 1) { if (existingHops.length > 0 && existingHops[existingHops.length - 1] === prefix) { - showNotification(`${prefix.toUpperCase()} cannot be adjacent to itself`, 'warning'); + showNotification(t('repeaters.toast.hop_adjacent_self', { hop: prefix.toUpperCase() }), 'warning'); return; } } else { if (existingHops.includes(prefix)) { - showNotification(`${prefix.toUpperCase()} is already in the path`, 'warning'); + showNotification(t('repeaters.toast.hop_already_used', { hop: prefix.toUpperCase() }), 'warning'); return; } } @@ -2745,7 +2745,7 @@ function openRepeaterMapPicker() { // Reset selection for next pick _rptMapSelectedRepeater = null; if (addBtn) addBtn.disabled = true; - if (selectedLabel) selectedLabel.textContent = 'Click a repeater on the map'; + if (selectedLabel) selectedLabel.textContent = t('repeaters.map.hint'); }; } @@ -2766,7 +2766,7 @@ async function loadRepeaterMapMarkers() { // Reset selection _rptMapSelectedRepeater = null; if (addBtn) addBtn.disabled = true; - if (selectedLabel) selectedLabel.textContent = 'Click a repeater on the map'; + if (selectedLabel) selectedLabel.textContent = t('repeaters.map.hint'); // Ensure repeaters cache is loaded if (!_repeatersCache) { @@ -2775,7 +2775,7 @@ async function loadRepeaterMapMarkers() { const data = await response.json(); if (data.success) _repeatersCache = data.repeaters; } catch (e) { - if (countEl) countEl.textContent = 'Failed to load'; + if (countEl) countEl.textContent = t('repeaters.load_failed'); return; } } @@ -2800,14 +2800,14 @@ async function loadRepeaterMapMarkers() { } catch (e) { /* show all on error */ } } - if (countEl) countEl.textContent = `${repeaters.length} repeaters`; + if (countEl) countEl.textContent = tn('repeaters.count', repeaters.length); const hashSize = parseInt(document.querySelector('input[name="pathHashSize"]:checked')?.value || '1'); const bounds = []; repeaters.forEach(rpt => { const prefix = rpt.public_key.substring(0, hashSize * 2).toUpperCase(); - const lastSeen = rpt.last_advert ? formatRelativeTimeDm(rpt.last_advert) : ''; + const lastSeen = rpt.last_advert ? formatTimeAgo(rpt.last_advert) : ''; const marker = L.circleMarker([rpt.adv_lat, rpt.adv_lon], { radius: 10, @@ -2819,16 +2819,16 @@ async function loadRepeaterMapMarkers() { }).addTo(_rptMapMarkers); marker.bindPopup( - `${rpt.name}
` + + `${escapeHtml(rpt.name)}
` + `${prefix}` + - (lastSeen ? `
Last seen: ${lastSeen}` : '') + (lastSeen ? `
${tHtml('repeaters.map.last_seen', { time: lastSeen })}` : '') ); marker.on('click', () => { _rptMapSelectedRepeater = rpt; if (addBtn) addBtn.disabled = false; if (selectedLabel) { - selectedLabel.innerHTML = `${prefix} ${rpt.name}`; + selectedLabel.innerHTML = `${prefix} ${escapeHtml(rpt.name)}`; } }); @@ -2872,7 +2872,7 @@ async function loadNoAutoFloodToggle(pubkey) { const data = await response.json(); if (data.success) { showNotification( - data.no_auto_flood ? 'Keep path enabled' : 'Keep path disabled', + data.no_auto_flood ? t('dm.toast.keep_path_on') : t('dm.toast.keep_path_off'), 'info' ); } @@ -2912,8 +2912,8 @@ function updateRepeaterSearchPlaceholder() { if (mode === 'id') { const hashSize = parseInt(document.querySelector('input[name="pathHashSize"]:checked')?.value || '1'); const chars = hashSize * 2; - searchInput.placeholder = `Search by first ${chars} hex chars...`; + searchInput.placeholder = t('repeaters.addpath.search_id_ph', { chars: chars }); } else { - searchInput.placeholder = 'Search by name...'; + searchInput.placeholder = t('repeaters.addpath.search_ph'); } } diff --git a/app/static/js/repeaters.js b/app/static/js/repeaters.js index 69a3afb..bbb0141 100644 --- a/app/static/js/repeaters.js +++ b/app/static/js/repeaters.js @@ -93,8 +93,8 @@ function esc(s) { // 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. +// single call site already guards it. contacts.js and dm.js have since been folded in +// the same way, so this is now the only implementation. // ================================================================ // State @@ -830,7 +830,7 @@ function renderHopPickerList(listEl, repeaters) { item.innerHTML = ` ${esc(prefix)} ${esc(rpt.name)} - ${samePrefix > 1 ? `` : ''} + ${samePrefix > 1 ? `` : ''} `; item.addEventListener('click', () => { appendHopToPathInput(prefix.toLowerCase(), hashSize); @@ -924,7 +924,7 @@ function openRepeaterMapPicker() { const addBtn = document.getElementById('rptMapAddBtn'); const selectedLabel = document.getElementById('rptMapSelected'); if (addBtn) addBtn.disabled = true; - if (selectedLabel) selectedLabel.textContent = 'Click a repeater on the map'; + if (selectedLabel) selectedLabel.textContent = t('repeaters.map.hint'); const modal = new bootstrap.Modal(modalEl); @@ -960,7 +960,7 @@ function openRepeaterMapPicker() { if (appendHopToPathInput(prefix, hashSize)) { _rptMapSelectedRepeater = null; addBtn.disabled = true; - if (selectedLabel) selectedLabel.textContent = 'Click a repeater on the map'; + if (selectedLabel) selectedLabel.textContent = t('repeaters.map.hint'); } }; } @@ -981,7 +981,7 @@ async function loadRepeaterMapMarkers() { _rptMapSelectedRepeater = null; if (addBtn) addBtn.disabled = true; - if (selectedLabel) selectedLabel.textContent = 'Click a repeater on the map'; + if (selectedLabel) selectedLabel.textContent = t('repeaters.map.hint'); if (!_repeatersCache) { try { diff --git a/app/templates/console.html b/app/templates/console.html index d1dd9f0..b8b8855 100644 --- a/app/templates/console.html +++ b/app/templates/console.html @@ -309,7 +309,7 @@ {{ device_name }}
- {{ t('console.status.connecting') }} + {{ t('common.connecting') }}
diff --git a/app/templates/dm.html b/app/templates/dm.html index f12e36c..dce42f8 100644 --- a/app/templates/dm.html +++ b/app/templates/dm.html @@ -3,7 +3,7 @@ - Direct Messages - mc-webui + {{ t('dm.title') }} - mc-webui