From f3bf0b2f5ca39274b6a779357d91c78dd9331ac6 Mon Sep 17 00:00:00 2001 From: rouggy Date: Thu, 9 Jul 2026 18:36:24 +0200 Subject: [PATCH] fix: different bugs on eQSL --- app.go | 66 ++++++++++++++++--- internal/qso/matchindex_test.go | 49 ++++++++++++++ internal/qso/qso.go | 112 ++++++++++++++++++++++++++++++++ 3 files changed, 218 insertions(+), 9 deletions(-) create mode 100644 internal/qso/matchindex_test.go diff --git a/app.go b/app.go index 9d2ea6a..cbe1f70 100644 --- a/app.go +++ b/app.go @@ -6868,7 +6868,14 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe } emit("ADIF head: " + snip) } - keyIDs, _ := a.qso.DedupeKeyIDs(ctx) + // Scope to THIS profile's / forced callsign so a QRZ logbook holding + // several of the operator's calls (F4BPO, TM2Q…) only confirms/adds QSOs + // for the active call. Windowed + phone-mode-tolerant matching (same as + // eQSL/LoTW). + qrzOwner := a.uploadOwnerCall(extsvc.ServiceQRZ) + mIdx, _ := a.qso.BuildMatchIndex(ctx, qrzOwner) + const qrzMatchWindow = 10 * time.Minute + qrzSkippedOtherCall := 0 // 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"}) @@ -6905,6 +6912,13 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe if sinceDate != "" && !q.QSODate.IsZero() && q.QSODate.UTC().Format("2006-01-02") < sinceDate { return nil } + // Skip a QSO logged under a DIFFERENT one of the operator's callsigns. + if qrzOwner != "" { + if rc := strings.ToUpper(strings.TrimSpace(rec["station_callsign"])); rc != "" && rc != qrzOwner { + qrzSkippedOtherCall++ + return nil + } + } total++ date := rec["qrzcom_qso_download_date"] if date == "" { @@ -6913,8 +6927,7 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe a.enrichContactedFromCty(&q) line := fmt.Sprintf("Callsign: %s Date: %s Band: %s Mode: %s", q.Callsign, q.QSODate.UTC().Format("2006-01-02 15:04"), q.Band, q.Mode) - key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode) - id, found := keyIDs[key] + id, found := mIdx.Match(q.Callsign, q.Band, q.Mode, q.QSODate.UTC(), qrzMatchWindow) switch { case found: if alreadyQrz[id] { @@ -6928,8 +6941,11 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe q.QRZComUploadStatus = "Y" q.QRZComDownloadStatus = "Y" q.QRZComDownloadDate = date + if q.StationCallsign == "" { + q.StationCallsign = qrzOwner + } if newID, e := a.qso.Add(ctx, q); e == nil { - keyIDs[key] = newID + mIdx.Add(q.Callsign, q.Band, q.Mode, q.QSODate.UTC(), newID) added++ emit(line + " ### ADDED ###") } @@ -6972,6 +6988,9 @@ 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 qrzSkippedOtherCall > 0 { + emit(fmt.Sprintf("Skipped %d QSO(s) logged under another of your callsigns (scoped to %s).", qrzSkippedOtherCall, qrzOwner)) + } // (last-download date already stored right after the fetch above) case extsvc.ServiceEQSL: @@ -6987,12 +7006,23 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe done(matched, total) return } - keyIDs, kerr := a.qso.DedupeKeyIDs(ctx) + // Scope everything to THIS profile's / forced callsign so an eQSL account + // holding several of the operator's calls (F4BPO, TM2Q…) only confirms/adds + // QSOs for the active call. + eqslOwner := a.uploadOwnerCall(extsvc.ServiceEQSL) + // Tolerant match index: an eQSL confirmation carries the OTHER station's + // logged time, often a minute or two off ours — so we match within a + // ±10-minute window. The mode must still match, except the phone sidebands + // SSB/USB/LSB are treated as one (a station may confirm USB where we logged + // SSB); FT8/FT4/CW/… stay exact. + mIdx, kerr := a.qso.BuildMatchIndex(ctx, eqslOwner) if kerr != nil { emit("Error reading local log: " + kerr.Error()) done(matched, total) return } + const eqslMatchWindow = 10 * time.Minute + skippedOtherCall := 0 // eQSL confirmations aren't ARRL-award-valid (only LoTW + paper QSL are), // so NEW is judged only against other eQSL confirmations. sets, _ := a.qso.ConfirmedSlots(ctx, []string{"eqsl_rcvd"}) @@ -7003,6 +7033,15 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe if !ok { return nil } + // Skip a confirmation logged under a DIFFERENT one of the operator's + // callsigns (the eQSL account may hold several) — it belongs to that + // call's log, not this one. + if eqslOwner != "" { + if rc := strings.ToUpper(strings.TrimSpace(rec["station_callsign"])); rc != "" && rc != eqslOwner { + skippedOtherCall++ + return nil + } + } total++ // eQSL stamps the confirmation date in QSLRDATE (fall back to today). date := rec["qslrdate"] @@ -7010,8 +7049,8 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe date = time.Now().UTC().Format("20060102") } a.enrichContactedFromCty(&q) - key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode) - if id, found := keyIDs[key]; found { + id, found := mIdx.Match(q.Callsign, q.Band, q.Mode, q.QSODate.UTC(), eqslMatchWindow) + if found { if e := a.qso.MarkEQSLConfirmed(ctx, id, date); e == nil { matched++ } @@ -7019,8 +7058,11 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe q.EQSLSent = "Y" q.EQSLRcvd = "Y" q.EQSLRcvdDate = date + if q.StationCallsign == "" { + q.StationCallsign = eqslOwner // stamp the active call on adds + } if newID, e := a.qso.Add(ctx, q); e == nil { - keyIDs[key] = newID + mIdx.Add(q.Callsign, q.Band, q.Mode, q.QSODate.UTC(), newID) added++ } } else { @@ -7061,6 +7103,9 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe for _, u := range unmatched { emit(" ⚠ no local QSO for: " + u) } + if skippedOtherCall > 0 { + emit(fmt.Sprintf("Skipped %d confirmation(s) logged under another of your callsigns (scoped to %s).", skippedOtherCall, eqslOwner)) + } if a.settings != nil { a.setSetting(a.profileScope()+keyExtEQSLLastDownload, time.Now().UTC().Format("2006-01-02")) } @@ -7312,7 +7357,10 @@ func (a *App) uploadOwnerCall(svc extsvc.Service) string { case extsvc.ServiceHRDLog: owner = cfg.HRDLog.Callsign case extsvc.ServiceEQSL: - owner = cfg.EQSL.Username + // eQSL has no Force-callsign field; its Username is just the account login + // (which may hold several of the operator's calls). Scope by the ACTIVE + // PROFILE's callsign instead — the operating identity — via the fallback + // below, so a download on the F4BPO profile only touches F4BPO QSOs. } owner = strings.ToUpper(strings.TrimSpace(owner)) if owner == "" && a.profiles != nil { diff --git a/internal/qso/matchindex_test.go b/internal/qso/matchindex_test.go new file mode 100644 index 0000000..a13c7ce --- /dev/null +++ b/internal/qso/matchindex_test.go @@ -0,0 +1,49 @@ +package qso + +import ( + "testing" + "time" +) + +func tm(s string) time.Time { + t, _ := time.Parse("2006-01-02T15:04", s) + return t +} + +func TestMatchIndexWindowAndMode(t *testing.T) { + idx := &MatchIndex{byMode: map[string][]matchRef{}} + // Local log: a CW QSO, an SSB (logged as USB) QSO, and an FT8 QSO. + idx.Add("4Z4DX", "15m", "CW", tm("2026-06-20T10:13"), 1) + idx.Add("V85NPV", "15m", "USB", tm("2025-04-18T17:01"), 2) + idx.Add("DK0SWL", "20m", "FT8", tm("2026-03-01T09:00"), 3) + + win := 10 * time.Minute + cases := []struct { + name string + call, band, mode string + when string + wantID int64 + wantOK bool + }{ + {"exact", "4Z4DX", "15m", "CW", "2026-06-20T10:13", 1, true}, + {"1-min drift", "4Z4DX", "15m", "CW", "2026-06-20T10:12", 1, true}, + {"9-min drift ok", "4Z4DX", "15m", "CW", "2026-06-20T10:04", 1, true}, + {"30-min drift misses", "4Z4DX", "15m", "CW", "2026-06-20T10:43", 0, false}, + // Phone sidebands are interchangeable: an SSB confirmation matches a USB log. + {"SSB conf vs USB log", "V85NPV", "15m", "SSB", "2025-04-18T17:00", 2, true}, + {"LSB conf vs USB log", "V85NPV", "15m", "LSB", "2025-04-18T17:00", 2, true}, + // But a digital/phone mismatch does NOT match (exact for non-phone). + {"FT8 conf vs SSB log misses", "V85NPV", "15m", "FT8", "2025-04-18T17:00", 0, false}, + // Digital is exact: FT8 matches FT8, but FT4 would not. + {"FT8 exact", "DK0SWL", "20m", "FT8", "2026-03-01T09:01", 3, true}, + {"FT4 vs FT8 misses", "DK0SWL", "20m", "FT4", "2026-03-01T09:01", 0, false}, + {"wrong band misses", "DK0SWL", "40m", "FT8", "2026-03-01T09:01", 0, false}, + {"unknown call misses", "N0CALL", "15m", "CW", "2026-06-20T10:13", 0, false}, + } + for _, c := range cases { + id, ok := idx.Match(c.call, c.band, c.mode, tm(c.when), win) + if ok != c.wantOK || (ok && id != c.wantID) { + t.Errorf("%s: got (id=%d ok=%v), want (id=%d ok=%v)", c.name, id, ok, c.wantID, c.wantOK) + } + } +} diff --git a/internal/qso/qso.go b/internal/qso/qso.go index 9a766af..8e022fd 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -490,6 +490,7 @@ type UploadRow struct { // Manager may filter on (guards the dynamic column name in the query). var uploadStatusCols = map[string]bool{ "lotw_sent": true, + "eqsl_sent": true, "qrzcom_qso_upload_status": true, "clublog_qso_upload_status": true, "hrdlog_qso_upload_status": true, @@ -1813,6 +1814,117 @@ func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) { return out, rows.Err() } +// matchRef is one local QSO's time + id, for time-window confirmation matching. +type matchRef struct { + when time.Time + id int64 +} + +// MatchIndex matches a downloaded confirmation to a local QSO within a TIME +// WINDOW (not an exact minute): QSL confirmations carry the OTHER station's +// logged time, which drifts a minute or two from ours. The MODE must match, with +// ONE equivalence: the phone modes SSB/USB/LSB are interchangeable (a station may +// confirm USB where we logged SSB). Every other mode is exact (FT8 only matches +// FT8, FT4 only FT4, CW only CW…). Built in one table scan. +type MatchIndex struct { + byMode map[string][]matchRef // call|band|canonMode → refs +} + +// canonMode folds the phone sidebands into a single "SSB" bucket; every other +// mode is returned uppercased and unchanged (exact-match). +func canonMode(mode string) string { + m := strings.ToUpper(strings.TrimSpace(mode)) + switch m { + case "SSB", "USB", "LSB": + return "SSB" + } + return m +} + +func matchKeyMode(call, band, mode string) string { + return strings.ToUpper(call) + "|" + strings.ToLower(band) + "|" + canonMode(mode) +} + +// parseQSODate reads the stored qso_date (ISO "2006-01-02T15:04[:05…]") to minute +// precision as UTC. Zero time on failure (never matched). +func parseQSODate(s string) time.Time { + if len(s) >= 16 { + if t, err := time.Parse("2006-01-02T15:04", s[:16]); err == nil { + return t + } + } + return time.Time{} +} + +// BuildMatchIndex scans the log once and indexes every QSO by call+band(+mode) +// with its time, for windowed confirmation matching. When ownerCall is non-empty +// the index is SCOPED to QSOs whose station_callsign is that call (or blank — +// legacy QSOs that predate station-callsign stamping), so a confirmation download +// for one of the operator's calls (e.g. F4BPO) never touches QSOs logged under +// another (e.g. TM2Q). +func (r *Repo) BuildMatchIndex(ctx context.Context, ownerCall string) (*MatchIndex, error) { + idx := &MatchIndex{byMode: map[string][]matchRef{}} + query := `SELECT id, callsign, qso_date, band, mode FROM qso` + var args []any + if oc := strings.ToUpper(strings.TrimSpace(ownerCall)); oc != "" { + query += ` WHERE UPPER(TRIM(COALESCE(station_callsign,''))) IN ('', ?)` + args = append(args, oc) + } + rows, err := r.db.QueryContext(ctx, query, args...) + if err != nil { + return idx, err + } + defer rows.Close() + for rows.Next() { + var id int64 + var call, when, band, mode string + if err := rows.Scan(&id, &call, &when, &band, &mode); err != nil { + return idx, err + } + idx.add(call, band, mode, parseQSODate(when), id) + } + return idx, rows.Err() +} + +// add inserts a QSO into the index (also used to register a freshly-inserted +// "add not-found" QSO so later confirmations in the same batch can match it). +func (idx *MatchIndex) add(call, band, mode string, when time.Time, id int64) { + mk := matchKeyMode(call, band, mode) + idx.byMode[mk] = append(idx.byMode[mk], matchRef{when: when, id: id}) +} + +// Add registers a QSO in the index (exported wrapper for callers that inserted a +// not-found confirmation). +func (idx *MatchIndex) Add(call, band, mode string, when time.Time, id int64) { + idx.add(call, band, mode, when, id) +} + +// Match returns the id of the local QSO closest in time to `when` (within +// `window`) for the given call/band/mode. The mode must match (phone sidebands +// folded together by canonMode). Ok is false when nothing is within the window. +func (idx *MatchIndex) Match(call, band, mode string, when time.Time, window time.Duration) (int64, bool) { + return closestRef(idx.byMode[matchKeyMode(call, band, mode)], when, window) +} + +func closestRef(refs []matchRef, when time.Time, window time.Duration) (int64, bool) { + var best int64 + bestD := window + time.Second + found := false + for _, rf := range refs { + if rf.when.IsZero() { + continue + } + d := when.Sub(rf.when) + if d < 0 { + d = -d + } + if d <= window && d < bestD { + bestD, best, found = d, rf.id, true + } + } + return best, found +} + // ConfirmedSets captures which DXCC / band / slot combinations are already // confirmed (by any QSL system), so a freshly-downloaded confirmation can be // flagged as a NEW DXCC / NEW BAND / NEW SLOT.