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.
runExport(true)}
className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
>
- Full (OpsLog round-trip)
+ All fields (OpsLog round-trip)
- Every field including OpsLog/application-specific APP_* tags —
- for a lossless backup you'll re-import into OpsLog.
+ Every field including application-specific APP_* and vendor tags —
+ a lossless backup you'll re-import into OpsLog.
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 */}
+
+
+
+
+
+ setShowDeprecated(e.target.checked)} />
+ Show deprecated
+
+
+
+ {/* 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)}
+ />
+
remove(k)} title="Remove field"
+ >
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
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) {
Reset to defaults
+
+ Export…
+
+
+ Import…
+
Cancel
Save
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
-
-
diff --git a/frontend/src/lib/awardRefs.ts b/frontend/src/lib/awardRefs.ts
index 5a30159..2f1b715 100644
--- a/frontend/src/lib/awardRefs.ts
+++ b/frontend/src/lib/awardRefs.ts
@@ -44,6 +44,10 @@ export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<
case 'iota': payload.iota = ref; break;
case 'sota_ref': payload.sota_ref = ref; break;
case 'pota_ref': payload.pota_ref = ref; break;
+ // Predefined-list awards on a QSO field (WAS/RAC/WAJA on state, JCC on
+ // county): picking a reference writes it straight into that column.
+ case 'state': payload.state = ref; break;
+ case 'cnty': payload.cnty = ref; break;
case 'wwff':
extras['WWFF_REF'] = ref;
extras['SIG'] = 'WWFF';
@@ -73,6 +77,8 @@ export function awardRefValue(qso: any, code: string, field: string): string {
case 'iota': return (qso.iota ?? '').toUpperCase();
case 'sota_ref': return (qso.sota_ref ?? '').toUpperCase();
case 'pota_ref': return (qso.pota_ref ?? '').toUpperCase();
+ case 'state': return (qso.state ?? '').toUpperCase();
+ case 'cnty': return (qso.cnty ?? '').toUpperCase();
case 'wwff': {
const ex = qso.extras ?? {};
if (ex['WWFF_REF']) return String(ex['WWFF_REF']).toUpperCase();
diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts
index 4294430..7b50187 100644
--- a/frontend/wailsjs/go/main/App.d.ts
+++ b/frontend/wailsjs/go/main/App.d.ts
@@ -1,9 +1,9 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
+import {adif} from '../models';
import {qso} from '../models';
import {main} from '../models';
import {profile} from '../models';
-import {adif} from '../models';
import {award} from '../models';
import {awardref} from '../models';
import {cat} from '../models';
@@ -15,6 +15,10 @@ import {operating} from '../models';
import {udp} from '../models';
import {lookup} from '../models';
+export function ADIFFields():Promise
>;
+
+export function ADIFVersion():Promise;
+
export function ActivateProfile(arg1:number):Promise;
export function AddQSO(arg1:qso.QSO):Promise;
@@ -77,8 +81,6 @@ export function DeleteQSO(arg1:number):Promise;
export function DeleteUDPIntegration(arg1:number):Promise;
-export function DisablePortableMode():Promise;
-
export function DisconnectAllClusters():Promise;
export function DisconnectClusterServer(arg1:number):Promise;
@@ -89,14 +91,14 @@ export function DownloadConfirmations(arg1:string,arg2:boolean):Promise;
export function DuplicateProfile(arg1:number,arg2:string):Promise;
-export function EnablePortableMode():Promise;
-
export function ExportADIF(arg1:string,arg2:boolean):Promise;
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise;
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array):Promise;
+export function ExportAwards():Promise;
+
export function FilterFields():Promise>;
export function FindQSOsForUpload(arg1:string,arg2:string):Promise>;
@@ -175,7 +177,7 @@ export function ImportADIF(arg1:string,arg2:string,arg3:boolean):Promise;
-export function IsPortableMode():Promise;
+export function ImportAwards():Promise;
export function ListAudioInputDevices():Promise>;
diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js
index ceca01f..15ad0bc 100644
--- a/frontend/wailsjs/go/main/App.js
+++ b/frontend/wailsjs/go/main/App.js
@@ -2,6 +2,14 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
+export function ADIFFields() {
+ return window['go']['main']['App']['ADIFFields']();
+}
+
+export function ADIFVersion() {
+ return window['go']['main']['App']['ADIFVersion']();
+}
+
export function ActivateProfile(arg1) {
return window['go']['main']['App']['ActivateProfile'](arg1);
}
@@ -126,10 +134,6 @@ export function DeleteUDPIntegration(arg1) {
return window['go']['main']['App']['DeleteUDPIntegration'](arg1);
}
-export function DisablePortableMode() {
- return window['go']['main']['App']['DisablePortableMode']();
-}
-
export function DisconnectAllClusters() {
return window['go']['main']['App']['DisconnectAllClusters']();
}
@@ -150,10 +154,6 @@ export function DuplicateProfile(arg1, arg2) {
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
}
-export function EnablePortableMode() {
- return window['go']['main']['App']['EnablePortableMode']();
-}
-
export function ExportADIF(arg1, arg2) {
return window['go']['main']['App']['ExportADIF'](arg1, arg2);
}
@@ -166,6 +166,10 @@ export function ExportADIFSelected(arg1, arg2, arg3) {
return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3);
}
+export function ExportAwards() {
+ return window['go']['main']['App']['ExportAwards']();
+}
+
export function FilterFields() {
return window['go']['main']['App']['FilterFields']();
}
@@ -322,8 +326,8 @@ export function ImportAwardReferencesText(arg1, arg2) {
return window['go']['main']['App']['ImportAwardReferencesText'](arg1, arg2);
}
-export function IsPortableMode() {
- return window['go']['main']['App']['IsPortableMode']();
+export function ImportAwards() {
+ return window['go']['main']['App']['ImportAwards']();
}
export function ListAudioInputDevices() {
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts
index 4608619..43a97df 100644
--- a/frontend/wailsjs/go/models.ts
+++ b/frontend/wailsjs/go/models.ts
@@ -16,6 +16,28 @@ export namespace adif {
this.size_kb = source["size_kb"];
}
}
+ export class FieldDef {
+ name: string;
+ kind: string;
+ category: string;
+ promoted: boolean;
+ deprecated: boolean;
+ intl: boolean;
+
+ static createFrom(source: any = {}) {
+ return new FieldDef(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.name = source["name"];
+ this.kind = source["kind"];
+ this.category = source["category"];
+ this.promoted = source["promoted"];
+ this.deprecated = source["deprecated"];
+ this.intl = source["intl"];
+ }
+ }
export class ImportResult {
total: number;
imported: number;
@@ -660,6 +682,20 @@ export namespace main {
this.mic_gain = source["mic_gain"];
}
}
+ export class AwardImportResult {
+ awards: number;
+ references: number;
+
+ static createFrom(source: any = {}) {
+ return new AwardImportResult(source);
+ }
+
+ constructor(source: any = {}) {
+ if ('string' === typeof source) source = JSON.parse(source);
+ this.awards = source["awards"];
+ this.references = source["references"];
+ }
+ }
export class AwardRefMeta {
code: string;
count: number;
@@ -1184,6 +1220,7 @@ export namespace main {
ok: boolean;
err: string;
db_path: string;
+ migrated_from_app_data: boolean;
static createFrom(source: any = {}) {
return new StartupStatus(source);
@@ -1194,6 +1231,7 @@ export namespace main {
this.ok = source["ok"];
this.err = source["err"];
this.db_path = source["db_path"];
+ this.migrated_from_app_data = source["migrated_from_app_data"];
}
}
export class StationInfoComputed {
@@ -1696,6 +1734,36 @@ export namespace qso {
tx_pwr?: number;
comment?: string;
notes?: string;
+ sig?: string;
+ sig_info?: string;
+ my_sig?: string;
+ my_sig_info?: string;
+ wwff_ref?: string;
+ my_wwff_ref?: string;
+ distance?: number;
+ rx_pwr?: number;
+ a_index?: number;
+ k_index?: number;
+ sfi?: number;
+ skcc?: string;
+ fists?: string;
+ ten_ten?: string;
+ contacted_op?: string;
+ eq_call?: string;
+ pfx?: string;
+ my_name?: string;
+ class?: string;
+ darc_dok?: string;
+ my_darc_dok?: string;
+ region?: string;
+ silent_key?: string;
+ swl?: string;
+ qso_complete?: string;
+ qso_random?: string;
+ credit_granted?: string;
+ credit_submitted?: string;
+ my_arrl_sect?: string;
+ my_vucc_grids?: string;
extras?: Record;
// Go type: time
created_at: any;
@@ -1803,6 +1871,36 @@ export namespace qso {
this.tx_pwr = source["tx_pwr"];
this.comment = source["comment"];
this.notes = source["notes"];
+ this.sig = source["sig"];
+ this.sig_info = source["sig_info"];
+ this.my_sig = source["my_sig"];
+ this.my_sig_info = source["my_sig_info"];
+ this.wwff_ref = source["wwff_ref"];
+ this.my_wwff_ref = source["my_wwff_ref"];
+ this.distance = source["distance"];
+ this.rx_pwr = source["rx_pwr"];
+ this.a_index = source["a_index"];
+ this.k_index = source["k_index"];
+ this.sfi = source["sfi"];
+ this.skcc = source["skcc"];
+ this.fists = source["fists"];
+ this.ten_ten = source["ten_ten"];
+ this.contacted_op = source["contacted_op"];
+ this.eq_call = source["eq_call"];
+ this.pfx = source["pfx"];
+ this.my_name = source["my_name"];
+ this.class = source["class"];
+ this.darc_dok = source["darc_dok"];
+ this.my_darc_dok = source["my_darc_dok"];
+ this.region = source["region"];
+ this.silent_key = source["silent_key"];
+ this.swl = source["swl"];
+ this.qso_complete = source["qso_complete"];
+ this.qso_random = source["qso_random"];
+ this.credit_granted = source["credit_granted"];
+ this.credit_submitted = source["credit_submitted"];
+ this.my_arrl_sect = source["my_arrl_sect"];
+ this.my_vucc_grids = source["my_vucc_grids"];
this.extras = source["extras"];
this.created_at = this.convertValues(source["created_at"], null);
this.updated_at = this.convertValues(source["updated_at"], null);
diff --git a/internal/adif/export.go b/internal/adif/export.go
index 97b16bc..d38e042 100644
--- a/internal/adif/export.go
+++ b/internal/adif/export.go
@@ -92,7 +92,7 @@ func (e *Exporter) writeDoc(ctx context.Context, w io.Writer, iter iterator) (in
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, "3.1.0 %s", len(app), app)
+ fmt.Fprintf(bw, "%s %s", len(adifVersion), adifVersion, len(app), app)
if ver != "" {
fmt.Fprintf(bw, " %s", len(ver), ver)
}
@@ -248,13 +248,46 @@ func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool) {
writeField(bw, "COMMENT", q.Comment)
writeField(bw, "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)
+
// --- Extras (unpromoted ADIF fields preserved verbatim) ---
- // In standard mode we drop application-specific tags (APP_*) so the file
- // stays portable to other loggers; in full mode they're kept for a
- // lossless OpsLog round-trip.
+ // 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 !includeApp && strings.HasPrefix(tag, "APP_") {
+ if !includeApp && (strings.HasPrefix(tag, "APP_") || !IsStandardField(tag)) {
continue
}
writeField(bw, tag, v)
diff --git a/internal/adif/fields.go b/internal/adif/fields.go
new file mode 100644
index 0000000..625bc52
--- /dev/null
+++ b/internal/adif/fields.go
@@ -0,0 +1,272 @@
+package adif
+
+import "strings"
+
+// This file embeds the complete ADIF 3.1.7 QSO-field dictionary. It is the
+// single source of truth for:
+// - the generic "ADIF fields" editor in the UI (any field becomes editable),
+// - the "standard ADIF" export mode (only spec fields are emitted),
+// - import diagnostics (a tag absent here is non-standard / vendor-specific).
+//
+// Promoted fields (those with dedicated QSO columns) are flagged Promoted=true
+// so the UI can hide them from the generic editor — they already have proper
+// inputs in the main tabs.
+
+// FieldKind is a coarse input type used by the generic editor to pick a widget.
+type FieldKind string
+
+const (
+ KindText FieldKind = "text" // String / IntlString / MultilineString
+ KindNumber FieldKind = "number" // Number / PositiveInteger
+ KindDate FieldKind = "date" // ADIF Date (YYYYMMDD)
+ KindTime FieldKind = "time" // ADIF Time (HHMMSS / HHMM)
+ KindBool FieldKind = "boolean" // Boolean (Y/N)
+ KindEnum FieldKind = "enum" // Enumeration
+ KindLoc FieldKind = "location" // Location (e.g. "N048 09.000")
+)
+
+// FieldDef describes one ADIF QSO field.
+type FieldDef struct {
+ Name string `json:"name"` // canonical uppercase ADIF tag
+ Kind FieldKind `json:"kind"` // editor widget hint
+ Category string `json:"category"` // grouping for the UI
+ Promoted bool `json:"promoted"` // has a dedicated QSO column
+ Deprecated bool `json:"deprecated"` // import-only per the spec
+ Intl bool `json:"intl"` // *_INTL UTF-8 variant
+}
+
+// adifVersion is the ADIF spec version OpsLog targets for import/export.
+const adifVersion = "3.1.7"
+
+// ADIFVersion returns the ADIF spec version OpsLog conforms to.
+func ADIFVersion() string { return adifVersion }
+
+// Fields is the full ADIF 3.1.7 QSO-field set. Order is alphabetical within
+// each category; categories order is roughly "most-used first" for the UI.
+var Fields = []FieldDef{
+ // ── Core / contact ──────────────────────────────────────────────
+ {Name: "CALL", Kind: KindText, Category: "Core", Promoted: true},
+ {Name: "QSO_DATE", Kind: KindDate, Category: "Core", Promoted: true},
+ {Name: "TIME_ON", Kind: KindTime, Category: "Core", Promoted: true},
+ {Name: "QSO_DATE_OFF", Kind: KindDate, Category: "Core", Promoted: true},
+ {Name: "TIME_OFF", Kind: KindTime, Category: "Core", Promoted: true},
+ {Name: "BAND", Kind: KindEnum, Category: "Core", Promoted: true},
+ {Name: "BAND_RX", Kind: KindEnum, Category: "Core", Promoted: true},
+ {Name: "MODE", Kind: KindEnum, Category: "Core", Promoted: true},
+ {Name: "SUBMODE", Kind: KindEnum, Category: "Core", Promoted: true},
+ {Name: "FREQ", Kind: KindNumber, Category: "Core", Promoted: true},
+ {Name: "FREQ_RX", Kind: KindNumber, Category: "Core", Promoted: true},
+ {Name: "RST_SENT", Kind: KindText, Category: "Core", Promoted: true},
+ {Name: "RST_RCVD", Kind: KindText, Category: "Core", Promoted: true},
+ {Name: "QSO_COMPLETE", Kind: KindEnum, Category: "Core", Promoted: true},
+ {Name: "QSO_RANDOM", Kind: KindBool, Category: "Core", Promoted: true},
+ {Name: "SWL", Kind: KindBool, Category: "Core", Promoted: true},
+
+ // ── Contacted station ───────────────────────────────────────────
+ {Name: "NAME", Kind: KindText, Category: "Contacted", Promoted: true},
+ {Name: "NAME_INTL", Kind: KindText, Category: "Contacted", Intl: true},
+ {Name: "QTH", Kind: KindText, Category: "Contacted", Promoted: true},
+ {Name: "QTH_INTL", Kind: KindText, Category: "Contacted", Intl: true},
+ {Name: "ADDRESS", Kind: KindText, Category: "Contacted", Promoted: true},
+ {Name: "ADDRESS_INTL", Kind: KindText, Category: "Contacted", Intl: true},
+ {Name: "EMAIL", Kind: KindText, Category: "Contacted", Promoted: true},
+ {Name: "WEB", Kind: KindText, Category: "Contacted", Promoted: true},
+ {Name: "GRIDSQUARE", Kind: KindText, Category: "Contacted", Promoted: true},
+ {Name: "GRIDSQUARE_EXT", Kind: KindText, Category: "Contacted", Promoted: true},
+ {Name: "VUCC_GRIDS", Kind: KindText, Category: "Contacted", Promoted: true},
+ {Name: "COUNTRY", Kind: KindText, Category: "Contacted", Promoted: true},
+ {Name: "COUNTRY_INTL", Kind: KindText, Category: "Contacted", Intl: true},
+ {Name: "STATE", Kind: KindEnum, Category: "Contacted", Promoted: true},
+ {Name: "CNTY", Kind: KindEnum, Category: "Contacted", Promoted: true},
+ {Name: "CNTY_ALT", Kind: KindEnum, Category: "Contacted"},
+ {Name: "DXCC", Kind: KindEnum, Category: "Contacted", Promoted: true},
+ {Name: "CONT", Kind: KindEnum, Category: "Contacted", Promoted: true},
+ {Name: "CQZ", Kind: KindNumber, Category: "Contacted", Promoted: true},
+ {Name: "ITUZ", Kind: KindNumber, Category: "Contacted", Promoted: true},
+ {Name: "IOTA", Kind: KindText, Category: "Contacted", Promoted: true},
+ {Name: "IOTA_ISLAND_ID", Kind: KindText, Category: "Contacted"},
+ {Name: "REGION", Kind: KindEnum, Category: "Contacted", Promoted: true},
+ {Name: "PFX", Kind: KindText, Category: "Contacted", Promoted: true},
+ {Name: "AGE", Kind: KindNumber, Category: "Contacted", Promoted: true},
+ {Name: "LAT", Kind: KindLoc, Category: "Contacted", Promoted: true},
+ {Name: "LON", Kind: KindLoc, Category: "Contacted", Promoted: true},
+ {Name: "ALTITUDE", Kind: KindNumber, Category: "Contacted"},
+ {Name: "DISTANCE", Kind: KindNumber, Category: "Contacted", Promoted: true},
+ {Name: "RIG", Kind: KindText, Category: "Contacted", Promoted: true},
+ {Name: "RIG_INTL", Kind: KindText, Category: "Contacted", Intl: true},
+ {Name: "ANT", Kind: KindText, Category: "Contacted", Promoted: true},
+ {Name: "CONTACTED_OP", Kind: KindText, Category: "Contacted", Promoted: true},
+ {Name: "EQ_CALL", Kind: KindText, Category: "Contacted", Promoted: true},
+ {Name: "GUEST_OP", Kind: KindText, Category: "Contacted", Deprecated: true},
+ {Name: "OWNER_CALLSIGN", Kind: KindText, Category: "Contacted"},
+ {Name: "SILENT_KEY", Kind: KindBool, Category: "Contacted", Promoted: true},
+ {Name: "USACA_COUNTIES", Kind: KindText, Category: "Contacted"},
+
+ // ── Special activity (POTA/SOTA/WWFF/SIG) ───────────────────────
+ {Name: "SIG", Kind: KindText, Category: "Activity", Promoted: true},
+ {Name: "SIG_INFO", Kind: KindText, Category: "Activity", Promoted: true},
+ {Name: "SIG_INTL", Kind: KindText, Category: "Activity", Intl: true},
+ {Name: "SIG_INFO_INTL", Kind: KindText, Category: "Activity", Intl: true},
+ {Name: "POTA_REF", Kind: KindText, Category: "Activity", Promoted: true},
+ {Name: "SOTA_REF", Kind: KindText, Category: "Activity", Promoted: true},
+ {Name: "WWFF_REF", Kind: KindText, Category: "Activity", Promoted: true},
+
+ // ── Power / propagation / space wx ──────────────────────────────
+ {Name: "TX_PWR", Kind: KindNumber, Category: "Propagation", Promoted: true},
+ {Name: "RX_PWR", Kind: KindNumber, Category: "Propagation", Promoted: true},
+ {Name: "A_INDEX", Kind: KindNumber, Category: "Propagation", Promoted: true},
+ {Name: "K_INDEX", Kind: KindNumber, Category: "Propagation", Promoted: true},
+ {Name: "SFI", Kind: KindNumber, Category: "Propagation", Promoted: true},
+ {Name: "PROP_MODE", Kind: KindEnum, Category: "Propagation", Promoted: true},
+ {Name: "SAT_NAME", Kind: KindText, Category: "Propagation", Promoted: true},
+ {Name: "SAT_MODE", Kind: KindText, Category: "Propagation", Promoted: true},
+ {Name: "ANT_AZ", Kind: KindNumber, Category: "Propagation", Promoted: true},
+ {Name: "ANT_EL", Kind: KindNumber, Category: "Propagation", Promoted: true},
+ {Name: "ANT_PATH", Kind: KindEnum, Category: "Propagation", Promoted: true},
+ {Name: "FORCE_INIT", Kind: KindBool, Category: "Propagation"},
+ {Name: "MAX_BURSTS", Kind: KindNumber, Category: "Propagation"},
+ {Name: "MS_SHOWER", Kind: KindText, Category: "Propagation"},
+ {Name: "NR_BURSTS", Kind: KindNumber, Category: "Propagation"},
+ {Name: "NR_PINGS", Kind: KindNumber, Category: "Propagation"},
+
+ // ── QSL / confirmations ─────────────────────────────────────────
+ {Name: "QSL_SENT", Kind: KindEnum, Category: "QSL", Promoted: true},
+ {Name: "QSL_RCVD", Kind: KindEnum, Category: "QSL", Promoted: true},
+ {Name: "QSLSDATE", Kind: KindDate, Category: "QSL", Promoted: true},
+ {Name: "QSLRDATE", Kind: KindDate, Category: "QSL", Promoted: true},
+ {Name: "QSL_VIA", Kind: KindText, Category: "QSL", Promoted: true},
+ {Name: "QSL_SENT_VIA", Kind: KindEnum, Category: "QSL"},
+ {Name: "QSL_RCVD_VIA", Kind: KindEnum, Category: "QSL"},
+ {Name: "QSLMSG", Kind: KindText, Category: "QSL", Promoted: true},
+ {Name: "QSLMSG_INTL", Kind: KindText, Category: "QSL", Intl: true},
+ {Name: "QSLMSG_RCVD", Kind: KindText, Category: "QSL", Promoted: true},
+ {Name: "LOTW_QSL_SENT", Kind: KindEnum, Category: "QSL", Promoted: true},
+ {Name: "LOTW_QSL_RCVD", Kind: KindEnum, Category: "QSL", Promoted: true},
+ {Name: "LOTW_QSLSDATE", Kind: KindDate, Category: "QSL", Promoted: true},
+ {Name: "LOTW_QSLRDATE", Kind: KindDate, Category: "QSL", Promoted: true},
+ {Name: "EQSL_QSL_SENT", Kind: KindEnum, Category: "QSL", Promoted: true},
+ {Name: "EQSL_QSL_RCVD", Kind: KindEnum, Category: "QSL", Promoted: true},
+ {Name: "EQSL_QSLSDATE", Kind: KindDate, Category: "QSL", Promoted: true},
+ {Name: "EQSL_QSLRDATE", Kind: KindDate, Category: "QSL", Promoted: true},
+ {Name: "EQSL_AG", Kind: KindBool, Category: "QSL"},
+ {Name: "DCL_QSL_SENT", Kind: KindEnum, Category: "QSL"},
+ {Name: "DCL_QSL_RCVD", Kind: KindEnum, Category: "QSL"},
+ {Name: "DCL_QSLSDATE", Kind: KindDate, Category: "QSL"},
+ {Name: "DCL_QSLRDATE", Kind: KindDate, Category: "QSL"},
+ {Name: "CLUBLOG_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
+ {Name: "CLUBLOG_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
+ {Name: "HRDLOG_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
+ {Name: "HRDLOG_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
+ {Name: "QRZCOM_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
+ {Name: "QRZCOM_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
+ {Name: "QRZCOM_QSO_DOWNLOAD_DATE", Kind: KindDate, Category: "QSL", Promoted: true},
+ {Name: "QRZCOM_QSO_DOWNLOAD_STATUS", Kind: KindEnum, Category: "QSL", Promoted: true},
+ {Name: "HAMLOGEU_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
+ {Name: "HAMLOGEU_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL"},
+ {Name: "HAMQTH_QSO_UPLOAD_DATE", Kind: KindDate, Category: "QSL"},
+ {Name: "HAMQTH_QSO_UPLOAD_STATUS", Kind: KindEnum, Category: "QSL"},
+
+ // ── Awards / credits ────────────────────────────────────────────
+ {Name: "CREDIT_SUBMITTED", Kind: KindText, Category: "Awards", Promoted: true},
+ {Name: "CREDIT_GRANTED", Kind: KindText, Category: "Awards", Promoted: true},
+ {Name: "AWARD_SUBMITTED", Kind: KindText, Category: "Awards"},
+ {Name: "AWARD_GRANTED", Kind: KindText, Category: "Awards"},
+
+ // ── Contest ─────────────────────────────────────────────────────
+ {Name: "CONTEST_ID", Kind: KindText, Category: "Contest", Promoted: true},
+ {Name: "SRX", Kind: KindNumber, Category: "Contest", Promoted: true},
+ {Name: "STX", Kind: KindNumber, Category: "Contest", Promoted: true},
+ {Name: "SRX_STRING", Kind: KindText, Category: "Contest", Promoted: true},
+ {Name: "STX_STRING", Kind: KindText, Category: "Contest", Promoted: true},
+ {Name: "CHECK", Kind: KindText, Category: "Contest", Promoted: true},
+ {Name: "CLASS", Kind: KindText, Category: "Contest", Promoted: true},
+ {Name: "PRECEDENCE", Kind: KindText, Category: "Contest", Promoted: true},
+ {Name: "ARRL_SECT", Kind: KindEnum, Category: "Contest", Promoted: true},
+
+ // ── Club memberships ────────────────────────────────────────────
+ {Name: "SKCC", Kind: KindText, Category: "Clubs", Promoted: true},
+ {Name: "FISTS", Kind: KindNumber, Category: "Clubs", Promoted: true},
+ {Name: "FISTS_CC", Kind: KindNumber, Category: "Clubs"},
+ {Name: "TEN_TEN", Kind: KindNumber, Category: "Clubs", Promoted: true},
+ {Name: "UKSMG", Kind: KindNumber, Category: "Clubs"},
+ {Name: "DARC_DOK", Kind: KindText, Category: "Clubs", Promoted: true},
+
+ // ── Morse key (3.1.5+) ──────────────────────────────────────────
+ {Name: "MORSE_KEY_TYPE", Kind: KindEnum, Category: "Morse key"},
+ {Name: "MORSE_KEY_INFO", Kind: KindText, Category: "Morse key"},
+
+ // ── Misc / crypto ───────────────────────────────────────────────
+ {Name: "COMMENT", Kind: KindText, Category: "Misc", Promoted: true},
+ {Name: "COMMENT_INTL", Kind: KindText, Category: "Misc", Intl: true},
+ {Name: "NOTES", Kind: KindText, Category: "Misc", Promoted: true},
+ {Name: "NOTES_INTL", Kind: KindText, Category: "Misc", Intl: true},
+ {Name: "PUBLIC_KEY", Kind: KindText, Category: "Misc"},
+ {Name: "VE_PROV", Kind: KindText, Category: "Misc", Deprecated: true},
+
+ // ── My station / operator ───────────────────────────────────────
+ {Name: "STATION_CALLSIGN", Kind: KindText, Category: "My station", Promoted: true},
+ {Name: "OPERATOR", Kind: KindText, Category: "My station", Promoted: true},
+ {Name: "MY_NAME", Kind: KindText, Category: "My station", Promoted: true},
+ {Name: "MY_NAME_INTL", Kind: KindText, Category: "My station", Intl: true},
+ {Name: "MY_GRIDSQUARE", Kind: KindText, Category: "My station", Promoted: true},
+ {Name: "MY_GRIDSQUARE_EXT", Kind: KindText, Category: "My station", Promoted: true},
+ {Name: "MY_VUCC_GRIDS", Kind: KindText, Category: "My station", Promoted: true},
+ {Name: "MY_COUNTRY", Kind: KindText, Category: "My station", Promoted: true},
+ {Name: "MY_COUNTRY_INTL", Kind: KindText, Category: "My station", Intl: true},
+ {Name: "MY_STATE", Kind: KindEnum, Category: "My station", Promoted: true},
+ {Name: "MY_CNTY", Kind: KindEnum, Category: "My station", Promoted: true},
+ {Name: "MY_CNTY_ALT", Kind: KindEnum, Category: "My station"},
+ {Name: "MY_DXCC", Kind: KindEnum, Category: "My station", Promoted: true},
+ {Name: "MY_CQ_ZONE", Kind: KindNumber, Category: "My station", Promoted: true},
+ {Name: "MY_ITU_ZONE", Kind: KindNumber, Category: "My station", Promoted: true},
+ {Name: "MY_IOTA", Kind: KindText, Category: "My station", Promoted: true},
+ {Name: "MY_IOTA_ISLAND_ID", Kind: KindText, Category: "My station"},
+ {Name: "MY_SOTA_REF", Kind: KindText, Category: "My station", Promoted: true},
+ {Name: "MY_POTA_REF", Kind: KindText, Category: "My station", Promoted: true},
+ {Name: "MY_WWFF_REF", Kind: KindText, Category: "My station", Promoted: true},
+ {Name: "MY_SIG", Kind: KindText, Category: "My station", Promoted: true},
+ {Name: "MY_SIG_INFO", Kind: KindText, Category: "My station", Promoted: true},
+ {Name: "MY_SIG_INTL", Kind: KindText, Category: "My station", Intl: true},
+ {Name: "MY_SIG_INFO_INTL", Kind: KindText, Category: "My station", Intl: true},
+ {Name: "MY_LAT", Kind: KindLoc, Category: "My station", Promoted: true},
+ {Name: "MY_LON", Kind: KindLoc, Category: "My station", Promoted: true},
+ {Name: "MY_ALTITUDE", Kind: KindNumber, Category: "My station"},
+ {Name: "MY_STREET", Kind: KindText, Category: "My station", Promoted: true},
+ {Name: "MY_STREET_INTL", Kind: KindText, Category: "My station", Intl: true},
+ {Name: "MY_CITY", Kind: KindText, Category: "My station", Promoted: true},
+ {Name: "MY_CITY_INTL", Kind: KindText, Category: "My station", Intl: true},
+ {Name: "MY_POSTAL_CODE", Kind: KindText, Category: "My station", Promoted: true},
+ {Name: "MY_POSTAL_CODE_INTL", Kind: KindText, Category: "My station", Intl: true},
+ {Name: "MY_RIG", Kind: KindText, Category: "My station", Promoted: true},
+ {Name: "MY_RIG_INTL", Kind: KindText, Category: "My station", Intl: true},
+ {Name: "MY_ANTENNA", Kind: KindText, Category: "My station", Promoted: true},
+ {Name: "MY_ANTENNA_INTL", Kind: KindText, Category: "My station", Intl: true},
+ {Name: "MY_ARRL_SECT", Kind: KindEnum, Category: "My station", Promoted: true},
+ {Name: "MY_USACA_COUNTIES", Kind: KindText, Category: "My station"},
+ {Name: "MY_DARC_DOK", Kind: KindText, Category: "My station", Promoted: true},
+ {Name: "MY_FISTS", Kind: KindNumber, Category: "My station"},
+ {Name: "MY_MORSE_KEY_TYPE", Kind: KindEnum, Category: "My station"},
+ {Name: "MY_MORSE_KEY_INFO", Kind: KindText, Category: "My station"},
+}
+
+// fieldIndex maps an uppercase ADIF tag to its definition for O(1) lookup.
+var fieldIndex = func() map[string]FieldDef {
+ m := make(map[string]FieldDef, len(Fields))
+ for _, f := range Fields {
+ m[f.Name] = f
+ }
+ return m
+}()
+
+// IsStandardField reports whether tag (any case) is a defined ADIF 3.1.7
+// field. APP_* and USERDEF tags are non-standard and return false.
+func IsStandardField(tag string) bool {
+ _, ok := fieldIndex[strings.ToUpper(strings.TrimSpace(tag))]
+ return ok
+}
+
+// LookupField returns the definition for a tag (any case), ok=false if unknown.
+func LookupField(tag string) (FieldDef, bool) {
+ f, ok := fieldIndex[strings.ToUpper(strings.TrimSpace(tag))]
+ return f, ok
+}
diff --git a/internal/adif/import.go b/internal/adif/import.go
index 3dd1eba..6a69407 100644
--- a/internal/adif/import.go
+++ b/internal/adif/import.go
@@ -284,6 +284,12 @@ var adifPromoted = stringSet(
"my_street", "my_city", "my_postal_code", "my_rig", "my_antenna",
// Misc
"tx_pwr", "comment", "notes",
+ // ADIF 3.1.7 additional promoted fields
+ "sig", "sig_info", "my_sig", "my_sig_info", "wwff_ref", "my_wwff_ref",
+ "distance", "rx_pwr", "a_index", "k_index", "sfi",
+ "skcc", "fists", "ten_ten", "contacted_op", "eq_call", "pfx", "my_name", "class",
+ "darc_dok", "my_darc_dok", "region", "silent_key", "swl", "qso_complete", "qso_random",
+ "credit_granted", "credit_submitted", "my_arrl_sect", "my_vucc_grids",
)
func stringSet(items ...string) map[string]struct{} {
@@ -482,6 +488,48 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
q.Comment = rec["comment"]
q.Notes = rec["notes"]
+ // ADIF 3.1.7 additional promoted fields
+ q.SIG = rec["sig"]
+ q.SIGInfo = rec["sig_info"]
+ q.MySIG = rec["my_sig"]
+ q.MySIGInfo = rec["my_sig_info"]
+ q.WWFFRef = strings.ToUpper(rec["wwff_ref"])
+ q.MyWWFFRef = strings.ToUpper(rec["my_wwff_ref"])
+ if v, ok := parseFloat(rec["distance"]); ok {
+ q.Distance = &v
+ }
+ if v, ok := parseFloat(rec["rx_pwr"]); ok {
+ q.RXPower = &v
+ }
+ if v, ok := parseFloat(rec["a_index"]); ok {
+ q.AIndex = &v
+ }
+ if v, ok := parseFloat(rec["k_index"]); ok {
+ q.KIndex = &v
+ }
+ if v, ok := parseFloat(rec["sfi"]); ok {
+ q.SFI = &v
+ }
+ q.SKCC = rec["skcc"]
+ q.FISTS = rec["fists"]
+ q.TenTen = rec["ten_ten"]
+ q.ContactedOp = strings.ToUpper(rec["contacted_op"])
+ q.EqCall = strings.ToUpper(rec["eq_call"])
+ q.PFX = strings.ToUpper(rec["pfx"])
+ q.MyName = rec["my_name"]
+ q.Class = rec["class"]
+ q.DarcDOK = rec["darc_dok"]
+ q.MyDarcDOK = rec["my_darc_dok"]
+ q.Region = rec["region"]
+ q.SilentKey = strings.ToUpper(rec["silent_key"])
+ q.SWL = strings.ToUpper(rec["swl"])
+ q.QSOComplete = rec["qso_complete"]
+ q.QSORandom = strings.ToUpper(rec["qso_random"])
+ q.CreditGranted = rec["credit_granted"]
+ q.CreditSubmitted = rec["credit_submitted"]
+ q.MyARRLSect = strings.ToUpper(rec["my_arrl_sect"])
+ q.MyVUCCGrids = strings.ToUpper(rec["my_vucc_grids"])
+
// Everything else lands in extras (uppercased ADIF names).
var extras map[string]string
for k, v := range rec {
diff --git a/internal/adif/roundtrip_test.go b/internal/adif/roundtrip_test.go
new file mode 100644
index 0000000..e525f28
--- /dev/null
+++ b/internal/adif/roundtrip_test.go
@@ -0,0 +1,121 @@
+package adif
+
+import (
+ "bufio"
+ "bytes"
+ "strings"
+ "testing"
+ "time"
+
+ "hamlog/internal/qso"
+)
+
+// TestPromotedFieldsRoundTrip writes a QSO carrying the ADIF 3.1.7 promoted
+// fields, parses it back, and checks they survive — guarding the export
+// writeRecord ↔ import recordToQSO field-name mapping against typos.
+func TestPromotedFieldsRoundTrip(t *testing.T) {
+ dist := 1234.5
+ rxp := 5.0
+ a := 12.0
+ in := qso.QSO{
+ Callsign: "EA8ABC", Band: "20m", Mode: "SSB",
+ QSODate: time.Date(2026, 6, 6, 12, 0, 0, 0, time.UTC),
+ SIG: "POTA", SIGInfo: "US-0001", MySIG: "WWFF", MySIGInfo: "ONFF-0001",
+ WWFFRef: "ONFF-0001", MyWWFFRef: "F-FFF-0001",
+ Distance: &dist, RXPower: &rxp, AIndex: &a,
+ SKCC: "12345S", FISTS: "999", TenTen: "55555",
+ ContactedOp: "EA8XYZ", EqCall: "EA8OLD", PFX: "EA8", MyName: "Greg",
+ Class: "1A", DarcDOK: "A01", MyDarcDOK: "B02", Region: "IV",
+ SilentKey: "N", SWL: "N", QSOComplete: "Y", QSORandom: "Y",
+ CreditGranted: "DXCC", CreditSubmitted: "WAS",
+ MyARRLSect: "EMA", MyVUCCGrids: "FN20,FN21",
+ }
+
+ var buf bytes.Buffer
+ bw := bufio.NewWriter(&buf)
+ bw.WriteString("\n")
+ writeRecord(bw, in, true)
+ bw.Flush()
+
+ var rec Record
+ if err := Parse(strings.NewReader(buf.String()), func(r Record) error { rec = r; return nil }); err != nil {
+ t.Fatalf("parse: %v", err)
+ }
+ out, ok := recordToQSO(rec)
+ if !ok {
+ t.Fatal("recordToQSO returned !ok")
+ }
+
+ checks := map[string]struct{ got, want string }{
+ "SIG": {out.SIG, in.SIG},
+ "SIG_INFO": {out.SIGInfo, in.SIGInfo},
+ "MY_SIG": {out.MySIG, in.MySIG},
+ "MY_SIG_INFO": {out.MySIGInfo, in.MySIGInfo},
+ "WWFF_REF": {out.WWFFRef, in.WWFFRef},
+ "MY_WWFF_REF": {out.MyWWFFRef, in.MyWWFFRef},
+ "SKCC": {out.SKCC, in.SKCC},
+ "FISTS": {out.FISTS, in.FISTS},
+ "TEN_TEN": {out.TenTen, in.TenTen},
+ "CONTACTED_OP": {out.ContactedOp, in.ContactedOp},
+ "EQ_CALL": {out.EqCall, in.EqCall},
+ "PFX": {out.PFX, in.PFX},
+ "MY_NAME": {out.MyName, in.MyName},
+ "CLASS": {out.Class, in.Class},
+ "DARC_DOK": {out.DarcDOK, in.DarcDOK},
+ "MY_DARC_DOK": {out.MyDarcDOK, in.MyDarcDOK},
+ "REGION": {out.Region, in.Region},
+ "SILENT_KEY": {out.SilentKey, in.SilentKey},
+ "SWL": {out.SWL, in.SWL},
+ "QSO_COMPLETE": {out.QSOComplete, in.QSOComplete},
+ "QSO_RANDOM": {out.QSORandom, in.QSORandom},
+ "CREDIT_GRANTED": {out.CreditGranted, in.CreditGranted},
+ "CREDIT_SUBMITTED": {out.CreditSubmitted, in.CreditSubmitted},
+ "MY_ARRL_SECT": {out.MyARRLSect, in.MyARRLSect},
+ "MY_VUCC_GRIDS": {out.MyVUCCGrids, in.MyVUCCGrids},
+ }
+ for tag, c := range checks {
+ if c.got != c.want {
+ t.Errorf("%s round-trip = %q, want %q", tag, c.got, c.want)
+ }
+ }
+ if out.Distance == nil || *out.Distance != dist {
+ t.Errorf("DISTANCE round-trip = %v, want %v", out.Distance, dist)
+ }
+ if out.RXPower == nil || *out.RXPower != rxp {
+ t.Errorf("RX_PWR round-trip = %v, want %v", out.RXPower, rxp)
+ }
+ if out.AIndex == nil || *out.AIndex != a {
+ t.Errorf("A_INDEX round-trip = %v, want %v", out.AIndex, a)
+ }
+}
+
+// TestStandardExportDropsNonStandard verifies that standard mode strips
+// vendor/APP tags while full mode keeps them.
+func TestStandardExportDropsNonStandard(t *testing.T) {
+ q := qso.QSO{
+ Callsign: "F4BPO", Band: "20m", Mode: "CW",
+ Extras: map[string]string{
+ "APP_LOG4OM_FOO": "x",
+ "DARC_DOK": "A01", // standard → kept in both
+ "MY_VENDOR_TAG": "y", // non-standard → dropped in standard mode
+ },
+ }
+
+ standard := renderRecord(q, false)
+ if strings.Contains(standard, "APP_LOG4OM_FOO") || strings.Contains(standard, "MY_VENDOR_TAG") {
+ t.Errorf("standard export should drop non-standard tags:\n%s", standard)
+ }
+
+ full := renderRecord(q, true)
+ if !strings.Contains(full, "APP_LOG4OM_FOO") || !strings.Contains(full, "MY_VENDOR_TAG") {
+ t.Errorf("full export should keep all extras:\n%s", full)
+ }
+}
+
+func renderRecord(q qso.QSO, includeApp bool) string {
+ var buf bytes.Buffer
+ bw := bufio.NewWriter(&buf)
+ writeRecord(bw, q, includeApp)
+ bw.Flush()
+ return buf.String()
+}
diff --git a/internal/award/award.go b/internal/award/award.go
index 598f275..c68d990 100644
--- a/internal/award/award.go
+++ b/internal/award/award.go
@@ -210,8 +210,13 @@ type refList struct {
byCode map[string]RefMeta // uppercased code → metadata
codes []string // codes in input order (for stable unworked listing)
withPattern []string // codes whose reference declares a regex (usually none)
+ names []nameCode // (uppercased name → code) for MatchBy="description"
}
+// nameCode pairs a reference's uppercased description with its code, for
+// description-based matching (e.g. WAJA finding a prefecture NAME in the QTH).
+type nameCode struct{ name, code string }
+
// RefMeta is one reference's metadata for the engine: enough to enforce a
// predefined list, per-reference DXCC scoping, a per-reference pattern, and to
// label results.
@@ -243,6 +248,9 @@ func NewRefList(metas []RefMeta) refList {
m.Code = code
if _, dup := rl.byCode[code]; !dup {
rl.codes = append(rl.codes, code)
+ if nm := strings.ToUpper(strings.TrimSpace(m.Name)); nm != "" {
+ rl.names = append(rl.names, nameCode{name: nm, code: code})
+ }
}
rl.byCode[code] = m
}
@@ -376,6 +384,15 @@ func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf N
for _, b := range sortedBands(bandWorked) {
r.Bands = append(r.Bands, BandCount{Band: b, Worked: bandWorked[b], Confirmed: bandConfirmed[b]})
}
+ // Never return nil slices: they marshal to JSON null, and the UI calls
+ // .filter/.length on them (an award with nothing worked yet — e.g. a
+ // freshly-created WWFF/WAJA — would otherwise white-screen the panel).
+ if r.Refs == nil {
+ r.Refs = []Ref{}
+ }
+ if r.Bands == nil {
+ r.Bands = []BandCount{}
+ }
out[i] = r
}
return out
@@ -428,12 +445,27 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
return nil
}
predefined := hasList && !d.Dynamic
+ byDesc := predefined && strings.EqualFold(strings.TrimSpace(d.MatchBy), "description")
var found []string
switch {
case re != nil:
// Award-level regex: capture group 1 (or whole match) for each hit.
found = regexTokens(re, raw)
+ case byDesc:
+ // Match references by their DESCRIPTION/name appearing in the field
+ // (e.g. WAJA finds the prefecture name inside the QTH). ExactMatch means
+ // the field equals the name; otherwise the name is a substring of it.
+ up := strings.ToUpper(raw)
+ for _, nc := range rl.names {
+ if d.ExactMatch {
+ if up == nc.name {
+ found = append(found, nc.code)
+ }
+ } else if strings.Contains(up, nc.name) {
+ found = append(found, nc.code)
+ }
+ }
case predefined && !d.ExactMatch:
// "Search reference inside the field": look up each token of the field in
// the list — O(tokens), not O(all references) — plus test the few
diff --git a/internal/award/award_test.go b/internal/award/award_test.go
index d650c5c..e5993c7 100644
--- a/internal/award/award_test.go
+++ b/internal/award/award_test.go
@@ -96,6 +96,38 @@ func TestComputeMultiRef(t *testing.T) {
}
}
+// WAJA-style award: MatchBy="description", non-exact, scanning the QTH for a
+// reference's NAME (the prefecture). Also guards against the nil-slice crash:
+// an award with nothing worked must return empty (non-nil) Refs/Bands.
+func TestComputeMatchByDescription(t *testing.T) {
+ def := Def{Code: "WAJA", Type: TypeQSOFields, Field: "qth", MatchBy: "description",
+ DXCCFilter: []int{339}, Confirm: []string{"lotw", "qsl"}, Valid: true}
+ qsos := []qso.QSO{
+ {Callsign: "JA1ABC", Band: "20m", DXCC: ip(339), QTH: "Tokyo city", LOTWRcvd: "Y"},
+ {Callsign: "JA3DEF", Band: "40m", DXCC: ip(339), QTH: "Osaka"},
+ {Callsign: "JA9XYZ", Band: "20m", DXCC: ip(339), QTH: "nowhere special"}, // no prefecture name
+ }
+ refMetas := map[string][]RefMeta{"WAJA": {
+ {Code: "100", Name: "Tokyo", Valid: true},
+ {Code: "270", Name: "Osaka", Valid: true},
+ {Code: "010", Name: "Hokkaido", Valid: true},
+ }}
+ r := Compute([]Def{def}, qsos, refMetas, nil)[0]
+ if r.Worked != 2 { // Tokyo + Osaka found by name inside QTH
+ t.Errorf("WAJA worked = %d, want 2 (%v)", r.Worked, refCodes(r))
+ }
+ if r.Total != 3 { // predefined denominator = list size
+ t.Errorf("WAJA total = %d, want 3", r.Total)
+ }
+
+ // Nil-slice guard: an award with zero worked refs must still return
+ // non-nil (empty) Refs/Bands so the JSON isn't null (UI white-screen).
+ empty := Compute([]Def{{Code: "WWFF", Type: TypeReference, Field: "wwff", Dynamic: true, Valid: true}}, nil, nil, nil)[0]
+ if empty.Refs == nil || empty.Bands == nil {
+ t.Errorf("empty award must have non-nil Refs/Bands, got Refs=%v Bands=%v", empty.Refs, empty.Bands)
+ }
+}
+
func refCodes(r Result) []string {
out := make([]string, 0, len(r.Refs))
for _, rf := range r.Refs {
diff --git a/internal/db/migrations/0019_adif_317_fields.sql b/internal/db/migrations/0019_adif_317_fields.sql
new file mode 100644
index 0000000..c9b148b
--- /dev/null
+++ b/internal/db/migrations/0019_adif_317_fields.sql
@@ -0,0 +1,55 @@
+-- Promote ~30 more ADIF 3.1.7 fields to dedicated columns so they are
+-- editable, queryable and exported as proper tags (rather than living only
+-- in extras_json). The long tail of rarely-used fields still rides in
+-- extras_json and is reachable via the generic "ADIF fields" editor.
+-- SQLite ADD COLUMN is metadata-only — fast even on large logbooks.
+
+-- --- Special-activity group (POTA/SOTA/WWFF/SIG) ---
+ALTER TABLE qso ADD COLUMN sig TEXT; -- e.g. "POTA", "WWFF"
+ALTER TABLE qso ADD COLUMN sig_info TEXT; -- the reference for SIG
+ALTER TABLE qso ADD COLUMN my_sig TEXT;
+ALTER TABLE qso ADD COLUMN my_sig_info TEXT;
+ALTER TABLE qso ADD COLUMN wwff_ref TEXT; -- contacted WWFF reference
+ALTER TABLE qso ADD COLUMN my_wwff_ref TEXT; -- my WWFF activation
+
+-- --- Distance / power / space weather ---
+ALTER TABLE qso ADD COLUMN distance REAL; -- km
+ALTER TABLE qso ADD COLUMN rx_pwr REAL; -- contacted station power (W)
+ALTER TABLE qso ADD COLUMN a_index REAL;
+ALTER TABLE qso ADD COLUMN k_index REAL;
+ALTER TABLE qso ADD COLUMN sfi REAL; -- solar flux index
+
+-- --- Club memberships ---
+ALTER TABLE qso ADD COLUMN skcc TEXT; -- can carry suffix letters
+ALTER TABLE qso ADD COLUMN fists TEXT;
+ALTER TABLE qso ADD COLUMN ten_ten TEXT;
+
+-- --- Contacted / station identity ---
+ALTER TABLE qso ADD COLUMN contacted_op TEXT; -- the actual operator worked
+ALTER TABLE qso ADD COLUMN eq_call TEXT; -- former / alternate callsign
+ALTER TABLE qso ADD COLUMN pfx TEXT; -- WPX prefix
+ALTER TABLE qso ADD COLUMN my_name TEXT;
+ALTER TABLE qso ADD COLUMN class TEXT; -- Field Day class
+
+-- --- German DOK / region ---
+ALTER TABLE qso ADD COLUMN darc_dok TEXT;
+ALTER TABLE qso ADD COLUMN my_darc_dok TEXT;
+ALTER TABLE qso ADD COLUMN region TEXT;
+
+-- --- Flags ---
+ALTER TABLE qso ADD COLUMN silent_key TEXT; -- Y/N
+ALTER TABLE qso ADD COLUMN swl TEXT; -- Y/N (SWL report)
+ALTER TABLE qso ADD COLUMN qso_complete TEXT; -- Y/N/NIL/?
+ALTER TABLE qso ADD COLUMN qso_random TEXT; -- Y/N
+
+-- --- Award credits ---
+ALTER TABLE qso ADD COLUMN credit_granted TEXT;
+ALTER TABLE qso ADD COLUMN credit_submitted TEXT;
+
+-- --- My station extras ---
+ALTER TABLE qso ADD COLUMN my_arrl_sect TEXT;
+ALTER TABLE qso ADD COLUMN my_vucc_grids TEXT;
+
+CREATE INDEX IF NOT EXISTS idx_qso_sig ON qso(sig);
+CREATE INDEX IF NOT EXISTS idx_qso_wwff_ref ON qso(wwff_ref);
+CREATE INDEX IF NOT EXISTS idx_qso_skcc ON qso(skcc);
diff --git a/internal/qso/qso.go b/internal/qso/qso.go
index 218a42c..e93968c 100644
--- a/internal/qso/qso.go
+++ b/internal/qso/qso.go
@@ -156,8 +156,42 @@ type QSO struct {
Comment string `json:"comment,omitempty"`
Notes string `json:"notes,omitempty"`
+ // --- ADIF 3.1.7 additional promoted fields ---
+ // Kept in one block so columnList / args() / scanQSO stay trivially in
+ // sync (they are appended at the end, before extras_json).
+ SIG string `json:"sig,omitempty"`
+ SIGInfo string `json:"sig_info,omitempty"`
+ MySIG string `json:"my_sig,omitempty"`
+ MySIGInfo string `json:"my_sig_info,omitempty"`
+ WWFFRef string `json:"wwff_ref,omitempty"`
+ MyWWFFRef string `json:"my_wwff_ref,omitempty"`
+ Distance *float64 `json:"distance,omitempty"`
+ RXPower *float64 `json:"rx_pwr,omitempty"`
+ AIndex *float64 `json:"a_index,omitempty"`
+ KIndex *float64 `json:"k_index,omitempty"`
+ SFI *float64 `json:"sfi,omitempty"`
+ SKCC string `json:"skcc,omitempty"`
+ FISTS string `json:"fists,omitempty"`
+ TenTen string `json:"ten_ten,omitempty"`
+ ContactedOp string `json:"contacted_op,omitempty"`
+ EqCall string `json:"eq_call,omitempty"`
+ PFX string `json:"pfx,omitempty"`
+ MyName string `json:"my_name,omitempty"`
+ Class string `json:"class,omitempty"`
+ DarcDOK string `json:"darc_dok,omitempty"`
+ MyDarcDOK string `json:"my_darc_dok,omitempty"`
+ Region string `json:"region,omitempty"`
+ SilentKey string `json:"silent_key,omitempty"`
+ SWL string `json:"swl,omitempty"`
+ QSOComplete string `json:"qso_complete,omitempty"`
+ QSORandom string `json:"qso_random,omitempty"`
+ CreditGranted string `json:"credit_granted,omitempty"`
+ CreditSubmitted string `json:"credit_submitted,omitempty"`
+ MyARRLSect string `json:"my_arrl_sect,omitempty"`
+ MyVUCCGrids string `json:"my_vucc_grids,omitempty"`
+
// Extras holds ADIF fields not promoted to columns. Keys are uppercase
- // ADIF field names (e.g. "DARC_DOK"); values are the raw string content.
+ // ADIF field names (e.g. "MS_SHOWER"); values are the raw string content.
Extras map[string]string `json:"extras,omitempty"`
CreatedAt time.Time `json:"created_at"`
@@ -205,7 +239,13 @@ const columnList = `callsign, qso_date, qso_date_off, band, band_rx, mode, submo
station_callsign, operator, my_grid, my_gridsquare_ext, my_country, my_state, my_cnty, my_iota,
my_sota_ref, my_pota_ref, my_dxcc, my_cq_zone, my_itu_zone, my_lat, my_lon,
my_street, my_city, my_postal_code, my_rig, my_antenna,
- tx_pwr, comment, notes, extras_json`
+ tx_pwr, comment, notes,
+ sig, sig_info, my_sig, my_sig_info, wwff_ref, my_wwff_ref,
+ distance, rx_pwr, a_index, k_index, sfi,
+ skcc, fists, ten_ten, contacted_op, eq_call, pfx, my_name, class,
+ darc_dok, my_darc_dok, region, silent_key, swl, qso_complete, qso_random,
+ credit_granted, credit_submitted, my_arrl_sect, my_vucc_grids,
+ extras_json`
const selectCols = `id, ` + columnList + `, created_at, updated_at`
@@ -258,7 +298,13 @@ func (q *QSO) args() []any {
q.StationCallsign, q.Operator, q.MyGrid, q.MyGridExt, q.MyCountry, q.MyState, q.MyCounty, q.MyIOTA,
q.MySOTARef, q.MyPOTARef, q.MyDXCC, q.MyCQZone, q.MyITUZone, q.MyLat, q.MyLon,
q.MyStreet, q.MyCity, q.MyPostalCode, q.MyRig, q.MyAntenna,
- q.TXPower, q.Comment, q.Notes, extras,
+ q.TXPower, q.Comment, q.Notes,
+ q.SIG, q.SIGInfo, q.MySIG, q.MySIGInfo, q.WWFFRef, q.MyWWFFRef,
+ q.Distance, q.RXPower, q.AIndex, q.KIndex, q.SFI,
+ q.SKCC, q.FISTS, q.TenTen, q.ContactedOp, q.EqCall, q.PFX, q.MyName, q.Class,
+ q.DarcDOK, q.MyDarcDOK, q.Region, q.SilentKey, q.SWL, q.QSOComplete, q.QSORandom,
+ q.CreditGranted, q.CreditSubmitted, q.MyARRLSect, q.MyVUCCGrids,
+ extras,
}
}
@@ -1472,6 +1518,15 @@ func scanQSO(s scanner) (QSO, error) {
myRig, myAntenna sql.NullString
txp sql.NullFloat64
comment, notes sql.NullString
+ sig, sigInfo, mySig, mySigInfo sql.NullString
+ wwffRef, myWWFFRef sql.NullString
+ distance, rxPwr, aIndex, kIndex, sfi sql.NullFloat64
+ skcc, fists, tenTen sql.NullString
+ contactedOp, eqCall, pfx, myName sql.NullString
+ class, darcDOK, myDarcDOK, region sql.NullString
+ silentKey, swl, qsoComplete, qsoRandom sql.NullString
+ creditGranted, creditSubmitted sql.NullString
+ myARRLSect, myVUCCGrids sql.NullString
extrasJSON sql.NullString
createdStr, updatedStr string
)
@@ -1494,7 +1549,13 @@ func scanQSO(s scanner) (QSO, error) {
&stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA,
&mySOTA, &myPOTA, &myDXCC, &myCQZ, &myITUZ, &myLat, &myLon,
&myStreet, &myCity, &myPostal, &myRig, &myAntenna,
- &txp, &comment, ¬es, &extrasJSON, &createdStr, &updatedStr,
+ &txp, &comment, ¬es,
+ &sig, &sigInfo, &mySig, &mySigInfo, &wwffRef, &myWWFFRef,
+ &distance, &rxPwr, &aIndex, &kIndex, &sfi,
+ &skcc, &fists, &tenTen, &contactedOp, &eqCall, &pfx, &myName, &class,
+ &darcDOK, &myDarcDOK, ®ion, &silentKey, &swl, &qsoComplete, &qsoRandom,
+ &creditGranted, &creditSubmitted, &myARRLSect, &myVUCCGrids,
+ &extrasJSON, &createdStr, &updatedStr,
); err != nil {
return QSO{}, fmt.Errorf("scan qso: %w", err)
}
@@ -1645,6 +1706,51 @@ func scanQSO(s scanner) (QSO, error) {
}
q.Comment = comment.String
q.Notes = notes.String
+ q.SIG = sig.String
+ q.SIGInfo = sigInfo.String
+ q.MySIG = mySig.String
+ q.MySIGInfo = mySigInfo.String
+ q.WWFFRef = wwffRef.String
+ q.MyWWFFRef = myWWFFRef.String
+ if distance.Valid {
+ v := distance.Float64
+ q.Distance = &v
+ }
+ if rxPwr.Valid {
+ v := rxPwr.Float64
+ q.RXPower = &v
+ }
+ if aIndex.Valid {
+ v := aIndex.Float64
+ q.AIndex = &v
+ }
+ if kIndex.Valid {
+ v := kIndex.Float64
+ q.KIndex = &v
+ }
+ if sfi.Valid {
+ v := sfi.Float64
+ q.SFI = &v
+ }
+ q.SKCC = skcc.String
+ q.FISTS = fists.String
+ q.TenTen = tenTen.String
+ q.ContactedOp = contactedOp.String
+ q.EqCall = eqCall.String
+ q.PFX = pfx.String
+ q.MyName = myName.String
+ q.Class = class.String
+ q.DarcDOK = darcDOK.String
+ q.MyDarcDOK = myDarcDOK.String
+ q.Region = region.String
+ q.SilentKey = silentKey.String
+ q.SWL = swl.String
+ q.QSOComplete = qsoComplete.String
+ q.QSORandom = qsoRandom.String
+ q.CreditGranted = creditGranted.String
+ q.CreditSubmitted = creditSubmitted.String
+ q.MyARRLSect = myARRLSect.String
+ q.MyVUCCGrids = myVUCCGrids.String
q.Extras = decodeExtras(extrasJSON.String)
return q, nil
}