diff --git a/app.go b/app.go index 8f8b1fb..d7b4a2f 100644 --- a/app.go +++ b/app.go @@ -12190,6 +12190,19 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service, 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 == "" { callLabel = "all callsigns" } @@ -12206,6 +12219,19 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service, return } 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) if kerr != nil { 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"}) var items []ConfirmationItem var unmatched []string + skippedOtherStation := 0 perr := adif.Parse(strings.NewReader(adifText), func(rec adif.Record) error { if ctx.Err() != nil { return ctx.Err() // window closed / superseded @@ -12231,6 +12258,16 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service, if !ok { 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++ date := rec["qslrdate"] if date == "" { @@ -12311,6 +12348,9 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service, } else { 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 { // Said out loud rather than folded silently into the total: these // matched on the mode CLASS, not the mode. LoTW hands back "DATA" for diff --git a/changelog.json b/changelog.json index 2576e30..98cbecb 100644 --- a/changelog.json +++ b/changelog.json @@ -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 QSO’s 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.", "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 profile’s 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 profile’s 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": [ "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.", "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.", - "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." ] }, { diff --git a/internal/qso/qso.go b/internal/qso/qso.go index 221f010..361e786 100644 --- a/internal/qso/qso.go +++ b/internal/qso/qso.go @@ -2984,6 +2984,33 @@ func DedupeKey(callsign, qsoDateMinute, band, mode string) string { 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 // confirmations back to local QSOs. func (r *Repo) DedupeKeyIDs(ctx context.Context) (map[string]int64, error) {