Step 5 migration: add localStorage to DB import flow

This commit is contained in:
Ponte
2026-02-11 15:11:34 +01:00
parent de1da956fc
commit e58ee18936
5 changed files with 208 additions and 15 deletions

View File

@@ -136,6 +136,10 @@ git pull
- PUT `/api/catalog/games/:id` - PUT `/api/catalog/games/:id`
- DELETE `/api/catalog/games/:id` - DELETE `/api/catalog/games/:id`
- POST `/api/catalog/games/:id/toggle-loan` - 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 ## Licence

View File

@@ -357,6 +357,127 @@ async function toggleGameLoan(id) {
return result.rowCount > 0; 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) { async function handleRequest(request, response) {
const url = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`); const url = new URL(request.url || "/", `http://${request.headers.host || "localhost"}`);
@@ -432,6 +553,17 @@ async function handleRequest(request, response) {
return; 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-]+)$/); const gameIdMatch = url.pathname.match(/^\/api\/catalog\/games\/([0-9a-fA-F-]+)$/);
if (request.method === "PUT" && gameIdMatch) { if (request.method === "PUT" && gameIdMatch) {
try { try {

77
app.js
View File

@@ -11,6 +11,7 @@ const initialState = {
}; };
const state = loadState(); const state = loadState();
let pendingLocalImport = payloadHasCatalogData(state) ? structuredClone(state) : null;
let dataMode = "local"; let dataMode = "local";
let apiReachable = false; let apiReachable = false;
@@ -31,6 +32,7 @@ const brandTabs = document.getElementById("brandTabs");
const consoleTabs = document.getElementById("consoleTabs"); const consoleTabs = document.getElementById("consoleTabs");
const gameSectionTitle = document.getElementById("gameSectionTitle"); const gameSectionTitle = document.getElementById("gameSectionTitle");
const dataModeInfo = document.getElementById("dataModeInfo"); const dataModeInfo = document.getElementById("dataModeInfo");
const migrateBtn = document.getElementById("migrateBtn");
const gamesList = document.getElementById("gamesList"); const gamesList = document.getElementById("gamesList");
const gameCardTemplate = document.getElementById("gameCardTemplate"); const gameCardTemplate = document.getElementById("gameCardTemplate");
let editingGameId = null; let editingGameId = null;
@@ -45,7 +47,7 @@ platformForm.addEventListener("submit", async (event) => {
return; return;
} }
if (apiReachable) { if (apiReachable && dataMode !== "local-pending-import") {
try { try {
await apiRequest("/api/catalog/consoles", { await apiRequest("/api/catalog/consoles", {
method: "POST", method: "POST",
@@ -72,6 +74,7 @@ platformForm.addEventListener("submit", async (event) => {
platformForm.reset(); platformForm.reset();
persist(); persist();
markLocalDataForImport();
render(); render();
}); });
@@ -83,7 +86,7 @@ gameForm.addEventListener("submit", async (event) => {
return; return;
} }
if (apiReachable) { if (apiReachable && dataMode !== "local-pending-import") {
const payload = { const payload = {
brand: state.selectedBrand, brand: state.selectedBrand,
consoleName: state.selectedConsole, consoleName: state.selectedConsole,
@@ -149,6 +152,7 @@ gameForm.addEventListener("submit", async (event) => {
resetEditMode(); resetEditMode();
persist(); persist();
markLocalDataForImport();
render(); render();
}); });
@@ -220,7 +224,7 @@ gamesList.addEventListener("click", async (event) => {
return; return;
} }
if (apiReachable) { if (apiReachable && dataMode !== "local-pending-import") {
try { try {
if (action === "delete") { if (action === "delete") {
await apiRequest(`/api/catalog/games/${id}`, { method: "DELETE" }); await apiRequest(`/api/catalog/games/${id}`, { method: "DELETE" });
@@ -253,6 +257,7 @@ gamesList.addEventListener("click", async (event) => {
} }
persist(); persist();
markLocalDataForImport();
render(); render();
}); });
@@ -260,6 +265,33 @@ cancelEditBtn.addEventListener("click", () => {
resetEditMode(); 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() { function render() {
renderDataMode(); renderDataMode();
renderBrandTabs(); renderBrandTabs();
@@ -274,20 +306,22 @@ function renderDataMode() {
if (dataMode === "api") { if (dataMode === "api") {
dataModeInfo.textContent = "Source: API (lecture/ecriture active sur la base de donnees)."; dataModeInfo.textContent = "Source: API (lecture/ecriture active sur la base de donnees).";
return; } else if (dataMode === "api-empty") {
}
if (dataMode === "api-empty") {
dataModeInfo.textContent = "Source: API (base vide). Ajoute une section pour demarrer."; dataModeInfo.textContent = "Source: API (base vide). Ajoute une section pour demarrer.";
return; } else if (dataMode === "local-pending-import") {
} dataModeInfo.textContent = "Source: localStorage detectee. Migration vers DB disponible.";
} else if (dataMode === "local-fallback") {
if (dataMode === "local-fallback") {
dataModeInfo.textContent = "Source: localStorage (fallback). API indisponible."; 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() { function renderBrandTabs() {
@@ -431,6 +465,10 @@ function persist() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} }
function markLocalDataForImport() {
pendingLocalImport = payloadHasCatalogData(state) ? structuredClone(state) : null;
}
async function apiRequest(path, options = {}) { async function apiRequest(path, options = {}) {
const requestOptions = { const requestOptions = {
method: options.method || "GET", method: options.method || "GET",
@@ -500,8 +538,19 @@ function payloadHasCatalogData(payload) {
async function refreshFromApi(preferredBrand, preferredConsole) { async function refreshFromApi(preferredBrand, preferredConsole) {
const payload = await apiRequest("/api/catalog/full"); const payload = await apiRequest("/api/catalog/full");
apiReachable = true; apiReachable = true;
dataMode = payloadHasCatalogData(payload) ? "api" : "api-empty"; const payloadHasData = payloadHasCatalogData(payload);
if (payloadHasData) {
dataMode = "api";
applyCatalogPayload(payload, preferredBrand, preferredConsole); 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(); persist();
render(); render();
} }

View File

@@ -50,6 +50,9 @@
<div class="panel-header"> <div class="panel-header">
<h2 id="gameSectionTitle">Jeux</h2> <h2 id="gameSectionTitle">Jeux</h2>
<p id="dataModeInfo" class="data-mode"></p> <p id="dataModeInfo" class="data-mode"></p>
<button id="migrateBtn" type="button" class="btn-secondary hidden">
Migrer localStorage vers DB
</button>
</div> </div>
<form id="gameForm" class="grid-form game-form"> <form id="gameForm" class="grid-form game-form">

View File

@@ -80,6 +80,11 @@ h1 {
color: var(--muted); color: var(--muted);
} }
#migrateBtn {
width: fit-content;
margin-bottom: 0.9rem;
}
.grid-form { .grid-form {
display: grid; display: grid;
gap: 0.7rem; gap: 0.7rem;