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:
+4
-2
@@ -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."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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]))
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user