import { error } from '@sveltejs/kit'; import type { Book, BookSeries, BookShelf } from '#lib/content'; import { timestamp, type SqlDatabase } from './database'; import { hasImage, imageUrl, removeImage, syncImage } from './images'; import { parseList, readChoice, readHistory, readNumber, readRating, readText, readTextList, type ExportedEntry, type RequestBody } from './input'; interface BookRow { goodreads_id: string; title: string; authors: string; description: string | null; pages: number | null; published: string | null; genres: string; average_rating: number | null; rating_count: number | null; cover_source: string | null; shelf: BookShelf; rating: number | null; series_name: string | null; series_url: string | null; series_position: string | null; added: string | null; started: string | null; finished: string | null; } const bookShelves: BookShelf[] = ['read', 'reading', 'want-to-read']; const goodreadsIdPattern = /^\d+$/; export function isGoodreadsId(text: string): boolean { return goodreadsIdPattern.test(text); } function coverImageId(goodreadsId: string): string { return `book-${goodreadsId}`; } function seriesOf(row: BookRow): BookSeries | undefined { if (!row.series_name) return undefined; return { name: row.series_name, url: row.series_url ?? '', ...(row.series_position && { position: row.series_position }) }; } function toBook(row: BookRow, hasCover: boolean): Book { const series = seriesOf(row); return { goodreadsId: row.goodreads_id, url: `https://www.goodreads.com/book/show/${row.goodreads_id}`, title: row.title, authors: parseList(row.authors), cover: hasCover ? imageUrl(coverImageId(row.goodreads_id)) : row.cover_source, coverSource: row.cover_source, description: row.description, pages: row.pages, published: row.published, genres: parseList(row.genres), averageRating: row.average_rating, ratingCount: row.rating_count, shelf: row.shelf, ...(row.rating !== null && { rating: row.rating }), ...(row.added && { added: row.added }), ...(row.started && { started: row.started }), ...(row.finished && { finished: row.finished }), ...(series && { series }) }; } function findRow(database: SqlDatabase, goodreadsId: string): BookRow | null { return (database.prepare('SELECT * FROM books WHERE goodreads_id = ?').get(goodreadsId) as BookRow | undefined) ?? null; } export function listBooks(database: SqlDatabase, imageIds: Set): Book[] { const rows = database.prepare('SELECT * FROM books ORDER BY title COLLATE NOCASE').all() as BookRow[]; return rows.map((row) => toBook(row, imageIds.has(coverImageId(row.goodreads_id)))); } export function exportBooks(database: SqlDatabase): ExportedEntry[] { const rows = database.prepare('SELECT * FROM books ORDER BY goodreads_id').all() as BookRow[]; return rows.map((row) => ({ id: row.goodreads_id, fields: { title: row.title, authors: parseList(row.authors), description: row.description, pages: row.pages, published: row.published, genres: parseList(row.genres), averageRating: row.average_rating, ratingCount: row.rating_count, coverSource: row.cover_source, shelf: row.shelf, rating: row.rating, series: row.series_name ? { name: row.series_name, url: row.series_url, position: row.series_position } : null, added: row.added, started: row.started, finished: row.finished } })); } function readSeries(body: RequestBody, existing: BookRow | null): Pick { const current = { series_name: existing?.series_name ?? null, series_url: existing?.series_url ?? null, series_position: existing?.series_position ?? null }; if (!('series' in body)) return current; if (body.series === null) return { series_name: null, series_url: null, series_position: null }; if (typeof body.series !== 'object' || Array.isArray(body.series)) error(400, 'series must be an object or null'); const series = body.series as RequestBody; const name = readText(series, 'name', null); if (!name) error(400, 'series needs a name'); return { series_name: name, series_url: readText(series, 'url', null), series_position: readText(series, 'position', null) }; } function mergeRow(goodreadsId: string, existing: BookRow | null, body: RequestBody): BookRow { const title = readText(body, 'title', existing?.title ?? null); if (!title) error(400, 'title is required'); const shelf = readChoice(body, 'shelf', bookShelves, existing?.shelf ?? 'read'); const pages = readNumber(body, 'pages', existing?.pages ?? null); return { goodreads_id: goodreadsId, title, authors: JSON.stringify(readTextList(body, 'authors', parseList(existing?.authors ?? '[]'))), description: readText(body, 'description', existing?.description ?? null), pages: pages === null ? null : Math.round(pages), published: readText(body, 'published', existing?.published ?? null), genres: JSON.stringify(readTextList(body, 'genres', parseList(existing?.genres ?? '[]'))), average_rating: readNumber(body, 'averageRating', existing?.average_rating ?? null), rating_count: readNumber(body, 'ratingCount', existing?.rating_count ?? null), cover_source: readText(body, 'coverSource', existing?.cover_source ?? null), shelf, rating: readRating(body, existing?.rating ?? null), ...readSeries(body, existing), ...readHistory( body, { added: existing?.added ?? null, started: existing?.started ?? null, finished: existing?.finished ?? null }, { isNew: !existing, previousStatus: existing?.shelf ?? null, status: shelf, activeStatus: 'reading', doneStatus: 'read' } ) }; } export async function saveBook(database: SqlDatabase, goodreadsId: string, body: RequestBody): Promise { const row = mergeRow(goodreadsId, findRow(database, goodreadsId), body); database .prepare( `INSERT INTO books (goodreads_id, title, authors, description, pages, published, genres, average_rating, rating_count, cover_source, shelf, rating, series_name, series_url, series_position, added, started, finished, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(goodreads_id) DO UPDATE SET title = excluded.title, authors = excluded.authors, description = excluded.description, pages = excluded.pages, published = excluded.published, genres = excluded.genres, average_rating = excluded.average_rating, rating_count = excluded.rating_count, cover_source = excluded.cover_source, shelf = excluded.shelf, rating = excluded.rating, series_name = excluded.series_name, series_url = excluded.series_url, series_position = excluded.series_position, added = excluded.added, started = excluded.started, finished = excluded.finished, updated_at = excluded.updated_at` ) .run( row.goodreads_id, row.title, row.authors, row.description, row.pages, row.published, row.genres, row.average_rating, row.rating_count, row.cover_source, row.shelf, row.rating, row.series_name, row.series_url, row.series_position, row.added, row.started, row.finished, timestamp() ); if ('coverSource' in body) await syncImage(database, coverImageId(goodreadsId), row.cover_source); return toBook(row, hasImage(database, coverImageId(goodreadsId))); } export function deleteBook(database: SqlDatabase, goodreadsId: string): boolean { if (!findRow(database, goodreadsId)) return false; database.prepare('DELETE FROM books WHERE goodreads_id = ?').run(goodreadsId); removeImage(database, coverImageId(goodreadsId)); return true; }