import { error } from '@sveltejs/kit'; import type { Game, PlayStatus } from '#lib/content'; import { likedLimit } from '#lib/library/gameProfile'; import { timestamp, type SqlDatabase } from './database'; import { hasImage, imageUrl, removeImage, syncImage } from './images'; import { parseList, readChoice, readFlag, readHistory, readNumber, readRating, readText, readTextList, type RequestBody } from './input'; interface GameRow { igdb_id: string; name: string; store_url: string | null; igdb_url: string | null; status: PlayStatus; rating: number | null; released: string | null; platforms: string; developers: string; publishers: string; genres: string; summary: string | null; score: number | null; image_source: string | null; cover_source: string | null; favorite: number; liked: number; tags: string; note: string | null; added: string | null; started: string | null; finished: string | null; } const playStatuses: PlayStatus[] = ['played', 'playing', 'planning', 'replayable']; const igdbIdPattern = /^\d+$/; export function isIgdbId(text: string): boolean { return igdbIdPattern.test(text); } function gameImageId(igdbId: string): string { return `game-${igdbId}`; } function toGame(row: GameRow, hasStoredImage: boolean): Game { return { igdbId: row.igdb_id, name: row.name, url: row.store_url, igdbUrl: row.igdb_url, image: hasStoredImage ? imageUrl(gameImageId(row.igdb_id)) : row.image_source, cover: row.cover_source, status: row.status, released: row.released, platforms: parseList(row.platforms), developers: parseList(row.developers), publishers: parseList(row.publishers), genres: parseList(row.genres), summary: row.summary, score: row.score, favorite: !!row.favorite, liked: !!row.liked, tags: parseList(row.tags), note: row.note, ...(row.rating !== null && { rating: row.rating }), ...(row.added && { added: row.added }), ...(row.started && { started: row.started }), ...(row.finished && { finished: row.finished }) }; } function findRow(database: SqlDatabase, igdbId: string): GameRow | null { return (database.prepare('SELECT * FROM games WHERE igdb_id = ?').get(igdbId) as GameRow | undefined) ?? null; } export function listGames(database: SqlDatabase, imageIds: Set): Game[] { const rows = database.prepare('SELECT * FROM games ORDER BY name COLLATE NOCASE').all() as GameRow[]; return rows.map((row) => toGame(row, imageIds.has(gameImageId(row.igdb_id)))); } function readList(body: RequestBody, key: string, current: string | undefined): string { return JSON.stringify(readTextList(body, key, parseList(current ?? '[]'))); } function mergeRow(igdbId: string, existing: GameRow | null, body: RequestBody): GameRow { const name = readText(body, 'name', existing?.name ?? null); if (!name) error(400, 'name is required'); const status = readChoice(body, 'status', playStatuses, existing?.status ?? 'played'); const score = readNumber(body, 'score', existing?.score ?? null); return { igdb_id: igdbId, name, store_url: readText(body, 'url', existing?.store_url ?? null), igdb_url: readText(body, 'igdbUrl', existing?.igdb_url ?? null), status, rating: readRating(body, existing?.rating ?? null), released: readText(body, 'released', existing?.released ?? null), platforms: readList(body, 'platforms', existing?.platforms), developers: readList(body, 'developers', existing?.developers), publishers: readList(body, 'publishers', existing?.publishers), genres: readList(body, 'genres', existing?.genres), summary: readText(body, 'summary', existing?.summary ?? null), score: score === null ? null : Math.round(score), image_source: readText(body, 'imageSource', existing?.image_source ?? null), cover_source: readText(body, 'coverSource', existing?.cover_source ?? null), favorite: Number(readFlag(body, 'favorite', !!existing?.favorite)), liked: Number(readFlag(body, 'liked', !!existing?.liked)), tags: readList(body, 'tags', existing?.tags), note: readText(body, 'note', existing?.note ?? null), ...readHistory( body, { added: existing?.added ?? null, started: existing?.started ?? null, finished: existing?.finished ?? null }, { isNew: !existing, previousStatus: existing?.status ?? null, status, activeStatus: 'playing', doneStatus: 'played' } ) }; } function checkLikedLimit(database: SqlDatabase, row: GameRow) { if (!row.liked) return; const others = database.prepare('SELECT COUNT(*) AS total FROM games WHERE liked = 1 AND igdb_id != ?').get(row.igdb_id) as { total: number }; if (Number(others.total) >= likedLimit) error(400, `Only ${likedLimit} games can be liked`); } export async function saveGame(database: SqlDatabase, igdbId: string, body: RequestBody): Promise { const row = mergeRow(igdbId, findRow(database, igdbId), body); checkLikedLimit(database, row); if (row.favorite) database.prepare('UPDATE games SET favorite = 0 WHERE igdb_id != ?').run(igdbId); database .prepare( `INSERT INTO games (igdb_id, name, store_url, igdb_url, status, rating, released, platforms, developers, publishers, genres, summary, score, image_source, cover_source, favorite, liked, tags, note, added, started, finished, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(igdb_id) DO UPDATE SET name = excluded.name, store_url = excluded.store_url, igdb_url = excluded.igdb_url, status = excluded.status, rating = excluded.rating, released = excluded.released, platforms = excluded.platforms, developers = excluded.developers, publishers = excluded.publishers, genres = excluded.genres, summary = excluded.summary, score = excluded.score, image_source = excluded.image_source, cover_source = excluded.cover_source, favorite = excluded.favorite, liked = excluded.liked, tags = excluded.tags, note = excluded.note, added = excluded.added, started = excluded.started, finished = excluded.finished, updated_at = excluded.updated_at` ) .run( row.igdb_id, row.name, row.store_url, row.igdb_url, row.status, row.rating, row.released, row.platforms, row.developers, row.publishers, row.genres, row.summary, row.score, row.image_source, row.cover_source, row.favorite, row.liked, row.tags, row.note, row.added, row.started, row.finished, timestamp() ); if ('imageSource' in body) await syncImage(database, gameImageId(igdbId), row.image_source); return toGame(row, hasImage(database, gameImageId(igdbId))); } export function deleteGame(database: SqlDatabase, igdbId: string): boolean { if (!findRow(database, igdbId)) return false; database.prepare('DELETE FROM games WHERE igdb_id = ?').run(igdbId); removeImage(database, gameImageId(igdbId)); return true; }