feat(metadata): add hardcover edition selection and refresh pinning (#936)

* feat(metadata): add hardcover edition selection and refresh pinning

Mirrors the StoryGraph edition picker for Hardcover, but backed by real
GraphQL edition data instead of scraping. An edition can now be chosen
either from the Hardcover settings page's new "Linked books" section or
from the metadata downloader/refresh flow, and once chosen it pins
future automatic metadata refreshes to that exact edition instead of
silently re-deriving one by ISBN every time.

book_metadata.hardcover_edition_id is shared across all users, while
hardcover_book_state.hardcover_edition_id is scoped per (user, book).
Cross-writing between them is one-directional and non-destructive per
flow: the settings page always updates the acting user's own sync
state and only back-fills the shared field if it is empty; the
metadata downloader always updates the shared field and only updates
the acting user's own sync state if they already have one. The
propagation uses the existing achievement event bus rather than a
direct module import, since HardcoverModule already depends on
BookModule.

* fix(metadata): address hardcover edition selection review findings

Responds to CodeRabbit review on PR #936:

- Surface Hardcover edition-lookup failures as errors instead of an
  empty list, so a provider outage cannot be mistaken for "no
  editions". Updates the client to throw and the UI to show a
  retryable error state.
- Validate that a manually-picked edition id actually belongs to the
  book's matched Hardcover book before persisting it, rejecting
  mismatches with a 400 instead of trusting an unvalidated client
  input.
- Require an exact safe-integer string before parsing a propagated
  hardcoverEditionId, so a value like "200abc" cannot be silently
  truncated into a valid-looking edition id.
- Add durationMs to the edition-propagation failure log per the
  project's logging conventions.
- Honor a pinned edition in the ISBN search() path too, not only in
  lookupById(), so a refresh that falls back to search still returns
  the previously chosen edition when it's present.

* test(metadata): strengthen hardcover edition-pin fallback assertions

The fallback tests for search() and lookupById() only used a
single-edition fixture, so they would have passed even if the
edition-pin filtering never ran. Use two-edition fixtures and assert
on which edition is actually selected instead.

* i18n(hardcover): use i18n keys for Linked books strings

HardcoverLinkedBooks.vue hardcoded its UI text instead of using
useI18n like its sibling components, breaking the ability to
translate the Hardcover settings page and non-English catalogs.

* fix(hardcover): harden edition selection and bound the linked-book list

Validate a picked edition against the book it belongs to rather than against a
capped, unordered list. Editions past the cap stay selectable, and two calls
can no longer disagree about which window they saw.

- Order the edition query by id and fetch one row past the cap, so truncation
  is detectable instead of silent. Verified against the live Hardcover API:
  both "The Name of the Wind" and "Pride and Prejudice" exceed the 50 cap.
- Report truncation for the linked-book list and the edition list, and say so
  in the UI rather than presenting a partial list as complete.
- Bound and order the linked-book query. It backed a settings list with the
  unbounded sync query; sync callers keep their previous unordered behaviour.
- Throw for "not connected" and "not matched" instead of returning an empty
  list, which the UI could not tell apart from a genuinely empty catalogue.
- Drop release_year from the edition query. Hardcover never populates it, so
  it was dead weight in the payload.
- Add a tuned --success token. The status pill hardcoded text-green-600, which
  is untuned in dark mode; the light value is set for 4.5:1 on card and page.
- Expose row expansion with aria-expanded and aria-controls, announce load
  failures through a live region, and keep the edition list open after a
  switch so the refreshed marker lands where the user is looking.

---------

Co-authored-by: neon <18559658+neonsolstice@users.noreply.github.com>
This commit is contained in:
Dukko
2026-08-08 23:35:53 -05:00
committed by GitHub
parent 54cb7dbda0
commit 9a349311cf
30 changed files with 1610 additions and 16 deletions
+44
View File
@@ -135,3 +135,47 @@ export interface HardcoverImportApplyResult extends HardcoverImportSummary {
progressApplied: number;
failed: number;
}
export interface HardcoverEdition {
id: number;
title: string | null;
format: string;
pages: number | null;
isbn10: string | null;
isbn13: string | null;
publisher: string | null;
language: string | null;
publishedDate: string | null;
coverUrl: string | null;
}
export interface HardcoverLinkedBook {
bookId: number;
title: string | null;
authorName: string | null;
hardcoverBookId: number | null;
hardcoverEditionId: number | null;
matchMethod: string | null;
matchError: string | null;
}
// Both list results are capped server-side; `truncated` lets the UI say so instead of
// presenting a partial list as the whole thing.
export interface HardcoverLinkedBooksResult {
books: HardcoverLinkedBook[];
truncated: boolean;
}
export interface HardcoverEditionsResult {
editions: HardcoverEdition[];
truncated: boolean;
}
export interface SetHardcoverEditionPayload {
editionId: number;
}
// Every failure path throws, so a resolved response always means the edition was applied.
export interface SetHardcoverEditionResult {
success: boolean;
}