up
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -2861,7 +2861,7 @@ export default function App() {
|
||||
<DialogHeader className="px-2">
|
||||
<DialogTitle>Export ADIF</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose which fields to include in the export.
|
||||
Choose which fields to include. OpsLog writes ADIF 3.1.7.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="px-2 py-1 space-y-2.5">
|
||||
@@ -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"
|
||||
>
|
||||
<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">
|
||||
Only standard ADIF-defined fields — portable to other loggers (Log4OM, N1MM, LoTW…).
|
||||
Application-specific <span className="font-mono">APP_*</span> tags are stripped.
|
||||
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> and any non-standard / vendor tags are stripped.
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
@@ -2881,10 +2881,10 @@ export default function App() {
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
Every field including OpsLog/application-specific <span className="font-mono">APP_*</span> tags —
|
||||
for a lossless backup you'll re-import into OpsLog.
|
||||
Every field including application-specific <span className="font-mono">APP_*</span> and vendor tags —
|
||||
a lossless backup you'll re-import into OpsLog.
|
||||
</div>
|
||||
</button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
<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 ? (
|
||||
<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">
|
||||
<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" />
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={save}><Save className="size-3.5 mr-1" /> Save</Button>
|
||||
|
||||
@@ -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) {
|
||||
<Loader2 className="size-3 animate-spin" />Searching…
|
||||
</div>
|
||||
)}
|
||||
{/* No callsign yet */}
|
||||
{!busy && !dxcc && q.length < 2 && (
|
||||
<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 && (
|
||||
{/* Too many auto-results → require typed search */}
|
||||
{!busy && q.length < 2 && tooManyAuto && (
|
||||
<div className="px-2 py-1.5 text-[11px] text-muted-foreground leading-snug">
|
||||
Type 2+ chars to search
|
||||
</div>
|
||||
)}
|
||||
{/* 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 && (
|
||||
<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>
|
||||
)}
|
||||
{/* Typed search, no results */}
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
|
||||
{/* Band breakdown */}
|
||||
{current.bands.length > 0 && (
|
||||
{(current.bands ?? []).length > 0 && (
|
||||
<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="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">
|
||||
<span className="font-mono font-semibold">{b.band}</span>{' '}
|
||||
<span className="text-emerald-600 font-mono">{b.confirmed}</span>
|
||||
|
||||
@@ -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, 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 {
|
||||
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<string, string> = {};
|
||||
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 = [] }:
|
||||
<TabsTrigger value="contest">Contest</TabsTrigger>
|
||||
<TabsTrigger value="sat">Sat / Prop</TabsTrigger>
|
||||
<TabsTrigger value="mystation">My Station</TabsTrigger>
|
||||
<TabsTrigger value="moreadif">More ADIF</TabsTrigger>
|
||||
<TabsTrigger value="extras">
|
||||
Extras
|
||||
ADIF fields
|
||||
{extrasCount > 0 && (
|
||||
<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>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="extras" className="mt-0 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
ADIF fields not promoted to first-class columns. One per line:{' '}
|
||||
<code className="bg-muted px-1 py-0.5 rounded font-mono">FIELD_NAME = value</code>
|
||||
</p>
|
||||
<Textarea rows={14} className="font-mono text-xs" value={extrasText} onChange={(e) => setExtrasText(e.target.value)} />
|
||||
<TabsContent value="moreadif" className="mt-0 space-y-4">
|
||||
{/* Special activity (POTA/SOTA/WWFF/SIG) */}
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground uppercase tracking-wider mb-2">Special activity</p>
|
||||
<div className="grid grid-cols-6 gap-3">
|
||||
<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 & 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 & 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 & 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>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
@@ -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();
|
||||
|
||||
Vendored
+8
-6
@@ -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<Array<adif.FieldDef>>;
|
||||
|
||||
export function ADIFVersion():Promise<string>;
|
||||
|
||||
export function ActivateProfile(arg1:number):Promise<void>;
|
||||
|
||||
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 DisablePortableMode():Promise<void>;
|
||||
|
||||
export function DisconnectAllClusters():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 EnablePortableMode():Promise<void>;
|
||||
|
||||
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 ExportADIFSelected(arg1:string,arg2:boolean,arg3:Array<number>):Promise<adif.ExportResult>;
|
||||
|
||||
export function ExportAwards():Promise<string>;
|
||||
|
||||
export function FilterFields():Promise<Array<string>>;
|
||||
|
||||
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 IsPortableMode():Promise<boolean>;
|
||||
export function ImportAwards():Promise<main.AwardImportResult>;
|
||||
|
||||
export function ListAudioInputDevices():Promise<Array<audio.Device>>;
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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<string, string>;
|
||||
// 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);
|
||||
|
||||
+38
-5
@@ -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, "<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 != "" {
|
||||
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, "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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user