// Book tracker queries. Public callers get catalogue fields only; owner-only data (files, // notes, highlights, sessions) is loaded separately and only after requireOwner(). import { db } from '#lib/server/db/index.js'; import { bookFiles, bookNotes, bookProgress, bookSessions, bookWorks } from '#lib/server/db/schema/index.js'; import { and, asc, desc, eq, gte, sql } from 'drizzle-orm'; import { formatPreference, isReadable, type BookCard, type Highlight, type LibraryStats, type ReadingStatus } from '#lib/books/shared.js'; import { pagesRead, SESSION_GAP_MS, type ProgressPayload } from '#lib/books/progress.js'; const TIME_ZONE = 'Europe/Amsterdam'; export const currentYear = () => Number( new Intl.DateTimeFormat('en-CA', { timeZone: TIME_ZONE, year: 'numeric' }).format(new Date()) ); export function coverSrc(work: { id: number; coverPath: string | null; coverUrl: string | null; updatedAt?: Date | null; }) { if (work.coverPath) return `/trackers/books/covers/${work.id}${work.updatedAt ? `?v=${work.updatedAt.getTime().toString(36)}` : ''}`; if (work.coverUrl?.startsWith('https://')) return work.coverUrl; return null; } const iso = (d: Date | null | undefined) => (d ? d.toISOString() : null); export async function libraryCards(owner: boolean): Promise { const formats = db .select({ workId: bookFiles.workId, formats: sql`array_agg(distinct lower(${bookFiles.format}))`.as('formats') }) .from(bookFiles) .groupBy(bookFiles.workId) .as('f'); const rows = await db .select({ id: bookWorks.id, title: bookWorks.title, subtitle: bookWorks.subtitle, authors: bookWorks.authors, year: bookWorks.year, tags: bookWorks.tags, pageCount: bookWorks.pageCount, coverPath: bookWorks.coverPath, coverUrl: bookWorks.coverUrl, createdAt: bookWorks.createdAt, updatedAt: bookWorks.updatedAt, status: bookProgress.status, percent: bookProgress.percent, rating: bookProgress.rating, lastReadAt: bookProgress.lastReadAt, finishedAt: bookProgress.finishedAt, formats: formats.formats }) .from(bookWorks) .leftJoin(bookProgress, eq(bookProgress.workId, bookWorks.id)) .leftJoin(formats, eq(formats.workId, bookWorks.id)) .orderBy(asc(bookWorks.title)); return rows.map((r) => ({ id: r.id, title: r.title, subtitle: r.subtitle, authors: r.authors, year: r.year, tags: r.tags, pageCount: r.pageCount, cover: coverSrc(r), status: r.status ?? null, percent: r.percent ?? 0, rating: r.rating ?? null, lastReadAt: iso(r.lastReadAt), finishedAt: iso(r.finishedAt), addedAt: r.createdAt.toISOString(), formats: owner ? (r.formats ?? []) : [], hasFile: owner ? (r.formats?.length ?? 0) > 0 : false })); } export async function libraryStats(): Promise { const year = currentYear(); const yearStart = sql`make_timestamptz(${year}, 1, 1, 0, 0, 0, ${TIME_ZONE})`; const [[counts], [pages]] = await Promise.all([ db .select({ total: sql`(select count(*)::int from ${bookWorks})`, finished: sql`count(*) filter (where ${bookProgress.status} = 'finished' and ${bookProgress.finishedAt} >= ${yearStart})::int`, reading: sql`count(*) filter (where ${bookProgress.status} = 'reading')::int` }) .from(bookProgress), db .select({ pages: sql`coalesce(sum(${bookSessions.pagesRead}), 0)::int` }) .from(bookSessions) .where(gte(bookSessions.startedAt, yearStart)) ]); return { total: counts?.total ?? 0, finishedThisYear: counts?.finished ?? 0, pagesThisYear: pages?.pages ?? 0, reading: counts?.reading ?? 0, year }; } export async function getWork(id: number) { const [work] = await db.select().from(bookWorks).where(eq(bookWorks.id, id)).limit(1); if (!work) return null; const [progress] = await db .select() .from(bookProgress) .where(eq(bookProgress.workId, id)) .limit(1); return { work, progress: progress ?? null }; } export async function workFiles(workId: number) { const files = await db .select({ id: bookFiles.id, path: bookFiles.path, format: bookFiles.format, sizeBytes: bookFiles.sizeBytes }) .from(bookFiles) .where(eq(bookFiles.workId, workId)); const rank = (f: string) => { const i = formatPreference.indexOf(f.toLowerCase()); return i === -1 ? 99 : i; }; return files .map((f) => ({ ...f, format: f.format.toLowerCase(), readable: isReadable(f.format) })) .sort((a, b) => rank(a.format) - rank(b.format)); } export async function workNotes(workId: number): Promise { const rows = await db .select() .from(bookNotes) .where(eq(bookNotes.workId, workId)) .orderBy(desc(bookNotes.createdAt)); return rows.map((n) => ({ id: n.id, fileId: n.fileId, locator: n.locator, page: n.page, highlight: n.highlight, note: n.note, color: n.color, createdAt: n.createdAt.toISOString() })); } export async function workSessions(workId: number) { const rows = await db .select() .from(bookSessions) .where(eq(bookSessions.workId, workId)) .orderBy(desc(bookSessions.startedAt)) .limit(200); return rows.map((s) => ({ id: s.id, startedAt: s.startedAt.toISOString(), endedAt: iso(s.endedAt), startPercent: s.startPercent, endPercent: s.endPercent, startPage: s.startPage, endPage: s.endPage, pagesRead: s.pagesRead, source: s.source, note: s.note })); } /** Sets the reading status, keeping the start/finish dates and percent consistent. */ export async function setStatus(workId: number, status: ReadingStatus) { const now = new Date(); await db .insert(bookProgress) .values({ workId, status, startedAt: status === 'want_to_read' ? null : now, finishedAt: status === 'finished' ? now : null, percent: status === 'finished' ? 100 : 0 }) .onConflictDoUpdate({ target: bookProgress.workId, set: { status, startedAt: status === 'want_to_read' ? sql`${bookProgress.startedAt}` : sql`coalesce(${bookProgress.startedAt}, ${now.toISOString()}::timestamptz)`, finishedAt: status === 'finished' ? sql`coalesce(${bookProgress.finishedAt}, ${now.toISOString()}::timestamptz)` : null, percent: status === 'finished' ? 100 : sql`${bookProgress.percent}` } }); } export async function setRating(workId: number, rating: number | null) { await db .insert(bookProgress) .values({ workId, rating }) .onConflictDoUpdate({ target: bookProgress.workId, set: { rating } }); } /** Manual progress from the detail page (percent and/or page). */ export async function setManualProgress(workId: number, percent: number, page: number | null) { await db .insert(bookProgress) .values({ workId, percent, page, status: percent > 0 ? 'reading' : 'want_to_read', startedAt: percent > 0 ? new Date() : null }) .onConflictDoUpdate({ target: bookProgress.workId, set: { percent, page, status: sql`case when ${bookProgress.status} = 'want_to_read' and ${percent} > 0 then 'reading'::books.reading_status else ${bookProgress.status} end`, startedAt: sql`coalesce(${bookProgress.startedAt}, case when ${percent} > 0 then now() end)` } }); } /** * Saves the reader position and keeps a reading session going. Returns the session id the * reader should send back with its next save. A save more than SESSION_GAP_MS after the * last one starts a new session. */ export async function saveProgress(workId: number, input: ProgressPayload) { return db.transaction(async (tx) => { const [work] = await tx .select({ pageCount: bookWorks.pageCount }) .from(bookWorks) .where(eq(bookWorks.id, workId)) .limit(1); if (!work) return null; if (input.fileId) { const [file] = await tx .select({ id: bookFiles.id }) .from(bookFiles) .where(and(eq(bookFiles.id, input.fileId), eq(bookFiles.workId, workId))) .limit(1); if (!file) return null; } const [before] = await tx .select() .from(bookProgress) .where(eq(bookProgress.workId, workId)) .limit(1); const now = new Date(); await tx .insert(bookProgress) .values({ workId, fileId: input.fileId ?? null, locator: input.locator ?? null, page: input.page ?? null, percent: input.percent, status: 'reading', startedAt: now, lastReadAt: now }) .onConflictDoUpdate({ target: bookProgress.workId, set: { fileId: input.fileId ?? null, locator: input.locator ?? null, page: input.page ?? null, percent: input.percent, lastReadAt: now, status: sql`case when ${bookProgress.status} = 'want_to_read' then 'reading'::books.reading_status else ${bookProgress.status} end`, startedAt: sql`coalesce(${bookProgress.startedAt}, now())` } }); const pageCount = input.pageCount ?? work.pageCount; let session = input.sessionId ? ( await tx .select() .from(bookSessions) .where(and(eq(bookSessions.id, input.sessionId), eq(bookSessions.workId, workId))) .limit(1) )[0] : undefined; const last = session?.endedAt ?? session?.startedAt; if (session && last && now.getTime() - last.getTime() > SESSION_GAP_MS) session = undefined; if (!session) { [session] = await tx .insert(bookSessions) .values({ workId, fileId: input.fileId ?? null, startedAt: now, endedAt: now, startPercent: before?.percent ?? input.percent, endPercent: input.percent, startPage: before?.page ?? input.page ?? null, endPage: input.page ?? null, pagesRead: 0, source: 'reader' }) .returning(); } else { const startPage = session.startPage ?? input.page ?? null; await tx .update(bookSessions) .set({ endedAt: now, endPercent: input.percent, endPage: input.page ?? null, startPage, pagesRead: pagesRead({ startPage: input.page != null ? startPage : null, endPage: input.page, startPercent: session.startPercent, endPercent: input.percent, pageCount }) }) .where(eq(bookSessions.id, session.id)); } return { sessionId: session.id }; }); } export async function logManualSession( workId: number, input: { startedAt: Date; endedAt: Date | null; startPage: number | null; endPage: number | null; startPercent: number | null; endPercent: number | null; note: string | null; } ) { const [work] = await db .select({ pageCount: bookWorks.pageCount }) .from(bookWorks) .where(eq(bookWorks.id, workId)) .limit(1); await db.insert(bookSessions).values({ workId, ...input, pagesRead: pagesRead({ ...input, pageCount: work?.pageCount }), source: 'manual' }); }