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 }