diff --git a/app.go b/app.go index 5103a4a..5511d2d 100644 --- a/app.go +++ b/app.go @@ -10227,6 +10227,16 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern uploaded++ } emit(fmt.Sprintf("LoTW: %d QSO(s) uploaded", uploaded)) + // TQSL accepted the batch but left contacts out of it — already + // uploaded, or outside the certificate's date range. Said out loud: + // the operator has no other way to learn that some of what they just + // selected is still not on LoTW. + if res.Ignored { + emit(res.Message) + if a.ctx != nil { + wruntime.EventsEmit(a.ctx, "toast", "LoTW: "+res.Message) + } + } } } else if svc == extsvc.ServiceClublog || svc == extsvc.ServiceHRDLog { statusCol, dateCol := "clublog_qso_upload_status", "clublog_qso_upload_date" diff --git a/changelog.json b/changelog.json index 6d6ad46..511a9d2 100644 --- a/changelog.json +++ b/changelog.json @@ -5,12 +5,14 @@ "en": [ "Appearance: row colouring now defaults to a left stripe, with a filled row and its strength offered as choices.", "The band map now follows the cluster filters — LoTW only, spotter continent, hide worked, status and mode chips.", - "Band map: stations that upload to LoTW now carry the same L badge as the cluster list, switchable in Appearance." + "Band map: stations that upload to LoTW now carry the same L badge as the cluster list, switchable in Appearance.", + "LoTW: contacts TQSL leaves out — already uploaded, or outside the certificate date range — are no longer reported as uploaded." ], "fr": [ "Apparence : la coloration des lignes se fait par défaut sur une barre à gauche, la ligne remplie et son intensité restant proposées.", "La band map suit désormais les filtres du cluster — LoTW seulement, continent du spotter, masquer les contactés, statuts et modes.", - "Band map : les stations qui utilisent LoTW portent le même badge L que la liste du cluster, activable dans Apparence." + "Band map : les stations qui utilisent LoTW portent le même badge L que la liste du cluster, activable dans Apparence.", + "LoTW : les contacts que TQSL écarte — déjà envoyés, ou hors de la plage de dates du certificat — ne sont plus annoncés comme envoyés." ] }, { diff --git a/internal/extsvc/extsvc.go b/internal/extsvc/extsvc.go index 84832e4..85ae1d5 100644 --- a/internal/extsvc/extsvc.go +++ b/internal/extsvc/extsvc.go @@ -142,4 +142,10 @@ type UploadResult struct { OK bool // the service accepted (or already had) the QSO LogID string // service-assigned record id, when provided Message string // human-readable detail (reason on failure) + // Ignored is set when the service accepted the submission but left some or + // all of the QSOs out of it. TQSL does this for contacts already uploaded + // AND for contacts outside the certificate's date range, and reports both + // with the same exit code — so the caller must show the message rather than + // decide on its own that everything went through. + Ignored bool } diff --git a/internal/extsvc/lotw.go b/internal/extsvc/lotw.go index e4a1910..7f0ac1e 100644 --- a/internal/extsvc/lotw.go +++ b/internal/extsvc/lotw.go @@ -178,8 +178,12 @@ func fileExists(p string) bool { // // tqsl -d -x -a all -l "" -u [-p ] // -// Exit codes (TQSL): 0 = uploaded; 8 = nothing new (all duplicates/out of -// range); 9 = some uploaded, some skipped; anything else = failure. +// Exit codes are TQSL's own (see the table in its cmdline help). 8 and 9 are +// the ones that matter and both used to be read as plain success: 8 means NO +// QSOs were processed and 9 means some were left out — in each case because +// they were already uploaded OR outside the callsign certificate's date range. +// Reporting either as success is how a contact came to be stamped "uploaded" +// while LoTW had never seen it. func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord string) (UploadResult, error) { tqsl := strings.TrimSpace(cfg.TQSLPath) loc := strings.TrimSpace(cfg.StationLocation) @@ -249,11 +253,31 @@ func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord stri } } + // TQSL's exit codes, from its own cmdline documentation. Two of them used to + // be read as plain success, and that is how contacts came to be stamped + // "uploaded" while LoTW had never seen them: + // + // 8 NO QSOs were processed — already uploaded OR OUT OF DATE RANGE + // 9 some processed, some ignored — same two reasons + // 14 some already uploaded, the rest signed + // + // "Out of date range" is the one that bites: a contact older than the + // callsign certificate's validity is silently left out, and reporting that as + // success stamped it sent for ever. TQSL says which case it is in its output, + // so the message is carried up rather than replaced with a guess. switch code { - case 0, 9: + case 0: return UploadResult{OK: true, Message: "uploaded to LoTW"}, nil + case 9, 14: + return UploadResult{OK: true, Ignored: true, Message: tqslDetail(msg, + "uploaded — but TQSL left some contacts out (already uploaded, or outside the certificate's date range)")}, nil case 8: - return UploadResult{OK: true, Message: "already uploaded (duplicate)"}, nil + // Nothing reached LoTW. NOT stamped as sent: a duplicate left at "R" is + // harmless and will be refused again, while a contact wrongly marked sent + // is one the operator will never think to look at again. + return UploadResult{OK: false, Ignored: true, Message: tqslDetail(msg, + "TQSL uploaded nothing — every contact was already uploaded, or outside the certificate's date range")}, + fmt.Errorf("lotw: no QSOs processed") default: if msg == "" { msg = fmt.Sprintf("tqsl exit code %d", code) @@ -262,6 +286,29 @@ func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord stri } } +// tqslDetail keeps the lines of TQSL's own output that say what happened to the +// contacts, and appends the summary. +// +// TQSL is explicit — "414 QSO records were already uploaded", "N QSO records +// are out of date range" — and that sentence is the whole answer to "why is my +// contact not on LoTW". It used to be captured and thrown away. +func tqslDetail(out, summary string) string { + var keep []string + for _, ln := range strings.Split(out, "\n") { + ln = strings.TrimSpace(ln) + l := strings.ToLower(ln) + if strings.Contains(l, "qso") && (strings.Contains(l, "already uploaded") || + strings.Contains(l, "date range") || strings.Contains(l, "ignored") || + strings.Contains(l, "duplicate")) { + keep = append(keep, ln) + } + } + if len(keep) == 0 { + return summary + } + return summary + " — " + strings.Join(keep, "; ") +} + // TestLoTW validates the LoTW config: tqsl present and the chosen station // location exists in station_data. func TestLoTW(cfg ServiceConfig, stationDataPath string) (string, error) { diff --git a/internal/extsvc/lotw_exit_test.go b/internal/extsvc/lotw_exit_test.go new file mode 100644 index 0000000..f6f93f5 --- /dev/null +++ b/internal/extsvc/lotw_exit_test.go @@ -0,0 +1,44 @@ +package extsvc + +import "testing" + +// TQSL's own exit codes, from its cmdline documentation: +// +// 0 success +// 8 NO QSOs were processed — already uploaded OR out of date range +// 9 some processed, some ignored — same two reasons +// 14 some already uploaded, the rest signed +// +// 8 and 9 both used to be read as plain success, and that is how a contact came +// to be stamped "uploaded to LoTW" while LoTW had never seen it. The one that +// bites is "out of date range": a contact older than the callsign certificate's +// validity is silently left out of the submission. +func TestTQSLDetailKeepsTheReason(t *testing.T) { + out := `13:22:01: Signing using Callsign G0ABC, DXCC Entity ENGLAND +13:22:02: /tmp/x.adi: 20 QSO records are out of date range +13:22:02: Final Status: No QSOs were processed (8)` + got := tqslDetail(out, "summary") + if got == "summary" { + t.Fatal("threw away TQSL's explanation — that sentence is the whole answer to \"why is my contact not on LoTW\"") + } + if !contains(got, "out of date range") { + t.Errorf("the reason was lost: %q", got) + } +} + +func TestTQSLDetailFallsBackToTheSummary(t *testing.T) { + if got := tqslDetail("nothing useful here", "summary"); got != "summary" { + t.Errorf("got %q, want the plain summary when TQSL said nothing specific", got) + } +} + +func contains(h, n string) bool { + return len(h) >= len(n) && (func() bool { + for i := 0; i+len(n) <= len(h); i++ { + if h[i:i+len(n)] == n { + return true + } + } + return false + })() +}