192 lines
7.4 KiB
TypeScript
192 lines
7.4 KiB
TypeScript
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<string>): 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<BookRow, 'series_name' | 'series_url' | 'series_position'> {
|
|
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<Book> {
|
|
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;
|
|
}
|