Skip to content

fix: integration wiring — warnings, renamed seals, v0.1.x repair, ambiguous codes - #6

Merged
arthursoares merged 8 commits into
developfrom
fix/integration-wiring
Jul 26, 2026
Merged

arthursoares merged 8 commits into
developfrom
fix/integration-wiring

Conversation

@arthursoares

Copy link
Copy Markdown
Owner

Final integration round wiring the deferred cross-PR pieces now that #1–#5 are merged:

  • Fix warnings are visible: FrameFix.fixed carries warnings (stale/foreign .bak cases); amber badge beside the FIXED seal, detail text, and "(m with warnings)" in the run summary.
  • Renamed fixed files keep their seal: scan uses JournalStore.sealedURLs(in:); renamed frames show "fixed as " and revert stays reachable.
  • Files fixed by v0.1.x are repairable: frames claiming one of the user's lenses resolve via UserLens.claiming, and the re-fix predicate is now field-based (LensWrite.differs) — a v0.1.x fix with an empty digest or the borrowed lens's aux:LensInfo reads as a difference and re-enters the fix set.
  • Ambiguous codes have an honest UI: banner entries per candidate pair with both pit patterns, per-candidate Map Code menus, and a "Mark These Frames" path when both candidates are already mapped to different lenses.
  • Reader unescapes XMP entities (release blocker found in review): the writer XML-escapes on serialize, the reader never unescaped, so any lens name containing &/< re-flagged forever after a successful fix. Verified settled on a real frame through the full Fixer path, for profiled and hand-typed lenses.

Known follow-up (backlog): removing the orphan crs:LensProfileSetup="Custom" that v0.1.x left on no-profile fixes needs a property-removal path in the writer; flagging it without one would loop. Reasoning recorded in differs' doc comment.

158 tests, 0 failures (0 skipped with the real-DNG fixture + exiftool available).

Implemented and reviewed with Opus 5.

🤖 Generated with Claude Code

arthursoares and others added 8 commits July 26, 2026 19:37
Fixer.fix has returned a FixOutcome with warnings since the backup work
landed — a .bak that is no longer a copy of the file it sits beside, kept
rather than overwritten because the oldest backup is the pristine one.
ScanView threw the outcome away, so the one case where Uncoded knows the
user's safety net is stale was the one case it kept quiet about.

FrameFix.fixed now carries them. The frame keeps its FIXED seal and its
revert — the write landed, and only the backup is in question — and gets
a small amber tick beside the seal, the warning underneath, and the full
text in the tooltip: the same shape as revertRefused, which is the other
"this succeeded, but" state. The run summary counts them apart, so
"12 fixed (2 with warnings)" never reads as a failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A scan sealed frames by comparing journal paths against file paths, which
is exactly the comparison that fails for the most common thing that
happens to a fixed file: Lightroom renames it on import. The frame came
back looking untouched, its undo unreachable, and fixing it again would
have appended a second copy of the metadata.

JournalStore.sealedURLs already answers this properly — path first, then
content for anything unmatched, and it declines to seal a copy or an
ambiguous claim, because a seal promises an undo and only one file can
have it. Wire it in place of fixedPaths(), and keep the filename it
recorded: the frame's tooltip now reads "fixed as L1000123.DNG, renamed
since", which is the difference between a mystery and a fact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Once a fix lands, the file stops claiming a Leica lens and starts claiming
the user's. That matches nothing in the 6-bit table, so on the next scan
the frame fell out of the mapped set entirely and sat there as
unrecognised — which is why the files v0.1.x fixed with an empty
crs:LensProfileDigest had no route back: they had to be recognised before
they could be repaired.

UserLens.claiming is now the scan plan's last resort, consulted only for
frames the code table had nothing at all to say about, so it can neither
shadow a marked override nor put a thumb on an ambiguity. Such a frame
resolves to its lens — not as an override, and not as a coded frame — and
is then treated exactly like a sealed one for re-fix purposes.

Which means the re-fix test had to grow up: comparing the claimed name
cannot see a file that names the right lens beside a missing digest, and
that is precisely the file this is for. LensWrite.differs compares the
fields a write actually sets (skipping empty ones, since xmpProperties
skips those too — otherwise a hand-typed lens would ask to be rewritten
forever). Tested both ways round, including on a real M11 packet: a frame
Uncoded just fixed must never come back asking to be fixed again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Some camera lens names fit more than one row of the 6-bit table — the
generations share a name, and the M11 writes no numeric code tag to
separate them. matchCandidates has always returned both, and matchedCode
has always returned nil for that case, which meant the frame rendered as
if it wore no code at all: absent from the "coded" count, absent from the
unclaimed-code banner, no way to map either code from its context menu.
A frame that is coded, and whose ambiguity is the one thing Uncoded knows
about it, said nothing.

It now says the one honest thing there is to say — here are the two
codes, pick the one on your lens — through the patterns that already
exist. The banner keys on the candidate list rather than a single code,
prints both pit patterns with an "or" between, and offers each candidate
through a menu instead of choosing. The context menu lists both "Map Code
… to a Lens…" items. The tooltip names both codes and their Leica lenses
and says why the app won't guess. And the frame counts as coded, because
it is: which code is the open question, not whether.

No new visual language, and still no guessing — mapping either code is
the user's call, and one of them is engraved on the lens in their hand.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ren't

The writer serializes the packet through XMLDocument, so a lens called
"Cooke & Sons" goes to disk as "Cooke &amp; Sons" — and it deliberately
emits &#xA;/&#xD;/&#x9; for whitespace, because attribute-value
normalization eats those characters written literally. xmpValue handed
all of that straight back.

Every consumer therefore saw a value that was not the value written. The
tooltip printed the escapes, which is cosmetic; LensWrite.differs
compared them against the unescaped name, which is not. Any lens or
profile name containing & < > " ' read as different from itself the
moment it was written: the frame carried a FIXED seal and a RE-FIX row at
once, every Fix run rewrote it byte-identically, and every run left
another journal record behind. Nothing corrupted, but nothing that ever
settled either.

xmpValue now unescapes — the five named entities and numeric character
references in both bases — in one left-to-right pass, so the escaped text
&amp;lt; stays &lt; instead of decoding twice into markup that was never
in the file. Anything that is not a reference we recognise survives
verbatim: an XMP packet is a file on disk like any other, and inventing a
character for &frac12; would be worse than printing it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
differs compared the name and the profile fields, and a lens with no Adobe
profile has no profile fields — so it returned early and such a frame
could never look like it needed anything. But v0.1.1 wrote only the EXIF
LensSpecification and left the camera's aux:LensInfo alone, describing the
borrowed Leica lens at its maximum aperture rather than the real lens's.
That copy is the one Lightroom shows, and for a lens with no profile it is
the whole of what a repair has to put right.

So the doc comment's promise — "only the fields this write actually sets
are compared" — is now true of the numbers as well: the focal length and
aperture in EXIF, and their XMP twin. aux:LensInfo compares exactly, being
a string either way. LensSpecification compares through the reader's own
rendering, shared rather than copied so the two cannot drift, and asks
what the writer would *store*: it puts every rational over a denominator
of 1000, so comparing a fourth decimal place against the rounded one on
disk would leave that lens asking to be rewritten for ever.

Not done, deliberately: the orphan crs:LensProfileSetup="Custom" v0.1.1
left beside no profile at all. It deserves to count as a difference, but
the writer only ever sets properties — it cannot clear one — so a fix
would leave it exactly where it found it and the frame would ask to be
rewritten on every scan, which is the failure this whole round is about.
Repairing those needs the writer to learn removal first; the reasoning is
written down where the check would have gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mapped

A frame whose lens name fits two codes resolves to nothing when those two
codes point at two different lenses — the app has no business picking one.
It landed in the unclaimed-code banner all the same, which told the user
the codes were "not claimed by any of your lenses yet" and offered to map
one. Both false, and the offer was a dead end: mapping a code that is
already mapped changes nothing about this frame.

The banner now knows the difference. Reaching the unclaimed set with any
candidate mapped can only mean two of them are mapped to different lenses
— a single mapped candidate would have resolved the frame — so the group
is marked contested and gets its own sentence and its own way out: mark
these frames, which raises the Assign Lens menu that already exists for a
marked set. Two clicks to the only answer there is, instead of a button
that does nothing.

The frame's own line said "none mapped", which was the same lie in
miniature; it now says what is true either way — that Uncoded won't guess
— and the tooltip mentions assigning as well as mapping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…l fix

The unit-level round trips prove differs against synthetic packets and one
write path. This proves the thing that actually matters end to end: fix a
real M11 frame — real 2 KB camera XMP, real EXIF IFD — through Fixer,
which fills in a missing profile digest of its own accord and so can write
something the lens row never said, and then ask what the next scan would
make of it. Nothing, twice over, for a profiled lens and for a hand-typed
one with no crs:LensProfile* at all.

Skips cleanly without UNCODED_TEST_DNG, like its neighbours.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@arthursoares
arthursoares merged commit 40e498b into develop Jul 26, 2026
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