4c539e6be1
* fix(opds): show 'Open & Read' for publications already in the library
PublicationView only flipped the acquisition button to 'Open & Read'
when the user downloaded the book within the current component
lifecycle — the downloadedBook state started as null on every mount
and was never reconciled against the actual library. So reopening the
detail page (after navigating away in the OPDS browser, or coming back
from the reader) always showed 'Download' / 'Open Access' again and
asked the user to re-download a book they already had.
Two real-world feeds (m.gutenberg.org and ManyBooks) exposed two
distinct failure modes that made naive title/author equality unusable:
(1) Gutenberg's OPDS entries never carry <dc:identifier>. The only work
identifier lives in <atom:id>urn:gutenberg:1342:2</atom:id>, which
foliate-js's opds.js parses into metadata.id — a field
getMetadataHashInfo never reads. So the identifier candidate list
was empty and identifier-overlap matching silently skipped every
book.
(2) Author strings disagree across the boundary: the feed emits
'<author><name>Austen, Jane</name>' (Lastname-first) while the EPUB
inside the same feed ships <dc:creator>Jane Austen</dc:creator>.
Plain string equality (even after normalization) never matched.
Add findExistingBookForPublication in app/opds/utils/findExistingBook.ts
with a layered matcher:
- Pass 1: full metaHash equality (the strongest signal — same fingerprint
the bookService uses internally).
- Pass 2: identifier overlap. collectPublicationIdentifiers() splices
metadata.id into the candidate list alongside metadata.identifier, so
Gutenberg's 'urn:gutenberg:1342:2' feeds into identifierKeys(), which
extracts the '1342' digit-tail (>=3 digits, so the trailing ':2'
version suffix is ignored) and matches the EPUB's
'http://www.gutenberg.org/1342' → '1342'.
- Pass 3: tolerant title + author match. hasAuthorOverlap() does a
token-set fallback: each name is split on whitespace/comma/semicolon,
single-letter tokens (initials) and year-range tokens ('1775-1817')
are dropped, and we require >=2 shared tokens by default. So
'Austen, Jane' ↔ 'Jane Austen' match via {austen, jane} but
'Author A' ↔ 'Author B' don't (both collapse to {author} after
dropping single-letter tokens — we track raw token count to refuse
the single-token shortcut when discriminative parts were filtered).
Genuine mononyms still match on a single shared token because raw
count is 1 on both sides.
OPDSPerson[] is fed through the library's own formatAuthors so the
produced author string matches Book.author byte-for-byte (Intl.ListFormat
output, locale-aware). Soft-deleted books are skipped so removing a copy
locally reverts the button.
Wire it in opds/page.tsx by subscribing to useLibraryStore.library
(rather than the snapshot reads inside handleDownload) and memoizing
existingBookForPublication. Pass it to PublicationView, which now
seeds downloadedBook from the prop and resyncs when the prop changes
(switching publications inside the browser) — but does not clobber a
download success it observed locally before the parent recomputed.
Covered by unit tests for the helper, including the real Gutenberg
URN-in-atom-id case, ':version' suffix on the URN, 'Lastname, Firstname'
vs 'Firstname Lastname', year-range stripping, and the 'Author A' vs
'Author B' non-match.
* fix(opds): dedupe downloads by source url
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>