Files
Spriteandclaude 1969b74cc9 SPEC: lookup outcome is three-way; wave 5 worker prompt
SPEC now states that a source which could not be reached must never be
reported as a book that does not exist: Found / NotFound / Unavailable,
where NotFound requires every source to have answered authoritatively.
Also records that a rejected barcode must not be silent, and that the
by-ISBN cover URL is not evidence a cover exists.

run-task.sh exports CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS=0, per the wave-4
post-mortem: `claude -p` otherwise kills background tasks at 600s, so a
worker that backgrounds a Gradle build can never report on it. Safe to
edit now — no workers are running (hazard: never edit it while they are).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDcPottghJXEvfYKqFM7zf
2026-09-09 10:42:32 +00:00

8.8 KiB

Bookshelf — authoritative spec

Two-person shared home library. Android app + self-hosted PocketBase. ALL workers must follow this exactly. Do not invent alternative names.

Non-negotiables

  • Offline-first. Home server is often unreachable (residential NAT). Every read comes from Room. Every write lands in Room first, syncs later. No screen may block on network.
  • Private. No public registration. Auth required for all data access.
  • Server URL is NOT hardcoded; user enters it on first run.

Repo layout

~/bookshelf/ server/ PocketBase binary(gitignored), pb_migrations/, setup-schema.sh, deploy/ app/ Android Gradle project docs/ this spec

Android

  • applicationId/namespace: org.modg.bookshelf
  • minSdk 26, compileSdk 37, targetSdk 37, JDK 21, Kotlin, Jetpack Compose, Material 3
  • SDK at ~/toolchain/android-sdk ; JDK at ~/toolchain/jdk21
  • DI: manual AppContainer held by Application. NO Hilt/kapt. Room uses KSP.
  • Libs: Compose BOM, room(+ksp), retrofit2 + kotlinx-serialization converter, okhttp logging, coil3 compose, camerax(core/camera2/lifecycle/view), com.google.mlkit:barcode-scanning, androidx.work runtime-ktx, datastore-preferences, navigation-compose, lifecycle-viewmodel-compose, accompanist-permissions (or manual)

Package structure (org.modg.bookshelf.*)

data.local Room: entities, daos, BookshelfDatabase, Converters data.remote PocketBaseApi (retrofit), dtos, PbAuthInterceptor data.metadata OpenLibrary + GoogleBooks lookup data.repo BookRepository, LocationRepository, SyncEngine, AuthRepository data.prefs SettingsStore (DataStore) ui.theme Color/Type/Theme ui.library, ui.detail, ui.scan, ui.locations, ui.settings, ui.setup ui.nav BookshelfNavHost

Data model — Room mirrors PocketBase 1:1

IDs: 15-char lowercase alnum, GENERATED CLIENT-SIDE for new records (PocketBase accepts client-supplied ids on create). Never remap ids after push.

BookEntity(id PK, title, subtitle, authorsJson, isbn13, isbn10, publisher, publishedDate, pageCount:Int?, description, coverUrl, coverSourceUrl, shelfId:String?, notes, addedBy, deleted:Boolean, createdAt:Long, updatedAt:Long, syncState:SyncState, localCoverPath:String?) BookcaseEntity(id PK, name, note, position:Int, deleted, createdAt, updatedAt, syncState) ShelfEntity(id PK, bookcaseId, label, position:Int, deleted, createdAt, updatedAt, syncState)

enum SyncState { SYNCED, PENDING_CREATE, PENDING_UPDATE, PENDING_DELETE }

All queries filter deleted = 0. Deletion is ALWAYS soft (tombstone) so sync can propagate it and nothing is silently lost from a shared library.

PocketBase schema (collections)

bookcases: name(text,req), note(text), position(number), deleted(bool) shelves: bookcase(relation->bookcases,req,maxSelect 1), label(text,req), position(number), deleted(bool) books: title(text,req), subtitle(text), authors(json), isbn13(text), isbn10(text), publisher(text), published_date(text), page_count(number), description(text), cover(file,maxSelect 1,image mimes,thumbs 100x150+300x450), cover_source_url(text), shelf(relation->shelves,maxSelect 1), notes(text), added_by(relation->users,maxSelect 1), deleted(bool) All three get autodate created/updated. Indexes: books(isbn13), books(updated), shelves(updated), bookcases(updated).

API rules — all of list/view/create/update/delete on the three collections: "@request.auth.id != """ users collection: createRule = null (SUPERUSER ONLY — this is what keeps the world out), listRule/viewRule = "@request.auth.id != """, updateRule = "id = @request.auth.id", deleteRule = null. NOTE: rule "" means PUBLIC in PocketBase; null means superuser-only. Do not confuse.

Sync design (SyncEngine)

Pull: GET /api/collections/{c}/records?filter=(updated>'{cursor}')&sort=updated &perPage=200&page=N — paginate to exhaustion. Cursor per collection in DataStore, stored as PB UTC string. Include tombstones. Push: records where syncState != SYNCED. PENDING_CREATE -> POST (with our id), PENDING_UPDATE -> PATCH, PENDING_DELETE -> PATCH {deleted:true}. On 404 for update/delete: drop local record. On 409/duplicate id: switch to PATCH. Order: push THEN pull (so our writes come back canonical). Conflict: last-write-wins on updated. Document this in README; do not build anything cleverer. Covers: on save, app downloads cover from metadata source and multipart-uploads it to the book's cover file field, so covers survive upstream URL rot. If offline, store localCoverPath and upload on next sync. Trigger: app start, manual pull-to-refresh, WorkManager periodic (~6h, network-constrained). Never let sync failure surface as a crash or a blocking dialog — a quiet status line only.

Book metadata lookup

Primary Open Library: https://openlibrary.org/api/books?bibkeys=ISBN:{isbn}&format=json&jscmd=data Fallback Google Books: https://www.googleapis.com/books/v1/volumes?q=isbn:{isbn} (no key) Cover: https://covers.openlibrary.org/b/isbn/{isbn}-L.jpg else GB imageLinks (force https, zoom=2) Merge: prefer whichever has a title; fill blanks from the other. Cover URL comes from a source that REPORTS one. Never synthesize the by-ISBN cover URL as if it were evidence: for an edition with no art that endpoint returns 200 + a 43-byte 1x1 transparent GIF, which loads "successfully" and paints nothing. As a last resort it may be used only with ?default=false, which makes a miss a 404.

Lookup outcome is THREE-WAY, never a bare null. A source that could not be reached must never be reported to the user as a book that does not exist: Found(metadata) - at least one source returned a record NotFound - EVERY source answered authoritatively and none had it Unavailable - no source could be reached (non-2xx, timeout, transport error) and none of the reachable ones had it UI: Found -> the save sheet. NotFound -> manual entry pre-filled with the scanned ISBN. Unavailable -> a retry affordance, with manual entry as the escape hatch; it must NOT claim the book is unknown.

Barcode scanning

CameraX Preview + ImageAnalysis -> ML Kit BarcodeScanning (EAN_13, EAN_8, UPC_A). Validate ISBN-13 checksum before lookup; ignore non-book barcodes. Debounce repeats. A rejected barcode is NOT silent: the camera screen must say a code was read and was not a book ISBN, or the user cannot tell a non-book barcode from a dead camera. Throttle that message — a non-book barcode sits in frame emitting continuously. Continuous mode: after a save, stay on camera for the next book (shelving a box of books is the real use case). Show a running "added this session" count. Handle: camera permission denial, torch toggle, and a manual-ISBN-entry escape hatch.

Design language — "feels like books"

Warm paper, dark mahogany, gold + silver metallics. Restrained, not skeuomorphic. Light: paper #F5EDE0, paperAlt #EDE3D2, ink #2B211A, inkSoft #5A4A3D, mahogany #5C2E23, mahoganyDeep #3E1E17, gold #C0932F, goldSoft #D9B45B, silver #9CA3AF, silverSoft #C7CCD1 Dark: ground #1C1411, surface #241A15, paperText #E8DCC8, mahogany #7A3E2F, gold #D9B45B, silver #C7CCD1 Type: serif display (Literata, OFL, bundle the TTF) for titles/headers; system sans for body/UI. Generous line-height. Motifs: subtle spine/edge treatments, thin gold hairline rules, gentle paper-grain on large surfaces. Covers are the hero — let them carry the color. Both light and dark themes required. Dynamic color OFF (it would fight the palette).

Screens

setup First run: server URL (+ https scheme validation, trailing-slash strip, reachability probe), email, password. Clear errors for wrong URL vs bad creds. library Cover grid (2-3 col adaptive). Search title/author/ISBN. Filter by bookcase/shelf. Sort title/author/added. Empty state invites first scan. FAB -> scan. Sync status line. detail Big cover, title/subtitle/authors/publisher/year/pages/ISBN, description (collapsible), notes (editable), location picker, edit, soft-delete w/ undo. scan Camera + reticle; on hit -> bottom sheet w/ fetched book + shelf picker + Save / Skip. Duplicate-ISBN warning if already owned. locations Bookcases -> shelves tree. CRUD + reorder. Book counts per shelf. Tap a shelf -> library filtered to it. "Move books" bulk action. settings Server, account, sign out, manual sync + last-sync time, book/cover counts.

Quality bar

  • No emulator on this box (no KVM). Verify via: ./gradlew assembleDebug, JVM unit tests, and Paparazzi screenshot rendering.
  • Unit-test the real logic: ISBN checksum, metadata merge, sync conflict resolution, DAO queries (Robolectric). Do not write assertion-free tests.
  • App must compile and run with NO server configured (setup screen) and must not crash when the server is unreachable.