Step 5 migration: add localStorage to DB import flow
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
132
api/server.js
132
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 {
|
||||
|
||||
79
app.js
79
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();
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@
|
||||
<div class="panel-header">
|
||||
<h2 id="gameSectionTitle">Jeux</h2>
|
||||
<p id="dataModeInfo" class="data-mode"></p>
|
||||
<button id="migrateBtn" type="button" class="btn-secondary hidden">
|
||||
Migrer localStorage vers DB
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="gameForm" class="grid-form game-form">
|
||||
|
||||
@@ -80,6 +80,11 @@ h1 {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
#migrateBtn {
|
||||
width: fit-content;
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.grid-form {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
|
||||
Reference in New Issue
Block a user