// ── Database page ───────────────────────────────────────────────────────────── const DB_PAGE_SIZE = 100; const _dbPages = { games: { offset: 0, total: 0 }, backups: { offset: 0, total: 0 }, hashes: { offset: 0, total: 0 }, }; async function loadDatabase() { await Promise.all([loadDbTable('games'), loadDbTable('backups'), loadHashRowsTable()]); } async function loadHashRowsTable() { try { const [rows, countRows] = await Promise.all([ window.pork.dbQuery(`SELECT id, game_id, hash_type, firmware_label, file_size, SUBSTR(file_hash,1,16) || '…' AS file_hash_short, backup_path, verified, hashed_at FROM game_hashes ORDER BY hashed_at DESC LIMIT ${DB_PAGE_SIZE} OFFSET ${_dbPages.hashes.offset}`), window.pork.dbQuery('SELECT COUNT(*) AS total FROM game_hashes'), ]); _dbPages.hashes.total = Number(countRows[0]?.total || 0); if (_dbPages.hashes.offset >= _dbPages.hashes.total && _dbPages.hashes.offset > 0) { _dbPages.hashes.offset = Math.max(0, Math.floor(Math.max(0, _dbPages.hashes.total - 1) / DB_PAGE_SIZE) * DB_PAGE_SIZE); return loadHashRowsTable(); } const countEl = $('hash-rows-count'); if (countEl) countEl.textContent = dbPageLabel('hashes'); updateDbPager('hashes'); renderDbTableEl('db-hash-rows-table', rows, 'hashes'); } catch (e) { setStatus(e.message, 'error'); } } async function loadDbTable(name) { try { const sql = name === 'games' ? `SELECT id, title, game_id, version, size, installed, backed_up, last_scanned FROM games ORDER BY title LIMIT ${DB_PAGE_SIZE} OFFSET ${_dbPages[name].offset}` : `SELECT id, game_id, backup_type, size, backup_path, created_at FROM backups ORDER BY created_at DESC LIMIT ${DB_PAGE_SIZE} OFFSET ${_dbPages[name].offset}`; const [rows, countRows] = await Promise.all([window.pork.dbQuery(sql), window.pork.dbQuery(`SELECT COUNT(*) AS total FROM ${name}`)]); _dbPages[name].total = Number(countRows[0]?.total || 0); if (_dbPages[name].offset >= _dbPages[name].total && _dbPages[name].offset > 0) { _dbPages[name].offset = Math.max(0, Math.floor(Math.max(0, _dbPages[name].total - 1) / DB_PAGE_SIZE) * DB_PAGE_SIZE); return loadDbTable(name); } const info = $(`db-${name}-page-info`); if (info) info.textContent = dbPageLabel(name); updateDbPager(name); renderDbTableEl(`db-${name}-table`, rows, name); } catch (e) { setStatus(e.message, 'error'); } } function dbPageLabel(name) { const page = _dbPages[name]; const first = page.total ? page.offset + 1 : 0; const last = Math.min(page.total, page.offset + DB_PAGE_SIZE); const label = name === 'games' ? 'games' : name === 'backups' ? 'back-ups' : 'hashes'; const separator = window.i18n?.current === 'nl' ? 'van' : 'of'; return `${first}–${last} ${separator} ${page.total} ${label}`; } function dbColumnLabel(column) { return String(column || '') .replace(/_/g, ' ') .replace(/\b\w/g, letter => letter.toUpperCase()) .replace(/\bId\b/g, 'ID') .replace(/\bSha 256\b/gi, 'SHA-256'); } function dbFormatValue(column, value) { if (value == null || value === '') return '—'; if (['size', 'file_size'].includes(column) && Number.isFinite(Number(value))) return _fmtBytes(Number(value)); if (['installed', 'backed_up', 'verified'].includes(column)) return Number(value) ? 'Yes' : 'No'; if (/(?:_at|last_scanned)$/.test(column)) { const numeric = Number(value); const timestamp = Number.isFinite(numeric) && numeric > 0 && numeric < 1e12 ? numeric * 1000 : value; const date = new Date(timestamp); if (!Number.isNaN(date.getTime())) return date.toLocaleString(window.i18n?.current || 'en'); } return String(value); } function updateDbPager(name) { document.querySelectorAll(`.db-page-btn[data-table="${name}"]`).forEach(button => { button.disabled = button.dataset.direction === 'previous' ? _dbPages[name].offset === 0 : _dbPages[name].offset + DB_PAGE_SIZE >= _dbPages[name].total; }); } document.querySelectorAll('.db-page-btn').forEach(button => button.addEventListener('click', async () => { const name = button.dataset.table; const direction = button.dataset.direction; if (!_dbPages[name]) return; _dbPages[name].offset = direction === 'previous' ? Math.max(0, _dbPages[name].offset - DB_PAGE_SIZE) : Math.min(Math.max(0, _dbPages[name].total - 1), _dbPages[name].offset + DB_PAGE_SIZE); if (name === 'hashes') await loadHashRowsTable(); else await loadDbTable(name); })); function renderDbTableEl(tableId, rows, tableName) { const table = $(tableId); if (!table) return; // Hide toolbar when table reloads const toolbar = $(`toolbar-${tableName}`); if (toolbar) toolbar.hidden = true; if (!rows.length) { table.innerHTML = ''; return; } const cols = Object.keys(rows[0]); const hasId = cols.includes('id'); table.innerHTML = ` ${hasId ? '' : ''}${cols.map(c => `${escHtml(dbColumnLabel(c))}`).join('')} ${rows.map(r => { const rowId = hasId ? r.id : ''; return `${hasId ? `` : ''}${cols.map(c => `${escHtml(dbFormatValue(c, r[c]))}`).join('')}`; }).join('')} `; if (!hasId) return; // Wire up "select all" header checkbox const selectAllCb = table.querySelector('.db-select-all'); if (selectAllCb) { selectAllCb.addEventListener('change', () => { table.querySelectorAll('.db-row-check').forEach(cb => { cb.checked = selectAllCb.checked; cb.closest('tr').classList.toggle('db-row-selected', selectAllCb.checked); }); _updateRowToolbar(tableName); }); } // Wire up individual row checkboxes table.querySelectorAll('.db-row-check').forEach(cb => { cb.addEventListener('change', () => { cb.closest('tr').classList.toggle('db-row-selected', cb.checked); _updateRowToolbar(tableName); }); }); } function _getCheckedIds(tableName) { const tableEl = tableName === 'games' ? $('db-games-table') : tableName === 'backups' ? $('db-backups-table') : $('db-hash-rows-table'); if (!tableEl) return []; return [...tableEl.querySelectorAll('.db-row-check:checked')].map(cb => Number(cb.value)); } function _updateRowToolbar(tableName) { const ids = _getCheckedIds(tableName); const toolbar = $(`toolbar-${tableName}`); if (!toolbar) return; toolbar.hidden = ids.length === 0; const countEl = $(`toolbar-${tableName}-count`); if (countEl) countEl.textContent = `${ids.length} selected`; } // DB Tabs document.querySelectorAll('.db-tab').forEach(tab => tab.addEventListener('click', () => { document.querySelectorAll('.db-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.db-panel').forEach(p => p.classList.remove('active')); tab.classList.add('active'); $(`dbtab-${tab.dataset.dbtab}`).classList.add('active'); if (tab.dataset.dbtab === 'hash-rows') loadHashRowsTable(); if (tab.dataset.dbtab === 'hashes') loadHashDbContent(); }) ); // Query runner $('btn-run-query').addEventListener('click', async () => { const sql = $('query-input').value.trim(); if (!sql) return; const out = $('query-results'); try { const rows = await window.pork.dbQuery(sql); if (!rows.length) { out.innerHTML = '
Query returned no rows.
'; return; } const cols = Object.keys(rows[0]); out.innerHTML = `
${cols.map(c => ``).join('')}${rows.map(r => `${cols.map(c => ``).join('')}`).join('')}
${escHtml(c)}
${escHtml(r[c] ?? '')}
`; } catch (e) { out.innerHTML = `
${escHtml(e.message)}
`; } }); // Exports $('btn-export-sql').addEventListener('click', async () => { const p = await window.pork.exportSql(); if (p) setStatus(`Exported to ${p}`, 'ok'); }); $('btn-export-games-csv').addEventListener('click', async () => { const p = await window.pork.exportCsv('games'); if (p) setStatus(`Exported to ${p}`, 'ok'); }); $('btn-export-backups-csv').addEventListener('click', async () => { const p = await window.pork.exportCsv('backups'); if (p) setStatus(`Exported to ${p}`, 'ok'); }); $('btn-export-hashes').addEventListener('click', async () => { const p = await window.pork.hashExport(); if (p) setStatus(`Hash DB exported to ${p}`, 'ok'); }); // ── Hash Database tab ─────────────────────────────────────────────────────────────────────── async function loadHashDbContent() { try { const info = await window.pork.hashCommunityInfo(); const countEl = $('hdb-community-count'); const verEl = $('hdb-community-ver'); const srcEl = $('hdb-community-source'); const localEl = $('hdb-local-count'); if (!countEl) return; // tab panel not in DOM countEl.textContent = `${info.count} hash${info.count !== 1 ? 'es' : ''}`; countEl.className = info.count > 0 ? 'hash-badge hash-community' : 'hash-badge hash-none'; verEl.textContent = info.updated ? `v${info.version || 1} · ${info.updated}` : ''; srcEl.textContent = info.source === 'imported' ? '(user-imported)' : '(bundled)'; localEl.textContent = `${info.localCount} hash${info.localCount !== 1 ? 'es' : ''}`; localEl.className = info.localCount > 0 ? 'hash-badge hash-local' : 'hash-badge hash-none'; } catch (_) {} try { const summaries = await window.pork.hashGameSummary(); const list = $('hdb-game-list'); if (!list) return; if (!summaries.length) { list.innerHTML = '
No hashes recorded yet. Open any game, go to Hash Verification, and click \'Hash All Files\'. Or use the Hash All Games / Backporks buttons on those pages.
'; return; } summaries.sort((a, b) => { if (a.community_mismatch !== b.community_mismatch) return a.community_mismatch ? -1 : 1; if (a.community_matches !== b.community_matches) return b.community_matches - a.community_matches; const typeOrder = { game: 0, backpork: 1 }; const tDiff = (typeOrder[a.hash_type] ?? 0) - (typeOrder[b.hash_type] ?? 0); if (tDiff !== 0) return tDiff; return a.game_id.localeCompare(b.game_id); }); list.innerHTML = `
${summaries.map(s => { const badge = s.community_mismatch ? `⚠ Hash Mismatch` : s.community_matches > 0 ? `✓ Verified (${s.community_matches}/${s.hash_count})` : `⧭ Hashed (${s.hash_count})`; const typeLabel = s.hash_type === 'backpork' ? `Backpork` : `Game`; const fwLabel = s.firmware_label ? escHtml(s.firmware_label) : '—'; return ``; }).join('')}
Game IDTypeFirmwareFiles HashedCommunity MatchesStatus
${escHtml(s.game_id)}${typeLabel}${fwLabel}${s.hash_count}${s.community_matches || '—'}${badge}
`; } catch (_) {} } const _fmtBytes = b => b < 1024 ? `${b} B` : b < 1048576 ? `${(b/1024).toFixed(1)} KB` : b < 1073741824 ? `${(b/1048576).toFixed(1)} MB` : `${(b/1073741824).toFixed(2)} GB`; let _hashBulkRunning = false; function _setBulkProgress({ show = true, pct = 0, status = '', fileStatus = '' } = {}) { const wrap = $('bulk-hash-progress'); const bar = $('bulk-hash-bar'); const pctEl = $('bulk-hash-pct'); const statEl = $('hash-all-games-status'); const fileEl = $('bulk-hash-file-status'); if (!wrap) return; wrap.style.display = show ? 'flex' : 'none'; if (bar) bar.style.width = `${pct}%`; if (pctEl) pctEl.textContent = `${pct}%`; if (statEl) statEl.textContent = status; if (fileEl) fileEl.textContent = fileStatus; } async function _runBulkHash(source) { if (_hashBulkRunning) { showToast('A hash operation is already running.', 'info'); return; } _hashBulkRunning = true; const isGames = source === 'local'; const btnEl = $(isGames ? 'btn-hash-all-games' : 'btn-hash-all-backporks'); const backporksStat = $('hash-all-backporks-status'); btnEl.disabled = true; if (isGames) { _setBulkProgress({ show: true, pct: 0, status: 'Preparing…', fileStatus: '' }); } else if (backporksStat) { backporksStat.textContent = 'Preparing…'; } try { const { results, total, totalFilesHashed, totalBytesHashed } = await window.pork.hashBulk(source); const ok = results.filter(r => r.ok).length; const fail = results.filter(r => !r.ok).length; const doneMsg = `Done ✓ — ${ok} game${ok !== 1 ? 's' : ''}, ${totalFilesHashed} file${totalFilesHashed !== 1 ? 's' : ''} (${_fmtBytes(totalBytesHashed || 0)})${fail ? `, ${fail} skipped` : ''}`; if (isGames) { _setBulkProgress({ show: true, pct: 100, status: doneMsg, fileStatus: '' }); // Fade the progress block away after a few seconds setTimeout(() => _setBulkProgress({ show: false }), 6000); } else if (backporksStat) { backporksStat.textContent = doneMsg; } // Refresh hash summaries and re-render badges everywhere const summaries = await window.pork.hashGameSummary(); _populateHashMaps(summaries); renderGames(); showToast(`Hash complete — ${totalFilesHashed} files (${_fmtBytes(totalBytesHashed || 0)})`, 'ok'); } catch (e) { const errMsg = 'Error: ' + e.message; if (isGames) { _setBulkProgress({ show: true, pct: 0, status: errMsg, fileStatus: '' }); } else if (backporksStat) { backporksStat.textContent = errMsg; } showToast('Hash failed: ' + e.message, 'error'); } finally { _hashBulkRunning = false; btnEl.disabled = false; } } window.pork.on('hash:bulk:progress', ({ phase, done, total, percent, gameId, currentFile, fileSize, filePct, totalFilesHashed, totalBytesHashed }) => { const backporksStat = $('hash-all-backporks-status'); const isGamesPage = !!document.querySelector('#page-games.active'); const overallStatus = `Game ${done}/${total} (${percent}%) — ${gameId || ''}` + (totalFilesHashed ? ` | ${totalFilesHashed} file${totalFilesHashed !== 1 ? 's' : ''} hashed` : '') + (totalBytesHashed ? ` | ${_fmtBytes(totalBytesHashed)}` : ''); let fileStatus = ''; if (currentFile) { const sizeSuffix = fileSize ? ` (${_fmtBytes(fileSize)})` : ''; const pctSuffix = (phase === 'file-progress' && filePct != null) ? ` — ${filePct}%` : ''; fileStatus = `⧭ ${currentFile}${sizeSuffix}${pctSuffix}`; } else if (phase === 'backup-done') { fileStatus = `✓ ${gameId} complete`; } // done = completed backups so far (0 during first game's files). // Interpolate within the current slot: done/total gets us to the start of this slot, // then add filePct spread across one slot width. const barPct = (phase === 'file-progress' && filePct != null) ? Math.min(100, Math.round(done / (total || 1) * 100 + filePct / (total || 1))) : percent; _setBulkProgress({ show: true, pct: barPct, status: overallStatus, fileStatus }); // Mirror to backporks status if the games progress block isn't visible if (backporksStat && !isGamesPage) { backporksStat.textContent = overallStatus; } }); $('btn-hash-all-games').addEventListener('click', async () => { const ok = await showConfirm('Hash All Game Backups?\n\nThis will SHA-256 every file in every local game backup. On large libraries this can take hours.\n\nThe app stays usable while hashing. Continue?'); if (ok) _runBulkHash('local'); }); $('btn-hash-all-backporks').addEventListener('click', async () => { const ok = await showConfirm('Hash All Backpork Backups?\n\nThis will SHA-256 every file in every backpork backup. On large libraries this can take hours.\n\nThe app stays usable while hashing. Continue?'); if (ok) _runBulkHash('backpork'); }); $('btn-hdb-export').addEventListener('click', async () => { const p = await window.pork.hashExport(); if (p) showToast(`Hash DB exported to ${p}`, 'ok'); }); $('btn-import-community-hashes').addEventListener('click', async () => { const btn = $('btn-import-community-hashes'); btn.disabled = true; try { const result = await window.pork.hashImportCommunity(); if (!result) { btn.disabled = false; return; } showToast(`Community hash list imported — ${result.count} hashes loaded`, 'ok'); await loadHashDbContent(); // Refresh game summaries so badges update immediately const summaries = await window.pork.hashGameSummary(); _populateHashMaps(summaries); renderGames(); } catch (e) { showToast('Import failed: ' + e.message, 'error'); } finally { btn.disabled = false; } }); // ── Selective Clear dialog ────────────────────────────────────────────────────── $('btn-clear-db').addEventListener('click', () => { // Reset all checkboxes document.querySelectorAll('#clear-select-options input[type="checkbox"]').forEach(cb => { cb.checked = false; }); $('clear-select-ok').disabled = true; openAccessibleDialog($('clear-select-overlay'), document.querySelector('#clear-select-options input')); }); // Toggle "Clear Selected" button enable/disable based on any checked document.querySelectorAll('#clear-select-options input[type="checkbox"]').forEach(cb => { cb.addEventListener('change', () => { const anyChecked = [...document.querySelectorAll('#clear-select-options input[type="checkbox"]')].some(c => c.checked); $('clear-select-ok').disabled = !anyChecked; }); }); // Select All button $('clear-select-all').addEventListener('click', () => { document.querySelectorAll('#clear-select-options input[type="checkbox"]').forEach(cb => { cb.checked = true; }); $('clear-select-ok').disabled = false; }); // Cancel $('clear-select-cancel').addEventListener('click', () => { closeAccessibleDialog($('clear-select-overlay')); }); // Clear Selected $('clear-select-ok').addEventListener('click', async () => { const checked = [...document.querySelectorAll('#clear-select-options input[type="checkbox"]')] .filter(cb => cb.checked).map(cb => cb.value); if (!checked.length) return; const labels = checked.map(v => v.charAt(0).toUpperCase() + v.slice(1)); // Hide the selection overlay so the confirm dialog is visible on top closeAccessibleDialog($('clear-select-overlay')); const confirmation = await showExactConfirmation(`Permanently delete: ${labels.join(', ')}?\n\nThis cannot be undone.`, 'WIS SELECTIE'); if (!confirmation) { // User cancelled — re-show the selection overlay with their choices preserved openAccessibleDialog($('clear-select-overlay'), document.querySelector('#clear-select-options input:checked')); return; } const opts = {}; checked.forEach(k => { opts[k] = true; }); await window.pork.dbClearSelective(opts, confirmation); loadDatabase(); loadHashDbContent(); const summaries = await window.pork.hashGameSummary(); _populateHashMaps(summaries); renderGames(); setStatus(`Cleared: ${labels.join(', ')}`, 'ok'); showToast(`Cleared: ${labels.join(', ')}`, 'ok'); }); // ── Row-level delete handlers ──────────────────────────────────────────────────── async function _deleteSelectedRows(tableName, dbTable) { const ids = _getCheckedIds(tableName); if (!ids.length) return; const confirmation = await showExactConfirmation(`Delete ${ids.length} ${tableName} row${ids.length !== 1 ? 's' : ''}?\n\nThis cannot be undone.`, 'VERWIJDER'); if (!confirmation) return; const { deleted } = await window.pork.dbDeleteRows(dbTable, ids, confirmation); showToast(`Deleted ${deleted} ${tableName} row${deleted !== 1 ? 's' : ''}.`, 'ok'); // Reload the affected table if (tableName === 'games') await loadDbTable('games'); if (tableName === 'backups') await loadDbTable('backups'); if (tableName === 'hashes') await loadHashRowsTable(); // Refresh game page data const summaries = await window.pork.hashGameSummary(); _populateHashMaps(summaries); renderGames(); } function _selectAllRows(tableName) { const tableEl = tableName === 'games' ? $('db-games-table') : tableName === 'backups' ? $('db-backups-table') : $('db-hash-rows-table'); tableEl.querySelectorAll('.db-row-check').forEach(cb => { cb.checked = true; cb.closest('tr').classList.add('db-row-selected'); }); const selectAll = tableEl.querySelector('.db-select-all'); if (selectAll) selectAll.checked = true; _updateRowToolbar(tableName); } function _deselectAllRows(tableName) { const tableEl = tableName === 'games' ? $('db-games-table') : tableName === 'backups' ? $('db-backups-table') : $('db-hash-rows-table'); tableEl.querySelectorAll('.db-row-check').forEach(cb => { cb.checked = false; cb.closest('tr').classList.remove('db-row-selected'); }); const selectAll = tableEl.querySelector('.db-select-all'); if (selectAll) selectAll.checked = false; _updateRowToolbar(tableName); } // Games toolbar $('btn-delete-games').addEventListener('click', () => _deleteSelectedRows('games', 'games')); $('btn-select-all-games').addEventListener('click', () => _selectAllRows('games')); $('btn-deselect-all-games').addEventListener('click', () => _deselectAllRows('games')); // Backups toolbar $('btn-delete-backups').addEventListener('click', () => _deleteSelectedRows('backups', 'backups')); $('btn-select-all-backups').addEventListener('click', () => _selectAllRows('backups')); $('btn-deselect-all-backups').addEventListener('click', () => _deselectAllRows('backups')); // Hashes toolbar $('btn-delete-hashes').addEventListener('click', () => _deleteSelectedRows('hashes', 'game_hashes')); $('btn-select-all-hashes').addEventListener('click', () => _selectAllRows('hashes')); $('btn-deselect-all-hashes').addEventListener('click', () => _deselectAllRows('hashes')); async function _clearHashes() { const confirmation = await showExactConfirmation('Delete all stored hashes from the database? This cannot be undone.', 'WIS HASHES'); if (!confirmation) return; await window.pork.dbClearHashes(confirmation); await loadHashRowsTable(); await loadHashDbContent(); const summaries = await window.pork.hashGameSummary(); _populateHashMaps(summaries); renderGames(); showToast('All hashes cleared.', 'ok'); } $('btn-clear-hashes').addEventListener('click', _clearHashes); $('btn-hdb-clear-hashes').addEventListener('click', _clearHashes);