diff --git a/changelog.json b/changelog.json index 9251980..7fb15f0 100644 --- a/changelog.json +++ b/changelog.json @@ -9,7 +9,8 @@ "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.", "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": [ "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.", "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.", - "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." ] }, { diff --git a/internal/adif/charcount_test.go b/internal/adif/charcount_test.go index 2cf04ac..ef07f22 100644 --- a/internal/adif/charcount_test.go +++ b/internal/adif/charcount_test.go @@ -19,8 +19,8 @@ func TestCharCountLengthRepair(t *testing.T) { }, { name: "cyrillic", - wantQTH: "Дзержинск", // 9 chars / 18 bytes, declared 9 - wantName: "Александр Чайка", // 15 chars / 29 bytes, declared 15 + wantQTH: "Дзержинск", // 9 chars / 18 bytes, declared 9 + wantName: "Александр Чайка", // 15 chars / 29 bytes, declared 15 adi: "\nUA3TFSАлександр ЧайкаДзержинск\n", }, } diff --git a/internal/adif/county_test.go b/internal/adif/county_test.go new file mode 100644 index 0000000..114c462 --- /dev/null +++ b/internal/adif/county_test.go @@ -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) + } +} diff --git a/internal/adif/export.go b/internal/adif/export.go index 57a2a73..95d44ac 100644 --- a/internal/adif/export.go +++ b/internal/adif/export.go @@ -15,9 +15,9 @@ import ( // ExportResult summarises an ADIF export for the UI. type ExportResult struct { - Path string `json:"path"` - Count int `json:"count"` - SizeKB int64 `json:"size_kb"` + Path string `json:"path"` + Count int `json:"count"` + SizeKB int64 `json:"size_kb"` } // 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("COUNTRY", q.Country) w("STATE", q.State) - w("CNTY", q.County) + w("CNTY", adifCounty(q.State, q.County)) wi("DXCC", q.DXCC) w("CONT", q.Continent) 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_COUNTRY", q.MyCountry) 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_SOTA_REF", q.MySOTARef) w("MY_POTA_REF", q.MyPOTARef) @@ -391,10 +391,10 @@ var parentMode = map[string]string{ "ISCAT": "MFSK", "Q65": "MFSK", "FST4": "MFSK", "FST4W": "MFSK", "MFSK16": "MFSK", "MFSK32": "MFSK", "MFSK64": "MFSK", "MFSK128": "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", "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", "DOMINOF": "DOMINO", "DOMINOEX": "DOMINO", "HELL80": "HELL", "FMHELL": "HELL", @@ -413,3 +413,25 @@ func modeForExport(mode, submode string) (string, string) { } 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 +} diff --git a/internal/adif/fields.go b/internal/adif/fields.go index 625bc52..c61b3ff 100644 --- a/internal/adif/fields.go +++ b/internal/adif/fields.go @@ -16,23 +16,23 @@ import "strings" type FieldKind string const ( - KindText FieldKind = "text" // String / IntlString / MultilineString - KindNumber FieldKind = "number" // Number / PositiveInteger - KindDate FieldKind = "date" // ADIF Date (YYYYMMDD) - KindTime FieldKind = "time" // ADIF Time (HHMMSS / HHMM) - KindBool FieldKind = "boolean" // Boolean (Y/N) - KindEnum FieldKind = "enum" // Enumeration - KindLoc FieldKind = "location" // Location (e.g. "N048 09.000") + KindText FieldKind = "text" // String / IntlString / MultilineString + KindNumber FieldKind = "number" // Number / PositiveInteger + KindDate FieldKind = "date" // ADIF Date (YYYYMMDD) + KindTime FieldKind = "time" // ADIF Time (HHMMSS / HHMM) + KindBool FieldKind = "boolean" // Boolean (Y/N) + KindEnum FieldKind = "enum" // Enumeration + KindLoc FieldKind = "location" // Location (e.g. "N048 09.000") ) // FieldDef describes one ADIF QSO field. type FieldDef struct { - Name string `json:"name"` // canonical uppercase ADIF tag - Kind FieldKind `json:"kind"` // editor widget hint - Category string `json:"category"` // grouping for the UI - Promoted bool `json:"promoted"` // has a dedicated QSO column - Deprecated bool `json:"deprecated"` // import-only per the spec - Intl bool `json:"intl"` // *_INTL UTF-8 variant + Name string `json:"name"` // canonical uppercase ADIF tag + Kind FieldKind `json:"kind"` // editor widget hint + Category string `json:"category"` // grouping for the UI + Promoted bool `json:"promoted"` // has a dedicated QSO column + Deprecated bool `json:"deprecated"` // import-only per the spec + Intl bool `json:"intl"` // *_INTL UTF-8 variant } // adifVersion is the ADIF spec version OpsLog targets for import/export. diff --git a/internal/adif/import.go b/internal/adif/import.go index e2bee22..2259ae5 100644 --- a/internal/adif/import.go +++ b/internal/adif/import.go @@ -428,7 +428,12 @@ func recordToQSO(rec Record) (qso.QSO, bool) { q.VUCCGrids = strings.ToUpper(rec["vucc_grids"]) q.Country = rec["country"] 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 { q.DXCC = &v } @@ -515,7 +520,12 @@ func recordToQSO(rec Record) (qso.QSO, bool) { q.MyGridExt = strings.ToUpper(rec["my_gridsquare_ext"]) q.MyCountry = rec["my_country"] 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.MySOTARef = strings.ToUpper(rec["my_sota_ref"]) q.MyPOTARef = strings.ToUpper(rec["my_pota_ref"]) @@ -698,3 +708,20 @@ var promotableSubmodes = map[string]bool{ func submodeSubsumesParent(submode string) bool { 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])) +} diff --git a/internal/adif/roundtrip_test.go b/internal/adif/roundtrip_test.go index 74142f1..1af2e30 100644 --- a/internal/adif/roundtrip_test.go +++ b/internal/adif/roundtrip_test.go @@ -20,7 +20,7 @@ func TestPromotedFieldsRoundTrip(t *testing.T) { in := qso.QSO{ Callsign: "EA8ABC", Band: "20m", Mode: "SSB", 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", Distance: &dist, RXPower: &rxp, AIndex: &a, SKCC: "12345S", FISTS: "999", TenTen: "55555",