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
+46 -6
View File
@@ -5465,37 +5465,55 @@ func (a *App) ADIFFields() []adif.FieldDef { return adif.Fields }
// ADIFVersion returns the ADIF spec version OpsLog conforms to (e.g. "3.1.7").
func (a *App) ADIFVersion() string { return adif.ADIFVersion() }
// adifFieldSet turns a list of ADIF tag names into an allow-set (uppercased),
// or nil when the list is empty — nil means "all fields" (standard/full mode),
// so the two-mode export path is unchanged when no explicit field list is given.
func adifFieldSet(fields []string) map[string]bool {
if len(fields) == 0 {
return nil
}
set := make(map[string]bool, len(fields))
for _, f := range fields {
if t := strings.ToUpper(strings.TrimSpace(f)); t != "" {
set[t] = true
}
}
return set
}
// ExportADIF writes every QSO to the given file path in ADIF 3.1 format.
// Streams from DB so memory stays flat even with 100k+ records.
// includeAppFields=false → portable standard ADIF (for other loggers);
// true → full export keeping OpsLog/app-specific APP_* fields (round-trip).
func (a *App) ExportADIF(path string, includeAppFields bool) (adif.ExportResult, error) {
// fields (optional): when non-empty, export EXACTLY those ADIF tags — the
// "choose which fields to export" mode (overrides includeAppFields).
func (a *App) ExportADIF(path string, includeAppFields bool, fields []string) (adif.ExportResult, error) {
if a.qso == nil {
return adif.ExportResult{}, fmt.Errorf("db not initialized")
}
if path == "" {
return adif.ExportResult{}, fmt.Errorf("empty path")
}
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields}
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields, Fields: adifFieldSet(fields)}
return ex.ExportFile(a.ctx, path)
}
// ExportADIFFiltered writes the QSOs matching the current filter to path, with
// NO row limit (the on-screen list is capped by the threshold; this is not).
func (a *App) ExportADIFFiltered(path string, includeAppFields bool, f qso.QueryFilter) (adif.ExportResult, error) {
func (a *App) ExportADIFFiltered(path string, includeAppFields bool, f qso.QueryFilter, fields []string) (adif.ExportResult, error) {
if a.qso == nil {
return adif.ExportResult{}, fmt.Errorf("db not initialized")
}
if path == "" {
return adif.ExportResult{}, fmt.Errorf("empty path")
}
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields}
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields, Fields: adifFieldSet(fields)}
return ex.ExportFileFiltered(a.ctx, path, f)
}
// ExportADIFSelected writes only the QSOs whose ids are given (the rows the
// operator highlighted in the grid).
func (a *App) ExportADIFSelected(path string, includeAppFields bool, ids []int64) (adif.ExportResult, error) {
func (a *App) ExportADIFSelected(path string, includeAppFields bool, ids []int64, fields []string) (adif.ExportResult, error) {
if a.qso == nil {
return adif.ExportResult{}, fmt.Errorf("db not initialized")
}
@@ -5505,10 +5523,32 @@ func (a *App) ExportADIFSelected(path string, includeAppFields bool, ids []int64
if len(ids) == 0 {
return adif.ExportResult{}, fmt.Errorf("no QSOs selected")
}
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields}
ex := &adif.Exporter{Repo: a.qso, AppName: "OpsLog", AppVersion: "0.1", IncludeAppFields: includeAppFields, Fields: adifFieldSet(fields)}
return ex.ExportFileByIDs(a.ctx, path, ids)
}
// ADIFExtraFields returns the distinct NON-standard ADIF tags actually present
// in the logbook's stored extras (APP_* and vendor tags) — the "OpsLog / extra
// fields" group in the export field picker, alongside the static official-ADIF
// list from ADIFFields(). Sampled + capped so it stays fast on a big remote log.
func (a *App) ADIFExtraFields() []string {
if a.qso == nil {
return nil
}
keys, err := a.qso.DistinctExtraKeys(a.ctx, 5000)
if err != nil {
return nil
}
out := make([]string, 0, len(keys))
for _, k := range keys {
if t := strings.ToUpper(strings.TrimSpace(k)); t != "" && !adif.IsStandardField(t) {
out = append(out, t)
}
}
sort.Strings(out)
return out
}
// CabrilloResult reports how many QSOs a Cabrillo export wrote and where.
type CabrilloResult struct {
Count int `json:"count"`