Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion static/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"description": "",
"main": "index.js",
"scripts": {
"test": "node --require ts-node/register --test sockets/maps.socket.test.ts",
"test": "node --require ts-node/register --test sockets/maps.socket.test.ts test/lang-parity.test.ts",
"typecheck": "tsc --noEmit"
},
"keywords": [],
Expand Down
66 changes: 46 additions & 20 deletions static/dashboard/public/js/dashboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,46 @@ const themeManager = {
};

// --- i18n System ---
// Locales the dashboard ships (kept in parity with the Android app). 'en' is the
// source of truth and the fallback for any missing locale/key.
const SUPPORTED_LANGS = ['en', 'es', 'fr', 'hi', 'pt', 'ru'];

// Translate a key, falling back to the given English text (or the key) when the active
// locale is missing that key. Safe for dynamically-rendered strings (see maps/kiwix JS).
window.t = (key, fallback) => (window.i18n && window.i18n[key]) || fallback || key;

const applyTranslations = () => {
if (!window.i18n) return;
// Text content (default): only overwrite when the key exists, so a missing key keeps
// the element's built-in English text.
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
if (window.i18n[key]) el.innerText = window.i18n[key];
});
// Attribute variants: placeholder and title.
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
if (window.i18n[key]) el.setAttribute('placeholder', window.i18n[key]);
});
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
if (window.i18n[key]) el.setAttribute('title', window.i18n[key]);
});
};

const loadLanguage = () => {
const userLang = (navigator.language || navigator.userLanguage).substring(0, 2).toLowerCase();
const lang = ['es', 'en'].includes(userLang) ? userLang : 'en';
const userLang = (navigator.language || navigator.userLanguage || 'en').substring(0, 2).toLowerCase();
const lang = SUPPORTED_LANGS.includes(userLang) ? userLang : 'en';
const script = document.createElement('script');
script.src = `lang/${lang}.js`;
// If the locale file fails to load, fall back to English so the UI is never blank.
script.onload = applyTranslations;
script.onerror = () => {
const fallback = document.createElement('script');
fallback.src = 'lang/en.js';
fallback.onload = applyTranslations;
document.head.appendChild(fallback);
};
document.head.appendChild(script);
};

Expand Down Expand Up @@ -170,7 +196,7 @@ function renderLocalBooks(books) {
container.innerHTML = '';

if (books.length === 0) {
container.innerHTML = '<div class="col-12 text-center text-secondary py-5">No local books found. Check the Top 100 tab!</div>';
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>`;
return;
}

Expand All @@ -184,9 +210,9 @@ function renderLocalBooks(books) {
<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;">
<div class="card-body p-3 d-flex flex-column">
<h6 class="fw-bold mb-1 text-truncate" title="${book.title}">${book.title}</h6>
<small class="text-secondary mb-2">${book.author || 'Unknown'} • ${book.year || 'N/A'}</small>
<small class="text-secondary mb-2">${book.author || window.t('common_unknown','Unknown')} • ${book.year || window.t('common_na','N/A')}</small>
<div class="mt-auto d-flex gap-2">
<a href="${readUrl}" target="_blank" class="btn btn-sm btn-success flex-grow-1 fw-bold">Read</a>
<a href="${readUrl}" target="_blank" class="btn btn-sm btn-success flex-grow-1 fw-bold">${window.t('books_read','Read')}</a>
<button id="btn-del-${book.id}" class="btn btn-sm btn-outline-danger" onclick="deleteLocalBook(${book.id})">🗑️</button>
</div>
</div>
Expand All @@ -198,7 +224,7 @@ function renderLocalBooks(books) {
}

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

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

if (books.length === 0) {
container.innerHTML = '<div class="list-group-item text-center text-secondary py-5 border-0">No books found in remote catalog.</div>';
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>`;
return;
}

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

Expand Down Expand Up @@ -278,10 +304,10 @@ function updateBookDownloadBtn() {
const btn = document.getElementById('books-batch-download-btn');
if (selectedRemoteBooks.length > 0) {
btn.classList.remove('disabled');
btn.innerText = `DOWNLOAD (${selectedRemoteBooks.length})`;
btn.innerText = `${window.t('books_download_btn','DOWNLOAD')} (${selectedRemoteBooks.length})`;
} else {
btn.classList.add('disabled');
btn.innerText = 'DOWNLOAD';
btn.innerText = window.t('books_download_btn','DOWNLOAD');
}
}

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

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

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

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

if (localBookId) {
btn.className = "btn btn-success w-100 fw-bold text-uppercase";
btn.innerText = "📖 Read in Calibre-Web";
btn.innerText = window.t('books_read_calibre','📖 Read in Calibre-Web');
btn.onclick = function() {
window.open(`/books/read/${localBookId}/epub`, '_blank');
const modalEl = document.getElementById('bookDetailsModal');
bootstrap.Modal.getInstance(modalEl).hide();
};
} else if (activeDownloads >= MAX_DOWNLOADS) {
btn.className = "btn btn-secondary w-100 fw-bold disabled text-uppercase";
btn.innerText = "⏳ Queue Full (Wait for current downloads)";
btn.innerText = window.t('books_queue_full','⏳ Queue Full (Wait for current downloads)');
btn.onclick = null;
} else {
btn.className = "btn btn-primary w-100 fw-bold text-uppercase";
btn.innerText = "📥 Download & Add to Calibre-Web";
btn.innerText = window.t('books_download_calibre','📥 Download & Add to Calibre-Web');
btn.onclick = function() {
startDownloadFromModal();
};
Expand Down Expand Up @@ -410,7 +436,7 @@ function startDownloadFromModal() {
const btn = document.getElementById('books-batch-download-btn');
if (btn) {
btn.classList.remove('disabled');
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>DOWNLOADING...';
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>${window.t('books_downloading','DOWNLOADING...')}`;
}

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

if (data.status === 'error') {
alert(`❌ The book download or upload failed.\n\nServer Reason: ${data.message}\n\nMake sure you are not logged in multiple tabs at once.`);
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.')}`);
}
});

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

if (!user || !pass) {
alert("Please enter both username and password.");
alert(window.t('calibre_enter_both','Please enter both username and password.'));
return;
}

Expand Down Expand Up @@ -493,7 +519,7 @@ socket.on('calibre_auth_updated', () => {
const btn = document.getElementById('books-batch-download-btn');
if (btn) {
btn.classList.add('disabled');
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>DOWNLOADING...';
btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>${window.t('books_downloading','DOWNLOADING...')}`;
}
activeDownloads += pendingCalibreAction.payload.length;
socket.emit('download_books_batch', pendingCalibreAction.payload);
Expand Down
119 changes: 117 additions & 2 deletions static/dashboard/public/lang/en.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,125 @@
window.i18n = {
// --- Navigation / sidebar ---
"nav_home": "Home",
"nav_maps": "Maps",
"nav_kiwix": "Kiwix",
"nav_books": "Books",
"nav_theme": "Theme",

// --- Footer / about ---
"footer_version": "K2Go Dashboard v1.0.1",
"footer_about": "About K2Go Dashboard",
"about_text": "This dashboard is designed to manage content across IIAB apps on Android devices.",
"status_running": "Running",
"status_stopped": "Stopped"

// --- Common / shared ---
"common_loading": "Loading...",
"common_close": "Close",
"common_cancel": "Cancel",
"common_yes": "Yes",
"common_no": "No",
"common_got_it": "Got it",
"common_got_it_upper": "GOT IT",
"common_delete_icon": "🗑️ Delete",
"common_deleting": "⏳ Deleting...",
"common_refreshing": "Refreshing...",
"common_hide_terminal": "▲ Hide Terminal",
"common_show_terminal": "▼ Show Terminal",
"common_waiting_commands": "Waiting for commands...",
"common_unknown": "Unknown",
"common_na": "N/A",

// --- Home panel ---
"home_title": "K2Go Dashboard",
"home_ip_label": "IP:",
"home_online": "Online",
"home_main_storage": "💾 Main Storage",
"home_ram": "⚡ RAM Memory",
"home_swap": "💽 Swap (Virtual)",
"home_installed_modules": "Installed Modules",
"home_card_kiwix_title": "Kiwix Manager",
"home_card_kiwix_desc": "Browse and download Wikipedia offline",
"home_card_maps_title": "Map Extractor",
"home_card_maps_desc": "Manage OpenStreetMap tiles",
"home_card_books_title": "Books Downloader",
"home_card_books_desc": "Curated catalog for Calibre-Web",

// --- Kiwix panel ---
"kiwix_title": "Kiwix Repository - Wikipedia",
"kiwix_connecting": "Connecting to local server...",
"kiwix_search_placeholder": "Search (e.g., es all nopic...)",
"kiwix_local_filter": "💾 Local:",
"kiwix_selected": "Selected:",
"kiwix_download_btn": "Download",
"kiwix_cancel_download": "🛑 Cancel Download",
"kiwix_load_more": "Load more results ↓",
"kiwix_limit_modal_body": "Please complete the downloads in the queue first. Help use Kiwix's bandwidth responsibly.",
"kiwix_aria_title": "❌ Error: Aria2 Not Found",
"kiwix_aria_body": "The binary /usr/bin/aria2c was not detected.",
"kiwix_indexer_title": "⚠️ Warning: Missing Indexer",
"kiwix_indexer_body": "iiab-make-kiwix-lib was not detected in the backend. Do you want to continue?",
"kiwix_continue_download": "Continue Download",
"kiwix_overwrite_confirm": "The file",
"kiwix_overwrite_confirm2": "is already downloaded.",
"kiwix_overwrite_confirm3": "Do you want to overwrite it?",
"kiwix_delete_confirm": "⚠️ Do you want to DELETE the file",
"kiwix_delete_confirm2": "This will update the Kiwix database.",
"kiwix_requesting_deletion": "[System] Requesting deletion from server...",
"kiwix_cancel_confirm": "⚠️ Do you want to CANCEL the ongoing download?\n\nIncomplete files will be wiped from your hard drive.",
"kiwix_aborting": "⏳ Aborting...",
"kiwix_downloading": "Downloading...",

// --- Maps panel ---
"maps_title": "Map Extraction Dashboard",
"maps_command_label": "Execute IIAB Maps shell command:",
"maps_command_placeholder": "e.g.: sudo /opt/iiab/maps/...",
"maps_start": "Start",
"maps_executing": "Executing...",
"maps_confirm_prompt": "⚠️ The system requires your confirmation to continue:",
"maps_existing_regions": "🗺️ Existing Regions",
"maps_refresh_list": "Refresh List",
"maps_loading_catalog": "Loading server catalog...",
"maps_empty": "No map regions registered in extracts.json.",
"maps_no_layers": "no layers listed",
"maps_incomplete": "⚠️ Incomplete extract — missing:",
"maps_render": "render:",
"maps_delete_confirm": "⚠️ Do you want to delete the region",
"maps_delete_confirm2": "from the server?",

// --- Books panel ---
"books_title": "📚 Books Downloader",
"books_subtitle": "A curated catalog of over 1,000 books powered by Project Gutenberg",
"books_search_placeholder": "Search local or remote catalog...",
"books_tab_local": "📚 Local Books",
"books_tab_top": "🏆 Top 100",
"books_tab_edu": "🎓 Educational & Children",
"books_download_btn": "DOWNLOAD",
"books_downloading": "DOWNLOADING...",
"books_limit_modal_body": "Please complete the downloads in the queue first. Help use Project Gutenberg's bandwidth responsibly.",
"books_modal_title_ph": "Book Title",
"books_modal_author_ph": "Author Name",
"books_modal_meta_ph": "Year: 1900",
"books_no_description": "No description available for this book.",
"books_download_calibre": "📥 Download & Add to Calibre-Web",
"books_read_calibre": "📖 Read in Calibre-Web",
"books_queue_full": "⏳ Queue Full (Wait for current downloads)",
"books_no_local": "No local books found. Check the Top 100 tab!",
"books_no_remote": "No books found in remote catalog.",
"books_read": "Read",
"books_badge_local": "Local",
"books_badge_info": "Info",
"books_delete_confirm": "Are you sure you want to delete this book?",
"books_error_alert": "❌ The book download or upload failed.",
"books_error_reason": "Server Reason:",
"books_error_hint": "Make sure you are not logged in multiple tabs at once.",

// --- Calibre-Web login modal ---
"calibre_error_title": "🔒 Calibre-Web Connection Error",
"calibre_error_body": "The stored uploader credentials failed. Please enter the current Calibre-Web admin username and password to process changes.",
"calibre_username": "Username",
"calibre_password": "Password",
"calibre_update_retry": "Update & Retry",
"calibre_enter_both": "Please enter both username and password.",

// --- Shared modal titles ---
"limit_modal_title": "⚠️ Suggested Download Limit"
};
Loading