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

162 lines
8.8 KiB
JavaScript

// ── Backups page ──────────────────────────────────────────────────────────────
function protectionText(key, fallback) {
if (typeof window.t !== 'function') return fallback;
const translated = window.t(key);
return translated && translated !== key ? translated : fallback;
}
async function loadBackups() {
try {
const gameContext = consumeGameWorkspaceContext('backups');
if (gameContext && $('backups-search')) $('backups-search').value = gameContext;
const titles = [];
let offset = 0;
let total = 0;
do {
const packet = await window.pork.listLibraryTitles({ limit: 200, offset });
const page = window.chimeraLibraryModel.items(packet);
titles.push(...page);
total = Number(packet && packet.total || page.length);
offset += page.length;
if (!page.length) break;
} while (offset < total);
state.backups = window.chimeraProtectionModel.flatten(titles);
populateBackupsRegionFilter();
renderBackups();
} catch (e) {
setStatus(e.message, 'error');
}
}
function populateBackupsRegionFilter() {
const sel = $('backups-region-filter');
const cur = sel.value;
const regions = [...new Set(
state.backups.map(copy => copy.region || '').filter(Boolean).sort()
)];
sel.innerHTML = '<option value="">All Regions</option>'
+ regions.map(r => `<option value="${escHtml(r)}"${r === cur ? ' selected' : ''}>${escHtml(r)}</option>`).join('');
}
function renderBackups() {
const search = ($('backups-search')?.value || '').toLowerCase();
const region = $('backups-region-filter')?.value || '';
const list = $('backups-list');
const empty = $('backups-empty');
const filtered = state.backups.filter(copy => {
if (region && copy.region !== region) return false;
if (search) {
const haystack = [copy.titleName, copy.titleId, copy.deviceName, copy.path, copy.hash].filter(Boolean).join(' ').toLowerCase();
if (!haystack.includes(search)) return false;
}
return true;
});
empty.hidden = filtered.length > 0;
list.innerHTML = filtered.map(copy => {
const verified = copy.lastVerifiedAt ? new Date(copy.lastVerifiedAt).toLocaleString(window.i18n?.current || 'en') : protectionText('protection.copy.never', 'Never');
const hash = copy.hash ? `${copy.hashAlgorithm.toUpperCase()} ${copy.hash}` : `${protectionText('protection.copy.notHashed', 'Not hashed')} (${copy.hashState})`;
const integrity = copy.missing
? protectionText('protection.copy.missing', 'Missing copy')
: copy.integrityFailed
? `${protectionText('protection.copy.failed', 'Failed')} (${copy.integrityState})`
: copy.integrityState === 'unknown' ? protectionText('protection.copy.notVerified', 'Not verified') : copy.integrityState;
const policyClass = copy.policy.healthy ? 'is-healthy' : copy.policy.status === 'needs-diversity' ? 'needs-diversity' : 'is-degraded';
const policyLabel = copy.policy.healthy
? `${copy.policy.distinctDeviceCount}/${copy.policy.requiredDistinctDevices} ${protectionText('protection.policy.devicesProtected', 'devices protected')}`
: `${copy.policy.distinctDeviceCount}/${copy.policy.requiredDistinctDevices} ${protectionText('protection.policy.distinctDevices', 'distinct devices')}`;
return `<article class="backup-card protection-copy-card${copy.missing ? ' is-missing' : ''}${copy.integrityFailed ? ' has-integrity-failure' : ''}" data-copy-id="${escHtml(copy.copyId)}">
<div class="protection-copy-title">
<div class="backup-card-icon backup-card-icon-empty"></div>
<div class="protection-copy-title-copy"><strong>${escHtml(copy.titleName)}</strong><code>${escHtml(copy.titleId)} · copy ${escHtml(copy.copyId)}</code></div>
</div>
<div class="protection-copy-facts">
<div class="protection-copy-fact"><span>${escHtml(protectionText('protection.copy.device', 'Device'))}</span><button class="protection-device-label" type="button" data-label-device-id="${escHtml(copy.deviceId || '')}" data-device-label="${escHtml(copy.deviceName)}" title="${escHtml(protectionText('protection.copy.renameDevice', 'Rename this drive'))}">${escHtml(copy.deviceName)}</button></div>
<div class="protection-copy-fact"><span>${escHtml(protectionText('protection.copy.path', 'Path'))}</span><code title="${escHtml(copy.path)}">${escHtml(copy.path)}</code></div>
<div class="protection-copy-fact"><span>${escHtml(protectionText('protection.copy.hash', 'Hash'))}</span><code title="${escHtml(hash)}">${escHtml(hash)}</code></div>
<div class="protection-copy-fact"><span>${escHtml(protectionText('protection.copy.lastVerified', 'Last verified'))}</span><strong>${escHtml(verified)}</strong></div>
<div class="protection-copy-fact"><span>${escHtml(protectionText('protection.copy.integrity', 'Integrity'))}</span><strong>${escHtml(integrity)}</strong></div>
<div class="protection-copy-fact"><span>${escHtml(protectionText('protection.copy.size', 'Size'))}</span><strong>${escHtml(copy.sizeBytes ? fmt(copy.sizeBytes) : protectionText('protection.copy.unknown', 'Unknown'))}</strong></div>
</div>
<div class="protection-copy-actions">
<span class="protection-copy-policy ${policyClass}">${escHtml(policyLabel)}</span>
<button class="btn btn-sm btn-accent" type="button" data-restore-copy-id="${escHtml(copy.copyId)}"${copy.missing || copy.integrityFailed ? ' disabled' : ''}>${escHtml(protectionText('protection.copy.prepareRestore', 'Prepare restore'))}</button>
<button class="btn btn-sm backup-card-view" type="button" data-view-game="${escHtml(copy.titleId)}">${escHtml(protectionText('protection.copy.titleDetails', 'Title details'))}</button>
</div>
</article>`;
}).join('');
list.onclick = async e => {
const labelButton = e.target.closest('[data-label-device-id]');
if (labelButton) {
const label = await showPrompt(protectionText('protection.copy.renamePrompt', 'Friendly name for this drive:'), labelButton.dataset.deviceLabel || '');
if (label == null) return;
await window.pork.labelStorageDevice({ deviceId: Number(labelButton.dataset.labelDeviceId), label });
await loadBackups();
setStatus(protectionText('protection.copy.renamed', 'Drive label saved.'), 'ok');
return;
}
const restore = e.target.closest('[data-restore-copy-id]');
if (restore) {
const copy = state.backups.find(item => item.copyId === restore.dataset.restoreCopyId);
if (!copy) return;
state.protectionRestoreTarget = copy.restoreTarget;
list.querySelectorAll('[data-restore-copy-id]').forEach(button => {
button.setAttribute('aria-pressed', String(button === restore));
if (!button.disabled) button.textContent = button === restore
? protectionText('protection.copy.restoreSelected', 'Restore source selected')
: protectionText('protection.copy.prepareRestore', 'Prepare restore');
});
setStatus(`${protectionText('protection.copy.restoreSelected', 'Restore source selected')}: ${copy.copyId} · ${copy.deviceName}. ${protectionText('protection.copy.noMutation', 'No restore has started.')}`, 'ok');
return;
}
const details = e.target.closest('[data-view-game]');
if (details) openModal(details.dataset.viewGame);
};
}
$('backups-search')?.addEventListener('input', renderBackups);
$('backups-region-filter')?.addEventListener('change', renderBackups);
$('btn-scan-local').addEventListener('click', async () => {
$('btn-scan-local').disabled = true;
setStatus('Scanning local game source folders…');
try {
const { total, added } = await window.pork.scanBackups();
await loadBackups();
await loadDashboard();
const msg = added > 0
? `Found ${total} game(s) — ${added} new, fetching metadata…`
: `Scan complete: ${total} game(s) found, none new`;
setStatus(msg, 'ok');
} catch (e) {
setStatus(e.message, 'error');
} finally {
$('btn-scan-local').disabled = false;
}
});
$('btn-rescan-all-backporks').addEventListener('click', async () => {
const btn = $('btn-rescan-all-backporks');
btn.disabled = true;
const orig = btn.textContent;
btn.textContent = 'Scanning…';
setStatus('Rescanning all backpork folders…');
try {
const { folders, count, uniqueGames } = await window.pork.backporksScanAll();
await loadBackporks();
await refreshFirmwareFilters();
const uniqueLabel = Number.isFinite(uniqueGames) && uniqueGames > 0
? ` across ${uniqueGames} unique game(s)`
: '';
setStatus(`Rescan complete — ${count} build(s) in ${folders} firmware folder(s)${uniqueLabel}`, 'ok');
} catch (e) {
setStatus(e.message, 'error');
} finally {
btn.disabled = false;
btn.textContent = orig;
}
});