179 lines
6.7 KiB
TypeScript
179 lines
6.7 KiB
TypeScript
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<string>): 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<Game> {
|
|
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;
|
|
}
|