diff --git a/api/server.js b/api/server.js index 3bca89d..9abb8ad 100644 --- a/api/server.js +++ b/api/server.js @@ -120,6 +120,7 @@ async function runMigrations() { estimated_value NUMERIC(10,2) CHECK (estimated_value IS NULL OR estimated_value >= 0), condition_score NUMERIC(4,2), loaned_to TEXT, + barcode TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); @@ -128,6 +129,7 @@ async function runMigrations() { await pool.query("ALTER TABLE games ADD COLUMN IF NOT EXISTS is_duplicate BOOLEAN NOT NULL DEFAULT FALSE;"); await pool.query("ALTER TABLE games ADD COLUMN IF NOT EXISTS purchase_price NUMERIC(10,2);"); await pool.query("ALTER TABLE games ADD COLUMN IF NOT EXISTS condition_score NUMERIC(4,2);"); + await pool.query("ALTER TABLE games ADD COLUMN IF NOT EXISTS barcode TEXT;"); await pool.query(` CREATE TABLE IF NOT EXISTS backup_snapshots ( @@ -245,6 +247,7 @@ async function getCatalogFull() { g.estimated_value, g.condition_score, g.loaned_to, + g.barcode, g.created_at FROM games g JOIN consoles c ON c.id = g.console_id @@ -276,6 +279,7 @@ async function getCatalogFull() { value: row.estimated_value != null ? Number(row.estimated_value) : null, condition: row.condition_score != null ? Number(row.condition_score) : null, loanedTo: row.loaned_to || "", + barcode: row.barcode || "", createdAt: row.created_at, }); } @@ -315,6 +319,7 @@ async function exportCatalogDumpWithClient(client) { g.estimated_value, g.condition_score, g.loaned_to, + g.barcode, g.created_at, g.updated_at FROM games g @@ -354,6 +359,7 @@ async function exportCatalogDumpWithClient(client) { value: row.estimated_value != null ? Number(row.estimated_value) : null, condition: row.condition_score != null ? Number(row.condition_score) : null, loanedTo: row.loaned_to || "", + barcode: row.barcode || "", createdAt: row.created_at, updatedAt: row.updated_at, })), @@ -497,6 +503,7 @@ async function restoreCatalogDump(mode, dump) { const genre = normalizeText(gameEntry && gameEntry.genre) || null; const publisher = normalizeText(gameEntry && gameEntry.publisher) || null; const loanedTo = normalizeText(gameEntry && gameEntry.loanedTo) || null; + const barcode = normalizeText(gameEntry && gameEntry.barcode) || null; const isDuplicate = Boolean(gameEntry && gameEntry.isDuplicate); const purchasePrice = gameEntry && gameEntry.purchasePrice != null && gameEntry.purchasePrice !== "" @@ -513,7 +520,7 @@ async function restoreCatalogDump(mode, dump) { console_id, title, genre, publisher, game_version, is_duplicate, release_year, purchase_price, estimated_value, condition_score, loaned_to, created_at ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, COALESCE($12::timestamptz, NOW())); + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, COALESCE($13::timestamptz, NOW())); `, [ consoleId, @@ -527,6 +534,7 @@ async function restoreCatalogDump(mode, dump) { value, condition, loanedTo, + barcode, createdAt, ], ); @@ -580,6 +588,7 @@ async function createGame(payload) { const version = normalizeText(payload.version) || null; const isDuplicate = Boolean(payload.isDuplicate); const loanedTo = normalizeText(payload.loanedTo) || null; + const barcode = normalizeText(payload.barcode) || null; const year = payload.year != null && payload.year !== "" ? Number(payload.year) : null; const purchasePrice = payload.purchasePrice != null && payload.purchasePrice !== "" ? Number(payload.purchasePrice) : null; @@ -590,12 +599,25 @@ async function createGame(payload) { ` INSERT INTO games( console_id, title, genre, publisher, game_version, is_duplicate, release_year, purchase_price, - estimated_value, condition_score, loaned_to + estimated_value, condition_score, loaned_to, barcode ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id::text AS id; `, - [consoleData.consoleId, title, genre, publisher, version, isDuplicate, year, purchasePrice, value, condition, loanedTo], + [ + consoleData.consoleId, + title, + genre, + publisher, + version, + isDuplicate, + year, + purchasePrice, + value, + condition, + loanedTo, + barcode, + ], ); await client.query("COMMIT"); @@ -624,6 +646,7 @@ async function updateGame(id, payload) { const version = normalizeText(payload.version) || null; const isDuplicate = Boolean(payload.isDuplicate); const loanedTo = normalizeText(payload.loanedTo) || null; + const barcode = normalizeText(payload.barcode) || null; const year = payload.year != null && payload.year !== "" ? Number(payload.year) : null; const purchasePrice = payload.purchasePrice != null && payload.purchasePrice !== "" ? Number(payload.purchasePrice) : null; @@ -644,7 +667,8 @@ async function updateGame(id, payload) { purchase_price = $9, estimated_value = $10, condition_score = $11, - loaned_to = $12 + loaned_to = $12, + barcode = $13 WHERE id = $1::uuid RETURNING id::text AS id; `, @@ -661,6 +685,7 @@ async function updateGame(id, payload) { value, condition, loanedTo, + barcode, ], ); @@ -696,6 +721,108 @@ async function toggleGameLoan(id) { return result.rowCount > 0; } +function normalizeBarcode(rawValue) { + const trimmed = normalizeText(rawValue); + return trimmed.replace(/[^\dA-Za-z-]/g, ""); +} + +async function lookupOwnedByBarcode(barcode) { + const result = await pool.query( + ` + SELECT + g.id::text AS id, + g.title, + g.publisher, + g.game_version, + g.barcode, + c.name AS console_name, + b.name AS brand_name + FROM games g + JOIN consoles c ON c.id = g.console_id + JOIN brands b ON b.id = c.brand_id + WHERE g.barcode = $1 + ORDER BY g.created_at DESC + LIMIT 1; + `, + [barcode], + ); + + if (!result.rowCount) { + return null; + } + + const row = result.rows[0]; + return { + id: row.id, + title: row.title, + publisher: row.publisher || "", + version: row.game_version || "", + barcode: row.barcode || "", + consoleName: row.console_name, + brand: row.brand_name, + }; +} + +async function lookupBarcodeExternal(barcode) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 4500); + try { + const response = await fetch(`https://api.upcitemdb.com/prod/trial/lookup?upc=${encodeURIComponent(barcode)}`, { + method: "GET", + signal: controller.signal, + headers: { + Accept: "application/json", + }, + }); + if (!response.ok) { + return null; + } + const payload = await response.json(); + const item = payload && Array.isArray(payload.items) ? payload.items[0] : null; + if (!item) { + return null; + } + const title = normalizeText(item.title); + if (!title) { + return null; + } + return { + title, + publisher: normalizeText(item.brand), + source: "upcitemdb", + }; + } catch { + return null; + } finally { + clearTimeout(timeout); + } +} + +async function lookupBarcode(barcodeRaw) { + const barcode = normalizeBarcode(barcodeRaw); + if (!barcode || barcode.length < 8) { + throw new Error("invalid barcode"); + } + + const ownedGame = await lookupOwnedByBarcode(barcode); + if (ownedGame) { + return { + barcode, + owned: true, + game: ownedGame, + lookup: null, + }; + } + + const external = await lookupBarcodeExternal(barcode); + return { + barcode, + owned: false, + game: null, + lookup: external, + }; +} + async function importCatalog(payload) { const brands = payload && payload.brands && typeof payload.brands === "object" ? payload.brands : {}; const gamesByConsole = @@ -776,6 +903,7 @@ async function importCatalog(payload) { const genre = normalizeText(game && game.genre) || null; const publisher = normalizeText(game && game.publisher) || null; const loanedTo = normalizeText(game && game.loanedTo) || null; + const barcode = normalizeText(game && game.barcode) || null; const year = game && game.year != null && game.year !== "" ? Number(game.year) : null; const purchasePrice = game && game.purchasePrice != null && game.purchasePrice !== "" ? Number(game.purchasePrice) : null; @@ -803,11 +931,24 @@ async function importCatalog(payload) { ` INSERT INTO games( console_id, title, genre, publisher, game_version, is_duplicate, release_year, purchase_price, - estimated_value, condition_score, loaned_to + estimated_value, condition_score, loaned_to, barcode ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11); + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12); `, - [consoleId, title, genre, publisher, version || null, isDuplicate, year, purchasePrice, value, condition, loanedTo], + [ + consoleId, + title, + genre, + publisher, + version || null, + isDuplicate, + year, + purchasePrice, + value, + condition, + loanedTo, + barcode, + ], ); insertedGames += 1; @@ -1229,6 +1370,18 @@ async function handleRequest(request, response) { return; } + const barcodeLookupMatch = url.pathname.match(/^\/api\/barcode\/lookup\/([^/]+)$/); + if (request.method === "GET" && barcodeLookupMatch) { + try { + const result = await lookupBarcode(decodeURIComponent(barcodeLookupMatch[1])); + sendJson(response, 200, { status: "ok", ...result }); + } catch (error) { + const statusCode = error.message === "invalid barcode" ? 400 : 500; + sendJson(response, statusCode, { 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 de5b1ae..0721420 100644 --- a/app.js +++ b/app.js @@ -20,6 +20,7 @@ const gameForm = document.getElementById("gameForm"); const brandInput = document.getElementById("brandInput"); const consoleInput = document.getElementById("consoleInput"); const titleInput = document.getElementById("titleInput"); +const barcodeInput = document.getElementById("barcodeInput"); const versionInput = document.getElementById("versionInput"); const genreInput = document.getElementById("genreInput"); const publisherInput = document.getElementById("publisherInput"); @@ -241,6 +242,7 @@ gameForm.addEventListener("submit", async (event) => { brand: state.selectedBrand, consoleName: state.selectedConsole, title, + barcode: barcodeInput.value.trim(), version: versionInput.value.trim(), genre: genreInput.value.trim(), publisher: publisherInput.value.trim(), @@ -283,6 +285,7 @@ gameForm.addEventListener("submit", async (event) => { games[idx] = { ...games[idx], title, + barcode: barcodeInput.value.trim(), version: versionInput.value.trim(), genre: genreInput.value.trim(), publisher: publisherInput.value.trim(), @@ -298,6 +301,7 @@ gameForm.addEventListener("submit", async (event) => { const game = { id: crypto.randomUUID(), title, + barcode: barcodeInput.value.trim(), version: versionInput.value.trim(), genre: genreInput.value.trim(), publisher: publisherInput.value.trim(), @@ -686,6 +690,7 @@ function buildGamePayload(game, brand, consoleName, overrides = {}) { brand, consoleName, title: game.title || "", + barcode: game.barcode || "", version: game.version || "", genre: game.genre || "", publisher: game.publisher || "", @@ -865,6 +870,7 @@ function renderGames() { const metaParts = [ showLoanedOnly ? `${game.brand} / ${game.consoleName}` : null, + game.barcode ? `Code: ${game.barcode}` : null, game.version ? `Version: ${game.version}` : null, game.genre ? `Genre: ${game.genre}` : null, game.publisher ? `Editeur: ${game.publisher}` : null, @@ -898,6 +904,7 @@ function renderGames() { function startEditMode(game) { editingGameId = game.id; titleInput.value = game.title || ""; + barcodeInput.value = game.barcode || ""; versionInput.value = game.version || ""; genreInput.value = game.genre || ""; publisherInput.value = game.publisher || ""; @@ -1029,6 +1036,10 @@ async function scanLoop() { } function applyScannedCode(codeValue) { + if (barcodeInput) { + barcodeInput.value = codeValue; + } + if (scannerLastCode) { scannerLastCode.textContent = `Dernier code detecte: ${codeValue}`; scannerLastCode.classList.remove("hidden"); @@ -1043,6 +1054,63 @@ function applyScannedCode(codeValue) { if (titleInput && !normalizeText(titleInput.value)) { titleInput.value = codeValue; } + + lookupScannedBarcode(codeValue).catch((error) => { + console.error(error); + updateScannerStatus(`Code detecte: ${codeValue} (lookup indisponible).`); + }); +} + +async function lookupScannedBarcode(codeValue) { + const normalized = normalizeText(codeValue); + if (!normalized) { + return; + } + + const result = await apiRequest(`/api/barcode/lookup/${encodeURIComponent(normalized)}`, { timeoutMs: 7000 }); + if (!result || result.status !== "ok") { + updateScannerStatus(`Code detecte: ${normalized}`); + return; + } + + const owned = result.owned && result.game; + if (owned) { + const ownedTitle = normalizeText(result.game.title) || "Jeu inconnu"; + const ownedConsole = normalizeText(result.game.consoleName); + const ownedLabel = ownedConsole ? `${ownedTitle} (${ownedConsole})` : ownedTitle; + updateScannerStatus(`Deja possede: ${ownedLabel}`); + alert(`Deja dans ta collection: ${ownedLabel}`); + + if (quickSearchInput) { + quickSearchInput.value = ownedTitle; + quickSearchTerm = ownedTitle; + renderSearchResults(); + } + if (titleInput) { + titleInput.value = ownedTitle; + } + if (publisherInput && !normalizeText(publisherInput.value) && result.game.publisher) { + publisherInput.value = result.game.publisher; + } + return; + } + + const hasAutoData = result.lookup && normalizeText(result.lookup.title); + if (hasAutoData) { + titleInput.value = result.lookup.title; + if (publisherInput && !normalizeText(publisherInput.value) && result.lookup.publisher) { + publisherInput.value = result.lookup.publisher; + } + if (quickSearchInput) { + quickSearchInput.value = result.lookup.title; + quickSearchTerm = result.lookup.title; + renderSearchResults(); + } + updateScannerStatus(`Titre trouve automatiquement: ${result.lookup.title}`); + return; + } + + updateScannerStatus(`Code detecte: ${normalized} (aucune fiche auto).`); } function normalizeText(value) { diff --git a/index.html b/index.html index 072d54c..d7eebe3 100644 --- a/index.html +++ b/index.html @@ -151,6 +151,10 @@ Titre +