fix: different bugs on eQSL
This commit is contained in:
@@ -6868,7 +6868,14 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
}
|
}
|
||||||
emit("ADIF head: " + snip)
|
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
|
// QRZ confirmations are QRZ-specific (not award-valid), so NEW is
|
||||||
// judged only against other QRZ confirmations.
|
// judged only against other QRZ confirmations.
|
||||||
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"qrzcom_qso_download_status"})
|
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 {
|
if sinceDate != "" && !q.QSODate.IsZero() && q.QSODate.UTC().Format("2006-01-02") < sinceDate {
|
||||||
return nil
|
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++
|
total++
|
||||||
date := rec["qrzcom_qso_download_date"]
|
date := rec["qrzcom_qso_download_date"]
|
||||||
if date == "" {
|
if date == "" {
|
||||||
@@ -6913,8 +6927,7 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
a.enrichContactedFromCty(&q)
|
a.enrichContactedFromCty(&q)
|
||||||
line := fmt.Sprintf("Callsign: %s Date: %s Band: %s Mode: %s",
|
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)
|
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 := mIdx.Match(q.Callsign, q.Band, q.Mode, q.QSODate.UTC(), qrzMatchWindow)
|
||||||
id, found := keyIDs[key]
|
|
||||||
switch {
|
switch {
|
||||||
case found:
|
case found:
|
||||||
if alreadyQrz[id] {
|
if alreadyQrz[id] {
|
||||||
@@ -6928,8 +6941,11 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
q.QRZComUploadStatus = "Y"
|
q.QRZComUploadStatus = "Y"
|
||||||
q.QRZComDownloadStatus = "Y"
|
q.QRZComDownloadStatus = "Y"
|
||||||
q.QRZComDownloadDate = date
|
q.QRZComDownloadDate = date
|
||||||
|
if q.StationCallsign == "" {
|
||||||
|
q.StationCallsign = qrzOwner
|
||||||
|
}
|
||||||
if newID, e := a.qso.Add(ctx, q); e == nil {
|
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++
|
added++
|
||||||
emit(line + " ### ADDED ###")
|
emit(line + " ### ADDED ###")
|
||||||
}
|
}
|
||||||
@@ -6972,6 +6988,9 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
sort.Strings(keys)
|
sort.Strings(keys)
|
||||||
emit(fmt.Sprintf("Parsed %d record(s). Fields seen: %s", parsed, strings.Join(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))
|
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)
|
// (last-download date already stored right after the fetch above)
|
||||||
|
|
||||||
case extsvc.ServiceEQSL:
|
case extsvc.ServiceEQSL:
|
||||||
@@ -6987,12 +7006,23 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
done(matched, total)
|
done(matched, total)
|
||||||
return
|
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 {
|
if kerr != nil {
|
||||||
emit("Error reading local log: " + kerr.Error())
|
emit("Error reading local log: " + kerr.Error())
|
||||||
done(matched, total)
|
done(matched, total)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
const eqslMatchWindow = 10 * time.Minute
|
||||||
|
skippedOtherCall := 0
|
||||||
// eQSL confirmations aren't ARRL-award-valid (only LoTW + paper QSL are),
|
// eQSL confirmations aren't ARRL-award-valid (only LoTW + paper QSL are),
|
||||||
// so NEW is judged only against other eQSL confirmations.
|
// so NEW is judged only against other eQSL confirmations.
|
||||||
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"eqsl_rcvd"})
|
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"eqsl_rcvd"})
|
||||||
@@ -7003,6 +7033,15 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
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++
|
total++
|
||||||
// eQSL stamps the confirmation date in QSLRDATE (fall back to today).
|
// eQSL stamps the confirmation date in QSLRDATE (fall back to today).
|
||||||
date := rec["qslrdate"]
|
date := rec["qslrdate"]
|
||||||
@@ -7010,8 +7049,8 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
date = time.Now().UTC().Format("20060102")
|
date = time.Now().UTC().Format("20060102")
|
||||||
}
|
}
|
||||||
a.enrichContactedFromCty(&q)
|
a.enrichContactedFromCty(&q)
|
||||||
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
|
id, found := mIdx.Match(q.Callsign, q.Band, q.Mode, q.QSODate.UTC(), eqslMatchWindow)
|
||||||
if id, found := keyIDs[key]; found {
|
if found {
|
||||||
if e := a.qso.MarkEQSLConfirmed(ctx, id, date); e == nil {
|
if e := a.qso.MarkEQSLConfirmed(ctx, id, date); e == nil {
|
||||||
matched++
|
matched++
|
||||||
}
|
}
|
||||||
@@ -7019,8 +7058,11 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
q.EQSLSent = "Y"
|
q.EQSLSent = "Y"
|
||||||
q.EQSLRcvd = "Y"
|
q.EQSLRcvd = "Y"
|
||||||
q.EQSLRcvdDate = date
|
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 {
|
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++
|
added++
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -7061,6 +7103,9 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
|
|||||||
for _, u := range unmatched {
|
for _, u := range unmatched {
|
||||||
emit(" ⚠ no local QSO for: " + u)
|
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 {
|
if a.settings != nil {
|
||||||
a.setSetting(a.profileScope()+keyExtEQSLLastDownload, time.Now().UTC().Format("2006-01-02"))
|
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:
|
case extsvc.ServiceHRDLog:
|
||||||
owner = cfg.HRDLog.Callsign
|
owner = cfg.HRDLog.Callsign
|
||||||
case extsvc.ServiceEQSL:
|
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))
|
owner = strings.ToUpper(strings.TrimSpace(owner))
|
||||||
if owner == "" && a.profiles != nil {
|
if owner == "" && a.profiles != nil {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -490,6 +490,7 @@ type UploadRow struct {
|
|||||||
// Manager may filter on (guards the dynamic column name in the query).
|
// Manager may filter on (guards the dynamic column name in the query).
|
||||||
var uploadStatusCols = map[string]bool{
|
var uploadStatusCols = map[string]bool{
|
||||||
"lotw_sent": true,
|
"lotw_sent": true,
|
||||||
|
"eqsl_sent": true,
|
||||||
"qrzcom_qso_upload_status": true,
|
"qrzcom_qso_upload_status": true,
|
||||||
"clublog_qso_upload_status": true,
|
"clublog_qso_upload_status": true,
|
||||||
"hrdlog_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()
|
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
|
// ConfirmedSets captures which DXCC / band / slot combinations are already
|
||||||
// confirmed (by any QSL system), so a freshly-downloaded confirmation can be
|
// confirmed (by any QSL system), so a freshly-downloaded confirmation can be
|
||||||
// flagged as a NEW DXCC / NEW BAND / NEW SLOT.
|
// flagged as a NEW DXCC / NEW BAND / NEW SLOT.
|
||||||
|
|||||||
Reference in New Issue
Block a user