Skip to content

fix(archive-metadata): make the OPF identifier writer match what the reader reports - #603

Merged
mbret merged 2 commits into
developfrom
claude/opf-identifier-writer-hardening
Aug 26, 2026
Merged

fix(archive-metadata): make the OPF identifier writer match what the reader reports#603
mbret merged 2 commits into
developfrom
claude/opf-identifier-writer-hardening

Conversation

@mbret

@mbret mbret commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Problem

The OPF writer and the OPF reader disagreed about the same document in five ways. Each is reproducible against an OPF the reader handles fine, and each is fixed here against the writer's existing isbn?: string API — no behaviour change for callers.

Before Result
1 A created <dc:identifier> was tagged setAttribute("opf:scheme", …) Serializes to XML that no longer parses when the package never declares the opf prefix
2 Root matched as tagName === "package", <metadata> via getElementsByTagName A prefixed <opf:package> was rejected outright
3 Clearing the ISBN removed only the element Left <meta refines="#isbn-id"> pointing at nothing
4 Scheme read from attributes only An EPUB 3 ISBN typed by an identifier-type refinement was never found → duplicate ISBN appended, carrying xmlns=""
5 Removed the ISBN element unconditionally <package unique-identifier="pub-id"> left dangling

Bug 4 concretely, patching a document whose ISBN is typed by refinement:

<dc:identifier id="pub-id">9783161484100</dc:identifier>
<meta refines="#pub-id" property="identifier-type" scheme="onix:codelist5">15</meta>
<dc:identifier xmlns="" opf:scheme="ISBN">9780306406157</dc:identifier>   <!-- duplicate -->

The fix

Every one of these is the same root cause: the writer decided which element to edit by re-reading the DOM itself, instead of asking the reader. So it does that now.

const parsed = parseOpf(xml)                      // each identifier verbatim, incl. its `id`
const resolved = resolveArchiveMetadata(parsed)   // the scheme the book advertises

parseOpf already handles prefixed packages, <identifier> left unprefixed under a default DC namespace, all three spellings of the scheme attribute, and unique. resolveArchiveMetadata already resolves EPUB 3 identifier-type refinements through the ONIX codelist 5 table. Pairing the two gives the writer every decision it had been making by hand, from the code that owns those decisions — so bugs 2, 3, 4 and 5 stop being possible rather than being separately patched, and the two sides cannot drift.

The first commit fixes the bugs with the logic written out by hand; the second replaces that with the delegation above. Kept as two commits because the first is where each bug and its test are legible.

What stays genuinely local to the writer:

  • Namespaced creationcreateElementNS(DC_NAMESPACE, …) and setAttributeNS(OPF_NAMESPACE, "opf:scheme", …), so the output can be re-parsed (bug 1). The reader has no say here; it never writes.
  • Locating the DOM node, since parseOpf returns no element handles: by id where the document gives one, falling back to position among the identifier elements otherwise — the only handle an anonymous identifier has, and the order the parser reported it in.
  • Not deleting the unique-identifier element. It is structural, and untagging it instead would buy nothing since the reader infers ISBN from a bare ISBN value.

Why this is separate

Extracted from #586, which replaces the single ISBN field with a list of scheme-tagged identifiers. These five are defects in the writer that exists today — different review criteria from the feature, and independently revertible. #586 is stacked on this branch.

Worth stating plainly: extracting this did not shrink #586 much (−89 insertions), because #586 replaces this writer's entry points rather than building on them. The value here is that five corruption bugs land and are revertible on their own.

Testing

  • 8 new cases, all verified to fail against the previous writer — I ran them against it before fixing
  • 15 existing cases pass unchanged, so nothing current callers rely on moved
  • archive-metadata: 101 tests pass; tsc and biome clean

🤖 Generated with Claude Code

…reader reports

Five ways the writer disagreed with the document it was editing, each
verified against an OPF the reader handles fine:

- A created `<dc:identifier>` was tagged with a plain `opf:scheme` name.
  A package that uses the OPF namespace by default need not declare the
  legacy prefix, and the result serialized to XML that no longer parses.
  Both the element and the attribute are now bound through their
  namespace.

- The root was matched as `tagName === "package"` and `<metadata>` via
  `getElementsByTagName`, so a package that prefixes the OPF namespace
  was rejected outright. Elements are matched by local name now, the
  same way the read side does.

- Clearing the ISBN removed the element but left the `<meta refines>`
  entries pointing at its id.

- The scheme was only read from attributes, so an EPUB 3 identifier
  typed by an `identifier-type` refinement was never found and a second
  ISBN was appended beside it — with `xmlns=""` on it. Refinements are
  read now, through the same ONIX codelist 5 table the reader uses.

- Clearing an ISBN carried by the element `<package unique-identifier>`
  names removed it and left the reference dangling. That element is
  structural, so it is no longer deleted; the reader infers ISBN from a
  bare ISBN value anyway, so there is nothing an untagging could buy.

The eight new cases fail against the previous writer and the fifteen
existing ones are unchanged, so the behaviour every current caller
relies on is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 26, 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 4:36pm
oboku-landing Ready Ready Preview Aug 26, 2026 4:36pm

@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: 8f514f9774

ℹ️ 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".

? []
: listChildrenByLocalName(metadata, "meta").filter(
function refinesElement(meta) {
return meta.getAttribute("refines")?.trim() === `#${id}`

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 Accept refinement references without a leading hash

When an EPUB uses refines="book-id", which the read side accepts by stripping an optional leading #, this exact comparison fails to associate the refinement with the existing identifier. Setting an ISBN then appends a duplicate identifier, while clearing it leaves the ISBN untouched; normalize the reference the same way as the reader before comparing it with id.

Useful? React with 👍 / 👎.

Comment on lines +162 to +165
/**
* The scheme an element states, wherever it states it: EPUB 2 puts it on the
* identifier, EPUB 3 refines it from a sibling `<meta>`.
*/

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 Remove the JSDoc that restates elementScheme

This comment merely narrates that elementScheme obtains a scheme from either the element or its refinement, which is already evident from the function name and implementation; keeping this kind of helper narration conflicts with the repository's explicit no-restatement comment convention.

AGENTS.md reference: AGENTS.md:L9-L13

Useful? React with 👍 / 👎.

… write

The hardening this branch added re-derived from the DOM what
archive-reader already reports: an ONIX codelist 5 table copied from
upstream, the three spellings of the scheme attribute, the EPUB 3
`identifier-type` refinement walk, and the `unique-identifier` lookup.

`parseOpf` reports each identifier verbatim — including the `id` that
addresses its element — and `resolveArchiveMetadata` says which scheme
the book ends up advertising for it. Pairing the two gives the writer
every decision it was making by hand, from the code that owns those
decisions, so the two cannot drift.

Elements are located by `id` where the document gives one, falling back
to position for identifiers authored without one — the only handle they
have, and the order the parser reported them in.

Same 23 tests, unchanged.

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