Skip to content

Commit 2af7e9b

Browse files
ADFA-4516 feat(dashboard): l10n parity with the app (6 locales) + de-hardcode client UI
Brings the device dashboard's localization up to the app's level and removes hardcoded user-facing client strings. - i18n hardening (dashboard.js): supported langs en/es/fr/hi/pt/ru (was es/en only), fallback to en on missing locale/key, data-i18n-placeholder + data-i18n-title support, and a window.t(key, fallback) helper for dynamically-rendered strings. - De-hardcode: every user-facing static string in index/app_home/app_kiwix/app_books/ app_maps/sidebar/footer is keyed (data-i18n/-placeholder/-title); dynamic strings in the inline JS + dashboard.js go through window.t. Server-emitted '[System]' terminal logs in sockets/*.ts are intentionally left as English technical output (out of scope). - Locales: en.js/es.js rewritten and fr/hi/pt/ru.js added — 106 keys, identical set across all six (app strings reused where concepts matched). - Parity test (test/lang-parity.test.ts) asserts every locale has exactly en's key set; wired into 'npm test' (now 14 tests: 8 maps + 6 parity). typecheck stays green. Known minor: two kiwix modal bodies wrap a <code> path; applyTranslations sets innerText so the monospace styling flattens to plain text (the path text is preserved in every locale). Cosmetic-only; to be refined in the dashboard UX ticket.
1 parent 1b4a2fe commit 2af7e9b

14 files changed

Lines changed: 915 additions & 113 deletions

File tree

‎static/dashboard/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"description": "",
55
"main": "index.js",
66
"scripts": {
7-
"test": "node --require ts-node/register --test sockets/maps.socket.test.ts",
7+
"test": "node --require ts-node/register --test sockets/maps.socket.test.ts test/lang-parity.test.ts",
88
"typecheck": "tsc --noEmit"
99
},
1010
"keywords": [],

‎static/dashboard/public/js/dashboard.js‎

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -35,20 +35,46 @@ const themeManager = {
3535
};
3636

3737
// --- i18n System ---
38+
// Locales the dashboard ships (kept in parity with the Android app). 'en' is the
39+
// source of truth and the fallback for any missing locale/key.
40+
const SUPPORTED_LANGS = ['en', 'es', 'fr', 'hi', 'pt', 'ru'];
41+
42+
// Translate a key, falling back to the given English text (or the key) when the active
43+
// locale is missing that key. Safe for dynamically-rendered strings (see maps/kiwix JS).
44+
window.t = (key, fallback) => (window.i18n && window.i18n[key]) || fallback || key;
45+
3846
const applyTranslations = () => {
3947
if (!window.i18n) return;
48+
// Text content (default): only overwrite when the key exists, so a missing key keeps
49+
// the element's built-in English text.
4050
document.querySelectorAll('[data-i18n]').forEach(el => {
4151
const key = el.getAttribute('data-i18n');
4252
if (window.i18n[key]) el.innerText = window.i18n[key];
4353
});
54+
// Attribute variants: placeholder and title.
55+
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
56+
const key = el.getAttribute('data-i18n-placeholder');
57+
if (window.i18n[key]) el.setAttribute('placeholder', window.i18n[key]);
58+
});
59+
document.querySelectorAll('[data-i18n-title]').forEach(el => {
60+
const key = el.getAttribute('data-i18n-title');
61+
if (window.i18n[key]) el.setAttribute('title', window.i18n[key]);
62+
});
4463
};
4564

4665
const loadLanguage = () => {
47-
const userLang = (navigator.language || navigator.userLanguage).substring(0, 2).toLowerCase();
48-
const lang = ['es', 'en'].includes(userLang) ? userLang : 'en';
66+
const userLang = (navigator.language || navigator.userLanguage || 'en').substring(0, 2).toLowerCase();
67+
const lang = SUPPORTED_LANGS.includes(userLang) ? userLang : 'en';
4968
const script = document.createElement('script');
5069
script.src = `lang/${lang}.js`;
70+
// If the locale file fails to load, fall back to English so the UI is never blank.
5171
script.onload = applyTranslations;
72+
script.onerror = () => {
73+
const fallback = document.createElement('script');
74+
fallback.src = 'lang/en.js';
75+
fallback.onload = applyTranslations;
76+
document.head.appendChild(fallback);
77+
};
5278
document.head.appendChild(script);
5379
};
5480

@@ -170,7 +196,7 @@ function renderLocalBooks(books) {
170196
container.innerHTML = '';
171197

172198
if (books.length === 0) {
173-
container.innerHTML = '<div class="col-12 text-center text-secondary py-5">No local books found. Check the Top 100 tab!</div>';
199+
container.innerHTML = `<div class="col-12 text-center text-secondary py-5">${window.t('books_no_local','No local books found. Check the Top 100 tab!')}</div>`;
174200
return;
175201
}
176202

@@ -184,9 +210,9 @@ function renderLocalBooks(books) {
184210
<img src="${coverUrl}" onerror="this.src='https://via.placeholder.com/300x450?text=No+Cover'" class="card-img-top" style="height: 250px; object-fit: cover; border-radius: 8px 8px 0 0;">
185211
<div class="card-body p-3 d-flex flex-column">
186212
<h6 class="fw-bold mb-1 text-truncate" title="${book.title}">${book.title}</h6>
187-
<small class="text-secondary mb-2">${book.author || 'Unknown'} • ${book.year || 'N/A'}</small>
213+
<small class="text-secondary mb-2">${book.author || window.t('common_unknown','Unknown')} • ${book.year || window.t('common_na','N/A')}</small>
188214
<div class="mt-auto d-flex gap-2">
189-
<a href="${readUrl}" target="_blank" class="btn btn-sm btn-success flex-grow-1 fw-bold">Read</a>
215+
<a href="${readUrl}" target="_blank" class="btn btn-sm btn-success flex-grow-1 fw-bold">${window.t('books_read','Read')}</a>
190216
<button id="btn-del-${book.id}" class="btn btn-sm btn-outline-danger" onclick="deleteLocalBook(${book.id})">🗑️</button>
191217
</div>
192218
</div>
@@ -198,7 +224,7 @@ function renderLocalBooks(books) {
198224
}
199225

200226
function deleteLocalBook(id) {
201-
if (confirm("Are you sure you want to delete this book?")) {
227+
if (confirm(window.t('books_delete_confirm','Are you sure you want to delete this book?'))) {
202228
console.log(`[Dashboard] 🗑️ Starting deletion of workbook ID: ${id}`);
203229

204230
// We give visual feedback: We change the trash can for a spinner
@@ -228,7 +254,7 @@ function renderRemoteBooks(books) {
228254
container.innerHTML = '';
229255

230256
if (books.length === 0) {
231-
container.innerHTML = '<div class="list-group-item text-center text-secondary py-5 border-0">No books found in remote catalog.</div>';
257+
container.innerHTML = `<div class="list-group-item text-center text-secondary py-5 border-0">${window.t('books_no_remote','No books found in remote catalog.')}</div>`;
232258
return;
233259
}
234260

@@ -237,7 +263,7 @@ function renderRemoteBooks(books) {
237263
const isChecked = selectedRemoteBooks.includes(book.gutenberg_id) ? 'checked' : '';
238264
const isLocalDisabled = localMatch ? 'disabled' : '';
239265
const opacity = localMatch ? 'opacity-75' : '';
240-
const badgeText = localMatch ? 'Local' : 'Info';
266+
const badgeText = localMatch ? window.t('books_badge_local','Local') : window.t('books_badge_info','Info');
241267
const badgeClass = localMatch ? 'bg-success' : 'bg-primary';
242268
const localIdParam = localMatch ? localMatch.id : 'null';
243269

@@ -278,10 +304,10 @@ function updateBookDownloadBtn() {
278304
const btn = document.getElementById('books-batch-download-btn');
279305
if (selectedRemoteBooks.length > 0) {
280306
btn.classList.remove('disabled');
281-
btn.innerText = `DOWNLOAD (${selectedRemoteBooks.length})`;
307+
btn.innerText = `${window.t('books_download_btn','DOWNLOAD')} (${selectedRemoteBooks.length})`;
282308
} else {
283309
btn.classList.add('disabled');
284-
btn.innerText = 'DOWNLOAD';
310+
btn.innerText = window.t('books_download_btn','DOWNLOAD');
285311
}
286312
}
287313

@@ -304,7 +330,7 @@ function downloadSelectedBooks() {
304330

305331
const btn = document.getElementById('books-batch-download-btn');
306332
btn.classList.add('disabled');
307-
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>DOWNLOADING...';
333+
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>${window.t('books_downloading','DOWNLOADING...')}`;
308334

309335
pendingCalibreAction = { type: 'download', payload: booksToDownload };
310336
socket.emit('download_books_batch', booksToDownload);
@@ -321,7 +347,7 @@ function downloadSelectedBooks() {
321347
// --- Security & Text Formatting ---
322348
function formatBookDescription(text) {
323349
if (!text || text === 'No description available for this book.') {
324-
return 'No description available for this book.';
350+
return window.t('books_no_description','No description available for this book.');
325351
}
326352

327353
// Escape html to prevent XSS
@@ -366,19 +392,19 @@ function openBookModal(id, localBookId = null) {
366392

367393
if (localBookId) {
368394
btn.className = "btn btn-success w-100 fw-bold text-uppercase";
369-
btn.innerText = "📖 Read in Calibre-Web";
395+
btn.innerText = window.t('books_read_calibre','📖 Read in Calibre-Web');
370396
btn.onclick = function() {
371397
window.open(`/books/read/${localBookId}/epub`, '_blank');
372398
const modalEl = document.getElementById('bookDetailsModal');
373399
bootstrap.Modal.getInstance(modalEl).hide();
374400
};
375401
} else if (activeDownloads >= MAX_DOWNLOADS) {
376402
btn.className = "btn btn-secondary w-100 fw-bold disabled text-uppercase";
377-
btn.innerText = "⏳ Queue Full (Wait for current downloads)";
403+
btn.innerText = window.t('books_queue_full','⏳ Queue Full (Wait for current downloads)');
378404
btn.onclick = null;
379405
} else {
380406
btn.className = "btn btn-primary w-100 fw-bold text-uppercase";
381-
btn.innerText = "📥 Download & Add to Calibre-Web";
407+
btn.innerText = window.t('books_download_calibre','📥 Download & Add to Calibre-Web');
382408
btn.onclick = function() {
383409
startDownloadFromModal();
384410
};
@@ -410,7 +436,7 @@ function startDownloadFromModal() {
410436
const btn = document.getElementById('books-batch-download-btn');
411437
if (btn) {
412438
btn.classList.remove('disabled');
413-
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>DOWNLOADING...';
439+
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>${window.t('books_downloading','DOWNLOADING...')}`;
414440
}
415441

416442
setTimeout(() => {
@@ -427,7 +453,7 @@ socket.on('book_status_update', (data) => {
427453
}
428454

429455
if (data.status === 'error') {
430-
alert(`❌ The book download or upload failed.\n\nServer Reason: ${data.message}\n\nMake sure you are not logged in multiple tabs at once.`);
456+
alert(`${window.t('books_error_alert','❌ The book download or upload failed.')}\n\n${window.t('books_error_reason','Server Reason:')} ${data.message}\n\n${window.t('books_error_hint','Make sure you are not logged in multiple tabs at once.')}`);
431457
}
432458
});
433459

@@ -448,7 +474,7 @@ socket.on('calibre_auth_required', () => {
448474
const batchBtn = document.getElementById('books-batch-download-btn');
449475
if (batchBtn && activeDownloads > 0) {
450476
batchBtn.classList.remove('disabled');
451-
batchBtn.innerText = `DOWNLOAD (${selectedRemoteBooks.length || activeDownloads})`;
477+
batchBtn.innerText = `${window.t('books_download_btn','DOWNLOAD')} (${selectedRemoteBooks.length || activeDownloads})`;
452478
// Reset the active downloads since the batch failed
453479
activeDownloads = 0;
454480
}
@@ -464,7 +490,7 @@ function submitCalibreLogin() {
464490
const pass = document.getElementById('calibre-pass-input').value.trim();
465491

466492
if (!user || !pass) {
467-
alert("Please enter both username and password.");
493+
alert(window.t('calibre_enter_both','Please enter both username and password.'));
468494
return;
469495
}
470496

@@ -493,7 +519,7 @@ socket.on('calibre_auth_updated', () => {
493519
const btn = document.getElementById('books-batch-download-btn');
494520
if (btn) {
495521
btn.classList.add('disabled');
496-
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>DOWNLOADING...';
522+
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>${window.t('books_downloading','DOWNLOADING...')}`;
497523
}
498524
activeDownloads += pendingCalibreAction.payload.length;
499525
socket.emit('download_books_batch', pendingCalibreAction.payload);

‎static/dashboard/public/lang/en.js‎

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,125 @@
11
window.i18n = {
2+
// --- Navigation / sidebar ---
23
"nav_home": "Home",
34
"nav_maps": "Maps",
45
"nav_kiwix": "Kiwix",
6+
"nav_books": "Books",
7+
"nav_theme": "Theme",
8+
9+
// --- Footer / about ---
510
"footer_version": "K2Go Dashboard v1.0.1",
611
"footer_about": "About K2Go Dashboard",
712
"about_text": "This dashboard is designed to manage content across IIAB apps on Android devices.",
8-
"status_running": "Running",
9-
"status_stopped": "Stopped"
13+
14+
// --- Common / shared ---
15+
"common_loading": "Loading...",
16+
"common_close": "Close",
17+
"common_cancel": "Cancel",
18+
"common_yes": "Yes",
19+
"common_no": "No",
20+
"common_got_it": "Got it",
21+
"common_got_it_upper": "GOT IT",
22+
"common_delete_icon": "🗑️ Delete",
23+
"common_deleting": "⏳ Deleting...",
24+
"common_refreshing": "Refreshing...",
25+
"common_hide_terminal": "▲ Hide Terminal",
26+
"common_show_terminal": "▼ Show Terminal",
27+
"common_waiting_commands": "Waiting for commands...",
28+
"common_unknown": "Unknown",
29+
"common_na": "N/A",
30+
31+
// --- Home panel ---
32+
"home_title": "K2Go Dashboard",
33+
"home_ip_label": "IP:",
34+
"home_online": "Online",
35+
"home_main_storage": "💾 Main Storage",
36+
"home_ram": "⚡ RAM Memory",
37+
"home_swap": "💽 Swap (Virtual)",
38+
"home_installed_modules": "Installed Modules",
39+
"home_card_kiwix_title": "Kiwix Manager",
40+
"home_card_kiwix_desc": "Browse and download Wikipedia offline",
41+
"home_card_maps_title": "Map Extractor",
42+
"home_card_maps_desc": "Manage OpenStreetMap tiles",
43+
"home_card_books_title": "Books Downloader",
44+
"home_card_books_desc": "Curated catalog for Calibre-Web",
45+
46+
// --- Kiwix panel ---
47+
"kiwix_title": "Kiwix Repository - Wikipedia",
48+
"kiwix_connecting": "Connecting to local server...",
49+
"kiwix_search_placeholder": "Search (e.g., es all nopic...)",
50+
"kiwix_local_filter": "💾 Local:",
51+
"kiwix_selected": "Selected:",
52+
"kiwix_download_btn": "Download",
53+
"kiwix_cancel_download": "🛑 Cancel Download",
54+
"kiwix_load_more": "Load more results ↓",
55+
"kiwix_limit_modal_body": "Please complete the downloads in the queue first. Help use Kiwix's bandwidth responsibly.",
56+
"kiwix_aria_title": "❌ Error: Aria2 Not Found",
57+
"kiwix_aria_body": "The binary /usr/bin/aria2c was not detected.",
58+
"kiwix_indexer_title": "⚠️ Warning: Missing Indexer",
59+
"kiwix_indexer_body": "iiab-make-kiwix-lib was not detected in the backend. Do you want to continue?",
60+
"kiwix_continue_download": "Continue Download",
61+
"kiwix_overwrite_confirm": "The file",
62+
"kiwix_overwrite_confirm2": "is already downloaded.",
63+
"kiwix_overwrite_confirm3": "Do you want to overwrite it?",
64+
"kiwix_delete_confirm": "⚠️ Do you want to DELETE the file",
65+
"kiwix_delete_confirm2": "This will update the Kiwix database.",
66+
"kiwix_requesting_deletion": "[System] Requesting deletion from server...",
67+
"kiwix_cancel_confirm": "⚠️ Do you want to CANCEL the ongoing download?\n\nIncomplete files will be wiped from your hard drive.",
68+
"kiwix_aborting": "⏳ Aborting...",
69+
"kiwix_downloading": "Downloading...",
70+
71+
// --- Maps panel ---
72+
"maps_title": "Map Extraction Dashboard",
73+
"maps_command_label": "Execute IIAB Maps shell command:",
74+
"maps_command_placeholder": "e.g.: sudo /opt/iiab/maps/...",
75+
"maps_start": "Start",
76+
"maps_executing": "Executing...",
77+
"maps_confirm_prompt": "⚠️ The system requires your confirmation to continue:",
78+
"maps_existing_regions": "🗺️ Existing Regions",
79+
"maps_refresh_list": "Refresh List",
80+
"maps_loading_catalog": "Loading server catalog...",
81+
"maps_empty": "No map regions registered in extracts.json.",
82+
"maps_no_layers": "no layers listed",
83+
"maps_incomplete": "⚠️ Incomplete extract — missing:",
84+
"maps_render": "render:",
85+
"maps_delete_confirm": "⚠️ Do you want to delete the region",
86+
"maps_delete_confirm2": "from the server?",
87+
88+
// --- Books panel ---
89+
"books_title": "📚 Books Downloader",
90+
"books_subtitle": "A curated catalog of over 1,000 books powered by Project Gutenberg",
91+
"books_search_placeholder": "Search local or remote catalog...",
92+
"books_tab_local": "📚 Local Books",
93+
"books_tab_top": "🏆 Top 100",
94+
"books_tab_edu": "🎓 Educational & Children",
95+
"books_download_btn": "DOWNLOAD",
96+
"books_downloading": "DOWNLOADING...",
97+
"books_limit_modal_body": "Please complete the downloads in the queue first. Help use Project Gutenberg's bandwidth responsibly.",
98+
"books_modal_title_ph": "Book Title",
99+
"books_modal_author_ph": "Author Name",
100+
"books_modal_meta_ph": "Year: 1900",
101+
"books_no_description": "No description available for this book.",
102+
"books_download_calibre": "📥 Download & Add to Calibre-Web",
103+
"books_read_calibre": "📖 Read in Calibre-Web",
104+
"books_queue_full": "⏳ Queue Full (Wait for current downloads)",
105+
"books_no_local": "No local books found. Check the Top 100 tab!",
106+
"books_no_remote": "No books found in remote catalog.",
107+
"books_read": "Read",
108+
"books_badge_local": "Local",
109+
"books_badge_info": "Info",
110+
"books_delete_confirm": "Are you sure you want to delete this book?",
111+
"books_error_alert": "❌ The book download or upload failed.",
112+
"books_error_reason": "Server Reason:",
113+
"books_error_hint": "Make sure you are not logged in multiple tabs at once.",
114+
115+
// --- Calibre-Web login modal ---
116+
"calibre_error_title": "🔒 Calibre-Web Connection Error",
117+
"calibre_error_body": "The stored uploader credentials failed. Please enter the current Calibre-Web admin username and password to process changes.",
118+
"calibre_username": "Username",
119+
"calibre_password": "Password",
120+
"calibre_update_retry": "Update & Retry",
121+
"calibre_enter_both": "Please enter both username and password.",
122+
123+
// --- Shared modal titles ---
124+
"limit_modal_title": "⚠️ Suggested Download Limit"
10125
};

0 commit comments

Comments
 (0)