fix(lotw): MY_CNTY must not sink a non-US station's upload

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.
This commit is contained in:
2026-08-31 15:56:34 +02:00
parent 91569e12f4
commit a03d907128
4 changed files with 64 additions and 2 deletions
+6
View File
@@ -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
}
+31
View File
@@ -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)<MY_CNTY:([0-9]+)(?::[A-Za-z])?>`)
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 {
+23
View File
@@ -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 }{
{"<CALL:5>F4BPO<MY_CNTY:16>ONTARIO,Kawartha<MY_STATE:7>ONTARIO<EOR>",
"<CALL:5>F4BPO<MY_STATE:7>ONTARIO<EOR>"},
{"<MY_CNTY:8>Kawartha<EOR>", "<EOR>"},
{"<MY_CNTY:9>NY,Monroe<EOR>", "<MY_CNTY:9>NY,Monroe<EOR>"},
{"<CALL:4>K1AB<EOR>", "<CALL:4>K1AB<EOR>"},
{"<MY_CNTY:8>Kawartha<EOR>\n<MY_CNTY:9>NY,Monroe<EOR>",
"<EOR>\n<MY_CNTY:9>NY,Monroe<EOR>"},
}
for _, c := range cases {
if got := scrubMyCnty(c.in); got != c.want {
t.Errorf("scrubMyCnty(%q) = %q, want %q", c.in, got, c.want)
}
}
}