From d90f953df40fd22795e703c857321ba2cbc683b1 Mon Sep 17 00:00:00 2001 From: rouggy Date: Fri, 31 Jul 2026 14:40:50 +0200 Subject: [PATCH] fix: QRZ.com download marked unconfirmed QSOs as confirmed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator's own fetch settles it. One record, both fields: N Y QRZ says the QSO is NOT confirmed and sets the download field anyway — it marks what was handed back, not what was confirmed. OpsLog read it as a confirmation, so eighteen QSOs came back "UPDATED" and green, none of them confirmed on QRZ. The site's own logbook page shows them without a star. Only app_qrzlog_status = C counts now. The test that asserted otherwise is corrected and carries the real record. And the download now takes back what it wrongly gave: when QRZ explicitly says not-confirmed and our log says confirmed, the flag is cleared and reported on its own line. These QSOs count towards award slots, so leaving them green until someone noticed was not an option — and nothing else in the app would ever have undone them. Only on an explicit no: a record with no app_qrzlog_status is QRZ saying nothing, and silence is not a retraction. --- app.go | 45 ++++++++++++++++++++++++++++++++++++--------- changelog.json | 6 ++++-- internal/qso/qso.go | 17 +++++++++++++++++ qrz_confirm_test.go | 31 ++++++++++++++++++++++++------- 4 files changed, 81 insertions(+), 18 deletions(-) diff --git a/app.go b/app.go index d39653b..00c7b7a 100644 --- a/app.go +++ b/app.go @@ -10132,6 +10132,7 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe mIdx, _ := a.qso.BuildMatchIndex(ctx, qrzOwner) const qrzMatchWindow = 10 * time.Minute qrzSkippedOtherCall := 0 + unconfirmed := 0 // QSOs whose wrongly-set QRZ flag was taken back // QRZ confirmations are QRZ-specific (not award-valid), so NEW is // judged only against other QRZ confirmations. sets, _ := a.qso.ConfirmedSlots(ctx, []string{"qrzcom_qso_download_status"}) @@ -10158,6 +10159,25 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe allKeys[k] = true } if !qrzRecordConfirmed(rec) { + // QRZ says this one is NOT confirmed. If our log says it is, that + // is a mark OpsLog set wrongly before it learned which field + // carries QRZ's answer — take it back rather than leave a QSO + // counting towards an award it has not earned. + // + // Only on an EXPLICIT no: a record with no app_qrzlog_status at + // all is QRZ saying nothing, and silence is not a retraction. + if st := strings.TrimSpace(rec["app_qrzlog_status"]); st != "" { + if q, ok := adif.RecordToQSO(rec); ok && q.Callsign != "" { + if id, found := mIdx.Match(q.Callsign, q.Band, q.Mode, q.QSODate.UTC(), qrzMatchWindow); found && alreadyQrz[id] { + if e := a.qso.ClearQRZConfirmed(ctx, id); e == nil { + delete(alreadyQrz, id) + unconfirmed++ + emit(fmt.Sprintf("Callsign: %s Date: %s Band: %s Mode: %s ### NOT CONFIRMED ON QRZ — flag cleared ###", + q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04"), q.Band, q.Mode)) + } + } + } + } return nil } q, ok := adif.RecordToQSO(rec) @@ -10252,6 +10272,11 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe sort.Strings(keys) emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(keys, ", "))) emit(fmt.Sprintf("Confirmed %d, added %d (of %d returned)", matched, added, total)) + if unconfirmed > 0 { + // Worth its own line and not a footnote: these QSOs were showing as + // confirmed a moment ago, and an award total may move because of it. + emit(fmt.Sprintf("Cleared %d QSO(s) that QRZ.com does NOT confirm — they had been marked wrongly by an earlier version", unconfirmed)) + } if perr != nil { emit("The download was INCOMPLETE — QRZ.com cut the ADIF short, so the records after that point were not read.") emit("The last-download date has NOT been moved, so nothing is lost: run it again (a narrower window returns less data).") @@ -10406,18 +10431,20 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe // "they all turn to Y" (2026-07-29), and on a log full of paper QSLs that is // most of it. // -// A QRZ confirmation is QRZ's own statement, and only two fields carry it: +// ONE field carries it: app_qrzlog_status, where C means confirmed. // -// app_qrzlog_status = C QRZ's confirmed marker on the record -// qrzcom_qso_download_status = Y the ADIF field QRZ sets for it +// qrzcom_qso_download_status was accepted too, and that was wrong. It is not a +// confirmation: QRZ sets it to Y on everything it hands back. An operator's own +// fetch showed both fields on the same record — // -// Anything else is a claim from some other source and must not be read as a QRZ -// confirmation — this status feeds award slots, so a false Y is a QSO counted as -// confirmed when it is not. +// N Y +// +// — a QSO QRZ says is NOT confirmed, which OpsLog then marked confirmed. That +// is how a whole download turned green (2026-07-31). +// +// This status feeds award slots, so a false Y is a QSO counted as confirmed when +// it is not — the one error worth being strict about here. func qrzRecordConfirmed(rec adif.Record) bool { - if strings.EqualFold(rec["qrzcom_qso_download_status"], "Y") { - return true - } return strings.EqualFold(strings.TrimSpace(rec["app_qrzlog_status"]), "C") } diff --git a/changelog.json b/changelog.json index 484c309..6ed3947 100644 --- a/changelog.json +++ b/changelog.json @@ -10,7 +10,8 @@ "CW decoder: a station replying at a different speed no longer decodes as a run of dashes, and callsigns are no longer split into single letters by a wide fist.", "FlexRadio: one table per band for the antennas and the TX power per mode class (phone / CW / digital). Antennas follow the band, power follows the band and the mode; an empty box leaves the power alone.", "Grouping the digital modes into one slot now takes effect on the spots already on screen, instead of only on those arriving afterwards.", - "The QSO right-click menu no longer runs off the bottom of the window: it opens above the cursor when there is not enough room below." + "The QSO right-click menu no longer runs off the bottom of the window: it opens above the cursor when there is not enough room below.", + "QRZ.com confirmations: only QRZ own confirmed marker counts now. The download also clears QSOs an earlier version marked confirmed when QRZ says they are not — run it once to correct your log." ], "fr": [ "FlexRadio : en split, OpsLog reste sur la slice de réception au lieu de suivre l'émission.", @@ -20,7 +21,8 @@ "Décodeur CW : une station qui répond à une autre vitesse ne se décode plus en série de traits, et les indicatifs ne sont plus coupés lettre par lettre par un espacement large.", "FlexRadio : un seul tableau par bande pour les antennes et la puissance d'émission par type de mode (phonie / CW / numérique). Les antennes suivent la bande, la puissance suit la bande et le mode ; une case vide ne touche pas à la puissance.", "Le regroupement des modes numériques en un seul créneau s'applique désormais aux spots déjà affichés, et non plus seulement à ceux qui arrivent ensuite.", - "Le menu contextuel des QSO ne déborde plus en bas de la fenêtre : il s'ouvre au-dessus du curseur quand la place manque en dessous." + "Le menu contextuel des QSO ne déborde plus en bas de la fenêtre : il s'ouvre au-dessus du curseur quand la place manque en dessous.", + "Confirmations QRZ.com : seul le marqueur de confirmation propre à QRZ compte désormais. Le téléchargement efface aussi les QSO qu'une version antérieure avait marqués confirmés alors que QRZ dit le contraire — lancez-le une fois pour corriger votre journal." ] }, { diff --git a/internal/qso/qso.go b/internal/qso/qso.go index 2870337..ff18402 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -2506,6 +2506,23 @@ func (r *Repo) MarkQRZConfirmed(ctx context.Context, id int64, date string) erro return nil } +// ClearQRZConfirmed takes back a QRZ confirmation. +// +// Needed because OpsLog set some wrongly: it read qrzcom_qso_download_status, +// which QRZ puts on everything it hands back, as QRZ's confirmation. Those +// QSOs count towards award slots, so leaving them green until the operator +// notices is not an option — and nothing else in the app would ever undo them. +func (r *Repo) ClearQRZConfirmed(ctx context.Context, id int64) error { + _, err := r.db.ExecContext(ctx, + `UPDATE qso SET qrzcom_qso_download_status = 'N', qrzcom_qso_download_date = NULL, + updated_at = ? WHERE id = ?`, + db.NowISO(), id) + if err != nil { + return fmt.Errorf("clear qrz confirmed %d: %w", id, err) + } + return nil +} + // MarkEQSLConfirmed stamps EQSL_QSL_RCVD=Y and the received date on a QSO after // an eQSL Inbox download. date is an ADIF YYYYMMDD string. func (r *Repo) MarkEQSLConfirmed(ctx context.Context, id int64, date string) error { diff --git a/qrz_confirm_test.go b/qrz_confirm_test.go index 4b567cc..c90f8b4 100644 --- a/qrz_confirm_test.go +++ b/qrz_confirm_test.go @@ -6,12 +6,22 @@ import ( "hamlog/internal/adif" ) -// What counts as a QRZ.com confirmation. +// What counts as a QRZ.com confirmation: app_qrzlog_status = C, and nothing else. // -// Reported 2026-07-29: after a confirmation download, every QSO turned to Y. The -// test accepted qsl_rcvd = Y — but that is the operator's own PAPER QSL flag, -// which they uploaded to QRZ themselves, so every QSO with a card came back -// looking QRZ-confirmed. On a log full of paper QSLs that is most of it. +// Narrowed twice, each time on evidence from an operator's own log. +// +// 2026-07-29 — qsl_rcvd = Y was accepted. That is the operator's own PAPER QSL +// flag, which they uploaded to QRZ themselves, so every QSO with a card came +// back looking QRZ-confirmed. +// +// 2026-07-31 — qrzcom_qso_download_status = Y was accepted, and this test said +// it should be. A fetch showed both fields on ONE record: +// +// N Y +// +// QRZ says not confirmed and sets the download field anyway: it marks what was +// handed back, not what was confirmed. Eighteen QSOs, all "UPDATED", all green, +// none of them confirmed on QRZ. // // This status feeds the award slots, so a false Y is a QSO counted as confirmed // when it is not — the reason the rule is narrow rather than generous. @@ -21,8 +31,7 @@ func TestQRZRecordConfirmed(t *testing.T) { rec adif.Record want bool }{ - // QRZ's own statements. - {"QRZ download status Y", adif.Record{"qrzcom_qso_download_status": "Y"}, true}, + // QRZ's own statement. {"QRZ log status C", adif.Record{"app_qrzlog_status": "C"}, true}, {"lower case is still QRZ's answer", adif.Record{"app_qrzlog_status": "c"}, true}, {"padded", adif.Record{"app_qrzlog_status": " C "}, true}, @@ -33,6 +42,14 @@ func TestQRZRecordConfirmed(t *testing.T) { {"LoTW confirmed, QRZ silent", adif.Record{"lotw_qsl_rcvd": "Y"}, false}, {"eQSL confirmed, QRZ silent", adif.Record{"eqsl_qsl_rcvd": "Y"}, false}, + // The download flag is not an answer: QRZ sets it on everything it returns. + {"downloaded, not confirmed", adif.Record{"qrzcom_qso_download_status": "Y"}, false}, + {"the record from the report", adif.Record{ + "call": "IQ4J", "band": "17m", "mode": "FT8", + "qrzcom_qso_upload_status": "Y", "app_qrzlog_status": "N", + "qrzcom_qso_download_date": "20260731", "qrzcom_qso_download_status": "Y", + }, false}, + // Explicit negatives and nothing at all. {"QRZ says no", adif.Record{"qrzcom_qso_download_status": "N"}, false}, {"QRZ status not confirmed", adif.Record{"app_qrzlog_status": "N"}, false},