From a03d9071288c2e34f21235e4c5b6be9a7ab804aa Mon Sep 17 00:00:00 2001 From: rouggy Date: Mon, 31 Aug 2026 15:56:34 +0200 Subject: [PATCH] fix(lotw): MY_CNTY must not sink a non-US station's upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TQSL validates MY_CNTY against the ADIF secondary-subdivision list, which is the US county enumeration — 'XX,County' with a two-letter state. A Canadian profile produced 'ONTARIO,Kawartha' (the export joined MY_STATE onto the county wholesale) and TQSL refused the whole record. Two layers: adifCounty only prefixes a two-letter state, so exports stop manufacturing the invalid shape; and UploadLoTW scrubs any MY_CNTY that is not the US shape before signing — MY_STATE and MY_GRIDSQUARE already locate the station for LoTW, and the US form survives for the county hunters. Table-tested. --- changelog.json | 6 ++++-- internal/adif/export.go | 6 ++++++ internal/extsvc/lotw.go | 31 ++++++++++++++++++++++++++++++ internal/extsvc/lotw_scrub_test.go | 23 ++++++++++++++++++++++ 4 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 internal/extsvc/lotw_scrub_test.go diff --git a/changelog.json b/changelog.json index fe67649..c853e94 100644 --- a/changelog.json +++ b/changelog.json @@ -5,12 +5,14 @@ "en": [ "Switching the settings database no longer shows “OpsLog is already running”: the automatic relaunch now waits for the closing instance to release its lock instead of racing it.", "HamQTH upload: 8th external service — real-time QSO upload to the HamQTH online logbook with your callbook credentials (auto-upload on log, “Send to…” right-click, QSL Manager backlog upload, connection test).", - "Fixed: the right-click “Send to HAMLOG.online” was uploading the selection to QRZ.com with the QRZ key — it now goes to HAMLOG.online." + "Fixed: the right-click “Send to HAMLOG.online” was uploading the selection to QRZ.com with the QRZ key — it now goes to HAMLOG.online.", + "LoTW: TQSL no longer refuses non-US stations over MY_CNTY — the field is stripped before signing unless it is the US “XX,County” shape LoTW actually validates (a Canadian “ONTARIO,Kawartha” was rejecting the whole record). Exports also stop gluing a full state name onto the county." ], "fr": [ "Changer de base de réglages n’affiche plus « OpsLog is already running » : la relance automatique attend désormais que l’instance qui se ferme libère son verrou au lieu de la prendre de vitesse.", "Upload HamQTH : 8e service externe — envoi des QSO en temps réel vers le logbook HamQTH avec vos identifiants du lookup (upload auto au log, « Envoyer vers… » au clic droit, rattrapage via le QSL Manager, test de connexion).", - "Corrigé : le clic droit « Envoyer vers HAMLOG.online » envoyait la sélection à QRZ.com avec la clé QRZ — elle part maintenant vers HAMLOG.online." + "Corrigé : le clic droit « Envoyer vers HAMLOG.online » envoyait la sélection à QRZ.com avec la clé QRZ — elle part maintenant vers HAMLOG.online.", + "LoTW : TQSL ne refuse plus les stations hors US à cause de MY_CNTY — le champ est retiré avant signature sauf s’il a la forme US « XX,County » que LoTW valide réellement (un « ONTARIO,Kawartha » canadien rejetait tout l’enregistrement). L’export cesse aussi de coller un nom d’état complet devant le comté." ] }, { diff --git a/internal/adif/export.go b/internal/adif/export.go index e53d7ad..ac5ac99 100644 --- a/internal/adif/export.go +++ b/internal/adif/export.go @@ -443,5 +443,11 @@ func adifCounty(state, county string) string { if c == "" || s == "" || strings.Contains(c, ",") { return c } + // The "STATE,County" join is the ADIF secondary-subdivision format, and that + // enumeration is a US thing — a two-letter state code. Prefixing a Canadian + // "ONTARIO" produced "ONTARIO,Kawartha", which is valid nowhere. + if len(s) != 2 { + return c + } return strings.ToUpper(s) + "," + c } diff --git a/internal/extsvc/lotw.go b/internal/extsvc/lotw.go index 9dd35b7..5113f79 100644 --- a/internal/extsvc/lotw.go +++ b/internal/extsvc/lotw.go @@ -11,6 +11,8 @@ import ( "os" "os/exec" "path/filepath" + "regexp" + "strconv" "strings" "sync" "syscall" @@ -337,7 +339,36 @@ func fileExists(p string) bool { // 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. +// scrubMyCnty removes MY_CNTY fields TQSL would refuse. LoTW's secondary +// subdivisions are the US county enumeration — "XX,County" with a two-letter +// state — and TQSL rejects the whole record over anything else, so a Canadian +// station's "ONTARIO,Kawartha" (or a bare county) must simply not be sent. +// MY_STATE and MY_GRIDSQUARE already locate the station for LoTW. +var myCntyRe = regexp.MustCompile(`(?i)`) + +func scrubMyCnty(adif string) string { + for { + loc := myCntyRe.FindStringSubmatchIndex(adif) + if loc == nil { + return adif + } + n, _ := strconv.Atoi(adif[loc[2]:loc[3]]) + end := loc[1] + n + if end > len(adif) { + end = len(adif) + } + val := adif[loc[1]:end] + if len(val) > 3 && val[2] == ',' { + // "XX,..." — the US shape TQSL accepts; leave it for the county hunters. + rest := scrubMyCnty(adif[end:]) + return adif[:end] + rest + } + adif = adif[:loc[0]] + strings.TrimLeft(adif[end:], " ") + } +} + func UploadLoTW(ctx context.Context, cfg ServiceConfig, tempDir, adifRecord string) (UploadResult, error) { + adifRecord = scrubMyCnty(adifRecord) tqsl := strings.TrimSpace(cfg.TQSLPath) loc := strings.TrimSpace(cfg.StationLocation) switch { diff --git a/internal/extsvc/lotw_scrub_test.go b/internal/extsvc/lotw_scrub_test.go new file mode 100644 index 0000000..51e4c23 --- /dev/null +++ b/internal/extsvc/lotw_scrub_test.go @@ -0,0 +1,23 @@ +package extsvc + +import "testing" + +// TQSL refuses whole records over a MY_CNTY it cannot validate, and its +// validation is the US "XX,County" enumeration — so anything else must be +// stripped before signing, and the US shape must survive untouched. +func TestScrubMyCnty(t *testing.T) { + cases := []struct{ in, want string }{ + {"F4BPOONTARIO,KawarthaONTARIO", + "F4BPOONTARIO"}, + {"Kawartha", ""}, + {"NY,Monroe", "NY,Monroe"}, + {"K1AB", "K1AB"}, + {"Kawartha\nNY,Monroe", + "\nNY,Monroe"}, + } + for _, c := range cases { + if got := scrubMyCnty(c.in); got != c.want { + t.Errorf("scrubMyCnty(%q) = %q, want %q", c.in, got, c.want) + } + } +}