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},