feat: ADIF export field picker — choose exactly which fields to write

Global "Export to ADIF" gains a third option "Choose fields…" next to the
standard-only and full-OpsLog choices, and right-clicking selected QSOs adds
"Export selected — choose fields…". The picker separates official ADIF 3.1.7
fields (grouped by category) from OpsLog/non-standard tags actually present in
the log (discovered via DistinctExtraKeys over extras_json), with All/None per
group and reset-to-defaults; the selection is persisted per user.

Backend: adif.Exporter gains a Fields allow-set that gates every promoted and
extras field in writeRecord; app.go ExportADIF/ExportADIFFiltered/
ExportADIFSelected take a fields []string param, plus new ADIFExtraFields.
This commit is contained in:
2026-07-24 10:56:07 +02:00
parent a5cfecbf76
commit e3aeeae616
11 changed files with 463 additions and 171 deletions
+162 -131
View File
@@ -34,6 +34,12 @@ type Exporter struct {
// 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,
@@ -100,7 +106,7 @@ func (e *Exporter) writeDoc(ctx context.Context, w io.Writer, iter iterator) (in
count := 0
err := iter(ctx, func(q qso.QSO) error {
writeRecord(bw, q, e.IncludeAppFields)
writeRecord(bw, q, e.IncludeAppFields, e.Fields)
count++
return nil
})
@@ -115,7 +121,7 @@ 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)
writeRecord(bw, q, false, nil)
bw.Flush()
return b.String()
}
@@ -127,7 +133,7 @@ func SingleRecordADIF(q qso.QSO) string {
func FullRecordADIF(q qso.QSO) string {
var b strings.Builder
bw := bufio.NewWriter(&b)
writeRecord(bw, q, true)
writeRecord(bw, q, true, nil)
bw.Flush()
return b.String()
}
@@ -155,161 +161,181 @@ func BatchRecordsADIF(records []string) string {
// 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) {
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 ---
writeField(bw, "CALL", q.Callsign)
w("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"))
w("QSO_DATE", q.QSODate.UTC().Format("20060102"))
w("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"))
w("QSO_DATE_OFF", q.QSODateOff.UTC().Format("20060102"))
w("TIME_OFF", q.QSODateOff.UTC().Format("150405"))
}
writeField(bw, "BAND", q.Band)
writeField(bw, "BAND_RX", q.BandRX)
w("BAND", q.Band)
w("BAND_RX", q.BandRX)
mode, submode := modeForExport(q.Mode, q.Submode)
writeField(bw, "MODE", mode)
writeField(bw, "SUBMODE", submode)
w("MODE", mode)
w("SUBMODE", submode)
if q.FreqHz != nil && *q.FreqHz > 0 {
writeField(bw, "FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64))
w("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))
w("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)
w("RST_SENT", q.RSTSent)
w("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)
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 ---
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)
writeField(bw, "QRZCOM_QSO_UPLOAD_DATE", q.QRZComUploadDate)
writeField(bw, "QRZCOM_QSO_UPLOAD_STATUS", q.QRZComUploadStatus)
writeField(bw, "QRZCOM_QSO_DOWNLOAD_DATE", q.QRZComDownloadDate)
writeField(bw, "QRZCOM_QSO_DOWNLOAD_STATUS", q.QRZComDownloadStatus)
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 ---
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)
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 ---
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)
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 ---
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)
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 ---
writeFloatPtr(bw, "TX_PWR", q.TXPower, 1)
writeField(bw, "COMMENT", q.Comment)
writeField(bw, "NOTES", q.Notes)
wf("TX_PWR", q.TXPower, 1)
w("COMMENT", q.Comment)
w("NOTES", q.Notes)
// --- ADIF 3.1.7 additional promoted fields ---
writeField(bw, "SIG", q.SIG)
writeField(bw, "SIG_INFO", q.SIGInfo)
writeField(bw, "MY_SIG", q.MySIG)
writeField(bw, "MY_SIG_INFO", q.MySIGInfo)
writeField(bw, "WWFF_REF", q.WWFFRef)
writeField(bw, "MY_WWFF_REF", q.MyWWFFRef)
writeFloatPtr(bw, "DISTANCE", q.Distance, 1)
writeFloatPtr(bw, "RX_PWR", q.RXPower, 1)
writeFloatPtr(bw, "A_INDEX", q.AIndex, 0)
writeFloatPtr(bw, "K_INDEX", q.KIndex, 0)
writeFloatPtr(bw, "SFI", q.SFI, 0)
writeField(bw, "SKCC", q.SKCC)
writeField(bw, "FISTS", q.FISTS)
writeField(bw, "TEN_TEN", q.TenTen)
writeField(bw, "CONTACTED_OP", q.ContactedOp)
writeField(bw, "EQ_CALL", q.EqCall)
writeField(bw, "PFX", q.PFX)
writeField(bw, "MY_NAME", q.MyName)
writeField(bw, "CLASS", q.Class)
writeField(bw, "DARC_DOK", q.DarcDOK)
writeField(bw, "MY_DARC_DOK", q.MyDarcDOK)
writeField(bw, "REGION", q.Region)
writeField(bw, "SILENT_KEY", q.SilentKey)
writeField(bw, "SWL", q.SWL)
writeField(bw, "QSO_COMPLETE", q.QSOComplete)
writeField(bw, "QSO_RANDOM", q.QSORandom)
writeField(bw, "CREDIT_GRANTED", q.CreditGranted)
writeField(bw, "CREDIT_SUBMITTED", q.CreditSubmitted)
writeField(bw, "MY_ARRL_SECT", q.MyARRLSect)
writeField(bw, "MY_VUCC_GRIDS", q.MyVUCCGrids)
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_*
@@ -318,7 +344,12 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool) {
// extra for a lossless OpsLog round-trip.
for k, v := range q.Extras {
tag := strings.ToUpper(k)
if !includeApp && (strings.HasPrefix(tag, "APP_") || !IsStandardField(tag)) {
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)
+36
View File
@@ -366,6 +366,42 @@ func decodeExtras(s string) map[string]string {
return m
}
// DistinctExtraKeys returns the set of keys present across stored QSO extras
// (the non-promoted ADIF tags kept verbatim), scanning the most recent `sample`
// rows that have any extras. Capped so it stays fast on a large remote logbook —
// the extras vocabulary is small and stable, so a recent sample finds it all.
func (r *Repo) DistinctExtraKeys(ctx context.Context, sample int) ([]string, error) {
if sample <= 0 {
sample = 5000
}
rows, err := r.db.QueryContext(ctx,
`SELECT extras_json FROM qso
WHERE extras_json IS NOT NULL AND extras_json <> '' AND extras_json <> '{}'
ORDER BY id DESC LIMIT ?`, sample)
if err != nil {
return nil, err
}
defer rows.Close()
seen := map[string]bool{}
for rows.Next() {
var s sql.NullString
if err := rows.Scan(&s); err != nil {
return nil, err
}
for k := range decodeExtras(s.String) {
seen[k] = true
}
}
if err := rows.Err(); err != nil {
return nil, err
}
out := make([]string, 0, len(seen))
for k := range seen {
out = append(out, k)
}
return out, nil
}
// Add inserts a QSO and returns its ID.
func (r *Repo) Add(ctx context.Context, q QSO) (int64, error) {
q.Callsign = strings.ToUpper(strings.TrimSpace(q.Callsign))