Three field reports. Icom CI-V address 00 could not be kept: zero was read as "not configured" in all three places that validate it, so every save put the rig back to the IC-7610's 0x98 — and the model dropdown followed, since it is derived from the address rather than stored. Picking "Other (custom address)" also had no effect of its own: the list re-derived itself and snapped back to whatever rig matched. It now stays chosen. Cloudlog/Wavelog showed "17m/17m" on ordinary FT8 contacts (OE6CLD). Every QSO is stamped with a receive side equal to the transmit side, and the uploaded record carried it; Wavelog draws band/band_rx whenever both are there. In ADIF an absent BAND_RX means "same as transmit", so the uploaded record now writes the receive side only when it differs. The copy forwarded to another logger over UDP keeps writing it in full — that is why it was stamped in the first place (Log4OM reads BAND_RX) — through its own ForwardRecordADIF. "S/F" in a spot comment joins superfox / sfox / F-H as FT8.
489 lines
16 KiB
Go
489 lines
16 KiB
Go
package adif
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"hamlog/internal/qso"
|
|
)
|
|
|
|
// ExportResult summarises an ADIF export for the UI.
|
|
type ExportResult struct {
|
|
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.
|
|
type Exporter struct {
|
|
Repo *qso.Repo
|
|
|
|
// AppName / AppVersion populate the ADIF header comments. Optional.
|
|
AppName string
|
|
AppVersion string
|
|
|
|
// IncludeAppFields controls whether application-specific fields (ADIF
|
|
// "APP_<programid>_<name>" tags, e.g. Log4OM's APP_LOG4OM_* or our own
|
|
// OpsLog extensions) are written. Leave false for a clean standard-ADIF
|
|
// export destined for another logger; set true for a full OpsLog→OpsLog
|
|
// round-trip that preserves everything.
|
|
IncludeAppFields bool
|
|
|
|
// Fields, when non-nil, restricts the export to exactly these ADIF tags
|
|
// (uppercase) — the "choose which fields to export" mode. nil = write every
|
|
// field (the standard/full behaviour above). When set it overrides
|
|
// IncludeAppFields: an APP_/vendor tag is written iff it's in the set.
|
|
Fields map[string]bool
|
|
}
|
|
|
|
// iterator streams QSOs through fn. The three concrete sources (all, filtered,
|
|
// by-ids) all match this shape so the document writer stays source-agnostic.
|
|
type iterator func(ctx context.Context, fn func(qso.QSO) error) error
|
|
|
|
// ExportFile creates path (overwriting if it exists) and writes every QSO.
|
|
func (e *Exporter) ExportFile(ctx context.Context, path string) (ExportResult, error) {
|
|
return e.exportFileWith(ctx, path, e.Repo.IterateAll)
|
|
}
|
|
|
|
// ExportFileFiltered writes only the QSOs matching f (no row limit).
|
|
func (e *Exporter) ExportFileFiltered(ctx context.Context, path string, f qso.QueryFilter) (ExportResult, error) {
|
|
return e.exportFileWith(ctx, path, func(ctx context.Context, fn func(qso.QSO) error) error {
|
|
return e.Repo.IterateFiltered(ctx, f, fn)
|
|
})
|
|
}
|
|
|
|
// ExportFileByIDs writes only the QSOs with the given ids.
|
|
func (e *Exporter) ExportFileByIDs(ctx context.Context, path string, ids []int64) (ExportResult, error) {
|
|
return e.exportFileWith(ctx, path, func(ctx context.Context, fn func(qso.QSO) error) error {
|
|
return e.Repo.IterateByIDs(ctx, ids, fn)
|
|
})
|
|
}
|
|
|
|
func (e *Exporter) exportFileWith(ctx context.Context, path string, iter iterator) (ExportResult, error) {
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
return ExportResult{}, fmt.Errorf("create %s: %w", path, err)
|
|
}
|
|
defer f.Close()
|
|
|
|
count, err := e.writeDoc(ctx, f, iter)
|
|
if err != nil {
|
|
return ExportResult{Path: path, Count: count}, err
|
|
}
|
|
info, _ := f.Stat()
|
|
return ExportResult{Path: path, Count: count, SizeKB: info.Size() / 1024}, nil
|
|
}
|
|
|
|
// Export writes a complete ADIF document (header + records + EOF) to w for
|
|
// every QSO. Returns the number of QSOs written.
|
|
func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) {
|
|
return e.writeDoc(ctx, w, e.Repo.IterateAll)
|
|
}
|
|
|
|
// writeDoc writes the ADIF header then streams every QSO from iter.
|
|
func (e *Exporter) writeDoc(ctx context.Context, w io.Writer, iter iterator) (int, error) {
|
|
bw := bufio.NewWriterSize(w, 64*1024)
|
|
defer bw.Flush()
|
|
|
|
app := strings.TrimSpace(e.AppName)
|
|
if app == "" {
|
|
app = "HamLog"
|
|
}
|
|
ver := strings.TrimSpace(e.AppVersion)
|
|
now := time.Now().UTC().Format("20060102 150405")
|
|
fmt.Fprintf(bw, "# ADIF export by %s %s — %s UTC\n", app, ver, now)
|
|
fmt.Fprintf(bw, "<ADIF_VER:%d>%s <PROGRAMID:%d>%s", len(adifVersion), adifVersion, len(app), app)
|
|
if ver != "" {
|
|
fmt.Fprintf(bw, " <PROGRAMVERSION:%d>%s", len(ver), ver)
|
|
}
|
|
fmt.Fprintf(bw, " <CREATED_TIMESTAMP:15>%s <EOH>\n\n", now)
|
|
|
|
count := 0
|
|
err := iter(ctx, func(q qso.QSO) error {
|
|
writeRecord(bw, q, e.IncludeAppFields, e.Fields)
|
|
count++
|
|
return nil
|
|
})
|
|
return count, err
|
|
}
|
|
|
|
// SingleRecordADIF returns one QSO serialised as an ADIF record (fields
|
|
// terminated by <EOR>), with no document header. Used by the external-
|
|
// service uploaders (QRZ.com / Clublog / …) which want a bare record as
|
|
// the ADIF parameter of their HTTP API.
|
|
func SingleRecordADIF(q qso.QSO) string {
|
|
var b strings.Builder
|
|
bw := bufio.NewWriter(&b)
|
|
// Uploads target other services — keep it standard (no app-specific tags),
|
|
// and say nothing about the receive side when there is nothing to say: in
|
|
// ADIF an absent BAND_RX/FREQ_RX means "same as transmit", and a logger
|
|
// given both draws both. Wavelog reads a simplex FT8 contact uploaded with
|
|
// BAND_RX filled in as split and shows it as "17m/17m".
|
|
writeRecord(bw, q, false, nil)
|
|
bw.Flush()
|
|
return b.String()
|
|
}
|
|
|
|
// ForwardRecordADIF is the record sent to ANOTHER LOGGER on the UDP link.
|
|
//
|
|
// The receive side is written even when it repeats the transmit side, which is
|
|
// the opposite of the upload rule above and is deliberate: Log4OM reads BAND_RX
|
|
// and found nothing there for contacts logged by a path that left it blank. A
|
|
// logger on the same desk is being handed a copy of our record, not published
|
|
// to a service that will draw conclusions from every tag present.
|
|
func ForwardRecordADIF(q qso.QSO) string {
|
|
var b strings.Builder
|
|
bw := bufio.NewWriter(&b)
|
|
writeRecord(bw, q, false, nil, keepRX)
|
|
bw.Flush()
|
|
return b.String()
|
|
}
|
|
|
|
// keepRX marks a record whose receive side must be written out in full.
|
|
type rxMode int
|
|
|
|
const keepRX rxMode = 1
|
|
|
|
// FullRecordADIF serialises one QSO LOSSLESSLY — including the APP_* extras —
|
|
// so it can be written out and read back with nothing dropped. Used by the
|
|
// offline queue: a QSO parked in the safety file must come back identical
|
|
// (extras carry its queue id, awards refs, ADIF 3.1.7 leftovers…).
|
|
func FullRecordADIF(q qso.QSO) string {
|
|
var b strings.Builder
|
|
bw := bufio.NewWriter(&b)
|
|
writeRecord(bw, q, true, nil)
|
|
bw.Flush()
|
|
return b.String()
|
|
}
|
|
|
|
// BatchRecordsADIF wraps already-serialised records (each terminated by <EOR>,
|
|
// e.g. from SingleRecordADIF) in a minimal ADIF document with a standard
|
|
// header. Used by file-based batch upload APIs such as Club Log's putlogs.php.
|
|
func BatchRecordsADIF(records []string) string {
|
|
var b strings.Builder
|
|
now := time.Now().UTC().Format("20060102 150405")
|
|
fmt.Fprintf(&b, "<ADIF_VER:%d>%s <PROGRAMID:6>OpsLog <CREATED_TIMESTAMP:15>%s <EOH>\n\n",
|
|
len(adifVersion), adifVersion, now)
|
|
for _, r := range records {
|
|
r = strings.TrimRight(r, "\r\n")
|
|
if r == "" {
|
|
continue
|
|
}
|
|
b.WriteString(r)
|
|
b.WriteString("\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// writeRecord serialises one QSO as ADIF tags terminated by <EOR>.
|
|
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
|
|
// mode (e.g. FT4 stored without a parent) is exported as the canonical
|
|
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers.
|
|
func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool, allow map[string]bool, rx ...rxMode) {
|
|
// The receive side, unless it merely repeats the transmit side. See
|
|
// SingleRecordADIF and ForwardRecordADIF.
|
|
bandRX, freqRX := q.BandRX, q.FreqRXHz
|
|
if len(rx) == 0 || rx[0] != keepRX {
|
|
if strings.EqualFold(strings.TrimSpace(bandRX), strings.TrimSpace(q.Band)) {
|
|
bandRX = ""
|
|
}
|
|
if freqRX != nil && q.FreqHz != nil && *freqRX == *q.FreqHz {
|
|
freqRX = nil
|
|
}
|
|
}
|
|
// allow == nil → write every promoted field (standard/full behaviour).
|
|
// Otherwise a promoted tag is written only when it's in the chosen set.
|
|
// w/wi/wf wrap the raw writers with that gate so the ~150 field lines below
|
|
// stay one-liners.
|
|
ok := func(tag string) bool { return allow == nil || allow[tag] }
|
|
w := func(tag, v string) {
|
|
if ok(tag) {
|
|
writeField(bw, tag, v)
|
|
}
|
|
}
|
|
wi := func(tag string, p *int) {
|
|
if ok(tag) {
|
|
writeIntPtr(bw, tag, p)
|
|
}
|
|
}
|
|
wf := func(tag string, p *float64, decimals int) {
|
|
if ok(tag) {
|
|
writeFloatPtr(bw, tag, p, decimals)
|
|
}
|
|
}
|
|
// --- Core ---
|
|
w("CALL", q.Callsign)
|
|
|
|
if !q.QSODate.IsZero() {
|
|
w("QSO_DATE", q.QSODate.UTC().Format("20060102"))
|
|
w("TIME_ON", q.QSODate.UTC().Format("150405"))
|
|
}
|
|
if !q.QSODateOff.IsZero() {
|
|
w("QSO_DATE_OFF", q.QSODateOff.UTC().Format("20060102"))
|
|
w("TIME_OFF", q.QSODateOff.UTC().Format("150405"))
|
|
}
|
|
w("BAND", q.Band)
|
|
w("BAND_RX", bandRX)
|
|
|
|
mode, submode := modeForExport(q.Mode, q.Submode)
|
|
w("MODE", mode)
|
|
w("SUBMODE", submode)
|
|
|
|
if q.FreqHz != nil && *q.FreqHz > 0 {
|
|
w("FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64))
|
|
}
|
|
if freqRX != nil && *freqRX > 0 {
|
|
w("FREQ_RX", strconv.FormatFloat(float64(*freqRX)/1_000_000, 'f', 6, 64))
|
|
}
|
|
|
|
w("RST_SENT", q.RSTSent)
|
|
w("RST_RCVD", q.RSTRcvd)
|
|
|
|
// --- Contacted ---
|
|
w("NAME", q.Name)
|
|
w("QTH", q.QTH)
|
|
w("ADDRESS", q.Address)
|
|
w("EMAIL", q.Email)
|
|
w("WEB", q.Web)
|
|
w("GRIDSQUARE", q.Grid)
|
|
w("GRIDSQUARE_EXT", q.GridExt)
|
|
w("VUCC_GRIDS", q.VUCCGrids)
|
|
w("COUNTRY", q.Country)
|
|
w("STATE", q.State)
|
|
w("CNTY", adifCounty(q.State, q.County))
|
|
wi("DXCC", q.DXCC)
|
|
w("CONT", q.Continent)
|
|
wi("CQZ", q.CQZ)
|
|
wi("ITUZ", q.ITUZ)
|
|
w("IOTA", q.IOTA)
|
|
w("SOTA_REF", q.SOTARef)
|
|
w("POTA_REF", q.POTARef)
|
|
wi("AGE", q.Age)
|
|
wf("LAT", q.Lat, 6)
|
|
wf("LON", q.Lon, 6)
|
|
w("RIG", q.Rig)
|
|
w("ANT", q.Ant)
|
|
|
|
// --- QSL / LoTW / eQSL / Clublog / HRDLog ---
|
|
w("QSL_SENT", q.QSLSent)
|
|
w("QSL_RCVD", q.QSLRcvd)
|
|
w("QSLSDATE", q.QSLSentDate)
|
|
w("QSLRDATE", q.QSLRcvdDate)
|
|
w("QSL_VIA", q.QSLVia)
|
|
// M (manager) is import-only in the QSL Via enumeration: we keep it when a
|
|
// file gives it to us, but a file we write must not carry it.
|
|
if q.QSLSentVia != QSLViaManager {
|
|
w("QSL_SENT_VIA", q.QSLSentVia)
|
|
}
|
|
if q.QSLRcvdVia != QSLViaManager {
|
|
w("QSL_RCVD_VIA", q.QSLRcvdVia)
|
|
}
|
|
w("QSLMSG", q.QSLMsg)
|
|
w("QSLMSG_RCVD", q.QSLMsgRcvd)
|
|
w("LOTW_QSL_SENT", q.LOTWSent)
|
|
w("LOTW_QSL_RCVD", q.LOTWRcvd)
|
|
w("LOTW_QSLSDATE", q.LOTWSentDate)
|
|
w("LOTW_QSLRDATE", q.LOTWRcvdDate)
|
|
w("EQSL_QSL_SENT", q.EQSLSent)
|
|
w("EQSL_QSL_RCVD", q.EQSLRcvd)
|
|
w("EQSL_QSLSDATE", q.EQSLSentDate)
|
|
w("EQSL_QSLRDATE", q.EQSLRcvdDate)
|
|
w("CLUBLOG_QSO_UPLOAD_DATE", q.ClublogUploadDate)
|
|
w("CLUBLOG_QSO_UPLOAD_STATUS", q.ClublogUploadStatus)
|
|
w("HRDLOG_QSO_UPLOAD_DATE", q.HRDLogUploadDate)
|
|
w("HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus)
|
|
w("QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate)
|
|
w("QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
|
|
w("QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
|
|
w("QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
|
|
w("CLUBLOG_QSO_DOWNLOAD_DATE", q.ClublogDownloadDate)
|
|
w("CLUBLOG_QSO_DOWNLOAD_STATUS", q.ClublogDownloadStatus)
|
|
|
|
// --- Contest ---
|
|
w("CONTEST_ID", q.ContestID)
|
|
wi("SRX", q.SRX)
|
|
wi("STX", q.STX)
|
|
w("SRX_STRING", q.SRXString)
|
|
w("STX_STRING", q.STXString)
|
|
w("CHECK", q.Check)
|
|
w("PRECEDENCE", q.Precedence)
|
|
w("ARRL_SECT", q.ARRLSect)
|
|
|
|
// --- Satellite / propagation ---
|
|
w("PROP_MODE", q.PropMode)
|
|
w("SAT_NAME", q.SatName)
|
|
w("SAT_MODE", q.SatMode)
|
|
wf("ANT_AZ", q.AntAz, 1)
|
|
wf("ANT_EL", q.AntEl, 1)
|
|
w("ANT_PATH", q.AntPath)
|
|
|
|
// --- My station / operator ---
|
|
w("STATION_CALLSIGN", q.StationCallsign)
|
|
w("OPERATOR", q.Operator)
|
|
w("MY_GRIDSQUARE", q.MyGrid)
|
|
w("MY_GRIDSQUARE_EXT", q.MyGridExt)
|
|
w("MY_COUNTRY", q.MyCountry)
|
|
w("MY_STATE", q.MyState)
|
|
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)
|
|
wi("MY_DXCC", q.MyDXCC)
|
|
wi("MY_CQ_ZONE", q.MyCQZone)
|
|
wi("MY_ITU_ZONE", q.MyITUZone)
|
|
wf("MY_LAT", q.MyLat, 6)
|
|
wf("MY_LON", q.MyLon, 6)
|
|
w("MY_STREET", q.MyStreet)
|
|
w("MY_CITY", q.MyCity)
|
|
w("MY_POSTAL_CODE", q.MyPostalCode)
|
|
w("MY_RIG", q.MyRig)
|
|
w("MY_ANTENNA", q.MyAntenna)
|
|
|
|
// --- Misc ---
|
|
wf("TX_PWR", q.TXPower, 1)
|
|
w("COMMENT", q.Comment)
|
|
w("NOTES", q.Notes)
|
|
|
|
// --- ADIF 3.1.7 additional promoted fields ---
|
|
w("SIG", q.SIG)
|
|
w("SIG_INFO", q.SIGInfo)
|
|
w("MY_SIG", q.MySIG)
|
|
w("MY_SIG_INFO", q.MySIGInfo)
|
|
w("WWFF_REF", q.WWFFRef)
|
|
w("MY_WWFF_REF", q.MyWWFFRef)
|
|
wf("DISTANCE", q.Distance, 1)
|
|
wf("RX_PWR", q.RXPower, 1)
|
|
wf("A_INDEX", q.AIndex, 0)
|
|
wf("K_INDEX", q.KIndex, 0)
|
|
wf("SFI", q.SFI, 0)
|
|
w("SKCC", q.SKCC)
|
|
w("FISTS", q.FISTS)
|
|
w("TEN_TEN", q.TenTen)
|
|
w("CONTACTED_OP", q.ContactedOp)
|
|
w("EQ_CALL", q.EqCall)
|
|
w("PFX", q.PFX)
|
|
w("MY_NAME", q.MyName)
|
|
w("CLASS", q.Class)
|
|
w("DARC_DOK", q.DarcDOK)
|
|
w("MY_DARC_DOK", q.MyDarcDOK)
|
|
w("REGION", q.Region)
|
|
w("SILENT_KEY", q.SilentKey)
|
|
w("SWL", q.SWL)
|
|
w("QSO_COMPLETE", q.QSOComplete)
|
|
w("QSO_RANDOM", q.QSORandom)
|
|
w("CREDIT_GRANTED", q.CreditGranted)
|
|
w("CREDIT_SUBMITTED", q.CreditSubmitted)
|
|
w("MY_ARRL_SECT", q.MyARRLSect)
|
|
w("MY_VUCC_GRIDS", q.MyVUCCGrids)
|
|
|
|
// --- Extras (unpromoted ADIF fields preserved verbatim) ---
|
|
// Standard mode emits ONLY valid ADIF-spec fields, so it drops APP_*
|
|
// application-specific tags AND any non-standard / vendor tag — keeping
|
|
// the file strictly portable to other loggers. Full mode keeps every
|
|
// extra for a lossless OpsLog round-trip.
|
|
for k, v := range q.Extras {
|
|
tag := strings.ToUpper(k)
|
|
if allow != nil {
|
|
// Chosen-fields mode: the set alone decides (incl. APP_/vendor tags).
|
|
if !allow[tag] {
|
|
continue
|
|
}
|
|
} else if !includeApp && (strings.HasPrefix(tag, "APP_") || !IsStandardField(tag)) {
|
|
continue
|
|
}
|
|
writeField(bw, tag, v)
|
|
}
|
|
|
|
bw.WriteString("<EOR>\n")
|
|
}
|
|
|
|
// writeField writes one `<TAG:length>value` pair, no-op when value is empty.
|
|
// length is the byte count (ADIF spec), which matches len(v) in Go since v is
|
|
// already a UTF-8 byte string.
|
|
func writeField(bw *bufio.Writer, tag, v string) {
|
|
if v == "" {
|
|
return
|
|
}
|
|
fmt.Fprintf(bw, "<%s:%d>%s ", tag, len(v), v)
|
|
}
|
|
|
|
func writeIntPtr(bw *bufio.Writer, tag string, p *int) {
|
|
if p == nil {
|
|
return
|
|
}
|
|
s := strconv.Itoa(*p)
|
|
writeField(bw, tag, s)
|
|
}
|
|
|
|
func writeFloatPtr(bw *bufio.Writer, tag string, p *float64, decimals int) {
|
|
if p == nil {
|
|
return
|
|
}
|
|
s := strconv.FormatFloat(*p, 'f', decimals, 64)
|
|
writeField(bw, tag, s)
|
|
}
|
|
|
|
// parentMode maps a "specific" mode (the ones we promote on import) back
|
|
// to its ADIF parent. Symmetric with promotableSubmodes in import.go.
|
|
var parentMode = map[string]string{
|
|
"FT2": "MFSK", "FT4": "MFSK", "JS8": "MFSK", "MSK144": "MFSK",
|
|
"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",
|
|
"QPSK31": "PSK", "QPSK63": "PSK", "QPSK125": "PSK", "QPSK250": "PSK", "QPSK500": "PSK",
|
|
"FREEDV": "DIGITALVOICE",
|
|
"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",
|
|
}
|
|
|
|
// modeForExport returns the (MODE, SUBMODE) pair to write. If we promoted
|
|
// on import (Mode=FT4 Submode=""), we re-derive the parent so the file
|
|
// is import-compatible with strict ADIF tools.
|
|
func modeForExport(mode, submode string) (string, string) {
|
|
if submode != "" {
|
|
// Already a (parent, child) pair — pass through unchanged.
|
|
return mode, submode
|
|
}
|
|
if parent, ok := parentMode[mode]; ok {
|
|
return parent, 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
|
|
}
|
|
// 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
|
|
}
|