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 } // ExportFile creates path (overwriting if it exists) and writes every QSO. func (e *Exporter) ExportFile(ctx context.Context, path string) (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.Export(ctx, f) 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. // Returns the number of QSOs successfully written. func (e *Exporter) Export(ctx context.Context, w io.Writer) (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, "3.1.0 %s", len(app), app) if ver != "" { fmt.Fprintf(bw, " %s", len(ver), ver) } fmt.Fprintf(bw, " %s \n\n", now) count := 0 err := e.Repo.IterateAll(ctx, func(q qso.QSO) error { writeRecord(bw, q) count++ return nil }) return count, err } // writeRecord serialises one QSO as ADIF tags terminated by . // 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) { // --- Core --- writeField(bw, "CALL", q.Callsign) if !q.QSODate.IsZero() { writeField(bw, "QSO_DATE", q.QSODate.UTC().Format("20060102")) writeField(bw, "TIME_ON", q.QSODate.UTC().Format("150405")) } if !q.QSODateOff.IsZero() { writeField(bw, "QSO_DATE_OFF", q.QSODateOff.UTC().Format("20060102")) writeField(bw, "TIME_OFF", q.QSODateOff.UTC().Format("150405")) } writeField(bw, "BAND", q.Band) writeField(bw, "BAND_RX", q.BandRX) mode, submode := modeForExport(q.Mode, q.Submode) writeField(bw, "MODE", mode) writeField(bw, "SUBMODE", submode) if q.FreqHz != nil && *q.FreqHz > 0 { writeField(bw, "FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64)) } if q.FreqRXHz != nil && *q.FreqRXHz > 0 { writeField(bw, "FREQ_RX", strconv.FormatFloat(float64(*q.FreqRXHz)/1_000_000, 'f', 6, 64)) } writeField(bw, "RST_SENT", q.RSTSent) writeField(bw, "RST_RCVD", q.RSTRcvd) // --- Contacted --- writeField(bw, "NAME", q.Name) writeField(bw, "QTH", q.QTH) writeField(bw, "ADDRESS", q.Address) writeField(bw, "EMAIL", q.Email) writeField(bw, "WEB", q.Web) writeField(bw, "GRIDSQUARE", q.Grid) writeField(bw, "GRIDSQUARE_EXT", q.GridExt) writeField(bw, "VUCC_GRIDS", q.VUCCGrids) writeField(bw, "COUNTRY", q.Country) writeField(bw, "STATE", q.State) writeField(bw, "CNTY", q.County) writeIntPtr(bw, "DXCC", q.DXCC) writeField(bw, "CONT", q.Continent) writeIntPtr(bw, "CQZ", q.CQZ) writeIntPtr(bw, "ITUZ", q.ITUZ) writeField(bw, "IOTA", q.IOTA) writeField(bw, "SOTA_REF", q.SOTARef) writeField(bw, "POTA_REF", q.POTARef) writeIntPtr(bw, "AGE", q.Age) writeFloatPtr(bw, "LAT", q.Lat, 6) writeFloatPtr(bw, "LON", q.Lon, 6) writeField(bw, "RIG", q.Rig) writeField(bw, "ANT", q.Ant) // --- QSL / LoTW / eQSL / Clublog / HRDLog --- writeField(bw, "QSL_SENT", q.QSLSent) writeField(bw, "QSL_RCVD", q.QSLRcvd) writeField(bw, "QSLSDATE", q.QSLSentDate) writeField(bw, "QSLRDATE", q.QSLRcvdDate) writeField(bw, "QSL_VIA", q.QSLVia) writeField(bw, "QSLMSG", q.QSLMsg) writeField(bw, "QSLMSG_RCVD", q.QSLMsgRcvd) writeField(bw, "LOTW_QSL_SENT", q.LOTWSent) writeField(bw, "LOTW_QSL_RCVD", q.LOTWRcvd) writeField(bw, "LOTW_QSLSDATE", q.LOTWSentDate) writeField(bw, "LOTW_QSLRDATE", q.LOTWRcvdDate) writeField(bw, "EQSL_QSL_SENT", q.EQSLSent) writeField(bw, "EQSL_QSL_RCVD", q.EQSLRcvd) writeField(bw, "EQSL_QSLSDATE", q.EQSLSentDate) writeField(bw, "EQSL_QSLRDATE", q.EQSLRcvdDate) writeField(bw, "CLUBLOG_QSO_UPLOAD_DATE", q.ClublogUploadDate) writeField(bw, "CLUBLOG_QSO_UPLOAD_STATUS", q.ClublogUploadStatus) writeField(bw, "HRDLOG_QSO_UPLOAD_DATE", q.HRDLogUploadDate) writeField(bw, "HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus) // --- Contest --- writeField(bw, "CONTEST_ID", q.ContestID) writeIntPtr(bw, "SRX", q.SRX) writeIntPtr(bw, "STX", q.STX) writeField(bw, "SRX_STRING", q.SRXString) writeField(bw, "STX_STRING", q.STXString) writeField(bw, "CHECK", q.Check) writeField(bw, "PRECEDENCE", q.Precedence) writeField(bw, "ARRL_SECT", q.ARRLSect) // --- Satellite / propagation --- writeField(bw, "PROP_MODE", q.PropMode) writeField(bw, "SAT_NAME", q.SatName) writeField(bw, "SAT_MODE", q.SatMode) writeFloatPtr(bw, "ANT_AZ", q.AntAz, 1) writeFloatPtr(bw, "ANT_EL", q.AntEl, 1) writeField(bw, "ANT_PATH", q.AntPath) // --- My station / operator --- writeField(bw, "STATION_CALLSIGN", q.StationCallsign) writeField(bw, "OPERATOR", q.Operator) writeField(bw, "MY_GRIDSQUARE", q.MyGrid) writeField(bw, "MY_GRIDSQUARE_EXT", q.MyGridExt) writeField(bw, "MY_COUNTRY", q.MyCountry) writeField(bw, "MY_STATE", q.MyState) writeField(bw, "MY_CNTY", q.MyCounty) writeField(bw, "MY_IOTA", q.MyIOTA) writeField(bw, "MY_SOTA_REF", q.MySOTARef) writeField(bw, "MY_POTA_REF", q.MyPOTARef) writeIntPtr(bw, "MY_DXCC", q.MyDXCC) writeIntPtr(bw, "MY_CQ_ZONE", q.MyCQZone) writeIntPtr(bw, "MY_ITU_ZONE", q.MyITUZone) writeFloatPtr(bw, "MY_LAT", q.MyLat, 6) writeFloatPtr(bw, "MY_LON", q.MyLon, 6) writeField(bw, "MY_STREET", q.MyStreet) writeField(bw, "MY_CITY", q.MyCity) writeField(bw, "MY_POSTAL_CODE", q.MyPostalCode) writeField(bw, "MY_RIG", q.MyRig) writeField(bw, "MY_ANTENNA", q.MyAntenna) // --- Misc --- writeFloatPtr(bw, "TX_PWR", q.TXPower, 1) writeField(bw, "COMMENT", q.Comment) writeField(bw, "NOTES", q.Notes) // --- Extras (unpromoted ADIF fields preserved verbatim) --- for k, v := range q.Extras { writeField(bw, strings.ToUpper(k), v) } bw.WriteString("\n") } // writeField writes one `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) { if v == "" { return } fmt.Fprintf(bw, "<%s:%d>%s ", tag, len(v), v) } 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, "" }