Files
bookshelf/docs/SPEC.md
T
Spriteandclaude ab83287b5d Move Open Library lookup to the Editions API; stop asking Google Books for its placeholder
Two user-reported shelf-testing symptoms, both a third party answering
misleadingly and the app believing it.

1. Open Library's /api/books?bibkeys=... now 404s for EVERY ISBN, including
   books OL demonstrably still holds, with OL's own x-ol-stats header on the
   response while /isbn/, /search.json, /api/volumes/brief and covers.* all
   serve normally. OL's docs call it the "Legacy Books API" that "may be phased
   out" and it is gone from their API index, so this reads as a retirement
   rather than an outage. The app had been effectively single-sourced on Google
   Books since it broke: every failure the user saw was a book GB lacks.

   Lookup now uses /isbn/{isbn}.json — current, non-legacy, edition-level, and
   the only option of the three that carries a description. /api/volumes/brief
   is a near drop-in for the old response shape and was rejected precisely
   because it is also legacy.

   Its costs, all handled: authors are references, so AuthorNameCache resolves
   and caches them for the process lifetime (books by one author get scanned in
   runs off one shelf); an edition may carry NO authors, in which case they live
   on the work — 9780898707168 on the user's own shelf is exactly this, so
   without the work fallback the move would have silently dropped its author;
   author/work requests are best-effort and can only degrade a record, never
   turn Found into Unavailable.

   404 on this endpoint is authoritative NotFound. The legacy endpoint reported
   a miss as 200 with an empty object, which is why every non-2xx there was a
   failure. Every other non-2xx still is.

2. Google Books answers zoom=2 with a grey "image not available" PNG at HTTP
   200 — not a 404 — for any volume it holds no full preview of. Coil loads it
   as a success, so BookCover's placeholder never fires and the cover pipeline
   uploads Google's placeholder to PocketBase as the book's cover. Measured over
   18 real volumes: 11 placeholders at zoom=2, 0 at zoom=1&w=400. zoom=0/3/6 are
   placeholders too. normalizeCoverUrl now pins zoom=1, adds w=400 and strips
   edge=curl.

SPEC.md's "Book metadata lookup" is rewritten with both rules and the evidence
for them — it was the source of the zoom=2 instruction, and would otherwise be
the reason someone restores it.

Also fixed, because it blocked verification: LibraryViewModelTest never cleared
the view models it built, and LibraryViewModel's eleven WhileSubscribed(5_000)
flows kept running five seconds into later tests, racing resetMain(). It now
cancels each viewModelScope in tearDown.

NOT fixed, reported instead: AddBookViewModel.performSave's in-flight guard is a
check-then-act and two coroutines can both pass it. Unrelated to this change
(that VM has no metadata dependency) and out of scope. See HAZARD #13.

Verified: assembleDebug exit 0; testDebugUnitTest --rerun-tasks 378 tests,
2 skipped, 0 failures (was 355); verifyPaparazziDebug exit 0, no pixels moved;
0 "always 'false'" warnings; no build files touched. LIVE_METADATA=1 live test
ran (not skipped): 9/9 Found with cover art, with the GB key absent, so Open
Library alone answered through the new endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-20 02:13:01 +00:00

202 lines
12 KiB
Markdown

# 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. The server is often slow or unreachable. Every read comes from
Room. Every write lands in Room first, syncs later. No screen may block on
network. (The original reason was residential NAT; the server currently runs
on a sprite that suspends when idle and wakes on request, so the same rule
holds for a different reason. The requirement does not depend on which.)
- Private. No public registration. Auth required for all data access. As of
2026-09-12 the server is INTERNET-EXPOSED, so these rules are the only thing
protecting the library — not defence-in-depth behind a home NAT.
- 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.
Auth: EVERY sync starts with POST /api/collections/users/auth-refresh and stores the
new token, BEFORE any push or pull. 401/403 (from refresh or any later call)
-> clear the token only (keep URL + email), stop, report AuthExpired: the UI
says "sign in again" and routes to setup, pre-filled. Offline -> ordinary
failure, token kept. WHY: PocketBase does not reject an expired token, it
treats the request as anonymous, and the rules then 404 on records that
exist — so the 404 rule above is only safe on a freshly-proven token
(2026-09-17: five books hard-deleted locally this way). User tokens last
180 days server-side (migration 1789608448).
Sign-in clears all pull cursors, so every fresh session reconciles fully.
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 EDITIONS API: https://openlibrary.org/isbn/{isbn}.json (302 -> the edition record)
Fallback Google Books: https://www.googleapis.com/books/v1/volumes?q=isbn:{isbn} (keyed)
Merge: prefer whichever has a title; fill blanks from the other.
DO NOT go back to `/api/books?bibkeys=...&jscmd=data`. On 2026-09-20 it was found
answering 404 to EVERY bibkey form for EVERY ISBN — including books Open Library
demonstrably still holds — while /isbn/, /search.json, /api/volumes/brief and
covers.openlibrary.org all served normally. OL's own docs call it the "Legacy Books
API" and say it "may be phased out in the future", and it is absent from their API
index, so treat it as retired. /api/volumes/brief (the "Legacy Partner API") is a
near drop-in for the old response shape and is ALSO legacy — that is why it was not
chosen. See docs/METADATA-SOURCES.md.
The Editions API costs extra requests, by design:
- `authors` are REFERENCES (`{"key": "/authors/OL26320A"}`), not names; each needs
a GET of /authors/{id}.json. `AuthorNameCache` holds resolved names for the
process lifetime — books by one author are scanned in runs off one shelf.
- an edition record may carry NO authors at all (real: 9780898707168), in which
case they are on the work; fall back to /works/{id}.json. Losing this fallback
silently drops the author for such books.
- author/work requests are BEST-EFFORT: the edition request already succeeded, so
a failure there degrades the record, it must never turn Found into Unavailable.
Cover: OL `covers: [id]` -> https://covers.openlibrary.org/b/id/{id}-L.jpg
else GB imageLinks (force https, **zoom=1**, add `w=400`, strip `edge=curl`)
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.
OL's `covers` array uses -1 as a "no cover here" sentinel; take the first POSITIVE id.
DO NOT force `zoom=2` on a Google Books cover (this spec said to, and was wrong).
For a volume Google holds no full preview of, zoom=2 is not a valid rendition and
Google answers 200 with a grey "image not available" PNG rather than a 404 — which
loads successfully, so BookCover's placeholder never fires and the cover pipeline
uploads Google's placeholder to PocketBase as the book's cover. Measured 2026-09-20:
11 of 18 real volumes did this at zoom=2; 0 of 18 at zoom=1 with a width. zoom=0/3/6
are placeholders too, so there is no larger-zoom escape.
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
On the Editions API a 404 IS authoritative NotFound — unlike the legacy endpoint,
which reported a miss as 200 with an empty object and so made every non-2xx a
failure. Every OTHER non-2xx remains Unavailable.
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.
Session expired -> "sign in again" + Sign in button instead of Sync now.
## 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.