Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWoivrRUEmJLFFwbsrGkqQ
253 lines
16 KiB
Plaintext
253 lines
16 KiB
Plaintext
You are implementing ONE feature in the Bookshelf Android app at ~/bookshelf.
|
|
|
|
READ FIRST, in this order:
|
|
1. ~/bookshelf/docs/SPEC.md — the authoritative contract, especially "Non-negotiables",
|
|
"Book metadata lookup" (the three-way outcome rule), "Design language", "Screens".
|
|
Do not contradict it and do not edit it.
|
|
2. ~/bookshelf/docs/METADATA-SOURCES.md — "Measured again 2026-09-09" and "The key
|
|
landed". Open Library is slow and long-tailed (median ~4s, max 22s) and fails
|
|
~13% of the time with fast TLS resets; Google Books is keyed at 1,000 req/day.
|
|
3. `logs/K-manual.summary` and `git show --stat HEAD` — the PREVIOUS wave (just
|
|
committed) added a manual "Add a book by hand" screen (`ui/add/`) with a route
|
|
that takes a pre-filled title/ISBN and a `BookDraft`. You will build on it.
|
|
Read its code, not just the report.
|
|
4. `data/metadata/` in full (it is small), `ui/library/`, `ui/nav/`.
|
|
|
|
## The feature
|
|
|
|
Today the library search bar filters only the user's own books, as they type. Extend
|
|
it to also search Open Library and Google Books, so a user can find and add a book by
|
|
title and/or author — including "book name + author name" when the title is common.
|
|
|
|
The user's design, and the decisions they have already made — do not relitigate:
|
|
1. User types a query into the existing library search bar.
|
|
2. Own library filters AS THEY TYPE (instant, offline, as today).
|
|
Online search runs ONLY ON SUBMIT (IME Search action, or an explicit button) —
|
|
never per keystroke. Decided by the user: per-keystroke would burn the 1,000/day
|
|
Google Books quota and results would thrash at OL's latency.
|
|
3. Results show an "In your library" section on top and online results below.
|
|
4. Results are DEDUPLICATED across the three sources (local, OL, GB): a book appears
|
|
once. "In your library" means the user owns SOME edition of that book — decided
|
|
by the user; an online hit that is a different edition of an owned book belongs
|
|
in the library section, not the online one.
|
|
5. Tapping an online result opens a save sheet (same idea as the scan screen's Found
|
|
sheet: book summary + shelf picker + Save / Skip). ISBN is filled in ONLY when it
|
|
is unambiguous — see "ISBN rule" below.
|
|
6. At the bottom of the online results: "Can't find it? Enter it by hand" -> the
|
|
previous wave's add screen, pre-filled with the query as the title.
|
|
|
|
## Verified API facts (orchestrator tested these live on 2026-09-13 — trust them)
|
|
|
|
Both APIs treat a free-text `q` as matching across title AND author, so
|
|
"hittite warrior williamson" and "the hobbit tolkien" both return the right book first.
|
|
|
|
**Open Library** — `https://openlibrary.org/search.json?q=<q>&limit=20&fields=<list>`
|
|
- Results are WORKS (`key` = "/works/OL2028038W"), not editions.
|
|
- **`isbn` is NOT in the default response. You MUST pass `fields=`** or you will get
|
|
no ISBNs at all. Use:
|
|
`fields=key,title,subtitle,author_name,first_publish_year,edition_count,cover_i,isbn,publisher,number_of_pages_median`
|
|
- `isbn` is a flat list mixing ISBN-10 and ISBN-13 of ALL editions, e.g.
|
|
["1883937388","9781883937386"] (one edition) or dozens for a popular work. It may
|
|
be absent (pre-ISBN books). `cover_i` may be null.
|
|
- Cover by id: `https://covers.openlibrary.org/b/id/{cover_i}-M.jpg`. That is a cover
|
|
the source REPORTED, so it is legitimate under SPEC's cover rule. Never synthesize a
|
|
by-ISBN cover URL.
|
|
- Observed latency 3-6.5s. Real example of a near-duplicate: the same book returned as
|
|
two works, authors "Joanne S. Williamson" and "Joanne Small Williamson".
|
|
**Google Books** — `https://www.googleapis.com/books/v1/volumes?q=<q>&maxResults=20&printType=books&key=<key>`
|
|
- Results are EDITIONS (volumes). `volumeInfo.industryIdentifiers` may be null.
|
|
`imageLinks` may be absent. `publishedDate` can be garbage ("101-01-01" observed).
|
|
- Popular titles return several editions of the same work ("The Hobbit" x5, author
|
|
strings "J.R.R. Tolkien", "J. R. R. Tolkien", "John Ronald Reuel Tolkien").
|
|
- Observed latency ~0.7s. Same key and same redaction concerns as `GoogleBooksClient`.
|
|
- Returns loosely-related noise after the real hits. Do not try to filter it; keep
|
|
source order.
|
|
|
|
## What to build
|
|
|
|
### 1. Search clients (`data/metadata/`)
|
|
|
|
A `search(query): SearchSourceResult` per source — add to the existing clients or add
|
|
sibling classes, your call, but REUSE their machinery rather than copying it:
|
|
- the same `OkHttpClient`, `withRetry` / `RetryPolicy`, `FailureKind`,
|
|
`SourceResult.fromException` / `fromHttpCode`, and for GB the key handling,
|
|
`requestUrl`-style pure URL construction, and `redact`.
|
|
- three-way per source, never a bare null: Found(list) / NotFound (authoritative
|
|
empty) / Failed(kind, reason). SPEC: a source that could not be reached must never
|
|
be presented as "no results".
|
|
- the wave-8 exception safety, exactly: never throws, `Throwable` -> `UNEXPECTED`
|
|
with the exception class name only, `CancellationException` RETHROWN FIRST.
|
|
- URL-encode the query via `HttpUrl.Builder` (not string concatenation).
|
|
- GB cover URLs through the existing https+zoom normalization in `GoogleBooksDtos.kt`
|
|
(it is `private` today; make it `internal` rather than duplicating it).
|
|
- Wire into `AppContainer` (one or two lines; you may edit that file for this).
|
|
|
|
### 2. Local matching
|
|
|
|
The current `BookDao.search` matches the whole string as one LIKE, so "hobbit tolkien"
|
|
matches nothing. Replace it for this screen with token matching: normalize the query
|
|
and the book (lowercase, strip diacritics, treat punctuation as space so "J.R.R." ~
|
|
"j r r"), split the query on whitespace, and require EVERY token to appear in at least
|
|
one of title, subtitle, authors, isbn13, isbn10. A home library is hundreds to low
|
|
thousands of books, so a pure Kotlin matcher over the already-observed book list is
|
|
fine and far more testable than SQL — prefer that. If you do change `BookDao.search`
|
|
instead, its Robolectric DAO tests must be updated, not deleted.
|
|
|
|
### 3. Merge + dedupe (pure, no Android, heavily tested)
|
|
|
|
A pure object (e.g. `SearchResultMerger`) taking the local books and each source's
|
|
result list, returning something like
|
|
`SearchResults(inLibrary: List<BookEntity>, online: List<OnlineBook>)`.
|
|
|
|
Identity — two records are the same book if EITHER:
|
|
a) they share any ISBN after normalizing everything to ISBN-13
|
|
(`IsbnUtils.toIsbn13`) — this is what joins a GB edition to its OL work, or
|
|
b) normalized title AND first author's surname match. Normalized title: lowercase,
|
|
strip diacritics and punctuation, drop a leading "the/a/an", drop anything after
|
|
the first ':' (subtitles). Surname: last alphabetic token of the first author.
|
|
("Joanne S. Williamson" ~ "Joanne Small Williamson"; "J.R.R. Tolkien" ~
|
|
"John Ronald Reuel Tolkien".)
|
|
Identity is transitive: group with union-find (or equivalent), not pairwise.
|
|
|
|
Placement:
|
|
- Any group containing a local book -> "In your library", shown as the local book(s).
|
|
This includes local books reached ONLY via an online hit (the online record matched
|
|
by ISBN/title but the local tokens did not).
|
|
- Every other group -> ONE `OnlineBook` in the online section. Order by the group's
|
|
best rank in either source (min position), so relevance is preserved and neither
|
|
source is buried under the other.
|
|
- Field fill for a merged OnlineBook: title/authors from OL if present (work-level,
|
|
cleaner), else GB; year from OL `first_publish_year` else the year GB's
|
|
`publishedDate` starts with IF it is a plausible 4-digit year; cover from OL
|
|
`cover_i` else GB thumbnail; remember which sources contributed.
|
|
|
|
**ISBN rule** (decided by the user — implement exactly):
|
|
- If the group has an OL work whose ISBNs normalize to EXACTLY ONE distinct ISBN-13
|
|
-> use it. (A single-edition work, or one whose only ISBN-bearing edition is that
|
|
one — ISBN-10 and ISBN-13 of the same edition are ONE ISBN.)
|
|
- Else if the group's GB volumes carry exactly one distinct ISBN-13 -> use it (GB
|
|
returned that specific edition for this query).
|
|
- Otherwise -> no ISBN. Never guess among several.
|
|
|
|
### 4. Library screen UI (`ui/library/`)
|
|
|
|
- IME action Search on the existing field triggers the online search. Because an IME
|
|
action is invisible, ALSO show, whenever the query is non-blank and no online search
|
|
has run for it, a clearly tappable row below the local results:
|
|
"Search Open Library & Google Books for "<query>"". Minimum query 2 characters.
|
|
- Sections: "In your library" (existing card style) then "Online" results as rows
|
|
(small cover, title, authors, year). If there are no local matches, say so in one
|
|
quiet line rather than hiding the section header — the user must be able to tell
|
|
"not owned" from "didn't look".
|
|
- Online states: loading (with the local section fully usable meanwhile — SPEC: no
|
|
screen may block on network); results; authoritative no-results; per-source failure
|
|
as a quiet line naming the source and its `reason` (e.g. "Google Books couldn't be
|
|
reached — tls connection reset"), with Retry, while still showing the OTHER source's
|
|
results; both failed -> a retry affordance that does NOT say "no results".
|
|
- Editing the query after a submit clears the online section (and cancels any in-flight
|
|
search): stale results for a different query are misleading.
|
|
- Shelf/bookcase filter keeps applying to the local section only.
|
|
- Tap an online result -> save sheet (bottom sheet on the library screen): cover,
|
|
title, authors, year, ISBN if the rule gave one, shelf picker (reuse
|
|
`ui/components/ShelfPickerSheet.kt` / `ShelfPicker` the way the scan sheet does,
|
|
remembered last shelf included), buttons Save / "Edit details" / Skip.
|
|
* Save -> `BookRepository.createBook` with everything known, `coverSourceUrl` =
|
|
the cover URL (the repository already downloads it best-effort). Guard it: catch
|
|
Throwable, rethrow CancellationException, show an error, keep the sheet. No
|
|
double-tap double-save. After a save the book now appears in "In your library"
|
|
by itself (Room flow) — verify that in a test rather than hand-moving it.
|
|
* If an ISBN is known, ENRICH in the background: call the existing
|
|
`MetadataRepository.lookup(isbn)` when the sheet opens and fill blanks
|
|
(publisher, pages, description, better cover) via the same fill-blanks idea as
|
|
`MetadataMerger` when it lands. Save must NEVER wait for this — if the user saves
|
|
first, save what is known. Cancel it when the sheet closes.
|
|
* "Edit details" -> the previous wave's add screen, pre-filled with the draft
|
|
(title, subtitle, authors, publisher, year, pages, ISBN, description, cover URL if
|
|
the draft supports it). If the add route cannot carry the full draft, extend it
|
|
in `ui/add/` + `ui/nav/` minimally and consistently with how that wave built it
|
|
(e.g. a serialized, URL-encoded draft argument, or a small draft holder). Say
|
|
which you chose.
|
|
- Keep the online search state in the ViewModel so it survives rotation.
|
|
- Split stateless content composables so Paparazzi renders the REAL UI (see
|
|
HANDOFF.md wave-6 "soft spots": a hand-rolled lookalike snapshot is not coverage).
|
|
|
|
## Constraints — these are hard
|
|
|
|
- Kotlin. Match the surrounding code's style, naming and comment density — this
|
|
codebase comments the evidence for a decision, not a restatement of the code.
|
|
- You MAY touch: `data/metadata/`, `ui/library/`, `ui/add/`, `ui/nav/`,
|
|
`ui/components/` (sharing only), `AppContainer.kt` (wiring only), and if you choose
|
|
the SQL route, `data/local/BookDao.kt` + `data/repo/BookRepository.kt` search
|
|
methods only. Plus tests. Nothing else.
|
|
- DO NOT touch any build file. Every library you need is already declared
|
|
(OkHttp, kotlinx-serialization, coil3, Room, Compose). If you think you need
|
|
something else, STOP and say so.
|
|
- DO NOT touch `data/remote`, `data/prefs`, sync code, `server/`, `docs/`.
|
|
- DO NOT read, print or grep `app/local.properties`; never put a real API key in code,
|
|
tests or your report. Tests use "test-key-123". Do NOT make live network calls from
|
|
unit tests (an opt-in live test gated on an env var like the existing
|
|
`LiveMetadataLookupTest` is welcome but not required).
|
|
- DO NOT git commit, add or push.
|
|
- Build with `./tasks/gw <task>` — NEVER `./gradlew`.
|
|
- RUN BUILDS IN THE FOREGROUND. Never background a build and end your turn promising a
|
|
later report — you will never get to give it. Previous workers did exactly this.
|
|
- No emulator. You cannot run the app. Do not claim you did.
|
|
|
|
## Definition of done
|
|
|
|
Run in the foreground and paste real output tails:
|
|
./tasks/gw assembleDebug -> exit 0
|
|
./tasks/gw testDebugUnitTest -> exit 0. Count MUST GO UP from whatever the
|
|
previous wave left (sum TEST-*.xml under
|
|
app/app/build/test-results/testDebugUnitTest/
|
|
BEFORE you change anything, and again at the end).
|
|
./tasks/gw recordPaparazziDebug -> exit 0
|
|
./tasks/gw verifyPaparazziDebug -> exit 0
|
|
|
|
Required tests with REAL assertions:
|
|
- OL parse from a fixture shaped like the real responses above (mixed ISBN-10/13,
|
|
missing isbn, null cover_i); GB parse with null industryIdentifiers and no
|
|
imageLinks; the OL request URL contains `fields=` including `isbn`; the query is
|
|
URL-encoded ("a&b c" -> not a broken URL); GB URL carries the key only when
|
|
non-blank; GB failure reasons are redacted.
|
|
- Each search client: HTTP error -> Failed not NotFound; empty docs -> NotFound;
|
|
throwing interceptor -> Failed(UNEXPECTED), class name only; CancellationException
|
|
propagates.
|
|
- Local matcher: "hobbit tolkien" matches The Hobbit by J.R.R. Tolkien;
|
|
"jrr tolkien" and "tolkien hobbit" too; diacritics ("bronte" ~ "Brontë");
|
|
a query token matching nothing excludes the book.
|
|
- Merger: GB edition joins OL work by shared ISBN; "Joanne S. Williamson" and
|
|
"Joanne Small Williamson" works collapse; "The Hobbit" x5 GB editions + OL work ->
|
|
ONE online entry; an online hit of a DIFFERENT edition of an owned book lands in
|
|
inLibrary, not online; transitivity (A~B by ISBN, B~C by title -> one group);
|
|
ranking interleaves sources by best rank; distinct books with the same title but
|
|
different authors stay separate.
|
|
- ISBN rule, each branch: OL ["1883937388","9781883937386"] -> that ISBN-13; OL with
|
|
many ISBNs + one GB ISBN -> GB's; several GB ISBNs and no single OL ISBN -> null;
|
|
OL with no isbn field -> falls to GB or null.
|
|
- ViewModel: typing does NOT trigger an online search; submit does; editing the query
|
|
clears online results and cancels the in-flight search; one source failing still
|
|
shows the other's results with a failure line; both failing is not reported as
|
|
"no results"; save maps fields to createBook; a throwing save keeps the sheet with
|
|
an error and does not throw.
|
|
- Paparazzi light + dark: search with both sections populated; online loading while
|
|
local results show; one source failed; the online-result save sheet.
|
|
|
|
Grep the build output for Kotlin warnings on files you touched; "Check for instance is
|
|
always 'false'" in particular is a real bug signal on this project, not noise.
|
|
|
|
## Report
|
|
|
|
End with a plain report:
|
|
- what you changed, file by file
|
|
- verbatim tails of each gradle command
|
|
- test count before and after, summed from the XML
|
|
- how the draft reaches the add screen, and any change you made to the previous
|
|
wave's route
|
|
- your own judgement: where will dedupe get it WRONG on a real shelf (false merges
|
|
and missed merges)? Name concrete cases. That is worth more than a clean report.
|
|
- anything you could NOT do or did differently, and why
|
|
- anything that looks wrong but was out of scope
|
|
|
|
Be honest. Workers here have over-claimed before, and everything is re-verified.
|