fix(lotw): keep another station's confirmations out, and show a suspect report

Unscoped, the report carries every station on the account — including the calls
belonging to another profile's logbook. Those match nothing here, and with 'add
the ones not found' ticked they would pour a second log into this one. The
station callsigns this logbook actually holds are now the filter: a portable
worked here is kept, an expedition call never used here is skipped and counted.

A near-empty report is also the failure that reads as success — 'matched 1 of 1'
where the account holds twelve thousand. Under 4 KB, what LoTW answered is shown
verbatim instead.
This commit is contained in:
2026-08-27 23:12:44 +02:00
parent 4366083152
commit cff590fe8a
3 changed files with 69 additions and 2 deletions
+40
View File
@@ -12190,6 +12190,19 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
ownCall = "" ownCall = ""
} }
callLabel := ownCall callLabel := ownCall
// Unscoped, the report carries every station on the account — including
// the ones belonging to ANOTHER profile's logbook (a Vietnam expedition,
// say). Those confirmations have nothing to match here, and with "add the
// ones not found" ticked they would pour a second log into this one. So
// the station callsigns this logbook actually holds become the filter:
// F4BPO/P is kept because it was worked here, XV9Q is skipped because it
// never was.
var ownStations map[string]bool
if ownCall == "" {
if st, e := a.qso.StationCallsigns(ctx); e == nil && len(st) > 0 {
ownStations = st
}
}
if callLabel == "" { if callLabel == "" {
callLabel = "all callsigns" callLabel = "all callsigns"
} }
@@ -12206,6 +12219,19 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
return return
} }
emit(fmt.Sprintf("LoTW returned %d KB of ADIF", len(adifText)/1024)) emit(fmt.Sprintf("LoTW returned %d KB of ADIF", len(adifText)/1024))
// A report far smaller than the account justifies is the one failure that
// looks like success: LoTW answers 200 with a near-empty ADIF when it
// disagrees with the query (an unknown callsign in qso_owncall, a login
// that half-worked). Show its own header rather than leaving "matched 1 of
// 1" to be read as "you have one confirmation".
if len(adifText) < 4096 {
head := strings.TrimSpace(adifText)
if len(head) > 400 {
head = head[:400]
}
emit("The report is unexpectedly small — what LoTW actually sent:")
emit(" " + strings.Join(strings.Fields(head), " "))
}
keyIDs, kerr := a.qso.DedupeKeyIDs(ctx) keyIDs, kerr := a.qso.DedupeKeyIDs(ctx)
if kerr != nil { if kerr != nil {
emit("Error reading local log: " + kerr.Error()) emit("Error reading local log: " + kerr.Error())
@@ -12223,6 +12249,7 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"}) sets, _ := a.qso.ConfirmedSlots(ctx, []string{"lotw_rcvd", "qsl_rcvd"})
var items []ConfirmationItem var items []ConfirmationItem
var unmatched []string var unmatched []string
skippedOtherStation := 0
perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error { perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error {
if ctx.Err() != nil { if ctx.Err() != nil {
return ctx.Err() // window closed / superseded return ctx.Err() // window closed / superseded
@@ -12231,6 +12258,16 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
if !ok { if !ok {
return nil return nil
} }
// Another station's confirmation — see ownStations above. Counted so
// the report says how many were left alone rather than silently
// dropping a third of the file.
if ownStations != nil {
st := strings.ToUpper(strings.TrimSpace(rec["station_callsign"]))
if st != "" && !ownStations[st] {
skippedOtherStation++
return nil
}
}
total++ total++
date := rec["qslrdate"] date := rec["qslrdate"]
if date == "" { if date == "" {
@@ -12311,6 +12348,9 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
} else { } else {
emit(fmt.Sprintf("Matched %d of %d confirmed QSO(s)", matched, total)) emit(fmt.Sprintf("Matched %d of %d confirmed QSO(s)", matched, total))
} }
if skippedOtherStation > 0 {
emit(fmt.Sprintf(" (%d confirmation(s) skipped — made under a callsign this logbook has never used)", skippedOtherStation))
}
if byClass > 0 { if byClass > 0 {
// Said out loud rather than folded silently into the total: these // Said out loud rather than folded silently into the total: these
// matched on the mode CLASS, not the mode. LoTW hands back "DATA" for // matched on the mode CLASS, not the mode. LoTW hands back "DATA" for
+2 -2
View File
@@ -10,7 +10,7 @@
"Cluster: a SOTA column, read from the summit reference the SOTA feeds put in the spot comment. Clicking the spot fills the QSOs SOTA award reference, as a POTA spot already did. Turn the column on in Columns.", "Cluster: a SOTA column, read from the summit reference the SOTA feeds put in the spot comment. Clicking the spot fills the QSOs SOTA award reference, as a POTA spot already did. Turn the column on in Columns.",
"Awards: a \"Slots to confirm\" filter and a running count beside the reference total, so the gap between worked and confirmed band-slots — the Challenge difference — can be seen reference by reference instead of only as two numbers.", "Awards: a \"Slots to confirm\" filter and a running count beside the reference total, so the gap between worked and confirmed band-slots — the Challenge difference — can be seen reference by reference instead of only as two numbers.",
"QSL Manager: a QRZ button next to the Paper QSL search, opening the callsign on QRZ.com.", "QSL Manager: a QRZ button next to the Paper QSL search, opening the callsign on QRZ.com.",
"LoTW: an \"All my callsigns\" option beside the download. The download is scoped to the profiles own callsign, so a QSO made as a portable or contest call was confirmed at LoTW and never marked here." "LoTW: an \"All my callsigns\" option beside the download. The download is scoped to the profiles own callsign, so a QSO made as a portable or contest call was confirmed at LoTW and never marked here. Confirmations made under a callsign this logbook has never used are skipped, and a suspiciously small report now shows what LoTW actually answered."
], ],
"fr": [ "fr": [
"Chaque radio porte son propre MY_RIG (Réglages → CAT), inscrit sur chaque QSO fait avec elle — avant la station par bande des Conditions de trafic, qui dit ce qui était prévu et non quelle radio émet. Laissé vide, rien ne change.", "Chaque radio porte son propre MY_RIG (Réglages → CAT), inscrit sur chaque QSO fait avec elle — avant la station par bande des Conditions de trafic, qui dit ce qui était prévu et non quelle radio émet. Laissé vide, rien ne change.",
@@ -20,7 +20,7 @@
"Cluster : une colonne SOTA, lue dans la référence de sommet que les flux SOTA mettent dans le commentaire du spot. Cliquer le spot remplit la référence SOTA du QSO, comme le faisait déjà un spot POTA. Colonne à activer dans Colonnes.", "Cluster : une colonne SOTA, lue dans la référence de sommet que les flux SOTA mettent dans le commentaire du spot. Cliquer le spot remplit la référence SOTA du QSO, comme le faisait déjà un spot POTA. Colonne à activer dans Colonnes.",
"Awards : un filtre « Slots à confirmer » et un compteur à côté du total de références, pour voir l'écart entre créneaux contactés et confirmés — la différence du Challenge — référence par référence et non plus seulement en deux chiffres.", "Awards : un filtre « Slots à confirmer » et un compteur à côté du total de références, pour voir l'écart entre créneaux contactés et confirmés — la différence du Challenge — référence par référence et non plus seulement en deux chiffres.",
"Gestionnaire QSL : un bouton QRZ à côté de la recherche QSL papier, qui ouvre l'indicatif sur QRZ.com.", "Gestionnaire QSL : un bouton QRZ à côté de la recherche QSL papier, qui ouvre l'indicatif sur QRZ.com.",
"LoTW : une option « Tous mes indicatifs » à côté du téléchargement. Celui-ci est limité à l'indicatif du profil, si bien qu'un QSO fait sous un indicatif portable ou de contest était confirmé chez LoTW sans jamais être marqué ici." "LoTW : une option « Tous mes indicatifs » à côté du téléchargement. Celui-ci est limité à l'indicatif du profil, si bien qu'un QSO fait sous un indicatif portable ou de contest était confirmé chez LoTW sans jamais être marqué ici. Les confirmations faites sous un indicatif que ce carnet n'a jamais utilisé sont ignorées, et un rapport anormalement petit affiche désormais ce que LoTW a réellement répondu."
] ]
}, },
{ {
+27
View File
@@ -2984,6 +2984,33 @@ func DedupeKey(callsign, qsoDateMinute, band, mode string) string {
return strings.ToUpper(callsign) + "|" + qsoDateMinute + "|" + strings.ToLower(band) + "|" + strings.ToUpper(mode) return strings.ToUpper(callsign) + "|" + qsoDateMinute + "|" + strings.ToLower(band) + "|" + strings.ToUpper(mode)
} }
// StationCallsigns lists the distinct station callsigns the logbook was worked
// under, upper-cased and without the blanks.
//
// Used to decide whether a downloaded confirmation belongs to THIS log at all:
// one LoTW account can hold several stations (a home call, a portable, an
// expedition), and a confirmation for a station this logbook has never used is
// somebody else's log — here, another profile's.
func (r *Repo) StationCallsigns(ctx context.Context) (map[string]bool, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT COALESCE(station_callsign,'') FROM qso`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]bool{}
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
return nil, err
}
if c = strings.ToUpper(strings.TrimSpace(c)); c != "" {
out[c] = true
}
}
return out, rows.Err()
}
// DedupeKeyIDs returns a map of dedupe key → QSO id, for matching downloaded // DedupeKeyIDs returns a map of dedupe key → QSO id, for matching downloaded
// confirmations back to local QSOs. // confirmations back to local QSOs.
func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) { func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {