162 lines
8.8 KiB
JavaScript
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;
|
|
}
|
|
});
|
|
|