Files
chimera-Public/renderer/modules/database.js
T

522 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ── 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 = `
<thead><tr>${hasId ? '<th class="db-row-cb"><input type="checkbox" class="db-select-all" data-table="' + tableName + '"></th>' : ''}${cols.map(c => `<th>${escHtml(dbColumnLabel(c))}</th>`).join('')}</tr></thead>
<tbody>
${rows.map(r => {
const rowId = hasId ? r.id : '';
return `<tr data-row-id="${rowId}">${hasId ? `<td class="db-row-cb"><input type="checkbox" class="db-row-check" data-table="${tableName}" value="${rowId}"></td>` : ''}${cols.map(c => `<td>${escHtml(dbFormatValue(c, r[c]))}</td>`).join('')}</tr>`;
}).join('')}
</tbody>`;
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 = '<div class="empty-msg">Query returned no rows.</div>'; return; }
const cols = Object.keys(rows[0]);
out.innerHTML = `
<div class="table-wrap">
<table>
<thead><tr>${cols.map(c => `<th>${escHtml(c)}</th>`).join('')}</tr></thead>
<tbody>${rows.map(r => `<tr>${cols.map(c => `<td>${escHtml(r[c] ?? '')}</td>`).join('')}</tr>`).join('')}</tbody>
</table>
</div>`;
} catch (e) {
out.innerHTML = `<div class="empty-msg" style="color:var(--red)">${escHtml(e.message)}</div>`;
}
});
// 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 = '<div class="hint" style="padding:16px;color:var(--text-dim)">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.</div>';
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 = `<div class="table-wrap" style="margin-top:14px">
<table style="width:100%">
<thead><tr><th>Game ID</th><th>Type</th><th>Firmware</th><th>Files Hashed</th><th>Community Matches</th><th>Status</th></tr></thead>
<tbody>${summaries.map(s => {
const badge = s.community_mismatch
? `<span class="hash-badge hash-mismatch">⚠ Hash Mismatch</span>`
: s.community_matches > 0
? `<span class="hash-badge hash-community">✓ Verified (${s.community_matches}/${s.hash_count})</span>`
: `<span class="hash-badge hash-local">⧭ Hashed (${s.hash_count})</span>`;
const typeLabel = s.hash_type === 'backpork'
? `<span class="pill" style="background:var(--surface-2);color:var(--text-dim);font-size:10px">Backpork</span>`
: `<span class="pill" style="background:var(--surface-2);color:var(--text-dim);font-size:10px">Game</span>`;
const fwLabel = s.firmware_label ? escHtml(s.firmware_label) : '—';
return `<tr><td><code>${escHtml(s.game_id)}</code></td><td>${typeLabel}</td><td>${fwLabel}</td><td>${s.hash_count}</td><td>${s.community_matches || '—'}</td><td>${badge}</td></tr>`;
}).join('')}</tbody>
</table></div>`;
} 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);