Skip to content

feat(optimize): replace the ISBN field with a list of scheme-tagged identifiers - #586

Open
mbret wants to merge 11 commits into
developfrom
claude/optimize-identifier-list
Open

feat(optimize): replace the ISBN field with a list of scheme-tagged identifiers#586
mbret wants to merge 11 commits into
developfrom
claude/optimize-identifier-list

Conversation

@mbret

@mbret mbret commented Aug 23, 2026

Copy link
Copy Markdown
Owner

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

ArchiveMetadataPatch was { isbn?: string } end to end, and each container writer had one hard-coded destination. A googleBookId field 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 its id — and any <meta refines> targeting it — survives an edit. Three latent bugs fell out on the way, each now covered:

  • the element referenced by <package unique-identifier> is pinned: only rewritten through the entry flagged unique, never removed, so the reference can't be left dangling (previously an ISBN-tagged unique identifier could be deleted out from under the package)
  • root and <metadata> are matched by local name, so a prefixed <opf:package> is accepted — the writer previously rejected its own output, making a second pass impossible
  • new elements use createElementNS, so they no longer pick up xmlns=""

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:

scheme stored as
ISBN, GTIN <GTIN>
URL <Web> verbatim
Google Books <Web>https://books.google.com/books?id=…
Project Gutenberg <Web>https://www.gutenberg.org/ebooks/…
Open Library <Web>https://openlibrary.org/books/OL…M
DOI <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 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 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 ISBN from the OPF and GTIN from ComicInfo, a Google Books id as GoogleBooks and as a URL. 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 in archive-reader itself (prose-reader#326, shipped in 1.357.0 and picked up by #599), so this reads catalog links through catalogIdentifierFromUrl and 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 identifierFromCatalogUrl so a <Web> link is recognised, not just a bare GoogleBooks scheme.

Verification

  • apps/web — 59 files / 319 tests (9 MetadataForm interaction tests, 12 targets tests 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)
  • tsc clean in apps/web, apps/api, packages/archive-metadata; biome clean; vite build succeeds

🤖 Generated with Claude Code

@vercel

vercel Bot commented Aug 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
oboku Ready Ready Preview Aug 26, 2026 11:01pm
oboku-landing Ready Ready Preview Aug 26, 2026 11:01pm

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +137 to +142
const attribute =
SCHEME_ATTRIBUTES.find(function isAlreadyCarried(candidate) {
return element.hasAttribute(candidate)
}) ?? SCHEME_ATTRIBUTES[0]

element.setAttribute(attribute, normalizeIdentifierScheme(scheme))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread packages/archive-metadata/src/opf/write.ts Outdated
Comment on lines +84 to +87
): string | undefined =>
identifiers.find(function isGtinBearing({ scheme }) {
return isIsbnBearingScheme(normalizeIdentifierScheme(scheme))
})?.value

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment on lines +218 to +219
for (const leftovers of reusable.values()) {
for (const element of leftovers) metadata.removeChild(element)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@mbret
mbret force-pushed the claude/optimize-identifier-list branch from f25331b to d6c095d Compare August 25, 2026 21:53
@mbret
mbret force-pushed the claude/optimize-identifier-list branch from d6c095d to 5554a09 Compare August 26, 2026 10:35
@mbret
mbret force-pushed the claude/optimize-identifier-list branch from 5554a09 to 03ffe4d Compare August 26, 2026 13:05
@mbret
mbret force-pushed the claude/optimize-identifier-list branch from 598bebe to 48b72d8 Compare August 26, 2026 16:23
@mbret
mbret changed the base branch from develop to claude/opf-identifier-writer-hardening August 26, 2026 16:23
Base automatically changed from claude/opf-identifier-writer-hardening to develop August 26, 2026 21:18
mbret and others added 11 commits August 27, 2026 00:55
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant