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:
77
src/lib/server/books/files.test.ts
Normal file
77
src/lib/server/books/files.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { parseRange, safeResolve, streamFile } from './files';
|
||||
|
||||
let root: string;
|
||||
beforeAll(async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), 'books-'));
|
||||
await mkdir(path.join(root, 'library', 'a'), { recursive: true });
|
||||
await writeFile(path.join(root, 'library', 'a', 'book.epub'), '0123456789');
|
||||
await writeFile(path.join(root, 'secret.txt'), 'nope');
|
||||
await symlink(path.join(root, 'secret.txt'), path.join(root, 'library', 'escape.epub'));
|
||||
});
|
||||
afterAll(() => rm(root, { recursive: true, force: true }));
|
||||
|
||||
describe('safeResolve', () => {
|
||||
it('finds files inside the library', async () => {
|
||||
expect(await safeResolve('library', 'a/book.epub', root)).toBe(
|
||||
path.join(root, 'library', 'a', 'book.epub')
|
||||
);
|
||||
});
|
||||
it.each([
|
||||
'../secret.txt',
|
||||
'a/../../secret.txt',
|
||||
'/etc/passwd',
|
||||
'escape.epub',
|
||||
'a',
|
||||
'',
|
||||
'missing.pdf'
|
||||
])('refuses %s', async (relative) => {
|
||||
expect(await safeResolve('library', relative, root)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRange', () => {
|
||||
it('parses single ranges', () => {
|
||||
expect(parseRange('bytes=0-3', 10)).toEqual({ start: 0, end: 3 });
|
||||
expect(parseRange('bytes=5-', 10)).toEqual({ start: 5, end: 9 });
|
||||
expect(parseRange('bytes=-4', 10)).toEqual({ start: 6, end: 9 });
|
||||
expect(parseRange('bytes=8-100', 10)).toEqual({ start: 8, end: 9 });
|
||||
});
|
||||
it('ignores or rejects bad ranges', () => {
|
||||
expect(parseRange(null, 10)).toBeNull();
|
||||
expect(parseRange('bytes=0-1,4-5', 10)).toBeNull();
|
||||
expect(parseRange('bytes=10-', 10)).toBe('invalid');
|
||||
expect(parseRange('bytes=5-2', 10)).toBe('invalid');
|
||||
});
|
||||
});
|
||||
|
||||
describe('streamFile', () => {
|
||||
const file = () => path.join(root, 'library', 'a', 'book.epub');
|
||||
it('serves the whole file', async () => {
|
||||
const res = await streamFile(file(), new Request('http://x/'), {});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get('content-type')).toBe('application/epub+zip');
|
||||
expect(await res.text()).toBe('0123456789');
|
||||
});
|
||||
it('serves partial content', async () => {
|
||||
const res = await streamFile(
|
||||
file(),
|
||||
new Request('http://x/', { headers: { range: 'bytes=2-4' } }),
|
||||
{}
|
||||
);
|
||||
expect(res.status).toBe(206);
|
||||
expect(res.headers.get('content-range')).toBe('bytes 2-4/10');
|
||||
expect(await res.text()).toBe('234');
|
||||
});
|
||||
it('answers 416 for unsatisfiable ranges', async () => {
|
||||
const res = await streamFile(
|
||||
file(),
|
||||
new Request('http://x/', { headers: { range: 'bytes=20-' } }),
|
||||
{}
|
||||
);
|
||||
expect(res.status).toBe(416);
|
||||
});
|
||||
});
|
||||
115
src/lib/server/books/files.ts
Normal file
115
src/lib/server/books/files.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
// Book files and covers on disk. Files are owner-only and only ever streamed through an
|
||||
// authenticated endpoint; nothing here is under static/.
|
||||
//
|
||||
// BOOKS_ROOT holds library/ (book files, books.files.path is relative to it) and covers/
|
||||
// (books.works.cover_path is relative to it). In prod the container gets the host's books
|
||||
// directory as a read-only bind mount at /books (see scripts/deploy-remote.sh).
|
||||
import { createReadStream, existsSync } from 'node:fs';
|
||||
import { realpath, stat } from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
export function booksRoot(): string {
|
||||
if (process.env.BOOKS_ROOT) return process.env.BOOKS_ROOT;
|
||||
return existsSync('/books/library') ? '/books' : path.join(homedir(), 'books');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves `relative` inside `<root>/<area>`, following symlinks, and refuses anything that
|
||||
* escapes it (`..`, absolute paths, symlinks out). Returns null when missing or unsafe.
|
||||
*/
|
||||
export async function safeResolve(
|
||||
area: 'library' | 'covers',
|
||||
relative: string,
|
||||
root = booksRoot()
|
||||
): Promise<string | null> {
|
||||
if (!relative || relative.includes('\0') || path.isAbsolute(relative)) return null;
|
||||
const base = path.join(root, area);
|
||||
try {
|
||||
const realBase = await realpath(base);
|
||||
const real = await realpath(path.join(base, relative));
|
||||
if (real !== realBase && !real.startsWith(realBase + path.sep)) return null;
|
||||
return (await stat(real)).isFile() ? real : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const contentTypes: Record<string, string> = {
|
||||
epub: 'application/epub+zip',
|
||||
pdf: 'application/pdf',
|
||||
mobi: 'application/x-mobipocket-ebook',
|
||||
azw: 'application/vnd.amazon.ebook',
|
||||
azw3: 'application/vnd.amazon.ebook',
|
||||
fb2: 'application/x-fictionbook+xml',
|
||||
cbz: 'application/vnd.comicbook+zip',
|
||||
djvu: 'image/vnd.djvu',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
avif: 'image/avif'
|
||||
};
|
||||
export function contentType(file: string): string {
|
||||
return contentTypes[path.extname(file).slice(1).toLowerCase()] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
/** Parses a single `bytes=a-b` range. Multi-range requests get the whole file. */
|
||||
export function parseRange(
|
||||
header: string | null,
|
||||
size: number
|
||||
): { start: number; end: number } | 'invalid' | null {
|
||||
if (!header) return null;
|
||||
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
||||
if (!match || (!match[1] && !match[2])) return null;
|
||||
let start: number;
|
||||
let end: number;
|
||||
if (!match[1]) {
|
||||
const suffix = Number(match[2]);
|
||||
if (suffix === 0) return 'invalid';
|
||||
start = Math.max(0, size - suffix);
|
||||
end = size - 1;
|
||||
} else {
|
||||
start = Number(match[1]);
|
||||
end = match[2] ? Math.min(Number(match[2]), size - 1) : size - 1;
|
||||
}
|
||||
if (start > end || start >= size) return 'invalid';
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/** Streams a file with Range support (pdf.js fetches large PDFs in chunks). */
|
||||
export async function streamFile(
|
||||
file: string,
|
||||
request: Request,
|
||||
headers: Record<string, string>
|
||||
): Promise<Response> {
|
||||
const { size, mtime } = await stat(file);
|
||||
const base = {
|
||||
'content-type': contentType(file),
|
||||
'accept-ranges': 'bytes',
|
||||
'last-modified': mtime.toUTCString(),
|
||||
...headers
|
||||
};
|
||||
const range = parseRange(request.headers.get('range'), size);
|
||||
if (range === 'invalid')
|
||||
return new Response(null, {
|
||||
status: 416,
|
||||
headers: { ...base, 'content-range': `bytes */${size}` }
|
||||
});
|
||||
const { start, end } = range ?? { start: 0, end: size - 1 };
|
||||
const length = size === 0 ? 0 : end - start + 1;
|
||||
const body =
|
||||
request.method === 'HEAD' || size === 0
|
||||
? null
|
||||
: (Readable.toWeb(createReadStream(file, { start, end })) as unknown as ReadableStream);
|
||||
return new Response(body, {
|
||||
status: range ? 206 : 200,
|
||||
headers: {
|
||||
...base,
|
||||
'content-length': String(length),
|
||||
...(range ? { 'content-range': `bytes ${start}-${end}/${size}` } : {})
|
||||
}
|
||||
});
|
||||
}
|
||||
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'
|
||||
});
|
||||
}
|
||||
84
src/lib/server/books/notes.ts
Normal file
84
src/lib/server/books/notes.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
// Highlights and notes (books.notes). A row with `highlight` is a highlight from the reader
|
||||
// (optionally with a note); a row with only `note` is a free-form note on the book.
|
||||
import { z } from 'zod';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import { bookFiles, bookNotes, bookWorks } from '#lib/server/db/schema/index.js';
|
||||
import { highlightColors, type Highlight } from '#lib/books/shared.js';
|
||||
|
||||
export const notePayload = z
|
||||
.object({
|
||||
fileId: z.number().int().positive().nullish(),
|
||||
locator: z.string().max(4000).nullish(),
|
||||
page: z.number().int().min(0).max(100000).nullish(),
|
||||
highlight: z.string().trim().max(20000).nullish(),
|
||||
note: z.string().trim().max(20000).nullish(),
|
||||
color: z.enum(highlightColors).nullish()
|
||||
})
|
||||
.refine((n) => !!(n.highlight || n.note), { message: 'Empty note' });
|
||||
export const noteUpdate = z.object({
|
||||
note: z.string().trim().max(20000).nullable().optional(),
|
||||
color: z.enum(highlightColors).nullable().optional()
|
||||
});
|
||||
|
||||
const toHighlight = (n: typeof bookNotes.$inferSelect): Highlight => ({
|
||||
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 createNote(workId: number, input: z.infer<typeof notePayload>) {
|
||||
const [work] = await db
|
||||
.select({ id: bookWorks.id })
|
||||
.from(bookWorks)
|
||||
.where(eq(bookWorks.id, workId))
|
||||
.limit(1);
|
||||
if (!work) return null;
|
||||
if (input.fileId) {
|
||||
const [file] = await db
|
||||
.select({ id: bookFiles.id })
|
||||
.from(bookFiles)
|
||||
.where(and(eq(bookFiles.id, input.fileId), eq(bookFiles.workId, workId)))
|
||||
.limit(1);
|
||||
if (!file) return null;
|
||||
}
|
||||
const [row] = await db
|
||||
.insert(bookNotes)
|
||||
.values({
|
||||
workId,
|
||||
fileId: input.fileId ?? null,
|
||||
locator: input.locator ?? null,
|
||||
page: input.page ?? null,
|
||||
highlight: input.highlight || null,
|
||||
note: input.note || null,
|
||||
color: input.highlight ? (input.color ?? 'yellow') : null
|
||||
})
|
||||
.returning();
|
||||
return toHighlight(row);
|
||||
}
|
||||
|
||||
export async function updateNote(workId: number, id: number, input: z.infer<typeof noteUpdate>) {
|
||||
const set: Partial<typeof bookNotes.$inferInsert> = {};
|
||||
if (input.note !== undefined) set.note = input.note || null;
|
||||
if (input.color !== undefined) set.color = input.color;
|
||||
if (!Object.keys(set).length) return null;
|
||||
const [row] = await db
|
||||
.update(bookNotes)
|
||||
.set(set)
|
||||
.where(and(eq(bookNotes.id, id), eq(bookNotes.workId, workId)))
|
||||
.returning();
|
||||
return row ? toHighlight(row) : null;
|
||||
}
|
||||
|
||||
export async function deleteNote(workId: number, id: number) {
|
||||
const rows = await db
|
||||
.delete(bookNotes)
|
||||
.where(and(eq(bookNotes.id, id), eq(bookNotes.workId, workId)))
|
||||
.returning({ id: bookNotes.id });
|
||||
return rows.length > 0;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
jsonb,
|
||||
primaryKey,
|
||||
real,
|
||||
smallint,
|
||||
text,
|
||||
uniqueIndex
|
||||
} from 'drizzle-orm/pg-core';
|
||||
@@ -25,6 +26,14 @@ export const bookMetadataStatus = booksSchema.enum('metadata_status', [
|
||||
'manual' // edited by hand; enrichment never overwrites it
|
||||
]);
|
||||
|
||||
// Reading state per work (on books.progress), shown publicly on /trackers/books.
|
||||
export const bookReadingStatus = booksSchema.enum('reading_status', [
|
||||
'want_to_read',
|
||||
'reading',
|
||||
'finished',
|
||||
'abandoned'
|
||||
]);
|
||||
|
||||
export const bookWorks = booksSchema.table(
|
||||
'works',
|
||||
{
|
||||
@@ -128,11 +137,16 @@ export const bookProgress = booksSchema.table(
|
||||
percent: real('percent').notNull().default(0),
|
||||
startedAt: timestamptz('started_at'),
|
||||
finishedAt: timestamptz('finished_at'),
|
||||
status: bookReadingStatus('status').notNull().default('want_to_read'),
|
||||
rating: smallint('rating'), // 1-5 stars
|
||||
lastReadAt: timestamptz('last_read_at'), // last time the reader saved a position
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('progress_work_id_key').on(t.workId),
|
||||
check('progress_percent_check', sql`${t.percent} between 0 and 100`)
|
||||
check('progress_percent_check', sql`${t.percent} between 0 and 100`),
|
||||
check('progress_rating_check', sql`${t.rating} is null or ${t.rating} between 1 and 5`),
|
||||
index('progress_status_idx').on(t.status)
|
||||
]
|
||||
);
|
||||
|
||||
@@ -154,9 +168,42 @@ export const bookNotes = booksSchema.table(
|
||||
(t) => [index('notes_work_id_idx').on(t.workId)]
|
||||
);
|
||||
|
||||
// One reading session: opened in the reader (source 'reader') or logged by hand ('manual').
|
||||
export const bookSessions = booksSchema.table(
|
||||
'sessions',
|
||||
{
|
||||
id: id(),
|
||||
workId: integer('work_id')
|
||||
.notNull()
|
||||
.references(() => bookWorks.id, { onDelete: 'cascade' }),
|
||||
fileId: integer('file_id').references(() => bookFiles.id, { onDelete: 'set null' }),
|
||||
startedAt: timestamptz('started_at').notNull().defaultNow(),
|
||||
endedAt: timestamptz('ended_at'),
|
||||
startPercent: real('start_percent'),
|
||||
endPercent: real('end_percent'),
|
||||
startPage: integer('start_page'),
|
||||
endPage: integer('end_page'),
|
||||
pagesRead: integer('pages_read'), // derived on save: page delta, or percent delta x page_count
|
||||
source: text('source').notNull().default('reader'),
|
||||
note: text('note'),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
index('sessions_work_id_idx').on(t.workId),
|
||||
index('sessions_started_at_idx').on(t.startedAt),
|
||||
check(
|
||||
'sessions_percent_check',
|
||||
sql`(${t.startPercent} is null or ${t.startPercent} between 0 and 100) and (${t.endPercent} is null or ${t.endPercent} between 0 and 100)`
|
||||
),
|
||||
check('sessions_end_check', sql`${t.endedAt} is null or ${t.endedAt} >= ${t.startedAt}`)
|
||||
]
|
||||
);
|
||||
|
||||
export type BookWork = typeof bookWorks.$inferSelect;
|
||||
export type NewBookWork = typeof bookWorks.$inferInsert;
|
||||
export type BookFile = typeof bookFiles.$inferSelect;
|
||||
export type BookShelf = typeof bookShelves.$inferSelect;
|
||||
export type BookProgress = typeof bookProgress.$inferSelect;
|
||||
export type BookNote = typeof bookNotes.$inferSelect;
|
||||
export type BookSession = typeof bookSessions.$inferSelect;
|
||||
export type BookReadingStatus = (typeof bookReadingStatus.enumValues)[number];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isOwner, isOwnerOnlyPath } from './owner';
|
||||
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from './owner';
|
||||
|
||||
const event = (user: Partial<NonNullable<App.Locals['user']>> | null) =>
|
||||
({ locals: { user, session: null } }) as unknown as { locals: App.Locals };
|
||||
@@ -17,4 +17,22 @@ describe('owner gate', () => {
|
||||
expect(isOwnerOnlyPath('/trackers/healthy')).toBe(false);
|
||||
expect(isOwnerOnlyPath('/trackers')).toBe(false);
|
||||
});
|
||||
|
||||
it('gates the book reader, files and APIs but not the catalogue', () => {
|
||||
expect(isOwnerOnlyPath('/trackers/books/files/12')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/books/12/read')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/books/12/progress')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/books/12/notes/3')).toBe(true);
|
||||
expect(isOwnerOnlyPath('/trackers/books')).toBe(false);
|
||||
expect(isOwnerOnlyPath('/trackers/books/12')).toBe(false);
|
||||
expect(isOwnerOnlyPath('/trackers/books/covers/12')).toBe(false);
|
||||
expect(isOwnerOnlyPath('/trackers/books/12/reader-notes-public')).toBe(false);
|
||||
});
|
||||
it('only lets the owner write in owner areas', () => {
|
||||
expect(isOwnerWritePath('/trackers/books/12')).toBe(true);
|
||||
expect(isOwnerWritePath('/trackers/books')).toBe(true);
|
||||
expect(isOwnerWritePath('/trackers/health/import')).toBe(true);
|
||||
expect(isOwnerWritePath('/trackers/bookshelf')).toBe(false);
|
||||
expect(isOwnerWritePath('/lists/books')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,9 +19,32 @@ export function requireOwner(event: OwnerEvent) {
|
||||
}
|
||||
|
||||
/** Paths that only the owner may see (checked in hooks.server.ts before any route runs). */
|
||||
export const ownerOnlyPrefixes = ['/trackers/health'] as const;
|
||||
export const ownerOnlyPrefixes = ['/trackers/health', '/trackers/books/files'] as const;
|
||||
|
||||
/**
|
||||
* Owner-only paths inside otherwise public areas: the book reader and its progress/notes
|
||||
* APIs. /trackers/books and /trackers/books/<id> themselves are public catalogue pages.
|
||||
*/
|
||||
export const ownerOnlyPatterns = [/^\/trackers\/books\/\d+\/(read|progress|notes)(\/|$)/] as const;
|
||||
|
||||
/**
|
||||
* Areas where only the owner may write: any non-GET request from anyone else is refused
|
||||
* (404) in hooks.server.ts, which also covers form actions on the public book pages.
|
||||
*/
|
||||
export const ownerWritePrefixes = ['/trackers/health', '/trackers/books'] as const;
|
||||
|
||||
const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, '');
|
||||
const under = (path: string, prefix: string) => path === prefix || path.startsWith(`${prefix}/`);
|
||||
|
||||
export function isOwnerOnlyPath(pathname: string): boolean {
|
||||
const path = pathname.toLowerCase().replace(/\/+$/, '');
|
||||
return ownerOnlyPrefixes.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
|
||||
const path = normalise(pathname);
|
||||
return (
|
||||
ownerOnlyPrefixes.some((prefix) => under(path, prefix)) ||
|
||||
ownerOnlyPatterns.some((pattern) => pattern.test(path))
|
||||
);
|
||||
}
|
||||
|
||||
export function isOwnerWritePath(pathname: string): boolean {
|
||||
const path = normalise(pathname);
|
||||
return ownerWritePrefixes.some((prefix) => under(path, prefix));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user