This commit is contained in:
2026-06-06 14:16:30 +02:00
parent f91f9ff3b8
commit 17f7a00bd7
19 changed files with 1278 additions and 91 deletions
+153 -1
View File
@@ -2141,9 +2141,14 @@ func bandForHz(hz int64) string {
// the operator assigns by hand. Such awards are read-only in the per-QSO editor. // the operator assigns by hand. Such awards are read-only in the per-QSO editor.
func isComputedAwardField(field string) bool { func isComputedAwardField(field string) bool {
switch field { 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 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 return false
} }
@@ -2326,6 +2331,145 @@ func (a *App) HasBuiltinReferences(code string) bool {
return ok 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 // builtinRefsVersion is bumped whenever the built-in reference data changes
// (e.g. the West Malaysia 155→299 fix) so existing installs re-seed the // (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. // 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. // 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. // Streams from DB so memory stays flat even with 100k+ records.
// includeAppFields=false → portable standard ADIF (for other loggers); // includeAppFields=false → portable standard ADIF (for other loggers);
+7 -7
View File
@@ -2861,7 +2861,7 @@ export default function App() {
<DialogHeader className="px-2"> <DialogHeader className="px-2">
<DialogTitle>Export ADIF</DialogTitle> <DialogTitle>Export ADIF</DialogTitle>
<DialogDescription> <DialogDescription>
Choose which fields to include in the export. Choose which fields to include. OpsLog writes ADIF 3.1.7.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="px-2 py-1 space-y-2.5"> <div className="px-2 py-1 space-y-2.5">
@@ -2870,10 +2870,10 @@ export default function App() {
onClick={() => runExport(false)} 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" className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
> >
<div className="font-semibold text-sm">Standard ADIF</div> <div className="font-semibold text-sm">Standard ADIF only</div>
<div className="text-xs text-muted-foreground mt-0.5"> <div className="text-xs text-muted-foreground mt-0.5">
Only standard ADIF-defined fields portable to other loggers (Log4OM, N1MM, LoTW). Only fields defined in the ADIF 3.1.7 spec portable to other loggers (Log4OM, N1MM, LoTW).
Application-specific <span className="font-mono">APP_*</span> tags are stripped. Application-specific <span className="font-mono">APP_*</span> and any non-standard / vendor tags are stripped.
</div> </div>
</button> </button>
<button <button
@@ -2881,10 +2881,10 @@ export default function App() {
onClick={() => runExport(true)} onClick={() => runExport(true)}
className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors" className="w-full text-left rounded-lg border border-border p-3 hover:border-primary/60 hover:bg-accent/30 transition-colors"
> >
<div className="font-semibold text-sm">Full (OpsLog round-trip)</div> <div className="font-semibold text-sm">All fields (OpsLog round-trip)</div>
<div className="text-xs text-muted-foreground mt-0.5"> <div className="text-xs text-muted-foreground mt-0.5">
Every field including OpsLog/application-specific <span className="font-mono">APP_*</span> tags Every field including application-specific <span className="font-mono">APP_*</span> and vendor tags
for a lossless backup you'll re-import into OpsLog. a lossless backup you'll re-import into OpsLog.
</div> </div>
</button> </button>
</div> </div>
@@ -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<string, string> | undefined;
onChange: (next: Record<string, string> | 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<FieldDef[]>([]);
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<string, FieldDef> = {};
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 (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
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. <code className="bg-muted px-1 rounded font-mono">APP_*</code>).
Stored losslessly and exported in the <strong>full</strong> ADIF mode.
</p>
{/* Add a field */}
<div className="flex items-center gap-2">
<div className="flex-1 max-w-xs">
<Combobox
value=""
options={addable}
placeholder="Add ADIF field…"
allowFreeText
onChange={addField}
/>
</div>
<label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer">
<input type="checkbox" checked={showDeprecated} onChange={(e) => setShowDeprecated(e.target.checked)} />
Show deprecated
</label>
</div>
{/* Current entries */}
{entries.length === 0 ? (
<div className="text-[11px] text-muted-foreground italic border border-dashed border-border rounded-md px-3 py-4 text-center">
No extra ADIF fields. Use the picker above to add one.
</div>
) : (
<div className="space-y-1.5">
{entries.map(([k, v]) => {
const def = meta[k];
return (
<div key={k} className="flex items-center gap-2">
<div className="w-48 shrink-0">
<span className="font-mono text-xs font-semibold">{k}</span>
{def && (
<span className="block text-[10px] text-muted-foreground leading-tight">
{def.category}{def.deprecated ? ' · deprecated' : ''}{def.intl ? ' · intl' : ''}
{!def && ''}
</span>
)}
{!def && <span className="block text-[10px] text-amber-600 leading-tight">non-standard</span>}
</div>
<Input
className="flex-1 h-8 text-xs font-mono"
value={v}
onChange={(e) => setKV(k, e.target.value)}
/>
<Button
type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => remove(k)} title="Remove field"
>
<X className="size-3.5" />
</Button>
</div>
);
})}
</div>
)}
</div>
);
}
+31 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'; 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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -17,6 +17,7 @@ import {
ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset, ImportAwardReferencesText, GetAwardPresets, ApplyAwardPreset,
ListCountries, DXCCForCountry, DXCCName, ListCountries, DXCCForCountry, DXCCName,
PopulateBuiltinReferences, HasBuiltinReferences, PopulateBuiltinReferences, HasBuiltinReferences,
ExportAwards, ImportAwards,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
// Above this many references the editor stops loading the whole list and // 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() { async function reset() {
try { setDefs((await ResetAwardDefs()) as any); setSel(0); } catch (e: any) { setErr(String(e?.message ?? e)); } 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) { async function updateList(code: string) {
setUpdating(code); setErr(''); setUpdating(code); setErr('');
try { await UpdateAwardReferenceList(code); await loadMeta(); } try { await UpdateAwardReferenceList(code); await loadMeta(); }
@@ -228,7 +251,7 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
{/* Right: tabbed editor for selected award */} {/* Right: tabbed editor for selected award */}
<div className="flex flex-col min-h-0 overflow-hidden"> <div className="flex flex-col min-h-0 overflow-hidden">
{err && <div className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5">{err}</div>} {err && <div className="mx-4 mt-3 text-xs text-destructive bg-destructive/10 border border-destructive/30 rounded px-3 py-1.5 whitespace-pre-line break-all">{err}</div>}
{!cur ? ( {!cur ? (
<div className="flex-1 grid place-items-center text-sm text-muted-foreground">Select or create an award.</div> <div className="flex-1 grid place-items-center text-sm text-muted-foreground">Select or create an award.</div>
) : ( ) : (
@@ -338,6 +361,12 @@ export function AwardEditor({ open, onClose, onSaved }: Props) {
<DialogFooter className="px-5 py-3 border-t !flex-row"> <DialogFooter className="px-5 py-3 border-t !flex-row">
<Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> Reset to defaults</Button> <Button variant="ghost" onClick={reset}><RotateCcw className="size-3.5 mr-1" /> Reset to defaults</Button>
<Button variant="outline" onClick={exportAwards} title="Export all award definitions + reference lists to a JSON backup">
<Download className="size-3.5 mr-1" /> Export
</Button>
<Button variant="outline" onClick={importAwards} title="Import an award bundle (definitions + reference lists)">
<Upload className="size-3.5 mr-1" /> Import
</Button>
<div className="flex-1" /> <div className="flex-1" />
<Button variant="outline" onClick={onClose}>Cancel</Button> <Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={save}><Save className="size-3.5 mr-1" /> Save</Button> <Button onClick={save}><Save className="size-3.5 mr-1" /> Save</Button>
+31 -19
View File
@@ -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/…) // 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. // 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. // If DXCC-filtered auto-results exceed this, require the user to type instead.
const AUTO_SHOW_MAX = 100; 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); if (!awards.find((a) => a.code === awardCode)) setAwardCode(awards[0].code);
}, [awards, awardCode]); }, [awards, awardCode]);
// Auto-load DXCC-filtered refs on award/dxcc change with empty query. // Is the selected award a giant dynamic list (POTA/SOTA/IOTA)? Those carry a
// Fetches AUTO_SHOW_MAX+1 so we can distinguish "all results shown" from "too many". // 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(() => { useEffect(() => {
setAutoResults([]); setAutoResults([]);
if (!dxcc) return; // Dynamic lists need an entity to scope to; predefined lists load regardless.
SearchAwardReferences(awardCode, '', dxcc, AUTO_SHOW_MAX + 1) if (isDynamic && !dxcc) return;
SearchAwardReferences(awardCode, '', refDxcc, AUTO_SHOW_MAX + 1)
.then((r) => setAutoResults((r ?? []) as any)) .then((r) => setAutoResults((r ?? []) as any))
.catch(() => {}); .catch(() => {});
}, [awardCode, dxcc]); }, [awardCode, dxcc, isDynamic, refDxcc]);
// Typed search (2+ chars). // Typed search (2+ chars).
useEffect(() => { useEffect(() => {
@@ -88,13 +103,13 @@ export function AwardRefSelector({ dxcc, value, onChange }: Props) {
const t = window.setTimeout(async () => { const t = window.setTimeout(async () => {
setBusy(true); setBusy(true);
try { try {
const r = await SearchAwardReferences(awardCode, q, dxcc ?? 0, 50); const r = await SearchAwardReferences(awardCode, q, refDxcc, 50);
setSearchResults((r ?? []) as any); setSearchResults((r ?? []) as any);
} catch { setSearchResults([]); } } catch { setSearchResults([]); }
finally { setBusy(false); } finally { setBusy(false); }
}, 200); }, 200);
return () => window.clearTimeout(t); return () => window.clearTimeout(t);
}, [awardCode, q, dxcc]); }, [awardCode, q, refDxcc]);
const tooManyAuto = autoResults.length > AUTO_SHOW_MAX; const tooManyAuto = autoResults.length > AUTO_SHOW_MAX;
// When q is empty/short: show auto-results (if ≤ AUTO_SHOW_MAX); when typing: show search results. // 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) {
<Loader2 className="size-3 animate-spin" />Searching <Loader2 className="size-3 animate-spin" />Searching
</div> </div>
)} )}
{/* No callsign yet */} {/* Too many auto-results → require typed search */}
{!busy && !dxcc && q.length < 2 && ( {!busy && q.length < 2 && tooManyAuto && (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
Enter a callsign, or type to search.
</div>
)}
{/* DXCC known but too many auto-results → require typed search */}
{!busy && !!dxcc && q.length < 2 && tooManyAuto && (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug"> <div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
Type 2+ chars to search Type 2+ chars to search
</div> </div>
)} )}
{/* DXCC known, auto-results loaded, none found */} {/* Empty short-query state: prompt for a callsign (dynamic lists) or
{!busy && !!dxcc && q.length < 2 && !tooManyAuto && autoResults.length === 0 && ( note the list is empty (predefined awards with no references). */}
{!busy && q.length < 2 && !tooManyAuto && autoResults.length === 0 && (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug"> <div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
No references for this entity. {isDynamic && !dxcc
? 'Enter a callsign, or type to search.'
: 'No references for this entity.'}
</div> </div>
)} )}
{/* Typed search, no results */} {/* Typed search, no results */}
+3 -3
View File
@@ -120,7 +120,7 @@ export function AwardsPanel() {
const filteredRefs = useMemo(() => { const filteredRefs = useMemo(() => {
if (!current) return []; if (!current) return [];
const q = refSearch.trim().toUpperCase(); const q = refSearch.trim().toUpperCase();
return current.refs.filter((r) => { return (current.refs ?? []).filter((r) => {
if (refFilter === 'worked' && !r.worked) return false; if (refFilter === 'worked' && !r.worked) return false;
if (refFilter === 'notworked' && r.worked) return false; if (refFilter === 'notworked' && r.worked) return false;
if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false; if (refFilter === 'worked_notconf' && !(r.worked && !r.confirmed)) return false;
@@ -206,11 +206,11 @@ export function AwardsPanel() {
</div> </div>
{/* Band breakdown */} {/* Band breakdown */}
{current.bands.length > 0 && ( {(current.bands ?? []).length > 0 && (
<div className="px-4 py-2 border-b border-border/60"> <div className="px-4 py-2 border-b border-border/60">
<div className="text-[11px] uppercase tracking-wider text-muted-foreground mb-1.5">By band (confirmed / worked)</div> <div className="text-[11px] uppercase tracking-wider text-muted-foreground mb-1.5">By band (confirmed / worked)</div>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{current.bands.map((b) => ( {(current.bands ?? []).map((b) => (
<div key={b.band} className="rounded-md border border-border bg-card px-2 py-1 text-xs"> <div key={b.band} className="rounded-md border border-border bg-card px-2 py-1 text-xs">
<span className="font-mono font-semibold">{b.band}</span>{' '} <span className="font-mono font-semibold">{b.band}</span>{' '}
<span className="text-emerald-600 font-mono">{b.confirmed}</span> <span className="text-emerald-600 font-mono">{b.confirmed}</span>
+87 -34
View File
@@ -2,7 +2,8 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { Trash2, Search, Loader2 } from 'lucide-react'; import { Trash2, Search, Loader2 } from 'lucide-react';
import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs } from '../../wailsjs/go/main/App'; import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
import { AwardRefSelector } from '@/components/AwardRefSelector'; import { AwardRefSelector } from '@/components/AwardRefSelector';
import { applyAwardRefs, buildAwardRefs } from '@/lib/awardRefs'; import { AdifExtrasEditor } from '@/components/AdifExtrasEditor';
import { applyAwardRefs } from '@/lib/awardRefs';
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
@@ -100,23 +101,6 @@ function parseLocalISO(s: string): string | null {
if (!m) return null; if (!m) return null;
return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:00.000Z`; return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:00.000Z`;
} }
function stringifyExtras(e?: Record<string, string>): string {
if (!e) return '';
return Object.entries(e).map(([k, v]) => `${k} = ${v}`).join('\n');
}
function parseExtras(t: string): Record<string, string> | undefined {
const out: Record<string, string> = {};
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 { function numOrUndef(v: any): number | undefined {
if (v === '' || v === null || v === undefined) return undefined; if (v === '' || v === null || v === undefined) return undefined;
const n = typeof v === 'number' ? v : parseFloat(String(v)); 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 [dateOff, setDateOff] = useState(toLocalISO(draft.qso_date_off));
const [endEnabled, setEndEnabled] = useState(!!draft.qso_date_off); const [endEnabled, setEndEnabled] = useState(!!draft.qso_date_off);
const [confSel, setConfSel] = useState('QSL'); // selected confirmation channel const [confSel, setConfSel] = useState('QSL'); // selected confirmation channel
const [extrasText, setExtrasText] = useState(stringifyExtras(draft.extras));
const [localErr, setLocalErr] = useState(''); const [localErr, setLocalErr] = useState('');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [looking, setLooking] = useState(false); const [looking, setLooking] = useState(false);
@@ -183,15 +166,17 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
const fieldOf: Record<string, string> = {}; const fieldOf: Record<string, string> = {};
for (const d of list) fieldOf[String(d.code).toUpperCase()] = String(d.field || '').toLowerCase(); for (const d of list) fieldOf[String(d.code).toUpperCase()] = String(d.field || '').toLowerCase();
awardFieldRef.current = fieldOf; awardFieldRef.current = fieldOf;
// Which awards are reference-list (manual) ones? Ask the backend, which // Seed the editable manual refs from the backend, which already matched
// also tells us pickable vs computed for the current QSO. // 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 { try {
const all = (await ComputeQSOAwardRefs(draft as any)) ?? []; 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 seed = all
const pickable = list .filter((r: any) => r.pickable)
.filter((d) => pickableCodes.has(String(d.code).toUpperCase())) .map((r: any) => `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`)
.map((d) => ({ code: String(d.code), field: String(d.field || '').toLowerCase() })); .join(';');
setAwardRefs(buildAwardRefs(draft, pickable)); setAwardRefs(seed);
} catch { /* leave manual refs empty on failure */ } } catch { /* leave manual refs empty on failure */ }
}) })
.catch(() => {}); .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), my_lat: numOrUndef(draft.my_lat), my_lon: numOrUndef(draft.my_lon),
ant_az: numOrUndef(draft.ant_az), ant_el: numOrUndef(draft.ant_el), ant_az: numOrUndef(draft.ant_az), ant_el: numOrUndef(draft.ant_el),
tx_pwr: numOrUndef(draft.tx_pwr), 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 Award Refs tab is authoritative for the reference-list awards. Reset
// the dedicated columns, then route the picked refs back onto the payload // the dedicated columns, then route the picked refs back onto the payload
@@ -334,8 +324,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
<TabsTrigger value="contest">Contest</TabsTrigger> <TabsTrigger value="contest">Contest</TabsTrigger>
<TabsTrigger value="sat">Sat / Prop</TabsTrigger> <TabsTrigger value="sat">Sat / Prop</TabsTrigger>
<TabsTrigger value="mystation">My Station</TabsTrigger> <TabsTrigger value="mystation">My Station</TabsTrigger>
<TabsTrigger value="moreadif">More ADIF</TabsTrigger>
<TabsTrigger value="extras"> <TabsTrigger value="extras">
Extras ADIF fields
{extrasCount > 0 && ( {extrasCount > 0 && (
<Badge variant="accent" className="ml-1 px-1.5 py-0 text-[9px]">{extrasCount}</Badge> <Badge variant="accent" className="ml-1 px-1.5 py-0 text-[9px]">{extrasCount}</Badge>
)} )}
@@ -602,12 +593,74 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }:
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="extras" className="mt-0 space-y-2"> <TabsContent value="moreadif" className="mt-0 space-y-4">
<p className="text-xs text-muted-foreground"> {/* Special activity (POTA/SOTA/WWFF/SIG) */}
ADIF fields not promoted to first-class columns. One per line:{' '} <div>
<code className="bg-muted px-1 py-0.5 rounded font-mono">FIELD_NAME = value</code> <p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Special activity</p>
</p> <div className="grid grid-cols-6 gap-3">
<Textarea rows={14} className="font-mono text-xs" value={extrasText} onChange={(e) => setExtrasText(e.target.value)} /> <F label="SIG"><Input value={draft.sig ?? ''} placeholder="POTA" onChange={(e) => set('sig', e.target.value)} /></F>
<F label="SIG info" span={2}><Input value={draft.sig_info ?? ''} placeholder="US-0001" onChange={(e) => set('sig_info', e.target.value)} /></F>
<F label="WWFF ref" span={2}><Input value={draft.wwff_ref ?? ''} placeholder="ONFF-0001" onChange={(e) => set('wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
<F label="Region"><Input value={draft.region ?? ''} onChange={(e) => set('region', e.target.value)} /></F>
</div>
</div>
{/* Power & propagation */}
<div>
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Power &amp; space weather</p>
<div className="grid grid-cols-6 gap-3">
<F label="RX power (W)"><Input type="number" value={draft.rx_pwr ?? ''} onChange={(e) => set('rx_pwr', numOrUndef(e.target.value) as any)} /></F>
<F label="Distance (km)"><Input type="number" value={draft.distance ?? ''} onChange={(e) => set('distance', numOrUndef(e.target.value) as any)} /></F>
<F label="A index"><Input type="number" value={draft.a_index ?? ''} onChange={(e) => set('a_index', numOrUndef(e.target.value) as any)} /></F>
<F label="K index"><Input type="number" value={draft.k_index ?? ''} onChange={(e) => set('k_index', numOrUndef(e.target.value) as any)} /></F>
<F label="SFI"><Input type="number" value={draft.sfi ?? ''} onChange={(e) => set('sfi', numOrUndef(e.target.value) as any)} /></F>
</div>
</div>
{/* Identity & clubs */}
<div>
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Identity &amp; clubs</p>
<div className="grid grid-cols-6 gap-3">
<F label="Contacted op" span={2}><Input value={draft.contacted_op ?? ''} placeholder="EA8XYZ" onChange={(e) => set('contacted_op', e.target.value)} className="font-mono uppercase" /></F>
<F label="Former call (EQ_CALL)" span={2}><Input value={draft.eq_call ?? ''} onChange={(e) => set('eq_call', e.target.value)} className="font-mono uppercase" /></F>
<F label="Class"><Input value={draft.class ?? ''} placeholder="1A" onChange={(e) => set('class', e.target.value)} /></F>
<F label="SKCC"><Input value={draft.skcc ?? ''} onChange={(e) => set('skcc', e.target.value)} /></F>
<F label="FISTS"><Input value={draft.fists ?? ''} onChange={(e) => set('fists', e.target.value)} /></F>
<F label="Ten-Ten"><Input value={draft.ten_ten ?? ''} onChange={(e) => set('ten_ten', e.target.value)} /></F>
<F label="DARC DOK"><Input value={draft.darc_dok ?? ''} onChange={(e) => set('darc_dok', e.target.value)} /></F>
</div>
</div>
{/* Flags & credits */}
<div>
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Flags &amp; credits</p>
<div className="grid grid-cols-6 gap-3">
<F label="QSO complete"><Input value={draft.qso_complete ?? ''} placeholder="Y/N/NIL/?" onChange={(e) => set('qso_complete', e.target.value)} /></F>
<F label="QSO random"><Input value={draft.qso_random ?? ''} placeholder="Y/N" onChange={(e) => set('qso_random', e.target.value)} /></F>
<F label="Silent key"><Input value={draft.silent_key ?? ''} placeholder="Y/N" onChange={(e) => set('silent_key', e.target.value)} /></F>
<F label="SWL"><Input value={draft.swl ?? ''} placeholder="Y/N" onChange={(e) => set('swl', e.target.value)} /></F>
<F label="Credit granted" span={3}><Input value={draft.credit_granted ?? ''} placeholder="DXCC,WAS" onChange={(e) => set('credit_granted', e.target.value)} /></F>
<F label="Credit submitted" span={3}><Input value={draft.credit_submitted ?? ''} onChange={(e) => set('credit_submitted', e.target.value)} /></F>
</div>
</div>
{/* My station extras */}
<div>
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">My station (ADIF)</p>
<div className="grid grid-cols-6 gap-3">
<F label="My name" span={2}><Input value={draft.my_name ?? ''} onChange={(e) => set('my_name', e.target.value)} /></F>
<F label="My WWFF ref" span={2}><Input value={draft.my_wwff_ref ?? ''} onChange={(e) => set('my_wwff_ref', e.target.value)} className="font-mono uppercase" /></F>
<F label="My ARRL sect" span={2}><Input value={draft.my_arrl_sect ?? ''} onChange={(e) => set('my_arrl_sect', e.target.value)} /></F>
<F label="My SIG"><Input value={draft.my_sig ?? ''} onChange={(e) => set('my_sig', e.target.value)} /></F>
<F label="My SIG info" span={2}><Input value={draft.my_sig_info ?? ''} onChange={(e) => set('my_sig_info', e.target.value)} /></F>
<F label="My DARC DOK"><Input value={draft.my_darc_dok ?? ''} onChange={(e) => set('my_darc_dok', e.target.value)} /></F>
<F label="My VUCC grids" span={2}><Input value={draft.my_vucc_grids ?? ''} onChange={(e) => set('my_vucc_grids', e.target.value)} className="font-mono uppercase" /></F>
</div>
</div>
</TabsContent>
<TabsContent value="extras" className="mt-0">
<AdifExtrasEditor value={draft.extras} onChange={(next) => set('extras', next as any)} />
</TabsContent> </TabsContent>
</div> </div>
</Tabs> </Tabs>
+6
View File
@@ -44,6 +44,10 @@ export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<
case 'iota': payload.iota = ref; break; case 'iota': payload.iota = ref; break;
case 'sota_ref': payload.sota_ref = ref; break; case 'sota_ref': payload.sota_ref = ref; break;
case 'pota_ref': payload.pota_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': case 'wwff':
extras['WWFF_REF'] = ref; extras['WWFF_REF'] = ref;
extras['SIG'] = 'WWFF'; extras['SIG'] = 'WWFF';
@@ -73,6 +77,8 @@ export function awardRefValue(qso: any, code: string, field: string): string {
case 'iota': return (qso.iota ?? '').toUpperCase(); case 'iota': return (qso.iota ?? '').toUpperCase();
case 'sota_ref': return (qso.sota_ref ?? '').toUpperCase(); case 'sota_ref': return (qso.sota_ref ?? '').toUpperCase();
case 'pota_ref': return (qso.pota_ref ?? '').toUpperCase(); case 'pota_ref': return (qso.pota_ref ?? '').toUpperCase();
case 'state': return (qso.state ?? '').toUpperCase();
case 'cnty': return (qso.cnty ?? '').toUpperCase();
case 'wwff': { case 'wwff': {
const ex = qso.extras ?? {}; const ex = qso.extras ?? {};
if (ex['WWFF_REF']) return String(ex['WWFF_REF']).toUpperCase(); if (ex['WWFF_REF']) return String(ex['WWFF_REF']).toUpperCase();
+8 -6
View File
@@ -1,9 +1,9 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
import {adif} from '../models';
import {qso} from '../models'; import {qso} from '../models';
import {main} from '../models'; import {main} from '../models';
import {profile} from '../models'; import {profile} from '../models';
import {adif} from '../models';
import {award} from '../models'; import {award} from '../models';
import {awardref} from '../models'; import {awardref} from '../models';
import {cat} from '../models'; import {cat} from '../models';
@@ -15,6 +15,10 @@ import {operating} from '../models';
import {udp} from '../models'; import {udp} from '../models';
import {lookup} from '../models'; import {lookup} from '../models';
export function ADIFFields():Promise<Array<adif.FieldDef>>;
export function ADIFVersion():Promise<string>;
export function ActivateProfile(arg1:number):Promise<void>; export function ActivateProfile(arg1:number):Promise<void>;
export function AddQSO(arg1:qso.QSO):Promise<number>; export function AddQSO(arg1:qso.QSO):Promise<number>;
@@ -77,8 +81,6 @@ export function DeleteQSO(arg1:number):Promise<void>;
export function DeleteUDPIntegration(arg1:number):Promise<void>; export function DeleteUDPIntegration(arg1:number):Promise<void>;
export function DisablePortableMode():Promise<void>;
export function DisconnectAllClusters():Promise<void>; export function DisconnectAllClusters():Promise<void>;
export function DisconnectClusterServer(arg1:number):Promise<void>; export function DisconnectClusterServer(arg1:number):Promise<void>;
@@ -89,14 +91,14 @@ export function DownloadConfirmations(arg1:string,arg2:boolean):Promise<void>;
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>; export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
export function EnablePortableMode():Promise<void>;
export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>; export function ExportADIF(arg1:string,arg2:boolean):Promise<adif.ExportResult>;
export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise<adif.ExportResult>; export function ExportADIFFiltered(arg1:string,arg2:boolean,arg3:qso.QueryFilter):Promise<adif.ExportResult>;
export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):Promise<adif.ExportResult>; export function ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):Promise<adif.ExportResult>;
export function ExportAwards():Promise<string>;
export function FilterFields():Promise<Array<string>>; export function FilterFields():Promise<Array<string>>;
export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>; export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>;
@@ -175,7 +177,7 @@ export function ImportADIF(arg1:string,arg2:string,arg3:boolean):Promise<adif.Im
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>; export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
export function IsPortableMode():Promise<boolean>; export function ImportAwards():Promise<main.AwardImportResult>;
export function ListAudioInputDevices():Promise<Array<audio.Device>>; export function ListAudioInputDevices():Promise<Array<audio.Device>>;
+14 -10
View File
@@ -2,6 +2,14 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT // 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) { export function ActivateProfile(arg1) {
return window['go']['main']['App']['ActivateProfile'](arg1); return window['go']['main']['App']['ActivateProfile'](arg1);
} }
@@ -126,10 +134,6 @@ export function DeleteUDPIntegration(arg1) {
return window['go']['main']['App']['DeleteUDPIntegration'](arg1); return window['go']['main']['App']['DeleteUDPIntegration'](arg1);
} }
export function DisablePortableMode() {
return window['go']['main']['App']['DisablePortableMode']();
}
export function DisconnectAllClusters() { export function DisconnectAllClusters() {
return window['go']['main']['App']['DisconnectAllClusters'](); return window['go']['main']['App']['DisconnectAllClusters']();
} }
@@ -150,10 +154,6 @@ export function DuplicateProfile(arg1, arg2) {
return window['go']['main']['App']['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) { export function ExportADIF(arg1, arg2) {
return window['go']['main']['App']['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); return window['go']['main']['App']['ExportADIFSelected'](arg1, arg2, arg3);
} }
export function ExportAwards() {
return window['go']['main']['App']['ExportAwards']();
}
export function FilterFields() { export function FilterFields() {
return window['go']['main']['App']['FilterFields'](); return window['go']['main']['App']['FilterFields']();
} }
@@ -322,8 +326,8 @@ export function ImportAwardReferencesText(arg1, arg2) {
return window['go']['main']['App']['ImportAwardReferencesText'](arg1, arg2); return window['go']['main']['App']['ImportAwardReferencesText'](arg1, arg2);
} }
export function IsPortableMode() { export function ImportAwards() {
return window['go']['main']['App']['IsPortableMode'](); return window['go']['main']['App']['ImportAwards']();
} }
export function ListAudioInputDevices() { export function ListAudioInputDevices() {
+98
View File
@@ -16,6 +16,28 @@ export namespace adif {
this.size_kb = source["size_kb"]; 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 { export class ImportResult {
total: number; total: number;
imported: number; imported: number;
@@ -660,6 +682,20 @@ export namespace main {
this.mic_gain = source["mic_gain"]; 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 { export class AwardRefMeta {
code: string; code: string;
count: number; count: number;
@@ -1184,6 +1220,7 @@ export namespace main {
ok: boolean; ok: boolean;
err: string; err: string;
db_path: string; db_path: string;
migrated_from_app_data: boolean;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new StartupStatus(source); return new StartupStatus(source);
@@ -1194,6 +1231,7 @@ export namespace main {
this.ok = source["ok"]; this.ok = source["ok"];
this.err = source["err"]; this.err = source["err"];
this.db_path = source["db_path"]; this.db_path = source["db_path"];
this.migrated_from_app_data = source["migrated_from_app_data"];
} }
} }
export class StationInfoComputed { export class StationInfoComputed {
@@ -1696,6 +1734,36 @@ export namespace qso {
tx_pwr?: number; tx_pwr?: number;
comment?: string; comment?: string;
notes?: 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<string, string>; extras?: Record<string, string>;
// Go type: time // Go type: time
created_at: any; created_at: any;
@@ -1803,6 +1871,36 @@ export namespace qso {
this.tx_pwr = source["tx_pwr"]; this.tx_pwr = source["tx_pwr"];
this.comment = source["comment"]; this.comment = source["comment"];
this.notes = source["notes"]; 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.extras = source["extras"];
this.created_at = this.convertValues(source["created_at"], null); this.created_at = this.convertValues(source["created_at"], null);
this.updated_at = this.convertValues(source["updated_at"], null); this.updated_at = this.convertValues(source["updated_at"], null);
+38 -5
View File
@@ -92,7 +92,7 @@ func (e *Exporter) writeDoc(ctx context.Context, w io.Writer, iter iterator) (in
ver := strings.TrimSpace(e.AppVersion) ver := strings.TrimSpace(e.AppVersion)
now := time.Now().UTC().Format("20060102 150405") now := time.Now().UTC().Format("20060102 150405")
fmt.Fprintf(bw, "# ADIF export by %s %s — %s UTC\n", app, ver, now) fmt.Fprintf(bw, "# ADIF export by %s %s — %s UTC\n", app, ver, now)
fmt.Fprintf(bw, "<ADIF_VER:5>3.1.0 <PROGRAMID:%d>%s", len(app), app) fmt.Fprintf(bw, "<ADIF_VER:%d>%s <PROGRAMID:%d>%s", len(adifVersion), adifVersion, len(app), app)
if ver != "" { if ver != "" {
fmt.Fprintf(bw, " <PROGRAMVERSION:%d>%s", len(ver), ver) fmt.Fprintf(bw, " <PROGRAMVERSION:%d>%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, "COMMENT", q.Comment)
writeField(bw, "NOTES", q.Notes) 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) --- // --- Extras (unpromoted ADIF fields preserved verbatim) ---
// In standard mode we drop application-specific tags (APP_*) so the file // Standard mode emits ONLY valid ADIF-spec fields, so it drops APP_*
// stays portable to other loggers; in full mode they're kept for a // application-specific tags AND any non-standard / vendor tag — keeping
// lossless OpsLog round-trip. // the file strictly portable to other loggers. Full mode keeps every
// extra for a lossless OpsLog round-trip.
for k, v := range q.Extras { for k, v := range q.Extras {
tag := strings.ToUpper(k) tag := strings.ToUpper(k)
if !includeApp && strings.HasPrefix(tag, "APP_") { if !includeApp && (strings.HasPrefix(tag, "APP_") || !IsStandardField(tag)) {
continue continue
} }
writeField(bw, tag, v) writeField(bw, tag, v)
+272
View File
@@ -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
}
+48
View File
@@ -284,6 +284,12 @@ var adifPromoted = stringSet(
"my_street", "my_city", "my_postal_code", "my_rig", "my_antenna", "my_street", "my_city", "my_postal_code", "my_rig", "my_antenna",
// Misc // Misc
"tx_pwr", "comment", "notes", "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{} { func stringSet(items ...string) map[string]struct{} {
@@ -482,6 +488,48 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
q.Comment = rec["comment"] q.Comment = rec["comment"]
q.Notes = rec["notes"] 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). // Everything else lands in extras (uppercased ADIF names).
var extras map[string]string var extras map[string]string
for k, v := range rec { for k, v := range rec {
+121
View File
@@ -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("<EOH>\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()
}
+32
View File
@@ -210,8 +210,13 @@ type refList struct {
byCode map[string]RefMeta // uppercased code → metadata byCode map[string]RefMeta // uppercased code → metadata
codes []string // codes in input order (for stable unworked listing) codes []string // codes in input order (for stable unworked listing)
withPattern []string // codes whose reference declares a regex (usually none) 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 // 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 // predefined list, per-reference DXCC scoping, a per-reference pattern, and to
// label results. // label results.
@@ -243,6 +248,9 @@ func NewRefList(metas []RefMeta) refList {
m.Code = code m.Code = code
if _, dup := rl.byCode[code]; !dup { if _, dup := rl.byCode[code]; !dup {
rl.codes = append(rl.codes, code) 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 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) { for _, b := range sortedBands(bandWorked) {
r.Bands = append(r.Bands, BandCount{Band: b, Worked: bandWorked[b], Confirmed: bandConfirmed[b]}) 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 out[i] = r
} }
return out return out
@@ -428,12 +445,27 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
return nil return nil
} }
predefined := hasList && !d.Dynamic predefined := hasList && !d.Dynamic
byDesc := predefined && strings.EqualFold(strings.TrimSpace(d.MatchBy), "description")
var found []string var found []string
switch { switch {
case re != nil: case re != nil:
// Award-level regex: capture group 1 (or whole match) for each hit. // Award-level regex: capture group 1 (or whole match) for each hit.
found = regexTokens(re, raw) 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: case predefined && !d.ExactMatch:
// "Search reference inside the field": look up each token of the field in // "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 // the list — O(tokens), not O(all references) — plus test the few
+32
View File
@@ -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 { func refCodes(r Result) []string {
out := make([]string, 0, len(r.Refs)) out := make([]string, 0, len(r.Refs))
for _, rf := range r.Refs { for _, rf := range r.Refs {
@@ -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);
+110 -4
View File
@@ -156,8 +156,42 @@ type QSO struct {
Comment string `json:"comment,omitempty"` Comment string `json:"comment,omitempty"`
Notes string `json:"notes,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 // 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"` Extras map[string]string `json:"extras,omitempty"`
CreatedAt time.Time `json:"created_at"` 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, 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_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, 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` 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.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.MySOTARef, q.MyPOTARef, q.MyDXCC, q.MyCQZone, q.MyITUZone, q.MyLat, q.MyLon,
q.MyStreet, q.MyCity, q.MyPostalCode, q.MyRig, q.MyAntenna, 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 myRig, myAntenna sql.NullString
txp sql.NullFloat64 txp sql.NullFloat64
comment, notes sql.NullString 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 extrasJSON sql.NullString
createdStr, updatedStr string createdStr, updatedStr string
) )
@@ -1494,7 +1549,13 @@ func scanQSO(s scanner) (QSO, error) {
&stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA, &stCall, &op, &myGrid, &myGridExt, &myCountry, &myState, &myCnty, &myIOTA,
&mySOTA, &myPOTA, &myDXCC, &myCQZ, &myITUZ, &myLat, &myLon, &mySOTA, &myPOTA, &myDXCC, &myCQZ, &myITUZ, &myLat, &myLon,
&myStreet, &myCity, &myPostal, &myRig, &myAntenna, &myStreet, &myCity, &myPostal, &myRig, &myAntenna,
&txp, &comment, &notes, &extrasJSON, &createdStr, &updatedStr, &txp, &comment, &notes,
&sig, &sigInfo, &mySig, &mySigInfo, &wwffRef, &myWWFFRef,
&distance, &rxPwr, &aIndex, &kIndex, &sfi,
&skcc, &fists, &tenTen, &contactedOp, &eqCall, &pfx, &myName, &class,
&darcDOK, &myDarcDOK, &region, &silentKey, &swl, &qsoComplete, &qsoRandom,
&creditGranted, &creditSubmitted, &myARRLSect, &myVUCCGrids,
&extrasJSON, &createdStr, &updatedStr,
); err != nil { ); err != nil {
return QSO{}, fmt.Errorf("scan qso: %w", err) return QSO{}, fmt.Errorf("scan qso: %w", err)
} }
@@ -1645,6 +1706,51 @@ func scanQSO(s scanner) (QSO, error) {
} }
q.Comment = comment.String q.Comment = comment.String
q.Notes = notes.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) q.Extras = decodeExtras(extrasJSON.String)
return q, nil return q, nil
} }