fix(adif): CNTY must be STATE,COUNTY

ADIF defines CNTY as "STATE,COUNTY" — "GA,BARROW". OpsLog wrote the bare county
name, which a receiving logger cannot resolve: county names repeat across
states, and there is a Washington County in thirty of them. The award engine
here was never affected, since it reads the columns rather than the ADIF; the
damage was to every file we hand to someone else.

One writer covers everything — writeRecord — so this fixes file exports and the
LoTW, Club Log, HRDLog and QRZ uploads together. MY_CNTY gets the same
treatment.

The county alone is still written when no state is known: a bare name is worth
more than nothing, and inventing a prefix would be worse than either. A value
that already carries a comma passes through untouched, so re-exporting an
imported record cannot double the prefix.

Import is the mirror: "GA,BARROW" fills both columns, and a file carrying the
bare county — as OpsLog's own older exports do — still imports unchanged. The
state parsed out of CNTY only fills a blank STATE, never overrides it: STATE is
the dedicated field and the more specific statement.
This commit is contained in:
2026-08-13 12:15:09 +02:00
parent 2695747db6
commit dc0b00b474
7 changed files with 124 additions and 27 deletions
+4 -2
View File
@@ -9,7 +9,8 @@
"Entry form: a narrower left column, State beside the locator, and a new line with County, CQ, ITU and DXCC.", "Entry form: a narrower left column, State beside the locator, and a new line with County, CQ, ITU and DXCC.",
"Station Control: the short and long path headings are repeated under the compass, at a readable size and clickable.", "Station Control: the short and long path headings are repeated under the compass, at a readable size and clickable.",
"Compact mode: the window now fits the entry strip instead of leaving a band of empty space below it.", "Compact mode: the window now fits the entry strip instead of leaving a band of empty space below it.",
"HRDLog: an ON AIR option publishes your live frequency, mode and rig on hrdlog.net." "HRDLog: an ON AIR option publishes your live frequency, mode and rig on hrdlog.net.",
"ADIF: the county is exported as STATE,COUNTY as the standard requires, and a county imported that way now fills the state too."
], ],
"fr": [ "fr": [
"Cluster : « Masquer les contactés » ne masque plus un spot qui est un nouveau préfixe, comté, carré ou parc dans une contrée déjà faite.", "Cluster : « Masquer les contactés » ne masque plus un spot qui est un nouveau préfixe, comté, carré ou parc dans une contrée déjà faite.",
@@ -18,7 +19,8 @@
"Saisie : colonne de gauche plus étroite, État à côté du locator, et une nouvelle ligne Comté, CQ, ITU et DXCC.", "Saisie : colonne de gauche plus étroite, État à côté du locator, et une nouvelle ligne Comté, CQ, ITU et DXCC.",
"Contrôle station : les azimuts court et long chemin sont repris sous la boussole, lisibles et cliquables.", "Contrôle station : les azimuts court et long chemin sont repris sous la boussole, lisibles et cliquables.",
"Mode compact : la fenêtre épouse la bande de saisie au lieu de laisser une bande vide en dessous.", "Mode compact : la fenêtre épouse la bande de saisie au lieu de laisser une bande vide en dessous.",
"HRDLog : une option ON AIR publie ta fréquence, ton mode et ta radio en direct sur hrdlog.net." "HRDLog : une option ON AIR publie ta fréquence, ton mode et ta radio en direct sur hrdlog.net.",
"ADIF : le comté est exporté sous la forme ÉTAT,COMTÉ comme l exige le standard, et un comté importé ainsi remplit aussi l état."
] ]
}, },
{ {
+2 -2
View File
@@ -19,8 +19,8 @@ func TestCharCountLengthRepair(t *testing.T) {
}, },
{ {
name: "cyrillic", name: "cyrillic",
wantQTH: "Дзержинск", // 9 chars / 18 bytes, declared 9 wantQTH: "Дзержинск", // 9 chars / 18 bytes, declared 9
wantName: "Александр Чайка", // 15 chars / 29 bytes, declared 15 wantName: "Александр Чайка", // 15 chars / 29 bytes, declared 15
adi: "<EOH>\n<CALL:6>UA3TFS<NAME:15>Александр Чайка<QTH:9>Дзержинск<EOR>\n", adi: "<EOH>\n<CALL:6>UA3TFS<NAME:15>Александр Чайка<QTH:9>Дзержинск<EOR>\n",
}, },
} }
+46
View File
@@ -0,0 +1,46 @@
package adif
import "testing"
// ADIF defines CNTY as "STATE,COUNTY" — "GA,BARROW". OpsLog exported the bare
// county, which a receiving logger cannot resolve: county names repeat across
// states, and there is a Washington County in thirty of them.
func TestADIFCountyFormat(t *testing.T) {
for _, tc := range []struct{ state, county, want string }{
{"GA", "BARROW", "GA,BARROW"},
{"ga", "Barrow", "GA,Barrow"}, // the state is a code, upper-cased; the name is not touched
{"", "BARROW", "BARROW"}, // no state known: a bare name beats nothing
{"GA", "", ""}, // no county: nothing to write
{"GA", "GA,BARROW", "GA,BARROW"}, // already formatted — never double the prefix
{" GA ", " BARROW ", "GA,BARROW"},
} {
if got := adifCounty(tc.state, tc.county); got != tc.want {
t.Errorf("adifCounty(%q, %q) = %q, want %q", tc.state, tc.county, got, tc.want)
}
}
}
// And the mirror: a file from a logger that follows the standard must fill both
// columns, while a file carrying the bare county still imports unchanged.
func TestSplitADIFCounty(t *testing.T) {
for _, tc := range []struct{ in, county, state string }{
{"GA,BARROW", "BARROW", "GA"},
{"ga, Barrow", "Barrow", "GA"},
{"BARROW", "BARROW", ""}, // the old OpsLog form, and many other loggers
{"", "", ""},
{" ", "", ""},
} {
c, s := splitADIFCounty(tc.in)
if c != tc.county || s != tc.state {
t.Errorf("splitADIFCounty(%q) = (%q, %q), want (%q, %q)", tc.in, c, s, tc.county, tc.state)
}
}
}
// Round trip: what OpsLog writes, OpsLog reads back to the same two values.
func TestCountyRoundTrip(t *testing.T) {
c, s := splitADIFCounty(adifCounty("GA", "BARROW"))
if c != "BARROW" || s != "GA" {
t.Errorf("round trip gave (%q, %q), want (BARROW, GA)", c, s)
}
}
+29 -7
View File
@@ -15,9 +15,9 @@ import (
// ExportResult summarises an ADIF export for the UI. // ExportResult summarises an ADIF export for the UI.
type ExportResult struct { type ExportResult struct {
Path string `json:"path"` Path string `json:"path"`
Count int `json:"count"` Count int `json:"count"`
SizeKB int64 `json:"size_kb"` SizeKB int64 `json:"size_kb"`
} }
// Exporter streams every QSO in a repo to an ADIF (.adi) file. // Exporter streams every QSO in a repo to an ADIF (.adi) file.
@@ -221,7 +221,7 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
w("VUCC_GRIDS", q.VUCCGrids) w("VUCC_GRIDS", q.VUCCGrids)
w("COUNTRY", q.Country) w("COUNTRY", q.Country)
w("STATE", q.State) w("STATE", q.State)
w("CNTY", q.County) w("CNTY", adifCounty(q.State, q.County))
wi("DXCC", q.DXCC) wi("DXCC", q.DXCC)
w("CONT", q.Continent) w("CONT", q.Continent)
wi("CQZ", q.CQZ) wi("CQZ", q.CQZ)
@@ -285,7 +285,7 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]
w("MY_GRIDSQUARE_EXT", q.MyGridExt) w("MY_GRIDSQUARE_EXT", q.MyGridExt)
w("MY_COUNTRY", q.MyCountry) w("MY_COUNTRY", q.MyCountry)
w("MY_STATE", q.MyState) w("MY_STATE", q.MyState)
w("MY_CNTY", q.MyCounty) w("MY_CNTY", adifCounty(q.MyState, q.MyCounty))
w("MY_IOTA", q.MyIOTA) w("MY_IOTA", q.MyIOTA)
w("MY_SOTA_REF", q.MySOTARef) w("MY_SOTA_REF", q.MySOTARef)
w("MY_POTA_REF", q.MyPOTARef) w("MY_POTA_REF", q.MyPOTARef)
@@ -391,10 +391,10 @@ var parentMode = map[string]string{
"ISCAT": "MFSK", "Q65": "MFSK", "FST4": "MFSK", "FST4W": "MFSK", "ISCAT": "MFSK", "Q65": "MFSK", "FST4": "MFSK", "FST4W": "MFSK",
"MFSK16": "MFSK", "MFSK32": "MFSK", "MFSK64": "MFSK", "MFSK128": "MFSK", "MFSK16": "MFSK", "MFSK32": "MFSK", "MFSK64": "MFSK", "MFSK128": "MFSK",
"OLIVIA": "MFSK", "OLIVIA": "MFSK",
"PSK31": "PSK", "PSK63": "PSK", "PSK125": "PSK", "PSK250": "PSK", "PSK500": "PSK", "PSK31": "PSK", "PSK63": "PSK", "PSK125": "PSK", "PSK250": "PSK", "PSK500": "PSK",
"QPSK31": "PSK", "QPSK63": "PSK", "QPSK125": "PSK", "QPSK250": "PSK", "QPSK500": "PSK", "QPSK31": "PSK", "QPSK63": "PSK", "QPSK125": "PSK", "QPSK250": "PSK", "QPSK500": "PSK",
"FREEDV": "DIGITALVOICE", "FREEDV": "DIGITALVOICE",
"VARA": "DYNAMIC", "VARA HF": "DYNAMIC", "VARA FM": "DYNAMIC", "VARAC": "DYNAMIC", "VARA": "DYNAMIC", "VARA HF": "DYNAMIC", "VARA FM": "DYNAMIC", "VARAC": "DYNAMIC",
"THOR4": "THOR", "THOR8": "THOR", "THOR16": "THOR", "THOR32": "THOR", "THOR4": "THOR", "THOR8": "THOR", "THOR16": "THOR", "THOR32": "THOR",
"DOMINOF": "DOMINO", "DOMINOEX": "DOMINO", "DOMINOF": "DOMINO", "DOMINOEX": "DOMINO",
"HELL80": "HELL", "FMHELL": "HELL", "HELL80": "HELL", "FMHELL": "HELL",
@@ -413,3 +413,25 @@ func modeForExport(mode, submode string) (string, string) {
} }
return mode, "" return mode, ""
} }
// adifCounty renders CNTY the way ADIF specifies: "STATE,COUNTY", e.g.
// "GA,BARROW".
//
// OpsLog stores the two apart, and exported only the county — which a receiving
// logger cannot resolve, since county names repeat across states (there is a
// Washington County in thirty of them). The award engine here was never
// affected because it reads the columns, not the ADIF; the damage was to
// everyone we send a file to.
//
// The county alone is still written when no state is known: a bare name is
// worth more than nothing, and inventing a prefix would be worse than either.
// A county that ALREADY carries a comma is passed through — it has been
// formatted once, and doubling the prefix would corrupt it.
func adifCounty(state, county string) string {
c := strings.TrimSpace(county)
s := strings.TrimSpace(state)
if c == "" || s == "" || strings.Contains(c, ",") {
return c
}
return strings.ToUpper(s) + "," + c
}
+13 -13
View File
@@ -16,23 +16,23 @@ import "strings"
type FieldKind string type FieldKind string
const ( const (
KindText FieldKind = "text" // String / IntlString / MultilineString KindText FieldKind = "text" // String / IntlString / MultilineString
KindNumber FieldKind = "number" // Number / PositiveInteger KindNumber FieldKind = "number" // Number / PositiveInteger
KindDate FieldKind = "date" // ADIF Date (YYYYMMDD) KindDate FieldKind = "date" // ADIF Date (YYYYMMDD)
KindTime FieldKind = "time" // ADIF Time (HHMMSS / HHMM) KindTime FieldKind = "time" // ADIF Time (HHMMSS / HHMM)
KindBool FieldKind = "boolean" // Boolean (Y/N) KindBool FieldKind = "boolean" // Boolean (Y/N)
KindEnum FieldKind = "enum" // Enumeration KindEnum FieldKind = "enum" // Enumeration
KindLoc FieldKind = "location" // Location (e.g. "N048 09.000") KindLoc FieldKind = "location" // Location (e.g. "N048 09.000")
) )
// FieldDef describes one ADIF QSO field. // FieldDef describes one ADIF QSO field.
type FieldDef struct { type FieldDef struct {
Name string `json:"name"` // canonical uppercase ADIF tag Name string `json:"name"` // canonical uppercase ADIF tag
Kind FieldKind `json:"kind"` // editor widget hint Kind FieldKind `json:"kind"` // editor widget hint
Category string `json:"category"` // grouping for the UI Category string `json:"category"` // grouping for the UI
Promoted bool `json:"promoted"` // has a dedicated QSO column Promoted bool `json:"promoted"` // has a dedicated QSO column
Deprecated bool `json:"deprecated"` // import-only per the spec Deprecated bool `json:"deprecated"` // import-only per the spec
Intl bool `json:"intl"` // *_INTL UTF-8 variant Intl bool `json:"intl"` // *_INTL UTF-8 variant
} }
// adifVersion is the ADIF spec version OpsLog targets for import/export. // adifVersion is the ADIF spec version OpsLog targets for import/export.
+29 -2
View File
@@ -428,7 +428,12 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
q.VUCCGrids = strings.ToUpper(rec["vucc_grids"]) q.VUCCGrids = strings.ToUpper(rec["vucc_grids"])
q.Country = rec["country"] q.Country = rec["country"]
q.State = strings.ToUpper(rec["state"]) q.State = strings.ToUpper(rec["state"])
q.County = rec["cnty"] q.County, _ = splitADIFCounty(rec["cnty"])
if st := rec["state"]; strings.TrimSpace(st) == "" {
if _, fromCnty := splitADIFCounty(rec["cnty"]); fromCnty != "" {
q.State = fromCnty
}
}
if v, ok := parseInt(rec["dxcc"]); ok { if v, ok := parseInt(rec["dxcc"]); ok {
q.DXCC = &v q.DXCC = &v
} }
@@ -515,7 +520,12 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
q.MyGridExt = strings.ToUpper(rec["my_gridsquare_ext"]) q.MyGridExt = strings.ToUpper(rec["my_gridsquare_ext"])
q.MyCountry = rec["my_country"] q.MyCountry = rec["my_country"]
q.MyState = strings.ToUpper(rec["my_state"]) q.MyState = strings.ToUpper(rec["my_state"])
q.MyCounty = rec["my_cnty"] q.MyCounty, _ = splitADIFCounty(rec["my_cnty"])
if st := rec["my_state"]; strings.TrimSpace(st) == "" {
if _, fromCnty := splitADIFCounty(rec["my_cnty"]); fromCnty != "" {
q.MyState = fromCnty
}
}
q.MyIOTA = strings.ToUpper(rec["my_iota"]) q.MyIOTA = strings.ToUpper(rec["my_iota"])
q.MySOTARef = strings.ToUpper(rec["my_sota_ref"]) q.MySOTARef = strings.ToUpper(rec["my_sota_ref"])
q.MyPOTARef = strings.ToUpper(rec["my_pota_ref"]) q.MyPOTARef = strings.ToUpper(rec["my_pota_ref"])
@@ -698,3 +708,20 @@ var promotableSubmodes = map[string]bool{
func submodeSubsumesParent(submode string) bool { func submodeSubsumesParent(submode string) bool {
return promotableSubmodes[submode] return promotableSubmodes[submode]
} }
// splitADIFCounty reads CNTY, which ADIF defines as "STATE,COUNTY".
//
// Returns the county on its own plus the state the field carried, so an import
// from a logger that follows the standard fills BOTH columns here. Files that
// write the bare county — as OpsLog itself used to — still import unchanged.
//
// The state from the field never overrides an explicit STATE tag: STATE is the
// dedicated field and the more specific statement. It only fills a blank.
func splitADIFCounty(cnty string) (county, state string) {
cnty = strings.TrimSpace(cnty)
i := strings.Index(cnty, ",")
if i < 0 {
return cnty, ""
}
return strings.TrimSpace(cnty[i+1:]), strings.ToUpper(strings.TrimSpace(cnty[:i]))
}
+1 -1
View File
@@ -20,7 +20,7 @@ func TestPromotedFieldsRoundTrip(t *testing.T) {
in := qso.QSO{ in := qso.QSO{
Callsign: "EA8ABC", Band: "20m", Mode: "SSB", Callsign: "EA8ABC", Band: "20m", Mode: "SSB",
QSODate: time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC), QSODate: time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC),
SIG: "POTA", SIGInfo: "US-0001", MySIG: "WWFF", MySIGInfo: "ONFF-0001", SIG: "POTA", SIGInfo: "US-0001", MySIG: "WWFF", MySIGInfo: "ONFF-0001",
WWFFRef: "ONFF-0001", MyWWFFRef: "F-FFF-0001", WWFFRef: "ONFF-0001", MyWWFFRef: "F-FFF-0001",
Distance: &dist, RXPower: &rxp, AIndex: &a, Distance: &dist, RXPower: &rxp, AIndex: &a,
SKCC: "12345S", FISTS: "999", TenTen: "55555", SKCC: "12345S", FISTS: "999", TenTen: "55555",