books-ui: library, detail page, EPUB/PDF reader, reading status/rating/sessions migration, progress/notes APIs, owner gating and tests
This commit is contained in:
373
src/lib/server/books/library.ts
Normal file
373
src/lib/server/books/library.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
// 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<BookCard[]> {
|
||||
const formats = db
|
||||
.select({
|
||||
workId: bookFiles.workId,
|
||||
formats: sql<string[]>`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<LibraryStats> {
|
||||
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<number>`(select count(*)::int from ${bookWorks})`,
|
||||
finished: sql<number>`count(*) filter (where ${bookProgress.status} = 'finished' and ${bookProgress.finishedAt} >= ${yearStart})::int`,
|
||||
reading: sql<number>`count(*) filter (where ${bookProgress.status} = 'reading')::int`
|
||||
})
|
||||
.from(bookProgress),
|
||||
db
|
||||
.select({ pages: sql<number>`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<Highlight[]> {
|
||||
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'
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user