An exported file was full of blank gaps: a record ran down a dozen lines and the next appeared to start in the middle of the page. ADDRESS is a multi-line field by the standard and callbooks and other loggers fill it that way — "Kabul", four blank lines, "Afghanistan" — and the writer copied the value out as it stood. The files were always valid, since ADIF counts bytes; they were unreadable, and so was anything that quoted them. Line breaks inside a value are now joined with a comma and tabs become spaces, which is how an address reads on one line anyway. The length prefix is computed after the flattening, so the record stays exact, and every path through the writer gets it: the file exports, the uploads and the record forwarded over UDP. The changelog's 0.27.15 block also takes back the FT-map hover fix, which landed after the 0.27.14 release commit and was sitting in that block.
518 lines
17 KiB
Go
518 lines
17 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) {
|
|
v = oneLine(v)
|
|
if v == "" {
|
|
return
|
|
}
|
|
fmt.Fprintf(bw, "<%s:%d>%s ", tag, len(v), v)
|
|
}
|
|
|
|
// oneLine flattens a value onto a single line.
|
|
//
|
|
// ADIF counts bytes, so a value carrying line breaks is still read correctly —
|
|
// and it turns the file into something nobody can read. ADDRESS is a multi-line
|
|
// field by the standard, and callbooks and other loggers fill it that way: a
|
|
// value of "Kabul" followed by four blank lines and "Afghanistan" came out of
|
|
// OpsLog as one record spread down a dozen lines, with the next record
|
|
// apparently starting in the middle of the page.
|
|
//
|
|
// The breaks are dropped rather than escaped: the parts are trimmed and joined
|
|
// with a comma, which is how an address reads on one line anyway, and empty
|
|
// fragments go. The length prefix is computed after this, so the record stays
|
|
// exact.
|
|
func oneLine(v string) string {
|
|
if !strings.ContainsAny(v, "\r\n\t") {
|
|
return v
|
|
}
|
|
parts := strings.FieldsFunc(v, func(r rune) bool { return r == '\r' || r == '\n' })
|
|
out := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(strings.ReplaceAll(part, "\t", " "))
|
|
if part != "" {
|
|
out = append(out, part)
|
|
}
|
|
}
|
|
return strings.Join(out, ", ")
|
|
}
|
|
|
|
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
|
|
}
|