374 lines
10 KiB
TypeScript
374 lines
10 KiB
TypeScript
// 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'
|
|
});
|
|
}
|