267 lines
8.4 KiB
Go
267 lines
8.4 KiB
Go
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, "<ADIF_VER:5>3.1.0 <PROGRAMID:%d>%s", len(app), app)
|
|
if ver != "" {
|
|
fmt.Fprintf(bw, " <PROGRAMVERSION:%d>%s", len(ver), ver)
|
|
}
|
|
fmt.Fprintf(bw, " <CREATED_TIMESTAMP:15>%s <EOH>\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 <EOR>.
|
|
// 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("<EOR>\n")
|
|
}
|
|
|
|
// writeField writes one `<TAG:length>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, ""
|
|
}
|