diff --git a/README.md b/README.md index 049d209..3fa887b 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,10 @@ git pull - PUT `/api/catalog/games/:id` - DELETE `/api/catalog/games/:id` - POST `/api/catalog/games/:id/toggle-loan` +- Etape 5: migration des donnees existantes `localStorage -> DB` + - POST `/api/catalog/import` + - bouton UI `Migrer localStorage vers DB` + - deduplication: `console + titre + annee` ## Licence diff --git a/api/server.js b/api/server.js index 4c37bbe..fa3a631 100644 --- a/api/server.js +++ b/api/server.js @@ -357,6 +357,127 @@ async function toggleGameLoan(id) { return result.rowCount > 0; } +async function importCatalog(payload) { + const brands = payload && payload.brands && typeof payload.brands === "object" ? payload.brands : {}; + const gamesByConsole = + payload && payload.gamesByConsole && typeof payload.gamesByConsole === "object" ? payload.gamesByConsole : {}; + + const client = await pool.connect(); + let insertedConsoles = 0; + let insertedGames = 0; + + try { + await client.query("BEGIN"); + + for (const [brandNameRaw, consoles] of Object.entries(brands)) { + if (!Array.isArray(consoles)) { + continue; + } + + for (const consoleNameRaw of consoles) { + const brandName = normalizeText(brandNameRaw).toUpperCase(); + const consoleName = normalizeText(consoleNameRaw); + if (!brandName || !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; + `, + [brandName, consoleName], + ); + + const result = await ensureConsole(client, brandNameRaw, consoleNameRaw); + if (result && result.consoleId && !existingConsole.rowCount) { + insertedConsoles += 1; + } + } + } + + for (const [consoleNameRaw, games] of Object.entries(gamesByConsole)) { + if (!Array.isArray(games) || !games.length) { + continue; + } + + const consoleName = normalizeText(consoleNameRaw); + if (!consoleName) { + continue; + } + + const consoleRow = await client.query( + ` + SELECT c.id, b.name AS brand_name + FROM consoles c + JOIN brands b ON b.id = c.brand_id + WHERE c.name = $1 + ORDER BY c.id ASC + LIMIT 1; + `, + [consoleName], + ); + + if (!consoleRow.rowCount) { + continue; + } + + const consoleId = consoleRow.rows[0].id; + for (const game of games) { + const title = normalizeText(game && game.title); + if (!title) { + continue; + } + + const genre = normalizeText(game && game.genre) || null; + const publisher = normalizeText(game && game.publisher) || null; + const loanedTo = normalizeText(game && game.loanedTo) || null; + const year = game && game.year != null && game.year !== "" ? Number(game.year) : null; + const value = game && game.value != null && game.value !== "" ? Number(game.value) : 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; + } + + await client.query( + ` + INSERT INTO games( + console_id, title, genre, publisher, release_year, estimated_value, loaned_to + ) + VALUES ($1, $2, $3, $4, $5, $6, $7); + `, + [consoleId, title, genre, publisher, year, value, loanedTo], + ); + + insertedGames += 1; + } + } + + await client.query("COMMIT"); + return { insertedConsoles, insertedGames }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + async function handleRequest(request, response) { const url = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`); @@ -432,6 +553,17 @@ async function handleRequest(request, response) { return; } + if (request.method === "POST" && url.pathname === "/api/catalog/import") { + try { + const body = await readJsonBody(request); + const result = await importCatalog(body); + 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 4b7cc3c..99fa041 100644 --- a/app.js +++ b/app.js @@ -11,6 +11,7 @@ const initialState = { }; const state = loadState(); +let pendingLocalImport = payloadHasCatalogData(state) ? structuredClone(state) : null; let dataMode = "local"; let apiReachable = false; @@ -31,6 +32,7 @@ const brandTabs = document.getElementById("brandTabs"); const consoleTabs = document.getElementById("consoleTabs"); const gameSectionTitle = document.getElementById("gameSectionTitle"); const dataModeInfo = document.getElementById("dataModeInfo"); +const migrateBtn = document.getElementById("migrateBtn"); const gamesList = document.getElementById("gamesList"); const gameCardTemplate = document.getElementById("gameCardTemplate"); let editingGameId = null; @@ -45,7 +47,7 @@ platformForm.addEventListener("submit", async (event) => { return; } - if (apiReachable) { + if (apiReachable && dataMode !== "local-pending-import") { try { await apiRequest("/api/catalog/consoles", { method: "POST", @@ -72,6 +74,7 @@ platformForm.addEventListener("submit", async (event) => { platformForm.reset(); persist(); + markLocalDataForImport(); render(); }); @@ -83,7 +86,7 @@ gameForm.addEventListener("submit", async (event) => { return; } - if (apiReachable) { + if (apiReachable && dataMode !== "local-pending-import") { const payload = { brand: state.selectedBrand, consoleName: state.selectedConsole, @@ -149,6 +152,7 @@ gameForm.addEventListener("submit", async (event) => { resetEditMode(); persist(); + markLocalDataForImport(); render(); }); @@ -220,7 +224,7 @@ gamesList.addEventListener("click", async (event) => { return; } - if (apiReachable) { + if (apiReachable && dataMode !== "local-pending-import") { try { if (action === "delete") { await apiRequest(`/api/catalog/games/${id}`, { method: "DELETE" }); @@ -253,6 +257,7 @@ gamesList.addEventListener("click", async (event) => { } persist(); + markLocalDataForImport(); render(); }); @@ -260,6 +265,33 @@ cancelEditBtn.addEventListener("click", () => { resetEditMode(); }); +migrateBtn.addEventListener("click", async () => { + if (!apiReachable || !pendingLocalImport || !payloadHasCatalogData(pendingLocalImport)) { + return; + } + + const confirmed = window.confirm( + "Importer les donnees locales dans la base de donnees ? (deduplication par console + titre + annee)", + ); + if (!confirmed) { + return; + } + + try { + const result = await apiRequest("/api/catalog/import", { + method: "POST", + body: pendingLocalImport, + }); + + pendingLocalImport = null; + await refreshFromApi(state.selectedBrand, state.selectedConsole); + alert(`Migration terminee: ${result.insertedGames || 0} jeu(x) importe(s).`); + } catch (error) { + console.error(error); + alert("Echec de la migration locale vers DB."); + } +}); + function render() { renderDataMode(); renderBrandTabs(); @@ -274,20 +306,22 @@ function renderDataMode() { if (dataMode === "api") { dataModeInfo.textContent = "Source: API (lecture/ecriture active sur la base de donnees)."; - return; - } - - if (dataMode === "api-empty") { + } else if (dataMode === "api-empty") { dataModeInfo.textContent = "Source: API (base vide). Ajoute une section pour demarrer."; - return; - } - - if (dataMode === "local-fallback") { + } else if (dataMode === "local-pending-import") { + dataModeInfo.textContent = "Source: localStorage detectee. Migration vers DB disponible."; + } else if (dataMode === "local-fallback") { dataModeInfo.textContent = "Source: localStorage (fallback). API indisponible."; - return; + } else { + dataModeInfo.textContent = "Source: localStorage"; } - dataModeInfo.textContent = "Source: localStorage"; + const showMigrateBtn = apiReachable && pendingLocalImport && payloadHasCatalogData(pendingLocalImport); + if (showMigrateBtn) { + migrateBtn.classList.remove("hidden"); + } else { + migrateBtn.classList.add("hidden"); + } } function renderBrandTabs() { @@ -431,6 +465,10 @@ function persist() { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); } +function markLocalDataForImport() { + pendingLocalImport = payloadHasCatalogData(state) ? structuredClone(state) : null; +} + async function apiRequest(path, options = {}) { const requestOptions = { method: options.method || "GET", @@ -500,8 +538,19 @@ function payloadHasCatalogData(payload) { async function refreshFromApi(preferredBrand, preferredConsole) { const payload = await apiRequest("/api/catalog/full"); apiReachable = true; - dataMode = payloadHasCatalogData(payload) ? "api" : "api-empty"; - applyCatalogPayload(payload, preferredBrand, preferredConsole); + const payloadHasData = payloadHasCatalogData(payload); + + if (payloadHasData) { + dataMode = "api"; + applyCatalogPayload(payload, preferredBrand, preferredConsole); + } else if (pendingLocalImport && payloadHasCatalogData(pendingLocalImport)) { + dataMode = "local-pending-import"; + applyCatalogPayload(pendingLocalImport, preferredBrand, preferredConsole); + } else { + dataMode = "api-empty"; + applyCatalogPayload(payload, preferredBrand, preferredConsole); + } + persist(); render(); } diff --git a/index.html b/index.html index c461df3..80055a0 100644 --- a/index.html +++ b/index.html @@ -50,6 +50,9 @@

Jeux

+
diff --git a/styles.css b/styles.css index b472cc2..2725081 100644 --- a/styles.css +++ b/styles.css @@ -80,6 +80,11 @@ h1 { color: var(--muted); } +#migrateBtn { + width: fit-content; + margin-bottom: 0.9rem; +} + .grid-form { display: grid; gap: 0.7rem;