Files
OpsLog/internal/adif/import.go
T
rouggy 4d5c5c3eb3 feat: move ADIF fields on import, for contest exports that misplace the exchange
An operator worked the RSGB IOTA contest in another logger and got back records
carrying <STATE:5>EU005: N1MM-class software stores the received exchange in the
column its contest module uses, not the one ADIF reserves. Imported verbatim,
every QSO gains a US-state field reading "EU005" and the IOTA award sees
nothing — the reference is in the log, just not where anything looks for it.

The import dialog gains optional source → destination rows, prefilled with
STATE → IOTA since that is the case that prompted it. Applied to the RAW record
before conversion, so it works for promoted columns and Extras alike and the
destination is parsed exactly as if the file had carried it there.

Two rules, both from the same principle that the file outranks our guess:
  - the source is CLEARED, so a reference does not linger in STATE where it
    would show as the contacted station's US state in the grid and every export;
  - a destination the file already filled is kept, judged against the record as
    it ARRIVED — otherwise the result would depend on Go's map iteration order,
    which is deliberately random.

The swap case in the test is what forced that second rule to be stated: "never
overwrite" and "exchange two fields" cannot both hold silently, so a destination
that is itself a mapping source is treated as an explicit swap and nothing else
is overwritten.
2026-07-30 14:57:05 +02:00

701 lines
22 KiB
Go

package adif
import (
"bytes"
"context"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"unicode/utf8"
"golang.org/x/text/encoding/charmap"
"hamlog/internal/qso"
)
// ImportResult summarises an ADIF import for the UI.
type ImportResult struct {
Total int `json:"total"` // records found in the file
Imported int `json:"imported"` // successfully inserted
Updated int `json:"updated"` // existing QSOs refreshed (update-duplicates mode)
Skipped int `json:"skipped"` // dropped (missing required fields)
Duplicates int `json:"duplicates"` // matched an existing or earlier-in-file QSO
DuplicateSamples []string `json:"duplicate_samples"` // up to maxDuplicateSamples human-readable IDs of skipped duplicates
Errors []string `json:"errors"` // up to maxErrors error messages
}
const (
maxErrors = 50
maxDuplicateSamples = 200 // cap memory + JSON payload size
)
// Importer streams an ADI file into a QSO repository.
type Importer struct {
Repo *qso.Repo
BatchSize int // 0 → 500
SkipDuplicates bool // when true, records matching an existing or earlier-in-file QSO are skipped; otherwise all are inserted
// UpdateDuplicates, when true, takes precedence over SkipDuplicates:
// a record matching an existing QSO MERGES its non-empty fields onto
// that QSO (refreshes QSL/confirmation statuses on re-sync) instead of
// being skipped or re-inserted.
UpdateDuplicates bool
// Enrich, when set, is called on each parsed QSO before dedup/insert.
// Used to recompute country / zones from cty.dat so a bad COUNTRY in the
// source file (common with contest loggers) is corrected on the way in.
Enrich func(*qso.QSO)
// FieldMap moves ADIF fields around before a record is converted, keyed
// lowercase source → lowercase destination.
//
// Contest software puts things where the operator, not the standard, expects
// them. The RSGB IOTA contest is the case that prompted this: N1MM and
// friends export the island reference in STATE, because that is the column
// their contest module uses for the received exchange. Imported verbatim,
// every QSO gets a US-state field holding "EU005" and the IOTA award sees
// nothing at all.
//
// Applied to the RAW record, so it works for promoted columns and Extras
// alike, and so the destination is parsed exactly as if the file had carried
// it there in the first place. A destination that already has a value is
// never overwritten — the file's own IOTA tag outranks a guess about STATE.
FieldMap map[string]string
// OnProgress, when set, is called periodically with (processed, total)
// record counts so the UI can show a progress bar. total is an estimate
// from counting <EOR> tags up front.
OnProgress func(processed, total int)
}
// ImportFile reads the file at path and imports it into the repo. The
// whole file is loaded into memory so we can do a definitive UTF-8 check
// before parsing — peeking a buffered window misses non-ASCII bytes that
// only appear past the header (typical when the ADIF header is pure ASCII
// but record fields like NAME/QTH have accented chars in Windows-1252).
func (im *Importer) ImportFile(ctx context.Context, path string) (ImportResult, error) {
data, err := os.ReadFile(path)
if err != nil {
return ImportResult{}, fmt.Errorf("open %s: %w", path, err)
}
// Strip UTF-8 BOM if present so the parser sees clean data.
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
return im.importBytes(ctx, data)
}
// pickValueDecoder returns the per-field byte-to-string decoder to use.
// If the file is valid UTF-8 we keep the bytes as-is; otherwise we assume
// Windows-1252 (de-facto encoding of MixW, Log4OM, HRD and most legacy
// Western-European loggers). Decoding has to happen on each field's bytes
// individually, NOT by wrapping the reader, because ADIF declares field
// lengths in source-encoding bytes — e.g. "<QTH:7>YAOUNDÉ" is 7 bytes in
// Windows-1252 (É is one byte 0xC9). Pre-decoding to UTF-8 would make É
// two bytes, and the parser reading 7 bytes after the tag would chop the
// É in half → "YAOUND" + an orphan 0xC3 byte → "YAOUND" after JSON.
// ValueDecoderFor returns the per-field byte decoder appropriate for a raw
// ADIF payload: identity when it's valid UTF-8, otherwise a Windows-1252
// decoder. Exposed so non-file ingest paths (UDP auto-log from Log4OM /
// JTAlert) transcode accented NAME/QTH fields the same way file import does.
func ValueDecoderFor(data []byte) func([]byte) string {
return pickValueDecoder(data)
}
func pickValueDecoder(data []byte) func([]byte) string {
if utf8.Valid(data) {
return nil // identity
}
dec := charmap.Windows1252.NewDecoder()
return func(b []byte) string {
out, err := dec.Bytes(b)
if err != nil {
return string(b)
}
return string(out)
}
}
// Import streams the ADI content from r into the repo. Assumes UTF-8;
// callers that may receive other encodings should go through ImportFile.
func (im *Importer) Import(ctx context.Context, r interface {
Read(p []byte) (int, error)
}) (ImportResult, error) {
data, err := io.ReadAll(r)
if err != nil {
return ImportResult{}, fmt.Errorf("read input: %w", err)
}
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
return im.importBytes(ctx, data)
}
func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult, error) {
if im.BatchSize <= 0 {
im.BatchSize = 500
}
decode := pickValueDecoder(data)
res := ImportResult{}
batch := make([]qso.QSO, 0, im.BatchSize)
// Up-front record-count estimate (count <EOR> tags, case-insensitive) so
// the UI progress bar has a denominator. Cheap single scan.
total := countEOR(data)
reportProgress := func(force bool) {
if im.OnProgress != nil && (force || res.Total%200 == 0) {
im.OnProgress(res.Total, total)
}
}
// One upfront query for every existing dedup key — cheaper than N
// per-record EXISTS calls. The same map gets new keys appended as we
// import so duplicates inside the file are caught too. Loaded
// unconditionally so the "duplicates" count is meaningful even when
// SkipDuplicates is off (the user still wants to know how many).
seen, err := im.Repo.ExistingDedupeKeys(ctx)
if err != nil {
return res, fmt.Errorf("load dedupe keys: %w", err)
}
// Update-duplicates mode needs the existing row's ID per key so it can
// fetch, merge and write it back. Loaded only when needed (extra query).
var keyIDs map[string]int64
if im.UpdateDuplicates {
keyIDs, err = im.Repo.DedupeKeyIDs(ctx)
if err != nil {
return res, fmt.Errorf("load dedupe ids: %w", err)
}
}
flush := func() error {
if len(batch) == 0 {
return nil
}
n, err := im.Repo.AddBatch(ctx, batch)
res.Imported += int(n)
batch = batch[:0]
return err
}
err = ParseWithDecoder(bytes.NewReader(data), decode, func(rec Record) error {
res.Total++
reportProgress(false)
applyFieldMap(rec, im.FieldMap)
q, ok := recordToQSO(rec)
if !ok {
res.Skipped++
if len(res.Errors) < maxErrors {
res.Errors = append(res.Errors,
fmt.Sprintf("record %d: missing required fields (call/band/mode/date)", res.Total))
}
return nil
}
if im.Enrich != nil {
im.Enrich(&q)
}
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
if _, dup := seen[key]; dup {
res.Duplicates++
if len(res.DuplicateSamples) < maxDuplicateSamples {
res.DuplicateSamples = append(res.DuplicateSamples,
fmt.Sprintf("%s · %s · %s · %s",
q.Callsign,
q.QSODate.UTC().Format("2006-01-02 15:04"),
q.Band, q.Mode))
}
if im.UpdateDuplicates {
if id, ok := keyIDs[key]; ok {
existing, gerr := im.Repo.GetByID(ctx, id)
if gerr != nil {
if len(res.Errors) < maxErrors {
res.Errors = append(res.Errors,
fmt.Sprintf("record %d (%s): load existing: %v", res.Total, q.Callsign, gerr))
}
return nil
}
qso.MergeNonZero(&existing, q)
if uerr := im.Repo.Update(ctx, existing); uerr != nil {
if len(res.Errors) < maxErrors {
res.Errors = append(res.Errors,
fmt.Sprintf("record %d (%s): update: %v", res.Total, q.Callsign, uerr))
}
return nil
}
res.Updated++
}
return nil
}
if im.SkipDuplicates {
return nil
}
// Fall through: insert anyway. Don't add to seen[] — keeps
// the duplicate count meaningful if the same row appears 3x.
} else {
seen[key] = struct{}{}
}
batch = append(batch, q)
if len(batch) >= im.BatchSize {
return flush()
}
return nil
})
if err != nil {
_ = flush()
return res, err
}
if err := flush(); err != nil {
return res, err
}
reportProgress(true) // final 100%
return res, nil
}
// countEOR estimates the record count by counting case-insensitive <EOR>
// tags. Used only to give the import progress bar a denominator.
func countEOR(data []byte) int {
n := 0
for i := 0; i+4 <= len(data); i++ {
if data[i] != '<' {
continue
}
if (data[i+1] == 'e' || data[i+1] == 'E') &&
(data[i+2] == 'o' || data[i+2] == 'O') &&
(data[i+3] == 'r' || data[i+3] == 'R') &&
(i+4 < len(data) && (data[i+4] == '>' || data[i+4] == ':')) {
n++
}
}
return n
}
// adifPromoted lists every lowercase ADIF tag that maps to a promoted column.
// Anything not in this set ends up in Extras.
var adifPromoted = stringSet(
// Core
"call", "qso_date", "time_on", "qso_date_off", "time_off",
"band", "band_rx", "mode", "submode", "freq", "freq_rx",
"rst_sent", "rst_rcvd",
// Contacted
"name", "qth", "address", "email", "web",
"gridsquare", "gridsquare_ext", "vucc_grids",
"country", "state", "cnty",
"dxcc", "cont", "cqz", "ituz",
"iota", "sota_ref", "pota_ref",
"age", "lat", "lon", "rig", "ant",
// QSL
"qsl_sent", "qsl_rcvd",
"qslsdate", "qslrdate", "qsl_via", "qsl_sent_via", "qsl_rcvd_via", "qslmsg", "qslmsg_rcvd",
"lotw_qsl_sent", "lotw_qsl_rcvd", "lotw_qslsdate", "lotw_qslrdate",
"eqsl_qsl_sent", "eqsl_qsl_rcvd", "eqsl_qslsdate", "eqsl_qslrdate",
"clublog_qso_upload_date", "clublog_qso_upload_status",
"hrdlog_qso_upload_date", "hrdlog_qso_upload_status",
"qrzcom_qso_upload_date", "qrzcom_qso_upload_status",
"qrzcom_qso_download_date", "qrzcom_qso_download_status",
// Contest
"contest_id", "srx", "stx", "srx_string", "stx_string",
"check", "precedence", "arrl_sect",
// Sat / propagation
"prop_mode", "sat_name", "sat_mode", "ant_az", "ant_el", "ant_path",
// My station
"station_callsign", "operator",
"my_gridsquare", "my_gridsquare_ext", "my_country", "my_state", "my_cnty", "my_iota",
"my_sota_ref", "my_pota_ref",
"my_dxcc", "my_cq_zone", "my_itu_zone", "my_lat", "my_lon",
"my_street", "my_city", "my_postal_code", "my_rig", "my_antenna",
// Misc
"tx_pwr", "comment", "notes",
// ADIF 3.1.7 additional promoted fields
"sig", "sig_info", "my_sig", "my_sig_info", "wwff_ref", "my_wwff_ref",
"distance", "rx_pwr", "a_index", "k_index", "sfi",
"skcc", "fists", "ten_ten", "contacted_op", "eq_call", "pfx", "my_name", "class",
"darc_dok", "my_darc_dok", "region", "silent_key", "swl", "qso_complete", "qso_random",
"credit_granted", "credit_submitted", "my_arrl_sect", "my_vucc_grids",
)
func stringSet(items ...string) map[string]struct{} {
m := make(map[string]struct{}, len(items))
for _, s := range items {
m[s] = struct{}{}
}
return m
}
// RecordToQSO is the exported alias used by the UDP auto-log path so it
// can convert a freshly received ADIF record into a QSO and then enrich
// it with lookup + operating data before inserting.
// applyFieldMap moves values between tags of a record, in place.
//
// The source is CLEARED after the move: a reference that belongs in IOTA should
// not also be left behind in STATE, where it would show up as the contacted
// station's US state in the grid and in every export.
func applyFieldMap(rec Record, m map[string]string) {
if len(m) == 0 || rec == nil {
return
}
// Read every source first. Applied one at a time, a swap (a→b, b→a) would
// read back what the previous move had just written.
orig := make(map[string]string, len(rec))
for k, v := range rec {
orig[k] = v
}
vals := make(map[string]string, len(m))
for from := range m {
if v := strings.TrimSpace(rec[from]); v != "" {
vals[from] = v
}
}
for from := range m {
delete(rec, from)
}
for from, to := range m {
v, ok := vals[from]
if !ok || from == to || to == "" {
continue
}
// Whether the destination was already filled is judged against the record
// as it ARRIVED, not as we are rewriting it — otherwise the outcome would
// depend on Go's map iteration order, which is deliberately random.
//
// A destination that is itself a mapping source is an explicit swap, and
// overwriting it is the whole point; anything else keeps what the file
// carried, because the file's own tag outranks a guess about where a
// reference might have been put.
if _, swap := m[to]; swap || strings.TrimSpace(orig[to]) == "" {
rec[to] = v
}
}
}
func RecordToQSO(rec Record) (qso.QSO, bool) { return recordToQSO(rec) }
// recordToQSO maps an ADIF record onto a QSO. Returns false if required
// fields are missing. Any ADIF tag we don't promote is stored in Extras.
func recordToQSO(rec Record) (qso.QSO, bool) {
call := strings.ToUpper(strings.TrimSpace(rec["call"]))
if call == "" {
return qso.QSO{}, false
}
band := strings.ToLower(strings.TrimSpace(rec["band"]))
mode := strings.ToUpper(strings.TrimSpace(rec["mode"]))
submode := strings.ToUpper(strings.TrimSpace(rec["submode"]))
date := parseDateTime(rec["qso_date"], rec["time_on"])
if date.IsZero() || band == "" || mode == "" {
return qso.QSO{}, false
}
// ADIF promotes specific digital flavours into the SUBMODE field with
// a generic parent in MODE (e.g. MODE=MFSK SUBMODE=FT4). Loggers
// uniformly display the submode in that case — promote it so all
// downstream code (entry strip, worked-before, exports) sees "FT4"
// rather than "MFSK". SSB+USB/LSB and RTTY+JOH stay on the parent
// because the parent IS the displayed mode there.
if submode != "" && submodeSubsumesParent(submode) {
mode = submode
submode = "" // redundant once promoted
}
q := qso.QSO{
Callsign: call,
QSODate: date,
QSODateOff: parseDateTime(rec["qso_date_off"], rec["time_off"]),
Band: band,
BandRX: strings.ToLower(rec["band_rx"]),
Mode: mode,
Submode: submode,
}
if hz, ok := parseFreqHz(rec["freq"]); ok {
q.FreqHz = &hz
}
if hz, ok := parseFreqHz(rec["freq_rx"]); ok {
q.FreqRXHz = &hz
}
// RX defaults to TX when the ADIF omits split info: an empty BAND_RX /
// FREQ_RX means the contact wasn't cross-band/split, so RX = TX.
if q.BandRX == "" {
q.BandRX = q.Band
}
if q.FreqRXHz == nil && q.FreqHz != nil {
v := *q.FreqHz
q.FreqRXHz = &v
}
q.RSTSent = rec["rst_sent"]
q.RSTRcvd = rec["rst_rcvd"]
// Contacted station
q.Name = rec["name"]
q.QTH = rec["qth"]
q.Address = rec["address"]
q.Email = rec["email"]
q.Web = rec["web"]
q.Grid = strings.ToUpper(rec["gridsquare"])
q.GridExt = strings.ToUpper(rec["gridsquare_ext"])
q.VUCCGrids = strings.ToUpper(rec["vucc_grids"])
q.Country = rec["country"]
q.State = strings.ToUpper(rec["state"])
q.County = rec["cnty"]
if v, ok := parseInt(rec["dxcc"]); ok {
q.DXCC = &v
}
q.Continent = strings.ToUpper(rec["cont"])
if v, ok := parseInt(rec["cqz"]); ok {
q.CQZ = &v
}
if v, ok := parseInt(rec["ituz"]); ok {
q.ITUZ = &v
}
q.IOTA = strings.ToUpper(rec["iota"])
q.SOTARef = strings.ToUpper(rec["sota_ref"])
q.POTARef = strings.ToUpper(rec["pota_ref"])
if v, ok := parseInt(rec["age"]); ok {
q.Age = &v
}
if v, ok := parseFloat(rec["lat"]); ok {
q.Lat = &v
}
if v, ok := parseFloat(rec["lon"]); ok {
q.Lon = &v
}
q.Rig = rec["rig"]
q.Ant = rec["ant"]
// QSL
q.QSLSent = rec["qsl_sent"]
q.QSLRcvd = rec["qsl_rcvd"]
q.QSLSentDate = rec["qslsdate"]
q.QSLRcvdDate = rec["qslrdate"]
q.QSLVia = rec["qsl_via"]
if q.QSLVia == "" { // many loggers (Log4OM) write QSL_SENT_VIA instead
q.QSLVia = rec["qsl_sent_via"]
}
q.QSLMsg = rec["qslmsg"]
q.QSLMsgRcvd = rec["qslmsg_rcvd"]
q.LOTWSent = rec["lotw_qsl_sent"]
q.LOTWRcvd = rec["lotw_qsl_rcvd"]
q.LOTWSentDate = rec["lotw_qslsdate"]
q.LOTWRcvdDate = rec["lotw_qslrdate"]
q.EQSLSent = rec["eqsl_qsl_sent"]
q.EQSLRcvd = rec["eqsl_qsl_rcvd"]
q.EQSLSentDate = rec["eqsl_qslsdate"]
q.EQSLRcvdDate = rec["eqsl_qslrdate"]
q.ClublogUploadDate = rec["clublog_qso_upload_date"]
q.ClublogUploadStatus = rec["clublog_qso_upload_status"]
q.HRDLogUploadDate = rec["hrdlog_qso_upload_date"]
q.HRDLogUploadStatus = rec["hrdlog_qso_upload_status"]
q.QRZComUploadDate = rec["qrzcom_qso_upload_date"]
q.QRZComUploadStatus = rec["qrzcom_qso_upload_status"]
q.QRZComDownloadDate = rec["qrzcom_qso_download_date"]
q.QRZComDownloadStatus = rec["qrzcom_qso_download_status"]
// Contest
q.ContestID = rec["contest_id"]
if v, ok := parseInt(rec["srx"]); ok {
q.SRX = &v
}
if v, ok := parseInt(rec["stx"]); ok {
q.STX = &v
}
q.SRXString = rec["srx_string"]
q.STXString = rec["stx_string"]
q.Check = rec["check"]
q.Precedence = rec["precedence"]
q.ARRLSect = strings.ToUpper(rec["arrl_sect"])
// Sat / propagation
q.PropMode = strings.ToUpper(rec["prop_mode"])
q.SatName = strings.ToUpper(rec["sat_name"])
q.SatMode = rec["sat_mode"]
if v, ok := parseFloat(rec["ant_az"]); ok {
q.AntAz = &v
}
if v, ok := parseFloat(rec["ant_el"]); ok {
q.AntEl = &v
}
q.AntPath = strings.ToUpper(rec["ant_path"])
// My station
q.StationCallsign = strings.ToUpper(rec["station_callsign"])
q.Operator = strings.ToUpper(rec["operator"])
q.MyGrid = strings.ToUpper(rec["my_gridsquare"])
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.MyIOTA = strings.ToUpper(rec["my_iota"])
q.MySOTARef = strings.ToUpper(rec["my_sota_ref"])
q.MyPOTARef = strings.ToUpper(rec["my_pota_ref"])
if v, ok := parseInt(rec["my_dxcc"]); ok {
q.MyDXCC = &v
}
if v, ok := parseInt(rec["my_cq_zone"]); ok {
q.MyCQZone = &v
}
if v, ok := parseInt(rec["my_itu_zone"]); ok {
q.MyITUZone = &v
}
if v, ok := parseFloat(rec["my_lat"]); ok {
q.MyLat = &v
}
if v, ok := parseFloat(rec["my_lon"]); ok {
q.MyLon = &v
}
q.MyStreet = rec["my_street"]
q.MyCity = rec["my_city"]
q.MyPostalCode = rec["my_postal_code"]
q.MyRig = rec["my_rig"]
q.MyAntenna = rec["my_antenna"]
// Misc
if v, ok := parseFloat(rec["tx_pwr"]); ok {
q.TXPower = &v
}
q.Comment = rec["comment"]
q.Notes = rec["notes"]
// ADIF 3.1.7 additional promoted fields
q.SIG = rec["sig"]
q.SIGInfo = rec["sig_info"]
q.MySIG = rec["my_sig"]
q.MySIGInfo = rec["my_sig_info"]
q.WWFFRef = strings.ToUpper(rec["wwff_ref"])
q.MyWWFFRef = strings.ToUpper(rec["my_wwff_ref"])
if v, ok := parseFloat(rec["distance"]); ok {
q.Distance = &v
}
if v, ok := parseFloat(rec["rx_pwr"]); ok {
q.RXPower = &v
}
if v, ok := parseFloat(rec["a_index"]); ok {
q.AIndex = &v
}
if v, ok := parseFloat(rec["k_index"]); ok {
q.KIndex = &v
}
if v, ok := parseFloat(rec["sfi"]); ok {
q.SFI = &v
}
q.SKCC = rec["skcc"]
q.FISTS = rec["fists"]
q.TenTen = rec["ten_ten"]
q.ContactedOp = strings.ToUpper(rec["contacted_op"])
q.EqCall = strings.ToUpper(rec["eq_call"])
q.PFX = strings.ToUpper(rec["pfx"])
q.MyName = rec["my_name"]
q.Class = rec["class"]
q.DarcDOK = rec["darc_dok"]
q.MyDarcDOK = rec["my_darc_dok"]
q.Region = rec["region"]
q.SilentKey = strings.ToUpper(rec["silent_key"])
q.SWL = strings.ToUpper(rec["swl"])
q.QSOComplete = rec["qso_complete"]
q.QSORandom = strings.ToUpper(rec["qso_random"])
q.CreditGranted = rec["credit_granted"]
q.CreditSubmitted = rec["credit_submitted"]
q.MyARRLSect = strings.ToUpper(rec["my_arrl_sect"])
q.MyVUCCGrids = strings.ToUpper(rec["my_vucc_grids"])
// Everything else lands in extras (uppercased ADIF names).
var extras map[string]string
for k, v := range rec {
if _, ok := adifPromoted[k]; ok {
continue
}
v = strings.TrimSpace(v)
if v == "" {
continue
}
if extras == nil {
extras = map[string]string{}
}
extras[strings.ToUpper(k)] = v
}
q.Extras = extras
return q, true
}
// parseDateTime combines ADIF QSO_DATE (YYYYMMDD) with TIME (HHMMSS or HHMM).
func parseDateTime(date, timeStr string) time.Time {
date = strings.TrimSpace(date)
timeStr = strings.TrimSpace(timeStr)
if len(date) != 8 {
return time.Time{}
}
layout := "20060102"
val := date
if len(timeStr) == 4 {
layout = "200601021504"
val = date + timeStr
} else if len(timeStr) == 6 {
layout = "20060102150405"
val = date + timeStr
}
t, err := time.ParseInLocation(layout, val, time.UTC)
if err != nil {
return time.Time{}
}
return t.UTC()
}
func parseFreqHz(s string) (int64, bool) {
s = strings.TrimSpace(s)
if s == "" {
return 0, false
}
mhz, err := strconv.ParseFloat(s, 64)
if err != nil || mhz <= 0 {
return 0, false
}
return int64(mhz*1_000_000 + 0.5), true
}
func parseInt(s string) (int, bool) {
s = strings.TrimSpace(s)
if s == "" {
return 0, false
}
v, err := strconv.Atoi(s)
if err != nil {
return 0, false
}
return v, true
}
func parseFloat(s string) (float64, bool) {
s = strings.TrimSpace(s)
if s == "" {
return 0, false
}
v, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0, false
}
return v, true
}
// promotableSubmodes are SUBMODE values that uniquely identify the mode
// every logger displays — the ADIF parent (MFSK, DATA, PSK, JT…) is
// generic and unhelpful to show. SSB's USB/LSB and RTTY's JOH are NOT
// here because operators want to see "SSB" / "RTTY", not the sideband
// or RTTY variant.
var promotableSubmodes = map[string]bool{
// MFSK family (modern FT/Q65/JS8/MSK144 live here)
"FT2": true, "FT4": true, "FT8": true, "JS8": true, "MSK144": true, "ISCAT": true,
"Q65": true, "FST4": true, "FST4W": true,
"MFSK16": true, "MFSK32": true, "MFSK64": true, "MFSK128": true,
"OLIVIA": true,
// JT family (some loggers still parent these under DATA)
"JT65": true, "JT9": true, "JT4": true, "JT6M": true, "JT44": true, "T10": true,
// PSK family
"PSK31": true, "PSK63": true, "PSK125": true, "PSK250": true, "PSK500": true,
"QPSK31": true, "QPSK63": true, "QPSK125": true, "QPSK250": true, "QPSK500": true,
// THOR / DOMINO / HELL
"THOR4": true, "THOR8": true, "THOR16": true, "THOR32": true,
"DOMINOF": true, "DOMINOEX": true,
"HELL80": true, "FMHELL": true,
// DIGITALVOICE variants
"FREEDV": true,
// VARA family — Log4OM parents these under DYNAMIC. VarAC chat uses
// "VARA HF" (with the space) so we match both spellings.
"VARA": true, "VARA HF": true, "VARA FM": true, "VARAC": true,
}
func submodeSubsumesParent(submode string) bool {
return promotableSubmodes[submode]
}