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 // IncludeAppFields controls whether application-specific fields (ADIF // "APP__" tags, e.g. Log4OM's APP_LOG4OM_* or our own // OpsLog extensions) are written. Leave false for a clean standard-ADIF // export destined for another logger; set true for a full OpsLog→OpsLog // round-trip that preserves everything. IncludeAppFields bool // Fields, when non-nil, restricts the export to exactly these ADIF tags // (uppercase) — the "choose which fields to export" mode. nil = write every // field (the standard/full behaviour above). When set it overrides // IncludeAppFields: an APP_/vendor tag is written iff it's in the set. Fields map[string]bool } // iterator streams QSOs through fn. The three concrete sources (all, filtered, // by-ids) all match this shape so the document writer stays source-agnostic. type iterator func(ctx context.Context, fn func(qso.QSO) error) error // ExportFile creates path (overwriting if it exists) and writes every QSO. func (e *Exporter) ExportFile(ctx context.Context, path string) (ExportResult, error) { return e.exportFileWith(ctx, path, e.Repo.IterateAll) } // ExportFileFiltered writes only the QSOs matching f (no row limit). func (e *Exporter) ExportFileFiltered(ctx context.Context, path string, f qso.QueryFilter) (ExportResult, error) { return e.exportFileWith(ctx, path, func(ctx context.Context, fn func(qso.QSO) error) error { return e.Repo.IterateFiltered(ctx, f, fn) }) } // ExportFileByIDs writes only the QSOs with the given ids. func (e *Exporter) ExportFileByIDs(ctx context.Context, path string, ids []int64) (ExportResult, error) { return e.exportFileWith(ctx, path, func(ctx context.Context, fn func(qso.QSO) error) error { return e.Repo.IterateByIDs(ctx, ids, fn) }) } func (e *Exporter) exportFileWith(ctx context.Context, path string, iter iterator) (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.writeDoc(ctx, f, iter) 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 for // every QSO. Returns the number of QSOs written. func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) { return e.writeDoc(ctx, w, e.Repo.IterateAll) } // writeDoc writes the ADIF header then streams every QSO from iter. func (e *Exporter) writeDoc(ctx context.Context, w io.Writer, iter iterator) (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, "%s %s", len(adifVersion), adifVersion, len(app), app) if ver != "" { fmt.Fprintf(bw, " %s", len(ver), ver) } fmt.Fprintf(bw, " %s \n\n", now) count := 0 err := iter(ctx, func(q qso.QSO) error { writeRecord(bw, q, e.IncludeAppFields, e.Fields) count++ return nil }) return count, err } // SingleRecordADIF returns one QSO serialised as an ADIF record (fields // terminated by ), with no document header. Used by the external- // service uploaders (QRZ.com / Clublog / …) which want a bare record as // the ADIF parameter of their HTTP API. func SingleRecordADIF(q qso.QSO) string { var b strings.Builder bw := bufio.NewWriter(&b) // Uploads target other services — keep it standard (no app-specific tags). writeRecord(bw, q, false, nil) bw.Flush() return b.String() } // FullRecordADIF serialises one QSO LOSSLESSLY — including the APP_* extras — // so it can be written out and read back with nothing dropped. Used by the // offline queue: a QSO parked in the safety file must come back identical // (extras carry its queue id, awards refs, ADIF 3.1.7 leftovers…). func FullRecordADIF(q qso.QSO) string { var b strings.Builder bw := bufio.NewWriter(&b) writeRecord(bw, q, true, nil) bw.Flush() return b.String() } // BatchRecordsADIF wraps already-serialised records (each terminated by , // e.g. from SingleRecordADIF) in a minimal ADIF document with a standard // header. Used by file-based batch upload APIs such as Club Log's putlogs.php. func BatchRecordsADIF(records []string) string { var b strings.Builder now := time.Now().UTC().Format("20060102 150405") fmt.Fprintf(&b, "%s OpsLog %s \n\n", len(adifVersion), adifVersion, now) for _, r := range records { r = strings.TrimRight(r, "\r\n") if r == "" { continue } b.WriteString(r) b.WriteString("\n") } return b.String() } // 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, includeApp bool, allow map[string]bool) { // allow == nil → write every promoted field (standard/full behaviour). // Otherwise a promoted tag is written only when it's in the chosen set. // w/wi/wf wrap the raw writers with that gate so the ~150 field lines below // stay one-liners. ok := func(tag string) bool { return allow == nil || allow[tag] } w := func(tag, v string) { if ok(tag) { writeField(bw, tag, v) } } wi := func(tag string, p *int) { if ok(tag) { writeIntPtr(bw, tag, p) } } wf := func(tag string, p *float64, decimals int) { if ok(tag) { writeFloatPtr(bw, tag, p, decimals) } } // --- Core --- w("CALL", q.Callsign) if !q.QSODate.IsZero() { w("QSO_DATE", q.QSODate.UTC().Format("20060102")) w("TIME_ON", q.QSODate.UTC().Format("150405")) } if !q.QSODateOff.IsZero() { w("QSO_DATE_OFF", q.QSODateOff.UTC().Format("20060102")) w("TIME_OFF", q.QSODateOff.UTC().Format("150405")) } w("BAND", q.Band) w("BAND_RX", q.BandRX) mode, submode := modeForExport(q.Mode, q.Submode) w("MODE", mode) w("SUBMODE", submode) if q.FreqHz != nil && *q.FreqHz > 0 { w("FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64)) } if q.FreqRXHz != nil && *q.FreqRXHz > 0 { w("FREQ_RX", strconv.FormatFloat(float64(*q.FreqRXHz)/1_000_000, 'f', 6, 64)) } w("RST_SENT", q.RSTSent) w("RST_RCVD", q.RSTRcvd) // --- Contacted --- w("NAME", q.Name) w("QTH", q.QTH) w("ADDRESS", q.Address) w("EMAIL", q.Email) w("WEB", q.Web) w("GRIDSQUARE", q.Grid) w("GRIDSQUARE_EXT", q.GridExt) w("VUCC_GRIDS", q.VUCCGrids) w("COUNTRY", q.Country) w("STATE", q.State) w("CNTY", q.County) wi("DXCC", q.DXCC) w("CONT", q.Continent) wi("CQZ", q.CQZ) wi("ITUZ", q.ITUZ) w("IOTA", q.IOTA) w("SOTA_REF", q.SOTARef) w("POTA_REF", q.POTARef) wi("AGE", q.Age) wf("LAT", q.Lat, 6) wf("LON", q.Lon, 6) w("RIG", q.Rig) w("ANT", q.Ant) // --- QSL / LoTW / eQSL / Clublog / HRDLog --- w("QSL_SENT", q.QSLSent) w("QSL_RCVD", q.QSLRcvd) w("QSLSDATE", q.QSLSentDate) w("QSLRDATE", q.QSLRcvdDate) w("QSL_VIA", q.QSLVia) w("QSLMSG", q.QSLMsg) w("QSLMSG_RCVD", q.QSLMsgRcvd) w("LOTW_QSL_SENT", q.LOTWSent) w("LOTW_QSL_RCVD", q.LOTWRcvd) w("LOTW_QSLSDATE", q.LOTWSentDate) w("LOTW_QSLRDATE", q.LOTWRcvdDate) w("EQSL_QSL_SENT", q.EQSLSent) w("EQSL_QSL_RCVD", q.EQSLRcvd) w("EQSL_QSLSDATE", q.EQSLSentDate) w("EQSL_QSLRDATE", q.EQSLRcvdDate) w("CLUBLOG_QSO_UPLOAD_DATE", q.ClublogUploadDate) w("CLUBLOG_QSO_UPLOAD_STATUS", q.ClublogUploadStatus) w("HRDLOG_QSO_UPLOAD_DATE", q.HRDLogUploadDate) w("HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus) w("QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate) w("QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus) w("QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate) w("QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus) // --- Contest --- w("CONTEST_ID", q.ContestID) wi("SRX", q.SRX) wi("STX", q.STX) w("SRX_STRING", q.SRXString) w("STX_STRING", q.STXString) w("CHECK", q.Check) w("PRECEDENCE", q.Precedence) w("ARRL_SECT", q.ARRLSect) // --- Satellite / propagation --- w("PROP_MODE", q.PropMode) w("SAT_NAME", q.SatName) w("SAT_MODE", q.SatMode) wf("ANT_AZ", q.AntAz, 1) wf("ANT_EL", q.AntEl, 1) w("ANT_PATH", q.AntPath) // --- My station / operator --- w("STATION_CALLSIGN", q.StationCallsign) w("OPERATOR", q.Operator) w("MY_GRIDSQUARE", q.MyGrid) w("MY_GRIDSQUARE_EXT", q.MyGridExt) w("MY_COUNTRY", q.MyCountry) w("MY_STATE", q.MyState) w("MY_CNTY", q.MyCounty) w("MY_IOTA", q.MyIOTA) w("MY_SOTA_REF", q.MySOTARef) w("MY_POTA_REF", q.MyPOTARef) wi("MY_DXCC", q.MyDXCC) wi("MY_CQ_ZONE", q.MyCQZone) wi("MY_ITU_ZONE", q.MyITUZone) wf("MY_LAT", q.MyLat, 6) wf("MY_LON", q.MyLon, 6) w("MY_STREET", q.MyStreet) w("MY_CITY", q.MyCity) w("MY_POSTAL_CODE", q.MyPostalCode) w("MY_RIG", q.MyRig) w("MY_ANTENNA", q.MyAntenna) // --- Misc --- wf("TX_PWR", q.TXPower, 1) w("COMMENT", q.Comment) w("NOTES", q.Notes) // --- ADIF 3.1.7 additional promoted fields --- w("SIG", q.SIG) w("SIG_INFO", q.SIGInfo) w("MY_SIG", q.MySIG) w("MY_SIG_INFO", q.MySIGInfo) w("WWFF_REF", q.WWFFRef) w("MY_WWFF_REF", q.MyWWFFRef) wf("DISTANCE", q.Distance, 1) wf("RX_PWR", q.RXPower, 1) wf("A_INDEX", q.AIndex, 0) wf("K_INDEX", q.KIndex, 0) wf("SFI", q.SFI, 0) w("SKCC", q.SKCC) w("FISTS", q.FISTS) w("TEN_TEN", q.TenTen) w("CONTACTED_OP", q.ContactedOp) w("EQ_CALL", q.EqCall) w("PFX", q.PFX) w("MY_NAME", q.MyName) w("CLASS", q.Class) w("DARC_DOK", q.DarcDOK) w("MY_DARC_DOK", q.MyDarcDOK) w("REGION", q.Region) w("SILENT_KEY", q.SilentKey) w("SWL", q.SWL) w("QSO_COMPLETE", q.QSOComplete) w("QSO_RANDOM", q.QSORandom) w("CREDIT_GRANTED", q.CreditGranted) w("CREDIT_SUBMITTED", q.CreditSubmitted) w("MY_ARRL_SECT", q.MyARRLSect) w("MY_VUCC_GRIDS", q.MyVUCCGrids) // --- Extras (unpromoted ADIF fields preserved verbatim) --- // Standard mode emits ONLY valid ADIF-spec fields, so it drops APP_* // application-specific tags AND any non-standard / vendor tag — keeping // the file strictly portable to other loggers. Full mode keeps every // extra for a lossless OpsLog round-trip. for k, v := range q.Extras { tag := strings.ToUpper(k) if allow != nil { // Chosen-fields mode: the set alone decides (incl. APP_/vendor tags). if !allow[tag] { continue } } else if !includeApp && (strings.HasPrefix(tag, "APP_") || !IsStandardField(tag)) { continue } writeField(bw, tag, 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, "" }