Files
OpsLog/internal/adif/import.go
T
rouggy 7ace2cc602 Initial codebase: Go + Wails amateur radio logbook
Backend (Go 1.25 / Wails v2):
- QSO storage on SQLite (modernc) with embedded migrations (0001..0005)
- Streaming ADIF import (batch insert) + WorkedBefore per callsign and DXCC
- Callsign lookup with QRZ.com + HamQTH providers (primary/failsafe routing)
  and SQLite-backed TTL cache
- DXCC resolver from cty.dat (auto-download, longest-prefix-match)
- Multi-profile operator identities (home/portable/SOTA/contest) — every
  QSO stamps MY_* from the active profile
- CAT control via OmniRig COM on a single OS-locked goroutine, with
  bidirectional sync (freq/mode/band/split/VFOs) and Rig1/Rig2 hot-swap
- Settings store (key/value), CAT debug log at %APPDATA%/HamLog/cat.log

Frontend (React 18 + TypeScript + Tailwind v4 + shadcn-style):
- Single-row entry strip with CAT-aware band/mode/freq, RST, Start/End
  UTC, per-field locks (band/mode/freq/start/end) for backdated QSOs
- Topbar: live freq (MHz.kHz.Hz dotted), live UTC, band/mode/SPLIT badges,
  CAT pill with rig selector and clickable Azimuth pill (rotor TODO)
- Settings tree: Profiles (Log4OM-style manager), Station Information
  (edits the active profile), unified Callsign Lookup with Test buttons,
  Bands/Modes lists, CAT
- Worked-before matrix (band × mode × class) with new-DXCC highlighting
- ADIF import from menu + Maintenance > Refresh cty.dat

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:16:45 +02:00

365 lines
9.2 KiB
Go

package adif
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"time"
"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
Skipped int `json:"skipped"` // dropped (missing required fields, etc.)
Errors []string `json:"errors"` // up to maxErrors error messages
}
const maxErrors = 50
// Importer streams an ADI file into a QSO repository.
type Importer struct {
Repo *qso.Repo
BatchSize int // 0 → 500
}
// ImportFile opens the file at path and imports it into the repo.
func (im *Importer) ImportFile(ctx context.Context, path string) (ImportResult, error) {
f, err := os.Open(path)
if err != nil {
return ImportResult{}, fmt.Errorf("open %s: %w", path, err)
}
defer f.Close()
return im.Import(ctx, f)
}
// Import streams the ADI content from r into the repo.
func (im *Importer) Import(ctx context.Context, r interface {
Read(p []byte) (int, error)
}) (ImportResult, error) {
if im.BatchSize <= 0 {
im.BatchSize = 500
}
res := ImportResult{}
batch := make([]qso.QSO, 0, im.BatchSize)
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 := Parse(r, func(rec Record) error {
res.Total++
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
}
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
}
return res, nil
}
// 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", "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",
// 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",
)
func stringSet(items ...string) map[string]struct{} {
m := make(map[string]struct{}, len(items))
for _, s := range items {
m[s] = struct{}{}
}
return m
}
// 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"]))
date := parseDateTime(rec["qso_date"], rec["time_on"])
if date.IsZero() || band == "" || mode == "" {
return qso.QSO{}, false
}
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: strings.ToUpper(rec["submode"]),
}
if hz, ok := parseFreqHz(rec["freq"]); ok {
q.FreqHz = &hz
}
if hz, ok := parseFreqHz(rec["freq_rx"]); ok {
q.FreqRXHz = &hz
}
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"]
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"]
// 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"]
// 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
}