Skip to content

Fix logbook fetch/download returning 0 records (#3) - #4

Open
MicaelJarniac wants to merge 2 commits into
qso-graph:mainfrom
MicaelJarniac:fix/issue-3-html-escaped-adif
Open

Fix logbook fetch/download returning 0 records (#3)#4
MicaelJarniac wants to merge 2 commits into
qso-graph:mainfrom
MicaelJarniac:fix/issue-3-html-escaped-adif

Conversation

@MicaelJarniac

@MicaelJarniac MicaelJarniac commented Aug 28, 2026

Copy link
Copy Markdown

Fixes #3.

Verified against a live 30-QSO QRZ logbook (PU2UMK — the same book from the issue report).

The failure is in _parse_kv, not the ADIF regex

The issue attributes this to _ADIF_FIELD_RE not matching <call:6>. That regex does fail on escaped input, but it is never reached. The payload is already destroyed one step earlier.

QRZ HTML-escapes the ADIF payload, so its value legitimately contains & characters. _parse_kv split the body on every &:

'RESULT=OK'  'COUNT=1'  'ADIF='  'lt;call:6'  'gt;PU2UMK'  'lt;eor'  'gt;'

The fragments contain no =, so they were dropped and ADIF resolved to the empty string. Measured against the live response:

old ADIF value length: 0        <- shredded by the '&' split
new ADIF value length: 27787

The suggested one-line fix is unsafe

html.unescape() on the whole body before splitting promotes any &amp; inside a QSO comment into a real delimiter, truncating that record and discarding every QSO after it:

suggested:  ADIF = '<call:4>W1AW<comment:7>R'                              -> 1 record (2nd QSO lost)
this PR:    ADIF = '<call:4>W1AW<comment:7>R&R net<eor><call:5>KI7MT<eor>' -> 2 records

This is not hypothetical — the live logbook contains comments such as PY2KAC Repeater, and & in comments is common. That change would swap a loud 0-record bug for a quiet data-loss bug.

Changes

  • _parse_kv — splits only at an & that introduces a new KEY= pair, then URL-decodes and HTML-unescapes each value individually. Order is the point: per-value unescaping stops a decoded & from ever being seen as a delimiter.
  • _unescape_adif — safety net for ADIF reaching the parser by another path. Unescapes only when literal field markers are absent but escaped ones are present, so an already-decoded &amp; is not decoded twice.
  • _parse_adif_records — fixes a latent bug where a declared field length overrunning the buffer left pos before the marker just consumed, stalling the scan. pos can no longer rewind.
  • Deduplicated the ADIF header/count logic into _ADIF_HEADER and _count_records.

Mock fixtures

Escaping the fixture string alone would not have caught this: the old mock bypassed _parse_kv entirely, so the broken function was never exercised in mock mode. Mock fetch/download_adif now decode _MOCK_FETCH_BODY through the same _parse_kv path as live responses. The fixture includes a R&R net comment to pin the &amp; behaviour.

Verification

Live, against the real logbook:

                           OLD   NEW  TRUTH
status COUNT                30    30     30
fetch records                0    30     30
download record_count        0    30     30

All three MCP tools now agree at 30. Test suite: 49 passed (43 existing + 6 new, QRZ-L2-049..054). 4 of the 6 new tests fail against the pre-fix source, confirming they actually pin the regression rather than just passing.

Out of scope — separate bug found while verifying

The live ACTION=STATUS response uses different field names than status() reads, so several stats silently return 0:

real keys:  CALLSIGN, DXCC_COUNT, START_DATE, END_DATE, ...
code reads: OWNER,    DXCC,       START,      END

dxcc, us_states, start_date and end_date are affected (count, confirmed and callsign work, since OWNER and COUNT do exist). The mock fixture encodes the wrong names too, which is why tests pass. Unrelated to the ADIF parsing bug, so I left it out of this PR — happy to file it separately or fold it in, whichever you prefer.


AI disclosure

This PR — code, tests, commit messages and description — was written by an AI agent (Claude, via OpenCode) working from a human-directed prompt. The human maintainer supplied QRZ credentials and directed the live-testing step.

Every factual claim above was verified by executing it against the live QRZ API and the test suite, not asserted from model knowledge. The measurements are reproducible from the descriptions given.

Reviewer note: please apply the same scrutiny you would to any other contribution, and more to the reasoning than the diff. The first revision of this PR was confidently wrong — it corrupted comment values containing +, % and & — and was caught only because a human suggested testing against a real contact. Plausible-sounding analysis is this tool's characteristic failure mode.

QRZ HTML-escapes the ADIF payload it returns, so the payload's own "&"
characters ("&lt;", "&gt;", "&amp;") were treated as key=value delimiters
by _parse_kv, which split the body on every "&". That shredded the ADIF
value into fragments with no "=", all of which were dropped, leaving
ADIF as the empty string before the ADIF field regex ever ran.

_parse_kv now splits only at an "&" that introduces a new "KEY=" pair and
unescapes each value individually. Per-value unescaping matters: decoding
the whole body up front would turn an "&amp;" inside a QSO comment into a
live delimiter, truncating the record and silently dropping every QSO
after it.

Also fix a latent bug in _parse_adif_records where a declared field length
overrunning the buffer left pos before the marker just consumed, stalling
the scan.

Mock fixtures previously bypassed _parse_kv entirely and used unescaped
ADIF, so the broken function was never exercised in mock mode. They now
use the real wire format and decode through the same path as live
responses.

Verified against a live 30-QSO logbook: status/fetch/download now all
report 30; before this change fetch and download reported 0.
Live testing against a real logbook (inserting QSOs with adversarial
comments, reading them back) showed the previous fix was still wrong, and
that the wire format differs from what both it and the issue assumed.

QRZ escapes only the ADIF *markers* ("&lt;call:6&gt;"). Field values are
passed through verbatim, so a value may contain raw "&", "=", "+", "%"
and newlines, none of which are delimiters or encodings:

    &lt;comment:7&gt;R&R net           bare "&", not "&amp;"
    &lt;comment:10&gt;A+B 50%20C       not URL-encoded
    &lt;comment:23&gt;&amp; &lt; ...    entity-like text, kept as typed

The previous approach unescaped and URL-decoded each value, which
corrupted all three: "A+B 50%20C" became "A B 50 C", and operator text
containing "&amp;" was silently mutated. Values are now consumed by their
declared length and never rewritten; markers are matched in either literal
or escaped form. Lengths are counted in characters, matching QRZ (a
12-character accented comment declares 12, not its 16 UTF-8 bytes).

ADIF is the final key in a FETCH response, so it is taken as the entire
remainder of the body rather than split on "&".

Also fixes qrz_download emitting escaped markers into its ".adi" output,
which no logger could import. Records are parsed and re-serialised with
literal markers, so record_count and the file agree. A literal "<eor>"
inside a comment no longer inflates the count.

Mock fixtures are transcribed from live responses so these cases are
covered in CI. Verified on Python 3.10-3.13.
@MicaelJarniac

MicaelJarniac commented Aug 28, 2026

Copy link
Copy Markdown
Author

Correction: my first fix was also wrong

I inserted QSOs with adversarial comments into a live logbook and read them back. That disproved a premise both the issue and my original patch relied on, so I've pushed a rewrite.

QRZ escapes only the ADIF markers. Field values are passed through verbatim.

Live wire output, verbatim:

&lt;comment:7&gt;R&R net             bare "&" — not "&amp;"
&lt;comment:9&gt;A&B & C&D           multiple bare "&"
&lt;comment:17&gt;100% S&P; a=b&c=d  "=" and "&" inside the value
&lt;comment:10&gt;A+B 50%20C         "+"/"%" literal — not URL-encoded
&lt;comment:23&gt;&amp; &lt; &gt; literal  entity-like text, stored as typed
&lt;comment:12&gt;Açaí ñ Münch       12 chars / 16 bytes — declared 12

In every case the declared length equals the raw returned value length. So values must be consumed by length, never by scanning for delimiters or entities.

My previous patch HTML-unescaped and URL-decoded each value. Against real data that corrupted three of six cases:

stored comment previous patch returned
A+B 50%20C A B 50 C (unquote_plus ate + and %20)
&amp; &lt; &gt; literal & < > literal (unescaped operator text)
100% S&P; a=b&c=d truncated at a=b

Scoreboard on the live fetch — old code, the issue's suggested html.unescape(body), and my first patch, versus the rewrite:

records parsed -> OLD: 0   SUGGESTED: 2   FIRST PATCH: 5   REWRITE: 6

The rewrite now round-trips all six recoverable cases. (The seventh, test <call> & <eor> marker, comes back as test call & marker eor — QRZ strips </> server-side on INSERT. The & survives; the angle brackets never reach the parser.)

What changed

  • ADIF is the last key in a FETCH response, so it's taken as the entire remainder of the body instead of being split on &.
  • Values are consumed by declared length and never rewritten — no URL-decode, no HTML-unescape.
  • Markers are matched in either literal or escaped form, so the payload needs no preprocessing.
  • Lengths counted in characters, not UTF-8 bytes.

Second bug this surfaced

qrz_download was concatenating QRZ's raw fragments into its .adi output, so the file contained &lt;call:5&gt;not importable by any logger, despite the tool being documented as "save to disk for import into your logger." Records are now re-serialised with literal markers. A literal <eor> typed into a comment also no longer inflates record_count (length-delimited values are skipped, so it can't be mistaken for structure).

Verification

Live: status=37 fetch=37 download=37 with the test QSOs present; back to 30/30/30 after cleanup. The logbook was restored to a byte-identical logid set — I diffed the pre/post logid lists and deleted only the 7 IDs I created.

Tests: 55 passing on Python 3.10–3.13.

One caveat on my earlier matrix claim in this PR: uv run --with . had silently served a cached wheel rather than my working tree, so that run was not testing the new code. Re-run with editable installs and verified each interpreter loads from source. Worth knowing if anyone reproduces locally.


AI disclosure: written by an AI agent (Claude, via OpenCode) on a human-directed prompt. All measurements quoted were produced by running against the live QRZ API, not asserted from model knowledge.

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.

Logbook fetch/download return 0 records: ADIF parser doesn't handle HTML-escaped QRZ responses

1 participant