From 17f7a00bd7f865e3946bd71d7b9c7f5037d98da2 Mon Sep 17 00:00:00 2001 From: rouggy Date: Sat, 6 Jun 2026 14:16:30 +0200 Subject: [PATCH] up --- app.go | 154 +++++++++- frontend/src/App.tsx | 14 +- frontend/src/components/AdifExtrasEditor.tsx | 132 +++++++++ frontend/src/components/AwardEditor.tsx | 33 ++- frontend/src/components/AwardRefSelector.tsx | 50 ++-- frontend/src/components/AwardsPanel.tsx | 6 +- frontend/src/components/QSOEditModal.tsx | 121 +++++--- frontend/src/lib/awardRefs.ts | 6 + frontend/wailsjs/go/main/App.d.ts | 14 +- frontend/wailsjs/go/main/App.js | 24 +- frontend/wailsjs/go/models.ts | 98 +++++++ internal/adif/export.go | 43 ++- internal/adif/fields.go | 272 ++++++++++++++++++ internal/adif/import.go | 48 ++++ internal/adif/roundtrip_test.go | 121 ++++++++ internal/award/award.go | 32 +++ internal/award/award_test.go | 32 +++ .../db/migrations/0019_adif_317_fields.sql | 55 ++++ internal/qso/qso.go | 114 +++++++- 19 files changed, 1278 insertions(+), 91 deletions(-) create mode 100644 frontend/src/components/AdifExtrasEditor.tsx create mode 100644 internal/adif/fields.go create mode 100644 internal/adif/roundtrip_test.go create mode 100644 internal/db/migrations/0019_adif_317_fields.sql diff --git a/app.go b/app.go index 7b15146..f478ac2 100644 --- a/app.go +++ b/app.go @@ -2141,9 +2141,14 @@ func bandForHz(hz int64) string { // the operator assigns by hand. Such awards are read-only in the per-QSO editor. func isComputedAwardField(field string) bool { switch field { - case "dxcc", "cqz", "ituz", "prefix", "callsign", "state", "cont", "country", "grid": + // Purely derived from the callsign / cty.dat — never assigned by hand. + case "dxcc", "cqz", "ituz", "prefix", "callsign", "cont", "country", "grid": return true } + // NB: "state" and "cnty" are deliberately NOT computed. They are QSO fields + // the operator often sets by hand (a QRZ lookup rarely fills the JA + // prefecture or VE province), and they drive predefined-list awards + // (WAS / RAC / WAJA / JCC). So they must be pickable in the per-QSO editor. return false } @@ -2326,6 +2331,145 @@ func (a *App) HasBuiltinReferences(code string) bool { return ok } +// ── Award export / import ────────────────────────────────────────────── +// +// A self-contained JSON bundle of every award definition AND its reference +// list. This is the backup users need so a reinstall / PC change never loses +// the awards they built by hand. It is independent of the database file. + +// AwardBundle is the on-disk format for an award export. +type AwardBundle struct { + Version int `json:"version"` + ExportedAt string `json:"exported_at"` + Awards []AwardBundleEntry `json:"awards"` +} + +// AwardBundleEntry pairs one award definition with its full reference list. +type AwardBundleEntry struct { + Def award.Def `json:"def"` + References []awardref.Ref `json:"references"` +} + +// AwardImportResult summarises an award import for the UI. +type AwardImportResult struct { + Awards int `json:"awards"` // definitions added or updated + References int `json:"references"` // references imported across all awards +} + +// ExportAwards shows a Save dialog and writes every award definition plus its +// reference list to a JSON bundle. Returns the path written, or "" if the user +// cancelled. +func (a *App) ExportAwards() (string, error) { + if a.awardRefs == nil { + return "", fmt.Errorf("db not initialized") + } + defs := a.awardDefs() + bundle := AwardBundle{ + Version: 1, + ExportedAt: time.Now().UTC().Format(time.RFC3339), + Awards: make([]AwardBundleEntry, 0, len(defs)), + } + for _, d := range defs { + refs, err := a.awardRefs.List(a.ctx, d.Code) + if err != nil { + return "", fmt.Errorf("list references for %s: %w", d.Code, err) + } + bundle.Awards = append(bundle.Awards, AwardBundleEntry{Def: d, References: refs}) + } + data, err := json.MarshalIndent(bundle, "", " ") + if err != nil { + return "", err + } + path, err := wruntime.SaveFileDialog(a.ctx, wruntime.SaveDialogOptions{ + Title: "Export awards", + DefaultFilename: "OpsLog_awards_" + time.Now().UTC().Format("20060102_150405") + ".json", + Filters: []wruntime.FileFilter{ + {DisplayName: "Award bundle (*.json)", Pattern: "*.json"}, + {DisplayName: "All files (*.*)", Pattern: "*.*"}, + }, + }) + if err != nil || path == "" { + return "", err + } + if err := os.WriteFile(path, data, 0o644); err != nil { + return "", fmt.Errorf("write %s: %w", path, err) + } + return path, nil +} + +// ImportAwards shows an Open dialog, reads an award bundle and merges it: +// definitions are upserted by code, and any entry that carries references +// replaces that award's list. Returns counts; the user cancelling yields a +// zero result and no error. +func (a *App) ImportAwards() (AwardImportResult, error) { + var res AwardImportResult + if a.awardRefs == nil || a.settings == nil { + return res, fmt.Errorf("db not initialized") + } + path, err := wruntime.OpenFileDialog(a.ctx, wruntime.OpenDialogOptions{ + Title: "Import awards", + Filters: []wruntime.FileFilter{ + {DisplayName: "Award bundle (*.json)", Pattern: "*.json"}, + {DisplayName: "All files (*.*)", Pattern: "*.*"}, + }, + }) + if err != nil || path == "" { + return res, err + } + data, err := os.ReadFile(path) + if err != nil { + return res, fmt.Errorf("read %s: %w", path, err) + } + var bundle AwardBundle + if err := json.Unmarshal(data, &bundle); err != nil { + return res, fmt.Errorf("parse award bundle: %w", err) + } + if len(bundle.Awards) == 0 { + return res, fmt.Errorf("no awards in file") + } + + // Merge definitions: upsert by code (imported wins), keep the rest. + defs := a.awardDefs() + byCode := map[string]int{} + for i, d := range defs { + byCode[strings.ToUpper(d.Code)] = i + } + for _, e := range bundle.Awards { + code := strings.ToUpper(strings.TrimSpace(e.Def.Code)) + if code == "" { + continue + } + if i, ok := byCode[code]; ok { + defs[i] = e.Def + } else { + byCode[code] = len(defs) + defs = append(defs, e.Def) + } + res.Awards++ + } + if migrated, changed := award.Migrate(defs); changed { + defs = migrated + } + b, _ := json.Marshal(defs) + if err := a.settings.Set(a.ctx, keyAwardDefs, string(b)); err != nil { + return res, fmt.Errorf("save award defs: %w", err) + } + + // Replace reference lists for entries that carry them (skip empty so we + // don't wipe built-in-seeded lists for a def exported without refs). + for _, e := range bundle.Awards { + if len(e.References) == 0 { + continue + } + n, err := a.ReplaceAwardReferences(e.Def.Code, e.References) + if err != nil { + return res, fmt.Errorf("import references for %s: %w", e.Def.Code, err) + } + res.References += n + } + return res, nil +} + // builtinRefsVersion is bumped whenever the built-in reference data changes // (e.g. the West Malaysia 155→299 fix) so existing installs re-seed the // derived lists. Bump this after correcting BuiltinRefs / the DXCC name table. @@ -2683,6 +2827,14 @@ func (a *App) SaveADIFFile() (string, error) { }) } +// ADIFFields returns the complete ADIF 3.1.7 QSO-field dictionary, for the +// generic "ADIF fields" editor (so any standard field can be viewed/edited) +// and for the export-mode help text. +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() } + // 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); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7cd75e1..59fbda1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2861,7 +2861,7 @@ export default function App() { Export ADIF - Choose which fields to include in the export. + Choose which fields to include. OpsLog writes ADIF 3.1.7.
@@ -2870,10 +2870,10 @@ export default function App() { onClick={() => runExport(false)} className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors" > -
Standard ADIF
+
Standard ADIF only
- Only standard ADIF-defined fields — portable to other loggers (Log4OM, N1MM, LoTW…). - Application-specific APP_* tags are stripped. + Only fields defined in the ADIF 3.1.7 spec — portable to other loggers (Log4OM, N1MM, LoTW…). + Application-specific APP_* and any non-standard / vendor tags are stripped.
diff --git a/frontend/src/components/AdifExtrasEditor.tsx b/frontend/src/components/AdifExtrasEditor.tsx new file mode 100644 index 0000000..fd9c03c --- /dev/null +++ b/frontend/src/components/AdifExtrasEditor.tsx @@ -0,0 +1,132 @@ +import { useEffect, useMemo, useState } from 'react'; +import { X, Plus } from 'lucide-react'; +import { ADIFFields } from '../../wailsjs/go/main/App'; +import { Input } from '@/components/ui/input'; +import { Combobox } from '@/components/ui/combobox'; +import { Button } from '@/components/ui/button'; + +type FieldDef = { + name: string; kind: string; category: string; + promoted: boolean; deprecated: boolean; intl: boolean; +}; + +interface Props { + // The QSO's extras map (uppercase ADIF tag → raw value). + value: Record | undefined; + onChange: (next: Record | undefined) => void; +} + +// AdifExtrasEditor — a dictionary-driven editor for every ADIF field that is +// NOT promoted to a first-class column. Backed by the QSO's extras map, so any +// ADIF 3.1.7 field (plus custom/vendor tags) can be viewed and edited. This is +// what makes "100% of ADIF fields available" true without 160 DB columns. +export function AdifExtrasEditor({ value, onChange }: Props) { + const [dict, setDict] = useState([]); + const [showDeprecated, setShowDeprecated] = useState(false); + + useEffect(() => { + ADIFFields().then((f) => setDict((f ?? []) as any)).catch(() => {}); + }, []); + + // Entries currently set, sorted for stable display. + const entries = useMemo( + () => Object.entries(value ?? {}).sort((a, b) => a[0].localeCompare(b[0])), + [value], + ); + + // Addable fields: standard non-promoted ADIF fields not already present. + // Deprecated (import-only) fields are hidden unless the toggle is on. + const addable = useMemo(() => { + const have = new Set(Object.keys(value ?? {})); + return dict + .filter((f) => !f.promoted && !have.has(f.name) && (showDeprecated || !f.deprecated)) + .map((f) => f.name); + }, [dict, value, showDeprecated]); + + const meta = useMemo(() => { + const m: Record = {}; + for (const f of dict) m[f.name] = f; + return m; + }, [dict]); + + function setKV(key: string, val: string) { + const next = { ...(value ?? {}) }; + next[key] = val; + onChange(next); + } + function remove(key: string) { + const next = { ...(value ?? {}) }; + delete next[key]; + onChange(Object.keys(next).length ? next : undefined); + } + function addField(name: string) { + const key = name.trim().toUpperCase(); + if (!key || (value && key in value)) return; + setKV(key, ''); + } + + return ( +
+

+ Every ADIF 3.1.7 field not shown in the other tabs. Pick a field to add it, + or type a custom/vendor tag (e.g. APP_*). + Stored losslessly and exported in the full ADIF mode. +

+ + {/* Add a field */} +
+
+ +
+ +
+ + {/* Current entries */} + {entries.length === 0 ? ( +
+ No extra ADIF fields. Use the picker above to add one. +
+ ) : ( +
+ {entries.map(([k, v]) => { + const def = meta[k]; + return ( +
+
+ {k} + {def && ( + + {def.category}{def.deprecated ? ' · deprecated' : ''}{def.intl ? ' · intl' : ''} + {!def && ''} + + )} + {!def && non-standard} +
+ setKV(k, e.target.value)} + /> + +
+ ); + })} +
+ )} +
+ ); +} diff --git a/frontend/src/components/AwardEditor.tsx b/frontend/src/components/AwardEditor.tsx index 14444aa..23e1a47 100644 --- a/frontend/src/components/AwardEditor.tsx +++ b/frontend/src/components/AwardEditor.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import { Plus, Trash2, RotateCcw, Save, Download, Loader2, Search } from 'lucide-react'; +import { Plus, Trash2, RotateCcw, Save, Download, Upload, Loader2, Search } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -17,6 +17,7 @@ import { ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset, ListCountries, DXCCForCountry, DXCCName, PopulateBuiltinReferences, HasBuiltinReferences, + ExportAwards, ImportAwards, } from '../../wailsjs/go/main/App'; // Above this many references the editor stops loading the whole list and @@ -182,6 +183,28 @@ export function AwardEditor({ open, onClose, onSaved }: Props) { async function reset() { try { setDefs((await ResetAwardDefs()) as any); setSel(0); } catch (e: any) { setErr(String(e?.message ?? e)); } } + // Export every award definition + reference list to a JSON bundle (backup + // that survives a reinstall / PC change, independent of the database). + async function exportAwards() { + setErr(''); + try { + const p = await ExportAwards(); + if (p) setErr(`Awards exported to:\n${p}`); + } catch (e: any) { setErr(String(e?.message ?? e)); } + } + // Import an award bundle: definitions are upserted by code, reference lists + // replaced. Reloads the editor afterwards. + async function importAwards() { + setErr(''); + try { + const r: any = await ImportAwards(); + if (!r || (!r.awards && !r.references)) return; // cancelled + const [d] = await Promise.all([GetAwardDefs(), loadMeta()]); + setDefs((d ?? []) as any); setSel(0); + onSaved(); + setErr(`Imported ${r.awards} award(s) and ${r.references} reference(s).`); + } catch (e: any) { setErr(String(e?.message ?? e)); } + } async function updateList(code: string) { setUpdating(code); setErr(''); try { await UpdateAwardReferenceList(code); await loadMeta(); } @@ -228,7 +251,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) { {/* Right: tabbed editor for selected award */}
- {err &&
{err}
} + {err &&
{err}
} {!cur ? (
Select or create an award.
) : ( @@ -338,6 +361,12 @@ export function AwardEditor({ open, onClose, onSaved }: Props) { + +
diff --git a/frontend/src/components/AwardRefSelector.tsx b/frontend/src/components/AwardRefSelector.tsx index 6fec24a..1daf926 100644 --- a/frontend/src/components/AwardRefSelector.tsx +++ b/frontend/src/components/AwardRefSelector.tsx @@ -11,7 +11,11 @@ type Meta = { code: string; count: number; can_update: boolean }; // Fields auto-derived from structured QSO data — their awards (DXCC/WAZ/WAS/…) // are computed, never manually picked, so they don't belong in this picker. -const COMPUTED_FIELDS = new Set(['dxcc', 'cqz', 'ituz', 'prefix', 'callsign', 'state', 'cont', 'country', 'grid']); +// Fields purely derived from the callsign / cty.dat — their awards are computed, +// never picked. NB: 'state' and 'cnty' are NOT here — they're operator-settable +// QSO fields driving predefined-list awards (WAS/RAC/WAJA/JCC), so they ARE +// pickable (a lookup rarely fills the JA prefecture or VE province). +const COMPUTED_FIELDS = new Set(['dxcc', 'cqz', 'ituz', 'prefix', 'callsign', 'cont', 'country', 'grid']); // If DXCC-filtered auto-results exceed this, require the user to type instead. const AUTO_SHOW_MAX = 100; @@ -72,15 +76,26 @@ export function AwardRefSelector({ dxcc, value, onChange }: Props) { if (!awards.find((a) => a.code === awardCode)) setAwardCode(awards[0].code); }, [awards, awardCode]); - // Auto-load DXCC-filtered refs on award/dxcc change with empty query. - // Fetches AUTO_SHOW_MAX+1 so we can distinguish "all results shown" from "too many". + // Is the selected award a giant dynamic list (POTA/SOTA/IOTA)? Those carry a + // per-reference DXCC so we filter by entity; predefined lists (WAS/RAC/WAJA) + // are small and their refs may lack a per-ref DXCC, so we load them whole. + const isDynamic = useMemo( + () => !!defs.find((d) => d.code === awardCode)?.dynamic, + [defs, awardCode], + ); + // For dynamic lists, restrict to the contacted entity; otherwise load all. + const refDxcc = isDynamic ? (dxcc ?? 0) : 0; + + // Auto-load refs on award/dxcc change with empty query. Fetches AUTO_SHOW_MAX+1 + // so we can distinguish "all results shown" from "too many to list". useEffect(() => { setAutoResults([]); - if (!dxcc) return; - SearchAwardReferences(awardCode, '', dxcc, AUTO_SHOW_MAX + 1) + // Dynamic lists need an entity to scope to; predefined lists load regardless. + if (isDynamic && !dxcc) return; + SearchAwardReferences(awardCode, '', refDxcc, AUTO_SHOW_MAX + 1) .then((r) => setAutoResults((r ?? []) as any)) .catch(() => {}); - }, [awardCode, dxcc]); + }, [awardCode, dxcc, isDynamic, refDxcc]); // Typed search (2+ chars). useEffect(() => { @@ -88,13 +103,13 @@ export function AwardRefSelector({ dxcc, value, onChange }: Props) { const t = window.setTimeout(async () => { setBusy(true); try { - const r = await SearchAwardReferences(awardCode, q, dxcc ?? 0, 50); + const r = await SearchAwardReferences(awardCode, q, refDxcc, 50); setSearchResults((r ?? []) as any); } catch { setSearchResults([]); } finally { setBusy(false); } }, 200); return () => window.clearTimeout(t); - }, [awardCode, q, dxcc]); + }, [awardCode, q, refDxcc]); const tooManyAuto = autoResults.length > AUTO_SHOW_MAX; // When q is empty/short: show auto-results (if ≤ AUTO_SHOW_MAX); when typing: show search results. @@ -212,22 +227,19 @@ export function AwardRefSelector({ dxcc, value, onChange }: Props) { Searching…
)} - {/* No callsign yet */} - {!busy && !dxcc && q.length < 2 && ( -
- Enter a callsign, or type to search. -
- )} - {/* DXCC known but too many auto-results → require typed search */} - {!busy && !!dxcc && q.length < 2 && tooManyAuto && ( + {/* Too many auto-results → require typed search */} + {!busy && q.length < 2 && tooManyAuto && (
Type 2+ chars to search
)} - {/* DXCC known, auto-results loaded, none found */} - {!busy && !!dxcc && q.length < 2 && !tooManyAuto && autoResults.length === 0 && ( + {/* Empty short-query state: prompt for a callsign (dynamic lists) or + note the list is empty (predefined awards with no references). */} + {!busy && q.length < 2 && !tooManyAuto && autoResults.length === 0 && (
- No references for this entity. + {isDynamic && !dxcc + ? 'Enter a callsign, or type to search.' + : 'No references for this entity.'}
)} {/* Typed search, no results */} diff --git a/frontend/src/components/AwardsPanel.tsx b/frontend/src/components/AwardsPanel.tsx index 7ba8384..fbeb9b6 100644 --- a/frontend/src/components/AwardsPanel.tsx +++ b/frontend/src/components/AwardsPanel.tsx @@ -120,7 +120,7 @@ export function AwardsPanel() { const filteredRefs = useMemo(() => { if (!current) return []; const q = refSearch.trim().toUpperCase(); - return current.refs.filter((r) => { + return (current.refs ?? []).filter((r) => { if (refFilter === 'worked' && !r.worked) return false; if (refFilter === 'notworked' && r.worked) return false; if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false; @@ -206,11 +206,11 @@ export function AwardsPanel() {
{/* Band breakdown */} - {current.bands.length > 0 && ( + {(current.bands ?? []).length > 0 && (
By band (confirmed / worked)
- {current.bands.map((b) => ( + {(current.bands ?? []).map((b) => (
{b.band}{' '} {b.confirmed} diff --git a/frontend/src/components/QSOEditModal.tsx b/frontend/src/components/QSOEditModal.tsx index f27a5bb..8c788f8 100644 --- a/frontend/src/components/QSOEditModal.tsx +++ b/frontend/src/components/QSOEditModal.tsx @@ -2,7 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Trash2, Search, Loader2 } from 'lucide-react'; import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs } from '../../wailsjs/go/main/App'; import { AwardRefSelector } from '@/components/AwardRefSelector'; -import { applyAwardRefs, buildAwardRefs } from '@/lib/awardRefs'; +import { AdifExtrasEditor } from '@/components/AdifExtrasEditor'; +import { applyAwardRefs } from '@/lib/awardRefs'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, } from '@/components/ui/dialog'; @@ -100,23 +101,6 @@ function parseLocalISO(s: string): string | null { if (!m) return null; return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:00.000Z`; } -function stringifyExtras(e?: Record): string { - if (!e) return ''; - return Object.entries(e).map(([k, v]) => `${k} = ${v}`).join('\n'); -} -function parseExtras(t: string): Record | undefined { - const out: Record = {}; - for (const raw of t.split('\n')) { - const line = raw.trim(); - if (!line) continue; - const idx = line.indexOf('='); - if (idx < 0) continue; - const k = line.slice(0, idx).trim().toUpperCase(); - const v = line.slice(idx + 1).trim(); - if (k && v) out[k] = v; - } - return Object.keys(out).length ? out : undefined; -} function numOrUndef(v: any): number | undefined { if (v === '' || v === null || v === undefined) return undefined; const n = typeof v === 'number' ? v : parseFloat(String(v)); @@ -163,7 +147,6 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }: const [dateOff, setDateOff] = useState(toLocalISO(draft.qso_date_off)); const [endEnabled, setEndEnabled] = useState(!!draft.qso_date_off); const [confSel, setConfSel] = useState('QSL'); // selected confirmation channel - const [extrasText, setExtrasText] = useState(stringifyExtras(draft.extras)); const [localErr, setLocalErr] = useState(''); const [saving, setSaving] = useState(false); const [looking, setLooking] = useState(false); @@ -183,15 +166,17 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }: const fieldOf: Record = {}; for (const d of list) fieldOf[String(d.code).toUpperCase()] = String(d.field || '').toLowerCase(); awardFieldRef.current = fieldOf; - // Which awards are reference-list (manual) ones? Ask the backend, which - // also tells us pickable vs computed for the current QSO. + // Seed the editable manual refs from the backend, which already matched + // each reference against its award's own list. Seeding from the raw QSO + // field instead would wrongly seed every state-award (WAS/RAC/WAJA) from + // the same `state` value — e.g. a US "CA" would seed RAC@CA too. try { const all = (await ComputeQSOAwardRefs(draft as any)) ?? []; - const pickableCodes = new Set(all.filter((r: any) => r.pickable).map((r: any) => String(r.code).toUpperCase())); - const pickable = list - .filter((d) => pickableCodes.has(String(d.code).toUpperCase())) - .map((d) => ({ code: String(d.code), field: String(d.field || '').toLowerCase() })); - setAwardRefs(buildAwardRefs(draft, pickable)); + const seed = all + .filter((r: any) => r.pickable) + .map((r: any) => `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`) + .join(';'); + setAwardRefs(seed); } catch { /* leave manual refs empty on failure */ } }) .catch(() => {}); @@ -292,7 +277,12 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }: my_lat: numOrUndef(draft.my_lat), my_lon: numOrUndef(draft.my_lon), ant_az: numOrUndef(draft.ant_az), ant_el: numOrUndef(draft.ant_el), tx_pwr: numOrUndef(draft.tx_pwr), - extras: parseExtras(extrasText), + distance: numOrUndef(draft.distance), + rx_pwr: numOrUndef(draft.rx_pwr), + a_index: numOrUndef(draft.a_index), + k_index: numOrUndef(draft.k_index), + sfi: numOrUndef(draft.sfi), + extras: draft.extras && Object.keys(draft.extras).length ? draft.extras : undefined, }; // The Award Refs tab is authoritative for the reference-list awards. Reset // the dedicated columns, then route the picked refs back onto the payload @@ -334,8 +324,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }: Contest Sat / Prop My Station + More ADIF - Extras + ADIF fields {extrasCount > 0 && ( {extrasCount} )} @@ -602,12 +593,74 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
- -

- ADIF fields not promoted to first-class columns. One per line:{' '} - FIELD_NAME = value -

-