feat(optimize): replace the ISBN field with a list of scheme-tagged identifiers - #586
feat(optimize): replace the ISBN field with a list of scheme-tagged identifiers#586mbret wants to merge 11 commits into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8777f26d08
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const attribute = | ||
| SCHEME_ATTRIBUTES.find(function isAlreadyCarried(candidate) { | ||
| return element.hasAttribute(candidate) | ||
| }) ?? SCHEME_ATTRIBUTES[0] | ||
|
|
||
| element.setAttribute(attribute, normalizeIdentifierScheme(scheme)) |
There was a problem hiding this comment.
Write a bound OPF scheme attribute
When a valid OPF uses the default OPF namespace but does not declare the legacy opf prefix, adding a tagged identifier reaches this fallback and setAttribute("opf:scheme", ...) creates an attribute with an unbound prefix. XMLSerializer preserves that unbound name, so parsing the resulting package fails and optimizing the EPUB corrupts its OPF; write a namespace-aware attribute with a declaration or use the supported unprefixed scheme form.
Useful? React with 👍 / 👎.
| ): string | undefined => | ||
| identifiers.find(function isGtinBearing({ scheme }) { | ||
| return isIsbnBearingScheme(normalizeIdentifierScheme(scheme)) | ||
| })?.value |
There was a problem hiding this comment.
Avoid silently dropping later GTIN values
For a ComicInfo-only book, the form permits multiple ISBN/GTIN rows and reports ComicInfo.xml as the destination for each, but this find serializes only the first value into the sole <GTIN> field. The confirmation therefore promises updates that disappear after saving and reinspecting the CBZ; enforce a single ISBN-bearing row for this target or explicitly mark later rows as unstorable.
Useful? React with 👍 / 👎.
| for (const leftovers of reusable.values()) { | ||
| for (const element of leftovers) metadata.removeChild(element) |
There was a problem hiding this comment.
Remove refinements with deleted identifiers
When the user removes a non-unique identifier that has an id, this loop deletes only the <dc:identifier> element. Any <meta refines="#that-id"> entries remain in the OPF with a dangling target, so the saved package contains invalid refinement metadata; dependent refinement elements should be removed or reconciled with the deletion.
Useful? React with 👍 / 👎.
f25331b to
d6c095d
Compare
d6c095d to
5554a09
Compare
5554a09 to
03ffe4d
Compare
598bebe to
48b72d8
Compare
…dentifiers
The optimize screen could only edit one ISBN because the writer's patch was
`{ isbn?: string }` end to end, with a hard-coded destination per container.
A second field would have meant a second hard-coded path, and a fixed set of
fields can never round-trip what the reader reports — an arbitrary list of
`{ scheme, value }`, custom schemes included.
The patch is now the identifier list itself: the complete set the archive
should end up with, so an omitted identifier is removed. Schemes are folded
onto their canonical casing through a table keyed by the reader's own scheme
union, so an upstream addition fails to compile until it is handled here.
The OPF writer reconciles `<dc:identifier>` elements by scheme, reusing the
existing element rather than replacing it so its `id` — and any
`<meta refines>` pointing at it — survives an edit. Three latent bugs fell
out along the way:
- the element `<package unique-identifier>` names is now pinned: only
rewritten through the entry flagged `unique`, never removed, so the
reference cannot be left dangling
- root and `<metadata>` are matched by local name, so a prefixed
`<opf:package>` is accepted; the writer previously rejected its own output
- new elements are created with `createElementNS`, so they no longer pick
up `xmlns=""`
ComicInfo keeps its two identifier slots: ISBN/GTIN-bearing values go to
`<GTIN>`, URLs to `<Web>`. Identifiers on a scheme neither field represents
are not written there, which the form warns about and the confirm dialog
spells out per identifier.
Nothing consumes the new schemes yet — reading them back for metadata lookup
is a follow-up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Identifiers" alone does not tell you what belongs in the list. The legend now carries the two schemes people actually come for, and the Google Books option is labelled the way they ask for it — an id — rather than by Google's own "volume" wording. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…arry them ComicInfo's `<Web>` is a list of catalog links, which is a place for every scheme whose catalog addresses a record by URL — so a Google Books id no longer needs an OPF to survive, and a CBZ can carry one at all. The crosswalk runs both ways, because the read side is what makes it usable: the reader reports a `<Web>` entry as a plain `URL` identifier, so without folding it back to the scheme it stands for, an EPUB written to both containers would grow a duplicate row on every inspection. Google Books and Project Gutenberg defer to @prose-reader/metadata-fetcher, which already owns those crosswalks for its own lookups; Open Library and DOI are parsed here only because it has no equivalent yet. A candidate link is returned only once it reads back, so a value its catalog cannot address is reported as unstorable instead of written as a link nothing recovers — which the form now also validates per scheme. What remains without a writable OPF is what genuinely has no field: a custom scheme, or an untagged identifier. Pulling metadata-fetcher in moves the @prose-reader/* pins to ^1.356.0; the delta from 1.352.0 is dependency bumps and a Google Books search fix. It adds 2.4 KB to the web bundle — xmldoc was already there via archive-reader. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…half-filled rows Three fields on one row does not survive the width the optimize panel actually gets: labels truncated to "Cus…" and helper text broke mid-word into "Requi red". Each field now takes its own line inside a bordered group per identifier, so a row reads the same however narrow the panel is. Remove is a labelled button rather than a bare icon, which also gives the pinned unique identifier somewhere to explain itself. Its colour is deliberately neutral: the theme's primary is orange, close enough to error red that a per-row destructive button competed with "add identifier" instead of signalling anything. Red now means a validation error and nothing else. Picking "Custom…" clears the scheme so the field beneath it can be typed into, which the warning was reading as an identifier with no home — announcing that saving would drop an empty-named scheme. It now only counts rows filled in enough to be saved at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…top over-promising ComicInfo
Three ways the writer could damage a package it was asked to fix, from review:
- a package using the OPF namespace by default need not declare the legacy
`opf` prefix, and `setAttribute("opf:scheme", …)` on one that doesn't
serializes an unbound prefix — a document that no longer parses. New
attributes are bound through `setAttributeNS` instead.
- an EPUB 3 identifier can be typed entirely by `<meta refines>`, which the
reader reports as that scheme while the writer saw an untagged element.
Reconciliation would create a replacement and delete the original, orphaning
the refinement of an identifier the user never touched. Element schemes now
read the refinement too, ONIX codelist 5 codes included, and a rewrite
updates whichever place the document states the scheme.
- deleting an identifier left every `<meta refines>` pointing at its id
behind. They go with it.
ComicInfo's `<GTIN>` is one field, so a second ISBN cannot be stored in it
while `<Web>` takes as many links as it is given. Destinations are therefore
decided over the whole list rather than per identifier, which is what the
confirm dialog reads — it promised ComicInfo for a value that then vanished on
save. The warning names the identifiers it would drop rather than their
schemes, since "no field for ISBN" reads as false when the first one fits.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…walk 1.357.0 ships `catalogIdentifierFromUrl`, so recognizing a Google Books or Gutenberg link is no longer this package's job — and the Open Library and DOI parsers it had to carry for want of an upstream equivalent go with it. What stays is the write half a reader has no business owning: building each catalog's official URL, still confirmed by reading it back before it is stored. One tolerance moves upstream with the parsing: an Open Library key must now name its collection (`/books/OL7353617M`) to be written, a bare id not saying whether it addresses an edition, a work or an author. Reading one stays liberal, which is the asymmetry worth having. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…er's 1.358.0 ships the URL builder next to the parser, round-tripped against each other upstream, so the four hosts and path shapes this package was carrying to write links are gone with the module that held them. The ComicInfo writer and the form's validation call `catalogUrlFromIdentifier` and `isMetadataCatalogScheme` directly. That also gives back the spelling tolerance the last revision had to drop: a bare `OL7353617M` is addressable again, because the builder canonicalizes through the parser's own normalizer rather than a template here. Which surfaces something the tolerance would otherwise have cost. A catalog accepts more spellings than it addresses records by, and the two containers can each hold a different one — an OPF's `OL7353617M` beside ComicInfo's `/books/OL7353617M` link is one identifier that would seed as two rows. Rebuilding the link and reading it back yields the catalog's own spelling, so seeding and saving both settle on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The table of the eight canonical spellings is gone, along with the fold built on it. 1.359.0 exports that fold, and it was only ever a copy kept in step with the reader's — the compile-time guard proved they matched, it could not make them one thing. Most call sites also stop normalizing before they ask. `isIsbnBearingScheme` compares loosely now, like the catalog predicate beside it, so handing it a raw scheme is enough. What remains is where a canonical spelling is the answer rather than the question: the scheme written into `opf:scheme`, the form's template lookup, and the value seeded into a row. `identifiers.ts` keeps what is oboku's rather than the vocabulary's — that `"Unknown"` means "emit no scheme attribute", a writer's decision the reader has no opinion on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`targets.ts` had grown into a grab bag: its name promised container targeting, ~35 of its 203 lines delivered that, and the rest held archive seeding, catalog canonicalization, form trimming, change detection and the patch builder. Nothing in a filename said which direction data flowed. Identifier domain logic now sits under `metadata/identifiers/`, one file per job — `fromArchive` and `toArchivePatch` name the two directions, `containers` answers where an identifier can be stored, `canonicalIdentifier` holds the collapsing both directions need, and `schemes` keeps the picker vocabulary. The form's own shape moves to `metadata/formValues.ts`. Behaviour is unchanged; the tests split alongside the modules they cover and share the archive builder through `inspection.fixture.ts`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…suite missed Rebasing onto the writer hardening surfaced three cases its own tests covered that this suite did not: a package that prefixes the OPF namespace, an `<identifier>` left unprefixed under a default DC namespace, and a capitalized `opf:Scheme` attribute. Ported to the identifier-list API; the five that only restated coverage already here are dropped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ve-reader The writer held its own copy of the accepted scheme attribute spellings and of ONIX codelist 5 — the latter byte-for-byte identical to the reader's table. Both describe what a package document can say, so 1.360.0 exposes them and the writer reads them from there. The reader's table also covers the codes this copy never did, since it is now the same table rather than a snapshot of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
48b72d8 to
22e132a
Compare
What
The optimize screen's single ISBN field is replaced by a dynamic list of identifiers. Each row picks a scheme — ISBN, Google Books id, GTIN, Open Library, Project Gutenberg, DOI, URL, No scheme — or Custom… for a free-text scheme, and carries its value, validated per scheme.
Why the field couldn't just gain a sibling
ArchiveMetadataPatchwas{ isbn?: string }end to end, and each container writer had one hard-coded destination. AgoogleBookIdfield would have meant a second hard-coded path, then a third for DOI — and a fixed set of fields can never round-trip what the reader reports, which is an arbitrary{ scheme, value }[]including custom schemes. So the patch became the identifier list.Writer changes
The patch is now the complete set the archive should end up with, not a delta — an identifier the list omits is removed. Schemes fold onto canonical casing via a table keyed off
KnownMetadataIdentifierScheme, so a scheme added upstream fails to compile until it is handled here.The OPF writer reconciles
<dc:identifier>elements by scheme, reusing the existing element of a scheme rather than replacing it, so itsid— and any<meta refines>targeting it — survives an edit. Three latent bugs fell out on the way, each now covered:<package unique-identifier>is pinned: only rewritten through the entry flaggedunique, never removed, so the reference can't be left dangling (previously an ISBN-tagged unique identifier could be deleted out from under the package)<metadata>are matched by local name, so a prefixed<opf:package>is accepted — the writer previously rejected its own output, making a second pass impossiblecreateElementNS, so they no longer pick upxmlns=""ComicInfo carries catalog identifiers as links
ComicInfo has
<GTIN>and<Web>, and<Web>is a list of catalog links — which is a home for every scheme whose catalog addresses a record by URL. So a Google Books id does not need an OPF, and a CBZ can carry one:<GTIN><Web>verbatim<Web>https://books.google.com/books?id=…<Web>https://www.gutenberg.org/ebooks/…<Web>https://openlibrary.org/books/OL…M<Web>https://doi.org/10.…The crosswalk runs both ways, and the read half is what makes it usable: the reader reports a
<Web>entry as a plainURLidentifier, so without folding it back to the scheme it stands for, an EPUB written to both containers would grow a duplicate row on every inspection.Google Books and Project Gutenberg defer to
@prose-reader/metadata-fetcher, which already owns those crosswalks for its own lookups. Open Library and DOI are parsed locally only because it has no equivalent yet — worth moving upstream alongside the other two, at which point those two entries here delete.A candidate link is only used once it reads back, so a value its catalog cannot address (a non-numeric Gutenberg id, say) is reported as unstorable rather than written as a link nothing recovers. The form validates the same rule per scheme, so it can't get that far.
What's left with no writable OPF is what genuinely has no field: a custom scheme, or an untagged identifier. The form warns which, and the confirm dialog lists destinations per identifier.
Round-trip detail
One identifier written to both containers comes back twice under whichever scheme each container has a slot for — an ISBN as
ISBNfrom the OPF andGTINfrom ComicInfo, a Google Books id asGoogleBooksand as aURL. Seeding collapses both cases, so a saved book doesn't grow duplicate rows.No new dependency
An earlier revision of this PR reached the URL crosswalk through
@prose-reader/metadata-fetcher. That crosswalk now lives inarchive-readeritself (prose-reader#326, shipped in 1.357.0 and picked up by #599), so this reads catalog links throughcatalogIdentifierFromUrland adds no dependency at all — the local Open Library and DOI parsers it had to carry are gone with it.What stays here is the write half, which a reader has no business owning: building each catalog's official URL. Every candidate is confirmed by reading it back before it is stored, so a value its catalog cannot express is reported as unstorable rather than written as a link nothing recovers.
One tolerance moved upstream with the parsing: an Open Library key must name its collection (
/books/OL7353617M) to be written, since a bare id does not say whether it addresses an edition, a work or an author. Reading one stays liberal. The form validates against the same rule, so it surfaces as a field error rather than a silent drop.Scope
Write path only. Nothing consumes the new schemes yet — #587 does that for Google Books, and once this lands it should also use
identifierFromCatalogUrlso a<Web>link is recognised, not just a bareGoogleBooksscheme.Verification
apps/web— 59 files / 319 tests (9MetadataForminteraction tests, 12targetstests covering seeding, both dedupe cases and per-scheme destinations against the real reader)packages/archive-metadata— 11 files / 128 tests (21 dedicated crosswalk tests both directions, OPF reconciliation, unique-identifier pinning, namespace handling, ComicInfo GTIN/Web)tscclean inapps/web,apps/api,packages/archive-metadata; biome clean;vite buildsucceeds🤖 Generated with Claude Code