Files
patrick 55e00ba960 feat: import a Calibre library
Reads metadata.db and copies the books into a library — from a zip uploaded on
the library settings page, or from a path with `litestar calibre-import`. The
source is never touched, and re-running only picks up what is new.

Also names the formats mimetypes does not know: a Calibre library is full of
MOBI and AZW3, and a null content type used to fail the book endpoint.
2026-08-17 13:38:44 -04:00

10 KiB

Chitai frontend

SvelteKit web app for the eBook library. See the repo-root AGENTS.md for the overall picture and dev-environment setup.

Stack: SvelteKit 2 with adapter-node · Svelte 5 (runes) · Tailwind v4 · Zod v4 · vendored foliate-js (EPUB) · vendored pdf.js (PDF) · mode-watcher (dark mode) · svelte-sonner (toasts) · pnpm.

Two experimental flags are on in svelte.config.js and the codebase depends on both: kit.experimental.remoteFunctions and compilerOptions.experimental.async (await in components).

Tailwind v4 has no config file — the theme, colour tokens (hex, not oklch) and @custom-variant dark all live in src/app.css. dark is the only custom variant defined, so generated components that assume others — shadcn's slider ships data-horizontal: / data-vertical: classes — silently produce no styles. Use the data-[orientation=…] form instead.

Talking to the backend

There are two mechanisms; pick deliberately.

1. Remote functions — the default. src/lib/api/*.remote.ts export query / command / form functions from $app/server. Each takes a Zod schema from $lib/schema as its validator and runs on the server, reaching the API through locals.api (the ApiClient in src/lib/server/api.ts):

export const getBook = query(stringCoerce, async (id): Promise<Book> => {
	const { locals } = getRequestEvent();
	const response = await locals.api.get(`/books/${id}`);
	if (!response.ok) error(response.status === 404 ? 404 : 500, '…');
	return await response.json();
});

Conventions: build query strings with createQueryParams from $lib/utils; on a failed response throw SvelteKit's error(status, message); multipart uploads go through postMultipart / putMultipart. Re-export new modules from src/lib/api/index.ts.

2. The catch-all proxy at src/routes/api/[...path]/+server.ts forwards GET/POST/PATCH/DELETE to the backend with the auth header attached. Use it only where the browser itself must fetch the backend — e.g. streaming a book file into the EPUB/PDF reader. It is not the general-purpose path.

Auth

src/hooks.server.ts is a sequence of two handles: the first reads the authToken cookie, constructs an ApiClient, validates it with GET /access/me and fills locals.user / locals.authToken / locals.api (clearing the cookie if invalid); the second redirects any route outside /login to the login page when there is no user.

The cookie is set in src/lib/api/auth.remote.ts (login) — httpOnly, secure, sameSite strict, one week — and deleted by logout. The backend JWT never reaches client-side JS.

Schemas

  • src/lib/schema/*.ts — hand-written Zod schemas, used as remote-function input validators and as the source of the exported TS types. Mirror backend/src/chitai/schemas/ when the API changes.
  • src/lib/schema/common.ts — shared building blocks: stringCoerce / arrayCoerce coercion helpers, PaginatedResponse<T>, and the pagination / search / order query schemas that most list endpoints compose from.
  • src/lib/schema/openapi/schema.d.tsgenerated from the backend's OpenAPI document with openapi-typescript. Never hand-edit; regenerate after backend API changes.

State

Client state lives in classes in src/lib/state/*.svelte.ts using $state / $derived, shared via Svelte context with a module-level Symbol key and a setXState / getXState pair:

const LIBRARY_KEY = Symbol('LIBRARY');
export function setLibraryState(libraries: Library[]) {
	return setContext(LIBRARY_KEY, new LibraryState(libraries));
}
export function getLibraryState() {
	return getContext<ReturnType<typeof setLibraryState>>(LIBRARY_KEY);
}

Follow that pattern rather than introducing stores. library.svelte.ts is the reference — including its optimistic-delete-with-rollback and toast handling. bookCollection / bookSelection / bookOperations split list data, selection and mutations across three cooperating classes.

Components

  • src/lib/components/ui/ — vendored shadcn-svelte (components.json) plus jsrepo blocks from @ieedan/shadcn-svelte-extras (jsrepo.json). Treat as generated: add components with the CLIs rather than hand-writing them, and prefer wrapping over editing.
  • App components live in forms/, layout/, view/ (browser, grid/list/table, filters, sort) and reader/ (see The readers).
  • cn() from $lib/utils merges Tailwind classes; the WithElementRef / WithoutChild helpers there are the shadcn prop-typing conventions.

The readers

PDF is the pdf.js viewer vendored under static/pdfjs/, pointed at by an iframe. Untouched by the EPUB work; leave it alone unless the task is about PDFs.

EPUB is built on foliate-js, copied verbatim into src/lib/vendor/foliate-js/ by scripts/vendor-foliate.sh (pinned commit; see src/lib/vendor/foliate-js/README.chitai.md). Upstream has no npm release and recommends a submodule; this repo has none and already vendors pdf.js the same way, so it is copied instead. Only the import closure reachable from view.js is vendored, and pdf.js in that directory is our stub, not upstream's — the real one imports a bare @pdfjs/pdf.min.mjs that Rollup resolves at build time even though the path never runs.

Layout:

Path What
lib/vendor/foliate-js/ The engine. Do not edit — vendor-foliate.sh overwrites it.
lib/reader/foliate.ts Lazy loader for the custom elements. The only thing that imports $foliate.
lib/reader/settings.ts · stylesheet.ts Defaults/bounds, and the CSS injected into the book.
lib/reader/progress.ts Debounced progress writer with a sendBeacon flush.
lib/state/reader-settings.svelte.ts Settings state, persisted to localStorage.
components/reader/foliate-view.svelte Wraps <foliate-view>; owns the imperative lifecycle.
components/reader/epub-reader.svelte The shell: chrome, TOC, errors, progress.

Things that will bite:

  • $foliate is a Vite-only alias. It is deliberately absent from kit.alias and tsconfig paths so TypeScript cannot resolve it and falls back to the ambient declaration in lib/reader/foliate-js.d.ts; src/lib/vendor is also in tsconfig exclude. Without both, checkJs walks ~11k lines of untyped JS. The declaration file must not be named foliate.d.ts — beside foliate.ts, TypeScript takes it for that file's emitted declaration and drops it.
  • Never import the vendored code at module scope. view.js calls customElements.define and subclasses HTMLElement on import, so it must stay behind loadFoliate() inside onMount. SSR is otherwise on for the reader route.
  • Sections render in iframes, which swallow key events. Keyboard handlers are bound per section document on the load event, and modifier combinations are replayed onto the host window so app shortcuts (the sidebar's ctrl+B) still work while reading.
  • Renderer settings split two ways. Flow, gap, margins, column count and line width are attributes set with setAttribute (there is no JS property API, no margin shorthand and no spread — a spread is max-column-count: 2). Typography is CSS passed to renderer.setStyles, which takes a [before, after] pair: the first is prepended to the section head so the book overrides it, the second appended so it wins. User settings belong in the second, with !important, or the book's own CSS beats them.
  • Progress needs no locations pre-pass. relocate carries both a CFI and an overall fraction, which map straight onto epub_cfi and percentage.

Routing

Route groups carry the layout structure:

  • (root) — the authenticated shell (sidebar, header); +layout.server.ts loads the libraries.
  • (root)/(library) — library-scoped pages: library/[libraryId]/view, book/[bookId], edit, and the readers at book/[bookId]/read/{epub,pdf}/[fileId].
  • +layout@.svelte breakouts reset to the root layout for the login page and the full-screen reader.

Conventions

Prettier (.prettierrc): tabs, single quotes, no trailing commas, 100 columns, with the Svelte and Tailwind plugins. Run pnpm check (svelte-check) and pnpm lint before considering work done — but take a baseline first, because neither is clean (see below).

src/lib/vendor/ is excluded from Prettier, ESLint and svelte-check. Don't reformat vendored code.

Known rough edges

Observed in the current tree — don't mistake these for intentional patterns to copy:

  • pnpm check is not clean. Baseline as of 2026-08-13: 30 errors, 1 warning, 8 files, most of them in src/routes/api/[...path]/+server.ts (see below). Get your own baseline before assuming an error is yours. src/lib/schema/openapi/schema.d.ts was regenerated on that date and is current; regenerate it again after any backend API change, with pnpm exec openapi-typescript http://localhost:8000/schema/openapi.json -o src/lib/schema/openapi/schema.d.ts against a backend running your branch — a stale server silently writes a stale file.
  • pnpm lint does not pass either — 131 pre-existing ESLint errors, 35 of them svelte/no-navigation-without-resolve on plain hrefs, plus ~59 files Prettier would rewrite (mostly vendored shadcn components). Check the files you touched, not the whole tree.
  • src/routes/api/[...path]/+server.ts — all four handlers are annotated RequestHandler while the import of that type is commented out at line 4. It also buffers whole responses with arrayBuffer() and forwards no Range header, so book downloads are not streamed. Requests are streamed — POST and PATCH pass request.body through with duplex: 'half' (see bodyOf), because a zipped Calibre library upload cannot be held in this process. The response side is still buffered; see TODO.md.
  • src/app.d.tsApp.Locals["user"] is typed from lucide-svelte's User icon component rather than the User interface in $lib/server/auth.
  • No CSP, which foliate's README asks for because EPUBs can carry scripts. See TODO.md for why it is not enabled yet.