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:
2026-10-24 01:06:33 +00:00
parent 8b2dfed239
commit 9c46e281c2
65 changed files with 25709 additions and 24 deletions

View File

@@ -0,0 +1,137 @@
<script lang="ts">
// A book cover: the real image when there is one, otherwise a generated cover in a
// colour derived from the title (also used when the image fails to load).
import { authorLine, coverHue } from './shared.js';
let {
src,
title,
authors = [],
placeholder = false,
size = 'md',
class: className = ''
}: {
src: string | null;
title: string;
authors?: readonly string[];
placeholder?: boolean;
size?: 'sm' | 'md' | 'lg';
class?: string;
} = $props();
let failed = $state<string | null>(null);
const showImage = $derived(!!src && failed !== src);
const hue = $derived(coverHue(title));
</script>
<div
class={['cover', `cover-${size}`, placeholder && 'is-placeholder', className]}
style:--hue={hue}
>
{#if showImage}
<img
{src}
alt=""
loading="lazy"
decoding="async"
referrerpolicy="no-referrer"
onerror={() => (failed = src)}
/>
{:else}
<div class="generated" aria-hidden="true">
<span class="g-title">{title}</span>
{#if authors.length}<span class="g-author">{authorLine(authors)}</span>{/if}
</div>
{/if}
</div>
<style>
.cover {
position: relative;
aspect-ratio: 2 / 3;
width: 100%;
overflow: hidden;
border-radius: 0.375rem 0.625rem 0.625rem 0.375rem;
background: var(--site-hover);
box-shadow:
0 1px 2px rgb(0 0 0 / 0.25),
0 8px 24px -12px rgb(0 0 0 / 0.5);
}
/* Spine highlight, like a real paperback. */
.cover::after {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(
90deg,
rgb(0 0 0 / 0.28) 0,
rgb(255 255 255 / 0.12) 2.5%,
rgb(0 0 0 / 0.08) 5%,
transparent 9%
);
border-radius: inherit;
}
img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.generated {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 14% 10% 10% 14%;
color: hsl(var(--hue) 30% 96%);
background:
radial-gradient(
120% 80% at 100% 0%,
hsl(calc(var(--hue) + 40) 60% 45% / 0.55),
transparent 60%
),
linear-gradient(160deg, hsl(var(--hue) 45% 32%), hsl(calc(var(--hue) + 20) 50% 16%));
}
.g-title {
font-family: Georgia, 'Times New Roman', serif;
font-weight: 600;
line-height: 1.15;
font-size: 1rem;
display: -webkit-box;
-webkit-line-clamp: 6;
line-clamp: 6;
-webkit-box-orient: vertical;
overflow: hidden;
overflow-wrap: anywhere;
}
.g-author {
font-size: 0.7rem;
letter-spacing: 0.04em;
text-transform: uppercase;
opacity: 0.8;
}
.cover-sm .g-title {
font-size: 0.6rem;
}
.cover-sm .g-author {
display: none;
}
.cover-lg .g-title {
font-size: 1.5rem;
}
.cover-lg .g-author {
font-size: 0.85rem;
}
.is-placeholder {
box-shadow: none;
outline: 1.5px dashed color-mix(in srgb, var(--site-muted) 60%, transparent);
outline-offset: 2px;
}
.is-placeholder img,
.is-placeholder .generated {
filter: grayscale(0.85) opacity(0.55);
}
</style>

View File

@@ -0,0 +1,68 @@
<script lang="ts">
// 1-5 star rating. Read-only by default; with `name` it renders radio buttons for a form.
import Star from 'lucide-svelte/icons/star';
let {
value,
size = 14,
name,
onchange
}: {
value: number | null;
size?: number;
name?: string;
onchange?: (value: number | null) => void;
} = $props();
let hover = $state<number | null>(null);
const shown = $derived(hover ?? value ?? 0);
</script>
{#if name || onchange}
<span class="stars interactive" role="radiogroup" aria-label="Rating">
{#each [1, 2, 3, 4, 5] as n (n)}
<button
type={name ? 'submit' : 'button'}
{name}
value={value === n ? '' : String(n)}
role="radio"
aria-checked={value === n}
aria-label={`${n} star${n > 1 ? 's' : ''}${value === n ? ' (click to clear)' : ''}`}
onpointerenter={() => (hover = n)}
onpointerleave={() => (hover = null)}
onclick={() => onchange?.(value === n ? null : n)}
>
<Star {size} class={n <= shown ? 'on' : 'off'} />
</button>
{/each}
</span>
{:else if value}
<span class="stars" aria-label={`${value} out of 5 stars`} title={`${value}/5`}>
{#each [1, 2, 3, 4, 5] as n (n)}
<Star {size} class={n <= value ? 'on' : 'off'} aria-hidden="true" />
{/each}
</span>
{/if}
<style>
.stars {
display: inline-flex;
align-items: center;
gap: 1px;
}
.stars :global(.on) {
fill: #f5b301;
color: #f5b301;
}
.stars :global(.off) {
color: color-mix(in srgb, var(--site-muted) 50%, transparent);
}
.interactive button {
padding: 2px;
border-radius: 4px;
line-height: 0;
}
.interactive button:hover {
transform: scale(1.12);
}
</style>

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { pagesRead, parseProgress } from './progress';
import { parsePdfLocator, pdfLocator } from './shared';
describe('progress payload', () => {
it('accepts a reader save and rounds the percent', () => {
expect(
parseProgress({
fileId: 3,
locator: 'epubcfi(/6/4!/4/2)',
percent: 12.34567,
sessionId: null
})
).toMatchObject({ fileId: 3, locator: 'epubcfi(/6/4!/4/2)', percent: 12.35 });
});
it.each([
null,
'nope',
{},
{ percent: -1 },
{ percent: 101 },
{ percent: 'ten' },
{ percent: 10, page: 1.5 },
{ percent: 10, fileId: -2 },
{ percent: 10, locator: 'x'.repeat(2001) }
])('rejects %j', (body) => {
expect(parseProgress(body)).toBeNull();
});
});
describe('pages read', () => {
it('uses the page delta when pages are known', () => {
expect(pagesRead({ startPage: 10, endPage: 34 })).toBe(24);
});
it('scales the percent delta by the page count', () => {
expect(pagesRead({ startPercent: 10, endPercent: 20, pageCount: 300 })).toBe(30);
});
it('never goes negative and needs enough data', () => {
expect(pagesRead({ startPage: 50, endPage: 20 })).toBe(0);
expect(pagesRead({ startPercent: 40, endPercent: 30, pageCount: 100 })).toBe(0);
expect(pagesRead({ startPercent: 10, endPercent: 20 })).toBeNull();
});
});
describe('pdf locators', () => {
it('round-trips pages and rects', () => {
const loc = pdfLocator(12, [{ x: 0.1, y: 0.2, w: 0.3333333, h: 0.02 }]);
expect(loc).toBe('page:12#r=0.1,0.2,0.3333,0.02');
expect(parsePdfLocator(loc)).toEqual({
page: 12,
rects: [{ x: 0.1, y: 0.2, w: 0.3333, h: 0.02 }]
});
expect(parsePdfLocator('page:3')).toEqual({ page: 3, rects: [] });
expect(parsePdfLocator('epubcfi(/6/2)')).toBeNull();
});
});

41
src/lib/books/progress.ts Normal file
View File

@@ -0,0 +1,41 @@
// Validation and maths for the reader's progress API (pure, unit tested).
import { z } from 'zod';
export const progressPayload = z.object({
fileId: z.number().int().positive().nullish(),
locator: z.string().max(2000).nullish(),
page: z.number().int().min(0).max(100000).nullish(),
percent: z.number().min(0).max(100),
sessionId: z.number().int().positive().nullish(),
/** Total pages in the opened file (PDF), used for pages-read on sessions. */
pageCount: z.number().int().positive().max(100000).nullish()
});
export type ProgressPayload = z.infer<typeof progressPayload>;
export function parseProgress(body: unknown): ProgressPayload | null {
const result = progressPayload.safeParse(body);
if (!result.success) return null;
const value = result.data;
return { ...value, percent: Math.round(value.percent * 100) / 100 };
}
/**
* Pages read in a session: the page delta when both pages are known (PDF), otherwise the
* percent delta scaled by the book's page count. Never negative (going back isn't reading).
*/
export function pagesRead(input: {
startPage?: number | null;
endPage?: number | null;
startPercent?: number | null;
endPercent?: number | null;
pageCount?: number | null;
}): number | null {
const { startPage, endPage, startPercent, endPercent, pageCount } = input;
if (startPage != null && endPage != null) return Math.max(0, endPage - startPage);
if (startPercent != null && endPercent != null && pageCount)
return Math.max(0, Math.round(((endPercent - startPercent) / 100) * pageCount));
return null;
}
/** A reader session continues if the last save was less than this long ago. */
export const SESSION_GAP_MS = 30 * 60 * 1000;

View File

@@ -0,0 +1,228 @@
<script lang="ts">
// Reflowable books (EPUB, MOBI/AZW3, FB2, CBZ) via the vendored foliate-js.
import { onMount } from 'svelte';
import { highlightCss, type Highlight, type HighlightColor } from '#lib/books/shared.js';
import {
fetchWithProgress,
themeColors,
type EngineProps,
type ReaderSettings,
type ReaderTheme,
type TocItem
} from './types.js';
type FoliateToc = { label?: string; href?: string; subitems?: FoliateToc[] | null };
type Annotation = { value: string; color?: string };
type Renderer = HTMLElement & {
setStyles(css: string): void;
getContents(): { doc: Document; index: number }[];
};
interface FoliateView extends HTMLElement {
book: { toc?: FoliateToc[] };
renderer: Renderer;
open(file: File): Promise<void>;
init(options: { lastLocation?: unknown; showTextStart?: boolean }): Promise<void>;
close(): void;
goTo(target: string | number): Promise<unknown>;
goToFraction(fraction: number): Promise<void>;
goLeft(): Promise<void>;
goRight(): Promise<void>;
addAnnotation(annotation: Annotation): Promise<unknown>;
deleteAnnotation(annotation: Annotation): Promise<unknown>;
getCFI(index: number, range: Range): string;
}
type DrawFn = (rects: unknown, options: Record<string, unknown>) => SVGElement;
let {
url,
name,
start,
startPercent,
highlights,
settings,
theme,
onready,
onrelocate,
onselection,
onhighlightclick,
onkey,
onloading,
onerror
}: EngineProps = $props();
let host = $state<FoliateView>();
let view = $state<FoliateView>();
let highlightDraw: DrawFn | undefined;
const fonts = {
serif: 'Charter, "Iowan Old Style", Georgia, "Palatino Linotype", serif',
sans: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif'
};
function css(s: ReaderSettings, t: ReaderTheme) {
const c = themeColors[t];
const family = s.font === 'publisher' ? null : fonts[s.font];
return `
@namespace epub "http://www.idpf.org/2007/ops";
html { color-scheme: ${t === 'dark' ? 'dark' : 'light'}; background: ${c.bg} !important; color: ${c.fg} !important; font-size: ${s.fontSize}% !important; }
body { background: transparent !important; color: inherit !important; ${family ? `font-family: ${family} !important;` : ''} }
${family ? 'body :not(pre, code, kbd, samp, tt, pre *, code *) { font-family: inherit !important; }' : ''}
p, li, blockquote, dd, dt { line-height: ${s.lineHeight} !important; }
p { hyphens: auto; -webkit-hyphens: auto; widows: 2; orphans: 2; }
a:any-link { color: ${c.link} !important; }
${t === 'light' ? '' : 'body *:not(a, a *) { color: inherit !important; background-color: transparent !important; border-color: currentColor; }'}
img, svg, video { max-width: 100%; height: auto; }
aside[epub|type~="footnote"], aside[epub|type~="endnote"] { display: none; }
::selection { background: rgba(250, 204, 21, 0.45); }`;
}
function apply(v: FoliateView) {
const r = v.renderer;
r.setAttribute('flow', settings.flow);
r.setAttribute('margin', '48px');
r.setAttribute('gap', '6%');
r.setAttribute('max-inline-size', '720px');
r.setAttribute('max-column-count', '2');
r.setStyles(css(settings, theme));
v.style.background = themeColors[theme].bg;
}
$effect(() => {
if (view) apply(view);
});
const color = (h: Highlight) =>
highlightCss[(h.color as HighlightColor) ?? 'yellow'] ?? highlightCss.yellow;
const toToc = (items: FoliateToc[] = []): TocItem[] =>
items
.filter((i) => i.href)
.map((i) => ({
label: (i.label ?? '').trim() || 'Untitled',
target: i.href!,
children: toToc(i.subitems ?? [])
}));
export async function goTo(target: string | number) {
await view?.goTo(target);
}
export async function goToPercent(percent: number) {
await view?.goToFraction(Math.min(0.9999, Math.max(0, percent / 100)));
}
export function next() {
void view?.goRight();
}
export function prev() {
void view?.goLeft();
}
export function addHighlight(h: Highlight) {
if (h.locator) void view?.addAnnotation({ value: h.locator, color: color(h) }).catch(() => {});
}
export function removeHighlight(h: Highlight) {
if (h.locator) void view?.deleteAnnotation({ value: h.locator }).catch(() => {});
}
export function clearSelection() {
for (const { doc } of view?.renderer.getContents() ?? []) doc.getSelection()?.removeAllRanges();
}
onMount(() => {
const abort = new AbortController();
let disposed = false;
let el: FoliateView | undefined;
(async () => {
try {
const base = '/vendor/foliate-js';
const [, overlayer] = await Promise.all([
import(/* @vite-ignore */ `${base}/view.js`),
import(/* @vite-ignore */ `${base}/overlayer.js`)
]);
highlightDraw = overlayer.Overlayer.highlight;
onloading(0);
const blob = await fetchWithProgress(url, onloading, abort.signal);
if (disposed) return;
onloading(null);
el = host;
if (!el) return;
el.addEventListener('relocate', (e) => {
const d = (e as CustomEvent).detail;
onrelocate({
percent: Math.max(0, Math.min(100, (d.fraction ?? 0) * 100)),
locator: d.cfi,
page: null,
pageCount: null,
label: d.tocItem?.label?.trim() || null
});
});
el.addEventListener('load', (e) => {
const { doc, index } = (e as CustomEvent).detail as { doc: Document; index: number };
doc.addEventListener('keydown', onkey);
let timer: ReturnType<typeof setTimeout>;
doc.addEventListener('selectionchange', () => {
clearTimeout(timer);
timer = setTimeout(() => {
const sel = doc.getSelection();
const text = sel?.toString().trim();
if (!sel || sel.isCollapsed || !sel.rangeCount || !text) return onselection(null);
try {
onselection({ text, locator: el!.getCFI(index, sel.getRangeAt(0)), page: null });
} catch {
onselection(null);
}
}, 250);
});
});
el.addEventListener('create-overlay', () => {
for (const h of highlights) addHighlight(h);
});
el.addEventListener('draw-annotation', (e) => {
const { draw, annotation } = (e as CustomEvent).detail as {
draw: (fn: DrawFn, opts: Record<string, unknown>) => void;
annotation: Annotation;
};
if (highlightDraw)
draw(highlightDraw, { color: annotation.color ?? highlightCss.yellow });
});
el.addEventListener('show-annotation', (e) => {
const value = (e as CustomEvent).detail.value as string;
const h = highlights.find((x) => x.locator === value);
if (h) onhighlightclick(h.id);
});
await el.open(new File([blob], name, { type: blob.type }));
if (disposed) return;
apply(el);
view = el;
const lastLocation = start ?? (startPercent > 0 ? { fraction: startPercent / 100 } : null);
await el.init({ lastLocation, showTextStart: !lastLocation });
onready(toToc(el.book.toc));
} catch (e) {
if (!disposed && !(e instanceof DOMException && e.name === 'AbortError'))
onerror(e instanceof Error ? e.message : 'Could not open this book');
}
})();
return () => {
disposed = true;
abort.abort();
el?.close?.();
};
});
</script>
<div class="host">
<foliate-view bind:this={host}></foliate-view>
</div>
<style>
.host {
position: absolute;
inset: 0;
}
.host :global(foliate-view) {
display: block;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,538 @@
<script lang="ts">
// PDFs via pdf.js: continuous scroll, pages rendered lazily near the viewport, a text layer
// for selection, and highlights stored as page-relative rectangles.
import { onMount } from 'svelte';
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
import type { PDFDocumentProxy, PDFPageProxy, RenderTask } from 'pdfjs-dist';
import {
highlightCss,
parsePdfLocator,
pdfLocator,
type Highlight,
type HighlightColor,
type Rect
} from '#lib/books/shared.js';
import { type EngineProps, type TocItem } from './types.js';
let {
url,
start,
startPercent,
highlights,
settings,
theme,
onready,
onrelocate,
onselection,
onhighlightclick,
onloading,
onerror
}: EngineProps = $props();
type PageInfo = { num: number; width: number; height: number }; // at scale 1
type Live = { task?: RenderTask; el: HTMLElement; layer: HTMLElement };
let scroller: HTMLDivElement;
let pdf: PDFDocumentProxy | undefined;
let pdfjs: typeof import('pdfjs-dist') | undefined;
let pages = $state<PageInfo[]>([]);
let width = $state(0);
let current = 1;
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain cache, not reactive state
const live = new Map<number, Live>();
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain cache, not reactive state
const pageEls = new Map<number, HTMLElement>();
let observer: IntersectionObserver | undefined;
const scale = $derived.by(() => {
const first = pages[0];
if (!first || !width) return 1;
const fit = Math.min(width - 24, 920) / first.width;
return Math.max(0.3, fit * settings.pdfZoom);
});
const byPage = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain cache, not reactive state
const map = new Map<number, { h: Highlight; rects: Rect[] }[]>();
for (const h of highlights) {
const loc = parsePdfLocator(h.locator);
if (!loc || !loc.rects.length) continue;
const list = map.get(loc.page) ?? [];
list.push({ h, rects: loc.rects });
map.set(loc.page, list);
}
return map;
});
const color = (h: Highlight) =>
highlightCss[(h.color as HighlightColor) ?? 'yellow'] ?? highlightCss.yellow;
async function render(num: number) {
const el = pageEls.get(num);
if (!pdf || !pdfjs || !el || live.has(num)) return;
const layer = el.querySelector<HTMLElement>('.layer')!;
const entry: Live = { el, layer };
live.set(num, entry);
const page: PDFPageProxy = await pdf.getPage(num);
if (live.get(num) !== entry) return;
const s = scale;
const viewport = page.getViewport({ scale: s });
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const canvas = document.createElement('canvas');
canvas.width = Math.floor(viewport.width * dpr);
canvas.height = Math.floor(viewport.height * dpr);
const text = document.createElement('div');
text.className = 'textLayer';
layer.replaceChildren(canvas, text);
el.style.setProperty('--scale-factor', String(s));
el.style.setProperty('--total-scale-factor', String(s));
entry.task = page.render({
canvas,
viewport,
transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined
});
try {
await entry.task.promise;
if (live.get(num) !== entry) return;
await new pdfjs.TextLayer({
textContentSource: page.streamTextContent(),
container: text,
viewport
}).render();
} catch {
// cancelled (scrolled away or re-zoomed)
}
}
function unrender(num: number) {
const entry = live.get(num);
if (!entry) return;
entry.task?.cancel();
entry.layer.replaceChildren();
live.delete(num);
}
function page(node: HTMLElement, num: number) {
pageEls.set(num, node);
observer?.observe(node);
return {
destroy() {
observer?.unobserve(node);
pageEls.delete(num);
unrender(num);
}
};
}
// Re-render everything when the zoom or the width changes, keeping the reading position.
let lastScale = 0;
$effect(() => {
const s = scale;
if (!lastScale) {
lastScale = s;
return;
}
if (Math.abs(s - lastScale) < 0.001) return;
lastScale = s;
const keep = position();
for (const num of [...live.keys()]) unrender(num);
requestAnimationFrame(() => {
scrollToPosition(keep.page, keep.fraction);
for (const [num, node] of pageEls) {
const r = node.getBoundingClientRect();
const v = scroller.getBoundingClientRect();
if (r.bottom > v.top - v.height && r.top < v.bottom + v.height) void render(num);
}
});
});
function position() {
if (!scroller) return { page: 1, fraction: 0 };
const anchor = scroller.scrollTop + scroller.clientHeight * 0.3;
for (const p of pages) {
const node = pageEls.get(p.num);
if (!node) continue;
const top = node.offsetTop;
const bottom = top + node.offsetHeight;
if (anchor < bottom + 8)
return {
page: p.num,
fraction: Math.max(0, Math.min(1, (anchor - top) / node.offsetHeight))
};
}
return { page: pages.length, fraction: 1 };
}
function scrollToPosition(num: number, fraction = 0) {
const node = pageEls.get(Math.max(1, Math.min(pages.length, num)));
if (!node) return;
scroller.scrollTop =
node.offsetTop + node.offsetHeight * fraction - (fraction ? scroller.clientHeight * 0.3 : 12);
}
let frame = 0;
function onscroll() {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const { page: num, fraction } = position();
current = num;
onrelocate({
percent: Math.min(100, ((num - 1 + fraction) / pages.length) * 100),
locator: pdfLocator(num),
page: num,
pageCount: pages.length,
label: labelFor(num)
});
});
}
let flatToc: { page: number; label: string }[] = [];
const labelFor = (num: number) => {
let label: string | null = null;
for (const item of flatToc) if (item.page <= num) label = item.label;
return label;
};
export async function goTo(target: string | number) {
if (typeof target === 'number') return scrollToPosition(target);
const loc = parsePdfLocator(target);
if (!loc) return;
const y = loc.rects[0]?.y;
if (y != null) scrollToPosition(loc.page, Math.max(0, y - 0.05) || 0.0001);
else scrollToPosition(loc.page);
}
export async function goToPercent(percent: number) {
const p = (percent / 100) * pages.length;
scrollToPosition(Math.floor(p) + 1, p % 1 || 0);
}
export function next() {
scrollToPosition(current + 1);
}
export function prev() {
const { fraction } = position();
scrollToPosition(fraction > 0.1 ? current : current - 1);
}
export function addHighlight() {
// Highlights are derived from props; nothing to draw imperatively.
}
export function removeHighlight() {}
export function clearSelection() {
document.getSelection()?.removeAllRanges();
}
/** Selection rects as fractions of the page, merged per line. */
function selectionRects(range: Range, pageEl: HTMLElement): Rect[] {
const box = pageEl.getBoundingClientRect();
const rects = [...range.getClientRects()]
.filter((r) => r.width > 1 && r.height > 1)
.map((r) => ({
x: (r.left - box.left) / box.width,
y: (r.top - box.top) / box.height,
w: r.width / box.width,
h: r.height / box.height
}))
.filter((r) => r.x >= -0.01 && r.y >= -0.01 && r.x < 1 && r.y < 1)
.sort((a, b) => a.y - b.y || a.x - b.x);
const merged: Rect[] = [];
for (const r of rects) {
const last = merged.at(-1);
if (last && Math.abs(last.y - r.y) < last.h * 0.5 && r.x <= last.x + last.w + 0.02) {
const right = Math.max(last.x + last.w, r.x + r.w);
const bottom = Math.max(last.y + last.h, r.y + r.h);
last.x = Math.min(last.x, r.x);
last.y = Math.min(last.y, r.y);
last.w = right - last.x;
last.h = bottom - last.y;
} else merged.push({ ...r });
}
return merged.slice(0, 60);
}
function hitHighlight(e: MouseEvent, num: number) {
if (!document.getSelection()?.isCollapsed) return;
const box = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = (e.clientX - box.left) / box.width;
const y = (e.clientY - box.top) / box.height;
const hit = byPage
.get(num)
?.find(({ rects }) =>
rects.some((r) => x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h)
);
if (hit) onhighlightclick(hit.h.id);
}
async function resolveToc(doc: PDFDocumentProxy): Promise<TocItem[]> {
const outline = await doc.getOutline().catch(() => null);
if (!outline) return [];
let budget = 600;
const walk = async (items: typeof outline): Promise<TocItem[]> => {
const out: TocItem[] = [];
for (const item of items) {
if (budget-- <= 0) break;
let num: number | null = null;
try {
const dest =
typeof item.dest === 'string' ? await doc.getDestination(item.dest) : item.dest;
const ref = dest?.[0];
if (ref && typeof ref === 'object') num = (await doc.getPageIndex(ref)) + 1;
else if (typeof ref === 'number') num = ref + 1;
} catch {
num = null;
}
const children = item.items?.length ? await walk(item.items) : [];
if (num) {
out.push({ label: item.title.trim() || `Page ${num}`, target: num, children });
flatToc.push({ page: num, label: item.title.trim() });
} else out.push(...children);
}
return out;
};
const toc = await walk(outline);
flatToc = flatToc.sort((a, b) => a.page - b.page);
return toc;
}
onMount(() => {
let disposed = false;
const resize = new ResizeObserver(() => (width = scroller.clientWidth));
resize.observe(scroller);
width = scroller.clientWidth;
observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const num = Number((entry.target as HTMLElement).dataset.page);
if (entry.isIntersecting) void render(num);
else unrender(num);
}
},
{ root: scroller, rootMargin: '150% 0px' }
);
let selTimer: ReturnType<typeof setTimeout>;
const onSelectionChange = () => {
clearTimeout(selTimer);
selTimer = setTimeout(() => {
const sel = document.getSelection();
const text = sel?.toString().trim();
const node = sel?.anchorNode;
const pageEl = (node instanceof Element ? node : node?.parentElement)?.closest<HTMLElement>(
'.pdf-page'
);
if (!sel || sel.isCollapsed || !text || !pageEl || !scroller.contains(pageEl))
return onselection(null);
const num = Number(pageEl.dataset.page);
const rects = selectionRects(sel.getRangeAt(0), pageEl);
if (!rects.length) return onselection(null);
onselection({ text, locator: pdfLocator(num, rects), page: num });
}, 250);
};
document.addEventListener('selectionchange', onSelectionChange);
(async () => {
try {
pdfjs = await import('pdfjs-dist');
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl;
onloading(0);
const task = pdfjs.getDocument({
url,
withCredentials: true,
rangeChunkSize: 1 << 20
});
task.onProgress = ({ loaded, total }: { loaded: number; total: number }) =>
onloading(total ? Math.min(1, loaded / total) : null);
const doc = await task.promise;
if (disposed) return void doc.destroy();
pdf = doc;
onloading(null);
// Use the first page's size for every page up front; real sizes fill in quickly.
const first = (await doc.getPage(1)).getViewport({ scale: 1 });
pages = Array.from({ length: doc.numPages }, (_, i) => ({
num: i + 1,
width: first.width,
height: first.height
}));
void (async () => {
for (let i = 2; i <= Math.min(doc.numPages, 2000) && !disposed; i++) {
const vp = (await doc.getPage(i)).getViewport({ scale: 1 });
if (vp.width !== first.width || vp.height !== first.height)
pages[i - 1] = { num: i, width: vp.width, height: vp.height };
}
})();
requestAnimationFrame(async () => {
const loc = parsePdfLocator(start);
if (loc) scrollToPosition(loc.page, loc.rects[0]?.y ?? 0);
else if (startPercent > 0) await goToPercent(startPercent);
onscroll();
onready(await resolveToc(doc));
});
} catch (e) {
if (!disposed) onerror(e instanceof Error ? e.message : 'Could not open this PDF');
}
})();
return () => {
disposed = true;
document.removeEventListener('selectionchange', onSelectionChange);
resize.disconnect();
observer?.disconnect();
for (const num of [...live.keys()]) unrender(num);
void pdf?.destroy();
};
});
</script>
<div
class={['scroller', `t-${theme}`]}
style:background={theme === 'light' ? '#e9e9e7' : theme === 'sepia' ? '#e6dcc4' : '#0b0b0c'}
bind:this={scroller}
{onscroll}
>
<div class="pages">
{#each pages as p (p.num)}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<div
class="pdf-page"
data-page={p.num}
style:width={`${Math.floor(p.width * scale)}px`}
style:height={`${Math.floor(p.height * scale)}px`}
use:page={p.num}
onclick={(e) => hitHighlight(e, p.num)}
>
<div class="layer"></div>
{#each byPage.get(p.num) ?? [] as { h, rects } (h.id)}
{#each rects as r, i (i)}
<span
class="hl"
style:left={`${r.x * 100}%`}
style:top={`${r.y * 100}%`}
style:width={`${r.w * 100}%`}
style:height={`${r.h * 100}%`}
style:background={color(h)}
></span>
{/each}
{/each}
<span class="num">{p.num}</span>
</div>
{/each}
</div>
</div>
<style>
.scroller {
position: absolute;
inset: 0;
overflow: auto;
overscroll-behavior: contain;
}
.pages {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 12px 12px 96px;
min-width: fit-content;
}
.pdf-page {
position: relative;
background: white;
box-shadow:
0 1px 3px rgb(0 0 0 / 0.12),
0 8px 24px rgb(0 0 0 / 0.08);
flex: none;
}
.t-sepia .pdf-page :global(canvas) {
filter: sepia(0.35) brightness(0.97);
}
.t-dark .pdf-page {
background: #1a1a1c;
}
.t-dark .pdf-page :global(canvas) {
filter: invert(0.9) hue-rotate(180deg);
}
.layer,
.layer :global(canvas) {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.hl {
position: absolute;
mix-blend-mode: multiply;
opacity: 0.45;
border-radius: 2px;
pointer-events: none;
z-index: 1;
}
.t-dark .hl {
mix-blend-mode: screen;
opacity: 0.35;
}
.num {
position: absolute;
right: -2.25rem;
bottom: 0;
font-size: 0.7rem;
color: rgb(128 128 128 / 0.8);
font-variant-numeric: tabular-nums;
}
@media (max-width: 1100px) {
.num {
display: none;
}
}
/* pdf.js text layer (from pdfjs-dist/web/pdf_viewer.css, trimmed) */
.pdf-page :global(.textLayer) {
position: absolute;
text-align: initial;
inset: 0;
overflow: clip;
opacity: 1;
line-height: 1;
text-size-adjust: none;
forced-color-adjust: none;
transform-origin: 0 0;
caret-color: CanvasText;
z-index: 2;
--min-font-size: 1;
--text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size));
--min-font-size-inv: calc(1 / var(--min-font-size));
--scale-round-x: 1px;
--scale-round-y: 1px;
}
.pdf-page :global(.textLayer :is(span, br)) {
color: transparent;
position: absolute;
white-space: pre;
cursor: text;
transform-origin: 0% 0%;
user-select: text;
}
.pdf-page :global(.textLayer > :not(.markedContent)),
.pdf-page :global(.textLayer .markedContent span:not(.markedContent)) {
z-index: 1;
--font-height: 0;
font-size: calc(var(--text-scale-factor) * var(--font-height));
--scale-x: 1;
--rotate: 0deg;
transform: rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv));
}
.pdf-page :global(.textLayer .markedContent) {
display: contents;
}
.pdf-page :global(.textLayer ::selection) {
background: rgb(250 204 21 / 0.4);
color: transparent;
}
.pdf-page :global(.textLayer br::selection) {
background: transparent;
}
.pdf-page :global(.textLayer .endOfContent) {
display: block;
position: absolute;
inset: 100% 0 0;
z-index: 0;
cursor: default;
user-select: none;
}
</style>

View File

@@ -0,0 +1,89 @@
// Contract between the reader shell (the /read page) and the EPUB/PDF engines.
import type { Highlight } from '#lib/books/shared.js';
export type TocItem = { label: string; target: string | number; children: TocItem[] };
export type Relocation = {
percent: number; // 0-100
locator: string; // EPUB CFI or `page:N`
page: number | null; // PDF page (1-based), or null for reflowable books
pageCount: number | null;
label: string | null; // current chapter
};
export type TextSelection = { text: string; locator: string; page: number | null };
export type ReaderTheme = 'light' | 'sepia' | 'dark';
export type ReaderSettings = {
theme: 'auto' | ReaderTheme;
fontSize: number; // percent
lineHeight: number;
font: 'publisher' | 'serif' | 'sans';
flow: 'paginated' | 'scrolled';
pdfZoom: number; // 1 = fit width
};
export const defaultSettings: ReaderSettings = {
theme: 'auto',
fontSize: 100,
lineHeight: 1.5,
font: 'publisher',
flow: 'paginated',
pdfZoom: 1
};
export const themeColors: Record<ReaderTheme, { bg: string; fg: string; link: string }> = {
light: { bg: '#fdfdfb', fg: '#1c1c1c', link: '#2563eb' },
sepia: { bg: '#f4ecd8', fg: '#433422', link: '#8a4b14' },
dark: { bg: '#121214', fg: '#d8d8d8', link: '#7aa8ff' }
};
/** What both engines expose to the shell through bind:this. */
export interface ReaderEngine {
goTo(target: string | number): Promise<void>;
goToPercent(percent: number): Promise<void>;
next(): void;
prev(): void;
addHighlight(h: Highlight): void;
removeHighlight(h: Highlight): void;
clearSelection(): void;
}
export type EngineProps = {
url: string;
name: string;
start: string | null;
startPercent: number;
highlights: Highlight[];
settings: ReaderSettings;
theme: ReaderTheme;
onready: (toc: TocItem[]) => void;
onrelocate: (r: Relocation) => void;
onselection: (s: TextSelection | null) => void;
onhighlightclick: (id: number) => void;
onkey: (e: KeyboardEvent) => void;
onloading: (fraction: number | null) => void;
onerror: (message: string) => void;
};
/** Downloads a file with progress, so big books show a loading bar instead of a blank page. */
export async function fetchWithProgress(
url: string,
onprogress: (fraction: number | null) => void,
signal?: AbortSignal
): Promise<Blob> {
const res = await fetch(url, { signal, credentials: 'same-origin' });
if (!res.ok) throw new Error(res.status === 404 ? 'File not found' : `HTTP ${res.status}`);
const total = Number(res.headers.get('content-length')) || 0;
if (!res.body || !total) return res.blob();
const reader = res.body.getReader();
const chunks: Uint8Array<ArrayBuffer>[] = [];
let loaded = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.byteLength;
onprogress(Math.min(1, loaded / total));
}
return new Blob(chunks, { type: res.headers.get('content-type') ?? '' });
}

104
src/lib/books/shared.ts Normal file
View File

@@ -0,0 +1,104 @@
// Shared (client + server) types and helpers for the book tracker.
export const readingStatuses = ['want_to_read', 'reading', 'finished', 'abandoned'] as const;
export type ReadingStatus = (typeof readingStatuses)[number];
export const statusLabels: Record<ReadingStatus, string> = {
want_to_read: 'want to read',
reading: 'reading',
finished: 'finished',
abandoned: 'abandoned'
};
export function isReadingStatus(value: unknown): value is ReadingStatus {
return typeof value === 'string' && (readingStatuses as readonly string[]).includes(value);
}
/** Formats the in-browser reader can open. MOBI/AZW3/FB2/CBZ go through foliate-js too. */
export const readableFormats = ['epub', 'pdf', 'mobi', 'azw3', 'azw', 'fb2', 'cbz'] as const;
export function isReadable(format: string): boolean {
return (readableFormats as readonly string[]).includes(format.toLowerCase());
}
/** Preferred file to open when a work has several formats. */
export const formatPreference = ['epub', 'azw3', 'mobi', 'azw', 'fb2', 'pdf', 'cbz'];
/** One card in the library. Public fields only, unless noted. */
export type BookCard = {
id: number;
title: string;
subtitle: string | null;
authors: string[];
year: number | null;
tags: string[];
pageCount: number | null;
cover: string | null; // URL (our cover endpoint or a remote cover), or null for a generated one
status: ReadingStatus | null; // null: never touched
percent: number;
rating: number | null;
lastReadAt: string | null;
finishedAt: string | null;
addedAt: string;
// Owner only (empty / false for everyone else):
formats: string[];
hasFile: boolean;
};
export type LibraryStats = {
total: number;
finishedThisYear: number;
pagesThisYear: number;
reading: number;
year: number;
};
export type Highlight = {
id: number;
fileId: number | null;
locator: string | null;
page: number | null;
highlight: string | null;
note: string | null;
color: string | null;
createdAt: string;
};
export const highlightColors = ['yellow', 'green', 'blue', 'pink', 'purple'] as const;
export type HighlightColor = (typeof highlightColors)[number];
export const highlightCss: Record<HighlightColor, string> = {
yellow: '#facc15',
green: '#4ade80',
blue: '#60a5fa',
pink: '#f472b6',
purple: '#c084fc'
};
/** Deterministic hue for generated covers, so a book always gets the same colour. */
export function coverHue(title: string): number {
let hash = 0;
for (const char of title) hash = (hash * 31 + char.codePointAt(0)!) >>> 0;
return hash % 360;
}
export function authorLine(authors: readonly string[]): string {
if (authors.length <= 2) return authors.join(' & ');
return `${authors[0]} et al.`;
}
/** PDF highlight locators: `page:12` or `page:12#r=x,y,w,h;x,y,w,h` (rects as page fractions). */
export type Rect = { x: number; y: number; w: number; h: number };
export function pdfLocator(page: number, rects: Rect[] = []): string {
const r = rects
.map((rect) => [rect.x, rect.y, rect.w, rect.h].map((n) => +n.toFixed(4)).join(','))
.join(';');
return r ? `page:${page}#r=${r}` : `page:${page}`;
}
export function parsePdfLocator(locator: string | null): { page: number; rects: Rect[] } | null {
const match = /^page:(\d+)(?:#r=([\d.,;-]+))?$/.exec(locator ?? '');
if (!match) return null;
const rects = (match[2] ?? '')
.split(';')
.filter(Boolean)
.map((part) => part.split(',').map(Number))
.filter((n) => n.length === 4 && n.every(Number.isFinite))
.map(([x, y, w, h]) => ({ x, y, w, h }));
return { page: Number(match[1]), rects };
}

View File

@@ -8,6 +8,7 @@
</script>
<script lang="ts">
import BookOpen from 'lucide-svelte/icons/book-open';
import CandlestickChart from 'lucide-svelte/icons/candlestick-chart';
import ChartNoAxesCombined from 'lucide-svelte/icons/chart-no-axes-combined';
import FolderKanban from 'lucide-svelte/icons/folder-kanban';
@@ -36,7 +37,8 @@
'cs prices': CandlestickChart,
'house tracker': House,
lists: ListChecks,
health: HeartPulse
health: HeartPulse,
books: BookOpen
};
const Icon = $derived(icons[name as keyof typeof icons] ?? FolderKanban);

View File

@@ -70,6 +70,12 @@ export const trackers = [
href: '/projects/animaldex',
external: false
},
{
name: 'books',
subtitle: 'my library: what I am reading, have read and want to read',
href: '/trackers/books',
external: false
},
{
name: 'lists',
subtitle: 'books, games, places and other things worth keeping',

View File

@@ -38,7 +38,11 @@ const publicPages: Record<string, { title: string; description: string }> = {
'/trackers': {
title: 'Trackers',
description:
'Stan Runge’s trackers: games, money, houses, Counter-Strike prices, Dutch energy, Animaldex and lists.'
'Stan Runge’s trackers: games, money, houses, books, Counter-Strike prices, Dutch energy, Animaldex and lists.'
},
'/trackers/books': {
title: 'Books',
description: 'Stan Runge’s library: books being read, finished and on the to-read pile.'
},
'/lists': { title: 'Lists', description: 'Lists collected and maintained by Stan Runge.' },
'/projects/game-tracker': {

View 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);
});
});

View 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}` } : {})
}
});
}

View 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'
});
}

View 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;
}

View File

@@ -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];

View File

@@ -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);
});
});

View File

@@ -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));
}

View File

@@ -18,6 +18,7 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
'/': 'Home',
'/projects': 'Projects',
'/trackers': 'Trackers',
'/trackers/books': 'Books',
'/blog': 'Blog',
'/lists': 'Lists',
'/login': 'Sign in',