Guidance for Claude Code working on this repo.
yarn preview— Full-stack local dev (Worker + React on :8787)yarn deploy—yarn build && wrangler deployto productionyarn deploy -- --env staging— Deploy to stagingyarn build— Build React app onlyyarn format— Prettieryarn format:check— Check formatting
yarn test— Vitest unit testsyarn test:watch— Vitest watch modeyarn test:visual— Playwright visual regressionyarn test:visual:update— Update Playwright snapshots
yarn db:init— Init local DB schemayarn db:seed— Load sample datayarn db:local -- "SQL"— Run SQL on local D1yarn db:remote -- "SQL"— Run SQL on remote D1yarn db:local:file -- path.sql— Execute SQL file locallyyarn db:remote:file -- path.sql— Execute SQL file remotely
yarn import-book -- --file="book.epub" --target="zh"— Import + translateyarn list-books:local/yarn list-books:remote— List booksyarn remove-book:local -- --uuid="..."/yarn remove-book:remote -- --uuid="..."— Remove bookyarn sync-remote-book -- --uuid="..."— Sync local book to remote D1yarn backfill-links -- --uuid="..." [--env=remote] [--dry-run]— Re-parse the original EPUB (from R2uploads/{uuid}/original.epub) and rewriteraw_htmlso pre-footnote-support books get internal links/note popovers; verifies stored translation XPaths still resolve before writing
TypeScript-first across frontend, backend, CLI, and translator service.
- React SPA (
src/components/) — BookShelf (3D closet, with a legacy 2D wall fallback), BilingualReaderV2, ErrorBoundary - CF Worker (
src/worker/) — API server: auth, book-handlers, credits, db, types - Railway Translator (
services/translator/) — Long-running translation service (Hono + Sharp)- Receives webhook from Worker on EPUB upload
- Translates via OpenAI-compatible API (default: gpt-4o-mini). All LLM work
(paragraph batches, chapter titles, per-node retries) flows through one
global worker pool spanning chapters (
TRANSLATE_CONCURRENCY, default 8) - Reads/writes D1 via REST API. Checkpoint resume is chapter-level: a chapter counts as done only when every node has a translations_v2 row; partially written chapters are wiped and redone on resume (translations_v2 has no UNIQUE constraint, so this is what keeps resume idempotent)
- Generates each book's cover + spine by compositing onto a pre-made blank
cloth-hardcover template (pure Sharp, no AI at request time) — see
cover-composer.ts. Spine width scales with book length.
- CLI Scripts (
scripts/) — import-book, list-books, remove-book, sync-remote-book, generate-blanks - D1 SQLite — Users, sessions, books/chapters/translations (v2 tables), translation jobs, credits, reading progress
- R2 Storage — Cover images, spine images, in-book images (
books/{uuid}/images/). CORS enabled (GET/HEADfrom*) — the 3D shelf loads these as WebGL textures - PWA — Installable (manifest + iOS metas);
src/sw-register.tsregisters the service worker and shows a refresh toast on new deploys. iOS standalone quirk: use100dvh/safe-area-inset-bottomfor full-screen layouts, not bare100vh
src/worker/index.ts— Main Worker entry, routing, middlewaresrc/worker/auth.ts— Google OAuth flowsrc/worker/book-handlers.ts— Book CRUD, upload, chapter contentsrc/worker/credits.ts— Credit balance, Stripe checkout/webhookssrc/worker/db.ts— Database helpers, migrationssrc/worker/translation/— Cloudflare Workflows translation backend (jobs withbackend='cf'):translate-core.tsmirrors the LLM-facing logic ofservices/translator/src/translate-worker.tsverbatim — keep the two in sync; orchestration is idempotent per-chapter Workflow steps (row-count check + partial wipe on entry).workflow.tsis the logic-freeTranslateBookWorkflowentrypoint (sliding window of 5 chapter steps);d1-binding-client.tsadapts the D1 binding to the REST client's surface. Started only via secret-guardedPOST /api/internal/translate-cf, which refuses jobs whosebackendisn't'cf'src/components/BilingualReaderV2.tsx— Main reader (scroll nav, paragraph toggle, progress). Internal links resolved by the parser (a[data-ov-chapter][data-ov-xpath]) navigate in-app: note references (data-ov-note) open a bilingual footnote popover (cross-chapter notes fetched via the chapter cache), other links jump vialoadChapterwith a floating "return to reading" chip (stack lives in AppV2); note markers are re-appended after translated text so they stay tappable in translated viewsrc/components/BookShelf.tsx— Library UI: hosts the 3D closet (default). Falls back to a classic 2D wall when WebGL is unavailable, but that fallback is legacy/deprecated — it has no upload entry point (upload only happens by clicking an empty slot in the 3D closet) and is slated for removalsrc/components/shelf3d/BookShelf3D.tsx— 3D closet view (three + @react-three/fiber, lazy-loaded): gaze/zoom camera, click-to-fly-out book with info panel, click-empty-slot-to-upload. Requires CORS on the R2 assets domain (configured on bucketovid)src/components/shelf3d/layout.ts— Pure shelf-packing math for the 3D view: books explicitly placed in a physical slot (shelf_row/shelf_col) render at that coordinate; everything else packs into a stable block of rows below the physical slots, grouped by shelf/ownership (adaptive case width, unit-tested)src/components/ErrorBoundary.tsx— Error boundary wrappersrc/utils/translator.ts— Unified translation module (used by CLI scripts)services/translator/src/index.ts— Railway service entry (Hono routes)services/translator/src/translate-worker.ts— Translation logicservices/translator/src/d1-client.ts— D1 REST API clientservices/translator/src/cover-composer.ts— Composes cover + spine onto blank cloth templates (Sharp): book-face detection, original-cover inset, title/author typesetting, length-based spine thicknessservices/translator/src/book-parser.ts— EPUB parsing; also extracts the embedded cover (used as the cover inset). Chapter extraction is TOC-first: when the EPUB has a usable nav.xhtml/NCX, files it references take its titles; tiny nav-referenced half-title pages (bare part/chapter numbers) are dropped and donate their nav title to the next unreferenced file (donated nav title > own h1 > inheritance); substantial unreferenced files with a leading h1 are chapters the TOC skipped and take their own h1, headingless ones inherit the preceding entry's title (split-chapter continuations); tiny text-only pages outside the TOC's range (publisher ads/filler) are dropped, andlinear="no"spine items are skipped per spec; untitled front/back matter falls back through OPF<guide>roles → filename patterns → headings → short-block derivation (prose/dialogue lines are rejected), with "Chapter N" as the true last resort. Resolves internal links across spine files to(chapter, xpath)coordinates (data-ov-chapter/data-ov-xpathattributes inraw_html) and classifies footnote references (data-ov-note) across the common EPUB shapes: EPUB3epub:type="noteref"+<aside epub:type="footnote">(aside hidden viadata-ov-hidden, still translated), separate endnotes pages (heading + backlink-ratio heuristic), Gutenberg-style same-file anchor pairs (note-label echo heuristic), plus plain cross-references; note labels are stripped from thetextsent to translation. Mirrored insrc/utils/book-processor.ts(CLI fallback importer) — keep the two in syncservices/translator/src/image-processor.ts— Legacy cover/spine image processing (Sharp), used by the cover-preview debug pageservices/translator/src/cover-preview.ts— Password-protected cover preview page
GET /api/auth/google— Start OAuth flowGET /api/auth/callback/google— OAuth callbackGET /api/auth/me— Current userPOST /api/auth/logout— Logout
GET /api/books(alias/api/v2/books) — List books (public + user's private)POST /api/books/estimate— Parse an EPUB via Railway and return a translation cost estimatePOST /api/books/upload— Upload EPUB (auth required, deducts credits; Railway handles the rest via/upload-and-parse). Accepts an optional shelf target (shelfSlotId, orshelfRow/shelfColto create one on the fly) from clicking an empty slot in the 3D closet. The handler picks the translation backend per user (chooseTranslationBackend:CF_TRANSLATION_ALLOWLISTemails →'cf',CF_TRANSLATION_DEFAULT=1→ everyone; else'railway') and passes it to Railway, which parses either way but for'cf'jobs hands translation to the Workflow viaPOST /api/internal/translate-cf(falling back to translating on Railway — after flipping job ownership — if the trigger fails)GET /api/book/:uuid/status— Parsing/translation progress (polled by the shelf)GET /api/book/:uuid/chapters— Chapter listGET /api/book/:uuid/chapter/:number— Chapter content (XPath-mapped paragraphs)GET /api/book/:uuid/content— Full book contentDELETE /api/book/:uuid— Delete book (owner only)POST /api/book/:uuid/share/GET /api/shared/:token/...— Share linksGET /api/shelf-slots— Physical shelf-slot grid (row/col/label) for the 3D closet's slot-based upload targets
POST /api/book/:uuid/mark-complete— Mark book read/unread ({isCompleted: bool})GET /api/book/:uuid/progress— Get reading progressPUT|POST /api/book/:uuid/progress— Save reading position (POST used bysendBeaconon unload)GET /api/progress— All of the user's per-book progress in one map
GET /api/credits— Balance + available packagesGET /api/credits/transactions— Purchase/usage historyPOST /api/stripe/checkout— Create Stripe checkout sessionGET /api/stripe/verify-session— Verify checkout (webhook fallback)POST /api/stripe/webhook— Stripe webhook
GET /api/cover-preview/:uuid— Password-protected cover/spine preview
Production runs the v2 schema (database/schema_v2.sql + database/migrations/); database/schema.sql is the legacy v1 layout.
- users —
id, google_id, email, name, picture, credits, created_at, updated_at - sessions —
id, user_id, session_token, expires_at - books_v2 —
id, uuid, title, original_title, author, language_pair, styles, book_cover_img_url, book_spine_img_url, user_id, status, display_order, created_at, updated_at - chapters_v2 —
id, book_id, chapter_number, title, original_title, raw_html (original EPUB HTML), text_nodes_json, order_index - translations_v2 —
id, chapter_id, xpath, original_text, original_html, translated_text, order_index(XPath-mapped onto the chapter's raw HTML) - translation_jobs —
book_uuid, source/target_language, total/completed_chapters, current_chapter, current_item_offset, glossary_json, glossary_extracted, translated_title, status, error_message, backend(checkpoint + progress state per book;backend— 'railway' or 'cf' — names the only service allowed to write the job, and the Railway stalled-job scanner filters on it)
- user_book_progress —
id, user_id, book_uuid, is_completed, completed_at, last_read_at, chapter_number, paragraph_xpath, show_original(UNIQUE user_id + book_uuid) - credit_transactions —
id, user_id, amount, type (purchase/usage), stripe_payment_intent_id, balance_after, created_at
- shelf_slots —
id, shelf_id, row, col, sort_order, label(UNIQUE shelf_id+row+col, UNIQUE shelf_id+sort_order). Physical coordinates on the 3D closet wall; created on the fly when a user clicks an empty slot to upload, or pre-labeled (e.g. "Gutenberg books") for curated bays - book_shelf_slots —
book_id (PK), slot_id, position— links a book to the physical slot it was uploaded into - book_shelves —
shelf_id, book_id, position— dormant/legacy named-shelf grouping (no current app writer;shelf3d/layout.tsstill readsshelf_idoff books for stable grouping of anything not yet in a physical slot)
GOOGLE_OAUTH_CLIENT_ID/GOOGLE_OAUTH_CLIENT_SECRETOPENAI_API_KEY,OPENAI_API_BASE_URL,OPENAI_MODELSTRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET,STRIPE_PUBLISHABLE_KEYTRANSLATOR_SERVICE_URL,TRANSLATOR_SECRET
APP_URL— e.g.https://ovid.ink
OPENAI_API_KEY,OPENAI_API_BASE_URL,OPENAI_MODELCLOUDFLARE_ACCOUNT_ID,CLOUDFLARE_API_TOKEN,CLOUDFLARE_D1_DATABASE_ID
- Branch off latest main — Always
git fetch origin && git checkout main && git pullbefore creating a new branch. Branching off a stale local main produces PRs that conflict with or revert recently merged work. - Rebase before opening a PR — After committing on the feature branch, run
git fetch origin && git rebase origin/mainand resolve conflicts beforegit push. Do this for every PR, even small fixes — main moves fast and yesterday's base is already stale. - Branch naming —
feature/orfix/branches, PR back to main. - Never force push to main. Force-push to your own feature branch (after rebase) is fine and expected.
- Tests required — Run
yarn testbefore submitting. New features need new tests. - CI — Push to main auto-deploys via GitHub Actions
- Railway — Translator service auto-deploys on git push separately