// ── 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 => `${escHtml(c)} `).join('')}
${rows.map(r => `${cols.map(c => `${escHtml(r[c] ?? '')} `).join('')} `).join('')}
`;
} 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 = `
Game ID Type Firmware Files Hashed Community Matches Status
${summaries.map(s => {
const badge = s.community_mismatch
? `⚠ Hash Mismatch `
: s.community_matches > 0
? ``
: `⧭ Hashed (${s.hash_count}) `;
const typeLabel = s.hash_type === 'backpork'
? `Backpork `
: `Game `;
const fwLabel = s.firmware_label ? escHtml(s.firmware_label) : '—';
return `${escHtml(s.game_id)}${typeLabel} ${fwLabel} ${s.hash_count} ${s.community_matches || '—'} ${badge} `;
}).join('')}
`;
} 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);