diff --git a/README.md b/README.md index 3fa887b..e0724bb 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,10 @@ git pull - POST `/api/catalog/import` - bouton UI `Migrer localStorage vers DB` - deduplication: `console + titre + annee` +- Etape 6: backup/restauration locale JSON + - GET `/api/backup/export` + - POST `/api/backup/restore` (modes `merge` ou `replace`) + - snapshot auto en base avant restore `replace` (`backup_snapshots`) ## Licence diff --git a/api/server.js b/api/server.js index fa3a631..2a765b6 100644 --- a/api/server.js +++ b/api/server.js @@ -113,6 +113,15 @@ async function runMigrations() { ); `); + await pool.query(` + CREATE TABLE IF NOT EXISTS backup_snapshots ( + id BIGSERIAL PRIMARY KEY, + reason TEXT NOT NULL, + dump_payload JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `); + await pool.query(` CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$ @@ -238,6 +247,241 @@ async function getCatalogFull() { return { brands, gamesByConsole }; } +async function exportCatalogDumpWithClient(client) { + const brandsResult = await client.query(` + SELECT id::int AS id, name + FROM brands + ORDER BY name ASC; + `); + + const consolesResult = await client.query(` + SELECT + c.id::int AS id, + b.name AS brand, + c.name + FROM consoles c + JOIN brands b ON b.id = c.brand_id + ORDER BY b.name ASC, c.name ASC; + `); + + const gamesResult = await client.query(` + SELECT + g.id::text AS id, + b.name AS brand, + c.name AS console_name, + g.title, + g.genre, + g.publisher, + g.release_year, + g.estimated_value, + g.loaned_to, + g.created_at, + g.updated_at + FROM games g + JOIN consoles c ON c.id = g.console_id + JOIN brands b ON b.id = c.brand_id + ORDER BY g.created_at DESC; + `); + + return { + version: "1.0", + exportedAt: new Date().toISOString(), + source: serviceName, + summary: { + brands: brandsResult.rows.length, + consoles: consolesResult.rows.length, + games: gamesResult.rows.length, + }, + catalog: { + brands: brandsResult.rows.map((row) => ({ + name: row.name, + })), + consoles: consolesResult.rows.map((row) => ({ + brand: row.brand, + name: row.name, + })), + games: gamesResult.rows.map((row) => ({ + id: row.id, + brand: row.brand, + consoleName: row.console_name, + title: row.title, + genre: row.genre || "", + publisher: row.publisher || "", + year: row.release_year || null, + value: row.estimated_value != null ? Number(row.estimated_value) : null, + loanedTo: row.loaned_to || "", + createdAt: row.created_at, + updatedAt: row.updated_at, + })), + }, + }; +} + +async function exportCatalogDump() { + const client = await pool.connect(); + try { + return await exportCatalogDumpWithClient(client); + } finally { + client.release(); + } +} + +function validateBackupDump(dump) { + if (!dump || typeof dump !== "object") { + throw new Error("Invalid dump payload"); + } + + if (!dump.catalog || typeof dump.catalog !== "object") { + throw new Error("Invalid dump catalog"); + } + + const { brands, consoles, games } = dump.catalog; + if (!Array.isArray(brands) || !Array.isArray(consoles) || !Array.isArray(games)) { + throw new Error("Invalid dump arrays"); + } +} + +function normalizeDateOrNull(value) { + if (!value) { + return null; + } + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return null; + } + return date.toISOString(); +} + +async function restoreCatalogDump(mode, dump) { + validateBackupDump(dump); + const restoreMode = mode === "replace" ? "replace" : "merge"; + + const client = await pool.connect(); + let insertedConsoles = 0; + let insertedGames = 0; + let preRestoreSnapshotId = null; + + try { + await client.query("BEGIN"); + + if (restoreMode === "replace") { + const snapshotDump = await exportCatalogDumpWithClient(client); + const snapshotResult = await client.query( + ` + INSERT INTO backup_snapshots(reason, dump_payload) + VALUES ($1, $2::jsonb) + RETURNING id::int AS id; + `, + ["pre-restore-replace", JSON.stringify(snapshotDump)], + ); + preRestoreSnapshotId = snapshotResult.rows[0].id; + + await client.query("DELETE FROM games;"); + await client.query("DELETE FROM consoles;"); + await client.query("DELETE FROM brands;"); + } + + for (const brandEntry of dump.catalog.brands) { + const brand = normalizeText(brandEntry && brandEntry.name).toUpperCase(); + if (!brand) { + continue; + } + await client.query( + ` + INSERT INTO brands(name) + VALUES ($1) + ON CONFLICT (name) DO NOTHING; + `, + [brand], + ); + } + + for (const consoleEntry of dump.catalog.consoles) { + const brand = normalizeText(consoleEntry && consoleEntry.brand).toUpperCase(); + const consoleName = normalizeText(consoleEntry && consoleEntry.name); + if (!brand || !consoleName) { + continue; + } + + const existingConsole = await client.query( + ` + SELECT c.id + FROM consoles c + JOIN brands b ON b.id = c.brand_id + WHERE b.name = $1 AND c.name = $2 + LIMIT 1; + `, + [brand, consoleName], + ); + + const ensured = await ensureConsole(client, brand, consoleName); + if (ensured && ensured.consoleId && !existingConsole.rowCount) { + insertedConsoles += 1; + } + } + + for (const gameEntry of dump.catalog.games) { + const brand = normalizeText(gameEntry && gameEntry.brand).toUpperCase(); + const consoleName = normalizeText(gameEntry && gameEntry.consoleName); + const title = normalizeText(gameEntry && gameEntry.title); + if (!brand || !consoleName || !title) { + continue; + } + + const ensured = await ensureConsole(client, brand, consoleName); + const consoleId = ensured.consoleId; + const year = gameEntry && gameEntry.year != null && gameEntry.year !== "" ? Number(gameEntry.year) : null; + + const dedupeResult = await client.query( + ` + SELECT 1 + FROM games + WHERE console_id = $1 + AND LOWER(title) = LOWER($2) + AND COALESCE(release_year, 0) = COALESCE($3, 0) + LIMIT 1; + `, + [consoleId, title, year], + ); + + if (dedupeResult.rowCount) { + continue; + } + + const genre = normalizeText(gameEntry && gameEntry.genre) || null; + const publisher = normalizeText(gameEntry && gameEntry.publisher) || null; + const loanedTo = normalizeText(gameEntry && gameEntry.loanedTo) || null; + const value = gameEntry && gameEntry.value != null && gameEntry.value !== "" ? Number(gameEntry.value) : null; + const createdAt = normalizeDateOrNull(gameEntry && gameEntry.createdAt); + + await client.query( + ` + INSERT INTO games( + console_id, title, genre, publisher, release_year, estimated_value, loaned_to, created_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8::timestamptz, NOW())); + `, + [consoleId, title, genre, publisher, year, value, loanedTo, createdAt], + ); + + insertedGames += 1; + } + + await client.query("COMMIT"); + return { + mode: restoreMode, + insertedConsoles, + insertedGames, + preRestoreSnapshotId, + }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + async function createConsole(brand, consoleName) { const client = await pool.connect(); try { @@ -564,6 +808,29 @@ async function handleRequest(request, response) { return; } + if (request.method === "GET" && url.pathname === "/api/backup/export") { + try { + const dump = await exportCatalogDump(); + sendJson(response, 200, dump); + } catch (error) { + sendJson(response, 500, { status: "error", message: error.message }); + } + return; + } + + if (request.method === "POST" && url.pathname === "/api/backup/restore") { + try { + const body = await readJsonBody(request); + const mode = body && body.mode ? body.mode : "merge"; + const dump = body && body.dump ? body.dump : body; + const result = await restoreCatalogDump(mode, dump); + sendJson(response, 200, { status: "ok", ...result }); + } catch (error) { + sendJson(response, 400, { status: "error", message: error.message }); + } + return; + } + const gameIdMatch = url.pathname.match(/^\/api\/catalog\/games\/([0-9a-fA-F-]+)$/); if (request.method === "PUT" && gameIdMatch) { try { diff --git a/app.js b/app.js index 99fa041..222def6 100644 --- a/app.js +++ b/app.js @@ -33,9 +33,15 @@ const consoleTabs = document.getElementById("consoleTabs"); const gameSectionTitle = document.getElementById("gameSectionTitle"); const dataModeInfo = document.getElementById("dataModeInfo"); const migrateBtn = document.getElementById("migrateBtn"); +const backupControls = document.getElementById("backupControls"); +const backupBtn = document.getElementById("backupBtn"); +const restoreMergeBtn = document.getElementById("restoreMergeBtn"); +const restoreReplaceBtn = document.getElementById("restoreReplaceBtn"); +const restoreFileInput = document.getElementById("restoreFileInput"); const gamesList = document.getElementById("gamesList"); const gameCardTemplate = document.getElementById("gameCardTemplate"); let editingGameId = null; +let pendingRestoreMode = "merge"; platformForm.addEventListener("submit", async (event) => { event.preventDefault(); @@ -292,6 +298,86 @@ migrateBtn.addEventListener("click", async () => { } }); +backupBtn.addEventListener("click", async () => { + if (!apiReachable) { + alert("API indisponible. Sauvegarde JSON impossible."); + return; + } + + try { + const dump = await apiRequest("/api/backup/export"); + const blob = new Blob([JSON.stringify(dump, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + a.href = url; + a.download = `video-games-backup-${stamp}.json`; + document.body.append(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (error) { + console.error(error); + alert("Echec de la sauvegarde JSON."); + } +}); + +restoreMergeBtn.addEventListener("click", () => { + pendingRestoreMode = "merge"; + restoreFileInput.click(); +}); + +restoreReplaceBtn.addEventListener("click", () => { + pendingRestoreMode = "replace"; + restoreFileInput.click(); +}); + +restoreFileInput.addEventListener("change", async (event) => { + const input = event.target; + const file = input.files && input.files[0] ? input.files[0] : null; + input.value = ""; + + if (!file) { + return; + } + + if (!apiReachable) { + alert("API indisponible. Restauration impossible."); + return; + } + + try { + const fileText = await file.text(); + const dump = JSON.parse(fileText); + + if (pendingRestoreMode === "replace") { + const confirmed = window.confirm( + "Mode remplacement: la base actuelle sera remplacee. Une sauvegarde pre-restore sera creee. Continuer ?", + ); + if (!confirmed) { + return; + } + } + + const result = await apiRequest("/api/backup/restore", { + method: "POST", + body: { + mode: pendingRestoreMode, + dump, + }, + }); + + pendingLocalImport = null; + await refreshFromApi(state.selectedBrand, state.selectedConsole); + alert( + `Restauration terminee (${result.mode}): ${result.insertedGames || 0} jeu(x), ${result.insertedConsoles || 0} console(s).`, + ); + } catch (error) { + console.error(error); + alert("Echec de la restauration JSON."); + } +}); + function render() { renderDataMode(); renderBrandTabs(); @@ -322,6 +408,12 @@ function renderDataMode() { } else { migrateBtn.classList.add("hidden"); } + + if (apiReachable) { + backupControls.classList.remove("hidden"); + } else { + backupControls.classList.add("hidden"); + } } function renderBrandTabs() { diff --git a/index.html b/index.html index 80055a0..5b4c0d4 100644 --- a/index.html +++ b/index.html @@ -53,6 +53,12 @@ +