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`
|
- 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
|
||||||
|
|
||||||
|
|||||||
132
api/server.js
132
api/server.js
@@ -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
77
app.js
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user