7ace2cc602
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>
365 lines
9.2 KiB
Go
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
|
|
}
|