fix: different bugs on eQSL

This commit is contained in:
2026-07-09 18:36:24 +02:00
parent 1f74e4d234
commit f3bf0b2f5c
3 changed files with 218 additions and 9 deletions
+49
View File
@@ -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)
}
}
}
+112
View File
@@ -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.