Compare commits

..

13 Commits

Author SHA1 Message Date
rouggy 17f7a00bd7 up 2026-06-06 14:16:30 +02:00
rouggy f91f9ff3b8 up 2026-06-06 11:59:32 +02:00
rouggy 176cc0e62b pota 2026-06-06 01:43:27 +02:00
rouggy b4e104f5a2 up 2026-06-06 01:21:24 +02:00
rouggy 922a185208 up 2026-06-06 00:02:56 +02:00
rouggy 51d3a734e8 award 2026-06-05 22:35:28 +02:00
rouggy 88623f55df awards 2026-06-05 17:22:38 +02:00
rouggy cf9dbf26f3 map 2026-06-05 02:55:54 +02:00
rouggy 95fdc1ccd1 aduio mail 2026-06-05 02:29:49 +02:00
rouggy a2a29c66d2 feat: added record qso dvk 2026-06-04 00:46:35 +02:00
rouggy 1a425a1b0d bug 2026-06-03 21:53:31 +02:00
rouggy 2b4326b553 feat: Winkeyer 2026-06-02 01:17:26 +02:00
rouggy 2eb77370e4 up 2026-05-30 01:56:57 +02:00
72 changed files with 15516 additions and 1229 deletions
+3046 -59
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -23,6 +23,7 @@
"ag-grid-react": "^35.3.0", "ag-grid-react": "^35.3.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"leaflet": "^1.9.4",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
@@ -30,6 +31,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"@types/leaflet": "^1.9.21",
"@types/node": "^25.9.1", "@types/node": "^25.9.1",
"@types/react": "^18.0.17", "@types/react": "^18.0.17",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.0.6",
@@ -2584,6 +2586,23 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "25.9.1", "version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
@@ -2995,6 +3014,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+2
View File
@@ -24,6 +24,7 @@
"ag-grid-react": "^35.3.0", "ag-grid-react": "^35.3.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"leaflet": "^1.9.4",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
@@ -31,6 +32,7 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"@types/leaflet": "^1.9.21",
"@types/node": "^25.9.1", "@types/node": "^25.9.1",
"@types/react": "^18.0.17", "@types/react": "^18.0.17",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.0.6",
+1 -1
View File
@@ -1 +1 @@
687705a933fcf09f20bdb5083955a417 c98874941451e4e6ffa48f22c1d764e7
+1080 -342
View File
File diff suppressed because it is too large Load Diff
@@ -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>
);
}
+544
View File
@@ -0,0 +1,544 @@
import { useEffect, useMemo, useState } from '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';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Textarea } from '@/components/ui/textarea';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Combobox } from '@/components/ui/combobox';
import { cn } from '@/lib/utils';
import {
GetAwardDefs, SaveAwardDefs, ResetAwardDefs, AwardFields,
GetAwardReferenceMeta, UpdateAwardReferenceList,
ListAwardReferences, SearchAwardReferences, SaveAwardReference, DeleteAwardReference,
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
// switches to search-only (mirrors Log4OM's "Too many items" behaviour).
const REF_LIST_CAP = 1000;
type RefMeta = { code: string; count: number; updated_at: string; can_update: boolean };
export type AwardDef = {
code: string; name: string; description?: string; valid?: boolean; protected?: boolean;
url?: string; download_url?: string; ref_url?: string; valid_from?: string; valid_to?: string; alias?: string;
type?: string; field: string; match_by?: string; exact_match?: boolean; pattern: string;
leading_str?: string; trailing_str?: string; multi?: boolean; dynamic?: boolean; add_prefixes?: string[];
dxcc_filter: number[] | null; valid_bands?: string[]; valid_modes?: string[]; emission?: string[];
confirm: string[] | null; validate?: string[] | null; grant_codes?: string; export_credit_granted?: boolean;
total: number; builtin?: boolean;
};
type AwardRef = {
code: string; name: string; dxcc: number; group: string; subgrp: string;
dxcc_list?: number[]; pattern?: string; valid: boolean; valid_from?: string; valid_to?: string;
score?: number; bonus?: number; gridsquare?: string; alias?: string;
};
type Preset = { key: string; name: string; field: string; dxcc: number; refs: AwardRef[] };
const AWARD_TYPES = ['QSOFIELDS', 'REFERENCE', 'DXCC', 'GRID'];
const CONFIRM_SRC = [
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
];
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','23cm','13cm'];
const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE'];
const EMISSIONS = ['CW', 'PHONE', 'DIGITAL'];
const emptyAward = (): AwardDef => ({
code: 'NEW', name: 'New award', description: '', valid: true,
type: 'QSOFIELDS', field: 'note', match_by: 'code', exact_match: true, pattern: '',
dxcc_filter: null, confirm: ['lotw', 'qsl'], validate: ['lotw', 'qsl'], total: 0,
});
interface Props { open: boolean; onClose: () => void; onSaved: () => void; }
// Small reusable multi-toggle chip group.
function Chips({ all, value, onToggle }: { all: string[]; value: string[]; onToggle: (v: string) => void }) {
return (
<div className="flex flex-wrap gap-1">
{all.map((v) => {
const on = value.includes(v);
return (
<button key={v} type="button" onClick={() => onToggle(v)}
className={cn('px-2 py-0.5 rounded text-[11px] border', on
? 'bg-primary text-primary-foreground border-primary'
: 'bg-background border-border text-muted-foreground hover:bg-accent')}>
{v}
</button>
);
})}
</div>
);
}
function Field2({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[120px_1fr] items-center gap-2">
<Label className="text-xs text-muted-foreground">{label}</Label>
{children}
</div>
);
}
// DxccFilter — pick the entities an award is scoped to by country name (like the
// QSO editor), resolving each to its ADIF DXCC number. Stored as number[].
function DxccFilter({ value, onChange, countries }: { value: number[]; onChange: (v: number[]) => void; countries: string[] }) {
const [names, setNames] = useState<Record<number, string>>({});
useEffect(() => {
let live = true;
(async () => {
const miss = value.filter((n) => names[n] === undefined);
if (miss.length === 0) return;
const got: Record<number, string> = {};
for (const n of miss) { try { got[n] = await DXCCName(n); } catch { got[n] = ''; } }
if (live) setNames((m) => ({ ...m, ...got }));
})();
return () => { live = false; };
}, [value]); // eslint-disable-line react-hooks/exhaustive-deps
async function addCountry(name: string) {
const n = await DXCCForCountry(name);
if (n && n > 0 && !value.includes(n)) {
setNames((m) => ({ ...m, [n]: name }));
onChange([...value, n]);
}
}
return (
<div className="space-y-1.5">
<Combobox value="" options={countries} placeholder="Add country…" onChange={addCountry} className="h-8 w-full" />
{value.length > 0 && (
<div className="flex flex-wrap gap-1">
{value.map((n) => (
<span key={n} className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[11px] bg-accent border border-border">
<span className="font-mono">#{n}</span>
<span className="text-muted-foreground">{names[n] || '…'}</span>
<button className="hover:text-destructive" onClick={() => onChange(value.filter((x) => x !== n))}>×</button>
</span>
))}
</div>
)}
</div>
);
}
export function AwardEditor({ open, onClose, onSaved }: Props) {
const [defs, setDefs] = useState<AwardDef[]>([]);
const [fields, setFields] = useState<string[]>([]);
const [meta, setMeta] = useState<Record<string, RefMeta>>({});
const [presets, setPresets] = useState<Preset[]>([]);
const [countries, setCountries] = useState<string[]>([]);
const [sel, setSel] = useState(0);
const [search, setSearch] = useState('');
const [updating, setUpdating] = useState<string | null>(null);
const [err, setErr] = useState('');
const loadMeta = () => GetAwardReferenceMeta()
.then((m) => setMeta(Object.fromEntries(((m ?? []) as RefMeta[]).map((x) => [x.code.toUpperCase(), x]))))
.catch(() => {});
useEffect(() => {
if (!open) return;
setErr('');
Promise.all([GetAwardDefs(), AwardFields(), GetAwardPresets(), ListCountries()])
.then(([d, f, p, c]) => { setDefs((d ?? []) as any); setFields((f ?? []) as any); setPresets((p ?? []) as any); setCountries((c ?? []) as any); })
.catch((e) => setErr(String(e?.message ?? e)));
loadMeta();
}, [open]);
const cur = defs[sel];
const patch = (p: Partial<AwardDef>) => setDefs((ds) => ds.map((d, j) => (j === sel ? { ...d, ...p } : d)));
const toggleIn = (key: keyof AwardDef, v: string) => {
const arr = ((cur?.[key] as string[]) ?? []);
patch({ [key]: arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v] } as any);
};
function addAward() {
setDefs((ds) => [...ds, emptyAward()]);
setSel(defs.length);
}
function removeAward(i: number) {
setDefs((ds) => ds.filter((_, j) => j !== i));
setSel((s) => Math.max(0, s >= i ? s - 1 : s));
}
async function save() {
setErr('');
try {
const clean = defs.filter((d) => d.code.trim())
.map((d) => ({ ...d, code: d.code.trim().toUpperCase(), confirm: d.confirm ?? [], validate: d.validate ?? [] }));
await SaveAwardDefs(clean as any);
onSaved();
onClose();
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
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(); }
catch (e: any) { setErr(`${code}: ${String(e?.message ?? e)}`); }
finally { setUpdating(null); }
}
const filtered = useMemo(() => {
const q = search.trim().toUpperCase();
return defs.map((d, i) => ({ d, i })).filter(({ d }) => !q || d.code.toUpperCase().includes(q) || d.name.toUpperCase().includes(q));
}, [defs, search]);
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<DialogContent className="max-w-5xl max-h-[92vh] grid grid-rows-[auto_1fr_auto] gap-0 p-0">
<DialogHeader className="px-5 py-3 border-b">
<DialogTitle>Award management</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-[220px_1fr] min-h-0 overflow-hidden">
{/* Left: award list */}
<div className="border-r flex flex-col min-h-0">
<div className="p-2 border-b">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
<Input className="h-7 pl-7 text-xs" placeholder="Search awards…" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
</div>
<div className="flex-1 overflow-auto">
{filtered.map(({ d, i }) => (
<button key={i} onClick={() => setSel(i)}
className={cn('flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs border-b border-border/30',
i === sel ? 'bg-accent' : 'hover:bg-accent/50')}>
<span className={cn('size-1.5 rounded-full shrink-0', d.valid === false ? 'bg-muted-foreground/40' : 'bg-emerald-500')} />
<span className="font-mono font-semibold shrink-0">{d.code}</span>
<span className="text-muted-foreground truncate">{d.name}</span>
</button>
))}
</div>
<Button variant="ghost" size="sm" className="m-2 h-7 justify-start" onClick={addAward}>
<Plus className="size-3.5 mr-1" /> New award
</Button>
</div>
{/* 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 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>
) : (
<Tabs defaultValue="info" className="flex flex-col min-h-0 overflow-hidden">
<TabsList className="px-3 justify-start">
<TabsTrigger value="info">Award info</TabsTrigger>
<TabsTrigger value="type">Award type</TabsTrigger>
<TabsTrigger value="conf">Confirmation</TabsTrigger>
<TabsTrigger value="refs">References</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-auto p-4">
{/* ── Award info ── */}
<TabsContent value="info" className="mt-0 space-y-2.5">
<div className="flex items-center gap-2">
<Input className="h-8 w-28 font-mono font-semibold" value={cur.code} onChange={(e) => patch({ code: e.target.value })} placeholder="CODE" />
<Input className="h-8 flex-1" value={cur.name} onChange={(e) => patch({ name: e.target.value })} placeholder="Award name" />
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={cur.valid !== false} onCheckedChange={(c) => patch({ valid: !!c })} /> Valid</label>
<button className="text-muted-foreground hover:text-destructive" title="Delete award" onClick={() => removeAward(sel)}><Trash2 className="size-4" /></button>
</div>
<Field2 label="Description"><Input className="h-8" value={cur.description ?? ''} onChange={(e) => patch({ description: e.target.value })} /></Field2>
<Field2 label="Award URL"><Input className="h-8" value={cur.url ?? ''} onChange={(e) => patch({ url: e.target.value })} /></Field2>
<Field2 label="Reference URL"><Input className="h-8" value={cur.ref_url ?? ''} onChange={(e) => patch({ ref_url: e.target.value })} placeholder="https://…/<REF>" /></Field2>
<div className="grid grid-cols-2 gap-3">
<Field2 label="Valid from"><Input type="date" className="h-8" value={cur.valid_from ?? ''} onChange={(e) => patch({ valid_from: e.target.value })} /></Field2>
<Field2 label="Valid to"><Input type="date" className="h-8" value={cur.valid_to ?? ''} onChange={(e) => patch({ valid_to: e.target.value })} /></Field2>
</div>
<div className="grid grid-cols-[120px_1fr] gap-2">
<Label className="text-xs text-muted-foreground pt-1.5">DXCC filter</Label>
<DxccFilter value={cur.dxcc_filter ?? []} onChange={(v) => patch({ dxcc_filter: v })} countries={countries} />
</div>
<div className="space-y-1"><Label className="text-xs text-muted-foreground">Valid bands (empty = all)</Label><Chips all={BANDS} value={cur.valid_bands ?? []} onToggle={(v) => toggleIn('valid_bands', v)} /></div>
<div className="space-y-1"><Label className="text-xs text-muted-foreground">Emission (empty = all)</Label><Chips all={EMISSIONS} value={cur.emission ?? []} onToggle={(v) => toggleIn('emission', v)} /></div>
<div className="space-y-1"><Label className="text-xs text-muted-foreground">Valid modes (empty = all)</Label><Chips all={MODES} value={cur.valid_modes ?? []} onToggle={(v) => toggleIn('valid_modes', v)} /></div>
</TabsContent>
{/* ── Award type ── */}
<TabsContent value="type" className="mt-0 space-y-2.5">
<Field2 label="Award type">
<Select value={cur.type || 'QSOFIELDS'} onValueChange={(v) => patch({ type: v })}>
<SelectTrigger className="h-8 text-xs w-48"><SelectValue /></SelectTrigger>
<SelectContent>{AWARD_TYPES.map((t) => <SelectItem key={t} value={t}>{t}</SelectItem>)}</SelectContent>
</Select>
</Field2>
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.multi} onCheckedChange={(c) => patch({ multi: !!c })} /> Allow multiple references on a single QSO</label>
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.dynamic} onCheckedChange={(c) => patch({ dynamic: !!c })} /> Dynamic references (not predefined any value counts, like POTA)</label>
<div className="border-t pt-2.5 mt-1 space-y-2.5">
<p className="text-[11px] text-muted-foreground">QSO parameters (used by QSOFIELDS / REFERENCE types)</p>
<Field2 label="Search in field">
<Select value={cur.field} onValueChange={(v) => patch({ field: v })}>
<SelectTrigger className="h-8 text-xs w-56"><SelectValue /></SelectTrigger>
<SelectContent className="max-h-72">{fields.map((f) => <SelectItem key={f} value={f}>{f}</SelectItem>)}</SelectContent>
</Select>
</Field2>
<Field2 label="Match by">
<div className="flex items-center gap-3 text-xs">
{['code', 'description', 'pattern'].map((m) => (
<label key={m} className="flex items-center gap-1.5 cursor-pointer">
<input type="radio" name="matchby" checked={(cur.match_by || 'code') === m} onChange={() => patch({ match_by: m })} className="accent-primary" /> {m}
</label>
))}
</div>
</Field2>
<label className="flex items-center gap-2 text-xs cursor-pointer pl-[128px]"><Checkbox checked={!!cur.exact_match} onCheckedChange={(c) => patch({ exact_match: !!c })} /> Exact match (else search reference inside the field)</label>
<Field2 label="Pattern (regex)"><Input className="h-8 font-mono text-xs" value={cur.pattern} onChange={(e) => patch({ pattern: e.target.value })} placeholder="group 1 = reference (for match-by pattern / dynamic)" /></Field2>
<div className="grid grid-cols-2 gap-3">
<Field2 label="Leading string"><Input className="h-8 font-mono text-xs" value={cur.leading_str ?? ''} onChange={(e) => patch({ leading_str: e.target.value })} /></Field2>
<Field2 label="Trailing string"><Input className="h-8 font-mono text-xs" value={cur.trailing_str ?? ''} onChange={(e) => patch({ trailing_str: e.target.value })} /></Field2>
</div>
</div>
</TabsContent>
{/* ── Confirmation ── */}
<TabsContent value="conf" className="mt-0 space-y-4">
<div className="grid grid-cols-2 gap-6">
<div className="space-y-1.5">
<Label className="text-xs font-semibold">Confirmation (worked confirmed)</Label>
{CONFIRM_SRC.map((c) => (
<label key={c.id} className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={(cur.confirm ?? []).includes(c.id)} onCheckedChange={() => toggleIn('confirm', c.id)} /> {c.label}</label>
))}
</div>
<div className="space-y-1.5">
<Label className="text-xs font-semibold">Validation (confirmed validated)</Label>
{CONFIRM_SRC.map((c) => (
<label key={c.id} className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={(cur.validate ?? []).includes(c.id)} onCheckedChange={() => toggleIn('validate', c.id)} /> {c.label}</label>
))}
</div>
</div>
<Field2 label="Grant codes"><Input className="h-8" value={cur.grant_codes ?? ''} onChange={(e) => patch({ grant_codes: e.target.value })} /></Field2>
<label className="flex items-center gap-2 text-xs cursor-pointer"><Checkbox checked={!!cur.export_credit_granted} onCheckedChange={(c) => patch({ export_credit_granted: !!c })} /> Export award in ADIF credit_granted field</label>
</TabsContent>
{/* ── References ── */}
<TabsContent value="refs" className="mt-0">
<ReferencesPanel
code={cur.code.trim().toUpperCase()} presets={presets} meta={meta[cur.code.toUpperCase()]}
onUpdateOnline={() => updateList(cur.code.toUpperCase())} updating={updating === cur.code.toUpperCase()}
onChanged={loadMeta} setErr={setErr}
/>
</TabsContent>
</div>
</Tabs>
)}
</div>
</div>
<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>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ReferencesPanel — manage the reference list of one award: search/list on the
// left, a per-reference editor on the right, plus bulk paste/CSV, presets and
// the online updater (POTA/SOTA/WWFF).
function ReferencesPanel({ code, presets, meta, onUpdateOnline, updating, onChanged, setErr }: {
code: string; presets: Preset[]; meta?: RefMeta;
onUpdateOnline: () => void; updating: boolean; onChanged: () => void; setErr: (s: string) => void;
}) {
const [refs, setRefs] = useState<AwardRef[]>([]);
const [q, setQ] = useState('');
const [selCode, setSelCode] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [bulk, setBulk] = useState('');
const [showBulk, setShowBulk] = useState(false);
const [hasBuiltin, setHasBuiltin] = useState(false);
const total = meta?.count ?? 0;
const large = total > REF_LIST_CAP;
// Small lists are loaded whole and filtered client-side; large lists (POTA,
// 85k parks) are search-only to stay responsive.
const load = () => {
if (!code) return;
if (total > REF_LIST_CAP) { setRefs([]); return; }
setBusy(true);
ListAwardReferences(code).then((r) => setRefs((r ?? []) as any)).catch(() => {}).finally(() => setBusy(false));
};
useEffect(load, [code, total]);
useEffect(() => { HasBuiltinReferences(code).then(setHasBuiltin).catch(() => setHasBuiltin(false)); }, [code]);
// Server-side search for large lists (debounced, min 2 chars).
useEffect(() => {
if (!large) return;
const s = q.trim();
if (s.length < 2) { setRefs([]); return; }
const t = window.setTimeout(() => {
setBusy(true);
SearchAwardReferences(code, s, 0, 200).then((r) => setRefs((r ?? []) as any)).catch(() => setRefs([])).finally(() => setBusy(false));
}, 200);
return () => window.clearTimeout(t);
}, [code, q, large]);
async function populateBuiltin() {
try { const n = await PopulateBuiltinReferences(code); load(); onChanged(); setErr(`Populated ${n} built-in references.`); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
const sel = refs.find((r) => r.code === selCode) || null;
// Large lists are already filtered by the server; small lists filter locally.
const filtered = useMemo(() => {
if (large) return refs;
const s = q.trim().toUpperCase();
return refs.filter((r) => !s || r.code.toUpperCase().includes(s) || (r.name ?? '').toUpperCase().includes(s));
}, [refs, q, large]);
const patchSel = (p: Partial<AwardRef>) => setRefs((rs) => rs.map((r) => (r.code === selCode ? { ...r, ...p } : r)));
async function saveRef(r: AwardRef) {
try { await SaveAwardReference(code, r as any); load(); onChanged(); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function addRef() {
const c = prompt('New reference code:')?.trim().toUpperCase();
if (!c) return;
const r: AwardRef = { code: c, name: '', dxcc: 0, group: '', subgrp: '', valid: true };
await saveRef(r); setSelCode(c);
}
async function delRef(c: string) {
try { await DeleteAwardReference(code, c); setSelCode(null); load(); onChanged(); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function applyPreset(key: string) {
if (!key) return;
try { await ApplyAwardPreset(code, key); load(); onChanged(); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function importBulk() {
try { const n = await ImportAwardReferencesText(code, bulk); setBulk(''); setShowBulk(false); load(); onChanged(); setErr(`Imported ${n} references.`); }
catch (e: any) { setErr(String(e?.message ?? e)); }
}
return (
<div className="space-y-2">
{/* Toolbar */}
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">Reference count: <span className="font-mono text-foreground">{total.toLocaleString()}</span></span>
<div className="flex-1" />
<Select value="" onValueChange={applyPreset}>
<SelectTrigger className="h-7 w-44 text-xs"><SelectValue placeholder="Apply preset…" /></SelectTrigger>
<SelectContent>{presets.map((p) => <SelectItem key={p.key} value={p.key}>{p.name}</SelectItem>)}</SelectContent>
</Select>
<Button variant="outline" size="sm" className="h-7" onClick={() => setShowBulk((s) => !s)}>Paste / CSV</Button>
{hasBuiltin && (
<Button variant="outline" size="sm" className="h-7" onClick={populateBuiltin} title="Replace with the shipped built-in list (DXCC entities, French departments, …)">
Populate built-in
</Button>
)}
{meta?.can_update && (
<Button variant="outline" size="sm" className="h-7" disabled={updating} onClick={onUpdateOnline}>
{updating ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <Download className="size-3.5 mr-1" />} Update online
</Button>
)}
<Button variant="outline" size="sm" className="h-7" onClick={addRef}><Plus className="size-3.5 mr-1" /> Add</Button>
</div>
{showBulk && (
<div className="space-y-1.5 border rounded p-2 bg-muted/30">
<p className="text-[11px] text-muted-foreground">One reference per line: <span className="font-mono">CODE,Description,Group,Subgroup,DXCC</span> (comma/semicolon/tab). Replaces the whole list.</p>
<Textarea rows={6} className="font-mono text-xs" value={bulk} onChange={(e) => setBulk(e.target.value)} placeholder={'ON,Ontario,,,1\nQC,Quebec,,,1'} />
<div className="flex justify-end gap-2"><Button variant="ghost" size="sm" className="h-7" onClick={() => setShowBulk(false)}>Cancel</Button><Button size="sm" className="h-7" onClick={importBulk}>Import</Button></div>
</div>
)}
<div className="grid grid-cols-[200px_1fr] gap-3">
{/* List */}
<div className="border rounded flex flex-col min-h-0 max-h-[46vh]">
<div className="p-1.5 border-b"><Input className="h-7 text-xs" placeholder="Search…" value={q} onChange={(e) => setQ(e.target.value)} /></div>
<div className="flex-1 overflow-auto">
{busy && <div className="px-2 py-1.5 text-[11px] text-muted-foreground flex items-center gap-1.5"><Loader2 className="size-3 animate-spin" /> Searching</div>}
{!busy && large && q.trim().length < 2 && (
<div className="m-2 rounded border border-amber-300 bg-amber-50 px-2 py-1.5 text-[11px] text-amber-800">
Too many items ({total.toLocaleString()}). Please refine search (type 2+ characters).
</div>
)}
{!busy && filtered.length === 0 && !(large && q.trim().length < 2) && <div className="px-2 py-1.5 text-[11px] text-muted-foreground">No references.</div>}
{filtered.map((r) => (
<button key={r.code} onClick={() => setSelCode(r.code)}
className={cn('flex w-full items-baseline gap-2 px-2 py-1 text-left text-xs border-b border-border/30', r.code === selCode ? 'bg-accent' : 'hover:bg-accent/50', !r.valid && 'opacity-50')}>
<span className="font-mono font-semibold shrink-0">{r.code}</span>
<span className="text-muted-foreground truncate">{r.name}</span>
</button>
))}
</div>
</div>
{/* Per-reference editor */}
<div className="border rounded p-3">
{!sel ? (
<div className="grid place-items-center h-full text-xs text-muted-foreground">Select a reference, or Add / import a list.</div>
) : (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Input className="h-8 w-28 font-mono font-semibold" value={sel.code} readOnly />
<label className="flex items-center gap-1.5 text-xs cursor-pointer"><Checkbox checked={sel.valid} onCheckedChange={(c) => patchSel({ valid: !!c })} /> Valid</label>
<div className="flex-1" />
<button className="text-muted-foreground hover:text-destructive" onClick={() => delRef(sel.code)}><Trash2 className="size-4" /></button>
</div>
<Field2 label="Description"><Input className="h-8" value={sel.name ?? ''} onChange={(e) => patchSel({ name: e.target.value })} /></Field2>
<div className="grid grid-cols-2 gap-3">
<Field2 label="Group"><Input className="h-8" value={sel.group ?? ''} onChange={(e) => patchSel({ group: e.target.value })} /></Field2>
<Field2 label="Subgroup"><Input className="h-8" value={sel.subgrp ?? ''} onChange={(e) => patchSel({ subgrp: e.target.value })} /></Field2>
</div>
<Field2 label="DXCC"><Input type="number" className="h-8 w-32 font-mono" value={sel.dxcc || ''} onChange={(e) => patchSel({ dxcc: parseInt(e.target.value, 10) || 0 })} /></Field2>
<Field2 label="Pattern (regex)"><Input className="h-8 font-mono text-xs" value={sel.pattern ?? ''} onChange={(e) => patchSel({ pattern: e.target.value })} placeholder="optional per-reference regex" /></Field2>
<div className="grid grid-cols-3 gap-3">
<Field2 label="Score"><Input type="number" className="h-8 font-mono" value={sel.score ?? 0} onChange={(e) => patchSel({ score: parseInt(e.target.value, 10) || 0 })} /></Field2>
<Field2 label="Bonus"><Input type="number" className="h-8 font-mono" value={sel.bonus ?? 0} onChange={(e) => patchSel({ bonus: parseInt(e.target.value, 10) || 0 })} /></Field2>
<Field2 label="Grid"><Input className="h-8 font-mono" value={sel.gridsquare ?? ''} onChange={(e) => patchSel({ gridsquare: e.target.value })} /></Field2>
</div>
<div className="flex justify-end pt-1"><Button size="sm" className="h-7" onClick={() => sel && saveRef(sel)}><Save className="size-3.5 mr-1" /> Save reference</Button></div>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,85 @@
import { useEffect, useRef, useState } from 'react';
import { X, Loader2 } from 'lucide-react';
import { SearchAwardReferences } from '../../wailsjs/go/main/App';
type Ref = { code: string; name: string; dxcc: number; group: string; subgrp: string };
interface Props {
code: string; // award code, e.g. "POTA"
label: string; // display label
dxcc?: number; // contacted-station DXCC; used when "this country" is on
countryOnly: boolean;
value: string; // currently assigned reference
onChange: (ref: string) => void;
}
// AwardRefPicker — type-ahead search over an award's reference list (POTA parks,
// SOTA summits, …), optionally restricted to the contacted DXCC. Picking a
// result assigns it to the QSO.
export function AwardRefPicker({ code, label, dxcc, countryOnly, value, onChange }: Props) {
const [q, setQ] = useState('');
const [results, setResults] = useState<Ref[]>([]);
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const boxRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const t = window.setTimeout(async () => {
setBusy(true);
try {
const r = await SearchAwardReferences(code, q, countryOnly ? (dxcc ?? 0) : 0, 30);
setResults((r ?? []) as any);
} catch { setResults([]); }
finally { setBusy(false); }
}, 200);
return () => window.clearTimeout(t);
}, [q, open, code, dxcc, countryOnly]);
useEffect(() => {
if (!open) return;
const close = (e: MouseEvent) => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); };
window.addEventListener('mousedown', close);
return () => window.removeEventListener('mousedown', close);
}, [open]);
return (
<div className="grid grid-cols-[60px_1fr] items-center gap-2">
<label className="text-xs font-semibold text-muted-foreground">{label}</label>
<div className="relative" ref={boxRef}>
{value ? (
<div className="flex items-center gap-1.5 h-7 px-2 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-800 text-xs">
<span className="font-mono font-semibold">{value}</span>
<button className="ml-auto hover:text-emerald-950" onClick={() => onChange('')} title="Remove"><X className="size-3.5" /></button>
</div>
) : (
<input
className="h-7 w-full rounded-md border border-input bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
placeholder={`Search ${label}`}
value={q}
onFocus={() => setOpen(true)}
onChange={(e) => { setQ(e.target.value); setOpen(true); }}
/>
)}
{open && !value && (
<div className="absolute z-50 mt-1 w-[320px] max-h-64 overflow-auto rounded-md border border-border bg-popover shadow-lg text-xs">
{busy && <div className="px-3 py-2 text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> Searching</div>}
{!busy && results.length === 0 && (
<div className="px-3 py-2 text-muted-foreground">No match{countryOnly && dxcc ? ' for this DXCC' : ''}.</div>
)}
{results.map((r) => (
<button
key={r.code}
className="flex w-full items-baseline gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onChange(r.code); setOpen(false); setQ(''); }}
>
<span className="font-mono font-semibold shrink-0">{r.code}</span>
<span className="text-muted-foreground truncate">{r.name}</span>
</button>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,272 @@
import { useEffect, useMemo, useState } from 'react';
import { X, Plus, Loader2 } from 'lucide-react';
import { SearchAwardReferences, GetAwardDefs, GetAwardReferenceMeta } from '../../wailsjs/go/main/App';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
type AwardRef = { code: string; name: string; dxcc: number; group: string; subgrp: string };
type AwardDef = { code: string; name: string; field?: string; dxcc_filter?: number[] | null; dynamic?: boolean };
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.
// 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;
interface Props {
dxcc?: number;
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064"
value: string;
onChange: (v: string) => void;
}
export function AwardRefSelector({ dxcc, value, onChange }: Props) {
const [defs, setDefs] = useState<AwardDef[]>([]);
const [metas, setMetas] = useState<Record<string, Meta>>({});
const [awardCode, setAwardCode] = useState('POTA');
const [q, setQ] = useState('');
// autoResults: loaded immediately when award/dxcc changes (empty query, DXCC-filtered).
// Shown when q is short and count ≤ AUTO_SHOW_MAX (e.g. 5 IOTA refs for France).
const [autoResults, setAutoResults] = useState<AwardRef[]>([]);
// searchResults: loaded when user types 2+ chars.
const [searchResults, setSearchResults] = useState<AwardRef[]>([]);
const [busy, setBusy] = useState(false);
const [selectedRef, setSelectedRef] = useState<AwardRef | null>(null);
const [selectedEntry, setSelectedEntry] = useState<string | null>(null);
const entries = value ? value.split(';').filter(Boolean) : [];
useEffect(() => {
Promise.all([GetAwardDefs(), GetAwardReferenceMeta()])
.then(([d, m]) => {
setDefs((d ?? []) as any);
setMetas(Object.fromEntries(((m ?? []) as Meta[]).map((x) => [String(x.code).toUpperCase(), x])));
})
.catch(() => {});
}, []);
// An award is offered when its DXCC scope matches the contacted entity (or it
// has no scope) AND it has references to pick from (a loaded list, an online
// list, or dynamic references like POTA). This is why DDFM (scope 227) shows
// for a French call but not for others.
const awards = useMemo(() => {
return defs.filter((d) => {
// Computed awards (field = dxcc/state/cqz/…) are derived automatically.
if (COMPUTED_FIELDS.has(String(d.field ?? '').toLowerCase())) return false;
const m = metas[String(d.code).toUpperCase()];
const hasRefs = (m?.count ?? 0) > 0 || !!m?.can_update || !!d.dynamic;
if (!hasRefs) return false;
const scope = d.dxcc_filter ?? [];
if (scope.length > 0 && (!dxcc || !scope.includes(dxcc))) return false;
return true;
}).map((d) => ({ code: d.code, name: d.name }));
}, [defs, metas, dxcc]);
// Keep the selected award valid as the offered list changes with the call.
useEffect(() => {
if (awards.length === 0) return;
if (!awards.find((a) => a.code === awardCode)) setAwardCode(awards[0].code);
}, [awards, awardCode]);
// 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([]);
// 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, isDynamic, refDxcc]);
// Typed search (2+ chars).
useEffect(() => {
if (q.length < 2) { setSearchResults([]); return; }
const t = window.setTimeout(async () => {
setBusy(true);
try {
const r = await SearchAwardReferences(awardCode, q, refDxcc, 50);
setSearchResults((r ?? []) as any);
} catch { setSearchResults([]); }
finally { setBusy(false); }
}, 200);
return () => window.clearTimeout(t);
}, [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.
const results: AwardRef[] = q.length >= 2 ? searchResults : (tooManyAuto ? [] : autoResults);
function addRef(ref: AwardRef) {
const entry = `${awardCode}@${ref.code}`;
if (!entries.includes(entry)) {
onChange([...entries, entry].join(';'));
}
}
function removeEntry(entry: string) {
const next = entries.filter((e) => e !== entry).join(';');
onChange(next);
if (selectedEntry === entry) setSelectedEntry(null);
}
return (
<div className="flex gap-2 h-[210px]">
{/* Left panel */}
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
<Select
value={awardCode}
onValueChange={(v) => { setAwardCode(v); setSelectedRef(null); setQ(''); setSearchResults([]); }}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{awards.map((a) => (
<SelectItem key={a.code} value={a.code}>{a.code}</SelectItem>
))}
</SelectContent>
</Select>
{/* Group / Sub from selected ref */}
<div className="grid grid-cols-[38px_1fr] items-center gap-x-1.5 gap-y-0.5 text-xs">
<span className="text-muted-foreground text-[11px]">Group</span>
<span className="font-mono truncate text-[11px]">{selectedRef?.group || '—'}</span>
<span className="text-muted-foreground text-[11px]">Sub</span>
<span className="font-mono truncate text-[11px]">{selectedRef?.subgrp || '—'}</span>
</div>
{/* Selected ref chip */}
{selectedRef ? (
<div className="flex items-center gap-1.5 h-6 px-2 rounded border border-emerald-300 bg-emerald-50 text-emerald-800 text-xs min-w-0">
<span className="font-mono font-semibold shrink-0">{selectedRef.code}</span>
<span className="truncate text-[10px] text-emerald-700">{selectedRef.name}</span>
<button className="ml-auto shrink-0 hover:text-emerald-950" onClick={() => setSelectedRef(null)}>
<X className="size-3" />
</button>
</div>
) : (
<div className="h-6 flex items-center px-2 text-[11px] text-muted-foreground italic border border-dashed border-border rounded">
pick a reference
</div>
)}
{/* Add — references are always scoped to the contacted DXCC */}
<div className="flex items-center gap-2">
<button
disabled={!selectedRef}
onClick={() => selectedRef && addRef(selectedRef)}
className="flex items-center gap-1 h-6 px-2 text-xs rounded border border-border hover:bg-accent disabled:opacity-40 disabled:cursor-not-allowed"
>
<Plus className="size-3" />Add
</button>
<span className="text-[11px] text-muted-foreground">
{dxcc ? `DXCC #${dxcc}` : 'Enter a callsign first'}
</span>
</div>
<div className="h-px bg-border shrink-0" />
{/* Added refs list */}
<div className="flex-1 overflow-auto space-y-0.5 min-h-0">
{entries.length === 0 ? (
<p className="text-[11px] text-muted-foreground italic py-0.5">No references added yet</p>
) : (
entries.map((entry) => (
<div
key={entry}
className={`flex items-center gap-1.5 px-2 py-0.5 rounded text-xs cursor-pointer select-none ${
selectedEntry === entry ? 'bg-accent' : 'hover:bg-accent/50'
}`}
onClick={() => setSelectedEntry(selectedEntry === entry ? null : entry)}
>
<span className="font-mono font-semibold flex-1 truncate">{entry}</span>
<button
className="shrink-0 hover:text-destructive"
onClick={(e) => { e.stopPropagation(); removeEntry(entry); }}
>
<X className="size-3" />
</button>
</div>
))
)}
</div>
</div>
{/* Right panel: reference search */}
<div className="w-[172px] shrink-0 flex flex-col gap-1.5 border-l pl-2 min-w-0">
<span className="text-xs font-semibold">References</span>
<input
className="h-6 w-full rounded border border-input bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
placeholder="Search…"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-0">
{busy && (
<div className="flex items-center gap-1.5 px-2 py-1.5 text-muted-foreground">
<Loader2 className="size-3 animate-spin" />Searching
</div>
)}
{/* 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>
)}
{/* 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">
{isDynamic && !dxcc
? 'Enter a callsign, or type to search.'
: 'No references for this entity.'}
</div>
)}
{/* Typed search, no results */}
{!busy && q.length >= 2 && results.length === 0 && (
<div className="px-2 py-2 text-[11px] text-muted-foreground leading-snug">
No results.
<br />
<span className="text-[10px]">Download reference lists in the Awards panel Import data.</span>
</div>
)}
{results.map((r) => (
<div
key={r.code}
className={`px-2 py-1 cursor-pointer border-b border-border/30 last:border-0 ${
selectedRef?.code === r.code ? 'bg-accent' : 'hover:bg-accent/50'
}`}
onClick={() => setSelectedRef(r)}
onDoubleClick={() => { setSelectedRef(r); addRef(r); }}
>
<div className="font-mono font-semibold leading-tight text-[11px]">{r.code}</div>
{r.name && (
<div className="text-[10px] text-muted-foreground leading-tight truncate">{r.name}</div>
)}
</div>
))}
</div>
</div>
</div>
);
}
+422
View File
@@ -0,0 +1,422 @@
import { useEffect, useMemo, useState } from 'react';
import { Award as AwardIcon, RefreshCw, Loader2, Search, Pencil, X, Grid3x3, List, BarChart3 } from 'lucide-react';
import { GetAwardDefs, GetAward, AwardCellQSOs, GetAwardStats } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { AwardEditor } from '@/components/AwardEditor';
type BandCount = { band: string; worked: number; confirmed: number };
type AwardRef = {
ref: string; name?: string; group?: string; subgrp?: string;
worked: boolean; confirmed: boolean; validated: boolean;
bands: string[]; confirmed_bands: string[]; validated_bands: string[];
};
type AwardResult = {
code: string; name: string; dimension: string;
worked: number; confirmed: number; validated: number; total: number;
bands: BandCount[]; refs: AwardRef[];
};
type AwardStatRow = { label: string; cells: number[]; total: number; grand_total: number };
type AwardStats = { code: string; bands: string[]; rows: AwardStatRow[] };
// Fixed band columns for the matrix view (Log4OM-style).
const GRID_BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','2m','70cm'];
// Per-band status for a reference, highest first.
type CellStatus = 'validated' | 'confirmed' | 'worked' | 'none';
function cellStatus(r: AwardRef, band: string): CellStatus {
if (r.validated_bands?.includes(band)) return 'validated';
if (r.confirmed_bands?.includes(band)) return 'confirmed';
if (r.bands?.includes(band)) return 'worked';
return 'none';
}
const CELL_STYLE: Record<CellStatus, string> = {
validated: 'bg-emerald-500 text-white',
confirmed: 'bg-amber-400 text-amber-950',
worked: 'bg-stone-400 text-white',
none: '',
};
const CELL_LABEL: Record<CellStatus, string> = { validated: 'V', confirmed: 'C', worked: 'W', none: '' };
function pct(n: number, total: number): number {
if (total <= 0) return 0;
return Math.min(100, Math.round((n / total) * 100));
}
// Two-segment progress: confirmed (solid green) over worked (light amber).
function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed: number; total: number }) {
if (total <= 0) return null;
return (
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden flex">
<div className="bg-emerald-500" style={{ width: `${pct(confirmed, total)}%` }} />
<div className="bg-amber-400/70" style={{ width: `${pct(worked - confirmed, total)}%` }} />
</div>
);
}
type AwardListItem = { code: string; name: string; valid?: boolean };
export function AwardsPanel() {
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
// Computed results are cached per award code — each award is scanned only the
// first time it's selected (or when explicitly rescanned).
const [byCode, setByCode] = useState<Record<string, AwardResult>>({});
const [loading, setLoading] = useState(false);
const [err, setErr] = useState('');
const [selected, setSelected] = useState<string>('');
const [refSearch, setRefSearch] = useState('');
const [editing, setEditing] = useState(false);
const [view, setView] = useState<'grid' | 'list' | 'stats'>('grid');
const [refFilter, setRefFilter] = useState<'all' | 'worked' | 'notworked' | 'worked_notconf'>('all');
const [cell, setCell] = useState<{ ref: string; band: string; name?: string } | null>(null);
const [stats, setStats] = useState<AwardStats | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
// Lazily fetch the statistics matrix when the Stats view is shown.
useEffect(() => {
if (view !== 'stats' || !selected) return;
setStatsLoading(true);
GetAwardStats(selected)
.then((s) => setStats(s as any))
.catch(() => setStats(null))
.finally(() => setStatsLoading(false));
}, [view, selected]);
// Compute one award (cached). force=true bypasses the cache (Rescan).
async function compute(code: string, force = false) {
if (!code) return;
if (!force && byCode[code]) { setSelected(code); return; }
setSelected(code);
setLoading(true);
setErr('');
try {
const r = (await GetAward(code)) as any as AwardResult;
setByCode((m) => ({ ...m, [code]: r }));
} catch (e: any) {
setErr(String(e?.message ?? e));
} finally {
setLoading(false);
}
}
// Load the award list (no QSO scan), then compute only the first award.
async function loadList() {
try {
const defs = ((await GetAwardDefs()) ?? []) as any[];
const list: AwardListItem[] = defs.map((d) => ({ code: d.code, name: d.name, valid: d.valid }));
setAwardList(list);
const first = list.find((a) => a.code === selected) ?? list[0];
if (first) compute(first.code);
} catch (e: any) {
setErr(String(e?.message ?? e));
}
}
useEffect(() => { loadList(); }, []);
const current = byCode[selected];
const filteredRefs = useMemo(() => {
if (!current) return [];
const q = refSearch.trim().toUpperCase();
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;
if (q && !(r.ref.includes(q) || (r.name ?? '').toUpperCase().includes(q) || (r.group ?? '').toUpperCase().includes(q))) return false;
return true;
});
}, [current, refSearch, refFilter]);
return (
<div className="flex h-full min-h-0">
{/* Award list */}
<div className="w-72 shrink-0 border-r border-border flex flex-col min-h-0">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60">
<AwardIcon className="size-4 text-primary" />
<span className="text-sm font-semibold">Awards</span>
<div className="flex-1" />
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title="Edit awards">
<Pencil className="size-3.5" />
</Button>
<Button variant="outline" size="sm" className="h-7 px-2" onClick={() => compute(selected, true)} disabled={loading || !selected}
title="Rescan all QSOs and recompute this award">
{loading ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
Rescan
</Button>
</div>
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); }} />
<div className="flex-1 overflow-auto">
{err && <div className="p-3 text-xs text-destructive">{err}</div>}
{awardList.map((a) => {
const r = byCode[a.code];
return (
<button
key={a.code}
onClick={() => { setRefSearch(''); compute(a.code); }}
className={cn(
'w-full text-left px-3 py-2 border-b border-border/40 hover:bg-accent/40 transition-colors',
selected === a.code && 'bg-accent/60',
a.valid === false && 'opacity-50',
)}
>
<div className="flex items-baseline justify-between gap-2">
<span className="font-semibold text-sm">{a.code}</span>
{r ? (
<span className="text-[11px] font-mono text-muted-foreground">
<span className="text-emerald-600">{r.confirmed}</span>
/<span className="text-foreground">{r.worked}</span>
{r.total > 0 && <span className="text-muted-foreground/70"> of {r.total}</span>}
</span>
) : (
<span className="text-[11px] font-mono text-muted-foreground/50">{selected === a.code && loading ? '…' : '—'}</span>
)}
</div>
<div className="text-[11px] text-muted-foreground truncate mb-1">{a.name}</div>
{r && <ProgressBar worked={r.worked} confirmed={r.confirmed} total={r.total} />}
</button>
);
})}
</div>
</div>
{/* Detail */}
<div className="flex-1 flex flex-col min-h-0">
{!current ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
{loading ? 'Computing…' : 'No data'}
</div>
) : (
<>
<div className="px-4 py-3 border-b border-border/60">
<div className="flex items-baseline gap-2">
<h3 className="text-lg font-bold">{current.code}</h3>
<span className="text-sm text-muted-foreground">{current.name}</span>
</div>
<div className="mt-1 flex items-center gap-4 text-sm">
<span><span className="font-bold text-foreground">{current.worked}</span> <span className="text-muted-foreground">worked</span></span>
<span><span className="font-bold text-emerald-600">{current.confirmed}</span> <span className="text-muted-foreground">confirmed</span></span>
<span><span className="font-bold text-sky-600">{current.validated}</span> <span className="text-muted-foreground">validated</span></span>
{current.total > 0 && (
<span className="text-muted-foreground">of {current.total} · {pct(current.confirmed, current.total)}% confirmed</span>
)}
</div>
<div className="mt-2 max-w-md"><ProgressBar worked={current.worked} confirmed={current.confirmed} total={current.total} /></div>
</div>
{/* Band breakdown */}
{(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) => (
<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>
<span className="text-muted-foreground font-mono">/{b.worked}</span>
</div>
))}
</div>
</div>
)}
{/* References toolbar */}
<div className="flex items-center gap-2 px-4 py-2 flex-wrap">
<div className="relative">
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input className="h-7 w-56 pl-7 text-xs" placeholder="Filter references…" value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
</div>
<div className="flex items-center rounded-md border border-border overflow-hidden text-xs">
{([['all', 'All'], ['worked', 'Wkd'], ['notworked', 'Not wkd'], ['worked_notconf', 'Wkd not cfmd']] as const).map(([k, label]) => (
<button key={k} onClick={() => setRefFilter(k)}
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
{label}
</button>
))}
</div>
<span className="text-[11px] text-muted-foreground">{filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''}</span>
<div className="flex-1" />
{/* Legend */}
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-stone-400" />W</span>
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-amber-400" />C</span>
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-emerald-500" />V</span>
</div>
<div className="flex items-center rounded-md border border-border overflow-hidden">
<button className={cn('px-1.5 py-1', view === 'grid' ? 'bg-accent' : 'hover:bg-accent/50')} title="Grid view" onClick={() => setView('grid')}><Grid3x3 className="size-3.5" /></button>
<button className={cn('px-1.5 py-1', view === 'list' ? 'bg-accent' : 'hover:bg-accent/50')} title="List view" onClick={() => setView('list')}><List className="size-3.5" /></button>
<button className={cn('px-1.5 py-1', view === 'stats' ? 'bg-accent' : 'hover:bg-accent/50')} title="Statistics" onClick={() => setView('stats')}><BarChart3 className="size-3.5" /></button>
</div>
</div>
{/* Statistics matrix: status × band, by mode category */}
{view === 'stats' ? (
<div className="flex-1 overflow-auto px-4 pb-3">
{statsLoading || !stats ? (
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Computing</div>
) : (
<table className="text-xs border-separate" style={{ borderSpacing: 0 }}>
<thead className="sticky top-0 z-10">
<tr className="bg-card">
<th className="sticky left-0 z-20 bg-card text-left py-1 pr-3 font-medium border-b border-border">Statistic</th>
{stats.bands.map((b) => <th key={b} className="py-1 px-1 font-mono font-medium border-b border-border text-center w-10">{b}</th>)}
<th className="py-1 px-2 font-medium border-b border-border text-center">Total</th>
<th className="py-1 px-2 font-medium border-b border-border text-center">Grand</th>
</tr>
</thead>
<tbody>
{stats.rows.map((row, i) => {
const isGroupStart = row.label === 'WORKED' || /^WORKED /.test(row.label);
return (
<tr key={row.label} className={cn(i % 3 === 0 && i > 0 && 'border-t-2 border-border')}>
<td className={cn('sticky left-0 bg-card py-0.5 pr-3 border-b border-border/30 whitespace-nowrap',
isGroupStart ? 'font-semibold text-foreground' : 'text-muted-foreground')}>{row.label}</td>
{row.cells.map((c, j) => (
<td key={j} className={cn('text-center py-0.5 border-b border-l border-border/20 font-mono', c > 0 ? 'bg-emerald-500/15 text-emerald-700' : 'text-muted-foreground/30')}>{c || ''}</td>
))}
<td className="text-center py-0.5 px-2 border-b border-l border-border font-mono font-semibold">{row.total || ''}</td>
<td className="text-center py-0.5 px-2 border-b border-l border-border/20 font-mono text-muted-foreground">{row.grand_total || ''}</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
) : view === 'grid' ? (
<div className="flex-1 overflow-auto px-4 pb-3">
<table className="text-xs border-separate" style={{ borderSpacing: 0 }}>
<thead className="sticky top-0 z-10">
<tr className="bg-card">
<th className="sticky left-0 z-20 bg-card text-left py-1 pr-2 font-medium border-b border-border w-24">Ref</th>
<th className="text-left py-1 pr-3 font-medium border-b border-border">Description</th>
{GRID_BANDS.map((b) => (
<th key={b} className="py-1 px-1 font-mono font-medium border-b border-border text-center w-9">{b}</th>
))}
</tr>
</thead>
<tbody>
{filteredRefs.map((r) => (
<tr key={r.ref} className={cn('hover:bg-accent/20', !r.worked && 'opacity-60')}>
<td className="sticky left-0 bg-card hover:bg-accent/20 py-0.5 pr-2 font-mono font-semibold border-b border-border/30">{r.ref}</td>
<td className="py-0.5 pr-3 text-muted-foreground truncate max-w-[260px] border-b border-border/30">
{r.name}{r.group ? <span className="text-muted-foreground/60"> · {r.group}</span> : ''}
</td>
{GRID_BANDS.map((b) => {
const s = cellStatus(r, b);
return (
<td key={b} className="border-b border-l border-border/30 p-0 text-center">
{s === 'none' ? <span className="block w-9 h-5" /> : (
<button
className={cn('block w-9 h-5 text-[10px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
title={`${r.ref} · ${b} — click to view QSOs`}
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
>{CELL_LABEL[s]}</button>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="flex-1 overflow-auto px-4 pb-3">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1 pr-2 font-medium w-24">Ref</th>
<th className="py-1 pr-2 font-medium">Name</th>
<th className="py-1 pr-2 font-medium w-40">Group</th>
<th className="py-1 pr-2 font-medium w-24">Status</th>
<th className="py-1 font-medium">Bands</th>
</tr>
</thead>
<tbody>
{filteredRefs.map((r) => (
<tr key={r.ref} className={cn('border-b border-border/30', !r.worked && 'opacity-50')}>
<td className="py-1 pr-2 font-mono font-semibold">{r.ref}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[240px]">{r.name}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[150px]">{r.group}</td>
<td className="py-1 pr-2">
{!r.worked ? <span className="text-muted-foreground/70"> missing</span>
: r.validated ? <span className="text-emerald-600">validated</span>
: r.confirmed ? <span className="text-amber-600">confirmed</span>
: <span className="text-stone-500">worked</span>}
</td>
<td className="py-1 font-mono text-muted-foreground">
{r.bands.map((b) => (
<span key={b} className={cn('mr-1', r.validated_bands.includes(b) ? 'text-emerald-600 font-semibold' : r.confirmed_bands.includes(b) && 'text-amber-600 font-semibold')}>{b}</span>
))}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
{cell && current && (
<CellQSOModal code={current.code} cell={cell} onClose={() => setCell(null)} />
)}
</div>
);
}
// CellQSOModal lists the QSOs behind one award-grid cell (reference × band).
function CellQSOModal({ code, cell, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; onClose: () => void }) {
const [qsos, setQsos] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
AwardCellQSOs(code, cell.ref, cell.band)
.then((r) => setQsos((r ?? []) as any))
.catch(() => setQsos([]))
.finally(() => setLoading(false));
}, [code, cell.ref, cell.band]);
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
return (
<div className="fixed inset-0 z-50 bg-black/40 grid place-items-center" onClick={onClose}>
<div className="bg-card border border-border rounded-lg shadow-xl w-[640px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 px-4 py-2.5 border-b">
<span className="font-semibold text-sm">{code} · <span className="font-mono">{cell.ref}</span> · {cell.band}</span>
{cell.name && <span className="text-xs text-muted-foreground truncate">{cell.name}</span>}
<div className="flex-1" />
<button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="size-4" /></button>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Loading</div>
) : qsos.length === 0 ? (
<div className="p-4 text-xs text-muted-foreground">No QSOs.</div>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border">
<tr><th className="py-1 px-3 font-medium">Date (UTC)</th><th className="py-1 pr-2 font-medium">Callsign</th><th className="py-1 pr-2 font-medium">Band</th><th className="py-1 pr-2 font-medium">Mode</th><th className="py-1 pr-3 font-medium">QSL</th></tr>
</thead>
<tbody>
{qsos.map((q, i) => (
<tr key={q.id ?? i} className="border-b border-border/30">
<td className="py-1 px-3 font-mono">{fmt(q.qso_date)}</td>
<td className="py-1 pr-2 font-mono font-semibold">{q.callsign}</td>
<td className="py-1 pr-2">{q.band}</td>
<td className="py-1 pr-2">{q.mode}</td>
<td className="py-1 pr-3 text-muted-foreground">{[q.lotw_rcvd === 'Y' && 'LoTW', q.qsl_rcvd === 'Y' && 'QSL', q.eqsl_rcvd === 'Y' && 'eQSL'].filter(Boolean).join(', ')}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="px-4 py-2 border-t text-[11px] text-muted-foreground">{qsos.length} QSO{qsos.length > 1 ? 's' : ''}</div>
</div>
</div>
);
}
+26 -15
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Minus, Plus, Crosshair, X } from 'lucide-react'; import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { spotStatusKey, inferSpotMode } from '@/lib/spot'; import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
// BandMap — vertical spectrum panel inspired by Log4OM. // BandMap — vertical spectrum panel inspired by Log4OM.
// - Full band is always visible; zoom changes pixels-per-kHz, scroll // - Full band is always visible; zoom changes pixels-per-kHz, scroll
@@ -32,6 +32,8 @@ interface Props {
currentFreqHz: number; currentFreqHz: number;
onSpotClick: (s: Spot) => void; onSpotClick: (s: Spot) => void;
onClose?: () => void; onClose?: () => void;
side?: 'left' | 'right';
onToggleSide?: () => void;
} }
const BAND_RANGES: Record<string, [number, number]> = { const BAND_RANGES: Record<string, [number, number]> = {
@@ -136,15 +138,15 @@ const PILL_GAP = 32; // px between scale border and first pill (room for leade
const LABEL_W = 200; const LABEL_W = 200;
const TOP_PAD = 14; // px of breathing room above/below the band edges so const TOP_PAD = 14; // px of breathing room above/below the band edges so
const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0 const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
// Max FT-mode (FT8/FT4/…) pills drawn at once. These pile up on a single // Max DATA-class (digital: FT8/FT4/JS8/RTTY/PSK/…) pills drawn at once.
// watering-hole frequency and otherwise spawn hundreds of spots that fan out // These pile up on the watering-hole frequencies and otherwise spawn
// and cover the whole map. ONLY the FT family is capped — CW, SSB and other // hundreds of spots that fan out and cover the whole map. ONLY digital is
// digital spots are always shown in full. When more than this FT spots are in // capped — CW and SSB are always shown in full. When more than this digital
// band we keep the most useful (new entities first, worked last; ties broken // spots are in band we keep the most useful (new entities first, worked
// by closeness to the rig freq). // last; ties broken by closeness to the rig freq).
const MAX_VISIBLE_SPOTS = 30; const MAX_VISIBLE_SPOTS = 30;
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose }: Props) { export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide }: Props) {
const range = BAND_RANGES[band]; const range = BAND_RANGES[band];
const segments = SEGMENT_COLORS[band] ?? []; const segments = SEGMENT_COLORS[band] ?? [];
const [zoomIdx, setZoomIdx] = useState(0); const [zoomIdx, setZoomIdx] = useState(0);
@@ -189,14 +191,16 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
inBand.push(s); inBand.push(s);
} }
// Only the FT family (FT8/FT4/…) is capped — it's what floods a single // Only DATA-class spots (every digital mode — FT8/FT4/JS8/RTTY/PSK/…)
// watering-hole frequency. Everything else (CW, SSB, RTTY, PSK, …) is // are capped — they're what floods the watering-hole frequencies. We key
// always shown in full. // off the mode CATEGORY (not a literal "FT8" string) because many FT8
const isFlood = (s: Spot) => /^FT/.test(inferSpotMode(s.comment ?? '', s.freq_hz)); // spots carry no mode word and the band-plan fallback labels them the
// generic "DATA" rather than "FT8". CW and SSB are always shown in full.
const isFlood = (s: Spot) => spotModeCategory(inferSpotMode(s.comment ?? '', s.freq_hz)) === 'DATA';
const ftSpots = inBand.filter(isFlood); const ftSpots = inBand.filter(isFlood);
const otherSpots = inBand.filter((s) => !isFlood(s)); const otherSpots = inBand.filter((s) => !isFlood(s));
// Rank an FT spot by usefulness (new entity → unworked → worked); ties // Rank a DATA spot by usefulness (new entity → unworked → worked); ties
// break by proximity to the rig frequency. Keep the top MAX_VISIBLE_SPOTS. // break by proximity to the rig frequency. Keep the top MAX_VISIBLE_SPOTS.
const rank = (s: Spot) => { const rank = (s: Spot) => {
const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz); const k = spotStatusKey(s.dx_call, s.band ?? '', s.comment ?? '', s.freq_hz);
@@ -363,6 +367,13 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
title="Scroll to current rig frequency"> title="Scroll to current rig frequency">
<Crosshair className="size-3" /> <Crosshair className="size-3" />
</button> </button>
{onToggleSide && (
<button type="button" onClick={onToggleSide}
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
title={side === 'right' ? 'Move band map to the left' : 'Move band map to the right'}>
{side === 'right' ? <PanelLeft className="size-3" /> : <PanelRight className="size-3" />}
</button>
)}
{onClose && ( {onClose && (
<button type="button" onClick={onClose} <button type="button" onClick={onClose}
className="size-5 inline-flex items-center justify-center rounded hover:bg-muted" className="size-5 inline-flex items-center justify-center rounded hover:bg-muted"
@@ -497,7 +508,7 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
</div> </div>
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0"> <div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
scroll · ctrl+wheel = zoom · = jump to rig scroll · ctrl+wheel = zoom · = jump to rig
{hidden > 0 && <span className="text-amber-600"> · {hidden} FT spot{hidden > 1 ? 's' : ''} hidden (top {MAX_VISIBLE_SPOTS} shown)</span>} {hidden > 0 && <span className="text-amber-600"> · {hidden} FT8/FT4 spot{hidden > 1 ? 's' : ''} hidden top {MAX_VISIBLE_SPOTS} kept (CW/SSB all shown)</span>}
</div> </div>
</div> </div>
); );
+44 -7
View File
@@ -46,6 +46,17 @@ const STATUS_CLASSES: Record<string, string> = {
dxcc_w: 'bg-indigo-300 hover:bg-indigo-200', dxcc_w: 'bg-indigo-300 hover:bg-indigo-200',
}; };
// Legend entries, in the same colour order as the cells. swatch = the
// background class (or a special ring marker for the current-entry cell).
const LEGEND: { swatch: string; ring?: boolean; label: string }[] = [
{ swatch: 'bg-emerald-700', label: 'Call confirmed' },
{ swatch: 'bg-emerald-300', label: 'Call worked' },
{ swatch: 'bg-indigo-800', label: 'Entity confirmed' },
{ swatch: 'bg-indigo-300', label: 'Entity worked' },
{ swatch: 'bg-stone-200', label: 'Not worked' },
{ swatch: 'bg-stone-200', ring: true, label: 'Current entry' },
];
function cellTitle(band: string, cls: string, status: string, current: boolean): string { function cellTitle(band: string, cls: string, status: string, current: boolean): string {
const desc = const desc =
status === 'call_c' ? 'This callsign confirmed' : status === 'call_c' ? 'This callsign confirmed' :
@@ -60,6 +71,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) {
const dxcc = wb?.dxcc ?? 0; const dxcc = wb?.dxcc ?? 0;
const dxccName = wb?.dxcc_name ?? ''; const dxccName = wb?.dxcc_name ?? '';
const dxccCount = wb?.dxcc_count ?? 0; const dxccCount = wb?.dxcc_count ?? 0;
const callCount = wb?.count ?? 0; // QSOs with this exact callsign
const hasDxcc = dxcc > 0; const hasDxcc = dxcc > 0;
const newOne = hasDxcc && dxccCount === 0; const newOne = hasDxcc && dxccCount === 0;
@@ -74,8 +86,8 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) {
return ( return (
<section <section
className={cn( className={cn(
'flex items-center gap-4 px-3 py-2 bg-card border-b border-border flex-wrap shrink-0', 'flex items-center gap-4 px-3 py-2 flex-wrap shrink-0',
newOne && 'bg-gradient-to-br from-amber-100 to-amber-200 border-amber-300', newOne && 'bg-gradient-to-br from-amber-100 to-amber-200 rounded-lg',
)} )}
> >
<div className="flex items-center gap-2 min-w-[220px]"> <div className="flex items-center gap-2 min-w-[220px]">
@@ -98,6 +110,13 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) {
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
<strong className="text-foreground font-semibold">{dxccCount}</strong>{' '} <strong className="text-foreground font-semibold">{dxccCount}</strong>{' '}
QSO{dxccCount > 1 ? 's' : ''} with this entity QSO{dxccCount > 1 ? 's' : ''} with this entity
{callCount > 0 && (
<>
{' · '}
<strong className="text-foreground font-semibold">{callCount}</strong>{' '}
with this call
</>
)}
</span> </span>
</> </>
) : busy ? ( ) : busy ? (
@@ -112,15 +131,16 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) {
)} )}
</div> </div>
<table className="border-separate" style={{ borderSpacing: 2 }}> <div className="flex flex-col gap-2">
<table className="border-separate" style={{ borderSpacing: 3 }}>
<thead> <thead>
<tr> <tr>
<th className="w-[22px]" /> <th className="w-[26px]" />
{BANDS.map((b) => ( {BANDS.map((b) => (
<th <th
key={b.tag} key={b.tag}
className={cn( className={cn(
'font-mono text-[10px] font-semibold px-1 text-center', 'font-mono text-[11px] font-semibold px-1 text-center',
b.tag === currentBand ? 'text-primary font-extrabold' : 'text-muted-foreground', b.tag === currentBand ? 'text-primary font-extrabold' : 'text-muted-foreground',
)} )}
> >
@@ -136,7 +156,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) {
<tr key={cls}> <tr key={cls}>
<th <th
className={cn( className={cn(
'font-mono text-[10px] font-semibold pr-1.5 text-right w-[22px]', 'font-mono text-[11px] font-semibold pr-1.5 text-right w-[26px]',
classCurrent ? 'text-primary font-extrabold' : 'text-muted-foreground', classCurrent ? 'text-primary font-extrabold' : 'text-muted-foreground',
)} )}
> >
@@ -150,7 +170,7 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) {
key={b.tag} key={b.tag}
title={cellTitle(b.tag, cls, st, isCurrent)} title={cellTitle(b.tag, cls, st, isCurrent)}
className={cn( className={cn(
'w-[22px] h-[18px] rounded transition-colors p-0', 'w-[28px] h-[24px] rounded transition-colors p-0',
st ? STATUS_CLASSES[st] : 'bg-stone-200 hover:bg-stone-300', st ? STATUS_CLASSES[st] : 'bg-stone-200 hover:bg-stone-300',
isCurrent && 'ring-2 ring-amber-500 ring-inset', isCurrent && 'ring-2 ring-amber-500 ring-inset',
)} )}
@@ -162,6 +182,23 @@ export function BandSlotGrid({ wb, busy, currentBand, currentMode }: Props) {
})} })}
</tbody> </tbody>
</table> </table>
{/* Colour legend — sits in the spare room under the matrix. */}
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap pl-[26px]">
{LEGEND.map((l) => (
<span key={l.label} className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<span
className={cn(
'inline-block size-3 rounded shrink-0',
l.swatch,
l.ring && 'ring-2 ring-amber-500 ring-inset',
)}
/>
{l.label}
</span>
))}
</div>
</div>
</section> </section>
); );
} }
+24 -11
View File
@@ -4,13 +4,14 @@ import {
type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent, type ColDef, type ColumnState, type GridReadyEvent, type RowClickedEvent,
} from 'ag-grid-community'; } from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react'; import { AgGridReact } from 'ag-grid-react';
import { Columns3 } from 'lucide-react'; import { Columns3, FilterX } from 'lucide-react';
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot'; import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
ModuleRegistry.registerModules([AllCommunityModule]); ModuleRegistry.registerModules([AllCommunityModule]);
@@ -57,6 +58,8 @@ export type ClusterSpot = {
received_at: string; received_at: string;
raw: string; raw: string;
repeats?: number; repeats?: number;
pota_ref?: string;
pota_name?: string;
}; };
export type SpotStatusEntry = { export type SpotStatusEntry = {
@@ -137,6 +140,13 @@ const COL_CATALOG: ColEntry[] = [
return <span style={style} title={isNew ? `NEW DXCC: ${status?.country ?? ''}` : workedCall ? 'Already worked this call' : undefined}>{p.value}</span>; return <span style={style} title={isNew ? `NEW DXCC: ${status?.country ?? ''}` : workedCall ? 'Already worked this call' : undefined}>{p.value}</span>;
}, },
}, },
{
group: 'Spot', label: 'POTA', colId: 'pota',
headerName: 'POTA', field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
defaultVisible: true,
cellStyle: { color: '#166534' },
tooltipValueGetter: (p: any) => (p.data?.pota_name ? `POTA — ${p.data.pota_name}` : undefined),
},
{ {
group: 'Spot', label: 'Freq', colId: 'freq', group: 'Spot', label: 'Freq', colId: 'freq',
headerName: 'Freq', field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono', headerName: 'Freq', field: 'freq_khz' as any, width: 95, type: 'rightAligned', cellClass: 'font-mono',
@@ -304,19 +314,18 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
const context = useMemo(() => ({ spotStatus }), [spotStatus]); const context = useMemo(() => ({ spotStatus }), [spotStatus]);
function onGridReady(e: GridReadyEvent) { function onGridReady(e: GridReadyEvent) {
try { const local = loadLocal(COL_STATE_KEY);
const raw = localStorage.getItem(COL_STATE_KEY); if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
if (raw) { loadRemote(COL_STATE_KEY).then((remote) => {
const state = JSON.parse(raw) as ColumnState[]; if (remote && !local) {
if (Array.isArray(state)) e.api.applyColumnState({ state, applyOrder: true }); e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
seedLocal(COL_STATE_KEY, remote);
} }
} catch {} });
} }
const saveColumnState = useCallback(() => { const saveColumnState = useCallback(() => {
try { const state = gridRef.current?.api?.getColumnState();
const state = gridRef.current?.api?.getColumnState(); if (state) saveState(COL_STATE_KEY, state);
if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state));
} catch {}
}, []); }, []);
function handleRowClicked(e: RowClickedEvent<ClusterSpot>) { function handleRowClicked(e: RowClickedEvent<ClusterSpot>) {
@@ -360,6 +369,10 @@ export function ClusterGrid({ rows, spotStatus, onSpotClick }: Props) {
return ( return (
<> <>
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20"> <div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
title="Clear all column filters">
<FilterX className="size-3.5" /> Clear filters
</Button>
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}> <Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
<Columns3 className="size-3.5" /> Columns <Columns3 className="size-3.5" /> Columns
</Button> </Button>
+31 -17
View File
@@ -1,5 +1,4 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Construction } from 'lucide-react';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
@@ -9,6 +8,7 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { pathBetween } from '@/lib/maidenhead'; import { pathBetween } from '@/lib/maidenhead';
import { BandSlotGrid } from '@/components/BandSlotGrid'; import { BandSlotGrid } from '@/components/BandSlotGrid';
import { AwardRefSelector } from '@/components/AwardRefSelector';
export interface DetailsState { export interface DetailsState {
state: string; state: string;
@@ -37,6 +37,10 @@ export interface DetailsState {
srx?: number; srx?: number;
stx?: number; stx?: number;
email: string; email: string;
// Award references for the contacted station (set via the Awards tab picker).
// Semicolon-delimited "AWARD@REF" entries, e.g. "POTA@FR-11553;IOTA@EU-064".
// App.tsx maps these back to pota_ref/sota_ref/iota when saving the QSO.
award_refs?: string;
} }
interface Props { interface Props {
@@ -53,9 +57,15 @@ interface Props {
mode: string; mode: string;
imageUrl?: string; imageUrl?: string;
onOpenImage?: () => void; onOpenImage?: () => void;
// Optional controlled active tab (so the app can switch it via keyboard).
tab?: TabName;
onTab?: (t: TabName) => void;
// When the WinKeyer is active, F1-F12 fire macros, so the tab shortcut is
// shown as Ctrl+F1…F5 instead of F1…F5.
keyerActive?: boolean;
} }
type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended'; export type TabName = 'stats' | 'info' | 'awards' | 'my' | 'extended';
const PROP_MODES = ['NONE','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR']; const PROP_MODES = ['NONE','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
@@ -75,9 +85,9 @@ function Field({ label, span = 1, children }: { label: string; span?: 1 | 2 | 3
); );
} }
export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode }: Props) { export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid, details, onChange, wb, wbBusy, band, mode, tab, onTab, keyerActive }: Props) {
const [open, setOpen] = useState<TabName>('stats'); const [internalOpen, setInternalOpen] = useState<TabName>('stats');
const open = tab ?? internalOpen; // controlled when `tab` is provided
// Bearing/distance from operator's home grid to the remote station. // Bearing/distance from operator's home grid to the remote station.
// Recomputed only when either grid actually changes. // Recomputed only when either grid actually changes.
const path = useMemo( const path = useMemo(
@@ -87,7 +97,8 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
const fmtDeg = (n: number) => `${Math.round(n)}°`; const fmtDeg = (n: number) => `${Math.round(n)}°`;
const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`; const fmtKm = (n: number) => `${Math.round(n).toLocaleString()} km`;
function toggle(t: TabName) { setOpen(t); } function toggle(t: TabName) { onTab ? onTab(t) : setInternalOpen(t); }
const fk = keyerActive ? 'Ctrl+F' : 'F';
const satelliteMode = !!details.sat_name || !!details.sat_mode || details.prop_mode === 'SAT'; const satelliteMode = !!details.sat_name || !!details.sat_mode || details.prop_mode === 'SAT';
function setSatellite(on: boolean) { function setSatellite(on: boolean) {
@@ -102,15 +113,15 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
} }
const tabs: { key: TabName; label: string }[] = [ const tabs: { key: TabName; label: string }[] = [
{ key: 'stats', label: 'Stats (F1)' }, { key: 'stats', label: `Stats (${fk}1)` },
{ key: 'info', label: 'Info (F2)' }, { key: 'info', label: `Info (${fk}2)` },
{ key: 'awards', label: 'Awards (F3)' }, { key: 'awards', label: `Awards (${fk}3)` },
{ key: 'my', label: 'My (F4)' }, { key: 'my', label: `My (${fk}4)` },
{ key: 'extended', label: 'Extended (F5)' }, { key: 'extended', label: `Extended (${fk}5)` },
]; ];
return ( return (
<section className="border border-border rounded-lg bg-card flex flex-col flex-1 min-h-0 overflow-hidden"> <section className="bg-card shadow-sm border border-border rounded-lg flex flex-col flex-1 min-h-0 overflow-hidden">
<nav className="flex items-center gap-1 px-3 bg-muted/40 border-b border-border shrink-0"> <nav className="flex items-center gap-1 px-3 bg-muted/40 border-b border-border shrink-0">
{tabs.map((t) => ( {tabs.map((t) => (
<button <button
@@ -189,18 +200,21 @@ export function DetailsPanel({ callsign: _cs, prefix, operatorGrid, remoteGrid,
)} )}
{open === 'awards' && ( {open === 'awards' && (
<div className="px-4 py-6 text-center text-xs text-muted-foreground"> <div className="px-3 py-2.5">
<Construction className="size-6 mx-auto mb-2 text-muted-foreground/60" /> <AwardRefSelector
<div className="font-semibold text-sm text-foreground/70">Awards module coming soon</div> dxcc={details.dxcc}
value={details.award_refs ?? ''}
onChange={(v) => onChange({ award_refs: v })}
/>
</div> </div>
)} )}
{open === 'my' && ( {open === 'my' && (
<div className="grid grid-cols-6 gap-2 px-3 py-2.5"> <div className="grid grid-cols-6 gap-2 px-3 py-2.5">
<Field label="Ant. azimuth (°)"> <Field label="Azimuth (°)">
<Input type="number" value={details.ant_az ?? ''} onChange={(e) => onChange({ ant_az: numOrUndef(e.target.value) })} /> <Input type="number" value={details.ant_az ?? ''} onChange={(e) => onChange({ ant_az: numOrUndef(e.target.value) })} />
</Field> </Field>
<Field label="Ant. elevation (°)"> <Field label="Elevation (°)">
<Input type="number" value={details.ant_el ?? ''} onChange={(e) => onChange({ ant_el: numOrUndef(e.target.value) })} /> <Input type="number" value={details.ant_el ?? ''} onChange={(e) => onChange({ ant_el: numOrUndef(e.target.value) })} />
</Field> </Field>
<Field label="Ant. path"> <Field label="Ant. path">
+69
View File
@@ -0,0 +1,69 @@
import { Mic, Square, X, Radio } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
export type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number };
export type DVKStat = { recording: boolean; playing: boolean; rec_slot: number };
type Props = {
messages: DVKMsg[];
status: DVKStat;
onPlay: (slot: number) => void;
onStop: () => void;
onClose: () => void;
};
// Operating panel for the Digital Voice Keyer — transmits the recorded F1F6
// voice messages to the rig ("To Radio"). Mirrors the WinKeyer panel's slot in
// the reserved area. Recording/labeling lives in Settings → Audio.
export function DvkPanel({ messages, status, onPlay, onStop, onClose }: Props) {
const anyAudio = messages.some((m) => m.has_audio);
return (
<div className="h-full flex flex-col rounded-lg border border-border bg-card shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/40 shrink-0">
<Mic className="size-3.5 text-primary" />
<span className="text-[11px] font-semibold uppercase tracking-wider">Voice keyer</span>
<span className={cn('size-2 rounded-full', status.playing ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500')} />
{status.playing && <span className="text-[10px] text-amber-600 font-medium">transmitting</span>}
<div className="flex-1" />
<Button variant="ghost" size="sm" className="h-6 px-2 text-[11px]" onClick={onStop} disabled={!status.playing}>
<Square className="size-3" /> Stop
</Button>
<button className="text-muted-foreground hover:text-foreground" title="Disable voice keyer" onClick={onClose}>
<X className="size-3.5" />
</button>
</div>
<div className="flex-1 min-h-0 overflow-auto p-2">
{!anyAudio ? (
<div className="h-full flex flex-col items-center justify-center gap-1 text-center text-[11px] text-muted-foreground px-3">
<Radio className="size-5 opacity-50" />
No messages recorded yet. Open <strong>Settings Audio devices &amp; voice keyer</strong> to record F1F6.
</div>
) : (
<div className="grid grid-cols-2 gap-1.5">
{messages.map((m) => (
<button
key={m.slot}
type="button"
disabled={!m.has_audio}
onClick={() => onPlay(m.slot)}
title={m.has_audio ? `Transmit F${m.slot}${m.label ? ' — ' + m.label : ''} (${m.duration_sec.toFixed(1)}s)` : `F${m.slot} — empty`}
className={cn(
'flex items-center gap-1.5 rounded-md border px-2 py-1.5 text-left transition-colors',
m.has_audio
? 'border-border bg-background hover:border-primary/60 hover:bg-accent/30 cursor-pointer'
: 'border-dashed border-border/60 text-muted-foreground/50 cursor-not-allowed',
)}
>
<span className="font-mono text-[11px] font-bold text-primary shrink-0">F{m.slot}</span>
<span className="text-xs truncate flex-1">{m.label || (m.has_audio ? 'message' : '—')}</span>
{m.has_audio && <span className="text-[9px] text-muted-foreground shrink-0">{m.duration_sec.toFixed(1)}s</span>}
</button>
))}
</div>
)}
</div>
</div>
);
}
+269
View File
@@ -0,0 +1,269 @@
import { useEffect, useState } from 'react';
import { Plus, Trash2, Save, FolderOpen, X } 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';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
// FilterBuilder — Log4OM-style advanced filter for the QSO list. The operator
// adds field/operator/value conditions, joins them with AND or OR, and can
// save/recall named presets. Closing the dialog applies the filter.
export type FilterOp =
| 'eq' | 'ne' | 'gt' | 'lt' | 'ge' | 'le'
| 'contains' | 'startswith' | 'endswith' | 'empty' | 'notempty';
export interface FilterCondition { field: string; op: FilterOp; value: string }
export interface QueryFilter {
quick_callsign?: string;
conditions: FilterCondition[];
match: 'AND' | 'OR';
limit?: number;
offset?: number;
}
// Curated field catalog. `value` MUST match a column in the backend whitelist
// (qso.FilterableFields); `type` only drives which operators/value input we show.
type FieldType = 'text' | 'number' | 'date';
const FIELDS: { value: string; label: string; type: FieldType }[] = [
{ value: 'callsign', label: 'Callsign', type: 'text' },
{ value: 'qso_date', label: 'Date / time (UTC)', type: 'date' },
{ value: 'qso_date_off', label: 'End date / time', type: 'date' },
{ value: 'band', label: 'Band', type: 'text' },
{ value: 'band_rx', label: 'RX band', type: 'text' },
{ value: 'mode', label: 'Mode', type: 'text' },
{ value: 'submode', label: 'Submode', type: 'text' },
{ value: 'freq_hz', label: 'Frequency (Hz)', type: 'number' },
{ value: 'freq_rx_hz', label: 'RX frequency (Hz)', type: 'number' },
{ value: 'rst_sent', label: 'RST sent', type: 'text' },
{ value: 'rst_rcvd', label: 'RST rcvd', type: 'text' },
{ value: 'name', label: 'Name', type: 'text' },
{ value: 'qth', label: 'QTH', type: 'text' },
{ value: 'address', label: 'Address', type: 'text' },
{ value: 'email', label: 'E-mail', type: 'text' },
{ value: 'grid', label: 'Grid', type: 'text' },
{ value: 'country', label: 'Country', type: 'text' },
{ value: 'state', label: 'State', type: 'text' },
{ value: 'cnty', label: 'County', type: 'text' },
{ value: 'dxcc', label: 'DXCC #', type: 'number' },
{ value: 'cont', label: 'Continent', type: 'text' },
{ value: 'cqz', label: 'CQ zone', type: 'number' },
{ value: 'ituz', label: 'ITU zone', type: 'number' },
{ value: 'iota', label: 'IOTA', type: 'text' },
{ value: 'sota_ref', label: 'SOTA ref', type: 'text' },
{ value: 'pota_ref', label: 'POTA ref', type: 'text' },
{ value: 'rig', label: 'Rig', type: 'text' },
{ value: 'ant', label: 'Antenna', type: 'text' },
{ value: 'qsl_sent', label: 'QSL sent', type: 'text' },
{ value: 'qsl_rcvd', label: 'QSL rcvd', type: 'text' },
{ value: 'qsl_via', label: 'QSL via', type: 'text' },
{ value: 'lotw_sent', label: 'LoTW sent', type: 'text' },
{ value: 'lotw_rcvd', label: 'LoTW rcvd', type: 'text' },
{ value: 'eqsl_sent', label: 'eQSL sent', type: 'text' },
{ value: 'eqsl_rcvd', label: 'eQSL rcvd', type: 'text' },
{ value: 'qrzcom_qso_upload_status', label: 'QRZ upload status', type: 'text' },
{ value: 'clublog_qso_upload_status', label: 'ClubLog upload status', type: 'text' },
{ value: 'contest_id', label: 'Contest ID', type: 'text' },
{ value: 'srx', label: 'Serial rcvd', type: 'number' },
{ value: 'stx', label: 'Serial sent', type: 'number' },
{ value: 'prop_mode', label: 'Propagation mode', type: 'text' },
{ value: 'sat_name', label: 'Satellite', type: 'text' },
{ value: 'station_callsign', label: 'My callsign', type: 'text' },
{ value: 'operator', label: 'Operator', type: 'text' },
{ value: 'owner_callsign', label: 'Owner callsign', type: 'text' },
{ value: 'my_grid', label: 'My grid', type: 'text' },
{ value: 'my_country', label: 'My country', type: 'text' },
{ value: 'tx_pwr', label: 'TX power (W)', type: 'number' },
{ value: 'comment', label: 'Comment', type: 'text' },
{ value: 'notes', label: 'Notes', type: 'text' },
];
const OPS: { value: FilterOp; label: string }[] = [
{ value: 'eq', label: 'equals (=)' },
{ value: 'ne', label: 'not equal (≠)' },
{ value: 'contains', label: 'contains' },
{ value: 'startswith', label: 'starts with' },
{ value: 'endswith', label: 'ends with' },
{ value: 'gt', label: 'greater than (>)' },
{ value: 'lt', label: 'less than (<)' },
{ value: 'ge', label: 'greater or equal (≥)' },
{ value: 'le', label: 'less or equal (≤)' },
{ value: 'empty', label: 'is empty' },
{ value: 'notempty', label: 'is not empty' },
];
const TEXT_OPS: FilterOp[] = ['contains', 'startswith', 'endswith', 'eq', 'ne', 'empty', 'notempty'];
const NUM_OPS: FilterOp[] = ['eq', 'ne', 'gt', 'lt', 'ge', 'le', 'empty', 'notempty'];
function opsFor(field: string): { value: FilterOp; label: string }[] {
const t = FIELDS.find((f) => f.value === field)?.type ?? 'text';
const allow = t === 'text' ? TEXT_OPS : NUM_OPS;
return OPS.filter((o) => allow.includes(o.value));
}
const PRESETS_KEY = 'hamlog.filterPresets';
function loadPresets(): Record<string, QueryFilter> {
try { return JSON.parse(localStorage.getItem(PRESETS_KEY) || '{}'); } catch { return {}; }
}
function savePresets(p: Record<string, QueryFilter>) {
localStorage.setItem(PRESETS_KEY, JSON.stringify(p));
}
interface Props {
open: boolean;
initial: QueryFilter;
onApply: (f: QueryFilter) => void; // applies and closes
onClose: () => void;
}
export function FilterBuilder({ open, initial, onApply, onClose }: Props) {
const [conditions, setConditions] = useState<FilterCondition[]>([]);
const [match, setMatch] = useState<'AND' | 'OR'>('AND');
const [presets, setPresets] = useState<Record<string, QueryFilter>>({});
const [presetName, setPresetName] = useState('');
// Seed from the active filter each time the dialog opens.
useEffect(() => {
if (!open) return;
setConditions(initial.conditions?.length ? initial.conditions.map((c) => ({ ...c })) : []);
setMatch(initial.match === 'OR' ? 'OR' : 'AND');
setPresets(loadPresets());
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
const setCond = (i: number, patch: Partial<FilterCondition>) =>
setConditions((cs) => cs.map((c, j) => (j === i ? { ...c, ...patch } : c)));
const addCond = () => setConditions((cs) => [...cs, { field: 'callsign', op: 'contains', value: '' }]);
const removeCond = (i: number) => setConditions((cs) => cs.filter((_, j) => j !== i));
function buildFilter(): QueryFilter {
const clean = conditions.filter((c) => c.field && c.op);
return { ...initial, conditions: clean, match };
}
function apply() { onApply(buildFilter()); }
function saveCurrentPreset() {
const name = presetName.trim();
if (!name) return;
const next = { ...presets, [name]: buildFilter() };
savePresets(next);
setPresets(next);
setPresetName('');
}
function loadPreset(name: string) {
const f = presets[name];
if (!f) return;
setConditions(f.conditions?.map((c) => ({ ...c })) ?? []);
setMatch(f.match === 'OR' ? 'OR' : 'AND');
}
function deletePreset(name: string) {
const next = { ...presets };
delete next[name];
savePresets(next);
setPresets(next);
}
const presetNames = Object.keys(presets).sort();
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) apply(); }}>
<DialogContent className="max-w-3xl">
<DialogHeader className="px-6 py-4">
<DialogTitle>QSO filter</DialogTitle>
</DialogHeader>
<div className="px-6 py-4 space-y-5">
{/* Match mode + presets */}
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted-foreground">Match</span>
<div className="inline-flex rounded-md border border-border overflow-hidden">
{(['AND', 'OR'] as const).map((m) => (
<button key={m} type="button" onClick={() => setMatch(m)}
className={`px-3 py-1 text-xs font-medium ${match === m ? 'bg-primary text-primary-foreground' : 'hover:bg-accent'}`}>
{m === 'AND' ? 'ALL (AND)' : 'ANY (OR)'}
</button>
))}
</div>
<div className="flex-1" />
{presetNames.length > 0 && (
<Select onValueChange={loadPreset}>
<SelectTrigger className="h-8 w-44 text-xs"><FolderOpen className="size-3.5 mr-1" /><SelectValue placeholder="Load preset…" /></SelectTrigger>
<SelectContent>
{presetNames.map((n) => (
<SelectItem key={n} value={n}>
<span className="inline-flex items-center gap-2">
{n}
<Trash2 className="size-3 text-muted-foreground hover:text-destructive" onClick={(e) => { e.stopPropagation(); deletePreset(n); }} />
</span>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Conditions */}
<div className="space-y-2 max-h-[50vh] overflow-auto p-1">
{conditions.length === 0 && (
<div className="text-xs text-muted-foreground py-4 text-center">No conditions the list shows all QSOs. Add one below.</div>
)}
{conditions.map((c, i) => {
const needsValue = c.op !== 'empty' && c.op !== 'notempty';
return (
<div key={i} className="flex items-center gap-2">
<span className="text-[10px] w-8 text-right text-muted-foreground font-mono">{i === 0 ? 'WHERE' : match}</span>
<Select value={c.field} onValueChange={(v) => {
// Reset op if the new field type doesn't allow the current one.
const allowed = opsFor(v).map((o) => o.value);
setCond(i, { field: v, op: allowed.includes(c.op) ? c.op : allowed[0] });
}}>
<SelectTrigger className="h-8 w-48 text-xs"><SelectValue /></SelectTrigger>
<SelectContent className="max-h-72">
{FIELDS.map((f) => <SelectItem key={f.value} value={f.value}>{f.label}</SelectItem>)}
</SelectContent>
</Select>
<Select value={c.op} onValueChange={(v) => setCond(i, { op: v as FilterOp })}>
<SelectTrigger className="h-8 w-40 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{opsFor(c.field).map((o) => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<Input
className="h-8 flex-1 text-xs"
disabled={!needsValue}
placeholder={needsValue ? 'value' : '—'}
value={c.value}
onChange={(e) => setCond(i, { value: e.target.value })}
onKeyDown={(e) => { if (e.key === 'Enter') apply(); }}
/>
<button type="button" onClick={() => removeCond(i)} className="text-muted-foreground hover:text-destructive shrink-0" title="Remove">
<X className="size-4" />
</button>
</div>
);
})}
<Button variant="outline" size="sm" className="h-8" onClick={addCond}>
<Plus className="size-3.5 mr-1" /> Add condition
</Button>
</div>
{/* Save preset */}
<div className="flex items-center gap-2 border-t border-border pt-3">
<Input className="h-8 w-56 text-xs" placeholder="Preset name…" value={presetName}
onChange={(e) => setPresetName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') saveCurrentPreset(); }} />
<Button variant="outline" size="sm" className="h-8" disabled={!presetName.trim()} onClick={saveCurrentPreset}>
<Save className="size-3.5 mr-1" /> Save preset
</Button>
</div>
</div>
<DialogFooter className="px-6 py-4 bg-transparent border-t-0">
<Button variant="ghost" onClick={() => { setConditions([]); }}>Clear</Button>
<Button variant="outline" onClick={onClose}>Cancel</Button>
<Button onClick={apply}>Apply &amp; close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+140
View File
@@ -0,0 +1,140 @@
import { useEffect, useRef } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { gridToLatLon, gridSquareBounds, greatCirclePoints, pathBetween } from '@/lib/maidenhead';
// MainMap — Log4OM-style dual map for the Main tab:
// • Left: a world map with the great-circle path drawn from the operator to
// the contacted station, plus distance + short/long-path azimuth.
// • Right: a street map zoomed onto the contacted station's grid locator.
// Built on Leaflet + free OSM/Carto tiles (no API key). Endpoints use
// circleMarkers / divIcons so we don't depend on Leaflet's image assets.
interface Props {
fromGrid: string; // operator grid (active profile)
toGrid: string; // contacted-station grid
fromLabel?: string; // operator callsign
toLabel?: string; // DX callsign
}
const CARTO_LIGHT = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
const OSM = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
const CARTO_ATTR = '&copy; OpenStreetMap &copy; CARTO';
const OSM_ATTR = '&copy; OpenStreetMap contributors';
function dot(color: string): L.DivIcon {
return L.divIcon({
className: '',
html: `<span style="display:block;width:12px;height:12px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 0 0 1px rgba(0,0,0,.4)"></span>`,
iconSize: [12, 12],
iconAnchor: [6, 6],
});
}
export function MainMap({ fromGrid, toGrid, fromLabel, toLabel }: Props) {
const worldRef = useRef<HTMLDivElement>(null);
const locatorRef = useRef<HTMLDivElement>(null);
const worldMap = useRef<L.Map | null>(null);
const locatorMap = useRef<L.Map | null>(null);
// Layers we add/remove as the QSO changes (kept separate from the basemap).
const worldOverlay = useRef<L.LayerGroup | null>(null);
const locatorOverlay = useRef<L.LayerGroup | null>(null);
// One-time map creation.
useEffect(() => {
if (worldRef.current && !worldMap.current) {
const m = L.map(worldRef.current, { zoomControl: true, attributionControl: true, worldCopyJump: true })
.setView([20, 0], 1);
L.tileLayer(CARTO_LIGHT, { attribution: CARTO_ATTR, subdomains: 'abcd', maxZoom: 19 }).addTo(m);
worldOverlay.current = L.layerGroup().addTo(m);
worldMap.current = m;
}
if (locatorRef.current && !locatorMap.current) {
const m = L.map(locatorRef.current, { zoomControl: true, attributionControl: true })
.setView([20, 0], 2);
L.tileLayer(OSM, { attribution: OSM_ATTR, maxZoom: 19 }).addTo(m);
locatorOverlay.current = L.layerGroup().addTo(m);
locatorMap.current = m;
}
// The Main tab may have just become visible — fix tile sizing.
const t = window.setTimeout(() => {
worldMap.current?.invalidateSize();
locatorMap.current?.invalidateSize();
}, 80);
return () => window.clearTimeout(t);
}, []);
// Redraw overlays whenever the operator/DX grids change.
useEffect(() => {
const wm = worldMap.current, lm = locatorMap.current;
const wo = worldOverlay.current, lo = locatorOverlay.current;
if (!wm || !lm || !wo || !lo) return;
wo.clearLayers();
lo.clearLayers();
const from = gridToLatLon(fromGrid);
const to = gridToLatLon(toGrid);
// ── Left: world + great-circle arc ──
if (from) L.marker([from.lat, from.lon], { icon: dot('#059669'), title: fromLabel || 'Home' }).addTo(wo);
if (to) {
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
.bindTooltip(toLabel || toGrid, { permanent: false, direction: 'top' }).addTo(wo);
}
if (from && to) {
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 96);
L.polyline(pts as L.LatLngExpression[], { color: '#dc2626', weight: 2, opacity: 0.9 }).addTo(wo);
const bounds = L.latLngBounds([[from.lat, from.lon], [to.lat, to.lon]]);
// Include the arc so high-latitude curves aren't clipped.
pts.forEach((p) => bounds.extend(p as L.LatLngExpression));
wm.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
} else if (to) {
wm.setView([to.lat, to.lon], 3);
} else if (from) {
wm.setView([from.lat, from.lon], 3);
}
// ── Right: street map on the DX locator ──
if (to) {
L.marker([to.lat, to.lon], { icon: dot('#dc2626'), title: toLabel || 'DX' })
.bindTooltip(`${toLabel ? toLabel + ' · ' : ''}${toGrid.toUpperCase()}`, { permanent: false, direction: 'top' })
.addTo(lo);
const b = gridSquareBounds(toGrid);
if (b) {
L.rectangle([[b.south, b.west], [b.north, b.east]], { color: '#dc2626', weight: 1, fillOpacity: 0.06 }).addTo(lo);
}
lm.setView([to.lat, to.lon], toGrid.trim().length >= 6 ? 9 : 7);
} else if (from) {
lm.setView([from.lat, from.lon], 5);
}
setTimeout(() => { wm.invalidateSize(); lm.invalidateSize(); }, 0);
}, [fromGrid, toGrid, fromLabel, toLabel]);
const path = pathBetween(fromGrid, toGrid);
return (
<div className="flex flex-col h-full min-h-0 gap-2 p-2">
<div className="grid grid-cols-2 gap-2 flex-1 min-h-0">
<div className="relative isolate rounded-lg overflow-hidden border border-border">
<div ref={worldRef} className="absolute inset-0" />
{path && (
<div className="absolute bottom-1 left-1 z-[500] rounded-md bg-card/90 backdrop-blur px-2 py-1 text-[11px] font-mono shadow border border-border pointer-events-none">
<div><span className="text-muted-foreground">Dist</span> {Math.round(path.distanceShort).toLocaleString()} km
<span className="text-muted-foreground"> · LP</span> {Math.round(path.distanceLong).toLocaleString()} km</div>
<div><span className="text-muted-foreground">Az SP</span> {Math.round(path.bearingShort)}°
<span className="text-muted-foreground"> · LP</span> {Math.round(path.bearingLong)}°</div>
</div>
)}
</div>
<div className="relative isolate rounded-lg overflow-hidden border border-border">
<div ref={locatorRef} className="absolute inset-0" />
{!gridToLatLon(toGrid) && (
<div className="absolute inset-0 z-[500] flex items-center justify-center text-xs text-muted-foreground bg-card/60 pointer-events-none">
Enter a grid or look up the callsign to center the map.
</div>
)}
</div>
</div>
</div>
);
}
+257 -16
View File
@@ -1,12 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks } from 'lucide-react'; import { UploadCloud, DownloadCloud, Search, Loader2, ScrollText, ListChecks, Trees, ExternalLink } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations } from '../../wailsjs/go/main/App'; import { FindQSOsForUpload, UploadQSOsManual, DownloadConfirmations, SyncPOTAHunterLog, ListQSO, BulkUpdateQSL } from '../../wailsjs/go/main/App';
import { Input } from '@/components/ui/input';
import { EventsOn } from '../../wailsjs/runtime/runtime'; import { EventsOn } from '../../wailsjs/runtime/runtime';
type UploadRow = { type UploadRow = {
@@ -23,8 +24,26 @@ const SERVICES = [
{ v: 'qrz', label: 'QRZ.com' }, { v: 'qrz', label: 'QRZ.com' },
{ v: 'clublog', label: 'Club Log' }, { v: 'clublog', label: 'Club Log' },
{ v: 'lotw', label: 'LoTW' }, { v: 'lotw', label: 'LoTW' },
{ v: 'pota', label: 'POTA hunter log' },
{ v: 'paper', label: 'Paper QSL' },
]; ];
const QSL_STATUSES = [
{ v: '_', label: '— leave —' },
{ v: 'Y', label: 'Yes' },
{ v: 'N', label: 'No' },
{ v: 'R', label: 'Requested' },
{ v: 'I', label: 'Ignore' },
];
type LogQSO = {
id: number; qso_date: string; callsign: string; band: string; mode: string; country?: string;
qsl_sent?: string; qsl_rcvd?: string; qsl_via?: string; qsl_sent_date?: string; qsl_rcvd_date?: string;
};
type POTAUnmatched = { activator: string; date: string; band: string; reference: string; reason: string; qso_id: number };
type POTASync = { fetched: number; updated: number; already_tagged: number; added: number; unmatched: number; unmatched_list: POTAUnmatched[] };
const SENT_STATUSES = [ const SENT_STATUSES = [
{ v: 'R', label: 'Requested' }, { v: 'R', label: 'Requested' },
{ v: 'N', label: 'No' }, { v: 'N', label: 'No' },
@@ -42,10 +61,82 @@ function fmtDate(iso: string): string {
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`; return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
} }
// fmtQslDate renders a QSL/LoTW/eQSL/ClubLog date (ADIF YYYYMMDD, or an ISO
// datetime) as YYYY-MM-DD — same shape as the QSO date, without the time.
export function fmtQslDate(s?: string): string {
if (!s) return '';
const t = s.trim();
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
const d = new Date(t);
if (!isNaN(d.getTime())) return d.toISOString().slice(0, 10);
return t;
}
// QSL Manager as an in-app tab panel: upload logged QSOs to online logbooks // QSL Manager as an in-app tab panel: upload logged QSOs to online logbooks
// and download confirmations, while the rest of the app stays usable. // and download confirmations, while the rest of the app stays usable.
export function QSLManagerPanel() { export function QSLManagerPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
const [service, setService] = useState('lotw'); const [service, setService] = useState('lotw');
const [potaSyncing, setPotaSyncing] = useState(false);
const [potaRes, setPotaRes] = useState<POTASync | null>(null);
const [potaErr, setPotaErr] = useState('');
const [potaAddMissing, setPotaAddMissing] = useState(false);
async function syncPota() {
setPotaSyncing(true); setPotaErr(''); setPotaRes(null);
try { setPotaRes((await SyncPOTAHunterLog(potaAddMissing)) as any as POTASync); }
catch (e: any) { setPotaErr(String(e?.message ?? e)); }
finally { setPotaSyncing(false); }
}
// ── Paper QSL: search a callsign, bulk-set sent/received + via + date ──
const [paperCall, setPaperCall] = useState('');
const [paperRows, setPaperRows] = useState<LogQSO[]>([]);
const [paperSel, setPaperSel] = useState<Set<number>>(new Set());
const [paperBusy, setPaperBusy] = useState(false);
const [paperMsg, setPaperMsg] = useState('');
const [qslRcvd, setQslRcvd] = useState('Y');
const [qslSent, setQslSent] = useState('_');
const [qslRcvdDate, setQslRcvdDate] = useState('');
const [qslSentDate, setQslSentDate] = useState('');
const [qslVia, setQslVia] = useState('');
const searchPaper = useCallback(async () => {
const c = paperCall.trim().toUpperCase();
if (!c) return;
setPaperBusy(true); setPaperMsg('');
try {
const r: any = await ListQSO({ callsign: c, limit: 1000 } as any);
const list = (r ?? []) as LogQSO[];
setPaperRows(list);
setPaperSel(new Set(list.map((x) => x.id)));
} catch (e: any) { setPaperMsg(String(e?.message ?? e)); setPaperRows([]); }
finally { setPaperBusy(false); }
}, [paperCall]);
function togglePaper(id: number) {
setPaperSel((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
}
const paperAllSel = paperRows.length > 0 && paperSel.size === paperRows.length;
async function applyPaper() {
const ids = paperRows.filter((r) => paperSel.has(r.id)).map((r) => r.id);
if (ids.length === 0) return;
setPaperBusy(true); setPaperMsg('');
const ymd = (d: string) => d.replaceAll('-', '');
try {
const n = await BulkUpdateQSL(ids as any, {
sent_status: qslSent === '_' ? '' : qslSent,
rcvd_status: qslRcvd === '_' ? '' : qslRcvd,
sent_date: ymd(qslSentDate),
rcvd_date: ymd(qslRcvdDate),
via: qslVia,
} as any);
setPaperMsg(`${n} QSO updated.`);
await searchPaper();
} catch (e: any) { setPaperMsg(String(e?.message ?? e)); }
finally { setPaperBusy(false); }
}
const [sent, setSent] = useState('R'); const [sent, setSent] = useState('R');
const [rows, setRows] = useState<UploadRow[]>([]); const [rows, setRows] = useState<UploadRow[]>([]);
const [selected, setSelected] = useState<Set<number>>(new Set()); const [selected, setSelected] = useState<Set<number>>(new Set());
@@ -149,18 +240,57 @@ export function QSLManagerPanel() {
<SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent> <SelectContent>{SERVICES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
</Select> </Select>
</div> </div>
<div className="flex flex-col gap-1"> {service === 'pota' ? (
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Sent status</label> <>
<Select value={sent} onValueChange={setSent}> <Button size="sm" className="h-8" onClick={syncPota} disabled={potaSyncing}>
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger> {potaSyncing ? <Loader2 className="size-3.5 animate-spin" /> : <Trees className="size-3.5" />}
<SelectContent>{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent> Sync hunter log
</Select> </Button>
</div> <label className="flex items-center gap-1.5 text-[11px] text-muted-foreground cursor-pointer self-center" title="Insert hunter-log contacts whose callsign isn't in your log yet (callsign/date/band/mode/park)">
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching || busy}> <Checkbox checked={potaAddMissing} onCheckedChange={(c) => setPotaAddMissing(!!c)} />
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />} Add not-found QSOs to my log
Select required </label>
</Button> <span className="text-[11px] text-muted-foreground self-center">Token in Settings External services POTA.</span>
</>
) : service === 'paper' ? (
<>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Callsign</label>
<Input className="h-8 w-40 font-mono uppercase" value={paperCall}
onChange={(e) => setPaperCall(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') searchPaper(); }}
placeholder="e.g. DL1ABC" />
</div>
<Button size="sm" className="h-8" onClick={searchPaper} disabled={paperBusy || !paperCall.trim()}>
{paperBusy ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
Search
</Button>
<span className="text-[11px] text-muted-foreground self-center">Find a callsign, then set QSL sent/received + via + date on the selection.</span>
</>
) : (
<>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Sent status</label>
<Select value={sent} onValueChange={setSent}>
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
<SelectContent>{SENT_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
</Select>
</div>
<Button size="sm" className="h-8" onClick={selectRequired} disabled={searching || busy}>
{searching ? <Loader2 className="size-3.5 animate-spin" /> : <Search className="size-3.5" />}
Select required
</Button>
</>
)}
<div className="flex-1" /> <div className="flex-1" />
{service === 'pota' && potaRes && (
<span className="text-xs text-muted-foreground">
{potaRes.updated} updated · {potaRes.added} added · {potaRes.already_tagged} already · {potaRes.unmatched} unmatched / {potaRes.fetched}
</span>
)}
{service === 'paper' && paperRows.length > 0 && (
<span className="text-xs text-muted-foreground">{paperRows.length} QSO · {paperSel.size} selected</span>
)}
{!showLog && viewMode === 'confirmations' && ( {!showLog && viewMode === 'confirmations' && (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Filter</label> <label className="text-[10px] uppercase tracking-wider text-muted-foreground">Filter</label>
@@ -192,7 +322,81 @@ export function QSLManagerPanel() {
<div className="flex-1 overflow-auto px-3 py-2 min-h-0"> <div className="flex-1 overflow-auto px-3 py-2 min-h-0">
{error && <div className="text-xs text-rose-700 mb-2">{error}</div>} {error && <div className="text-xs text-rose-700 mb-2">{error}</div>}
{showLog ? ( {service === 'paper' ? (
paperRows.length === 0 ? (
<div className="text-sm text-muted-foreground py-10 text-center">Search a callsign to list its QSOs, then set QSL status below.</div>
) : (
<table className="w-full text-xs border-collapse">
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1.5 px-2 w-8"><Checkbox checked={paperAllSel} onCheckedChange={() => setPaperSel(paperAllSel ? new Set() : new Set(paperRows.map((r) => r.id)))} /></th>
<th className="py-1.5 px-2">Date UTC</th><th className="py-1.5 px-2">Callsign</th>
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Mode</th>
<th className="py-1.5 px-2">QSL Sent</th><th className="py-1.5 px-2">QSL Rcvd</th><th className="py-1.5 px-2">Via</th>
</tr>
</thead>
<tbody>
{paperRows.map((r) => (
<tr key={r.id}
className={cn('border-b border-border/40 cursor-pointer hover:bg-accent/30', paperSel.has(r.id) && 'bg-primary/5')}
onClick={() => togglePaper(r.id)}>
<td className="py-1 px-2" onClick={(e) => e.stopPropagation()}><Checkbox checked={paperSel.has(r.id)} onCheckedChange={() => togglePaper(r.id)} /></td>
<td className="py-1 px-2 font-mono">{fmtDate(r.qso_date)}</td>
<td className="py-1 px-2 font-mono font-bold">{r.callsign}</td>
<td className="py-1 px-2">{r.band}</td>
<td className="py-1 px-2">{r.mode}</td>
<td className="py-1 px-2 font-mono">{r.qsl_sent || '—'}{r.qsl_sent_date ? ` ${fmtQslDate(r.qsl_sent_date)}` : ''}</td>
<td className="py-1 px-2 font-mono">{r.qsl_rcvd || '—'}{r.qsl_rcvd_date ? ` ${fmtQslDate(r.qsl_rcvd_date)}` : ''}</td>
<td className="py-1 px-2 text-muted-foreground truncate max-w-[160px]">{r.qsl_via}</td>
</tr>
))}
</tbody>
</table>
)
) : service === 'pota' ? (
<div className="space-y-3">
{potaErr && <div className="text-xs rounded-md px-3 py-2 border border-destructive/30 bg-destructive/10 text-destructive">{potaErr}</div>}
{!potaRes && !potaErr && !potaSyncing && (
<div className="text-sm text-muted-foreground py-10 text-center">Click Sync hunter log to fetch your pota.app log and stamp park references.</div>
)}
{potaSyncing && <div className="text-sm text-muted-foreground py-10 text-center flex items-center justify-center gap-2"><Loader2 className="size-4 animate-spin" /> Syncing with pota.app</div>}
{potaRes && (
<>
<div className="text-xs rounded-md px-3 py-2 border border-emerald-300 bg-emerald-50 text-emerald-800">
{potaRes.updated} QSO updated · {potaRes.added} added to log · {potaRes.already_tagged} already tagged · {potaRes.unmatched} unmatched (of {potaRes.fetched} hunter-log entries). Rescan the POTA award to count the new references.
</div>
{potaRes.unmatched_list?.length > 0 && (
<table className="w-full text-xs border-collapse">
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1.5 px-2">Activator</th><th className="py-1.5 px-2">Date UTC</th>
<th className="py-1.5 px-2">Band</th><th className="py-1.5 px-2">Park</th>
<th className="py-1.5 px-2">Why unmatched</th>
</tr>
</thead>
<tbody>
{potaRes.unmatched_list.map((u, i) => (
<tr key={i}
className={cn('border-b border-border/40', u.qso_id > 0 && 'cursor-pointer hover:bg-accent/30')}
onClick={() => u.qso_id > 0 && onEditQSO?.(u.qso_id)}
title={u.qso_id > 0 ? 'Open this QSO to fix it' : ''}>
<td className="py-1 px-2 font-mono font-bold">{u.activator}</td>
<td className="py-1 px-2 font-mono">{u.date}</td>
<td className="py-1 px-2">{u.band}</td>
<td className="py-1 px-2 font-mono">{u.reference}</td>
<td className="py-1 px-2 text-muted-foreground">
{u.reason}
{u.qso_id > 0 && <ExternalLink className="inline size-3 ml-1 opacity-60" />}
</td>
</tr>
))}
</tbody>
</table>
)}
</>
)}
</div>
) : showLog ? (
<div className="font-mono text-[11px] space-y-0.5 py-1"> <div className="font-mono text-[11px] space-y-0.5 py-1">
{logLines.length === 0 ? ( {logLines.length === 0 ? (
<div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> starting</div> <div className="text-muted-foreground flex items-center gap-2"><Loader2 className="size-3 animate-spin" /> starting</div>
@@ -270,7 +474,43 @@ export function QSLManagerPanel() {
)} )}
</div> </div>
{/* Action bar */} {/* Paper-QSL apply form */}
{service === 'paper' && (
<div className="flex items-end flex-wrap gap-3 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">QSL received</label>
<div className="flex gap-1.5">
<Select value={qslRcvd} onValueChange={setQslRcvd}>
<SelectTrigger className="h-8 w-28"><SelectValue /></SelectTrigger>
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
</Select>
<Input type="date" className="h-8 w-36" value={qslRcvdDate} onChange={(e) => setQslRcvdDate(e.target.value)} title="QSL received date" />
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">QSL sent</label>
<div className="flex gap-1.5">
<Select value={qslSent} onValueChange={setQslSent}>
<SelectTrigger className="h-8 w-28"><SelectValue /></SelectTrigger>
<SelectContent>{QSL_STATUSES.map((s) => <SelectItem key={s.v} value={s.v}>{s.label}</SelectItem>)}</SelectContent>
</Select>
<Input type="date" className="h-8 w-36" value={qslSentDate} onChange={(e) => setQslSentDate(e.target.value)} title="QSL sent date" />
</div>
</div>
<div className="flex flex-col gap-1">
<label className="text-[10px] uppercase tracking-wider text-muted-foreground">Via</label>
<Input className="h-8 w-40" value={qslVia} onChange={(e) => setQslVia(e.target.value)} placeholder="BUREAU / DIRECT / manager" />
</div>
<div className="flex-1" />
{paperMsg && <span className="text-[11px] text-muted-foreground self-center">{paperMsg}</span>}
<Button size="sm" onClick={applyPaper} disabled={paperBusy || paperSel.size === 0}>
Apply to {paperSel.size} selected
</Button>
</div>
)}
{/* Action bar (upload/download — not for POTA / Paper QSL) */}
{service !== 'pota' && service !== 'paper' && (
<div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0"> <div className="flex items-center justify-between gap-2 px-3 py-2 border-t border-border bg-muted/20 shrink-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={download} disabled={busy} <Button variant="outline" size="sm" onClick={download} disabled={busy}
@@ -286,6 +526,7 @@ export function QSLManagerPanel() {
<UploadCloud className="size-3.5" /> Upload {selectedCount} to {serviceLabel} <UploadCloud className="size-3.5" /> Upload {selectedCount} to {serviceLabel}
</Button> </Button>
</div> </div>
)}
</div> </div>
); );
} }
+137
View File
@@ -0,0 +1,137 @@
import { useEffect } from 'react';
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown } from 'lucide-react';
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
type Props = {
menu: QSOMenuState;
onClose: () => void;
onUpdateFromCty: (ids: number[]) => void;
onUpdateFromQRZ: (ids: number[]) => void;
onUpdateFromClublog?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void;
onExportFiltered?: () => void;
};
const UPLOAD_TARGETS: { service: string; label: string }[] = [
{ service: 'qrz', label: 'Send to QRZ.com' },
{ service: 'clublog', label: 'Send to Club Log' },
{ service: 'lotw', label: 'Send to LoTW' },
];
// Lightweight right-click menu for the QSO grids. AG Grid's native context
// menu is an Enterprise feature, so this is a plain floating menu driven by
// onCellContextMenu. Closes on any outside click, scroll or Escape.
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onExportSelected, onExportFiltered }: Props) {
useEffect(() => {
if (!menu) return;
const close = () => onClose();
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('mousedown', close);
window.addEventListener('scroll', close, true);
window.addEventListener('resize', close);
window.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('mousedown', close);
window.removeEventListener('scroll', close, true);
window.removeEventListener('resize', close);
window.removeEventListener('keydown', onKey);
};
}, [menu, onClose]);
if (!menu) return null;
const n = menu.ids.length;
// Keep the menu on-screen near the cursor.
const x = Math.min(menu.x, window.innerWidth - 248);
const y = Math.min(menu.y, window.innerHeight - (onSendTo ? 230 : 110));
return (
<div
className="fixed z-[200] min-w-[240px] rounded-md border border-border bg-popover shadow-lg py-1 text-sm"
style={{ left: x, top: y }}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="px-3 py-1 text-[11px] uppercase tracking-wider text-muted-foreground">
{n} QSO{n > 1 ? 's' : ''} selected
</div>
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onUpdateFromCty(menu.ids); onClose(); }}
>
<Globe2 className="size-4 text-primary" />
<span>Fix country &amp; zones from cty.dat</span>
</button>
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onUpdateFromQRZ(menu.ids); onClose(); }}
>
<RefreshCw className="size-4 text-sky-600" />
<span>Update from QRZ.com</span>
</button>
{onUpdateFromClublog && (
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onUpdateFromClublog(menu.ids); onClose(); }}
>
<BadgeCheck className="size-4 text-violet-600" />
<span>Update from ClubLog (exceptions)</span>
</button>
)}
{onSendRecording && (
<>
<div className="my-1 border-t border-border" />
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onSendRecording(menu.ids); onClose(); }}
>
<Mail className="size-4 text-rose-600" />
<span>Send recording by e-mail</span>
</button>
</>
)}
{(onExportSelected || onExportFiltered) && (
<>
<div className="my-1 border-t border-border" />
{onExportSelected && (
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onExportSelected(menu.ids); onClose(); }}
>
<FileDown className="size-4 text-sky-600" />
<span>Export selected to ADIF ({n})</span>
</button>
)}
{onExportFiltered && (
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onExportFiltered(); onClose(); }}
>
<FileDown className="size-4 text-violet-600" />
<span>Export filtered view to ADIF (no limit)</span>
</button>
)}
</>
)}
{onSendTo && (
<>
<div className="my-1 border-t border-border" />
{UPLOAD_TARGETS.map((t) => (
<button
key={t.service}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
onClick={() => { onSendTo(t.service, menu.ids); onClose(); }}
>
<Upload className="size-4 text-emerald-600" />
<span>{t.label}</span>
</button>
))}
</>
)}
</div>
);
}
+397 -140
View File
@@ -1,6 +1,9 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { Trash2, Search, Loader2 } from 'lucide-react'; import { Trash2, Search, Loader2 } from 'lucide-react';
import { LookupCallsign } from '../../wailsjs/go/main/App'; import { LookupCallsign, DXCCForCountry, GetAwardDefs, ComputeQSOAwardRefs } from '../../wailsjs/go/main/App';
import { AwardRefSelector } from '@/components/AwardRefSelector';
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';
@@ -13,11 +16,25 @@ import { Badge } from '@/components/ui/badge';
import { import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Combobox } from '@/components/ui/combobox';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { flagURL } from '@/lib/flags';
import type { QSOForm } from '@/types'; import type { QSOForm } from '@/types';
type QSO = QSOForm; type QSO = QSOForm;
// Quick prefix from a callsign (drops portable suffixes, keeps a slashed
// prefix). Read-only display, mirrors Log4OM's PFX box.
function pfxOf(call: string): string {
const c = (call || '').trim().toUpperCase();
if (!c) return '';
const base = c.includes('/') ? c.split('/')[0] : c;
let lastDigit = -1;
for (let i = 0; i < base.length; i++) if (base[i] >= '0' && base[i] <= '9') lastDigit = i;
return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base;
}
const BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','70cm','23cm']; const BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','70cm','23cm'];
const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9']; const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9'];
const QSL_STATUSES = [ const QSL_STATUSES = [
@@ -29,11 +46,46 @@ const QSL_STATUSES = [
]; ];
const PROP_MODES = ['_','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR']; const PROP_MODES = ['_','AS','AUE','AUR','BS','ECH','EME','ES','F2','F2M','FAI','GWAVE','INTERNET','ION','IRL','LOS','MS','RPT','RS','SAT','TEP','TR'];
// Confirmation channels — each maps to its QSO sent/received status, dates and
// (paper-only) via fields. Drives the "Manage Confirmation" editor and the
// live status grid (Log4OM style).
type ConfDef = {
key: string; label: string;
sent?: keyof QSOForm; rcvd?: keyof QSOForm;
sentDate?: keyof QSOForm; rcvdDate?: keyof QSOForm;
via?: keyof QSOForm;
};
const CONFIRMATIONS: ConfDef[] = [
{ key: 'QSL', label: 'QSL (paper)', sent: 'qsl_sent', rcvd: 'qsl_rcvd', sentDate: 'qsl_sent_date', rcvdDate: 'qsl_rcvd_date', via: 'qsl_via' },
{ key: 'LOTW', label: 'LoTW', sent: 'lotw_sent', rcvd: 'lotw_rcvd', sentDate: 'lotw_sent_date', rcvdDate: 'lotw_rcvd_date' },
{ key: 'EQSL', label: 'eQSL', sent: 'eqsl_sent', rcvd: 'eqsl_rcvd', sentDate: 'eqsl_sent_date', rcvdDate: 'eqsl_rcvd_date' },
{ key: 'QRZCOM', label: 'QRZ.com', sent: 'qrzcom_qso_upload_status' as any, sentDate: 'qrzcom_qso_upload_date' as any, rcvd: 'qrzcom_qso_download_status' as any, rcvdDate: 'qrzcom_qso_download_date' as any },
{ key: 'CLUBLOG', label: 'Club Log', sent: 'clublog_qso_upload_status' as any, sentDate: 'clublog_qso_upload_date' as any },
{ key: 'HRDLOG', label: 'HRDLog', sent: 'hrdlog_qso_upload_status' as any, sentDate: 'hrdlog_qso_upload_date' as any },
];
// Colour-coded status cell for the confirmation grid.
function StatusCell({ value }: { value?: string }) {
const v = (value || '').toUpperCase();
// Empty = no value set yet → show a neutral dash, NOT "No" (which is the
// explicit "N" status). Mirrors the dropdown, which shows "—" for empty.
if (v === '') {
return <span className="block text-center text-[11px] text-muted-foreground"></span>;
}
const label = v === 'Y' ? 'Yes' : v === 'R' ? 'Requested' : v === 'I' ? 'Ignore' : v === 'M' ? 'Modified' : 'No';
const cls = v === 'Y' ? 'bg-emerald-600 text-white'
: v === 'R' ? 'bg-orange-400 text-white'
: v === 'I' ? 'bg-stone-400 text-white'
: 'bg-amber-400 text-amber-950';
return <span className={cn('block text-center text-[11px] font-semibold rounded px-1 py-0.5', cls)}>{label}</span>;
}
interface Props { interface Props {
qso: QSO; qso: QSO;
onSave: (q: QSO) => void; onSave: (q: QSO) => void;
onDelete: (id: number) => void; onDelete: (id: number) => void;
onClose: () => void; onClose: () => void;
countries?: string[];
} }
function toLocalISO(d: any): string { function toLocalISO(d: any): string {
@@ -49,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));
@@ -96,21 +131,83 @@ function QslSelect({ value, onChange }: { value?: string; onChange: (v: string)
); );
} }
export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) { export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [] }: Props) {
const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso))); const [draft, setDraft] = useState<QSO>(() => JSON.parse(JSON.stringify(qso)));
const [freqMhz, setFreqMhz] = useState(draft.freq_hz ? String(draft.freq_hz / 1_000_000) : ''); // Frequencies are edited as kHz + Hz (Log4OM style) and recombined on save.
const [freqRxMhz, setFreqRxMhz] = useState(draft.freq_rx_hz ? String(draft.freq_rx_hz / 1_000_000) : ''); const splitHz = (hz?: number) => hz
? { khz: String(Math.floor(hz / 1000)), hz: String(hz % 1000).padStart(3, '0') }
: { khz: '', hz: '' };
const f0 = splitHz(draft.freq_hz);
const fr0 = splitHz(draft.freq_rx_hz);
const [freqKHz, setFreqKHz] = useState(f0.khz);
const [freqHz, setFreqHz] = useState(f0.hz);
const [freqRxKHz, setFreqRxKHz] = useState(fr0.khz);
const [freqRxHz, setFreqRxHz] = useState(fr0.hz);
const [dateOn, setDateOn] = useState(toLocalISO(draft.qso_date)); const [dateOn, setDateOn] = useState(toLocalISO(draft.qso_date));
const [dateOff, setDateOff] = useState(toLocalISO(draft.qso_date_off)); const [dateOff, setDateOff] = useState(toLocalISO(draft.qso_date_off));
const [extrasText, setExtrasText] = useState(stringifyExtras(draft.extras)); const [endEnabled, setEndEnabled] = useState(!!draft.qso_date_off);
const [confSel, setConfSel] = useState('QSL'); // selected confirmation channel
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);
// === Award references (Log4OM-style tab) ===
// Manual refs are edited as a "CODE@REF;…" string; computed refs (DXCC, WAZ,
// WPX, …) are derived from the QSO by the backend and shown read-only.
const awardFieldRef = useRef<Record<string, string>>({});
const [awardRefs, setAwardRefs] = useState('');
const [computedRefs, setComputedRefs] = useState<Array<{ code: string; ref: string; name?: string }>>([]);
// Load award definitions once, then seed the editable manual refs from the QSO.
useEffect(() => {
GetAwardDefs()
.then(async (defs) => {
const list = (defs ?? []) as any[];
const fieldOf: Record<string, string> = {};
for (const d of list) fieldOf[String(d.code).toUpperCase()] = String(d.field || '').toLowerCase();
awardFieldRef.current = fieldOf;
// 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 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(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Recompute the read-only computed refs whenever a source field changes.
useEffect(() => {
const t = window.setTimeout(async () => {
try {
const all = (await ComputeQSOAwardRefs(draft as any)) ?? [];
setComputedRefs(all.filter((r: any) => !r.pickable).map((r: any) => ({ code: r.code, ref: r.ref, name: r.name })));
} catch { setComputedRefs([]); }
}, 250);
return () => window.clearTimeout(t);
}, [draft.dxcc, draft.cqz, draft.ituz, draft.cont, draft.state, draft.callsign, draft.notes, draft.band]);
function set<K extends keyof QSO>(key: K, value: QSO[K]) { function set<K extends keyof QSO>(key: K, value: QSO[K]) {
setDraft((d) => ({ ...d, [key]: value })); setDraft((d) => ({ ...d, [key]: value }));
} }
// Country drives the DXCC entity number (ADIF). The DXCC field is read-only;
// picking a Country resolves and stamps its DXCC# so they can't diverge.
async function onCountryChange(v: string) {
set('country', v);
try {
const n = await DXCCForCountry(v);
set('dxcc', (n && n > 0 ? n : undefined) as any);
} catch { /* leave DXCC as-is if resolution fails */ }
}
// Re-run a callsign lookup (QRZ/HamQTH + cty.dat) and merge the result into // Re-run a callsign lookup (QRZ/HamQTH + cty.dat) and merge the result into
// the draft — handy after correcting the callsign. Only overwrites the // the draft — handy after correcting the callsign. Only overwrites the
// lookup-derived fields; leaves call/band/mode/RST/dates alone. // lookup-derived fields; leaves call/band/mode/RST/dates alone.
@@ -159,16 +256,14 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
operator: (draft.operator ?? '').trim().toUpperCase(), operator: (draft.operator ?? '').trim().toUpperCase(),
my_grid: (draft.my_grid ?? '').trim().toUpperCase(), my_grid: (draft.my_grid ?? '').trim().toUpperCase(),
my_gridsquare_ext: (draft.my_gridsquare_ext ?? '').trim().toUpperCase(), my_gridsquare_ext: (draft.my_gridsquare_ext ?? '').trim().toUpperCase(),
iota: (draft.iota ?? '').trim().toUpperCase(), // iota / sota_ref / pota_ref are set below from the Award Refs tab.
sota_ref: (draft.sota_ref ?? '').trim().toUpperCase(),
pota_ref: (draft.pota_ref ?? '').trim().toUpperCase(),
my_iota: (draft.my_iota ?? '').trim().toUpperCase(), my_iota: (draft.my_iota ?? '').trim().toUpperCase(),
my_sota_ref: (draft.my_sota_ref ?? '').trim().toUpperCase(), my_sota_ref: (draft.my_sota_ref ?? '').trim().toUpperCase(),
my_pota_ref: (draft.my_pota_ref ?? '').trim().toUpperCase(), my_pota_ref: (draft.my_pota_ref ?? '').trim().toUpperCase(),
qso_date: parseLocalISO(dateOn) ?? new Date().toISOString(), qso_date: parseLocalISO(dateOn) ?? new Date().toISOString(),
qso_date_off: parseLocalISO(dateOff) ?? undefined, qso_date_off: endEnabled ? (parseLocalISO(dateOff) ?? undefined) : undefined,
freq_hz: freqMhz.trim() ? Math.round(parseFloat(freqMhz) * 1_000_000) : undefined, freq_hz: freqKHz.trim() ? parseInt(freqKHz, 10) * 1000 + (parseInt(freqHz, 10) || 0) : undefined,
freq_rx_hz: freqRxMhz.trim() ? Math.round(parseFloat(freqRxMhz) * 1_000_000) : undefined, freq_rx_hz: freqRxKHz.trim() ? parseInt(freqRxKHz, 10) * 1000 + (parseInt(freqRxHz, 10) || 0) : undefined,
dxcc: intOrUndef(draft.dxcc), dxcc: intOrUndef(draft.dxcc),
cqz: intOrUndef(draft.cqz), cqz: intOrUndef(draft.cqz),
ituz: intOrUndef(draft.ituz), ituz: intOrUndef(draft.ituz),
@@ -182,8 +277,18 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
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 dedicated columns, then route the picked refs back onto the payload
// (POTA/SOTA/IOTA → columns, WWFF/custom → extras).
out.iota = ''; out.sota_ref = ''; out.pota_ref = '';
applyAwardRefs(out, awardRefs, awardFieldRef.current);
onSave(out); onSave(out);
} }
@@ -210,17 +315,18 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
<DialogDescription className="sr-only">Edit fields for QSO #{draft.id}</DialogDescription> <DialogDescription className="sr-only">Edit fields for QSO #{draft.id}</DialogDescription>
</DialogHeader> </DialogHeader>
<Tabs defaultValue="basic" className="flex flex-col overflow-hidden min-h-0"> <Tabs defaultValue="qsoinfo" className="flex flex-col overflow-hidden min-h-0">
<TabsList className="px-3 overflow-x-auto"> <TabsList className="px-3 overflow-x-auto">
<TabsTrigger value="basic">Basic</TabsTrigger> <TabsTrigger value="qsoinfo">QSO Info</TabsTrigger>
<TabsTrigger value="contacted">Contacted</TabsTrigger> <TabsTrigger value="contact">Contact's details</TabsTrigger>
<TabsTrigger value="qsl">QSL</TabsTrigger> <TabsTrigger value="awards">Award Refs</TabsTrigger>
<TabsTrigger value="qsl">QSL Info</TabsTrigger>
<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="notes">Notes</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>
)} )}
@@ -234,106 +340,202 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
)} )}
<div className="overflow-y-auto px-5 py-4 flex-1"> <div className="overflow-y-auto px-5 py-4 flex-1">
<TabsContent value="basic" className="mt-0"> <TabsContent value="qsoinfo" className="mt-0">
<div className="grid grid-cols-6 gap-3"> {/* Top: Callsign + RST + Fetch */}
<F label="Callsign" span={6}> <div className="flex items-end gap-2 mb-3">
<div className="flex gap-2"> <div className="flex flex-col flex-1 min-w-0">
<Input className="font-mono text-lg font-bold tracking-wider uppercase h-11 flex-1" <Label>Callsign</Label>
value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} /> <Input className="font-mono text-lg font-bold tracking-wider uppercase h-10"
<Button type="button" variant="outline" className="h-11" onClick={fetchLookup} disabled={looking} value={draft.callsign ?? ''} onChange={(e) => set('callsign', e.target.value)} />
title="Look up this callsign (QRZ.com / HamQTH) and refresh name, QTH, country, grid, zones…"> </div>
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} <div className="flex flex-col w-20"><Label>S</Label>
Fetch <Input value={draft.rst_sent ?? ''} onChange={(e) => set('rst_sent', e.target.value)} className="font-mono" /></div>
</Button> <div className="flex flex-col w-20"><Label>R</Label>
<Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} className="font-mono" /></div>
<Button type="button" variant="outline" className="h-10" onClick={fetchLookup} disabled={looking}
title="Look up this callsign (QRZ.com / HamQTH) and refresh name, country, grid, zones…">
{looking ? <Loader2 className="size-4 animate-spin" /> : <Search className="size-4" />} Fetch
</Button>
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
{/* ── Left column ── */}
<div className="flex flex-col gap-2.5">
<div><Label>Name</Label><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></div>
<div className="flex items-center gap-2">
<Label className="w-20 shrink-0">Band</Label>
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}>
<SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
<SelectContent>{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
</Select>
</div> </div>
</F> <div className="flex items-center gap-2">
<F label="Start (UTC)" span={3}><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></F> <Label className="w-20 shrink-0">RX Band</Label>
<F label="End (UTC)" span={3}><Input type="datetime-local" value={dateOff} onChange={(e) => setDateOff(e.target.value)} /></F> <Select value={draft.band_rx || '_'} onValueChange={(v) => set('band_rx', v === '_' ? '' : v)}>
<F label="Band"> <SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
<Select value={draft.band || ''} onValueChange={(v) => set('band', v)}> <SelectContent><SelectItem value="_"></SelectItem>{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent>
<SelectTrigger><SelectValue /></SelectTrigger> </Select>
<SelectContent>{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)}</SelectContent> </div>
</Select> <div className="flex items-center gap-2">
</F> <Label className="w-20 shrink-0">Mode</Label>
<F label="Mode"> <Select value={draft.mode || ''} onValueChange={(v) => set('mode', v)}>
<Select value={draft.mode || ''} onValueChange={(v) => set('mode', v)}> <SelectTrigger className="h-8 flex-1"><SelectValue /></SelectTrigger>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectContent>{MODES.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
<SelectContent>{MODES.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent> </Select>
</Select> </div>
</F> <div className="flex items-center gap-2">
<F label="Submode"><Input value={draft.submode ?? ''} onChange={(e) => set('submode', e.target.value)} /></F> <Label className="w-20 shrink-0">Country</Label>
<F label="Band RX"> <Combobox value={draft.country ?? ''} options={countries} placeholder="Country"
<Select value={draft.band_rx || '_'} onValueChange={(v) => set('band_rx', v === '_' ? '' : v)}> onChange={onCountryChange} className="flex-1" />
<SelectTrigger><SelectValue /></SelectTrigger> </div>
<SelectContent> <div className="flex items-center gap-2">
<SelectItem value="_"></SelectItem> <Label className="w-20 shrink-0">ITU</Label>
{BANDS.map((b) => <SelectItem key={b} value={b}>{b}</SelectItem>)} <Input type="number" value={draft.ituz ?? ''} onChange={(e) => set('ituz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
</SelectContent> <Label>CQ</Label>
</Select> <Input type="number" value={draft.cqz ?? ''} onChange={(e) => set('cqz', intOrUndef(e.target.value) as any)} className="font-mono w-16 text-center" />
</F> <Input type="number" value={draft.dxcc ?? ''} readOnly tabIndex={-1} className="font-mono w-16 text-center bg-muted/60 text-muted-foreground cursor-not-allowed" title="DXCC entity # — set automatically from Country" />
<F label="Freq (MHz)"><Input value={freqMhz} onChange={(e) => setFreqMhz(e.target.value)} /></F> {flagURL(draft.dxcc) && <img src={flagURL(draft.dxcc)} alt="" className="h-4 rounded-[2px] border border-border/50" referrerPolicy="no-referrer" />}
<F label="Freq RX (MHz)"><Input value={freqRxMhz} onChange={(e) => setFreqRxMhz(e.target.value)} /></F> </div>
<F label="RST sent"><Input value={draft.rst_sent ?? ''} onChange={(e) => set('rst_sent', e.target.value)} /></F> <div className="flex items-center gap-2">
<F label="RST rcvd"><Input value={draft.rst_rcvd ?? ''} onChange={(e) => set('rst_rcvd', e.target.value)} /></F> <Label className="w-20 shrink-0">Freq</Label>
<F label="TX power (W)"><Input type="number" value={draft.tx_pwr ?? ''} onChange={(e) => set('tx_pwr', numOrUndef(e.target.value) as any)} /></F> <Input value={freqKHz} onChange={(e) => setFreqKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
<F label="Name" span={3}><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></F> <Input value={freqHz} onChange={(e) => setFreqHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
<F label="Country" span={2}><Input value={draft.country ?? ''} onChange={(e) => set('country', e.target.value)} /></F> </div>
<F label="Grid"><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} /></F> <div className="flex items-center gap-2">
<Label className="w-20 shrink-0">RX Freq</Label>
<Input value={freqRxKHz} onChange={(e) => setFreqRxKHz(e.target.value.replace(/\D/g, ''))} className="font-mono w-24" placeholder="kHz" />
<Input value={freqRxHz} onChange={(e) => setFreqRxHz(e.target.value.replace(/\D/g, ''))} maxLength={3} className="font-mono w-16" placeholder="Hz" />
</div>
</div>
{/* ── Right column ── */}
<div className="flex flex-col gap-2.5">
<div><Label>QSO Start (UTC)</Label><Input type="datetime-local" value={dateOn} onChange={(e) => setDateOn(e.target.value)} /></div>
<div>
<Label className="flex items-center gap-2">
<Checkbox checked={endEnabled} onCheckedChange={(c) => setEndEnabled(!!c)} /> QSO End (UTC)
</Label>
<Input type="datetime-local" value={dateOff} disabled={!endEnabled} onChange={(e) => setDateOff(e.target.value)} />
</div>
<div className="flex items-end gap-2">
<div className="flex flex-col flex-1"><Label>Grid</Label><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} className="font-mono uppercase" /></div>
<div className="flex flex-col w-24"><Label>PFX</Label><Input readOnly value={pfxOf(draft.callsign ?? '')} className="font-mono bg-muted/40" /></div>
</div>
<div><Label>Comment</Label><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></div>
<div><Label>Note</Label><Textarea rows={3} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></div>
</div>
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="contacted" className="mt-0"> <TabsContent value="contact" className="mt-0">
<div className="grid grid-cols-6 gap-3"> <div className="grid grid-cols-2 gap-x-6 gap-y-2.5">
<F label="Name" span={3}><Input value={draft.name ?? ''} onChange={(e) => set('name', e.target.value)} /></F> {/* Left column */}
<F label="QTH" span={3}><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></F> <div className="flex flex-col gap-2.5">
<F label="Address" span={6}><Input value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></F> <div><Label>County</Label><Input value={draft.cnty ?? ''} onChange={(e) => set('cnty', e.target.value)} /></div>
<F label="Email" span={3}><Input value={(draft as any).email ?? ''} onChange={(e) => (set as any)('email', e.target.value)} /></F> <div><Label>State</Label><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></div>
<F label="Web" span={3}><Input value={draft.web ?? ''} onChange={(e) => set('web', e.target.value)} /></F> <div><Label>QTH</Label><Input value={draft.qth ?? ''} onChange={(e) => set('qth', e.target.value)} /></div>
<F label="Country" span={2}><Input value={draft.country ?? ''} onChange={(e) => set('country', e.target.value)} /></F> <div><Label>Address</Label><Textarea rows={4} value={draft.address ?? ''} onChange={(e) => set('address', e.target.value)} /></div>
<F label="DXCC"><Input type="number" value={draft.dxcc ?? ''} onChange={(e) => set('dxcc', intOrUndef(e.target.value) as any)} /></F> </div>
<F label="Cont"><Input value={draft.cont ?? ''} onChange={(e) => set('cont', e.target.value)} /></F> {/* Right column */}
<F label="CQ zone"><Input type="number" value={draft.cqz ?? ''} onChange={(e) => set('cqz', intOrUndef(e.target.value) as any)} /></F> <div className="flex flex-col gap-2.5">
<F label="ITU zone"><Input type="number" value={draft.ituz ?? ''} onChange={(e) => set('ituz', intOrUndef(e.target.value) as any)} /></F> <div><Label>E-mail address</Label><Input value={(draft as any).email ?? ''} onChange={(e) => (set as any)('email', e.target.value)} /></div>
<F label="State"><Input value={draft.state ?? ''} onChange={(e) => set('state', e.target.value)} /></F> <div className="flex items-end gap-2">
<F label="County"><Input value={draft.cnty ?? ''} onChange={(e) => set('cnty', e.target.value)} /></F> <div className="flex flex-col flex-1"><Label>Lat</Label><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
<F label="Grid"><Input value={draft.grid ?? ''} onChange={(e) => set('grid', e.target.value)} /></F> <div className="flex flex-col flex-1"><Label>Lon</Label><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} className="font-mono" /></div>
<F label="Grid ext"><Input value={draft.gridsquare_ext ?? ''} onChange={(e) => set('gridsquare_ext', e.target.value)} /></F> </div>
<F label="VUCC grids" span={2}><Input value={draft.vucc_grids ?? ''} onChange={(e) => set('vucc_grids', e.target.value)} /></F> <div><Label>QSL Msg</Label><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></div>
<F label="IOTA"><Input value={draft.iota ?? ''} onChange={(e) => set('iota', e.target.value)} /></F> <div><Label>QSL Via</Label><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></div>
<F label="SOTA ref"><Input value={draft.sota_ref ?? ''} onChange={(e) => set('sota_ref', e.target.value)} /></F> </div>
<F label="POTA ref"><Input value={draft.pota_ref ?? ''} onChange={(e) => set('pota_ref', e.target.value)} /></F> </div>
<F label="Age"><Input type="number" value={draft.age ?? ''} onChange={(e) => set('age', intOrUndef(e.target.value) as any)} /></F> </TabsContent>
<F label="Latitude"><Input type="number" step="0.000001" value={draft.lat ?? ''} onChange={(e) => set('lat', numOrUndef(e.target.value) as any)} /></F>
<F label="Longitude"><Input type="number" step="0.000001" value={draft.lon ?? ''} onChange={(e) => set('lon', numOrUndef(e.target.value) as any)} /></F> <TabsContent value="awards" className="mt-0">
<F label="Rig (contacted)" span={3}><Input value={draft.rig ?? ''} onChange={(e) => set('rig', e.target.value)} /></F> <div className="grid grid-cols-[1fr_240px] gap-5">
<F label="Antenna (contacted)" span={3}><Input value={draft.ant ?? ''} onChange={(e) => set('ant', e.target.value)} /></F> {/* Left: pick reference-list awards (POTA/SOTA/IOTA/WWFF/…) */}
<div>
<AwardRefSelector dxcc={draft.dxcc} value={awardRefs} onChange={setAwardRefs} />
</div>
{/* Right: computed awards (read-only) derived from this QSO */}
<div className="flex flex-col gap-1.5 min-w-0">
<span className="text-xs font-semibold">Computed (automatic)</span>
<p className="text-[11px] text-muted-foreground leading-snug">
Derived from this QSO's fields (DXCC, zones, prefix, notes). Not editable here.
</p>
<div className="flex-1 overflow-auto border rounded-md text-xs min-h-[160px] max-h-[210px]">
{computedRefs.length === 0 ? (
<div className="px-2 py-1.5 text-[11px] text-muted-foreground">None yet.</div>
) : (
computedRefs.map((r) => (
<div key={`${r.code}@${r.ref}`} className="px-2 py-1 border-b border-border/30 last:border-0">
<span className="font-mono font-semibold">{r.code}@{r.ref}</span>
{r.name && <span className="text-[10px] text-muted-foreground ml-1.5 truncate">{r.name}</span>}
</div>
))
)}
</div>
</div>
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="qsl" className="mt-0"> <TabsContent value="qsl" className="mt-0">
<div className="grid grid-cols-6 gap-3"> {(() => {
<F label="QSL sent"><QslSelect value={draft.qsl_sent ?? ''} onChange={(v) => set('qsl_sent', v)} /></F> const def = CONFIRMATIONS.find((c) => c.key === confSel) ?? CONFIRMATIONS[0];
<F label="QSL rcvd"><QslSelect value={draft.qsl_rcvd ?? ''} onChange={(v) => set('qsl_rcvd', v)} /></F> const val = (k?: keyof QSOForm) => (k ? ((draft as any)[k] ?? '') : '');
<F label="QSL sent date"><Input value={draft.qsl_sent_date ?? ''} placeholder="YYYYMMDD" onChange={(e) => set('qsl_sent_date', e.target.value)} /></F> const put = (k: keyof QSOForm | undefined, v: any) => { if (k) (set as any)(k, v); };
<F label="QSL rcvd date"><Input value={draft.qsl_rcvd_date ?? ''} placeholder="YYYYMMDD" onChange={(e) => set('qsl_rcvd_date', e.target.value)} /></F> return (
<F label="QSL via" span={3}><Input value={draft.qsl_via ?? ''} onChange={(e) => set('qsl_via', e.target.value)} /></F> <div className="flex gap-6">
<F label="QSL message" span={3}><Input value={draft.qsl_msg ?? ''} onChange={(e) => set('qsl_msg', e.target.value)} /></F> {/* Left: edit one confirmation channel at a time */}
<F label="QSL message rcvd" span={6}><Input value={draft.qslmsg_rcvd ?? ''} onChange={(e) => set('qslmsg_rcvd', e.target.value)} /></F> <div className="flex-1 max-w-sm space-y-3">
<F label="LoTW sent"><QslSelect value={draft.lotw_sent ?? ''} onChange={(v) => set('lotw_sent', v)} /></F> <div>
<F label="LoTW rcvd"><QslSelect value={draft.lotw_rcvd ?? ''} onChange={(v) => set('lotw_rcvd', v)} /></F> <Label>Manage Confirmation</Label>
<F label="LoTW sent date"><Input value={draft.lotw_sent_date ?? ''} onChange={(e) => set('lotw_sent_date', e.target.value)} /></F> <Select value={confSel} onValueChange={setConfSel}>
<F label="LoTW rcvd date"><Input value={draft.lotw_rcvd_date ?? ''} onChange={(e) => set('lotw_rcvd_date', e.target.value)} /></F> <SelectTrigger><SelectValue /></SelectTrigger>
<F label="eQSL sent"><QslSelect value={draft.eqsl_sent ?? ''} onChange={(v) => set('eqsl_sent', v)} /></F> <SelectContent>{CONFIRMATIONS.map((c) => <SelectItem key={c.key} value={c.key}>{c.label}</SelectItem>)}</SelectContent>
<F label="eQSL rcvd"><QslSelect value={draft.eqsl_rcvd ?? ''} onChange={(v) => set('eqsl_rcvd', v)} /></F> </Select>
<F label="eQSL sent date"><Input value={draft.eqsl_sent_date ?? ''} onChange={(e) => set('eqsl_sent_date', e.target.value)} /></F> </div>
<F label="eQSL rcvd date"><Input value={draft.eqsl_rcvd_date ?? ''} onChange={(e) => set('eqsl_rcvd_date', e.target.value)} /></F> <div className="grid grid-cols-2 gap-3">
<F label="Clublog status" span={2}><Input value={draft.clublog_qso_upload_status ?? ''} onChange={(e) => set('clublog_qso_upload_status', e.target.value)} /></F> <div><Label>Sent</Label><QslSelect value={val(def.sent)} onChange={(v) => put(def.sent, v)} /></div>
<F label="Clublog date"><Input value={draft.clublog_qso_upload_date ?? ''} onChange={(e) => set('clublog_qso_upload_date', e.target.value)} /></F> <div><Label>Received</Label>
<F label="HRDLog status" span={2}><Input value={draft.hrdlog_qso_upload_status ?? ''} onChange={(e) => set('hrdlog_qso_upload_status', e.target.value)} /></F> {def.rcvd
<F label="HRDLog date"><Input value={draft.hrdlog_qso_upload_date ?? ''} onChange={(e) => set('hrdlog_qso_upload_date', e.target.value)} /></F> ? <QslSelect value={val(def.rcvd)} onChange={(v) => put(def.rcvd, v)} />
<F label="QRZ.com status" span={2}><Input value={draft.qrzcom_qso_upload_status ?? ''} onChange={(e) => set('qrzcom_qso_upload_status', e.target.value)} /></F> : <Input disabled value="—" />}
<F label="QRZ.com date"><Input value={draft.qrzcom_qso_upload_date ?? ''} onChange={(e) => set('qrzcom_qso_upload_date', e.target.value)} /></F> </div>
</div> <div><Label>Date sent</Label><Input value={val(def.sentDate)} placeholder="YYYYMMDD" onChange={(e) => put(def.sentDate, e.target.value)} className="font-mono" /></div>
<div><Label>Date received</Label><Input value={val(def.rcvdDate)} placeholder="YYYYMMDD" disabled={!def.rcvdDate} onChange={(e) => put(def.rcvdDate, e.target.value)} className="font-mono" /></div>
{def.via && (
<div className="col-span-2"><Label>Via</Label><Input value={val(def.via)} onChange={(e) => put(def.via, e.target.value)} placeholder="BUREAU / DIRECT / manager…" /></div>
)}
</div>
<p className="text-[11px] text-muted-foreground">
Pick a channel, edit it the table on the right updates live. Everything is written when you click <strong>Save changes</strong>.
</p>
</div>
{/* Right: live status grid for every channel */}
<div className="w-72 shrink-0">
<table className="w-full border-separate" style={{ borderSpacing: 4 }}>
<thead>
<tr className="text-[10px] uppercase tracking-wider text-muted-foreground">
<th className="text-left font-semibold">Type</th>
<th className="font-semibold">Sent</th>
<th className="font-semibold">Received</th>
</tr>
</thead>
<tbody>
{CONFIRMATIONS.map((c) => (
<tr key={c.key} className={cn('text-xs', c.key === confSel && 'bg-accent/40')}>
<td className="font-medium pr-2 py-0.5">{c.label}</td>
<td className="w-24"><StatusCell value={val(c.sent)} /></td>
<td className="w-24">{c.rcvd ? <StatusCell value={val(c.rcvd)} /> : <span className="block text-center text-[11px] text-muted-foreground"></span>}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
})()}
</TabsContent> </TabsContent>
<TabsContent value="contest" className="mt-0"> <TabsContent value="contest" className="mt-0">
@@ -372,7 +574,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
<F label="Operator" span={3}><Input value={draft.operator ?? ''} onChange={(e) => set('operator', e.target.value)} /></F> <F label="Operator" span={3}><Input value={draft.operator ?? ''} onChange={(e) => set('operator', e.target.value)} /></F>
<F label="My grid"><Input value={draft.my_grid ?? ''} onChange={(e) => set('my_grid', e.target.value)} /></F> <F label="My grid"><Input value={draft.my_grid ?? ''} onChange={(e) => set('my_grid', e.target.value)} /></F>
<F label="Grid ext"><Input value={draft.my_gridsquare_ext ?? ''} onChange={(e) => set('my_gridsquare_ext', e.target.value)} /></F> <F label="Grid ext"><Input value={draft.my_gridsquare_ext ?? ''} onChange={(e) => set('my_gridsquare_ext', e.target.value)} /></F>
<F label="Country" span={2}><Input value={draft.my_country ?? ''} onChange={(e) => set('my_country', e.target.value)} /></F> <F label="Country" span={2}><Combobox value={draft.my_country ?? ''} options={countries} placeholder="Country" onChange={(v) => set('my_country', v)} /></F>
<F label="State"><Input value={draft.my_state ?? ''} onChange={(e) => set('my_state', e.target.value)} /></F> <F label="State"><Input value={draft.my_state ?? ''} onChange={(e) => set('my_state', e.target.value)} /></F>
<F label="County"><Input value={draft.my_cnty ?? ''} onChange={(e) => set('my_cnty', e.target.value)} /></F> <F label="County"><Input value={draft.my_cnty ?? ''} onChange={(e) => set('my_cnty', e.target.value)} /></F>
<F label="DXCC"><Input type="number" value={draft.my_dxcc ?? ''} onChange={(e) => set('my_dxcc', intOrUndef(e.target.value) as any)} /></F> <F label="DXCC"><Input type="number" value={draft.my_dxcc ?? ''} onChange={(e) => set('my_dxcc', intOrUndef(e.target.value) as any)} /></F>
@@ -391,19 +593,74 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose }: Props) {
</div> </div>
</TabsContent> </TabsContent>
<TabsContent value="notes" className="mt-0"> <TabsContent value="moreadif" className="mt-0 space-y-4">
<div className="grid grid-cols-6 gap-3"> {/* Special activity (POTA/SOTA/WWFF/SIG) */}
<F label="Comment" span={6}><Input value={draft.comment ?? ''} onChange={(e) => set('comment', e.target.value)} /></F> <div>
<F label="Notes" span={6}><Textarea rows={6} value={draft.notes ?? ''} onChange={(e) => set('notes', e.target.value)} /></F> <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 &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> </div>
</TabsContent> </TabsContent>
<TabsContent value="extras" className="mt-0 space-y-2"> <TabsContent value="extras" className="mt-0">
<p className="text-xs text-muted-foreground"> <AdifExtrasEditor value={draft.extras} onChange={(next) => set('extras', next as any)} />
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> </TabsContent>
</div> </div>
</Tabs> </Tabs>
+85 -29
View File
@@ -4,13 +4,15 @@ import {
type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent, type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
} from 'ag-grid-community'; } from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react'; import { AgGridReact } from 'ag-grid-react';
import { Columns3 } from 'lucide-react'; import { Columns3, FilterX } from 'lucide-react';
import type { QSOForm } from '@/types'; import type { QSOForm } from '@/types';
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
// Register every Community feature once. v32+ requires explicit registration; // Register every Community feature once. v32+ requires explicit registration;
// AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/ // AllCommunityModule keeps it simple and pulls in sort/filter/resize/reorder/
@@ -45,6 +47,13 @@ type Props = {
total: number; total: number;
onRowDoubleClicked?: (q: QSOForm) => void; onRowDoubleClicked?: (q: QSOForm) => void;
onRowSelected?: (id: number | null) => void; onRowSelected?: (id: number | null) => void;
onUpdateFromCty?: (ids: number[]) => void;
onUpdateFromQRZ?: (ids: number[]) => void;
onUpdateFromClublog?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
onExportSelected?: (ids: number[]) => void;
onExportFiltered?: () => void;
}; };
const COL_STATE_KEY = 'hamlog.qsoColState.v2'; const COL_STATE_KEY = 'hamlog.qsoColState.v2';
@@ -65,8 +74,12 @@ function fmtDateUTC(s: any): string {
} }
function fmtDateOnly(s: any): string { function fmtDateOnly(s: any): string {
if (!s) return ''; if (!s) return '';
const d = new Date(s); const t = String(s).trim();
if (isNaN(d.getTime())) return s; // QSL/LoTW/eQSL/ClubLog dates are ADIF YYYYMMDD; upload dates may be ISO.
const m = t.match(/^(\d{4})(\d{2})(\d{2})/);
if (m) return `${m[1]}-${m[2]}-${m[3]}`;
const d = new Date(t);
if (isNaN(d.getTime())) return t;
const p = (n: number) => String(n).padStart(2, '0'); const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`; return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
} }
@@ -74,9 +87,11 @@ function fmtDateOnly(s: any): string {
// Full catalog of selectable columns, grouped for the picker. `defaultVisible` // Full catalog of selectable columns, grouped for the picker. `defaultVisible`
// = shown out of the box; anything else stays hidden until the user toggles // = shown out of the box; anything else stays hidden until the user toggles
// it in the Columns dialog. // it in the Columns dialog.
type ColEntry = ColDef<QSOForm> & { group: string; label: string; defaultVisible?: boolean }; export type ColEntry = ColDef<QSOForm> & { group: string; label: string; defaultVisible?: boolean };
const COL_CATALOG: ColEntry[] = [ // Shared so the Worked-before grid (which now also shows full QSO records)
// can offer the exact same column choices without duplicating the catalog.
export const COL_CATALOG: ColEntry[] = [
// ── QSO basics ── // ── QSO basics ──
{ group: 'QSO', label: 'Date UTC', colId: 'qso_date', headerName: 'Date UTC', field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true }, { group: 'QSO', label: 'Date UTC', colId: 'qso_date', headerName: 'Date UTC', field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value), sort: 'desc', defaultVisible: true },
{ group: 'QSO', label: 'Date Off', colId: 'qso_date_off', headerName: 'Date off', field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) }, { group: 'QSO', label: 'Date Off', colId: 'qso_date_off', headerName: 'Date off', field: 'qso_date_off' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateUTC(p.value) },
@@ -135,14 +150,19 @@ const COL_CATALOG: ColEntry[] = [
{ group: 'eQSL', label: 'eQSL sent date', colId: 'eqsl_sent_date', headerName: 'eQSL S date', field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'eQSL', label: 'eQSL sent date', colId: 'eqsl_sent_date', headerName: 'eQSL S date', field: 'eqsl_sent_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'eQSL', label: 'eQSL rcvd date', colId: 'eqsl_rcvd_date', headerName: 'eQSL R date', field: 'eqsl_rcvd_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
// ── Uploads ── // ── Uploads (online logbooks) ──
{ group: 'Uploads', label: 'ClubLog upload date', colId: 'clublog_qso_upload_date', headerName: 'ClubLog date', field: 'clublog_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) }, // ADIF models these as an "upload status/date" (= YOU pushed the QSO) and,
{ group: 'Uploads', label: 'ClubLog upload status', colId: 'clublog_qso_upload_status', headerName: 'ClubLog status', field: 'clublog_qso_upload_status' as any, width: 110 }, // for QRZ only, a "download status/date" (= it came back confirmed). We
{ group: 'Uploads', label: 'HRDLog upload date', colId: 'hrdlog_qso_upload_date', headerName: 'HRDLog date', field: 'hrdlog_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) }, // relabel to the same sent/rcvd wording as LoTW/eQSL. Club Log & HRDLog have
{ group: 'Uploads', label: 'HRDLog upload status', colId: 'hrdlog_qso_upload_status', headerName: 'HRDLog status', field: 'hrdlog_qso_upload_status' as any, width: 110 }, // NO rcvd field in ADIF — they're upload-only, so only "sent" is shown.
{ group: 'Uploads', label: 'QRZ.com upload date', colId: 'qrzcom_qso_upload_date', headerName: 'QRZ.com date', field: 'qrzcom_qso_upload_date' as any, width: 130, valueFormatter: (p) => fmtDateOnly(p.value) }, { group: 'Uploads', label: 'ClubLog sent', colId: 'clublog_qso_upload_status', headerName: 'ClubLog sent', field: 'clublog_qso_upload_status' as any, width: 100 },
{ group: 'Uploads', label: 'QRZ.com upload status', colId: 'qrzcom_qso_upload_status', headerName: 'QRZ.com status', field: 'qrzcom_qso_upload_status' as any, width: 110 }, { group: 'Uploads', label: 'ClubLog sent date', colId: 'clublog_qso_upload_date', headerName: 'ClubLog S date', field: 'clublog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'Uploads', label: 'QRZ.com confirmed', colId: 'qrzcom_qso_download_status', headerName: 'QRZ.com cfm', field: 'qrzcom_qso_download_status' as any, width: 100 }, { group: 'Uploads', label: 'HRDLog sent', colId: 'hrdlog_qso_upload_status', headerName: 'HRDLog sent', field: 'hrdlog_qso_upload_status' as any, width: 100 },
{ group: 'Uploads', label: 'HRDLog sent date', colId: 'hrdlog_qso_upload_date', headerName: 'HRDLog S date', field: 'hrdlog_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'Uploads', label: 'QRZ.com sent', colId: 'qrzcom_qso_upload_status', headerName: 'QRZ.com sent', field: 'qrzcom_qso_upload_status' as any, width: 100 },
{ group: 'Uploads', label: 'QRZ.com rcvd', colId: 'qrzcom_qso_download_status', headerName: 'QRZ.com rcvd', field: 'qrzcom_qso_download_status' as any, width: 100 },
{ group: 'Uploads', label: 'QRZ.com sent date', colId: 'qrzcom_qso_upload_date', headerName: 'QRZ.com S date', field: 'qrzcom_qso_upload_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
{ group: 'Uploads', label: 'QRZ.com rcvd date', colId: 'qrzcom_qso_download_date', headerName: 'QRZ.com R date', field: 'qrzcom_qso_download_date' as any, width: 120, valueFormatter: (p) => fmtDateOnly(p.value) },
// ── Contest ── // ── Contest ──
{ group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 }, { group: 'Contest', label: 'Contest ID', colId: 'contest_id', headerName: 'Contest', field: 'contest_id' as any, width: 110 },
@@ -190,14 +210,33 @@ const COL_CATALOG: ColEntry[] = [
{ group: 'Misc', label: 'Updated', colId: 'updated_at', headerName: 'Updated at', field: 'updated_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) }, { group: 'Misc', label: 'Updated', colId: 'updated_at', headerName: 'Updated at', field: 'updated_at' as any, width: 150, valueFormatter: (p) => fmtDateUTC(p.value) },
]; ];
const GROUP_ORDER = [ export const GROUP_ORDER = [
'QSO', 'Contacted', 'QSL', 'LoTW', 'eQSL', 'Uploads', 'QSO', 'Contacted', 'QSL', 'LoTW', 'eQSL', 'Uploads',
'Contest', 'Propagation', 'My station', 'Misc', 'Contest', 'Propagation', 'My station', 'Misc',
]; ];
export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Props) { export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onExportSelected, onExportFiltered }: Props) {
const gridRef = useRef<any>(null); const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null);
// Right-click: if the clicked row isn't already part of the selection,
// select just it; then open the bulk-action menu on the whole selection.
function onCellContextMenu(e: any) {
const ev = e.event as MouseEvent | undefined;
ev?.preventDefault();
const api = gridRef.current?.api;
if (!api) return;
if (e.node && !e.node.isSelected()) {
api.deselectAll();
e.node.setSelected(true);
}
const ids = (api.getSelectedRows() as QSOForm[])
.map((r) => r.id as number)
.filter((n) => !!n);
if (ids.length === 0) return;
setMenu({ x: ev?.clientX ?? 0, y: ev?.clientY ?? 0, ids });
}
// Compute initial column defs: all columns defined, but those not marked // Compute initial column defs: all columns defined, but those not marked
// defaultVisible start hidden. The user's saved state (loaded onGridReady) // defaultVisible start hidden. The user's saved state (loaded onGridReady)
@@ -215,21 +254,20 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
}), []); }), []);
function onGridReady(e: GridReadyEvent) { function onGridReady(e: GridReadyEvent) {
try { const local = loadLocal(COL_STATE_KEY);
const raw = localStorage.getItem(COL_STATE_KEY); if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
if (raw) { // Fall back to the portable DB copy when the local cache is empty
const state = JSON.parse(raw) as ColumnState[]; // (fresh machine / after a reinstall), then re-seed the cache.
if (Array.isArray(state)) { loadRemote(COL_STATE_KEY).then((remote) => {
e.api.applyColumnState({ state, applyOrder: true }); if (remote && !local) {
} e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
seedLocal(COL_STATE_KEY, remote);
} }
} catch {} });
} }
const saveColumnState = useCallback(() => { const saveColumnState = useCallback(() => {
try { const state = gridRef.current?.api?.getColumnState();
const state = gridRef.current?.api?.getColumnState(); if (state) saveState(COL_STATE_KEY, state);
if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state));
} catch {}
}, []); }, []);
function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) { function handleRowDoubleClicked(e: RowDoubleClickedEvent<QSOForm>) {
@@ -281,6 +319,10 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
return ( return (
<> <>
<div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20"> <div className="flex items-center justify-end gap-2 px-2.5 py-1 border-b border-border/60 bg-muted/20">
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
title="Clear all column filters">
<FilterX className="size-3.5" /> Clear filters
</Button>
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}> <Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
<Columns3 className="size-3.5" /> Columns <Columns3 className="size-3.5" /> Columns
</Button> </Button>
@@ -293,7 +335,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
rowData={rows} rowData={rows}
columnDefs={columnDefs} columnDefs={columnDefs}
defaultColDef={defaultColDef} defaultColDef={defaultColDef}
rowSelection={{ mode: 'singleRow', checkboxes: false, enableClickSelection: true }} rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
onGridReady={onGridReady} onGridReady={onGridReady}
onColumnResized={saveColumnState} onColumnResized={saveColumnState}
onColumnMoved={saveColumnState} onColumnMoved={saveColumnState}
@@ -302,6 +344,8 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
onSortChanged={saveColumnState} onSortChanged={saveColumnState}
onRowDoubleClicked={handleRowDoubleClicked} onRowDoubleClicked={handleRowDoubleClicked}
onSelectionChanged={onSelectionChanged} onSelectionChanged={onSelectionChanged}
onCellContextMenu={onCellContextMenu}
preventDefaultOnContextMenu
animateRows={false} animateRows={false}
suppressCellFocus suppressCellFocus
getRowId={(p) => String((p.data as any).id)} getRowId={(p) => String((p.data as any).id)}
@@ -309,6 +353,18 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
</div> </div>
</div> </div>
<QSOContextMenu
menu={menu}
onClose={() => setMenu(null)}
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
onUpdateFromClublog={onUpdateFromClublog}
onSendTo={onSendTo}
onSendRecording={onSendRecording}
onExportSelected={onExportSelected}
onExportFiltered={onExportFiltered}
/>
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}> <Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogContent className="max-w-3xl"> <DialogContent className="max-w-3xl">
<DialogHeader> <DialogHeader>
@@ -318,7 +374,7 @@ export function RecentQSOsGrid({ rows, onRowDoubleClicked, onRowSelected }: Prop
Your selection is saved. Your selection is saved.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto py-2"> <div className="grid grid-cols-3 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
{GROUP_ORDER.map((group) => { {GROUP_ORDER.map((group) => {
const cols = COL_CATALOG.filter((c) => c.group === group); const cols = COL_CATALOG.filter((c) => c.group === group);
if (cols.length === 0) return null; if (cols.length === 0) return null;
+703 -25
View File
@@ -3,7 +3,7 @@ import {
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2, ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight, ChevronDown, ChevronRight,
User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon, User, Database, Radio, Cog, Server, Award, Antenna as AntennaIcon,
Compass, Wifi, Construction, UploadCloud, Compass, Wifi, Construction, UploadCloud, Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider, GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
@@ -11,14 +11,21 @@ import {
GetCATSettings, SaveCATSettings, GetCATSettings, SaveCATSettings,
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile, ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop, GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
GetEmailSettings, SaveEmailSettings, TestEmail,
GetDVKMessages, SetDVKLabel, DVKStartRecord, DVKStopRecord, DVKPreview, DVKStop, GetDVKStatus,
ListClusterServers, SaveClusterServer, DeleteClusterServer, ListClusterServers, SaveClusterServer, DeleteClusterServer,
GetClusterAutoConnect, SetClusterAutoConnect, GetClusterAutoConnect, SetClusterAutoConnect,
ConnectClusterServer, DisconnectClusterServer, ConnectClusterServer, DisconnectClusterServer,
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus,
GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder, GetBackupSettings, SaveBackupSettings, RunBackupNow, PickBackupFolder,
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, QuitApp, CreateDatabase,
GetDataDir,
GetQSLDefaults, SaveQSLDefaults, GetQSLDefaults, SaveQSLDefaults,
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload,
GetPOTAToken, SavePOTAToken,
TestLoTWUpload, ListTQSLStationLocations, TestLoTWUpload, ListTQSLStationLocations,
ComputeStationInfo, ComputeStationInfo,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
@@ -131,6 +138,8 @@ interface Props {
Section IDs are stable strings adding new ones means adding a panel below. Section IDs are stable strings adding new ones means adding a panel below.
`disabled: true` greys them out and shows the "coming soon" placeholder. */ `disabled: true` greys them out and shows the "coming soon" placeholder. */
type SectionId = type SectionId =
| 'general'
| 'email'
| 'station' | 'station'
| 'profiles' | 'profiles'
| 'operating' | 'operating'
@@ -146,6 +155,7 @@ type SectionId =
| 'awards' | 'awards'
| 'cat' | 'cat'
| 'rotator' | 'rotator'
| 'winkeyer'
| 'antenna' | 'antenna'
| 'audio'; | 'audio';
@@ -165,15 +175,16 @@ const TREE: TreeNode[] = [
}, },
{ {
kind: 'group', label: 'Software Configuration', icon: Cog, defaultOpen: true, children: [ kind: 'group', label: 'Software Configuration', icon: Cog, defaultOpen: true, children: [
{ kind: 'item', label: 'General', id: 'general' },
{ kind: 'item', label: 'E-mail (SMTP)', id: 'email' },
{ kind: 'item', label: 'Callsign Lookup', id: 'lookup' }, { kind: 'item', label: 'Callsign Lookup', id: 'lookup' },
{ kind: 'group', label: 'Lists', icon: Database, defaultOpen: true, children: [ { kind: 'group', label: 'Lists', icon: Database, defaultOpen: true, children: [
{ kind: 'item', label: 'Bands', id: 'lists-bands' }, { kind: 'item', label: 'Bands', id: 'lists-bands' },
{ kind: 'item', label: 'Modes & default RST', id: 'lists-modes' }, { kind: 'item', label: 'Modes & default RST', id: 'lists-modes' },
]}, ]},
{ kind: 'item', label: 'DX Cluster', id: 'cluster' }, { kind: 'item', label: 'DX Cluster', id: 'cluster' },
{ kind: 'item', label: 'UDP integrations (WSJT-X, JTDX, MSHV…)', id: 'udp' }, { kind: 'item', label: 'UDP integrations', id: 'udp' },
{ kind: 'item', label: 'Database backup', id: 'backup' }, { kind: 'item', label: 'Database', id: 'database' },
{ kind: 'item', label: 'Database location', id: 'database' },
{ kind: 'item', label: 'Awards', id: 'awards', disabled: true }, { kind: 'item', label: 'Awards', id: 'awards', disabled: true },
], ],
}, },
@@ -181,8 +192,9 @@ const TREE: TreeNode[] = [
kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [ kind: 'group', label: 'Hardware Configuration', icon: Server, defaultOpen: true, children: [
{ kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' }, { kind: 'item', label: 'CAT interface (OmniRig)', id: 'cat' },
{ kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator' }, { kind: 'item', label: 'Rotator (PstRotator)', id: 'rotator' },
{ kind: 'item', label: 'CW Keyer (WinKeyer)', id: 'winkeyer' },
{ kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna', disabled: true }, { kind: 'item', label: 'Antenna (Ultrabeam)', id: 'antenna', disabled: true },
{ kind: 'item', label: 'Audio devices', id: 'audio', disabled: true }, { kind: 'item', label: 'Audio devices & voice keyer', id: 'audio' },
], ],
}, },
]; ];
@@ -199,11 +211,12 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
'lists-modes': 'Modes & default RST', 'lists-modes': 'Modes & default RST',
cluster: 'DX Cluster', cluster: 'DX Cluster',
backup: 'Database backup', backup: 'Database backup',
database: 'Database location', database: 'Database',
udp: 'UDP integrations', udp: 'UDP integrations',
awards: 'Awards', awards: 'Awards',
cat: 'CAT interface', cat: 'CAT interface',
rotator: 'Rotator', rotator: 'Rotator',
winkeyer: 'CW Keyer (WinKeyer)',
antenna: 'Antenna', antenna: 'Antenna',
audio: 'Audio devices', audio: 'Audio devices',
}; };
@@ -284,6 +297,21 @@ function SectionHeader({ title, hint }: { title: string; hint?: string }) {
); );
} }
// ProfileScopeNote flags that the panel's settings are saved per-profile, so
// the user knows which operating identity (F4BPO / TM2Q / …) they're editing.
function ProfileScopeNote({ profile }: { profile?: { name?: string; callsign?: string } }) {
return (
<div className="-mt-2 mb-4">
<span className="inline-flex items-center gap-1.5 rounded-md bg-primary/10 text-primary text-xs px-2.5 py-1">
<User className="size-3.5" />
Saved for profile <strong className="font-semibold">{profile?.name || '—'}</strong>
{profile?.callsign ? <span className="font-mono opacity-80">({profile.callsign})</span> : null}
switch profiles to edit another identity.
</span>
</div>
);
}
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) { function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
const label = SECTION_LABELS[id] ?? id; const label = SECTION_LABELS[id] ?? id;
const IconCmp = Icon ?? Construction; const IconCmp = Icon ?? Construction;
@@ -340,17 +368,91 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
const [rotatorTesting, setRotatorTesting] = useState(false); const [rotatorTesting, setRotatorTesting] = useState(false);
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null); const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
// WinKeyer CW keyer settings + macro editor.
type WKMac = { label: string; text: string };
type WKSettings = {
enabled: boolean; engine: string; esc_clears_call: boolean;
port: string; baud: number; wpm: number; weight: number;
lead_in_ms: number; tail_ms: number; ratio: number; farnsworth: number;
sidetone_hz: number; mode: string; swap: boolean; autospace: boolean;
use_ptt: boolean; serial_echo: boolean; macros: WKMac[];
};
const [wk, setWk] = useState<WKSettings>({
enabled: false, engine: 'winkeyer', esc_clears_call: true,
port: '', baud: 1200, wpm: 25, weight: 50, lead_in_ms: 10,
tail_ms: 50, ratio: 50, farnsworth: 0, sidetone_hz: 600, mode: 'iambic_b',
swap: false, autospace: true, use_ptt: false, serial_echo: true, macros: [],
});
const [wkPorts, setWkPorts] = useState<string[]>([]);
const setWkField = (patch: Partial<WKSettings>) => setWk((s) => ({ ...s, ...patch }));
// ── Audio (DVK + QSO recorder) ──
type AudioSettings = {
from_radio: string; to_radio: string; recording_device: string; listening_device: string;
qso_record: boolean; qso_dir: string; preroll_seconds: number;
ptt_method: 'none' | 'cat' | 'rts' | 'dtr'; ptt_port: string; format: 'wav' | 'mp3';
from_gain: number; mic_gain: number;
};
type AudioDev = { id: string; name: string; default: boolean };
const [audioCfg, setAudioCfg] = useState<AudioSettings>({
from_radio: '', to_radio: '', recording_device: '', listening_device: '',
qso_record: false, qso_dir: '', preroll_seconds: 8, ptt_method: 'none', ptt_port: '', format: 'wav',
from_gain: 100, mic_gain: 100,
});
const [audioInputs, setAudioInputs] = useState<AudioDev[]>([]);
const [audioOutputs, setAudioOutputs] = useState<AudioDev[]>([]);
const setAudioField = (patch: Partial<AudioSettings>) => setAudioCfg((s) => ({ ...s, ...patch }));
const reloadAudioDevices = () => {
ListAudioInputDevices().then((d) => setAudioInputs((d ?? []) as AudioDev[])).catch(() => {});
ListAudioOutputDevices().then((d) => setAudioOutputs((d ?? []) as AudioDev[])).catch(() => {});
};
// DVK voice-keyer messages (F1F6).
type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number };
type DVKStat = { recording: boolean; playing: boolean; rec_slot: number };
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
const [dvkErr, setDvkErr] = useState('');
// General behaviour prefs (machine-local, applied live via localStorage).
const [autofocusWB, setAutofocusWB] = useState(() => localStorage.getItem('opslog.autofocusWB') !== '0');
// E-mail / SMTP (send QSO recordings).
type EmailCfg = {
enabled: boolean; smtp_host: string; smtp_port: number; smtp_user: string; smtp_password: string;
from: string; encryption: 'ssl' | 'starttls' | 'none'; auth: boolean; auto_send: boolean; subject: string; body: string;
};
const [emailCfg, setEmailCfg] = useState<EmailCfg>({
enabled: false, smtp_host: '', smtp_port: 587, smtp_user: '', smtp_password: '',
from: '', encryption: 'starttls', auth: true, auto_send: false, subject: '', body: '',
});
const [emailMsg, setEmailMsg] = useState('');
const setEmailField = (patch: Partial<EmailCfg>) => setEmailCfg((s) => ({ ...s, ...patch }));
// ClubLog Country File (cty.xml) exception status.
type ClubInfo = { enabled: boolean; loaded: boolean; date: string; count: number };
const [clubInfo, setClubInfo] = useState<ClubInfo>({ enabled: false, loaded: false, date: '', count: 0 });
const [clubBusy, setClubBusy] = useState(false);
const [clubErr, setClubErr] = useState('');
useEffect(() => { GetClublogCtyInfo().then((i) => setClubInfo(i as ClubInfo)).catch(() => {}); }, []);
const reloadDvk = () => { GetDVKMessages().then((m) => setDvkMsgs((m ?? []) as DVKMsg[])).catch(() => {}); };
useEffect(() => {
GetDVKStatus().then((s) => setDvkStat(s as DVKStat)).catch(() => {});
const off = EventsOn('audio:status', (s: any) => setDvkStat(s as DVKStat));
return () => { off?.(); };
}, []);
type QSLDefaults = { type QSLDefaults = {
qsl_sent: string; qsl_rcvd: string; qsl_sent: string; qsl_rcvd: string;
lotw_sent: string; lotw_rcvd: string; lotw_sent: string; lotw_rcvd: string;
eqsl_sent: string; eqsl_rcvd: string; eqsl_sent: string; eqsl_rcvd: string;
clublog_status: string; hrdlog_status: string; qrzcom_status: string; clublog_status: string; hrdlog_status: string; qrzcom_status: string;
qrzcom_confirmed: string;
}; };
const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({ const [qslDefaults, setQslDefaults] = useState<QSLDefaults>({
qsl_sent: '', qsl_rcvd: '', qsl_sent: '', qsl_rcvd: '',
lotw_sent: '', lotw_rcvd: '', lotw_sent: '', lotw_rcvd: '',
eqsl_sent: '', eqsl_rcvd: '', eqsl_sent: '', eqsl_rcvd: '',
clublog_status: '', hrdlog_status: '', qrzcom_status: '', clublog_status: '', hrdlog_status: '', qrzcom_status: '',
qrzcom_confirmed: '',
}); });
// External services (logbook upload). One block per service; only QRZ is // External services (logbook upload). One block per service; only QRZ is
@@ -381,7 +483,12 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
const [stationLocations, setStationLocations] = useState<string[]>([]); const [stationLocations, setStationLocations] = useState<string[]>([]);
// Active tab in the External Services panel — lifted here because // Active tab in the External Services panel — lifted here because
// PANELS[selected]() is called as a function, so panels can't hold hooks. // PANELS[selected]() is called as a function, so panels can't hold hooks.
const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'hamqth' | 'lotw'>('qrz'); const [extSvcTab, setExtSvcTab] = useState<'qrz' | 'clublog' | 'hrdlog' | 'eqsl' | 'hamqth' | 'lotw' | 'pota'>('qrz');
// POTA hunter-log sync (stamps pota_ref on local QSOs from your pota.app log).
const [potaToken, setPotaToken] = useState('');
const [potaBusy, setPotaBusy] = useState(false);
const [potaResult, setPotaResult] = useState<{ ok: boolean; msg: string; unmatched?: any[] } | null>(null);
useEffect(() => { GetPOTAToken().then((t) => setPotaToken(t || '')).catch(() => {}); }, []);
const [backupCfg, setBackupCfg] = useState<mainModels.BackupSettings>({ const [backupCfg, setBackupCfg] = useState<mainModels.BackupSettings>({
enabled: false, folder: '', rotation: 5, zip: false, enabled: false, folder: '', rotation: 5, zip: false,
@@ -392,6 +499,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
const [dbSettings, setDbSettings] = useState<{ path: string; default_path: string; is_custom: boolean }>({ path: '', default_path: '', is_custom: false }); const [dbSettings, setDbSettings] = useState<{ path: string; default_path: string; is_custom: boolean }>({ path: '', default_path: '', is_custom: false });
const [dbMsg, setDbMsg] = useState(''); const [dbMsg, setDbMsg] = useState('');
const [dataDir, setDataDir] = useState('');
const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]); const [clusterServers, setClusterServers] = useState<ClusterServer[]>([]);
const [clusterAutoConnect, setClusterAutoConnectState] = useState(false); const [clusterAutoConnect, setClusterAutoConnectState] = useState(false);
@@ -479,10 +587,17 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
setQslDefaults(qd as any); setQslDefaults(qd as any);
setExtSvc(es as any); setExtSvc(es as any);
try { setDbSettings(await GetDatabaseSettings() as any); } catch {} try { setDbSettings(await GetDatabaseSettings() as any); } catch {}
try { setDataDir(await GetDataDir()); } catch {}
try { try {
const locs: any = await ListTQSLStationLocations(); const locs: any = await ListTQSLStationLocations();
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean)); setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
} catch { /* TQSL not installed — leave the dropdown empty */ } } catch { /* TQSL not installed — leave the dropdown empty */ }
try { setWk(await GetWinkeyerSettings() as any); } catch {}
try { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {}
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
reloadAudioDevices();
reloadDvk();
} catch (e: any) { } catch (e: any) {
setErr(String(e?.message ?? e)); setErr(String(e?.message ?? e));
} finally { } finally {
@@ -598,7 +713,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
}); });
} }
async function save() { async function save(close = true) {
setSaving(true); setErr(''); setMsg(''); setSaving(true); setErr(''); setMsg('');
try { try {
// Bands: dedup, lowercase, trim. Order = user's drag order. // Bands: dedup, lowercase, trim. Order = user's drag order.
@@ -636,6 +751,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
await SaveLookupSettings(lookup as any); await SaveLookupSettings(lookup as any);
await SaveCATSettings(catCfg as any); await SaveCATSettings(catCfg as any);
await SaveRotatorSettings(rotator as any); await SaveRotatorSettings(rotator as any);
await SaveWinkeyerSettings(wk as any);
await SaveAudioSettings(audioCfg as any);
await SaveEmailSettings(emailCfg as any);
await SaveBackupSettings(backupCfg as any); await SaveBackupSettings(backupCfg as any);
await SaveQSLDefaults(qslDefaults as any); await SaveQSLDefaults(qslDefaults as any);
await SaveExternalServices(extSvc as any); await SaveExternalServices(extSvc as any);
@@ -643,7 +761,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
setMsg('Settings saved.'); setMsg('Settings saved.');
onSaved(); onSaved();
setTimeout(onClose, 500); if (close) setTimeout(onClose, 500);
else setTimeout(() => setMsg(''), 2000);
} catch (e: any) { } catch (e: any) {
setErr(String(e?.message ?? e)); setErr(String(e?.message ?? e));
} finally { } finally {
@@ -788,7 +907,17 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
async function profileActivate() { async function profileActivate() {
if (!currentProfile) return; if (!currentProfile) return;
try { await ActivateProfile(currentProfile.id as number); await reloadProfiles(); onSaved(); } try {
await ActivateProfile(currentProfile.id as number);
await reloadProfiles();
// Per-profile settings follow the active identity — reload the panels
// that are now scoped to the newly-active profile.
const [ap, qd, es] = await Promise.all([GetActiveProfile(), GetQSLDefaults(), GetExternalServices()]);
setActiveProfile(ap as Profile);
setQslDefaults(qd as any);
setExtSvc(es as any);
onSaved();
}
catch (e: any) { setErr(String(e?.message ?? e)); } catch (e: any) { setErr(String(e?.message ?? e)); }
} }
async function profileRemove() { async function profileRemove() {
@@ -1436,6 +1565,153 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
); );
} }
function WinkeyerPanel() {
const setMacro = (i: number, patch: Partial<WKMac>) => setWk((s) => {
const macros = [...s.macros];
while (macros.length <= i) macros.push({ label: '', text: '' });
macros[i] = { ...macros[i], ...patch };
return { ...s, macros };
});
const num = (v: string, d: number) => { const n = parseInt(v, 10); return isNaN(n) ? d : n; };
return (
<>
<SectionHeader
title="CW Keyer (WinKeyer)"
hint="Drive a K1EL WinKeyer (WK1/2/3) over a serial port to send Morse from OpsLog. Enable it, pick the COM port and keying parameters, then connect from the keyer panel (Tools → WinKeyer CW keyer)."
/>
<div className="space-y-4 max-w-2xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={wk.enabled} onCheckedChange={(c) => setWkField({ enabled: !!c })} />
Enable CW keyer (shows the keyer panel)
</label>
<div className="grid grid-cols-4 gap-3 items-end">
<div className="space-y-1">
<Label>Keyer engine</Label>
<Select value={wk.engine} onValueChange={(v) => setWkField({ engine: v })}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="winkeyer">WinKeyer (serial)</SelectItem>
<SelectItem value="tci" disabled>TCI (coming soon)</SelectItem>
</SelectContent>
</Select>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer col-span-3 pb-1.5">
<Checkbox checked={wk.esc_clears_call} onCheckedChange={(c) => setWkField({ esc_clears_call: !!c })} />
ESC clears the callsign too (otherwise ESC only stops transmission)
</label>
</div>
<div className="grid grid-cols-4 gap-3">
<div className="space-y-1 col-span-2">
<Label>Serial port</Label>
<div className="flex items-center gap-2">
<Select value={wk.port || '_'} onValueChange={(v) => setWkField({ port: v === '_' ? '' : v })}>
<SelectTrigger className="h-8 flex-1"><SelectValue placeholder="— COM port —" /></SelectTrigger>
<SelectContent>
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button variant="ghost" size="sm" className="h-8 px-2" title="Reload ports"
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
<ArrowDown className="size-3.5 rotate-90" />
</Button>
</div>
</div>
<div className="space-y-1">
<Label>Baud</Label>
<Select value={String(wk.baud)} onValueChange={(v) => setWkField({ baud: num(v, 1200) })}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
{[1200, 9600].map((b) => <SelectItem key={b} value={String(b)}>{b}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label>Speed (WPM)</Label>
<Input type="number" min={5} max={99} value={wk.wpm} onChange={(e) => setWkField({ wpm: num(e.target.value, 25) })} className="font-mono" />
</div>
</div>
<div className="grid grid-cols-4 gap-3">
<div className="space-y-1">
<Label>Weight</Label>
<Input type="number" min={10} max={90} value={wk.weight} onChange={(e) => setWkField({ weight: num(e.target.value, 50) })} className="font-mono" />
</div>
<div className="space-y-1">
<Label>Lead-in (ms)</Label>
<Input type="number" min={0} value={wk.lead_in_ms} onChange={(e) => setWkField({ lead_in_ms: num(e.target.value, 10) })} className="font-mono" />
</div>
<div className="space-y-1">
<Label>Tail (ms)</Label>
<Input type="number" min={0} value={wk.tail_ms} onChange={(e) => setWkField({ tail_ms: num(e.target.value, 50) })} className="font-mono" />
</div>
<div className="space-y-1">
<Label>Ratio (33-66)</Label>
<Input type="number" min={33} max={66} value={wk.ratio} onChange={(e) => setWkField({ ratio: num(e.target.value, 50) })} className="font-mono" />
</div>
<div className="space-y-1">
<Label>Farnsworth</Label>
<Input type="number" min={0} max={99} value={wk.farnsworth} onChange={(e) => setWkField({ farnsworth: num(e.target.value, 0) })} className="font-mono" />
</div>
<div className="space-y-1">
<Label>Sidetone (Hz)</Label>
<Input type="number" min={0} value={wk.sidetone_hz} onChange={(e) => setWkField({ sidetone_hz: num(e.target.value, 600) })} className="font-mono" />
</div>
<div className="space-y-1 col-span-2">
<Label>Paddle mode</Label>
<Select value={wk.mode} onValueChange={(v) => setWkField({ mode: v })}>
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="iambic_b">Iambic B</SelectItem>
<SelectItem value="iambic_a">Iambic A</SelectItem>
<SelectItem value="ultimatic">Ultimatic</SelectItem>
<SelectItem value="bug">Bug</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-wrap gap-x-5 gap-y-2">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={wk.swap} onCheckedChange={(c) => setWkField({ swap: !!c })} /> Swap paddles
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={wk.autospace} onCheckedChange={(c) => setWkField({ autospace: !!c })} /> Auto-space
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={wk.use_ptt} onCheckedChange={(c) => setWkField({ use_ptt: !!c })} /> Key PTT
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={wk.serial_echo} onCheckedChange={(c) => setWkField({ serial_echo: !!c })} /> Serial echo
</label>
</div>
{/* Macro editor */}
<div className="border-t border-border/60 pt-3">
<Label className="text-sm font-medium">CW message macros (F1)</Label>
<p className="text-[11px] text-muted-foreground mb-2">
Use variables: <span className="font-mono">&lt;MY_CALL&gt; &lt;CALL&gt; &lt;STX&gt; &lt;STRX&gt; &lt;MY_NAME&gt; &lt;HIS_NAME&gt; &lt;MY_QTH&gt; &lt;GRID&gt; &lt;CONT_TX&gt; &lt;n&gt;</span> (cut numbers: 9N, 0T). <span className="font-mono">*</span>=my call, <span className="font-mono">!</span>=his call.
</p>
<div className="space-y-1.5 max-h-[34vh] overflow-y-auto pr-1">
{Array.from({ length: 12 }).map((_, i) => {
const m = wk.macros[i] ?? { label: '', text: '' };
return (
<div key={i} className="flex items-center gap-2">
<span className="font-mono text-[10px] text-primary font-semibold w-6 shrink-0">F{i + 1}</span>
<Input className="h-8 w-28 shrink-0 text-xs" placeholder="Label" value={m.label} onChange={(e) => setMacro(i, { label: e.target.value })} />
<Input className="h-8 flex-1 font-mono text-xs" placeholder="CQ CQ DE <MY_CALL> K" value={m.text} onChange={(e) => setMacro(i, { text: e.target.value })} />
</div>
);
})}
</div>
</div>
</div>
</>
);
}
function statusForServer(id: number): ClusterServerStatus | undefined { function statusForServer(id: number): ClusterServerStatus | undefined {
return clusterStatuses.find((s) => (s.server_id as number) === id); return clusterStatuses.find((s) => (s.server_id as number) === id);
} }
@@ -1623,6 +1899,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
title="Confirmations" title="Confirmations"
hint="Default QSL / eQSL / LoTW / upload status applied to every QSO you log — manually or via UDP auto-log from WSJT-X / JTDX / MSHV. Leave a field blank to keep the QSO column empty." hint="Default QSL / eQSL / LoTW / upload status applied to every QSO you log — manually or via UDP auto-log from WSJT-X / JTDX / MSHV. Leave a field blank to keep the QSO column empty."
/> />
<ProfileScopeNote profile={activeProfileObj} />
<div className="space-y-3 max-w-2xl"> <div className="space-y-3 max-w-2xl">
{/* Paper QSL */} {/* Paper QSL */}
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end"> <div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
@@ -1663,13 +1940,13 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<div className="border-t border-border/60 pt-3 space-y-3"> <div className="border-t border-border/60 pt-3 space-y-3">
<div className="text-[11px] text-muted-foreground"> <div className="text-[11px] text-muted-foreground">
Upload status fields (Clublog / HRDLog / QRZ.com) are stamped on every QSO; "N" is typical when you want OpsLog to track which QSOs still need to be uploaded. "Sent" = the QSO's upload status to the service; "N" is typical so OpsLog tracks which QSOs still need uploading. Club Log &amp; HRDLog are upload-only (no "rcvd" in ADIF); QRZ.com also has "Rcvd" = confirmed back.
</div> </div>
{/* Clublog */} {/* Clublog */}
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end"> <div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
<Label className="text-sm font-medium pb-1.5">Clublog</Label> <Label className="text-sm font-medium pb-1.5">Clublog</Label>
<div> <div>
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Upload status</Label> <Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Sent</Label>
{renderSelect('clublog_status', FULL_OPTIONS)} {renderSelect('clublog_status', FULL_OPTIONS)}
</div> </div>
<div /> <div />
@@ -1678,7 +1955,7 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end"> <div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
<Label className="text-sm font-medium pb-1.5">HRDLog</Label> <Label className="text-sm font-medium pb-1.5">HRDLog</Label>
<div> <div>
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Upload status</Label> <Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Sent</Label>
{renderSelect('hrdlog_status', FULL_OPTIONS)} {renderSelect('hrdlog_status', FULL_OPTIONS)}
</div> </div>
<div /> <div />
@@ -1687,10 +1964,13 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end"> <div className="grid grid-cols-[150px_1fr_1fr] gap-3 items-end">
<Label className="text-sm font-medium pb-1.5">QRZ.com</Label> <Label className="text-sm font-medium pb-1.5">QRZ.com</Label>
<div> <div>
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Upload status</Label> <Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Sent</Label>
{renderSelect('qrzcom_status', FULL_OPTIONS)} {renderSelect('qrzcom_status', FULL_OPTIONS)}
</div> </div>
<div /> <div>
<Label className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1 block">Rcvd</Label>
{renderSelect('qrzcom_confirmed', FULL_OPTIONS)}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1852,7 +2132,21 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
{ k: 'eqsl', label: 'EQSL' }, { k: 'eqsl', label: 'EQSL' },
{ k: 'hamqth', label: 'HAMQTH' }, { k: 'hamqth', label: 'HAMQTH' },
{ k: 'lotw', label: 'LOTW', ready: true }, { k: 'lotw', label: 'LOTW', ready: true },
{ k: 'pota', label: 'POTA', ready: true },
]; ];
async function savePotaToken() {
setPotaBusy(true);
setPotaResult(null);
try {
await SavePOTAToken(potaToken);
setPotaResult({ ok: true, msg: 'Token saved. Run the sync from the QSL Manager → POTA hunter log.' });
} catch (e: any) {
setPotaResult({ ok: false, msg: String(e?.message ?? e) });
} finally {
setPotaBusy(false);
}
}
const qrz = extSvc.qrz; const qrz = extSvc.qrz;
const setQrz = (patch: Partial<ExtServiceCfg>) => const setQrz = (patch: Partial<ExtServiceCfg>) =>
setExtSvc((s) => ({ ...s, qrz: { ...s.qrz, ...patch } })); setExtSvc((s) => ({ ...s, qrz: { ...s.qrz, ...patch } }));
@@ -1923,6 +2217,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
hint="Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 12 min delay so a mis-logged QSO can still be fixed first)." hint="Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 12 min delay so a mis-logged QSO can still be fixed first)."
/> />
<ProfileScopeNote profile={activeProfileObj} />
{/* Tab strip */} {/* Tab strip */}
<div className="flex flex-wrap gap-1 border-b border-border mb-4"> <div className="flex flex-wrap gap-1 border-b border-border mb-4">
{TABS.map((t) => ( {TABS.map((t) => (
@@ -2100,6 +2396,19 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<ArrowDown className="size-3.5 rotate-90" /> <ArrowDown className="size-3.5 rotate-90" />
</Button> </Button>
</div> </div>
<Label className="text-sm">Force station callsign</Label>
<div>
<Input
value={lotw.force_station_callsign}
onChange={(e) => setLotw({ force_station_callsign: e.target.value.toUpperCase() })}
placeholder="e.g. F4BPO/P — leave blank to use the QSO's own call"
className="font-mono uppercase w-64"
/>
<div className="text-[10px] text-muted-foreground mt-1">
Overrides STATION_CALLSIGN at sign time so one certificate can sign several calls
(F4BPO, F4BPO/P, TM2Q). Pick the matching Station Location above.
</div>
</div>
<Label className="text-sm">Key password</Label> <Label className="text-sm">Key password</Label>
<Input <Input
type="password" type="password"
@@ -2151,6 +2460,38 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</div> </div>
</div> </div>
</div> </div>
) : extSvcTab === 'pota' ? (
<div className="space-y-4 max-w-2xl">
<p className="text-xs text-muted-foreground leading-relaxed">
Update your QSOs with the park reference from your <strong>pota.app hunter log</strong>.
Paste your session token: log in at <span className="font-mono">pota.app</span>, open the browser
DevTools <strong>Network</strong> tab, click any <span className="font-mono">api.pota.app</span> request,
and copy the full <strong>Authorization</strong> header value. The token expires after a while re-copy it if the sync fails.
</p>
<div className="grid grid-cols-[170px_1fr] gap-3 items-start">
<Label className="text-sm pt-2">Session token</Label>
<Textarea
value={potaToken}
onChange={(e) => setPotaToken(e.target.value)}
placeholder="eyJ… (Authorization header from pota.app)"
className="font-mono text-[11px] h-20"
/>
</div>
<div className="flex items-center gap-3">
<Button onClick={savePotaToken} disabled={potaBusy}>
{potaBusy ? <><Loader2 className="size-4 mr-1.5 animate-spin" /> Saving</> : 'Save token'}
</Button>
<span className="text-[11px] text-muted-foreground">
Then run the sync from the <strong>QSL Manager</strong> tab service <strong>POTA hunter log</strong> (you can see and fix unmatched QSOs there).
</span>
</div>
{potaResult && (
<div className={cn('text-xs rounded-md px-3 py-2 border',
potaResult.ok ? 'border-emerald-300 bg-emerald-50 text-emerald-800' : 'border-destructive/30 bg-destructive/10 text-destructive')}>
{potaResult.msg}
</div>
)}
</div>
) : ( ) : (
<div className="flex flex-col items-center justify-center text-muted-foreground gap-2 py-16"> <div className="flex flex-col items-center justify-center text-muted-foreground gap-2 py-16">
<Construction className="size-10 opacity-30" /> <Construction className="size-10 opacity-30" />
@@ -2184,6 +2525,15 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
setDbMsg(`A copy was saved to:\n${p}\nand selected. Restart OpsLog to apply.`); setDbMsg(`A copy was saved to:\n${p}\nand selected. Restart OpsLog to apply.`);
} catch (e: any) { setErr(String(e?.message ?? e)); } } catch (e: any) { setErr(String(e?.message ?? e)); }
} }
async function createNew() {
try {
const p = await PickSaveDatabase();
if (!p) return;
await CreateDatabase(p);
await refreshDb();
setDbMsg(`New empty logbook created at:\n${p}\nand selected. Restart OpsLog to apply.`);
} catch (e: any) { setErr(String(e?.message ?? e)); }
}
async function resetDefault() { async function resetDefault() {
try { try {
await ResetDatabaseToDefault(); await ResetDatabaseToDefault();
@@ -2194,8 +2544,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
return ( return (
<> <>
<SectionHeader <SectionHeader
title="Database location" title="Database"
hint="Keep your log database wherever you like — another drive or a synced folder (Seafile, Dropbox…) — so it survives a Windows reinstall. Everything (QSOs, settings, lookup cache) lives in this one file." hint="Your whole log (QSOs, settings, lookup cache) lives in one SQLite file. Keep it wherever you like — another drive or a synced folder (Seafile, Dropbox…) — and back it up automatically."
/> />
<div className="space-y-4 max-w-2xl"> <div className="space-y-4 max-w-2xl">
<div className="space-y-1"> <div className="space-y-1">
@@ -2210,15 +2560,17 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={openExisting}>Open existing database</Button> <Button size="sm" onClick={createNew}><Plus className="size-3.5" /> New database</Button>
<Button variant="outline" size="sm" onClick={saveCopy}>Save a copy &amp; switch to it</Button> <Button variant="outline" size="sm" onClick={openExisting}>Open existing</Button>
<Button variant="outline" size="sm" onClick={saveCopy}>Save a copy &amp; switch</Button>
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>Reset to default</Button>} {dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>Reset to default</Button>}
</div> </div>
<div className="text-[11px] text-muted-foreground bg-amber-50 border border-amber-200 rounded-md p-2.5 leading-relaxed"> <div className="text-[11px] text-muted-foreground bg-amber-50 border border-amber-200 rounded-md p-2.5 leading-relaxed">
<strong>Open existing</strong> points OpsLog at a database file you already have (e.g. after reinstalling Windows).{' '} <strong>New</strong> creates a fresh empty logbook and switches to it (handy for a separate contest/SOTA log).{' '}
<strong>Save a copy</strong> writes the current database to a new place and switches to it.{' '} <strong>Open existing</strong> points OpsLog at a file you already have.{' '}
A database change takes effect on the next launch. <strong>Save a copy</strong> clones the current database elsewhere and switches to it.{' '}
Any database change takes effect on the next launch.
</div> </div>
{dbMsg && ( {dbMsg && (
@@ -2228,12 +2580,336 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
</div> </div>
)} )}
</div> </div>
{/* Data location */}
<div className="border-t border-border/60 mt-6 pt-5 space-y-3 max-w-2xl">
<div>
<div className="text-sm font-medium">Data location</div>
<div className="text-[11px] text-muted-foreground mt-0.5">
OpsLog is fully portable all data lives next to the executable so you can run it from a USB stick or reinstall Windows without losing anything.
</div>
</div>
<div className="space-y-1">
<Label>Current data directory</Label>
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
{dataDir || '—'}
</div>
</div>
</div>
{/* Backup settings, merged into this Database section. */}
<div className="border-t border-border/60 mt-6 pt-5">
{BackupPanel()}
</div>
</>
);
}
function AudioPanel() {
const deviceSelect = (
field: keyof AudioSettings,
devices: AudioDev[],
placeholder: string,
) => (
<Select
value={(audioCfg[field] as string) || '_'}
onValueChange={(v) => setAudioField({ [field]: v === '_' ? '' : v } as any)}
>
<SelectTrigger className="h-8"><SelectValue placeholder={placeholder} /></SelectTrigger>
<SelectContent>
<SelectItem value="_"> none / system default </SelectItem>
{devices.map((d) => (
<SelectItem key={d.id} value={d.id}>{d.name}{d.default ? ' (default)' : ''}</SelectItem>
))}
</SelectContent>
</Select>
);
return (
<>
<div className="flex items-start justify-between">
<SectionHeader
title="Audio devices & voice keyer"/>
<Button variant="outline" size="sm" className="h-7 text-[11px] shrink-0" onClick={reloadAudioDevices}>
Refresh devices
</Button>
</div>
<div className="space-y-3 max-w-2xl">
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">From Radio (RX in)</Label>
{deviceSelect('from_radio', audioInputs, 'Rig audio output → soundcard input')}
<Label className="text-sm">To Radio (TX out)</Label>
{deviceSelect('to_radio', audioOutputs, 'Soundcard output → rig mic/data in')}
<Label className="text-sm">Recording mic</Label>
{deviceSelect('recording_device', audioInputs, 'Your microphone (record DVK messages)')}
<Label className="text-sm">Listening (preview)</Label>
{deviceSelect('listening_device', audioOutputs, 'Local speakers for preview')}
</div>
<p className="text-[11px] text-muted-foreground">
<strong>From Radio</strong> = what you receive (used by the QSO recorder).{' '}
<strong>To Radio</strong> = where voice-keyer messages are transmitted.
</p>
</div>
<div className="border-t border-border/60 pt-3 space-y-3 max-w-2xl">
<h4 className="text-sm font-semibold text-foreground">QSO recorder</h4>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={audioCfg.qso_record} onCheckedChange={(c) => setAudioField({ qso_record: !!c })} />
Record every QSO to an audio file (From Radio + your mic)
</label>
<div className="grid grid-cols-[170px_1fr] gap-3 items-center">
<Label className="text-sm">Recordings folder</Label>
<div className="flex gap-2">
<Input value={audioCfg.qso_dir} onChange={(e) => setAudioField({ qso_dir: e.target.value })}
placeholder="C:\…\OpsLog\Recordings" className="h-8 font-mono text-xs" />
<Button variant="outline" size="sm" className="h-8 shrink-0"
onClick={() => PickAudioFolder().then((d) => { if (d) setAudioField({ qso_dir: d }); }).catch(() => {})}>
Browse
</Button>
</div>
<Label className="text-sm">Pre-roll (seconds)</Label>
<Input type="number" min={0} max={60} value={audioCfg.preroll_seconds}
onChange={(e) => setAudioField({ preroll_seconds: Math.max(0, Math.min(60, parseInt(e.target.value, 10) || 0)) })}
className="h-8 w-24 font-mono" />
<Label className="text-sm">File format</Label>
<Select value={audioCfg.format} onValueChange={(v) => setAudioField({ format: v as any })}>
<SelectTrigger className="h-8 w-40"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="wav">WAV (lossless, larger)</SelectItem>
<SelectItem value="mp3">MP3 (compressed, small)</SelectItem>
</SelectContent>
</Select>
<Label className="text-sm">From Radio level</Label>
<div className="flex items-center gap-2">
<input type="range" min={10} max={300} step={5} value={audioCfg.from_gain}
onChange={(e) => setAudioField({ from_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
<span className="font-mono text-xs w-12 text-right">{audioCfg.from_gain}%</span>
</div>
<Label className="text-sm">Mic level</Label>
<div className="flex items-center gap-2">
<input type="range" min={10} max={300} step={5} value={audioCfg.mic_gain}
onChange={(e) => setAudioField({ mic_gain: parseInt(e.target.value, 10) })} className="w-48 accent-primary" />
<span className="font-mono text-xs w-12 text-right">{audioCfg.mic_gain}%</span>
</div>
</div>
<p className="text-xs text-muted-foreground">If your voice is louder than the station, lower Mic level.</p>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={emailCfg.auto_send} onCheckedChange={(c) => setEmailField({ auto_send: !!c })} />
Auto-send the recording to the station by e-mail when I log a QSO
</label>
</div>
<div className="border-t border-border/60 pt-3 space-y-2 max-w-2xl">
<h4 className="text-sm font-semibold text-foreground">Voice keyer messages (F1F6)</h4>
<div className="rounded-md border border-border/60 p-2.5 space-y-2">
<div className="grid grid-cols-[120px_1fr] gap-2 items-center">
<Label className="text-sm">PTT method</Label>
<div className="flex gap-2 items-center">
<Select value={audioCfg.ptt_method} onValueChange={(v) => setAudioField({ ptt_method: v as any })}>
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="none">None (VOX)</SelectItem>
<SelectItem value="cat">CAT (OmniRig)</SelectItem>
<SelectItem value="rts">Serial RTS</SelectItem>
<SelectItem value="dtr">Serial DTR</SelectItem>
</SelectContent>
</Select>
{audioCfg.ptt_method !== 'none' && (
<Button variant="outline" size="sm" className="h-8" onClick={() => { setDvkErr(''); TestPTT().catch((e: any) => setDvkErr('PTT test: ' + String(e?.message ?? e))); }}>
Test PTT
</Button>
)}
</div>
{(audioCfg.ptt_method === 'rts' || audioCfg.ptt_method === 'dtr') && (
<>
<Label className="text-sm">PTT COM port</Label>
<div className="flex gap-2 items-center">
<Select value={audioCfg.ptt_port || '_'} onValueChange={(v) => setAudioField({ ptt_port: v === '_' ? '' : v })}>
<SelectTrigger className="h-8 w-44"><SelectValue placeholder="Pick a COM port" /></SelectTrigger>
<SelectContent>
<SelectItem value="_"> select </SelectItem>
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button variant="ghost" size="sm" className="h-8 text-[11px]"
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
Refresh
</Button>
</div>
</>
)}
</div>
</div>
{dvkErr && <p className="text-[11px] text-destructive">{dvkErr}</p>}
<div className="space-y-1.5">
{dvkMsgs.map((m) => {
const recHere = dvkStat.recording && dvkStat.rec_slot === m.slot;
const recBusy = dvkStat.recording && !recHere;
return (
<div key={m.slot} className="flex items-center gap-2">
<span className="w-7 font-mono text-xs font-bold text-muted-foreground">F{m.slot}</span>
<Input
className="h-8 flex-1"
placeholder={`Message ${m.slot} label (CQ, report, 73…)`}
value={m.label}
onChange={(e) => setDvkMsgs((ms) => ms.map((x) => x.slot === m.slot ? { ...x, label: e.target.value } : x))}
onBlur={(e) => SetDVKLabel(m.slot, e.target.value).catch(() => {})}
/>
<span className="w-16 text-[11px] text-muted-foreground text-right">
{m.has_audio ? `${m.duration_sec.toFixed(1)}s` : '—'}
</span>
<Button
type="button"
variant={recHere ? 'destructive' : 'outline'} size="sm" className="h-8 w-28 shrink-0 select-none touch-none"
disabled={recBusy}
onPointerDown={(e) => {
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
setDvkErr('');
DVKStartRecord(m.slot).catch((err) => setDvkErr('Record: ' + String(err?.message ?? err)));
}}
onPointerUp={() => {
DVKStopRecord().then(reloadDvk).catch((err) => setDvkErr('Save: ' + String(err?.message ?? err)));
}}
>
{recHere ? '● Recording…' : '● Hold to rec'}
</Button>
<Button
type="button"
variant="outline" size="sm" className="h-8 w-20 shrink-0"
disabled={!m.has_audio || dvkStat.recording}
onClick={() => (dvkStat.playing ? DVKStop() : DVKPreview(m.slot).catch((err) => setDvkErr('Play: ' + String(err?.message ?? err))))}
>
{dvkStat.playing ? '■ Stop' : '▶ Play'}
</Button>
</div>
);
})}
</div>
</div>
</>
);
}
function GeneralPanel() {
return (
<>
<SectionHeader title="General" hint="App behaviour preferences (saved instantly, machine-local)." />
<div className="space-y-4 max-w-lg">
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox
checked={autofocusWB}
onCheckedChange={(c) => { const v = !!c; setAutofocusWB(v); localStorage.setItem('opslog.autofocusWB', v ? '1' : '0'); }}
className="mt-0.5"
/>
<span>
Auto-focus "Worked before" for stations already worked
<span className="block text-xs text-muted-foreground mt-0.5">
When you type a callsign you've contacted before, OpsLog jumps to the Worked before tab. Turn off to stay on your current tab.
</span>
</span>
</label>
<div className="border-t border-border/60 pt-4 space-y-2">
<h4 className="text-sm font-semibold text-foreground">ClubLog exceptions (DXpedition overrides)</h4>
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox
checked={clubInfo.enabled}
onCheckedChange={async (c) => {
const v = !!c; setClubInfo((s) => ({ ...s, enabled: v })); setClubErr('');
try {
await SetClublogCtyEnabled(v);
let info = (await GetClublogCtyInfo()) as ClubInfo;
// First enable with no cached data → download it now.
if (v && !info.loaded) {
setClubBusy(true);
try { info = (await DownloadClublogCty()) as ClubInfo; }
finally { setClubBusy(false); }
}
setClubInfo(info);
} catch (e: any) { setClubErr(String(e?.message ?? e)); }
}}
className="mt-0.5"
/>
<span>
Use ClubLog Country File for callsign resolution
<span className="block text-xs text-muted-foreground mt-0.5">
Applies ClubLog's date-ranged full-callsign <strong>exceptions</strong> that cty.dat lacks e.g. VK2/SP9FIH
resolves to Lord Howe Island (not Australia) for the DXpedition dates. Used on entry, import, UDP, and the
right-click <em>Update from ClubLog</em>.
</span>
</span>
</label>
<div className="flex items-center gap-3 pl-6">
<Button variant="outline" size="sm" className="h-8" disabled={clubBusy}
onClick={() => { setClubBusy(true); setClubErr(''); DownloadClublogCty().then((i) => setClubInfo(i as ClubInfo)).catch((e: any) => setClubErr(String(e?.message ?? e))).finally(() => setClubBusy(false)); }}>
{clubBusy ? 'Downloading…' : (clubInfo.loaded ? 'Update ClubLog data' : 'Download ClubLog data')}
</Button>
<span className="text-[11px] text-muted-foreground">
{clubInfo.loaded
? `${clubInfo.count.toLocaleString()} exceptions${clubInfo.date ? ' · ' + clubInfo.date.slice(0, 10) : ''}`
: 'not downloaded'}
</span>
</div>
{clubErr && <p className="text-[11px] text-destructive pl-6">{clubErr}</p>}
</div>
</div>
</>
);
}
function EmailPanel() {
return (
<>
<SectionHeader title="E-mail"/>
<div className="space-y-3 max-w-2xl">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={emailCfg.enabled} onCheckedChange={(c) => setEmailField({ enabled: !!c })} />
Enable e-mail sending
</label>
<div className="grid grid-cols-[130px_1fr] gap-2 items-center">
<Label className="text-sm">SMTP server</Label>
<Input className="h-8" placeholder="ex5.mail.ovh.net" value={emailCfg.smtp_host} onChange={(e) => setEmailField({ smtp_host: e.target.value })} />
<Label className="text-sm">Port / encryption</Label>
<div className="flex gap-2 items-center">
<Input type="number" className="h-8 w-24 font-mono" value={emailCfg.smtp_port} onChange={(e) => setEmailField({ smtp_port: parseInt(e.target.value, 10) || 0 })} />
<Select value={emailCfg.encryption} onValueChange={(v) => setEmailField({ encryption: v as any })}>
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="starttls">STARTTLS (587)</SelectItem>
<SelectItem value="ssl">SSL/TLS (465)</SelectItem>
<SelectItem value="none">None</SelectItem>
</SelectContent>
</Select>
</div>
<div />
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={emailCfg.auth} onCheckedChange={(c) => setEmailField({ auth: !!c })} />
SMTP requires authorization
</label>
<Label className="text-sm">Username</Label>
<Input className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_user} onChange={(e) => setEmailField({ smtp_user: e.target.value })} />
<Label className="text-sm">Password</Label>
<Input type="password" className="h-8" disabled={!emailCfg.auth} value={emailCfg.smtp_password} onChange={(e) => setEmailField({ smtp_password: e.target.value })} />
<Label className="text-sm">From address</Label>
<Input className="h-8" placeholder="you@example.com" value={emailCfg.from} onChange={(e) => setEmailField({ from: e.target.value })} />
</div>
<div className="flex items-center gap-3">
<Button variant="outline" size="sm" className="h-8"
onClick={() => { setEmailMsg('Sending test…'); SaveEmailSettings(emailCfg as any).then(() => TestEmail('')).then(() => setEmailMsg('Test e-mail sent ✓')).catch((e: any) => setEmailMsg('Test failed: ' + String(e?.message ?? e))); }}>
Send test e-mail
</Button>
<span className="text-[11px] text-muted-foreground">{emailMsg}</span>
</div>
</div>
</> </>
); );
} }
// Map sections to their content + icon (for placeholder). // Map sections to their content + icon (for placeholder).
const PANELS: Record<SectionId, () => JSX.Element> = { const PANELS: Record<SectionId, () => JSX.Element> = {
general: GeneralPanel,
email: EmailPanel,
station: StationPanel, station: StationPanel,
profiles: ProfilesPanel, profiles: ProfilesPanel,
operating: OperatingPanelWrapper, operating: OperatingPanelWrapper,
@@ -2249,8 +2925,9 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
awards: () => <ComingSoon id="awards" icon={Award} />, awards: () => <ComingSoon id="awards" icon={Award} />,
cat: CATPanel, cat: CATPanel,
rotator: RotatorPanel, rotator: RotatorPanel,
winkeyer: WinkeyerPanel,
antenna: () => <ComingSoon id="antenna" icon={AntennaIcon} />, antenna: () => <ComingSoon id="antenna" icon={AntennaIcon} />,
audio: () => <ComingSoon id="audio" icon={Server} />, audio: AudioPanel,
}; };
return ( return (
@@ -2293,7 +2970,8 @@ export function SettingsModal({ onClose, onSaved, initialSection }: Props) {
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose} disabled={saving}>Cancel</Button> <Button variant="outline" onClick={onClose} disabled={saving}>Cancel</Button>
<Button onClick={save} disabled={saving || loading}>{saving ? 'Saving…' : 'Save all'}</Button> <Button variant="outline" onClick={() => save(false)} disabled={saving || loading}>{saving ? 'Saving…' : 'Save'}</Button>
<Button onClick={() => save(true)} disabled={saving || loading}>{saving ? 'Saving…' : 'Save and close'}</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
+195
View File
@@ -0,0 +1,195 @@
import { useEffect, useState } from 'react';
import { Radio, Square, Send, Plug, Power, RefreshCw, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
export interface WKMacro { label: string; text: string }
export interface WKStatus {
connected: boolean;
busy: boolean;
wpm: number;
version: number;
port: string;
error?: string;
}
interface Props {
status: WKStatus;
ports: string[];
port: string;
wpm: number;
macros: WKMacro[];
sent: string; // text echoed back by the keyer as it transmits
onSelectPort: (p: string) => void;
onRefreshPorts: () => void;
onConnect: () => void;
onDisconnect: () => void;
onSetSpeed: (wpm: number) => void;
onSend: (text: string) => void; // raw text (App resolves variables)
onSendMacro: (index: number) => void; // App resolves the macro + sends
onStop: () => void;
onClose: () => void; // disable the keyer (hide the panel)
sendOnType: boolean;
onToggleSendOnType: (on: boolean) => void;
onSendRaw: (chars: string) => void; // key typed chars as-is (no variables)
onBackspace: () => void; // remove last not-yet-keyed char
}
// WinkeyerPanel — Log4OM-style CW keyer operating window. Lives in the
// reserved space to the right of the F1-F5 tabs. Sends Morse via the WinKeyer
// hardware: free-text CW, one-click macros (F1…), live speed, and abort.
export function WinkeyerPanel({
status, ports, port, wpm, macros, sent,
onSelectPort, onRefreshPorts, onConnect, onDisconnect, onSetSpeed,
onSend, onSendMacro, onStop, onClose,
sendOnType, onToggleSendOnType, onSendRaw, onBackspace,
}: Props) {
const [cwText, setCwText] = useState('');
const [speed, setSpeed] = useState(wpm);
// Keep the local speed slider in sync when the device/config changes it.
useEffect(() => { setSpeed(status.connected ? status.wpm || wpm : wpm); }, [status.wpm, status.connected, wpm]);
const connected = status.connected;
function sendText() {
const t = cwText.trim();
if (t && !sendOnType) onSend(t); // in send-on-type the text already went out
setCwText('');
}
// In "send on type" mode, key each newly-typed char immediately, and send a
// WinKeyer backspace for each deleted char (removes it from the buffer if it
// hasn't been keyed yet). Only end-of-string edits are mirrored live.
function onCwChange(v: string) {
if (sendOnType && connected) {
const old = cwText;
if (v.length > old.length && v.startsWith(old)) {
onSendRaw(v.slice(old.length));
} else if (v.length < old.length && old.startsWith(v)) {
for (let i = 0; i < old.length - v.length; i++) onBackspace();
}
}
setCwText(v);
}
return (
<section className="flex flex-col gap-2 h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
{/* Header / connection bar */}
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
<Radio className="size-4 text-primary shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">WinKeyer</span>
<span className={cn('size-2 rounded-full', connected ? (status.busy ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500') : 'bg-muted-foreground/40')}
title={connected ? (status.busy ? 'Sending…' : `Connected (v${status.version})`) : 'Disconnected'} />
<div className="flex-1" />
{!connected ? (
<>
<Select value={port || '_'} onValueChange={(v) => onSelectPort(v === '_' ? '' : v)}>
<SelectTrigger className="h-7 w-28 text-xs"><SelectValue placeholder="COM port" /></SelectTrigger>
<SelectContent>
{ports.length === 0 && <SelectItem value="_" disabled>No ports</SelectItem>}
{ports.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
</SelectContent>
</Select>
<Button variant="ghost" size="sm" className="h-7 px-1.5" title="Refresh ports" onClick={onRefreshPorts}>
<RefreshCw className="size-3.5" />
</Button>
<Button size="sm" className="h-7" onClick={onConnect} disabled={!port}>
<Plug className="size-3.5" /> Connect
</Button>
</>
) : (
<Button variant="outline" size="sm" className="h-7" onClick={onDisconnect}>
<Power className="size-3.5" /> Disconnect
</Button>
)}
<Button variant="ghost" size="sm" className="h-7 px-1.5" title="Hide / disable WinKeyer" onClick={onClose}>
<X className="size-3.5" />
</Button>
</div>
<div className="flex flex-col gap-2 px-3 pb-2 min-h-0 overflow-y-auto">
{/* Speed */}
<div className="flex items-center gap-2">
<Label className="text-xs w-12 shrink-0">Speed</Label>
<input
type="range" min={5} max={50} value={speed}
onChange={(e) => setSpeed(parseInt(e.target.value, 10))}
onMouseUp={() => onSetSpeed(speed)}
onTouchEnd={() => onSetSpeed(speed)}
disabled={!connected}
className="flex-1 accent-primary"
/>
<span className="font-mono text-sm font-bold w-14 text-right">{speed} wpm</span>
</div>
{/* Live transmitted text (echoed by the keyer as it sends). */}
<div className="flex items-center gap-2">
<Label className="text-xs w-12 shrink-0">TX</Label>
<div className={cn(
'flex-1 min-w-0 h-8 rounded-md border border-border bg-muted/30 px-2.5 flex items-center font-mono text-sm tracking-wide truncate',
status.busy ? 'text-emerald-700' : 'text-muted-foreground',
)}>
{sent || <span className="opacity-50"></span>}
{status.busy && <span className="ml-0.5 animate-pulse"></span>}
</div>
</div>
{/* CW text */}
<div className="flex items-end gap-2">
<div className="flex flex-col flex-1 min-w-0">
<Label className="mb-1 h-3.5 text-xs flex items-center gap-2">
CW text
<label className="flex items-center gap-1 text-[10px] font-normal cursor-pointer text-muted-foreground"
title="Key each character live as you type (backspace removes un-sent chars)">
<input type="checkbox" className="accent-primary" checked={sendOnType}
onChange={(e) => onToggleSendOnType(e.target.checked)} />
send on type
</label>
</Label>
<Input
value={cwText}
onChange={(e) => onCwChange(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendText(); } }}
placeholder={sendOnType ? 'Type — sent live…' : 'Type and press Enter to send…'}
disabled={!connected}
className="font-mono uppercase"
/>
</div>
<Button size="sm" className="h-8" onClick={sendText} disabled={!connected}>
<Send className="size-3.5" /> {sendOnType ? 'Clear' : 'Send'}
</Button>
<Button variant="destructive" size="sm" className="h-8" onClick={onStop} disabled={!connected} title="Abort (clear keyer buffer)">
<Square className="size-3.5" /> Stop
</Button>
</div>
{/* Macro buttons F1… */}
<div className="grid grid-cols-3 gap-1.5">
{macros.map((m, i) => (
<button
key={i}
type="button"
onClick={() => onSendMacro(i)}
disabled={!connected}
title={m.text}
className={cn(
'flex flex-col items-start rounded-md border border-border px-2 py-1 text-left transition-colors',
connected ? 'hover:border-primary/60 hover:bg-accent/40' : 'opacity-50 cursor-not-allowed',
)}
>
<span className="text-[10px] font-mono text-primary font-semibold">F{i + 1}</span>
<span className="text-xs font-medium truncate w-full">{m.label || `Macro ${i + 1}`}</span>
</button>
))}
</div>
{status.error && <div className="text-[11px] text-rose-600">{status.error}</div>}
</div>
</section>
);
}
+65 -69
View File
@@ -1,17 +1,20 @@
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
import { import {
AllCommunityModule, ModuleRegistry, themeQuartz, AllCommunityModule, ModuleRegistry, themeQuartz,
type ColDef, type ColumnState, type GridReadyEvent, type ColDef, type ColumnState, type GridReadyEvent, type RowDoubleClickedEvent,
} from 'ag-grid-community'; } from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react'; import { AgGridReact } from 'ag-grid-react';
import { Columns3, Star } from 'lucide-react'; import { Columns3, FilterX, Star } from 'lucide-react';
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import type { WorkedBeforeView } from '@/types'; import type { WorkedBeforeView, QSOForm } from '@/types';
import { COL_CATALOG, GROUP_ORDER } from './RecentQSOsGrid';
import { QSOContextMenu, type QSOMenuState } from './QSOContextMenu';
import { loadLocal, loadRemote, saveState, seedLocal } from '@/lib/gridPrefs';
ModuleRegistry.registerModules([AllCommunityModule]); ModuleRegistry.registerModules([AllCommunityModule]);
@@ -37,23 +40,22 @@ const hamlogTheme = themeQuartz.withParams({
iconSize: 12, iconSize: 12,
}); });
type WorkedEntry = NonNullable<WorkedBeforeView['entries']>[number]; type WorkedEntry = QSOForm; // entries are now full QSO records
type Props = { type Props = {
wb: WorkedBeforeView | null; wb: WorkedBeforeView | null;
busy: boolean; busy: boolean;
currentCall: string; currentCall: string;
onRowDoubleClicked?: (q: QSOForm) => void;
onUpdateFromCty?: (ids: number[]) => void;
onUpdateFromQRZ?: (ids: number[]) => void;
onUpdateFromClublog?: (ids: number[]) => void;
onSendTo?: (service: string, ids: number[]) => void;
onSendRecording?: (ids: number[]) => void;
}; };
const COL_STATE_KEY = 'hamlog.workedBeforeColState.v1'; const COL_STATE_KEY = 'hamlog.workedBeforeColState.v2';
function fmtDateTime(s: any): string {
if (!s) return '';
const d = new Date(s);
if (isNaN(d.getTime())) return '';
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`;
}
function fmtDate(s: any): string { function fmtDate(s: any): string {
if (!s) return ''; if (!s) return '';
const d = new Date(s); const d = new Date(s);
@@ -62,52 +64,29 @@ function fmtDate(s: any): string {
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`; return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
} }
const bandPill = (p: any) => p.value export function WorkedBeforeGrid({ wb, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording }: Props) {
? <span style={{
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
backgroundColor: '#f0d9a8', color: '#7a4a14', lineHeight: '16px',
}}>{p.value}</span>
: '';
const modePill = (p: any) => p.value
? <span style={{
display: 'inline-block', padding: '2px 8px', borderRadius: 6,
fontFamily: 'ui-monospace, monospace', fontSize: 11, fontWeight: 600,
backgroundColor: '#d1fae5', color: '#047857', lineHeight: '16px',
}}>{p.value}</span>
: '';
// Single Y/N flag column renderer: green dot for "Y", grey dash otherwise.
const flagRenderer = (p: any) => {
if (p.value === 'Y') {
return <span style={{
display: 'inline-block', width: 16, height: 16, borderRadius: 4,
backgroundColor: '#10b981', color: 'white', textAlign: 'center',
fontSize: 10, fontWeight: 700, lineHeight: '16px',
}}>Y</span>;
}
return <span style={{ color: '#a8a29e' }}></span>;
};
type ColEntry = ColDef<WorkedEntry> & { group: string; label: string; defaultVisible?: boolean };
const COL_CATALOG: ColEntry[] = [
{ group: 'QSO', label: 'Date UTC', colId: 'qso_date', headerName: 'Date UTC', field: 'qso_date' as any, width: 150, cellClass: 'font-mono', valueFormatter: (p) => fmtDateTime(p.value), sort: 'desc', defaultVisible: true },
{ group: 'QSO', label: 'Band', colId: 'band', headerName: 'Band', field: 'band' as any, width: 75, cellClass: 'flex items-center', cellRenderer: bandPill, defaultVisible: true },
{ group: 'QSO', label: 'Mode', colId: 'mode', headerName: 'Mode', field: 'mode' as any, width: 80, cellClass: 'flex items-center', cellRenderer: modePill, defaultVisible: true },
{ group: 'QSO', label: 'RST sent', colId: 'rst_sent', headerName: 'RST sent', field: 'rst_sent' as any, width: 90, cellClass: 'font-mono', defaultVisible: true },
{ group: 'QSO', label: 'RST rcvd', colId: 'rst_rcvd', headerName: 'RST rcvd', field: 'rst_rcvd' as any, width: 90, cellClass: 'font-mono', defaultVisible: true },
{ group: 'QSL', label: 'QSL sent', colId: 'qsl_sent', headerName: 'QSL S', field: 'qsl_sent' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
{ group: 'QSL', label: 'QSL rcvd', colId: 'qsl_rcvd', headerName: 'QSL R', field: 'qsl_rcvd' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
{ group: 'LoTW', label: 'LoTW sent', colId: 'lotw_sent', headerName: 'LoTW S', field: 'lotw_sent' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
{ group: 'LoTW', label: 'LoTW rcvd', colId: 'lotw_rcvd', headerName: 'LoTW R', field: 'lotw_rcvd' as any, width: 70, cellClass: 'flex items-center', cellRenderer: flagRenderer, defaultVisible: true },
];
const GROUP_ORDER = ['QSO', 'QSL', 'LoTW'];
export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
const gridRef = useRef<any>(null); const gridRef = useRef<any>(null);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [menu, setMenu] = useState<QSOMenuState>(null);
function handleRowDoubleClicked(e: RowDoubleClickedEvent<WorkedEntry>) {
if (e.data && onRowDoubleClicked) onRowDoubleClicked(e.data);
}
function onCellContextMenu(e: any) {
const ev = e.event as MouseEvent | undefined;
ev?.preventDefault();
const api = gridRef.current?.api;
if (!api) return;
if (e.node && !e.node.isSelected()) {
api.deselectAll();
e.node.setSelected(true);
}
const ids = (api.getSelectedRows() as WorkedEntry[])
.map((r) => r.id as number)
.filter((n) => !!n);
if (ids.length === 0) return;
setMenu({ x: ev?.clientX ?? 0, y: ev?.clientY ?? 0, ids });
}
const hasCall = currentCall.trim() !== ''; const hasCall = currentCall.trim() !== '';
const count = wb?.count ?? 0; const count = wb?.count ?? 0;
@@ -123,19 +102,18 @@ export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
}), []); }), []);
function onGridReady(e: GridReadyEvent) { function onGridReady(e: GridReadyEvent) {
try { const local = loadLocal(COL_STATE_KEY);
const raw = localStorage.getItem(COL_STATE_KEY); if (local) e.api.applyColumnState({ state: local as ColumnState[], applyOrder: true });
if (raw) { loadRemote(COL_STATE_KEY).then((remote) => {
const state = JSON.parse(raw) as ColumnState[]; if (remote && !local) {
if (Array.isArray(state)) e.api.applyColumnState({ state, applyOrder: true }); e.api.applyColumnState({ state: remote as ColumnState[], applyOrder: true });
seedLocal(COL_STATE_KEY, remote);
} }
} catch {} });
} }
const saveColumnState = useCallback(() => { const saveColumnState = useCallback(() => {
try { const state = gridRef.current?.api?.getColumnState();
const state = gridRef.current?.api?.getColumnState(); if (state) saveState(COL_STATE_KEY, state);
if (state) localStorage.setItem(COL_STATE_KEY, JSON.stringify(state));
} catch {}
}, []); }, []);
function isColVisible(colId: string): boolean { function isColVisible(colId: string): boolean {
@@ -218,6 +196,10 @@ export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
</div> </div>
)} )}
<div className="flex-1" /> <div className="flex-1" />
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => gridRef.current?.api?.setFilterModel(null)}
title="Clear all column filters">
<FilterX className="size-3.5" /> Clear filters
</Button>
<Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}> <Button variant="ghost" size="sm" className="h-7 text-[11px]" onClick={() => setPickerOpen(true)}>
<Columns3 className="size-3.5" /> Columns <Columns3 className="size-3.5" /> Columns
</Button> </Button>
@@ -237,6 +219,10 @@ export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
onColumnPinned={saveColumnState} onColumnPinned={saveColumnState}
onColumnVisible={saveColumnState} onColumnVisible={saveColumnState}
onSortChanged={saveColumnState} onSortChanged={saveColumnState}
onRowDoubleClicked={handleRowDoubleClicked}
onCellContextMenu={onCellContextMenu}
preventDefaultOnContextMenu
rowSelection={{ mode: 'multiRow', checkboxes: false, headerCheckbox: false, enableClickSelection: true }}
animateRows={false} animateRows={false}
suppressCellFocus suppressCellFocus
getRowId={(p) => String((p.data as any).id)} getRowId={(p) => String((p.data as any).id)}
@@ -244,6 +230,16 @@ export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
</div> </div>
</div> </div>
<QSOContextMenu
menu={menu}
onClose={() => setMenu(null)}
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
onUpdateFromClublog={onUpdateFromClublog}
onSendTo={onSendTo}
onSendRecording={onSendRecording}
/>
{count > entries.length && ( {count > entries.length && (
<div className="text-center py-1 text-[11px] italic text-muted-foreground border-t border-border/60 bg-muted/30"> <div className="text-center py-1 text-[11px] italic text-muted-foreground border-t border-border/60 bg-muted/30">
+ {count - entries.length} older QSOs (not shown capped for performance) + {count - entries.length} older QSOs (not shown capped for performance)
@@ -251,19 +247,19 @@ export function WorkedBeforeGrid({ wb, busy, currentCall }: Props) {
)} )}
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}> <Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogContent className="max-w-md"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Worked-before columns</DialogTitle> <DialogTitle>Worked-before columns</DialogTitle>
<DialogDescription> <DialogDescription>
Pick the columns you want visible in the Worked-before table. Pick the columns you want visible in the Worked-before table.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="max-h-[60vh] overflow-y-auto py-2"> <div className="grid grid-cols-2 gap-4 max-h-[60vh] overflow-y-auto px-5 py-3">
{GROUP_ORDER.map((group) => { {GROUP_ORDER.map((group) => {
const cols = COL_CATALOG.filter((c) => c.group === group); const cols = COL_CATALOG.filter((c) => c.group === group);
if (cols.length === 0) return null; if (cols.length === 0) return null;
return ( return (
<div key={group} className="rounded-md border border-border p-2.5 mb-2"> <div key={group} className="rounded-md border border-border p-2.5">
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60"> <div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group}</span> <span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{group}</span>
<div className="flex gap-0.5"> <div className="flex gap-0.5">
+108
View File
@@ -0,0 +1,108 @@
// Shared helpers for per-QSO award references.
//
// In the UI a QSO's manually-assigned award references are edited as a single
// semicolon-delimited string of "CODE@REF" entries, e.g.
// "POTA@FR-11553;IOTA@EU-064"
// On save each entry is routed to the QSO field its award actually reads from
// (see internal/award/award.go): POTA/SOTA/IOTA have dedicated columns; WWFF
// and custom awards live in uppercase ADIF extras keys.
// parseAwardRefs turns "POTA@FR-11553;IOTA@EU-064" into
// { POTA: "FR-11553", IOTA: "EU-064" }. Repeated codes join with commas.
export function parseAwardRefs(v: string): Record<string, string> {
const out: Record<string, string> = {};
for (const entry of (v ?? '').split(';').filter(Boolean)) {
const at = entry.indexOf('@');
if (at <= 0) continue;
const code = entry.slice(0, at).toUpperCase();
const ref = entry.slice(at + 1).trim().toUpperCase();
if (!ref) continue;
out[code] = out[code] ? `${out[code]},${ref}` : ref;
}
return out;
}
// appendTokens adds space-separated tokens (a "A,B" ref string) to a text field,
// skipping any already present, so re-picking is idempotent.
function appendTokens(existing: string | undefined, refs: string): string {
let out = (existing ?? '').trim();
for (const tok of refs.split(',').map((s) => s.trim()).filter(Boolean)) {
const re = new RegExp(`(^|\\s)${tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\s|$)`, 'i');
if (!re.test(out)) out = out ? `${out} ${tok}` : tok;
}
return out;
}
// applyAwardRefs writes picked references onto a QSO payload using each award's
// scanned field. fieldOf maps an award CODE (uppercase) to its field name.
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>) {
const byCode = parseAwardRefs(awardRefs);
const extras: Record<string, string> = { ...(payload.extras ?? {}) };
for (const [code, ref] of Object.entries(byCode)) {
const field = fieldOf[code] || code.toLowerCase();
switch (field) {
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';
extras['SIG_INFO'] = ref;
break;
// QSOFIELDS awards read their reference from a free-text field (e.g. DDFM
// scans the note for "D06"). Picking such a reference appends its code(s)
// to that field so the matcher finds it.
case 'note': case 'notes':
payload.notes = appendTokens(payload.notes, ref);
break;
case 'comment':
payload.comment = appendTokens(payload.comment, ref);
break;
default:
extras[field.toUpperCase()] = ref;
break;
}
}
if (Object.keys(extras).length > 0) payload.extras = extras;
}
// awardRefValue reads a single award's stored reference from a QSO, inverse of
// applyAwardRefs. Used to seed the editor when opening an existing QSO.
export function awardRefValue(qso: any, code: string, field: string): string {
switch (field) {
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();
if (String(ex['SIG'] ?? '').toUpperCase() === 'WWFF') return String(ex['SIG_INFO'] ?? '').toUpperCase();
return '';
}
default: {
const ex = qso.extras ?? {};
return String(ex[field.toUpperCase()] ?? '').toUpperCase();
}
}
}
// buildAwardRefs reconstructs the "CODE@REF;…" editor string from a QSO for the
// given pickable awards (code → field). Only awards with a stored value appear.
export function buildAwardRefs(qso: any, pickable: Array<{ code: string; field: string }>): string {
const out: string[] = [];
for (const { code, field } of pickable) {
const v = awardRefValue(qso, code, field);
// A multi-reference field (n-fer POTA "US-6544,US-0680") becomes one
// editor entry per reference, so each shows on its own removable line.
for (const ref of v.split(/[,;]/).map((s) => s.trim()).filter(Boolean)) {
out.push(`${code.toUpperCase()}@${ref}`);
}
}
return out.join(';');
}
+43
View File
@@ -0,0 +1,43 @@
// Portable grid-column preferences (visibility / order / width / sort).
//
// Stored in the DB settings table (so they travel with the logbook and
// survive a reinstall) AND mirrored to the WebView localStorage as a fast,
// flicker-free cache. On a fresh machine localStorage is empty, so we fall
// back to the DB copy and re-seed the cache.
import { GetUIPref, SetUIPref } from '../../wailsjs/go/main/App';
// loadLocal reads the cached column state synchronously (used in onGridReady
// to apply instantly, before the async DB round-trip).
export function loadLocal(key: string): any[] | null {
try {
const raw = localStorage.getItem(key);
const v = raw ? JSON.parse(raw) : null;
return Array.isArray(v) ? v : null;
} catch {
return null;
}
}
// loadRemote pulls the portable copy from the DB (null if none / unset).
export async function loadRemote(key: string): Promise<any[] | null> {
try {
const v = await GetUIPref(key);
const parsed = v ? JSON.parse(v) : null;
return Array.isArray(parsed) ? parsed : null;
} catch {
return null;
}
}
// saveState write-throughs to both the cache and the DB (fire-and-forget).
export function saveState(key: string, state: any[]) {
const json = JSON.stringify(state);
try { localStorage.setItem(key, json); } catch { /* quota / private mode */ }
SetUIPref(key, json).catch(() => { /* DB unavailable — cache still holds it */ });
}
// seedLocal writes a value into the cache without touching the DB (used after
// hydrating the cache from the DB on a fresh machine).
export function seedLocal(key: string, state: any[]) {
try { localStorage.setItem(key, JSON.stringify(state)); } catch { /* ignore */ }
}
+49
View File
@@ -49,6 +49,19 @@ export function gridToLatLon(grid: string): { lat: number; lon: number } | null
return { lat, lon }; return { lat, lon };
} }
// gridSquareBounds returns the SW/NE corners of a Maidenhead square so a map
// can draw its outline. Half-extents shrink with locator precision.
export function gridSquareBounds(grid: string):
{ south: number; west: number; north: number; east: number } | null {
const c = gridToLatLon(grid);
if (!c) return null;
const g = grid.trim();
let dLon = 1, dLat = 0.5; // 4-char square: 2°×1°
if (g.length >= 6) { dLon = 1 / 24; dLat = 0.5 / 24; }
if (g.length >= 8) { dLon = 1 / 24 / 10; dLat = 0.5 / 24 / 10; }
return { south: c.lat - dLat, north: c.lat + dLat, west: c.lon - dLon, east: c.lon + dLon };
}
// PathInfo describes both short and long great-circle path between two // PathInfo describes both short and long great-circle path between two
// points. Bearing in degrees from true north (0360). Distance in km. // points. Bearing in degrees from true north (0360). Distance in km.
export interface PathInfo { export interface PathInfo {
@@ -88,5 +101,41 @@ export function pathBetween(fromGrid: string, toGrid: string): PathInfo | null {
}; };
} }
// greatCirclePoints returns n+1 [lat, lon] points along the short great-circle
// path between two lat/lon points (spherical slerp). Longitudes are unwrapped
// to stay continuous (no ±180 jump) so a map polyline draws as one smooth arc.
export function greatCirclePoints(
lat1: number, lon1: number, lat2: number, lon2: number, n = 96,
): [number, number][] {
const φ1 = toRad(lat1), λ1 = toRad(lon1);
const φ2 = toRad(lat2), λ2 = toRad(lon2);
// Angular distance between the two points.
const sinΔφ = Math.sin((φ2 - φ1) / 2);
const sinΔλ = Math.sin((λ2 - λ1) / 2);
const h = sinΔφ * sinΔφ + Math.cos(φ1) * Math.cos(φ2) * sinΔλ * sinΔλ;
const d = 2 * Math.asin(Math.min(1, Math.sqrt(h)));
const out: [number, number][] = [];
if (d === 0) return [[lat1, lon1]];
let prevLon = NaN;
for (let i = 0; i <= n; i++) {
const f = i / n;
const A = Math.sin((1 - f) * d) / Math.sin(d);
const B = Math.sin(f * d) / Math.sin(d);
const x = A * Math.cos(φ1) * Math.cos(λ1) + B * Math.cos(φ2) * Math.cos(λ2);
const y = A * Math.cos(φ1) * Math.sin(λ1) + B * Math.cos(φ2) * Math.sin(λ2);
const z = A * Math.sin(φ1) + B * Math.sin(φ2);
const lat = toDeg(Math.atan2(z, Math.sqrt(x * x + y * y)));
let lon = toDeg(Math.atan2(y, x));
// Unwrap longitude so the polyline never snaps across the whole map.
if (!Number.isNaN(prevLon)) {
while (lon - prevLon > 180) lon -= 360;
while (lon - prevLon < -180) lon += 360;
}
prevLon = lon;
out.push([lat, lon]);
}
return out;
}
function toRad(d: number): number { return (d * Math.PI) / 180; } function toRad(d: number): number { return (d * Math.PI) / 180; }
function toDeg(r: number): number { return (r * 180) / Math.PI; } function toDeg(r: number): number { return (r * 180) / Math.PI; }
+163 -3
View File
@@ -1,24 +1,42 @@
// 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 {awardref} from '../models';
import {cat} from '../models'; import {cat} from '../models';
import {cluster} from '../models'; import {cluster} from '../models';
import {extsvc} from '../models'; import {extsvc} from '../models';
import {winkeyer} from '../models';
import {audio} from '../models';
import {operating} from '../models'; 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>;
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>;
export function AwardFields():Promise<Array<string>>;
export function BulkUpdateQSL(arg1:Array<number>,arg2:main.QSLBulkUpdate):Promise<number>;
export function ClearLookupCache():Promise<void>; export function ClearLookupCache():Promise<void>;
export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>; export function ClusterSpotStatuses(arg1:Array<main.SpotQuery>):Promise<Array<main.SpotStatus>>;
export function ComputeQSOAwardRefs(arg1:qso.QSO):Promise<Array<main.QSOAwardRef>>;
export function ComputeStationInfo(arg1:string,arg2:string):Promise<main.StationInfoComputed>; export function ComputeStationInfo(arg1:string,arg2:string):Promise<main.StationInfoComputed>;
export function ConnectAllClusters():Promise<void>; export function ConnectAllClusters():Promise<void>;
@@ -27,8 +45,30 @@ export function ConnectClusterServer(arg1:number):Promise<void>;
export function CountQSO():Promise<number>; export function CountQSO():Promise<number>;
export function CountQSOFiltered(arg1:qso.QueryFilter):Promise<number>;
export function CreateDatabase(arg1:string):Promise<void>;
export function DVKCancelRecord():Promise<void>;
export function DVKPlay(arg1:number):Promise<void>;
export function DVKPreview(arg1:number):Promise<void>;
export function DVKStartRecord(arg1:number):Promise<void>;
export function DVKStop():Promise<void>;
export function DVKStopRecord():Promise<void>;
export function DXCCForCountry(arg1:string):Promise<number>;
export function DXCCName(arg1:number):Promise<string>;
export function DeleteAllQSO():Promise<number>; export function DeleteAllQSO():Promise<number>;
export function DeleteAwardReference(arg1:string,arg2:string):Promise<void>;
export function DeleteClusterServer(arg1:number):Promise<void>; export function DeleteClusterServer(arg1:number):Promise<void>;
export function DeleteOperatingAntenna(arg1:number):Promise<void>; export function DeleteOperatingAntenna(arg1:number):Promise<void>;
@@ -45,30 +85,64 @@ export function DisconnectAllClusters():Promise<void>;
export function DisconnectClusterServer(arg1:number):Promise<void>; export function DisconnectClusterServer(arg1:number):Promise<void>;
export function DownloadClublogCty():Promise<main.ClublogCtyInfo>;
export function DownloadConfirmations(arg1:string,arg2:boolean):Promise<void>; 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 ExportADIF(arg1:string):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 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>>; export function FindQSOsForUpload(arg1:string,arg2:string):Promise<Array<qso.UploadRow>>;
export function GetActiveProfile():Promise<profile.Profile>; export function GetActiveProfile():Promise<profile.Profile>;
export function GetAudioSettings():Promise<main.AudioSettings>;
export function GetAward(arg1:string):Promise<award.Result>;
export function GetAwardDefs():Promise<Array<award.Def>>;
export function GetAwardPresets():Promise<Array<awardref.Preset>>;
export function GetAwardReferenceMeta():Promise<Array<main.AwardRefMeta>>;
export function GetAwardStats(arg1:string):Promise<main.AwardStatsResult>;
export function GetAwards():Promise<Array<award.Result>>;
export function GetBackupSettings():Promise<main.BackupSettings>; export function GetBackupSettings():Promise<main.BackupSettings>;
export function GetCATSettings():Promise<main.CATSettings>; export function GetCATSettings():Promise<main.CATSettings>;
export function GetCATState():Promise<cat.RigState>; export function GetCATState():Promise<cat.RigState>;
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
export function GetClusterAutoConnect():Promise<boolean>; export function GetClusterAutoConnect():Promise<boolean>;
export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>; export function GetClusterStatus():Promise<Array<cluster.ServerStatus>>;
export function GetCtyDatInfo():Promise<main.CtyDatInfo>; export function GetCtyDatInfo():Promise<main.CtyDatInfo>;
export function GetDVKMessages():Promise<Array<main.DVKMessage>>;
export function GetDVKStatus():Promise<main.DVKStatus>;
export function GetDataDir():Promise<string>;
export function GetDatabaseSettings():Promise<main.DatabaseSettings>; export function GetDatabaseSettings():Promise<main.DatabaseSettings>;
export function GetEmailSettings():Promise<main.EmailSettings>;
export function GetExternalServices():Promise<extsvc.ExternalServices>; export function GetExternalServices():Promise<extsvc.ExternalServices>;
export function GetListsSettings():Promise<main.ListsSettings>; export function GetListsSettings():Promise<main.ListsSettings>;
@@ -77,6 +151,8 @@ export function GetLogFilePath():Promise<string>;
export function GetLookupSettings():Promise<main.LookupSettings>; export function GetLookupSettings():Promise<main.LookupSettings>;
export function GetPOTAToken():Promise<string>;
export function GetQSLDefaults():Promise<main.QSLDefaults>; export function GetQSLDefaults():Promise<main.QSLDefaults>;
export function GetQSO(arg1:number):Promise<qso.QSO>; export function GetQSO(arg1:number):Promise<qso.QSO>;
@@ -89,7 +165,25 @@ export function GetStartupStatus():Promise<main.StartupStatus>;
export function GetStationSettings():Promise<main.StationSettings>; export function GetStationSettings():Promise<main.StationSettings>;
export function ImportADIF(arg1:string,arg2:boolean):Promise<adif.ImportResult>; export function GetUIPref(arg1:string):Promise<string>;
export function GetWinkeyerSettings():Promise<main.WinkeyerSettings>;
export function GetWinkeyerStatus():Promise<winkeyer.Status>;
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
export function ImportADIF(arg1:string,arg2:string,arg3:boolean):Promise<adif.ImportResult>;
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
export function ImportAwards():Promise<main.AwardImportResult>;
export function ListAudioInputDevices():Promise<Array<audio.Device>>;
export function ListAudioOutputDevices():Promise<Array<audio.Device>>;
export function ListAwardReferences(arg1:string):Promise<Array<awardref.Ref>>;
export function ListClusterServers():Promise<Array<cluster.ServerConfig>>; export function ListClusterServers():Promise<Array<cluster.ServerConfig>>;
@@ -101,6 +195,10 @@ export function ListProfiles():Promise<Array<profile.Profile>>;
export function ListQSO(arg1:qso.ListFilter):Promise<Array<qso.QSO>>; export function ListQSO(arg1:qso.ListFilter):Promise<Array<qso.QSO>>;
export function ListQSOFiltered(arg1:qso.QueryFilter):Promise<Array<qso.QSO>>;
export function ListSerialPorts():Promise<Array<string>>;
export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>>; export function ListTQSLStationLocations():Promise<Array<extsvc.StationLocation>>;
export function ListUDPIntegrations():Promise<Array<udp.Config>>; export function ListUDPIntegrations():Promise<Array<udp.Config>>;
@@ -119,20 +217,34 @@ export function OpenExternalURL(arg1:string):Promise<void>;
export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefault>; export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefault>;
export function PickAudioFolder():Promise<string>;
export function PickBackupFolder():Promise<string>; export function PickBackupFolder():Promise<string>;
export function PickOpenDatabase():Promise<string>; export function PickOpenDatabase():Promise<string>;
export function PickSaveDatabase():Promise<string>; export function PickSaveDatabase():Promise<string>;
export function PopulateBuiltinReferences(arg1:string):Promise<number>;
export function QSOAudioBegin():Promise<boolean>;
export function QSOAudioCancel():Promise<void>;
export function QuitApp():Promise<void>; export function QuitApp():Promise<void>;
export function RefreshCtyDat():Promise<main.CtyDatInfo>; export function RefreshCtyDat():Promise<main.CtyDatInfo>;
export function ReloadUDPIntegrations():Promise<Array<string>>; export function ReloadUDPIntegrations():Promise<Array<string>>;
export function ReplaceAwardReferences(arg1:string,arg2:Array<awardref.Ref>):Promise<number>;
export function ResetAwardDefs():Promise<Array<award.Def>>;
export function ResetDatabaseToDefault():Promise<void>; export function ResetDatabaseToDefault():Promise<void>;
export function RestartQSORecorder():Promise<void>;
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>; export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
export function RotatorPark():Promise<void>; export function RotatorPark():Promise<void>;
@@ -143,12 +255,20 @@ export function RunBackupNow():Promise<string>;
export function SaveADIFFile():Promise<string>; export function SaveADIFFile():Promise<string>;
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
export function SaveAwardDefs(arg1:Array<award.Def>):Promise<void>;
export function SaveAwardReference(arg1:string,arg2:awardref.Ref):Promise<void>;
export function SaveBackupSettings(arg1:main.BackupSettings):Promise<void>; export function SaveBackupSettings(arg1:main.BackupSettings):Promise<void>;
export function SaveCATSettings(arg1:main.CATSettings):Promise<void>; export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>; export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>; export function SaveExternalServices(arg1:extsvc.ExternalServices):Promise<void>;
export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>; export function SaveListsSettings(arg1:main.ListsSettings):Promise<void>;
@@ -159,6 +279,8 @@ export function SaveOperatingAntenna(arg1:operating.Antenna):Promise<operating.A
export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>; export function SaveOperatingStation(arg1:operating.Station):Promise<operating.Station>;
export function SavePOTAToken(arg1:string):Promise<void>;
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>; export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>; export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
@@ -169,32 +291,70 @@ export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>; export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
export function SaveWinkeyerSettings(arg1:main.WinkeyerSettings):Promise<void>;
export function SearchAwardReferences(arg1:string,arg2:string,arg3:number,arg4:number):Promise<Array<awardref.Ref>>;
export function SendClusterCommand(arg1:string):Promise<void>; export function SendClusterCommand(arg1:string):Promise<void>;
export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<void>; export function SendClusterSpot(arg1:string,arg2:number,arg3:string):Promise<void>;
export function SendQSORecordingEmail(arg1:number):Promise<void>;
export function SetCATFrequency(arg1:number):Promise<void>; export function SetCATFrequency(arg1:number):Promise<void>;
export function SetCATMode(arg1:string):Promise<void>; export function SetCATMode(arg1:string):Promise<void>;
export function SetClublogCtyEnabled(arg1:boolean):Promise<void>;
export function SetClusterAutoConnect(arg1:boolean):Promise<void>; export function SetClusterAutoConnect(arg1:boolean):Promise<void>;
export function SetCompactMode(arg1:boolean):Promise<void>; export function SetCompactMode(arg1:boolean):Promise<void>;
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
export function SetUIPref(arg1:string,arg2:string):Promise<void>;
export function SwitchCATRig(arg1:number):Promise<void>; export function SwitchCATRig(arg1:number):Promise<void>;
export function SyncPOTAHunterLog(arg1:boolean):Promise<main.POTASyncResult>;
export function TestClublogUpload():Promise<string>; export function TestClublogUpload():Promise<string>;
export function TestEmail(arg1:string):Promise<void>;
export function TestLoTWUpload():Promise<string>; export function TestLoTWUpload():Promise<string>;
export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>; export function TestLookupProvider(arg1:string,arg2:string,arg3:string,arg4:string):Promise<lookup.Result>;
export function TestPTT():Promise<void>;
export function TestQRZUpload():Promise<string>; export function TestQRZUpload():Promise<string>;
export function TestRotator(arg1:main.RotatorSettings):Promise<void>; export function TestRotator(arg1:main.RotatorSettings):Promise<void>;
export function UpdateAwardReferenceList(arg1:string):Promise<main.AwardRefMeta>;
export function UpdateQSO(arg1:qso.QSO):Promise<void>; export function UpdateQSO(arg1:qso.QSO):Promise<void>;
export function UpdateQSOsFromClublog(arg1:Array<number>):Promise<number>;
export function UpdateQSOsFromCty(arg1:Array<number>):Promise<number>;
export function UpdateQSOsFromQRZ(arg1:Array<number>):Promise<number>;
export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>; export function UploadQSOsManual(arg1:string,arg2:Array<number>):Promise<void>;
export function WinkeyerBackspace():Promise<void>;
export function WinkeyerConnect():Promise<void>;
export function WinkeyerDisconnect():Promise<void>;
export function WinkeyerSend(arg1:string):Promise<void>;
export function WinkeyerSetSpeed(arg1:number):Promise<void>;
export function WinkeyerStop():Promise<void>;
export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>; export function WorkedBefore(arg1:string,arg2:number):Promise<qso.WorkedBefore>;
+316 -4
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);
} }
@@ -10,6 +18,22 @@ export function AddQSO(arg1) {
return window['go']['main']['App']['AddQSO'](arg1); return window['go']['main']['App']['AddQSO'](arg1);
} }
export function ApplyAwardPreset(arg1, arg2) {
return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2);
}
export function AwardCellQSOs(arg1, arg2, arg3) {
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3);
}
export function AwardFields() {
return window['go']['main']['App']['AwardFields']();
}
export function BulkUpdateQSL(arg1, arg2) {
return window['go']['main']['App']['BulkUpdateQSL'](arg1, arg2);
}
export function ClearLookupCache() { export function ClearLookupCache() {
return window['go']['main']['App']['ClearLookupCache'](); return window['go']['main']['App']['ClearLookupCache']();
} }
@@ -18,6 +42,10 @@ export function ClusterSpotStatuses(arg1) {
return window['go']['main']['App']['ClusterSpotStatuses'](arg1); return window['go']['main']['App']['ClusterSpotStatuses'](arg1);
} }
export function ComputeQSOAwardRefs(arg1) {
return window['go']['main']['App']['ComputeQSOAwardRefs'](arg1);
}
export function ComputeStationInfo(arg1, arg2) { export function ComputeStationInfo(arg1, arg2) {
return window['go']['main']['App']['ComputeStationInfo'](arg1, arg2); return window['go']['main']['App']['ComputeStationInfo'](arg1, arg2);
} }
@@ -34,10 +62,54 @@ export function CountQSO() {
return window['go']['main']['App']['CountQSO'](); return window['go']['main']['App']['CountQSO']();
} }
export function CountQSOFiltered(arg1) {
return window['go']['main']['App']['CountQSOFiltered'](arg1);
}
export function CreateDatabase(arg1) {
return window['go']['main']['App']['CreateDatabase'](arg1);
}
export function DVKCancelRecord() {
return window['go']['main']['App']['DVKCancelRecord']();
}
export function DVKPlay(arg1) {
return window['go']['main']['App']['DVKPlay'](arg1);
}
export function DVKPreview(arg1) {
return window['go']['main']['App']['DVKPreview'](arg1);
}
export function DVKStartRecord(arg1) {
return window['go']['main']['App']['DVKStartRecord'](arg1);
}
export function DVKStop() {
return window['go']['main']['App']['DVKStop']();
}
export function DVKStopRecord() {
return window['go']['main']['App']['DVKStopRecord']();
}
export function DXCCForCountry(arg1) {
return window['go']['main']['App']['DXCCForCountry'](arg1);
}
export function DXCCName(arg1) {
return window['go']['main']['App']['DXCCName'](arg1);
}
export function DeleteAllQSO() { export function DeleteAllQSO() {
return window['go']['main']['App']['DeleteAllQSO'](); return window['go']['main']['App']['DeleteAllQSO']();
} }
export function DeleteAwardReference(arg1, arg2) {
return window['go']['main']['App']['DeleteAwardReference'](arg1, arg2);
}
export function DeleteClusterServer(arg1) { export function DeleteClusterServer(arg1) {
return window['go']['main']['App']['DeleteClusterServer'](arg1); return window['go']['main']['App']['DeleteClusterServer'](arg1);
} }
@@ -70,6 +142,10 @@ export function DisconnectClusterServer(arg1) {
return window['go']['main']['App']['DisconnectClusterServer'](arg1); return window['go']['main']['App']['DisconnectClusterServer'](arg1);
} }
export function DownloadClublogCty() {
return window['go']['main']['App']['DownloadClublogCty']();
}
export function DownloadConfirmations(arg1, arg2) { export function DownloadConfirmations(arg1, arg2) {
return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2); return window['go']['main']['App']['DownloadConfirmations'](arg1, arg2);
} }
@@ -78,8 +154,24 @@ export function DuplicateProfile(arg1, arg2) {
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2); return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
} }
export function ExportADIF(arg1) { export function ExportADIF(arg1, arg2) {
return window['go']['main']['App']['ExportADIF'](arg1); return window['go']['main']['App']['ExportADIF'](arg1, arg2);
}
export function ExportADIFFiltered(arg1, arg2, arg3) {
return window['go']['main']['App']['ExportADIFFiltered'](arg1, arg2, arg3);
}
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']();
} }
export function FindQSOsForUpload(arg1, arg2) { export function FindQSOsForUpload(arg1, arg2) {
@@ -90,6 +182,34 @@ export function GetActiveProfile() {
return window['go']['main']['App']['GetActiveProfile'](); return window['go']['main']['App']['GetActiveProfile']();
} }
export function GetAudioSettings() {
return window['go']['main']['App']['GetAudioSettings']();
}
export function GetAward(arg1) {
return window['go']['main']['App']['GetAward'](arg1);
}
export function GetAwardDefs() {
return window['go']['main']['App']['GetAwardDefs']();
}
export function GetAwardPresets() {
return window['go']['main']['App']['GetAwardPresets']();
}
export function GetAwardReferenceMeta() {
return window['go']['main']['App']['GetAwardReferenceMeta']();
}
export function GetAwardStats(arg1) {
return window['go']['main']['App']['GetAwardStats'](arg1);
}
export function GetAwards() {
return window['go']['main']['App']['GetAwards']();
}
export function GetBackupSettings() { export function GetBackupSettings() {
return window['go']['main']['App']['GetBackupSettings'](); return window['go']['main']['App']['GetBackupSettings']();
} }
@@ -102,6 +222,10 @@ export function GetCATState() {
return window['go']['main']['App']['GetCATState'](); return window['go']['main']['App']['GetCATState']();
} }
export function GetClublogCtyInfo() {
return window['go']['main']['App']['GetClublogCtyInfo']();
}
export function GetClusterAutoConnect() { export function GetClusterAutoConnect() {
return window['go']['main']['App']['GetClusterAutoConnect'](); return window['go']['main']['App']['GetClusterAutoConnect']();
} }
@@ -114,10 +238,26 @@ export function GetCtyDatInfo() {
return window['go']['main']['App']['GetCtyDatInfo'](); return window['go']['main']['App']['GetCtyDatInfo']();
} }
export function GetDVKMessages() {
return window['go']['main']['App']['GetDVKMessages']();
}
export function GetDVKStatus() {
return window['go']['main']['App']['GetDVKStatus']();
}
export function GetDataDir() {
return window['go']['main']['App']['GetDataDir']();
}
export function GetDatabaseSettings() { export function GetDatabaseSettings() {
return window['go']['main']['App']['GetDatabaseSettings'](); return window['go']['main']['App']['GetDatabaseSettings']();
} }
export function GetEmailSettings() {
return window['go']['main']['App']['GetEmailSettings']();
}
export function GetExternalServices() { export function GetExternalServices() {
return window['go']['main']['App']['GetExternalServices'](); return window['go']['main']['App']['GetExternalServices']();
} }
@@ -134,6 +274,10 @@ export function GetLookupSettings() {
return window['go']['main']['App']['GetLookupSettings'](); return window['go']['main']['App']['GetLookupSettings']();
} }
export function GetPOTAToken() {
return window['go']['main']['App']['GetPOTAToken']();
}
export function GetQSLDefaults() { export function GetQSLDefaults() {
return window['go']['main']['App']['GetQSLDefaults'](); return window['go']['main']['App']['GetQSLDefaults']();
} }
@@ -158,8 +302,44 @@ export function GetStationSettings() {
return window['go']['main']['App']['GetStationSettings'](); return window['go']['main']['App']['GetStationSettings']();
} }
export function ImportADIF(arg1, arg2) { export function GetUIPref(arg1) {
return window['go']['main']['App']['ImportADIF'](arg1, arg2); return window['go']['main']['App']['GetUIPref'](arg1);
}
export function GetWinkeyerSettings() {
return window['go']['main']['App']['GetWinkeyerSettings']();
}
export function GetWinkeyerStatus() {
return window['go']['main']['App']['GetWinkeyerStatus']();
}
export function HasBuiltinReferences(arg1) {
return window['go']['main']['App']['HasBuiltinReferences'](arg1);
}
export function ImportADIF(arg1, arg2, arg3) {
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3);
}
export function ImportAwardReferencesText(arg1, arg2) {
return window['go']['main']['App']['ImportAwardReferencesText'](arg1, arg2);
}
export function ImportAwards() {
return window['go']['main']['App']['ImportAwards']();
}
export function ListAudioInputDevices() {
return window['go']['main']['App']['ListAudioInputDevices']();
}
export function ListAudioOutputDevices() {
return window['go']['main']['App']['ListAudioOutputDevices']();
}
export function ListAwardReferences(arg1) {
return window['go']['main']['App']['ListAwardReferences'](arg1);
} }
export function ListClusterServers() { export function ListClusterServers() {
@@ -182,6 +362,14 @@ export function ListQSO(arg1) {
return window['go']['main']['App']['ListQSO'](arg1); return window['go']['main']['App']['ListQSO'](arg1);
} }
export function ListQSOFiltered(arg1) {
return window['go']['main']['App']['ListQSOFiltered'](arg1);
}
export function ListSerialPorts() {
return window['go']['main']['App']['ListSerialPorts']();
}
export function ListTQSLStationLocations() { export function ListTQSLStationLocations() {
return window['go']['main']['App']['ListTQSLStationLocations'](); return window['go']['main']['App']['ListTQSLStationLocations']();
} }
@@ -218,6 +406,10 @@ export function OperatingDefaultForBand(arg1) {
return window['go']['main']['App']['OperatingDefaultForBand'](arg1); return window['go']['main']['App']['OperatingDefaultForBand'](arg1);
} }
export function PickAudioFolder() {
return window['go']['main']['App']['PickAudioFolder']();
}
export function PickBackupFolder() { export function PickBackupFolder() {
return window['go']['main']['App']['PickBackupFolder'](); return window['go']['main']['App']['PickBackupFolder']();
} }
@@ -230,6 +422,18 @@ export function PickSaveDatabase() {
return window['go']['main']['App']['PickSaveDatabase'](); return window['go']['main']['App']['PickSaveDatabase']();
} }
export function PopulateBuiltinReferences(arg1) {
return window['go']['main']['App']['PopulateBuiltinReferences'](arg1);
}
export function QSOAudioBegin() {
return window['go']['main']['App']['QSOAudioBegin']();
}
export function QSOAudioCancel() {
return window['go']['main']['App']['QSOAudioCancel']();
}
export function QuitApp() { export function QuitApp() {
return window['go']['main']['App']['QuitApp'](); return window['go']['main']['App']['QuitApp']();
} }
@@ -242,10 +446,22 @@ export function ReloadUDPIntegrations() {
return window['go']['main']['App']['ReloadUDPIntegrations'](); return window['go']['main']['App']['ReloadUDPIntegrations']();
} }
export function ReplaceAwardReferences(arg1, arg2) {
return window['go']['main']['App']['ReplaceAwardReferences'](arg1, arg2);
}
export function ResetAwardDefs() {
return window['go']['main']['App']['ResetAwardDefs']();
}
export function ResetDatabaseToDefault() { export function ResetDatabaseToDefault() {
return window['go']['main']['App']['ResetDatabaseToDefault'](); return window['go']['main']['App']['ResetDatabaseToDefault']();
} }
export function RestartQSORecorder() {
return window['go']['main']['App']['RestartQSORecorder']();
}
export function RotatorGoTo(arg1, arg2) { export function RotatorGoTo(arg1, arg2) {
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2); return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
} }
@@ -266,6 +482,18 @@ export function SaveADIFFile() {
return window['go']['main']['App']['SaveADIFFile'](); return window['go']['main']['App']['SaveADIFFile']();
} }
export function SaveAudioSettings(arg1) {
return window['go']['main']['App']['SaveAudioSettings'](arg1);
}
export function SaveAwardDefs(arg1) {
return window['go']['main']['App']['SaveAwardDefs'](arg1);
}
export function SaveAwardReference(arg1, arg2) {
return window['go']['main']['App']['SaveAwardReference'](arg1, arg2);
}
export function SaveBackupSettings(arg1) { export function SaveBackupSettings(arg1) {
return window['go']['main']['App']['SaveBackupSettings'](arg1); return window['go']['main']['App']['SaveBackupSettings'](arg1);
} }
@@ -278,6 +506,10 @@ export function SaveClusterServer(arg1) {
return window['go']['main']['App']['SaveClusterServer'](arg1); return window['go']['main']['App']['SaveClusterServer'](arg1);
} }
export function SaveEmailSettings(arg1) {
return window['go']['main']['App']['SaveEmailSettings'](arg1);
}
export function SaveExternalServices(arg1) { export function SaveExternalServices(arg1) {
return window['go']['main']['App']['SaveExternalServices'](arg1); return window['go']['main']['App']['SaveExternalServices'](arg1);
} }
@@ -298,6 +530,10 @@ export function SaveOperatingStation(arg1) {
return window['go']['main']['App']['SaveOperatingStation'](arg1); return window['go']['main']['App']['SaveOperatingStation'](arg1);
} }
export function SavePOTAToken(arg1) {
return window['go']['main']['App']['SavePOTAToken'](arg1);
}
export function SaveProfile(arg1) { export function SaveProfile(arg1) {
return window['go']['main']['App']['SaveProfile'](arg1); return window['go']['main']['App']['SaveProfile'](arg1);
} }
@@ -318,6 +554,14 @@ export function SaveUDPIntegration(arg1) {
return window['go']['main']['App']['SaveUDPIntegration'](arg1); return window['go']['main']['App']['SaveUDPIntegration'](arg1);
} }
export function SaveWinkeyerSettings(arg1) {
return window['go']['main']['App']['SaveWinkeyerSettings'](arg1);
}
export function SearchAwardReferences(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['SearchAwardReferences'](arg1, arg2, arg3, arg4);
}
export function SendClusterCommand(arg1) { export function SendClusterCommand(arg1) {
return window['go']['main']['App']['SendClusterCommand'](arg1); return window['go']['main']['App']['SendClusterCommand'](arg1);
} }
@@ -326,6 +570,10 @@ export function SendClusterSpot(arg1, arg2, arg3) {
return window['go']['main']['App']['SendClusterSpot'](arg1, arg2, arg3); return window['go']['main']['App']['SendClusterSpot'](arg1, arg2, arg3);
} }
export function SendQSORecordingEmail(arg1) {
return window['go']['main']['App']['SendQSORecordingEmail'](arg1);
}
export function SetCATFrequency(arg1) { export function SetCATFrequency(arg1) {
return window['go']['main']['App']['SetCATFrequency'](arg1); return window['go']['main']['App']['SetCATFrequency'](arg1);
} }
@@ -334,6 +582,10 @@ export function SetCATMode(arg1) {
return window['go']['main']['App']['SetCATMode'](arg1); return window['go']['main']['App']['SetCATMode'](arg1);
} }
export function SetClublogCtyEnabled(arg1) {
return window['go']['main']['App']['SetClublogCtyEnabled'](arg1);
}
export function SetClusterAutoConnect(arg1) { export function SetClusterAutoConnect(arg1) {
return window['go']['main']['App']['SetClusterAutoConnect'](arg1); return window['go']['main']['App']['SetClusterAutoConnect'](arg1);
} }
@@ -342,14 +594,30 @@ export function SetCompactMode(arg1) {
return window['go']['main']['App']['SetCompactMode'](arg1); return window['go']['main']['App']['SetCompactMode'](arg1);
} }
export function SetDVKLabel(arg1, arg2) {
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
}
export function SetUIPref(arg1, arg2) {
return window['go']['main']['App']['SetUIPref'](arg1, arg2);
}
export function SwitchCATRig(arg1) { export function SwitchCATRig(arg1) {
return window['go']['main']['App']['SwitchCATRig'](arg1); return window['go']['main']['App']['SwitchCATRig'](arg1);
} }
export function SyncPOTAHunterLog(arg1) {
return window['go']['main']['App']['SyncPOTAHunterLog'](arg1);
}
export function TestClublogUpload() { export function TestClublogUpload() {
return window['go']['main']['App']['TestClublogUpload'](); return window['go']['main']['App']['TestClublogUpload']();
} }
export function TestEmail(arg1) {
return window['go']['main']['App']['TestEmail'](arg1);
}
export function TestLoTWUpload() { export function TestLoTWUpload() {
return window['go']['main']['App']['TestLoTWUpload'](); return window['go']['main']['App']['TestLoTWUpload']();
} }
@@ -358,6 +626,10 @@ export function TestLookupProvider(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4); return window['go']['main']['App']['TestLookupProvider'](arg1, arg2, arg3, arg4);
} }
export function TestPTT() {
return window['go']['main']['App']['TestPTT']();
}
export function TestQRZUpload() { export function TestQRZUpload() {
return window['go']['main']['App']['TestQRZUpload'](); return window['go']['main']['App']['TestQRZUpload']();
} }
@@ -366,14 +638,54 @@ export function TestRotator(arg1) {
return window['go']['main']['App']['TestRotator'](arg1); return window['go']['main']['App']['TestRotator'](arg1);
} }
export function UpdateAwardReferenceList(arg1) {
return window['go']['main']['App']['UpdateAwardReferenceList'](arg1);
}
export function UpdateQSO(arg1) { export function UpdateQSO(arg1) {
return window['go']['main']['App']['UpdateQSO'](arg1); return window['go']['main']['App']['UpdateQSO'](arg1);
} }
export function UpdateQSOsFromClublog(arg1) {
return window['go']['main']['App']['UpdateQSOsFromClublog'](arg1);
}
export function UpdateQSOsFromCty(arg1) {
return window['go']['main']['App']['UpdateQSOsFromCty'](arg1);
}
export function UpdateQSOsFromQRZ(arg1) {
return window['go']['main']['App']['UpdateQSOsFromQRZ'](arg1);
}
export function UploadQSOsManual(arg1, arg2) { export function UploadQSOsManual(arg1, arg2) {
return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2); return window['go']['main']['App']['UploadQSOsManual'](arg1, arg2);
} }
export function WinkeyerBackspace() {
return window['go']['main']['App']['WinkeyerBackspace']();
}
export function WinkeyerConnect() {
return window['go']['main']['App']['WinkeyerConnect']();
}
export function WinkeyerDisconnect() {
return window['go']['main']['App']['WinkeyerDisconnect']();
}
export function WinkeyerSend(arg1) {
return window['go']['main']['App']['WinkeyerSend'](arg1);
}
export function WinkeyerSetSpeed(arg1) {
return window['go']['main']['App']['WinkeyerSetSpeed'](arg1);
}
export function WinkeyerStop() {
return window['go']['main']['App']['WinkeyerStop']();
}
export function WorkedBefore(arg1, arg2) { export function WorkedBefore(arg1, arg2) {
return window['go']['main']['App']['WorkedBefore'](arg1, arg2); return window['go']['main']['App']['WorkedBefore'](arg1, arg2);
} }
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -3,11 +3,15 @@ module hamlog
go 1.25.0 go 1.25.0
require ( require (
github.com/braheezy/shine-mp3 v0.1.0
github.com/go-ole/go-ole v1.3.0 github.com/go-ole/go-ole v1.3.0
github.com/moutend/go-wca v0.3.0
github.com/wailsapp/wails/v2 v2.11.0 github.com/wailsapp/wails/v2 v2.11.0
github.com/wneessen/go-mail v0.7.3
go.bug.st/serial v1.7.1
golang.org/x/net v0.35.0 golang.org/x/net v0.35.0
golang.org/x/sys v0.45.0 golang.org/x/sys v0.45.0
golang.org/x/text v0.22.0 golang.org/x/text v0.37.0
modernc.org/sqlite v1.50.1 modernc.org/sqlite v1.50.1
) )
+16 -6
View File
@@ -1,9 +1,12 @@
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/braheezy/shine-mp3 v0.1.0 h1:N2wZhv6ipCFduTSftaPNdDgZ5xFmQAPvB7JcqA4sSi8=
github.com/braheezy/shine-mp3 v0.1.0/go.mod h1:0H/pmcpFAd+Fnrj6Pc7du7wL36U/HqtfcgPJuCgc1L4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
@@ -40,6 +43,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/moutend/go-wca v0.3.0 h1:IzhsQ44zBzMdT42xlBjiLSVya9cPYOoKx9E+yXVhFo8=
github.com/moutend/go-wca v0.3.0/go.mod h1:7VrPO512jnjFGJ6rr+zOoCfiYjOHRPNfbttJuxAurcw=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
@@ -69,15 +74,20 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ= github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k= github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0=
github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk=
go.bug.st/serial v1.7.1 h1:5aP8wYL0UjEYOVs3oPAGscjaSfRQLHtCvBFXNN/rwtc=
go.bug.st/serial v1.7.1/go.mod h1:d0MmS16Qt9b1m06yoYRNUXhRRTJV5Qg2S5EKqQtnayQ=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -88,11 +98,11 @@ golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY= modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
+44
View File
@@ -0,0 +1,44 @@
package adif
import (
"strings"
"testing"
"unicode/utf8"
)
// Loggers (notably Log4OM's UDP/exported ADIF) sometimes declare a field
// length as the CHARACTER count instead of the UTF-8 byte count, truncating
// multibyte values mid-rune. The parser must recover the full value.
func TestCharCountLengthRepair(t *testing.T) {
cases := []struct{ name, wantQTH, wantName, adi string }{
{
name: "latin",
wantQTH: "Tóalmás", // 7 chars / 9 bytes, declared 7
wantName: "Laci Budai",
adi: "<EOH>\n<CALL:5>HA5XY<QTH:7>Tóalmás<NAME:10>Laci Budai<EOR>\n",
},
{
name: "cyrillic",
wantQTH: "Дзержинск", // 9 chars / 18 bytes, declared 9
wantName: "Александр Чайка", // 15 chars / 29 bytes, declared 15
adi: "<EOH>\n<CALL:6>UA3TFS<NAME:15>Александр Чайка<QTH:9>Дзержинск<EOR>\n",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var got Record
if err := Parse(strings.NewReader(c.adi), func(r Record) error { got = r; return nil }); err != nil {
t.Fatalf("parse: %v", err)
}
if got["qth"] != c.wantQTH {
t.Errorf("qth = %q, want %q", got["qth"], c.wantQTH)
}
if got["name"] != c.wantName {
t.Errorf("name = %q, want %q", got["name"], c.wantName)
}
if !utf8.ValidString(got["name"]) || !utf8.ValidString(got["qth"]) {
t.Errorf("result not valid UTF-8: name=%q qth=%q", got["name"], got["qth"])
}
})
}
}
+85 -14
View File
@@ -27,31 +27,61 @@ type Exporter struct {
// AppName / AppVersion populate the ADIF header comments. Optional. // AppName / AppVersion populate the ADIF header comments. Optional.
AppName string AppName string
AppVersion string AppVersion string
// IncludeAppFields controls whether application-specific fields (ADIF
// "APP_<programid>_<name>" tags, e.g. Log4OM's APP_LOG4OM_* or our own
// OpsLog extensions) are written. Leave false for a clean standard-ADIF
// export destined for another logger; set true for a full OpsLog→OpsLog
// round-trip that preserves everything.
IncludeAppFields bool
} }
// iterator streams QSOs through fn. The three concrete sources (all, filtered,
// by-ids) all match this shape so the document writer stays source-agnostic.
type iterator func(ctx context.Context, fn func(qso.QSO) error) error
// ExportFile creates path (overwriting if it exists) and writes every QSO. // ExportFile creates path (overwriting if it exists) and writes every QSO.
func (e *Exporter) ExportFile(ctx context.Context, path string) (ExportResult, error) { func (e *Exporter) ExportFile(ctx context.Context, path string) (ExportResult, error) {
return e.exportFileWith(ctx, path, e.Repo.IterateAll)
}
// ExportFileFiltered writes only the QSOs matching f (no row limit).
func (e *Exporter) ExportFileFiltered(ctx context.Context, path string, f qso.QueryFilter) (ExportResult, error) {
return e.exportFileWith(ctx, path, func(ctx context.Context, fn func(qso.QSO) error) error {
return e.Repo.IterateFiltered(ctx, f, fn)
})
}
// ExportFileByIDs writes only the QSOs with the given ids.
func (e *Exporter) ExportFileByIDs(ctx context.Context, path string, ids []int64) (ExportResult, error) {
return e.exportFileWith(ctx, path, func(ctx context.Context, fn func(qso.QSO) error) error {
return e.Repo.IterateByIDs(ctx, ids, fn)
})
}
func (e *Exporter) exportFileWith(ctx context.Context, path string, iter iterator) (ExportResult, error) {
f, err := os.Create(path) f, err := os.Create(path)
if err != nil { if err != nil {
return ExportResult{}, fmt.Errorf("create %s: %w", path, err) return ExportResult{}, fmt.Errorf("create %s: %w", path, err)
} }
defer f.Close() defer f.Close()
count, err := e.Export(ctx, f) count, err := e.writeDoc(ctx, f, iter)
if err != nil { if err != nil {
return ExportResult{Path: path, Count: count}, err return ExportResult{Path: path, Count: count}, err
} }
info, _ := f.Stat() info, _ := f.Stat()
return ExportResult{ return ExportResult{Path: path, Count: count, SizeKB: info.Size() / 1024}, nil
Path: path,
Count: count,
SizeKB: info.Size() / 1024,
}, nil
} }
// Export writes a complete ADIF document (header + records + EOF) to w. // Export writes a complete ADIF document (header + records + EOF) to w for
// Returns the number of QSOs successfully written. // every QSO. Returns the number of QSOs written.
func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) { func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) {
return e.writeDoc(ctx, w, e.Repo.IterateAll)
}
// writeDoc writes the ADIF header then streams every QSO from iter.
func (e *Exporter) writeDoc(ctx context.Context, w io.Writer, iter iterator) (int, error) {
bw := bufio.NewWriterSize(w, 64*1024) bw := bufio.NewWriterSize(w, 64*1024)
defer bw.Flush() defer bw.Flush()
@@ -62,15 +92,15 @@ func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) {
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)
} }
fmt.Fprintf(bw, " <CREATED_TIMESTAMP:15>%s <EOH>\n\n", now) fmt.Fprintf(bw, " <CREATED_TIMESTAMP:15>%s <EOH>\n\n", now)
count := 0 count := 0
err := e.Repo.IterateAll(ctx, func(q qso.QSO) error { err := iter(ctx, func(q qso.QSO) error {
writeRecord(bw, q) writeRecord(bw, q, e.IncludeAppFields)
count++ count++
return nil return nil
}) })
@@ -84,7 +114,8 @@ func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) {
func SingleRecordADIF(q qso.QSO) string { func SingleRecordADIF(q qso.QSO) string {
var b strings.Builder var b strings.Builder
bw := bufio.NewWriter(&b) bw := bufio.NewWriter(&b)
writeRecord(bw, q) // Uploads target other services — keep it standard (no app-specific tags).
writeRecord(bw, q, false)
bw.Flush() bw.Flush()
return b.String() return b.String()
} }
@@ -93,7 +124,7 @@ func SingleRecordADIF(q qso.QSO) string {
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted" // Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
// mode (e.g. FT4 stored without a parent) is exported as the canonical // mode (e.g. FT4 stored without a parent) is exported as the canonical
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers. // pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers.
func writeRecord(bw *bufio.Writer, q qso.QSO) { func writeRecord(bw *bufio.Writer, q qso.QSO, includeApp bool) {
// --- Core --- // --- Core ---
writeField(bw, "CALL", q.Callsign) writeField(bw, "CALL", q.Callsign)
@@ -217,9 +248,49 @@ func writeRecord(bw *bufio.Writer, q qso.QSO) {
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) ---
// 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 { for k, v := range q.Extras {
writeField(bw, strings.ToUpper(k), v) tag := strings.ToUpper(k)
if !includeApp && (strings.HasPrefix(tag, "APP_") || !IsStandardField(tag)) {
continue
}
writeField(bw, tag, v)
} }
bw.WriteString("<EOR>\n") bw.WriteString("<EOR>\n")
+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
}
+147 -1
View File
@@ -20,6 +20,7 @@ import (
type ImportResult struct { type ImportResult struct {
Total int `json:"total"` // records found in the file Total int `json:"total"` // records found in the file
Imported int `json:"imported"` // successfully inserted Imported int `json:"imported"` // successfully inserted
Updated int `json:"updated"` // existing QSOs refreshed (update-duplicates mode)
Skipped int `json:"skipped"` // dropped (missing required fields) Skipped int `json:"skipped"` // dropped (missing required fields)
Duplicates int `json:"duplicates"` // matched an existing or earlier-in-file QSO Duplicates int `json:"duplicates"` // matched an existing or earlier-in-file QSO
DuplicateSamples []string `json:"duplicate_samples"` // up to maxDuplicateSamples human-readable IDs of skipped duplicates DuplicateSamples []string `json:"duplicate_samples"` // up to maxDuplicateSamples human-readable IDs of skipped duplicates
@@ -36,6 +37,19 @@ type Importer struct {
Repo *qso.Repo Repo *qso.Repo
BatchSize int // 0 → 500 BatchSize int // 0 → 500
SkipDuplicates bool // when true, records matching an existing or earlier-in-file QSO are skipped; otherwise all are inserted SkipDuplicates bool // when true, records matching an existing or earlier-in-file QSO are skipped; otherwise all are inserted
// UpdateDuplicates, when true, takes precedence over SkipDuplicates:
// a record matching an existing QSO MERGES its non-empty fields onto
// that QSO (refreshes QSL/confirmation statuses on re-sync) instead of
// being skipped or re-inserted.
UpdateDuplicates bool
// Enrich, when set, is called on each parsed QSO before dedup/insert.
// Used to recompute country / zones from cty.dat so a bad COUNTRY in the
// source file (common with contest loggers) is corrected on the way in.
Enrich func(*qso.QSO)
// OnProgress, when set, is called periodically with (processed, total)
// record counts so the UI can show a progress bar. total is an estimate
// from counting <EOR> tags up front.
OnProgress func(processed, total int)
} }
// ImportFile reads the file at path and imports it into the repo. The // ImportFile reads the file at path and imports it into the repo. The
@@ -62,6 +76,14 @@ func (im *Importer) ImportFile(ctx context.Context, path string) (ImportResult,
// Windows-1252 (É is one byte 0xC9). Pre-decoding to UTF-8 would make É // Windows-1252 (É is one byte 0xC9). Pre-decoding to UTF-8 would make É
// two bytes, and the parser reading 7 bytes after the tag would chop the // two bytes, and the parser reading 7 bytes after the tag would chop the
// É in half → "YAOUND" + an orphan 0xC3 byte → "YAOUND" after JSON. // É in half → "YAOUND" + an orphan 0xC3 byte → "YAOUND" after JSON.
// ValueDecoderFor returns the per-field byte decoder appropriate for a raw
// ADIF payload: identity when it's valid UTF-8, otherwise a Windows-1252
// decoder. Exposed so non-file ingest paths (UDP auto-log from Log4OM /
// JTAlert) transcode accented NAME/QTH fields the same way file import does.
func ValueDecoderFor(data []byte) func([]byte) string {
return pickValueDecoder(data)
}
func pickValueDecoder(data []byte) func([]byte) string { func pickValueDecoder(data []byte) func([]byte) string {
if utf8.Valid(data) { if utf8.Valid(data) {
return nil // identity return nil // identity
@@ -97,6 +119,15 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
res := ImportResult{} res := ImportResult{}
batch := make([]qso.QSO, 0, im.BatchSize) batch := make([]qso.QSO, 0, im.BatchSize)
// Up-front record-count estimate (count <EOR> tags, case-insensitive) so
// the UI progress bar has a denominator. Cheap single scan.
total := countEOR(data)
reportProgress := func(force bool) {
if im.OnProgress != nil && (force || res.Total%200 == 0) {
im.OnProgress(res.Total, total)
}
}
// One upfront query for every existing dedup key — cheaper than N // One upfront query for every existing dedup key — cheaper than N
// per-record EXISTS calls. The same map gets new keys appended as we // per-record EXISTS calls. The same map gets new keys appended as we
// import so duplicates inside the file are caught too. Loaded // import so duplicates inside the file are caught too. Loaded
@@ -107,6 +138,16 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
return res, fmt.Errorf("load dedupe keys: %w", err) return res, fmt.Errorf("load dedupe keys: %w", err)
} }
// Update-duplicates mode needs the existing row's ID per key so it can
// fetch, merge and write it back. Loaded only when needed (extra query).
var keyIDs map[string]int64
if im.UpdateDuplicates {
keyIDs, err = im.Repo.DedupeKeyIDs(ctx)
if err != nil {
return res, fmt.Errorf("load dedupe ids: %w", err)
}
}
flush := func() error { flush := func() error {
if len(batch) == 0 { if len(batch) == 0 {
return nil return nil
@@ -119,6 +160,7 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
err = ParseWithDecoder(bytes.NewReader(data), decode, func(rec Record) error { err = ParseWithDecoder(bytes.NewReader(data), decode, func(rec Record) error {
res.Total++ res.Total++
reportProgress(false)
q, ok := recordToQSO(rec) q, ok := recordToQSO(rec)
if !ok { if !ok {
res.Skipped++ res.Skipped++
@@ -128,6 +170,9 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
} }
return nil return nil
} }
if im.Enrich != nil {
im.Enrich(&q)
}
key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode) key := qso.DedupeKey(q.Callsign, q.QSODate.UTC().Format("2006-01-02T15:04"), q.Band, q.Mode)
if _, dup := seen[key]; dup { if _, dup := seen[key]; dup {
res.Duplicates++ res.Duplicates++
@@ -138,6 +183,28 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
q.QSODate.UTC().Format("2006-01-02 15:04"), q.QSODate.UTC().Format("2006-01-02 15:04"),
q.Band, q.Mode)) q.Band, q.Mode))
} }
if im.UpdateDuplicates {
if id, ok := keyIDs[key]; ok {
existing, gerr := im.Repo.GetByID(ctx, id)
if gerr != nil {
if len(res.Errors) < maxErrors {
res.Errors = append(res.Errors,
fmt.Sprintf("record %d (%s): load existing: %v", res.Total, q.Callsign, gerr))
}
return nil
}
qso.MergeNonZero(&existing, q)
if uerr := im.Repo.Update(ctx, existing); uerr != nil {
if len(res.Errors) < maxErrors {
res.Errors = append(res.Errors,
fmt.Sprintf("record %d (%s): update: %v", res.Total, q.Callsign, uerr))
}
return nil
}
res.Updated++
}
return nil
}
if im.SkipDuplicates { if im.SkipDuplicates {
return nil return nil
} }
@@ -159,9 +226,28 @@ func (im *Importer) importBytes(ctx context.Context, data []byte) (ImportResult,
if err := flush(); err != nil { if err := flush(); err != nil {
return res, err return res, err
} }
reportProgress(true) // final 100%
return res, nil return res, nil
} }
// countEOR estimates the record count by counting case-insensitive <EOR>
// tags. Used only to give the import progress bar a denominator.
func countEOR(data []byte) int {
n := 0
for i := 0; i+4 <= len(data); i++ {
if data[i] != '<' {
continue
}
if (data[i+1] == 'e' || data[i+1] == 'E') &&
(data[i+2] == 'o' || data[i+2] == 'O') &&
(data[i+3] == 'r' || data[i+3] == 'R') &&
(i+4 < len(data) && (data[i+4] == '>' || data[i+4] == ':')) {
n++
}
}
return n
}
// adifPromoted lists every lowercase ADIF tag that maps to a promoted column. // adifPromoted lists every lowercase ADIF tag that maps to a promoted column.
// Anything not in this set ends up in Extras. // Anything not in this set ends up in Extras.
var adifPromoted = stringSet( var adifPromoted = stringSet(
@@ -178,7 +264,7 @@ var adifPromoted = stringSet(
"age", "lat", "lon", "rig", "ant", "age", "lat", "lon", "rig", "ant",
// QSL // QSL
"qsl_sent", "qsl_rcvd", "qsl_sent", "qsl_rcvd",
"qslsdate", "qslrdate", "qsl_via", "qslmsg", "qslmsg_rcvd", "qslsdate", "qslrdate", "qsl_via", "qsl_sent_via", "qsl_rcvd_via", "qslmsg", "qslmsg_rcvd",
"lotw_qsl_sent", "lotw_qsl_rcvd", "lotw_qslsdate", "lotw_qslrdate", "lotw_qsl_sent", "lotw_qsl_rcvd", "lotw_qslsdate", "lotw_qslrdate",
"eqsl_qsl_sent", "eqsl_qsl_rcvd", "eqsl_qslsdate", "eqsl_qslrdate", "eqsl_qsl_sent", "eqsl_qsl_rcvd", "eqsl_qslsdate", "eqsl_qslrdate",
"clublog_qso_upload_date", "clublog_qso_upload_status", "clublog_qso_upload_date", "clublog_qso_upload_status",
@@ -198,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{} {
@@ -253,6 +345,15 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
if hz, ok := parseFreqHz(rec["freq_rx"]); ok { if hz, ok := parseFreqHz(rec["freq_rx"]); ok {
q.FreqRXHz = &hz q.FreqRXHz = &hz
} }
// RX defaults to TX when the ADIF omits split info: an empty BAND_RX /
// FREQ_RX means the contact wasn't cross-band/split, so RX = TX.
if q.BandRX == "" {
q.BandRX = q.Band
}
if q.FreqRXHz == nil && q.FreqHz != nil {
v := *q.FreqHz
q.FreqRXHz = &v
}
q.RSTSent = rec["rst_sent"] q.RSTSent = rec["rst_sent"]
q.RSTRcvd = rec["rst_rcvd"] q.RSTRcvd = rec["rst_rcvd"]
@@ -300,6 +401,9 @@ func recordToQSO(rec Record) (qso.QSO, bool) {
q.QSLSentDate = rec["qslsdate"] q.QSLSentDate = rec["qslsdate"]
q.QSLRcvdDate = rec["qslrdate"] q.QSLRcvdDate = rec["qslrdate"]
q.QSLVia = rec["qsl_via"] q.QSLVia = rec["qsl_via"]
if q.QSLVia == "" { // many loggers (Log4OM) write QSL_SENT_VIA instead
q.QSLVia = rec["qsl_sent_via"]
}
q.QSLMsg = rec["qslmsg"] q.QSLMsg = rec["qslmsg"]
q.QSLMsgRcvd = rec["qslmsg_rcvd"] q.QSLMsgRcvd = rec["qslmsg_rcvd"]
q.LOTWSent = rec["lotw_qsl_sent"] q.LOTWSent = rec["lotw_qsl_sent"]
@@ -384,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 {
+43
View File
@@ -16,6 +16,7 @@ import (
"io" "io"
"strconv" "strconv"
"strings" "strings"
"unicode/utf8"
) )
// Record is a single ADIF record. Keys are lowercased field names. // Record is a single ADIF record. Keys are lowercased field names.
@@ -83,6 +84,17 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
if _, err := io.ReadFull(br, val); err != nil { if _, err := io.ReadFull(br, val); err != nil {
return fmt.Errorf("read field %s: %w", name, err) return fmt.Errorf("read field %s: %w", name, err)
} }
// Repair character-count lengths. The ADIF spec says LENGTH is a
// byte count, but some loggers (notably Log4OM's UDP "ADIF
// message") write the CHARACTER count instead. For UTF-8 values
// with accented chars that truncates mid-rune — e.g. "<QTH:7>
// Tóalmás" is 9 bytes but says 7, leaving an orphan byte that
// renders as "Tóalm". When we're in UTF-8 mode (no Windows-1252
// decoder) and the naive byte read isn't valid UTF-8, keep reading
// until the value holds `length` whole runes (or the next tag).
if decodeValue == nil && !utf8.Valid(val) {
val = extendToRunes(br, val, length)
}
if headerDone && name != "" { if headerDone && name != "" {
if decodeValue != nil { if decodeValue != nil {
rec[name] = decodeValue(val) rec[name] = decodeValue(val)
@@ -94,6 +106,37 @@ func parseWith(r io.Reader, decodeValue func([]byte) string, fn func(Record) err
} }
} }
// extendToRunes recovers a value whose declared length was a character count
// rather than a byte count. `have` holds the first `wantRunes` BYTES of the
// value, which turned out to be invalid UTF-8 (a multibyte rune was cut). We
// append bytes from br until the value holds `wantRunes` complete runes — or
// until the next '<' (start of the following tag) / EOF, so we never cross
// into another field. Capped so a genuinely-corrupt value can't run away.
func extendToRunes(br *bufio.Reader, have []byte, wantRunes int) []byte {
const maxExtra = 8 // at most ~4 extra bytes/rune for the few cut runes
limit := len(have) + maxExtra*wantRunes + maxExtra
for len(have) < limit {
// Stop only when the value is complete UTF-8 (no partial trailing
// rune) AND holds enough runes. Checking utf8.RuneCount alone is a
// trap: a trailing orphan lead byte (e.g. the D0 of a cut Cyrillic
// "а") counts as one rune, so the loop would stop one continuation
// byte short → "Чайк". Requiring utf8.Valid forces us to read it.
if utf8.Valid(have) && utf8.RuneCount(have) >= wantRunes {
break
}
b, err := br.ReadByte()
if err != nil {
break
}
if b == '<' {
_ = br.UnreadByte() // belongs to the next tag — leave it
break
}
have = append(have, b)
}
return have
}
// parseSpec splits "callsign:5", "callsign:5:S" or "eor" into name and length. // parseSpec splits "callsign:5", "callsign:5:S" or "eor" into name and length.
// name is lowercased; length is 0 for control tags or when missing. // name is lowercased; length is 0 for control tags or when missing.
func parseSpec(spec string) (name string, length int) { func parseSpec(spec string) (name string, length int) {
+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()
}
+103
View File
@@ -0,0 +1,103 @@
//go:build windows
// Package audio drives Windows audio endpoints via WASAPI (through go-ole /
// go-wca) — pure Go, no CGO, the same COM stack OmniRig already uses. It
// powers the Digital Voice Keyer (record/play voice messages to the rig) and
// the QSO recorder (rolling-buffer capture saved as WAV).
package audio
import (
"fmt"
"runtime"
"sort"
"github.com/go-ole/go-ole"
"github.com/moutend/go-wca/pkg/wca"
)
// Device is one audio endpoint (a capture input or a render output).
type Device struct {
ID string `json:"id"` // stable WASAPI endpoint id (persisted)
Name string `json:"name"` // friendly name shown in dropdowns
Default bool `json:"default"` // is this the system default endpoint
}
// ListInputDevices returns the active capture endpoints — microphones,
// line-in, and the soundcard input wired to the rig's audio out ("From Radio").
func ListInputDevices() ([]Device, error) { return listEndpoints(wca.ECapture) }
// ListOutputDevices returns the active render endpoints — speakers and the
// soundcard output wired to the rig's mic/data input ("To Radio").
func ListOutputDevices() ([]Device, error) { return listEndpoints(wca.ERender) }
// listEndpoints enumerates active endpoints for a data-flow direction. COM is
// thread-affine, so we lock the OS thread and Co(Un)Initialize around the work
// — this is a one-shot call from a Wails binding, not a long-lived session.
func listEndpoints(flow uint32) (out []Device, err error) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if e := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); e != nil {
// 0x1 = S_FALSE → already initialised on this thread, fine.
if oe, ok := e.(*ole.OleError); !ok || oe.Code() != 0x00000001 {
return nil, fmt.Errorf("CoInitializeEx: %w", e)
}
}
defer ole.CoUninitialize()
var mmde *wca.IMMDeviceEnumerator
if e := wca.CoCreateInstance(wca.CLSID_MMDeviceEnumerator, 0, wca.CLSCTX_ALL,
wca.IID_IMMDeviceEnumerator, &mmde); e != nil {
return nil, fmt.Errorf("create MMDeviceEnumerator: %w", e)
}
defer mmde.Release()
// Record the default endpoint id so the UI can flag it.
var defID string
var defDev *wca.IMMDevice
if e := mmde.GetDefaultAudioEndpoint(flow, wca.EConsole, &defDev); e == nil && defDev != nil {
_ = defDev.GetId(&defID)
defDev.Release()
}
var coll *wca.IMMDeviceCollection
if e := mmde.EnumAudioEndpoints(flow, wca.DEVICE_STATE_ACTIVE, &coll); e != nil {
return nil, fmt.Errorf("enum endpoints: %w", e)
}
defer coll.Release()
var count uint32
if e := coll.GetCount(&count); e != nil {
return nil, fmt.Errorf("count endpoints: %w", e)
}
for i := uint32(0); i < count; i++ {
var dev *wca.IMMDevice
if coll.Item(i, &dev) != nil || dev == nil {
continue
}
var id string
_ = dev.GetId(&id)
name := endpointName(dev, id)
dev.Release()
out = append(out, Device{ID: id, Name: name, Default: id != "" && id == defID})
}
sort.SliceStable(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
}
// endpointName reads PKEY_Device_FriendlyName, falling back to the raw id.
func endpointName(dev *wca.IMMDevice, fallback string) string {
var ps *wca.IPropertyStore
if dev.OpenPropertyStore(wca.STGM_READ, &ps) != nil || ps == nil {
return fallback
}
defer ps.Release()
var pv wca.PROPVARIANT
if ps.GetValue(&wca.PKEY_Device_FriendlyName, &pv) != nil {
return fallback
}
if s := pv.String(); s != "" {
return s
}
return fallback
}
+271
View File
@@ -0,0 +1,271 @@
//go:build windows
package audio
import (
"fmt"
"runtime"
"time"
"unsafe"
"github.com/go-ole/go-ole"
"github.com/moutend/go-wca/pkg/wca"
)
const (
// AUDCLNT_BUFFERFLAGS_SILENT — the capture packet is silent; emit zeros.
bufferFlagSilent uint32 = 0x1
// 1-second WASAPI buffer (REFERENCE_TIME is in 100-ns units).
bufferDuration100ns = 10_000_000
)
func coInit() error {
if e := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); e != nil {
if oe, ok := e.(*ole.OleError); !ok || oe.Code() != 0x00000001 { // S_FALSE ok
return e
}
}
return nil
}
// openDevice resolves an IMMDevice by endpoint id, falling back to the default
// endpoint for the flow when id is empty or not found. Caller must Release().
func openDevice(flow uint32, id string) (*wca.IMMDevice, error) {
var mmde *wca.IMMDeviceEnumerator
if err := wca.CoCreateInstance(wca.CLSID_MMDeviceEnumerator, 0, wca.CLSCTX_ALL,
wca.IID_IMMDeviceEnumerator, &mmde); err != nil {
return nil, fmt.Errorf("create enumerator: %w", err)
}
defer mmde.Release()
if id != "" {
var coll *wca.IMMDeviceCollection
if err := mmde.EnumAudioEndpoints(flow, wca.DEVICE_STATE_ACTIVE, &coll); err == nil && coll != nil {
defer coll.Release()
var count uint32
coll.GetCount(&count)
for i := uint32(0); i < count; i++ {
var dev *wca.IMMDevice
if coll.Item(i, &dev) != nil || dev == nil {
continue
}
var did string
dev.GetId(&did)
if did == id {
return dev, nil // caller owns it
}
dev.Release()
}
}
}
var dev *wca.IMMDevice
if err := mmde.GetDefaultAudioEndpoint(flow, wca.EConsole, &dev); err != nil {
return nil, fmt.Errorf("no audio endpoint (id %q): %w", id, err)
}
return dev, nil
}
// pcmFormat is the fixed capture format (16 kHz mono 16-bit PCM). WASAPI's
// AUTOCONVERTPCM resamples from the device's native mix format for us.
func pcmFormat() *wca.WAVEFORMATEX {
return &wca.WAVEFORMATEX{
WFormatTag: 1, // WAVE_FORMAT_PCM
NChannels: channels,
NSamplesPerSec: sampleRate,
NAvgBytesPerSec: bytesPerSec,
NBlockAlign: blockAlign,
WBitsPerSample: bitsPerSample,
CbSize: 0,
}
}
const autoConvert = wca.AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | wca.AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY
// recordPCM captures from a device into 16 kHz mono 16-bit PCM bytes until the
// stop channel is closed.
func recordPCM(deviceID string, stop <-chan struct{}) ([]byte, error) {
out := make([]byte, 0, bytesPerSec*4)
err := captureStream(deviceID, stop, func(chunk []byte) { out = append(out, chunk...) })
return out, err
}
// captureStream opens a device and calls onChunk with freshly-captured 16 kHz
// mono 16-bit PCM as it arrives, until stop closes. onChunk receives a private
// copy it may retain. Runs on a COM-initialised, OS-locked thread.
func captureStream(deviceID string, stop <-chan struct{}, onChunk func([]byte)) error {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if err := coInit(); err != nil {
return fmt.Errorf("CoInitialize: %w", err)
}
defer ole.CoUninitialize()
dev, err := openDevice(wca.ECapture, deviceID)
if err != nil {
return err
}
defer dev.Release()
var ac *wca.IAudioClient
if err := dev.Activate(wca.IID_IAudioClient, wca.CLSCTX_ALL, nil, &ac); err != nil {
return fmt.Errorf("activate capture: %w", err)
}
defer ac.Release()
if err := ac.Initialize(wca.AUDCLNT_SHAREMODE_SHARED, autoConvert,
wca.REFERENCE_TIME(bufferDuration100ns), 0, pcmFormat(), nil); err != nil {
return fmt.Errorf("initialize capture: %w", err)
}
var acc *wca.IAudioCaptureClient
if err := ac.GetService(wca.IID_IAudioCaptureClient, &acc); err != nil {
return fmt.Errorf("get capture service: %w", err)
}
defer acc.Release()
if err := ac.Start(); err != nil {
return fmt.Errorf("start capture: %w", err)
}
defer ac.Stop()
for {
select {
case <-stop:
return nil
default:
}
var packet uint32
if err := acc.GetNextPacketSize(&packet); err != nil {
return err
}
if packet == 0 {
time.Sleep(10 * time.Millisecond)
continue
}
for packet > 0 {
var data *byte
var frames, flags uint32
var devpos, qpcpos uint64
if err := acc.GetBuffer(&data, &frames, &flags, &devpos, &qpcpos); err != nil {
return err
}
n := int(frames) * blockAlign
if n > 0 {
chunk := make([]byte, n)
if flags&bufferFlagSilent == 0 && data != nil {
copy(chunk, unsafe.Slice(data, n))
}
onChunk(chunk)
}
acc.ReleaseBuffer(frames)
if err := acc.GetNextPacketSize(&packet); err != nil {
return err
}
}
}
}
// playPCM renders raw PCM (with the given format) to a device, stopping early
// if the stop channel closes. Runs on a COM-initialised, OS-locked thread.
func playPCM(deviceID string, pcm []byte, rate, ch, bits int, stop <-chan struct{}) error {
if len(pcm) == 0 {
return nil
}
runtime.LockOSThread()
defer runtime.UnlockOSThread()
if err := coInit(); err != nil {
return fmt.Errorf("CoInitialize: %w", err)
}
defer ole.CoUninitialize()
dev, err := openDevice(wca.ERender, deviceID)
if err != nil {
return err
}
defer dev.Release()
var ac *wca.IAudioClient
if err := dev.Activate(wca.IID_IAudioClient, wca.CLSCTX_ALL, nil, &ac); err != nil {
return fmt.Errorf("activate render: %w", err)
}
defer ac.Release()
frameBytes := ch * bits / 8
if frameBytes <= 0 {
return fmt.Errorf("bad audio format")
}
wfx := &wca.WAVEFORMATEX{
WFormatTag: 1, NChannels: uint16(ch), NSamplesPerSec: uint32(rate),
NAvgBytesPerSec: uint32(rate * frameBytes), NBlockAlign: uint16(frameBytes),
WBitsPerSample: uint16(bits), CbSize: 0,
}
if err := ac.Initialize(wca.AUDCLNT_SHAREMODE_SHARED, autoConvert,
wca.REFERENCE_TIME(bufferDuration100ns), 0, wfx, nil); err != nil {
return fmt.Errorf("initialize render: %w", err)
}
var bufFrames uint32
if err := ac.GetBufferSize(&bufFrames); err != nil {
return err
}
var arc *wca.IAudioRenderClient
if err := ac.GetService(wca.IID_IAudioRenderClient, &arc); err != nil {
return fmt.Errorf("get render service: %w", err)
}
defer arc.Release()
totalFrames := len(pcm) / frameBytes
written := 0
feed := func(maxFrames int) error {
if maxFrames <= 0 || written >= totalFrames {
return nil
}
n := totalFrames - written
if n > maxFrames {
n = maxFrames
}
var data *byte
if err := arc.GetBuffer(uint32(n), &data); err != nil {
return err
}
dst := unsafe.Slice(data, n*frameBytes)
copy(dst, pcm[written*frameBytes:(written+n)*frameBytes])
arc.ReleaseBuffer(uint32(n), 0)
written += n
return nil
}
// Pre-fill before starting to avoid an initial glitch.
if err := feed(int(bufFrames)); err != nil {
return err
}
if err := ac.Start(); err != nil {
return fmt.Errorf("start render: %w", err)
}
defer ac.Stop()
for written < totalFrames {
select {
case <-stop:
return nil
default:
}
var padding uint32
ac.GetCurrentPadding(&padding)
if err := feed(int(bufFrames - padding)); err != nil {
return err
}
time.Sleep(8 * time.Millisecond)
}
// Drain the remaining buffered audio.
for {
select {
case <-stop:
return nil
default:
}
var padding uint32
if ac.GetCurrentPadding(&padding) != nil || padding == 0 {
return nil
}
time.Sleep(10 * time.Millisecond)
}
}
+137
View File
@@ -0,0 +1,137 @@
//go:build windows
package audio
import (
"fmt"
"sync"
)
// Manager owns the DVK record/playback lifecycle: at most one recording and
// one playback at a time. Device ids are passed per call so the host can route
// recording to the mic and playback to the rig (or the preview speakers).
type Manager struct {
mu sync.Mutex
recStop chan struct{}
recDone chan recResult
playStop chan struct{}
onChange func() // fired on any record/playback state transition
}
type recResult struct {
pcm []byte
err error
}
// NewManager creates a DVK manager. onChange (optional) is called whenever the
// recording/playback state changes, so the host can push an audio:status event.
func NewManager(onChange func()) *Manager { return &Manager{onChange: onChange} }
func (m *Manager) notify() {
if m.onChange != nil {
m.onChange()
}
}
// StartRecording begins capturing from deviceID into memory. Finish with
// StopRecording (which writes the WAV) or CancelRecording (which discards it).
func (m *Manager) StartRecording(deviceID string) error {
m.mu.Lock()
if m.recStop != nil {
m.mu.Unlock()
return fmt.Errorf("already recording")
}
stop := make(chan struct{})
done := make(chan recResult, 1)
m.recStop, m.recDone = stop, done
m.mu.Unlock() // release BEFORE notify — onChange re-enters via IsRecording()
go func() {
pcm, err := recordPCM(deviceID, stop)
done <- recResult{pcm, err}
}()
m.notify()
return nil
}
// StopRecording ends the capture and writes it to path as a WAV file.
func (m *Manager) StopRecording(path string) error {
m.mu.Lock()
stop, done := m.recStop, m.recDone
m.recStop, m.recDone = nil, nil
m.mu.Unlock()
if stop == nil {
return fmt.Errorf("not recording")
}
close(stop)
res := <-done
m.notify()
if res.err != nil {
return res.err
}
if len(res.pcm) == 0 {
return fmt.Errorf("captured no audio (check the recording device)")
}
return writeWAV(path, res.pcm)
}
// CancelRecording aborts a recording without saving.
func (m *Manager) CancelRecording() {
m.mu.Lock()
stop, done := m.recStop, m.recDone
m.recStop, m.recDone = nil, nil
m.mu.Unlock()
if stop != nil {
close(stop)
<-done
m.notify()
}
}
func (m *Manager) IsRecording() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.recStop != nil
}
func (m *Manager) IsPlaying() bool {
m.mu.Lock()
defer m.mu.Unlock()
return m.playStop != nil
}
// Play renders a WAV file to deviceID. Any current playback is stopped first.
// Returns immediately; playback runs in the background.
func (m *Manager) Play(deviceID, path string) error {
pcm, rate, ch, bits, err := readWAV(path)
if err != nil {
return err
}
m.StopPlayback()
stop := make(chan struct{})
m.mu.Lock()
m.playStop = stop
m.mu.Unlock()
go func() {
_ = playPCM(deviceID, pcm, rate, ch, bits, stop)
m.mu.Lock()
if m.playStop == stop {
m.playStop = nil
}
m.mu.Unlock()
m.notify()
}()
m.notify()
return nil
}
// StopPlayback halts any in-progress playback.
func (m *Manager) StopPlayback() {
m.mu.Lock()
stop := m.playStop
m.playStop = nil
m.mu.Unlock()
if stop != nil {
close(stop)
m.notify()
}
}
+54
View File
@@ -0,0 +1,54 @@
//go:build windows
package audio
import (
"os"
"github.com/braheezy/shine-mp3/pkg/mp3"
)
// mp3Rate is the encode sample rate. The capture pipeline is 16 kHz, but the
// Shine encoder emits broken "free-format" frames at MPEG-2 rates (16/22/24
// kHz) that most players reject. Encoding at an MPEG-1 rate (we upsample ×2 to
// 32 kHz) produces standard, universally-playable MP3s.
const mp3Rate = sampleRate * 2 // 32000
// writeMP3 encodes 16 kHz mono 16-bit PCM to a standard MP3 file using the
// pure-Go Shine encoder (no CGO). Two quirks are worked around:
// - 16 kHz (MPEG-2) yields broken free-format frames → upsample ×2 to 32 kHz.
// - Shine's Write only encodes half the samples for MONO input (its loop
// advances by samples_per_pass*2). Feeding STEREO interleaved data (the
// encoder reads samples_per_pass*channels per pass) encodes everything, so
// we duplicate mono → L=R stereo.
func writeMP3(path string, pcm []byte) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
mono32 := upsample2(bytesToInt16(pcm)) // 16 kHz → 32 kHz mono
stereo := make([]int16, len(mono32)*2) // L=R interleaved
for i, v := range mono32 {
stereo[2*i], stereo[2*i+1] = v, v
}
enc := mp3.NewEncoder(mp3Rate, 2)
return enc.Write(f, stereo)
}
// upsample2 doubles the sample rate with linear interpolation (16 kHz → 32 kHz).
func upsample2(in []int16) []int16 {
if len(in) == 0 {
return in
}
out := make([]int16, len(in)*2)
for i := range in {
out[2*i] = in[i]
if i+1 < len(in) {
out[2*i+1] = int16((int32(in[i]) + int32(in[i+1])) / 2)
} else {
out[2*i+1] = in[i]
}
}
return out
}
+283
View File
@@ -0,0 +1,283 @@
//go:build windows
package audio
import (
"encoding/binary"
"fmt"
"strings"
"sync"
"time"
)
// Recorder continuously captures audio into a rolling pre-roll buffer so a QSO
// recording can begin a few seconds BEFORE the operator entered the callsign.
// It optionally mixes two sources (the rig RX "From Radio" + your mic) into a
// single mono track, so both sides of the contact are captured.
//
// Lifecycle: Start() runs capture+mix in the background. BeginQSO() snapshots
// the pre-roll and starts accumulating; SaveQSO() writes the WAV; DiscardQSO()
// drops it. Stop() tears down capture.
type Recorder struct {
mu sync.Mutex
stopCh chan struct{}
wg sync.WaitGroup
running bool
prerollSamples int
// Per-source sample queues (guarded by srcMu), drained by the mixer.
srcMu sync.Mutex
bufA []int16 // From Radio
bufB []int16 // mic
twoSrc bool
gainA float64 // From Radio gain (1.0 = unity), guarded by srcMu
gainB float64 // mic gain
// Mixed output state (guarded by mu).
ring []int16 // last prerollSamples of mixed audio
active bool
acc []int16 // active QSO accumulation (seeded from ring on BeginQSO)
}
func NewRecorder() *Recorder { return &Recorder{gainA: 1, gainB: 1} }
// SetGains sets the per-source mix levels (1.0 = unity). Use this to balance a
// hot mic against quieter rig RX audio. Values ≤0 fall back to unity.
func (r *Recorder) SetGains(fromGain, micGain float64) {
if fromGain <= 0 {
fromGain = 1
}
if micGain <= 0 {
micGain = 1
}
r.srcMu.Lock()
r.gainA, r.gainB = fromGain, micGain
r.srcMu.Unlock()
}
// scaleSample applies gain to a sample with clamping.
func scaleSample(s int16, g float64) int16 {
if g == 1 {
return s
}
v := float64(s) * g
if v > 32767 {
return 32767
}
if v < -32768 {
return -32768
}
return int16(v)
}
func (r *Recorder) Running() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.running
}
func (r *Recorder) Active() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.active
}
// Start begins continuous capture from fromDev (required) mixed with micDev
// (optional — "" or same as fromDev → single source). prerollSec is how much
// audio to retain ahead of BeginQSO.
func (r *Recorder) Start(fromDev, micDev string, prerollSec int) error {
r.mu.Lock()
if r.running {
r.mu.Unlock()
return nil
}
if prerollSec < 0 {
prerollSec = 0
}
r.prerollSamples = prerollSec * sampleRate
r.twoSrc = micDev != "" && micDev != fromDev
r.stopCh = make(chan struct{})
r.running = true
r.ring, r.acc, r.active, r.bufA, r.bufB = nil, nil, false, nil, nil
stop := r.stopCh
twoSrc := r.twoSrc
r.mu.Unlock()
// Capture goroutine(s) feed the per-source queues.
r.wg.Add(1)
go func() {
defer r.wg.Done()
_ = captureStream(fromDev, stop, func(chunk []byte) {
s := bytesToInt16(chunk)
r.srcMu.Lock()
r.bufA = append(r.bufA, s...)
r.srcMu.Unlock()
})
}()
if twoSrc {
r.wg.Add(1)
go func() {
defer r.wg.Done()
_ = captureStream(micDev, stop, func(chunk []byte) {
s := bytesToInt16(chunk)
r.srcMu.Lock()
r.bufB = append(r.bufB, s...)
r.srcMu.Unlock()
})
}()
}
// Mixer goroutine.
r.wg.Add(1)
go func() {
defer r.wg.Done()
t := time.NewTicker(40 * time.Millisecond)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
r.mixTick()
}
}
}()
return nil
}
// mixTick drains the source queues, mixes what's available, and appends to the
// ring + active accumulation.
func (r *Recorder) mixTick() {
r.srcMu.Lock()
var mixed []int16
if r.twoSrc {
n := len(r.bufA)
if len(r.bufB) < n {
n = len(r.bufB)
}
if n > 0 {
mixed = make([]int16, n)
for i := 0; i < n; i++ {
mixed[i] = clampSum(scaleSample(r.bufA[i], r.gainA), scaleSample(r.bufB[i], r.gainB))
}
r.bufA = append(r.bufA[:0], r.bufA[n:]...)
r.bufB = append(r.bufB[:0], r.bufB[n:]...)
}
// Drift guard: if the clocks diverge, drop the excess so the two
// sources stay roughly aligned (≤1 s skew).
if d := len(r.bufA) - len(r.bufB); d > sampleRate {
r.bufA = append(r.bufA[:0], r.bufA[d:]...)
} else if d < -sampleRate {
r.bufB = append(r.bufB[:0], r.bufB[-d:]...)
}
} else if len(r.bufA) > 0 {
mixed = make([]int16, len(r.bufA))
for i, s := range r.bufA {
mixed[i] = scaleSample(s, r.gainA)
}
r.bufA = r.bufA[:0]
}
r.srcMu.Unlock()
if len(mixed) == 0 {
return
}
r.mu.Lock()
r.ring = append(r.ring, mixed...)
if len(r.ring) > r.prerollSamples {
r.ring = append(r.ring[:0], r.ring[len(r.ring)-r.prerollSamples:]...)
}
if r.active {
r.acc = append(r.acc, mixed...)
}
r.mu.Unlock()
}
// BeginQSO starts accumulating a recording, seeded with the current pre-roll.
// No-op if already accumulating or not running.
func (r *Recorder) BeginQSO() {
r.mu.Lock()
defer r.mu.Unlock()
if !r.running || r.active {
return
}
r.acc = append([]int16(nil), r.ring...)
r.active = true
}
// SaveQSO writes the accumulated recording to path as a WAV and stops
// accumulating. Returns an error if no recording was active.
func (r *Recorder) SaveQSO(path string) error {
r.mu.Lock()
if !r.active {
r.mu.Unlock()
return fmt.Errorf("no active recording")
}
samples := r.acc
r.acc, r.active = nil, false
r.mu.Unlock()
if len(samples) == 0 {
return fmt.Errorf("recording was empty")
}
data := int16sToBytes(samples)
if strings.HasSuffix(strings.ToLower(path), ".mp3") {
return writeMP3(path, data)
}
return writeWAV(path, data)
}
// DiscardQSO drops the active accumulation without saving (callsign cleared).
func (r *Recorder) DiscardQSO() {
r.mu.Lock()
r.acc, r.active = nil, false
r.mu.Unlock()
}
// Stop tears down capture+mix.
func (r *Recorder) Stop() {
r.mu.Lock()
if !r.running {
r.mu.Unlock()
return
}
r.running = false
stop := r.stopCh
r.stopCh = nil
r.mu.Unlock()
close(stop)
r.wg.Wait()
r.mu.Lock()
r.ring, r.acc, r.active = nil, nil, false
r.mu.Unlock()
r.srcMu.Lock()
r.bufA, r.bufB = nil, nil
r.srcMu.Unlock()
}
func clampSum(a, b int16) int16 {
v := int32(a) + int32(b)
if v > 32767 {
return 32767
}
if v < -32768 {
return -32768
}
return int16(v)
}
func bytesToInt16(b []byte) []int16 {
out := make([]int16, len(b)/2)
for i := range out {
out[i] = int16(binary.LittleEndian.Uint16(b[i*2:]))
}
return out
}
func int16sToBytes(s []int16) []byte {
b := make([]byte, len(s)*2)
for i, v := range s {
binary.LittleEndian.PutUint16(b[i*2:], uint16(v))
}
return b
}
+86
View File
@@ -0,0 +1,86 @@
//go:build windows
package audio
import (
"encoding/binary"
"fmt"
"os"
)
// The DVK/recorder pipeline uses a single fixed PCM format end-to-end: 16 kHz
// mono 16-bit. That's plenty for SSB voice (3 kHz audio bandwidth), keeps files
// tiny (~32 KB/s), and — fed through WASAPI's AUTOCONVERTPCM — plays/records on
// any device regardless of its native mix format.
const (
sampleRate = 16000
channels = 1
bitsPerSample = 16
blockAlign = channels * bitsPerSample / 8 // bytes per frame (=2)
bytesPerSec = sampleRate * blockAlign // =32000
)
// writeWAV writes 16-bit PCM as a canonical RIFF/WAVE file.
func writeWAV(path string, pcm []byte) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
dataLen := len(pcm)
put := func(v any) { _ = binary.Write(f, binary.LittleEndian, v) }
f.WriteString("RIFF")
put(uint32(36 + dataLen))
f.WriteString("WAVE")
f.WriteString("fmt ")
put(uint32(16)) // PCM fmt chunk size
put(uint16(1)) // WAVE_FORMAT_PCM
put(uint16(channels)) //
put(uint32(sampleRate)) //
put(uint32(bytesPerSec)) // byte rate
put(uint16(blockAlign)) //
put(uint16(bitsPerSample)) //
f.WriteString("data")
put(uint32(dataLen))
_, err = f.Write(pcm)
return err
}
// readWAV reads a PCM WAV and returns the raw sample bytes plus its format.
// Handles arbitrary chunk ordering (walks the RIFF chunk list).
func readWAV(path string) (pcm []byte, rate, ch, bits int, err error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, 0, 0, 0, err
}
if len(b) < 12 || string(b[0:4]) != "RIFF" || string(b[8:12]) != "WAVE" {
return nil, 0, 0, 0, fmt.Errorf("not a WAVE file")
}
i := 12
for i+8 <= len(b) {
id := string(b[i : i+4])
size := int(binary.LittleEndian.Uint32(b[i+4 : i+8]))
body := i + 8
if body+size > len(b) {
size = len(b) - body
}
switch id {
case "fmt ":
if size >= 16 {
ch = int(binary.LittleEndian.Uint16(b[body+2 : body+4]))
rate = int(binary.LittleEndian.Uint32(b[body+4 : body+8]))
bits = int(binary.LittleEndian.Uint16(b[body+14 : body+16]))
}
case "data":
pcm = b[body : body+size]
}
i = body + size
if size%2 == 1 {
i++ // chunks are word-aligned
}
}
if pcm == nil || rate == 0 {
return nil, 0, 0, 0, fmt.Errorf("WAV missing fmt/data")
}
return pcm, rate, ch, bits, nil
}
+815
View File
@@ -0,0 +1,815 @@
// Package award computes amateur-radio award progress (worked / confirmed)
// directly from the logbook. An award is defined declaratively: a QSO FIELD to
// scan plus an optional regular-expression PATTERN that extracts the reference
// from that field. With no pattern the whole field value is the reference; with
// a pattern, capture group 1 (or the whole match) is the reference and a single
// QSO may yield several references (e.g. a Note holding "D74 D73").
//
// Examples:
// DXCC : field "dxcc" (no pattern) → entity number
// WAS : field "state", DXCCFilter [291,110,6] → US state
// DDFM : field "note", pattern "D(\d{1,2}[AB]?)" → French department from notes
// WPX : field "prefix" (computed from callsign)
package award
import (
"regexp"
"sort"
"strconv"
"strings"
"hamlog/internal/qso"
)
// AwardType selects how a QSO is matched to an award's references.
//
// "DXCC" — match the QSO's DXCC entity number (references keyed by entity)
// "QSOFIELDS" — search a QSO field for a reference code/description/pattern
// "REFERENCE" — the reference is carried by a dedicated field (POTA_REF, …) or
// the per-reference DXCC list (e.g. RAC provinces by state)
// "GRID" — match a Maidenhead grid square
type AwardType = string
const (
TypeDXCC AwardType = "DXCC"
TypeQSOFields AwardType = "QSOFIELDS"
TypeReference AwardType = "REFERENCE"
TypeGrid AwardType = "GRID"
)
// Def defines one award. Fields mirror Log4OM's Award Management model: an
// identity + scope (when the award applies) + a matching rule (how a QSO maps
// to a reference) + confirmation rules. Most fields are optional; the zero
// value of a legacy Def (only Field/Pattern/DXCCFilter/Confirm/Total set) still
// behaves as before.
type Def struct {
// --- Identity ---
Code string `json:"code"` // unique key, e.g. "DXCC"
Name string `json:"name"` // friendly name
Description string `json:"description,omitempty"` // free text
Valid bool `json:"valid"` // award enabled
Protected bool `json:"protected,omitempty"` // shipped/locked award
URL string `json:"url,omitempty"` // award home page
DownloadURL string `json:"download_url,omitempty"` // reference-list source
RefURL string `json:"ref_url,omitempty"` // per-ref link, <REF> placeholder
ValidFrom string `json:"valid_from,omitempty"` // ISO date (QSOs before don't count)
ValidTo string `json:"valid_to,omitempty"` // ISO date (QSOs after don't count)
Alias string `json:"alias,omitempty"`
// --- Type & matching ---
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
Field string `json:"field"` // QSO field to scan (see fieldRaw)
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
Pattern string `json:"pattern"` // award-level Go regexp; group 1 = reference
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
Multi bool `json:"multi,omitempty"` // a QSO may count for several references
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes
// --- Scope ---
DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
ValidBands []string `json:"valid_bands,omitempty"` // empty = all bands
ValidModes []string `json:"valid_modes,omitempty"` // empty = all modes
Emission []string `json:"emission,omitempty"` // CW | DIGITAL | PHONE (empty = all)
// --- Confirmation ---
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
Validate []string `json:"validate,omitempty"` // validated/granted sources
GrantCodes string `json:"grant_codes,omitempty"` // ADIF credit grant codes
ExportCreditGranted bool `json:"export_credit_granted,omitempty"` // write ADIF credit_granted
Total int `json:"total"` // known denominator (0 = unknown / derive from list)
Builtin bool `json:"builtin"` // shipped default (informational)
}
// Defaults are the built-in awards seeded on first run (then user-editable).
func Defaults() []Def {
// Confirmed = any confirmation (LoTW or paper QSL). Validated = the stricter
// "electronically verified" tier: LoTW only — a paper QSL confirms but does
// NOT validate (matches ARRL/Log4OM). eQSL counts only where the program
// accepts it (WAC).
lq := []string{"lotw", "qsl"}
lo := []string{"lotw"}
return []Def{
{Code: "DXCC", Name: "DX Century Club", Type: TypeDXCC, Field: "dxcc", Confirm: lq, Validate: lo, Total: 340, Valid: true, Builtin: true, Protected: true},
{Code: "WAS", Name: "Worked All States", Type: TypeQSOFields, Field: "state", MatchBy: "code", ExactMatch: true, DXCCFilter: []int{291, 110, 6}, Confirm: lq, Validate: lo, Total: 50, Valid: true, Builtin: true, Protected: true},
{Code: "WAZ", Name: "Worked All Zones (CQ)", Type: TypeQSOFields, Field: "cqz", MatchBy: "code", ExactMatch: true, Confirm: lq, Validate: lo, Total: 40, Valid: true, Builtin: true, Protected: true},
{Code: "WAC", Name: "Worked All Continents", Type: TypeQSOFields, Field: "cont", MatchBy: "code", ExactMatch: true, Confirm: []string{"lotw", "qsl", "eqsl"}, Validate: lo, Total: 6, Valid: true, Builtin: true, Protected: true},
{Code: "WPX", Name: "Worked All Prefixes (CQ WPX)", Type: TypeQSOFields, Field: "prefix", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
{Code: "DDFM", Name: "Départements Français Métropolitains", Type: TypeQSOFields, Field: "note", Pattern: `(?i)\b(D\d{1,2}[AB]?)\b`, DXCCFilter: []int{227}, Confirm: lq, Validate: lo, Total: 96, Valid: true, Builtin: true, Protected: true},
{Code: "IOTA", Name: "Islands On The Air", Type: TypeReference, Field: "iota", Dynamic: true, Confirm: []string{"qsl"}, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
{Code: "POTA", Name: "Parks On The Air", Type: TypeReference, Field: "pota_ref", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
{Code: "SOTA", Name: "Summits On The Air", Type: TypeReference, Field: "sota_ref", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
{Code: "WWFF", Name: "World Wide Flora & Fauna", Type: TypeReference, Field: "wwff", Dynamic: true, Confirm: lq, Validate: lo, Total: 0, Valid: true, Builtin: true, Protected: true},
}
}
// Migrate upgrades award definitions saved before the richer model existed.
// Such defs have Type=="" and the zero value for the new fields (notably
// Valid==false, which would otherwise hide every legacy award). For each legacy
// def it enables the award, fills the matching/confirmation fields from the
// matching built-in default (preserving the user's field/filters/confirm), and
// fixes the DDFM capture pattern. Returns the (possibly) migrated slice and
// whether anything changed. Idempotent: a def with Type!="" is left untouched.
func Migrate(defs []Def) ([]Def, bool) {
defaults := map[string]Def{}
for _, d := range Defaults() {
defaults[strings.ToUpper(d.Code)] = d
}
const oldDDFM = `(?i)\bD(\d{1,2}[AB]?)\b`
changed := false
out := make([]Def, len(defs))
for i, d := range defs {
if d.Type != "" {
out[i] = d // already on the new model
continue
}
changed = true
d.Valid = true // legacy defs predate the Valid flag → enable them
if def, ok := defaults[strings.ToUpper(d.Code)]; ok {
d.Type = def.Type
d.ExactMatch = def.ExactMatch
d.Dynamic = def.Dynamic
d.Protected = def.Protected
if len(d.Validate) == 0 {
d.Validate = def.Validate
}
// Fix DDFM's capture group ("06" → "D06") so refs match the list.
if strings.EqualFold(d.Code, "DDFM") && (d.Pattern == "" || d.Pattern == oldDDFM) {
d.Pattern = def.Pattern
}
} else {
d.Type = TypeQSOFields // sensible default for custom legacy awards
}
out[i] = d
}
return out, changed
}
// Fields lists the scannable QSO fields for the award editor.
func Fields() []string {
return []string{
"dxcc", "cqz", "ituz", "prefix", "callsign",
"state", "cont", "country", "grid",
"iota", "sota_ref", "pota_ref", "wwff",
"name", "qth", "address", "comment", "note",
}
}
// BandCount holds distinct-reference counts on one band.
type BandCount struct {
Band string `json:"band"`
Worked int `json:"worked"`
Confirmed int `json:"confirmed"`
}
// Ref is one reference's status within an award.
type Ref struct {
Ref string `json:"ref"`
Name string `json:"name,omitempty"`
Group string `json:"group,omitempty"`
SubGrp string `json:"subgrp,omitempty"`
Worked bool `json:"worked"`
Confirmed bool `json:"confirmed"`
Validated bool `json:"validated"`
Bands []string `json:"bands"`
ConfirmedBands []string `json:"confirmed_bands"`
ValidatedBands []string `json:"validated_bands"`
}
// Result is an award's computed progress.
type Result struct {
Code string `json:"code"`
Name string `json:"name"`
Field string `json:"field"`
Worked int `json:"worked"`
Confirmed int `json:"confirmed"`
Validated int `json:"validated"`
Total int `json:"total"`
Bands []BandCount `json:"bands"`
Refs []Ref `json:"refs"`
Error string `json:"error,omitempty"` // e.g. bad regexp pattern
}
// NameResolver optionally maps a (field, ref) pair to a human name. May be nil.
type NameResolver func(field, ref string) string
type refAgg struct {
bands map[string]struct{}
confirmedBands map[string]struct{}
validatedBands map[string]struct{}
anyConfirmed bool
anyValidated bool
}
// refList is the per-award reference data Compute needs (a thin view of
// awardref.Ref, kept local so the award package stays storage-agnostic).
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.
type RefMeta struct {
Code string
Name string
Group string
SubGrp string
DXCCList []int // nil = any
Pattern string
re *regexp.Regexp
Valid bool
}
// NewRefList builds the engine's reference view from (code, meta) pairs.
func NewRefList(metas []RefMeta) refList {
rl := refList{byCode: make(map[string]RefMeta, len(metas))}
for _, m := range metas {
code := normalizeRef(m.Code)
if code == "" {
continue
}
if p := strings.TrimSpace(m.Pattern); p != "" {
if re, err := regexp.Compile(p); err == nil {
m.re = re
rl.withPattern = append(rl.withPattern, code)
}
}
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
}
return rl
}
// Compute runs every definition over the QSOs in a single pass. refMetas maps an
// award code to its reference metadata; awards present there with Dynamic=false
// are "predefined" (only listed references count, and the full list — including
// unworked references — appears in the result).
func Compute(defs []Def, qsos []qso.QSO, refMetas map[string][]RefMeta, nameOf NameResolver) []Result {
refLists := make(map[string]refList, len(refMetas))
for code, metas := range refMetas {
refLists[strings.ToUpper(strings.TrimSpace(code))] = NewRefList(metas)
}
// Pre-compile award-level patterns once.
res := make([]*regexp.Regexp, len(defs))
perr := make([]string, len(defs))
for i := range defs {
if p := strings.TrimSpace(defs[i].Pattern); p != "" {
re, err := regexp.Compile(p)
if err != nil {
perr[i] = "bad pattern: " + err.Error()
} else {
res[i] = re
}
}
}
agg := make([]map[string]*refAgg, len(defs))
for i := range defs {
agg[i] = map[string]*refAgg{}
}
for qi := range qsos {
q := &qsos[qi]
for i := range defs {
d := &defs[i]
if perr[i] != "" || !inScope(d, q) {
continue
}
rl, hasList := refLists[strings.ToUpper(d.Code)]
refs := candidates(d, res[i], q, rl, hasList)
if len(refs) == 0 {
continue
}
band := strings.ToLower(strings.TrimSpace(q.Band))
isConf := confirmed(q, d.Confirm)
isVal := confirmed(q, d.Validate)
for _, ref := range refs {
a := agg[i][ref]
if a == nil {
a = &refAgg{bands: map[string]struct{}{}, confirmedBands: map[string]struct{}{}, validatedBands: map[string]struct{}{}}
agg[i][ref] = a
}
if band != "" {
a.bands[band] = struct{}{}
}
if isConf {
a.anyConfirmed = true
if band != "" {
a.confirmedBands[band] = struct{}{}
}
}
if isVal {
a.anyValidated = true
if band != "" {
a.validatedBands[band] = struct{}{}
}
}
}
}
}
out := make([]Result, len(defs))
for i := range defs {
d := &defs[i]
rl, hasList := refLists[strings.ToUpper(d.Code)]
predefined := hasList && !d.Dynamic
r := Result{Code: d.Code, Name: d.Name, Field: d.Field, Total: d.Total, Error: perr[i]}
bandWorked := map[string]int{}
bandConfirmed := map[string]int{}
for ref, a := range agg[i] {
r.Worked++
if a.anyConfirmed {
r.Confirmed++
}
if a.anyValidated {
r.Validated++
}
rf := Ref{Ref: ref, Worked: true, Confirmed: a.anyConfirmed, Validated: a.anyValidated,
Bands: setToSorted(a.bands), ConfirmedBands: setToSorted(a.confirmedBands), ValidatedBands: setToSorted(a.validatedBands)}
labelRef(&rf, d, ref, rl, hasList, nameOf)
r.Refs = append(r.Refs, rf)
for b := range a.bands {
bandWorked[b]++
}
for b := range a.confirmedBands {
bandConfirmed[b]++
}
}
// Predefined awards: the full list is the denominator, and unworked
// references are listed too (greyed in the UI).
if predefined {
r.Total = len(rl.codes)
for _, code := range rl.codes {
if _, worked := agg[i][code]; worked {
continue
}
m := rl.byCode[code]
if !m.Valid {
continue
}
rf := Ref{Ref: code, Name: m.Name, Group: m.Group, SubGrp: m.SubGrp, Bands: []string{}, ConfirmedBands: []string{}, ValidatedBands: []string{}}
if rf.Name == "" && nameOf != nil {
rf.Name = nameOf(d.Field, code)
}
r.Refs = append(r.Refs, rf)
}
}
sort.Slice(r.Refs, func(a, b int) bool {
if r.Refs[a].Worked != r.Refs[b].Worked {
return r.Refs[a].Worked // worked first
}
if r.Refs[a].Confirmed != r.Refs[b].Confirmed {
return r.Refs[a].Confirmed
}
return natLess(r.Refs[a].Ref, r.Refs[b].Ref)
})
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
}
// MatchQSO returns the reference codes a single QSO contributes to for one
// award (respecting scope + predefined enforcement). metas is the award's
// reference list (empty/nil for dynamic awards). Used for cell drill-down.
func MatchQSO(d Def, metas []RefMeta, q *qso.QSO) []string {
if !inScope(&d, q) {
return nil
}
var re *regexp.Regexp
if p := strings.TrimSpace(d.Pattern); p != "" {
if c, err := regexp.Compile(p); err == nil {
re = c
} else {
return nil
}
}
rl := NewRefList(metas)
return candidates(&d, re, q, rl, len(metas) > 0)
}
// Confirmed reports whether a QSO satisfies any of the given confirmation
// sources (lotw|qsl|eqsl). Exported for the statistics view.
func Confirmed(q *qso.QSO, sources []string) bool { return confirmed(q, sources) }
// EmissionOf maps an ADIF mode to its broad category (CW|PHONE|DIGITAL).
func EmissionOf(mode string) string { return emissionOf(mode) }
// labelRef fills a worked reference's name/group from the reference list (or the
// name resolver as a fallback).
func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf NameResolver) {
if hasList {
if m, ok := rl.byCode[code]; ok {
rf.Name, rf.Group, rf.SubGrp = m.Name, m.Group, m.SubGrp
}
}
if rf.Name == "" && nameOf != nil {
rf.Name = nameOf(d.Field, code)
}
}
// candidates extracts the reference(s) a QSO contributes to an award, enforcing
// a predefined list when one applies.
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
raw := strings.TrimSpace(stripAffix(fieldRaw(d.Field, q), d.LeadingStr, d.TrailingStr))
if raw == "" {
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
// references that declare a regex.
for _, tok := range tokenize(raw) {
if _, ok := rl.byCode[tok]; ok {
found = append(found, tok)
}
}
for _, code := range rl.withPattern {
if m := rl.byCode[code]; m.re != nil && m.re.MatchString(raw) {
found = append(found, code)
}
}
default:
// Whole field value is the candidate, split on comma/semicolon so a
// multi-reference field (e.g. an n-fer POTA QSO "US-6544,US-0680")
// counts each reference separately.
found = splitRefs(raw)
}
if !predefined {
return dedupe(found)
}
// Enforce the predefined list: keep only listed, valid references. The
// award-level DXCCFilter already scopes which QSOs are considered (see
// inScope), so we do NOT additionally require the QSO's entity to match the
// reference's own DXCC — that wrongly excluded e.g. WAS Alaska (state AK is
// DXCC entity 6, not 291). Per-reference DXCC stays metadata for the picker.
var out []string
seen := map[string]struct{}{}
for _, c := range found {
c = normalizeRef(c)
m, ok := rl.byCode[c]
if !ok || !m.Valid {
continue
}
if _, dup := seen[c]; dup {
continue
}
seen[c] = struct{}{}
out = append(out, c)
}
return out
}
func regexTokens(re *regexp.Regexp, raw string) []string {
matches := re.FindAllStringSubmatch(raw, -1)
out := make([]string, 0, len(matches))
for _, m := range matches {
ref := m[0]
if len(m) > 1 && m[1] != "" {
ref = m[1]
}
if ref = normalizeRef(ref); ref != "" {
out = append(out, ref)
}
}
return dedupe(out)
}
func dedupe(in []string) []string {
if len(in) <= 1 {
return in
}
seen := make(map[string]struct{}, len(in))
out := in[:0]
for _, s := range in {
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
// tokenize splits a field into uppercased tokens on any non-alphanumeric run,
// keeping '-' and '/' which appear inside reference codes (e.g. "FR-11553").
// The whole trimmed value is also returned so single-token fields match.
func tokenize(raw string) []string {
up := strings.ToUpper(strings.TrimSpace(raw))
if up == "" {
return nil
}
out := []string{up}
cur := strings.Builder{}
for _, r := range up {
if (r >= '0' && r <= '9') || (r >= 'A' && r <= 'Z') || r == '-' || r == '/' {
cur.WriteRune(r)
} else if cur.Len() > 0 {
out = append(out, cur.String())
cur.Reset()
}
}
if cur.Len() > 0 {
out = append(out, cur.String())
}
return out
}
// stripAffix removes a leading and/or trailing literal string before matching.
func stripAffix(s, lead, trail string) string {
s = strings.TrimSpace(s)
if lead != "" {
s = strings.TrimPrefix(s, lead)
}
if trail != "" {
s = strings.TrimSuffix(s, trail)
}
return s
}
func normalizeRef(s string) string { return strings.ToUpper(strings.TrimSpace(s)) }
// splitRefs splits a field value on comma/semicolon into normalized references,
// so a multi-reference field (n-fer POTA "US-6544,US-0680") yields one entry
// per reference. A value with no separator yields a single reference.
func splitRefs(raw string) []string {
if !strings.ContainsAny(raw, ",;") {
return []string{normalizeRef(raw)}
}
var out []string
for _, p := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ';' }) {
if n := normalizeRef(p); n != "" {
out = append(out, n)
}
}
return dedupe(out)
}
func isDigit(b byte) bool { return b >= '0' && b <= '9' }
// natLess is a natural ("human") comparison: digit runs compare as numbers, so
// references sort 1,2,…,9,10,11 (not 1,10,11,2) and "D2A" before "D10".
func natLess(a, b string) bool {
ia, ib := 0, 0
for ia < len(a) && ib < len(b) {
ca, cb := a[ia], b[ib]
if isDigit(ca) && isDigit(cb) {
ja, jb := ia, ib
for ja < len(a) && isDigit(a[ja]) {
ja++
}
for jb < len(b) && isDigit(b[jb]) {
jb++
}
na := strings.TrimLeft(a[ia:ja], "0")
nb := strings.TrimLeft(b[ib:jb], "0")
if len(na) != len(nb) {
return len(na) < len(nb) // fewer digits = smaller number
}
if na != nb {
return na < nb
}
ia, ib = ja, jb
} else {
if ca != cb {
return ca < cb
}
ia++
ib++
}
}
return len(a)-ia < len(b)-ib
}
// inScope reports whether a QSO falls within an award's scope (DXCC entity,
// bands, modes, emission category, validity dates).
func inScope(d *Def, q *qso.QSO) bool {
if len(d.DXCCFilter) > 0 && !dxccAllowed(q.DXCC, d.DXCCFilter) {
return false
}
if len(d.ValidBands) > 0 && !containsFold(d.ValidBands, q.Band) {
return false
}
if len(d.ValidModes) > 0 && !containsFold(d.ValidModes, q.Mode) {
return false
}
if len(d.Emission) > 0 && !containsFold(d.Emission, emissionOf(q.Mode)) {
return false
}
if d.ValidFrom != "" && q.QSODate.Format("2006-01-02") < d.ValidFrom {
return false
}
if d.ValidTo != "" && q.QSODate.Format("2006-01-02") > d.ValidTo {
return false
}
return true
}
func containsFold(list []string, v string) bool {
v = strings.TrimSpace(v)
for _, x := range list {
if strings.EqualFold(strings.TrimSpace(x), v) {
return true
}
}
return false
}
// emissionOf maps an ADIF mode to its broad emission category.
func emissionOf(mode string) string {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "CW":
return "CW"
case "SSB", "USB", "LSB", "AM", "FM", "DV", "DIGITALVOICE", "PHONE", "C4FM":
return "PHONE"
case "":
return ""
default:
return "DIGITAL"
}
}
// fieldRaw returns the raw string value of a QSO field (computed for numeric /
// derived fields). Unknown fields yield "".
func fieldRaw(field string, q *qso.QSO) string {
switch strings.ToLower(strings.TrimSpace(field)) {
case "dxcc":
if q.DXCC != nil && *q.DXCC > 0 {
return strconv.Itoa(*q.DXCC)
}
case "cqz":
if q.CQZ != nil && *q.CQZ > 0 {
return strconv.Itoa(*q.CQZ)
}
case "ituz":
if q.ITUZ != nil && *q.ITUZ > 0 {
return strconv.Itoa(*q.ITUZ)
}
case "prefix":
return wpxPrefix(q.Callsign)
case "callsign":
return q.Callsign
case "state":
return q.State
case "cont":
return q.Continent
case "country":
return q.Country
case "grid":
return q.Grid
case "iota":
return q.IOTA
case "sota_ref":
return q.SOTARef
case "pota_ref":
return q.POTARef
case "name":
return q.Name
case "qth":
return q.QTH
case "address":
return q.Address
case "comment":
return q.Comment
case "note", "notes":
return q.Notes
case "wwff":
if q.Extras != nil {
if v := strings.TrimSpace(q.Extras["WWFF_REF"]); v != "" {
return v
}
if strings.EqualFold(q.Extras["SIG"], "WWFF") {
return q.Extras["SIG_INFO"]
}
}
}
return ""
}
func dxccAllowed(dxcc *int, filter []int) bool {
if dxcc == nil {
return false
}
for _, f := range filter {
if *dxcc == f {
return true
}
}
return false
}
// confirmed reports whether the QSO satisfies any accepted confirmation source.
// ADIF *_QSL_RCVD values Y (confirmed) and V (verified) both count.
func confirmed(q *qso.QSO, sources []string) bool {
for _, s := range sources {
switch s {
case "lotw":
if isYes(q.LOTWRcvd) {
return true
}
case "qsl":
if isYes(q.QSLRcvd) {
return true
}
case "eqsl":
if isYes(q.EQSLRcvd) {
return true
}
}
}
return false
}
func isYes(v string) bool {
switch strings.ToUpper(strings.TrimSpace(v)) {
case "Y", "V":
return true
}
return false
}
func setToSorted(m map[string]struct{}) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
var bandOrder = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm"}
func sortedBands(m map[string]int) []string {
idx := map[string]int{}
for i, b := range bandOrder {
idx[b] = i
}
out := make([]string, 0, len(m))
for b := range m {
out = append(out, b)
}
sort.Slice(out, func(a, b int) bool {
ia, oka := idx[out[a]]
ib, okb := idx[out[b]]
if oka && okb {
return ia < ib
}
if oka != okb {
return oka
}
return out[a] < out[b]
})
return out
}
+222
View File
@@ -0,0 +1,222 @@
package award
import (
"sort"
"testing"
"hamlog/internal/qso"
)
func TestWPXPrefix(t *testing.T) {
cases := map[string]string{
"F4BPO": "F4",
"EA8ABC": "EA8",
"9A1AA": "9A1",
"OH2BH": "OH2",
"K1ABC": "K1",
"RAEM": "RA0",
"F4BPO/P": "F4",
"F4BPO/9": "F9",
"VP8/F4BPO": "VP8",
"PA0XYZ": "PA0",
}
for in, want := range cases {
if got := wpxPrefix(in); got != want {
t.Errorf("wpxPrefix(%q) = %q, want %q", in, got, want)
}
}
}
func ip(n int) *int { return &n }
func TestComputeDXCCAndConfirm(t *testing.T) {
qsos := []qso.QSO{
{Callsign: "K1ABC", Band: "20m", DXCC: ip(291), State: "MA", LOTWRcvd: "Y"},
{Callsign: "K2DEF", Band: "40m", DXCC: ip(291), State: "NY"}, // worked, not confirmed
{Callsign: "DL1XYZ", Band: "20m", DXCC: ip(230), QSLRcvd: "Y"}, // DXCC Germany confirmed
{Callsign: "F4BPO", Band: "20m", DXCC: ip(227), Notes: "nice qso D74", EQSLRcvd: "Y"}, // France dept 74 in note
{Callsign: "F5ABC", Band: "40m", DXCC: ip(227), Notes: "D2A Corsica", QSLRcvd: "Y"}, // France dept 2A, confirmed
}
res := Compute(Defaults(), qsos, nil, nil)
by := map[string]Result{}
for _, r := range res {
by[r.Code] = r
}
dxcc := by["DXCC"]
if dxcc.Worked != 3 { // USA, Germany, France
t.Errorf("DXCC worked = %d, want 3", dxcc.Worked)
}
// DXCC confirms on lotw|qsl → USA(lotw) + Germany(qsl) + France(qsl via F5ABC).
if dxcc.Confirmed != 3 {
t.Errorf("DXCC confirmed = %d, want 3", dxcc.Confirmed)
}
// Validated is the stricter LoTW-only tier: a paper QSL confirms but does
// NOT validate, so only USA (LoTW) counts.
if dxcc.Validated != 1 {
t.Errorf("DXCC validated = %d, want 1 (LoTW only, QSL doesn't validate)", dxcc.Validated)
}
was := by["WAS"]
if was.Worked != 2 { // MA, NY only (France excluded by DXCC filter)
t.Errorf("WAS worked = %d, want 2", was.Worked)
}
// DDFM scans the Note field with pattern D(\d{1,2}[AB]?): 74 and 2A.
ddfm := by["DDFM"]
if ddfm.Worked != 2 {
t.Errorf("DDFM worked = %d, want 2 (refs %v)", ddfm.Worked, refCodes(ddfm))
}
if ddfm.Confirmed != 1 { // 2A confirmed via QSL; 74 only eQSL (not accepted)
t.Errorf("DDFM confirmed = %d, want 1", ddfm.Confirmed)
}
}
func TestNatLess(t *testing.T) {
in := []string{"10", "2", "1", "20", "3", "D10", "D2A", "D2", "AL", "AK"}
want := []string{"1", "2", "3", "10", "20", "AK", "AL", "D2", "D2A", "D10"}
sort.Slice(in, func(i, j int) bool { return natLess(in[i], in[j]) })
for i := range want {
if in[i] != want[i] {
t.Fatalf("natLess order = %v, want %v", in, want)
}
}
}
// A multi-reference field (n-fer POTA) counts each park separately.
func TestComputeMultiRef(t *testing.T) {
def := Def{Code: "POTA", Type: TypeReference, Field: "pota_ref", Dynamic: true, Confirm: []string{"lotw", "qsl"}, Valid: true}
qsos := []qso.QSO{
{Callsign: "W2QMI", Band: "20m", POTARef: "US-6544,US-0680", LOTWRcvd: "Y"},
{Callsign: "K1ABC", Band: "40m", POTARef: "US-0680"}, // shared park
}
r := Compute([]Def{def}, qsos, nil, nil)[0]
if r.Worked != 2 { // distinct parks: US-6544, US-0680
t.Errorf("POTA worked = %d, want 2 (%v)", r.Worked, refCodes(r))
}
}
// 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 {
out = append(out, rf.Ref)
}
return out
}
// Legacy defs (Type=="", Valid==false, old DDFM pattern) get upgraded.
func TestMigrate(t *testing.T) {
legacy := []Def{
{Code: "DXCC", Field: "dxcc", Confirm: []string{"lotw", "qsl"}, Total: 340}, // Valid=false, Type=""
{Code: "DDFM", Field: "note", Pattern: `(?i)\bD(\d{1,2}[AB]?)\b`, Total: 96},
{Code: "MYAWARD", Field: "note"}, // custom legacy
}
out, changed := Migrate(legacy)
if !changed {
t.Fatal("expected migration to change legacy defs")
}
for _, d := range out {
if !d.Valid {
t.Errorf("%s should be enabled after migration", d.Code)
}
if d.Type == "" {
t.Errorf("%s should have a type after migration", d.Code)
}
}
if out[1].Pattern != `(?i)\b(D\d{1,2}[AB]?)\b` {
t.Errorf("DDFM pattern not fixed: %q", out[1].Pattern)
}
if out[2].Type != TypeQSOFields {
t.Errorf("custom legacy award type = %q, want QSOFIELDS", out[2].Type)
}
// Idempotent: a second pass changes nothing.
if _, changed2 := Migrate(out); changed2 {
t.Error("migration should be idempotent")
}
}
// Regression: a predefined reference whose own DXCC differs from the QSO's
// entity must still count when the field code matches and the award-level
// DXCC filter allows it (WAS Alaska: state AK, but DXCC entity 6, not 291).
func TestComputePredefinedCrossDXCC(t *testing.T) {
def := Def{Code: "WAS", Type: TypeQSOFields, Field: "state", ExactMatch: true,
DXCCFilter: []int{291, 110, 6}, Confirm: []string{"lotw", "qsl"}, Valid: true}
qsos := []qso.QSO{
{Callsign: "KL5DX", Band: "20m", DXCC: ip(6), State: "AK", LOTWRcvd: "Y"}, // Alaska
{Callsign: "K1ABC", Band: "20m", DXCC: ip(291), State: "MA"}, // continental
}
refMetas := map[string][]RefMeta{"WAS": {
{Code: "AK", Name: "Alaska", DXCCList: []int{291}, Valid: true}, // wrong DXCC on purpose
{Code: "MA", Name: "Massachusetts", DXCCList: []int{291}, Valid: true},
}}
r := Compute([]Def{def}, qsos, refMetas, nil)[0]
if r.Worked != 2 {
t.Errorf("WAS worked = %d, want 2 (Alaska must count despite DXCC 6) %v", r.Worked, refCodes(r))
}
}
// A predefined award only counts references present in its list, lists the
// unworked ones too, and uses the list size as the denominator.
func TestComputePredefinedList(t *testing.T) {
def := Def{Code: "RAC", Name: "RAC", Type: TypeQSOFields, Field: "state", ExactMatch: true,
DXCCFilter: []int{1}, Confirm: []string{"lotw", "qsl"}, Validate: []string{"lotw"}, Valid: true}
qsos := []qso.QSO{
{Callsign: "VE3AAA", Band: "20m", DXCC: ip(1), State: "ON", LOTWRcvd: "Y"}, // worked+confirmed+validated
{Callsign: "VE7BBB", Band: "40m", DXCC: ip(1), State: "BC", QSLRcvd: "Y"}, // worked+confirmed (not validated)
{Callsign: "VE9CCC", Band: "20m", DXCC: ip(1), State: "ZZ"}, // not a real province → ignored
{Callsign: "K1ABC", Band: "20m", DXCC: ip(291), State: "MA"}, // wrong DXCC → ignored
}
refMetas := map[string][]RefMeta{"RAC": {
{Code: "ON", Name: "Ontario", Valid: true},
{Code: "BC", Name: "British Columbia", Valid: true},
{Code: "AB", Name: "Alberta", Valid: true},
}}
r := Compute([]Def{def}, qsos, refMetas, nil)[0]
if r.Worked != 2 {
t.Errorf("RAC worked = %d, want 2 (%v)", r.Worked, refCodes(r))
}
if r.Confirmed != 2 {
t.Errorf("RAC confirmed = %d, want 2", r.Confirmed)
}
if r.Validated != 1 { // only ON via LoTW
t.Errorf("RAC validated = %d, want 1", r.Validated)
}
if r.Total != 3 { // denominator = list size
t.Errorf("RAC total = %d, want 3", r.Total)
}
if len(r.Refs) != 3 { // ON, BC worked + AB unworked
t.Errorf("RAC refs = %d, want 3 (%v)", len(r.Refs), refCodes(r))
}
}
+119
View File
@@ -0,0 +1,119 @@
package award
import "strings"
// wpxPrefix derives the CQ WPX prefix from a callsign. This is an approximation
// of the official WPX rules — good enough to count distinct prefixes worked:
// - standard call: letters+digits up to and including the LAST digit of the
// first group (F4BPO→F4, EA8ABC→EA8, 9A1AA→9A1, OH2BH→OH2)
// - no digit: first two letters + "0" (RAEM→RA0)
// - portable "A/B": a short alpha(+digit) segment is treated as the prefix
// designator; a lone-digit segment replaces the call's digit (F4BPO/9→F9)
func wpxPrefix(call string) string {
c := strings.ToUpper(strings.TrimSpace(call))
if c == "" {
return ""
}
if strings.Contains(c, "/") {
return portablePrefix(c)
}
return standardPrefix(c)
}
func portablePrefix(c string) string {
parts := strings.Split(c, "/")
// Drop pure operating-modifier suffixes.
kept := make([]string, 0, len(parts))
for _, p := range parts {
switch p {
case "P", "M", "MM", "AM", "QRP", "A", "R", "B", "LH":
continue
}
if p != "" {
kept = append(kept, p)
}
}
if len(kept) == 0 {
kept = parts
}
// Pick a base = the longest segment (the actual call).
base := kept[0]
for _, p := range kept[1:] {
if len(p) > len(base) {
base = p
}
}
for _, p := range kept {
if p == base {
continue
}
if isAllDigits(p) {
// Lone digit replaces the call's region digit: F4BPO/9 → F9.
return replaceLastDigit(standardPrefix(base), p)
}
if len(p) <= 4 && hasLetter(p) {
// Prefix designator wins: VP8/F4BPO → VP8 (+digit if missing).
return ensureTrailingDigit(p)
}
}
return standardPrefix(base)
}
// standardPrefix applies the basic WPX rule to a plain callsign: the prefix is
// the call up to and including its last digit (9A1AA→9A1, EA8ABC→EA8). Standard
// callsigns carry no digit in the suffix, so "last digit" is the prefix digit.
func standardPrefix(c string) string {
lastDigit := -1
for i := 0; i < len(c); i++ {
if c[i] >= '0' && c[i] <= '9' {
lastDigit = i
}
}
if lastDigit < 0 {
// No digit at all: first two letters + 0.
if len(c) >= 2 {
return c[:2] + "0"
}
return c + "0"
}
return c[:lastDigit+1]
}
func ensureTrailingDigit(p string) string {
for i := 0; i < len(p); i++ {
if p[i] >= '0' && p[i] <= '9' {
return p
}
}
return p + "0"
}
func replaceLastDigit(prefix, digit string) string {
for i := len(prefix) - 1; i >= 0; i-- {
if prefix[i] >= '0' && prefix[i] <= '9' {
return prefix[:i] + digit
}
}
return prefix + digit
}
func isAllDigits(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return false
}
}
return true
}
func hasLetter(s string) bool {
for i := 0; i < len(s); i++ {
if (s[i] >= 'A' && s[i] <= 'Z') || (s[i] >= 'a' && s[i] <= 'z') {
return true
}
}
return false
}
+282
View File
@@ -0,0 +1,282 @@
// Package awardref stores and updates award reference lists (POTA parks, SOTA
// summits, WWFF references, …). These provide award totals, reference names,
// and per-DXCC filtering. Lists are downloaded from each program's public file.
package awardref
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
)
// allCols is the column list shared by read queries so they stay in sync.
const allCols = `ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias`
func encodeDXCCList(l []int) string {
if len(l) == 0 {
return ""
}
b, err := json.Marshal(l)
if err != nil {
return ""
}
return string(b)
}
func decodeDXCCList(s string) []int {
if strings.TrimSpace(s) == "" {
return nil
}
var l []int
if json.Unmarshal([]byte(s), &l) != nil {
return nil
}
return l
}
// scanRef reads one row selected with allCols into a Ref.
func scanRef(rows *sql.Rows) (Ref, error) {
var r Ref
var dxccList string
var valid int
if err := rows.Scan(&r.Code, &r.Name, &r.DXCC, &r.Group, &r.SubGrp,
&dxccList, &r.Pattern, &valid, &r.ValidFrom, &r.ValidTo,
&r.Score, &r.Bonus, &r.GridSquare, &r.Alias); err != nil {
return r, err
}
r.DXCCList = decodeDXCCList(dxccList)
r.Valid = valid != 0
return r, nil
}
// Ref is one award reference. The first five fields are the original schema;
// the rest mirror Log4OM's per-reference editor (group/subgroup, multi-DXCC,
// per-reference regex, validity window, score/bonus, grid, alias).
type Ref struct {
Code string `json:"code"`
Name string `json:"name"` // description
DXCC int `json:"dxcc"` // primary entity (kept for compatibility / fast filter)
Group string `json:"group"`
SubGrp string `json:"subgrp"`
DXCCList []int `json:"dxcc_list,omitempty"` // all entities this ref is valid for
Pattern string `json:"pattern,omitempty"` // per-reference Go regexp
Valid bool `json:"valid"` // reference enabled
ValidFrom string `json:"valid_from,omitempty"`
ValidTo string `json:"valid_to,omitempty"`
Score int `json:"score,omitempty"`
Bonus int `json:"bonus,omitempty"`
GridSquare string `json:"gridsquare,omitempty"`
Alias string `json:"alias,omitempty"`
}
// Repo accesses the award_references table.
type Repo struct{ db *sql.DB }
// NewRepo builds a reference repo on the given connection.
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
// ReplaceAll atomically replaces every reference for one award.
func (r *Repo) ReplaceAll(ctx context.Context, awardCode string, refs []Ref) (int, error) {
code := strings.ToUpper(strings.TrimSpace(awardCode))
if code == "" {
return 0, fmt.Errorf("empty award code")
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback() //nolint:errcheck
if _, err := tx.ExecContext(ctx, `DELETE FROM award_references WHERE award_code = ?`, code); err != nil {
return 0, fmt.Errorf("clear refs: %w", err)
}
stmt, err := tx.PrepareContext(ctx,
`INSERT OR REPLACE INTO award_references
(award_code, ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
if err != nil {
return 0, err
}
defer stmt.Close()
n := 0
for _, ref := range refs {
rc := strings.ToUpper(strings.TrimSpace(ref.Code))
if rc == "" {
continue
}
// A bulk-replaced list is the authoritative enabled set: store every
// row as valid. Per-reference disabling is done through Upsert.
if _, err := stmt.ExecContext(ctx, code, rc, strings.TrimSpace(ref.Name), ref.DXCC, ref.Group, ref.SubGrp,
encodeDXCCList(ref.DXCCList), ref.Pattern, 1, ref.ValidFrom, ref.ValidTo,
ref.Score, ref.Bonus, ref.GridSquare, ref.Alias); err != nil {
return 0, fmt.Errorf("insert ref %s: %w", rc, err)
}
n++
}
if err := tx.Commit(); err != nil {
return 0, err
}
return n, nil
}
// Count returns how many references an award has stored.
func (r *Repo) Count(ctx context.Context, awardCode string) (int, error) {
var n int
err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM award_references WHERE award_code = ?`,
strings.ToUpper(strings.TrimSpace(awardCode))).Scan(&n)
return n, err
}
// Counts returns reference counts for every award.
func (r *Repo) Counts(ctx context.Context) (map[string]int, error) {
rows, err := r.db.QueryContext(ctx, `SELECT award_code, COUNT(*) FROM award_references GROUP BY award_code`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]int{}
for rows.Next() {
var code string
var n int
if err := rows.Scan(&code, &n); err != nil {
return nil, err
}
out[code] = n
}
return out, rows.Err()
}
// NamesFor returns ref_code → name for the given codes of one award (batched so
// we never load a 250k-row map just to label a few worked references).
func (r *Repo) NamesFor(ctx context.Context, awardCode string, codes []string) (map[string]string, error) {
out := map[string]string{}
if len(codes) == 0 {
return out, nil
}
code := strings.ToUpper(strings.TrimSpace(awardCode))
// Chunk to stay under SQLite's parameter limit.
const chunk = 400
for start := 0; start < len(codes); start += chunk {
end := start + chunk
if end > len(codes) {
end = len(codes)
}
batch := codes[start:end]
ph := strings.TrimSuffix(strings.Repeat("?,", len(batch)), ",")
args := make([]any, 0, len(batch)+1)
args = append(args, code)
for _, c := range batch {
args = append(args, strings.ToUpper(strings.TrimSpace(c)))
}
rows, err := r.db.QueryContext(ctx,
`SELECT ref_code, name FROM award_references WHERE award_code = ? AND ref_code IN (`+ph+`)`, args...)
if err != nil {
return nil, err
}
for rows.Next() {
var rc, name string
if err := rows.Scan(&rc, &name); err != nil {
rows.Close()
return nil, err
}
out[rc] = name
}
rows.Close()
}
return out, nil
}
// Search returns up to `limit` references of an award matching a code/name
// query, optionally restricted to a DXCC entity. Drives the per-QSO picker.
func (r *Repo) Search(ctx context.Context, awardCode, query string, dxcc, limit int) ([]Ref, error) {
code := strings.ToUpper(strings.TrimSpace(awardCode))
q := strings.TrimSpace(query)
if limit <= 0 || limit > 200 {
limit = 50
}
sqlStr := `SELECT ` + allCols + ` FROM award_references WHERE award_code = ?`
args := []any{code}
if dxcc > 0 {
// Match the primary dxcc OR the multi-DXCC list (JSON contains the id).
sqlStr += ` AND (dxcc = ? OR dxcc_list LIKE ?)`
args = append(args, dxcc, fmt.Sprintf("%%%d%%", dxcc))
}
if q != "" {
sqlStr += ` AND (ref_code LIKE ? OR name LIKE ?)`
args = append(args, "%"+strings.ToUpper(q)+"%", "%"+q+"%")
}
sqlStr += ` ORDER BY ref_code LIMIT ?`
args = append(args, limit)
rows, err := r.db.QueryContext(ctx, sqlStr, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Ref
for rows.Next() {
ref, err := scanRef(rows)
if err != nil {
return nil, err
}
out = append(out, ref)
}
return out, rows.Err()
}
// List returns every reference of an award, ordered by code. Used by the
// reference editor and (via the engine) to show unworked references.
func (r *Repo) List(ctx context.Context, awardCode string) ([]Ref, error) {
code := strings.ToUpper(strings.TrimSpace(awardCode))
rows, err := r.db.QueryContext(ctx,
`SELECT `+allCols+` FROM award_references WHERE award_code = ? ORDER BY ref_code`, code)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Ref
for rows.Next() {
ref, err := scanRef(rows)
if err != nil {
return nil, err
}
out = append(out, ref)
}
return out, rows.Err()
}
// Upsert inserts or updates a single reference of an award.
func (r *Repo) Upsert(ctx context.Context, awardCode string, ref Ref) error {
code := strings.ToUpper(strings.TrimSpace(awardCode))
rc := strings.ToUpper(strings.TrimSpace(ref.Code))
if code == "" || rc == "" {
return fmt.Errorf("empty award or reference code")
}
_, err := r.db.ExecContext(ctx,
`INSERT OR REPLACE INTO award_references
(award_code, ref_code, name, dxcc, grp, subgrp, dxcc_list, pattern, valid, valid_from, valid_to, score, bonus, gridsquare, alias)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
code, rc, strings.TrimSpace(ref.Name), ref.DXCC, ref.Group, ref.SubGrp,
encodeDXCCList(ref.DXCCList), ref.Pattern, b2i(ref.Valid), ref.ValidFrom, ref.ValidTo,
ref.Score, ref.Bonus, ref.GridSquare, ref.Alias)
return err
}
// Delete removes one reference from an award.
func (r *Repo) Delete(ctx context.Context, awardCode, refCode string) error {
_, err := r.db.ExecContext(ctx,
`DELETE FROM award_references WHERE award_code = ? AND ref_code = ?`,
strings.ToUpper(strings.TrimSpace(awardCode)), strings.ToUpper(strings.TrimSpace(refCode)))
return err
}
func b2i(b bool) int {
if b {
return 1
}
return 0
}
+99
View File
@@ -0,0 +1,99 @@
package awardref
import (
"strconv"
"hamlog/internal/dxcc"
)
// BuiltinRefs returns the seed reference list for a built-in award (DXCC
// entities, CQ zones, continents, US states, French departments). ok=false for
// awards whose list is downloaded online (POTA/SOTA/WWFF) or fully custom.
//
// The reference CODE must equal what award.Compute extracts from the QSO field
// so worked references map onto the list:
// - DXCC → entity number ("291")
// - WAZ → CQ zone number ("1".."40")
// - WAC → continent code ("EU", "NA", …)
// - WAS → ADIF STATE code ("AL", …)
// - DDFM → "D06" (the award pattern captures the leading D)
func BuiltinRefs(code string) ([]Ref, bool) {
switch code {
case "DXCC":
return dxccEntities(), true
case "WAZ":
return cqZones(), true
case "WAC":
return continents(), true
case "WAS":
return usStates().Refs, true
case "DDFM":
return frenchDepartments(), true
}
return nil, false
}
func dxccEntities() []Ref {
ents := dxcc.AllEntities()
out := make([]Ref, 0, len(ents))
for _, e := range ents {
out = append(out, ref(strconv.Itoa(e.Num), e.Name, e.Num))
}
return out
}
func cqZones() []Ref {
out := make([]Ref, 0, 40)
for z := 1; z <= 40; z++ {
out = append(out, ref(strconv.Itoa(z), "CQ Zone "+strconv.Itoa(z), 0))
}
return out
}
func continents() []Ref {
pairs := [][2]string{
{"AF", "Africa"}, {"AN", "Antarctica"}, {"AS", "Asia"},
{"EU", "Europe"}, {"NA", "North America"}, {"OC", "Oceania"}, {"SA", "South America"},
}
out := make([]Ref, 0, len(pairs))
for _, p := range pairs {
out = append(out, ref(p[0], p[1], 0))
}
return out
}
// frenchDepartments — the 96 metropolitan French departments (DXCC 227).
func frenchDepartments() []Ref {
const fr = 227
deps := [][2]string{
{"D01", "Ain"}, {"D02", "Aisne"}, {"D03", "Allier"}, {"D04", "Alpes-de-Haute-Provence"},
{"D05", "Hautes-Alpes"}, {"D06", "Alpes-Maritimes"}, {"D07", "Ardèche"}, {"D08", "Ardennes"},
{"D09", "Ariège"}, {"D10", "Aube"}, {"D11", "Aude"}, {"D12", "Aveyron"},
{"D13", "Bouches-du-Rhône"}, {"D14", "Calvados"}, {"D15", "Cantal"}, {"D16", "Charente"},
{"D17", "Charente-Maritime"}, {"D18", "Cher"}, {"D19", "Corrèze"}, {"D2A", "Corse-du-Sud"},
{"D2B", "Haute-Corse"}, {"D21", "Côte-d'Or"}, {"D22", "Côtes-d'Armor"}, {"D23", "Creuse"},
{"D24", "Dordogne"}, {"D25", "Doubs"}, {"D26", "Drôme"}, {"D27", "Eure"},
{"D28", "Eure-et-Loir"}, {"D29", "Finistère"}, {"D30", "Gard"}, {"D31", "Haute-Garonne"},
{"D32", "Gers"}, {"D33", "Gironde"}, {"D34", "Hérault"}, {"D35", "Ille-et-Vilaine"},
{"D36", "Indre"}, {"D37", "Indre-et-Loire"}, {"D38", "Isère"}, {"D39", "Jura"},
{"D40", "Landes"}, {"D41", "Loir-et-Cher"}, {"D42", "Loire"}, {"D43", "Haute-Loire"},
{"D44", "Loire-Atlantique"}, {"D45", "Loiret"}, {"D46", "Lot"}, {"D47", "Lot-et-Garonne"},
{"D48", "Lozère"}, {"D49", "Maine-et-Loire"}, {"D50", "Manche"}, {"D51", "Marne"},
{"D52", "Haute-Marne"}, {"D53", "Mayenne"}, {"D54", "Meurthe-et-Moselle"}, {"D55", "Meuse"},
{"D56", "Morbihan"}, {"D57", "Moselle"}, {"D58", "Nièvre"}, {"D59", "Nord"},
{"D60", "Oise"}, {"D61", "Orne"}, {"D62", "Pas-de-Calais"}, {"D63", "Puy-de-Dôme"},
{"D64", "Pyrénées-Atlantiques"}, {"D65", "Hautes-Pyrénées"}, {"D66", "Pyrénées-Orientales"}, {"D67", "Bas-Rhin"},
{"D68", "Haut-Rhin"}, {"D69", "Rhône"}, {"D70", "Haute-Saône"}, {"D71", "Saône-et-Loire"},
{"D72", "Sarthe"}, {"D73", "Savoie"}, {"D74", "Haute-Savoie"}, {"D75", "Paris"},
{"D76", "Seine-Maritime"}, {"D77", "Seine-et-Marne"}, {"D78", "Yvelines"}, {"D79", "Deux-Sèvres"},
{"D80", "Somme"}, {"D81", "Tarn"}, {"D82", "Tarn-et-Garonne"}, {"D83", "Var"},
{"D84", "Vaucluse"}, {"D85", "Vendée"}, {"D86", "Vienne"}, {"D87", "Haute-Vienne"},
{"D88", "Vosges"}, {"D89", "Yonne"}, {"D90", "Territoire de Belfort"}, {"D91", "Essonne"},
{"D92", "Hauts-de-Seine"}, {"D93", "Seine-Saint-Denis"}, {"D94", "Val-de-Marne"}, {"D95", "Val-d'Oise"},
}
out := make([]Ref, 0, len(deps))
for _, d := range deps {
out = append(out, ref(d[0], d[1], fr))
}
return out
}
+194
View File
@@ -0,0 +1,194 @@
package awardref
import (
"context"
"encoding/csv"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
// Importer downloads and parses a program's reference list into []Ref.
type Importer struct {
AwardCode string
URL string
Fetch func(ctx context.Context, body io.Reader) ([]Ref, error)
}
// Importers is the registry of built-in reference-list updaters, keyed by
// award code. Awards not present here have no online list (manual only).
var Importers = map[string]Importer{
"POTA": {AwardCode: "POTA", URL: "https://pota.app/all_parks.csv", Fetch: parsePOTA},
"SOTA": {AwardCode: "SOTA", URL: "https://www.sotadata.org.uk/summitslist.csv", Fetch: parseSOTA},
"WWFF": {AwardCode: "WWFF", URL: "https://wwff.co/wwff-data/wwff_directory.csv", Fetch: parseWWFF},
"IOTA": {AwardCode: "IOTA", URL: "https://www.iota-world.org/islands-on-the-air/downloads/download-file.html?path=groups.json", Fetch: parseIOTA},
}
// parseIOTA reads iota-world.org's groups.json (refreshed daily): an array of
// {refno, name, dxcc_num, grp_region}. The reference is the IOTA number
// (EU-005); the DXCC number lets the per-QSO picker filter by entity.
func parseIOTA(_ context.Context, body io.Reader) ([]Ref, error) {
var groups []struct {
RefNo string `json:"refno"`
Name string `json:"name"`
DXCC string `json:"dxcc_num"`
Region string `json:"grp_region"`
}
if err := json.NewDecoder(body).Decode(&groups); err != nil {
return nil, fmt.Errorf("parse IOTA json: %w", err)
}
out := make([]Ref, 0, len(groups))
for _, g := range groups {
ref := strings.ToUpper(strings.TrimSpace(g.RefNo))
if ref == "" {
continue
}
dxcc, _ := strconv.Atoi(strings.TrimSpace(g.DXCC))
grp := strings.TrimSpace(g.Region)
if grp == "" { // fall back to the continent prefix (AF/EU/NA/…)
if i := strings.IndexByte(ref, '-'); i > 0 {
grp = ref[:i]
}
}
out = append(out, Ref{Code: ref, Name: strings.TrimSpace(g.Name), DXCC: dxcc, Group: grp})
}
return out, nil
}
// CanUpdate reports whether an award has an online reference list.
func CanUpdate(awardCode string) bool {
_, ok := Importers[strings.ToUpper(strings.TrimSpace(awardCode))]
return ok
}
// Download fetches and parses the reference list for an award (does not store).
func Download(ctx context.Context, awardCode string) ([]Ref, error) {
imp, ok := Importers[strings.ToUpper(strings.TrimSpace(awardCode))]
if !ok {
return nil, fmt.Errorf("no online list for award %q", awardCode)
}
req, err := http.NewRequestWithContext(ctx, "GET", imp.URL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "OpsLog")
client := &http.Client{Timeout: 5 * time.Minute}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("download %s: %w", imp.URL, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("download %s: http %d", imp.URL, resp.StatusCode)
}
return imp.Fetch(ctx, resp.Body)
}
// headerIndex maps lowercased header names to their column index.
func headerIndex(header []string) map[string]int {
m := make(map[string]int, len(header))
for i, h := range header {
m[strings.ToLower(strings.TrimSpace(h))] = i
}
return m
}
func get(rec []string, idx int) string {
if idx < 0 || idx >= len(rec) {
return ""
}
return strings.TrimSpace(rec[idx])
}
// parsePOTA: "reference","name","active","entityId","locationDesc"
func parsePOTA(_ context.Context, body io.Reader) ([]Ref, error) {
r := csv.NewReader(body)
r.FieldsPerRecord = -1
header, err := r.Read()
if err != nil {
return nil, err
}
h := headerIndex(header)
iRef, iName, iActive, iEnt, iLoc := h["reference"], h["name"], h["active"], h["entityid"], h["locationdesc"]
var out []Ref
for {
rec, err := r.Read()
if err == io.EOF {
break
}
if err != nil {
continue
}
if iActive >= 0 && get(rec, iActive) == "0" {
continue
}
dxcc, _ := strconv.Atoi(get(rec, iEnt))
out = append(out, Ref{Code: get(rec, iRef), Name: get(rec, iName), DXCC: dxcc, Group: get(rec, iLoc)})
}
return out, nil
}
// parseSOTA: first line is a title, then header
// SummitCode,AssociationName,RegionName,SummitName,…
func parseSOTA(_ context.Context, body io.Reader) ([]Ref, error) {
r := csv.NewReader(body)
r.FieldsPerRecord = -1
// First record is the "SOTA Summits List (Date=…)" title line — skip it.
if _, err := r.Read(); err != nil {
return nil, err
}
header, err := r.Read()
if err != nil {
return nil, err
}
h := headerIndex(header)
iRef, iName, iAssoc, iRegion := h["summitcode"], h["summitname"], h["associationname"], h["regionname"]
var out []Ref
for {
rec, err := r.Read()
if err == io.EOF {
break
}
if err != nil {
continue
}
out = append(out, Ref{Code: get(rec, iRef), Name: get(rec, iName), Group: get(rec, iAssoc), SubGrp: get(rec, iRegion)})
}
return out, nil
}
// parseWWFF: reference,status,name,…,dxccEnum,… (header-driven)
func parseWWFF(_ context.Context, body io.Reader) ([]Ref, error) {
r := csv.NewReader(body)
r.FieldsPerRecord = -1
header, err := r.Read()
if err != nil {
return nil, err
}
h := headerIndex(header)
iRef, iName, iStatus, iCountry := h["reference"], h["name"], h["status"], h["country"]
iDXCC := h["dxccenum"]
if iDXCC < 0 {
iDXCC = h["dxcc"]
}
var out []Ref
for {
rec, err := r.Read()
if err == io.EOF {
break
}
if err != nil {
continue
}
if iStatus >= 0 && !strings.EqualFold(get(rec, iStatus), "active") {
continue
}
dxcc, _ := strconv.Atoi(get(rec, iDXCC))
out = append(out, Ref{Code: get(rec, iRef), Name: get(rec, iName), DXCC: dxcc, Group: get(rec, iCountry)})
}
return out, nil
}
+77
View File
@@ -0,0 +1,77 @@
package awardref
// Preset is a ready-made reference list a user can apply to an award in one
// click (Canadian provinces, US states, …). Codes match the values that land
// in the corresponding QSO field (e.g. ADIF STATE codes).
type Preset struct {
Key string `json:"key"` // stable id, e.g. "ca_provinces"
Name string `json:"name"` // friendly label
Field string `json:"field"` // suggested QSO field to scan
DXCC int `json:"dxcc"` // suggested DXCC scope (0 = none)
Refs []Ref `json:"refs"`
}
// Presets is the catalogue of built-in reference lists, returned to the UI.
func Presets() []Preset {
return []Preset{
caProvinces(),
usStates(),
}
}
// PresetByKey returns a preset by its key (ok=false if unknown).
func PresetByKey(key string) (Preset, bool) {
for _, p := range Presets() {
if p.Key == key {
return p, true
}
}
return Preset{}, false
}
func ref(code, name string, dxcc int) Ref {
return Ref{Code: code, Name: name, DXCC: dxcc, Valid: true}
}
// caProvinces — RAC Canadian Provinces (DXCC 1 = Canada). Codes are ADIF STATE
// values for VE provinces/territories.
func caProvinces() Preset {
const ca = 1
return Preset{
Key: "ca_provinces", Name: "Canadian Provinces (RAC)", Field: "state", DXCC: ca,
Refs: []Ref{
ref("AB", "Alberta", ca), ref("BC", "British Columbia", ca),
ref("MB", "Manitoba", ca), ref("NB", "New Brunswick", ca),
ref("NL", "Newfoundland and Labrador", ca), ref("NS", "Nova Scotia", ca),
ref("NT", "Northwest Territories", ca), ref("NU", "Nunavut", ca),
ref("ON", "Ontario", ca), ref("PE", "Prince Edward Island", ca),
ref("QC", "Quebec", ca), ref("SK", "Saskatchewan", ca),
ref("YT", "Yukon", ca),
},
}
}
// usStates — Worked All States (DXCC 291 = United States). 50 ADIF STATE codes.
func usStates() Preset {
const us = 291
codes := [][2]string{
{"AL", "Alabama"}, {"AK", "Alaska"}, {"AZ", "Arizona"}, {"AR", "Arkansas"},
{"CA", "California"}, {"CO", "Colorado"}, {"CT", "Connecticut"}, {"DE", "Delaware"},
{"FL", "Florida"}, {"GA", "Georgia"}, {"HI", "Hawaii"}, {"ID", "Idaho"},
{"IL", "Illinois"}, {"IN", "Indiana"}, {"IA", "Iowa"}, {"KS", "Kansas"},
{"KY", "Kentucky"}, {"LA", "Louisiana"}, {"ME", "Maine"}, {"MD", "Maryland"},
{"MA", "Massachusetts"}, {"MI", "Michigan"}, {"MN", "Minnesota"}, {"MS", "Mississippi"},
{"MO", "Missouri"}, {"MT", "Montana"}, {"NE", "Nebraska"}, {"NV", "Nevada"},
{"NH", "New Hampshire"}, {"NJ", "New Jersey"}, {"NM", "New Mexico"}, {"NY", "New York"},
{"NC", "North Carolina"}, {"ND", "North Dakota"}, {"OH", "Ohio"}, {"OK", "Oklahoma"},
{"OR", "Oregon"}, {"PA", "Pennsylvania"}, {"RI", "Rhode Island"}, {"SC", "South Carolina"},
{"SD", "South Dakota"}, {"TN", "Tennessee"}, {"TX", "Texas"}, {"UT", "Utah"},
{"VT", "Vermont"}, {"VA", "Virginia"}, {"WA", "Washington"}, {"WV", "West Virginia"},
{"WI", "Wisconsin"}, {"WY", "Wyoming"},
}
refs := make([]Ref, 0, len(codes))
for _, c := range codes {
refs = append(refs, ref(c[0], c[1], us))
}
return Preset{Key: "us_states", Name: "US States (WAS)", Field: "state", DXCC: us, Refs: refs}
}
+8
View File
@@ -27,6 +27,9 @@ type Backend interface {
// Implementations decide USB vs LSB (typically by current freq) and // Implementations decide USB vs LSB (typically by current freq) and
// generic vs specific digital modes (most rigs just have DATA). // generic vs specific digital modes (most rigs just have DATA).
SetMode(mode string) error SetMode(mode string) error
// SetPTT keys (on=true) or unkeys the transmitter. Used by the Digital
// Voice Keyer to put the rig into TX while a message plays.
SetPTT(on bool) error
} }
// RigState is the snapshot exchanged with the frontend. // RigState is the snapshot exchanged with the frontend.
@@ -161,6 +164,11 @@ func (m *Manager) SetMode(mode string) error {
return m.exec(func(b Backend) error { return b.SetMode(mode) }) return m.exec(func(b Backend) error { return b.SetMode(mode) })
} }
// SetPTT dispatches a transmit on/off request to the CAT goroutine.
func (m *Manager) SetPTT(on bool) error {
return m.exec(func(b Backend) error { return b.SetPTT(on) })
}
// exec marshals a backend operation onto the CAT goroutine. Returns the // exec marshals a backend operation onto the CAT goroutine. Returns the
// operation's error or a "busy"/"not running" error if dispatch failed. // operation's error or a "busy"/"not running" error if dispatch failed.
func (m *Manager) exec(fn func(Backend) error) error { func (m *Manager) exec(fn func(Backend) error) error {
+30 -12
View File
@@ -6,20 +6,38 @@ import (
"path/filepath" "path/filepath"
) )
// debugLog writes CAT debug events to %APPDATA%/HamLog/cat.log so users can // LogSink, when set by the host app at startup, receives every CAT debug
// diagnose mode/freq mismatches without rebuilding with -windowsconsole. // line so they land in the unified app log (opslog.log) alongside the rest
// // of OpsLog's diagnostics. Until it's wired we fall back to a dedicated
// Initialised lazily on first use. Falls back to the standard library // cat.log file so early-startup lines aren't lost.
// default logger (stderr, usually invisible in a Wails GUI build) if the var LogSink func(format string, args ...any)
// log file can't be opened.
var debugLog = openDebugLog()
func openDebugLog() *log.Logger { // catLogger forwards Printf either to the host LogSink (preferred) or to a
// local file/stderr fallback. Keeps the call sites (debugLog.Printf(...))
// unchanged.
type catLogger struct{ fallback *log.Logger }
func (c *catLogger) Printf(format string, args ...any) {
if LogSink != nil {
LogSink("cat: "+format, args...)
return
}
if c.fallback != nil {
c.fallback.Printf(format, args...)
}
}
// debugLog writes CAT debug events so users can diagnose mode/freq mismatches
// without rebuilding with a console. Once LogSink is set, lines flow into the
// main opslog.log.
var debugLog = &catLogger{fallback: openFallbackLog()}
func openFallbackLog() *log.Logger {
base, err := os.UserConfigDir() base, err := os.UserConfigDir()
if err != nil { if err != nil {
return log.Default() return log.Default()
} }
dir := filepath.Join(base, "HamLog") dir := filepath.Join(base, "OpsLog")
if err := os.MkdirAll(dir, 0o755); err != nil { if err := os.MkdirAll(dir, 0o755); err != nil {
return log.Default() return log.Default()
} }
@@ -31,12 +49,12 @@ func openDebugLog() *log.Logger {
return log.New(f, "", log.LstdFlags|log.Lmicroseconds) return log.New(f, "", log.LstdFlags|log.Lmicroseconds)
} }
// DebugLogPath returns the path the cat.log file would be opened at, for // DebugLogPath returns where the fallback cat.log lives, for surfacing in the
// surfacing in the UI / docs. // UI / docs. When LogSink is wired, CAT lines are in the main app log instead.
func DebugLogPath() string { func DebugLogPath() string {
base, err := os.UserConfigDir() base, err := os.UserConfigDir()
if err != nil { if err != nil {
return "" return ""
} }
return filepath.Join(base, "HamLog", "cat.log") return filepath.Join(base, "OpsLog", "cat.log")
} }
+109 -20
View File
@@ -160,42 +160,92 @@ func (o *OmniRig) ReadState() (RigState, error) {
func (o *OmniRig) SetFrequency(hz int64) error { func (o *OmniRig) SetFrequency(hz int64) error {
if o.rig == nil { if o.rig == nil {
debugLog.Printf("OmniRig.SetFrequency(%d): NOT CONNECTED", hz)
return fmt.Errorf("not connected") return fmt.Errorf("not connected")
} }
// OmniRig Freq is a Long (int32). Validate to avoid silent truncation. // OmniRig Freq is a Long (int32). Validate to avoid silent truncation.
if hz < 0 || hz > 0x7fffffff { if hz < 0 || hz > 0x7fffffff {
debugLog.Printf("OmniRig.SetFrequency(%d): out of int32 range", hz)
return fmt.Errorf("frequency out of OmniRig int32 range") return fmt.Errorf("frequency out of OmniRig int32 range")
} }
hz32 := int32(hz) hz32 := int32(hz)
// Pick the right OmniRig property. Many rig .ini files only define a // Log the rig's writable-params, status and VFO state up front so a
// WRITE command for FreqA/FreqB but not the generic Freq — in which case // friend's session shows exactly what OmniRig reports for their rig.
// PutProperty(Freq) silently succeeds but the rig never moves. Write to status, statusStr, rigType := int64(-1), "", ""
// the active VFO's specific property when we know it; fall back to Freq. if v, err := oleutil.GetProperty(o.rig, "Status"); err == nil {
prop := "FreqA" status = v.Val
}
if v, err := oleutil.GetProperty(o.rig, "StatusStr"); err == nil {
statusStr = v.ToString()
}
if v, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
rigType = v.ToString()
}
rawVfo, vfo := int64(-1), ""
if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil { if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
switch omniRigVfo(vfoVar.Val) { rawVfo = vfoVar.Val
vfo = omniRigVfo(vfoVar.Val)
} else {
debugLog.Printf("OmniRig.SetFrequency: Vfo read error: %v", err)
}
split := int64(0)
if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil {
split = v.Val
}
// What can this rig's .ini actually write? OmniRig exposes a WriteableParams
// bitmask — if FreqA/FreqB/Freq bits are missing, the write is a silent no-op.
writeable := int64(-1)
if v, err := oleutil.GetProperty(o.rig, "WriteableParams"); err == nil {
writeable = v.Val
}
debugLog.Printf("OmniRig.SetFrequency(%d Hz / %.6f MHz): rig=%q status=%d(%s) vfo=%q(raw=%d) split=%d writeableParams=0x%X",
hz, float64(hz)/1e6, rigType, status, statusStr, vfo, rawVfo, split, writeable)
// Primary path: OmniRig's SetSimplexMode is the rig-agnostic "QSY here"
// method (RX=TX=freq, simplex). It works on rigs — notably Icom (IC-9100) —
// where direct FreqA/FreqB writes are accepted but never move the radio.
// Clearing split is the right thing when tuning to a spot anyway.
if _, err := oleutil.CallMethod(o.rig, "SetSimplexMode", int32(hz32)); err == nil {
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode(%d) OK", hz32)
} else {
debugLog.Printf("OmniRig.SetFrequency: SetSimplexMode unavailable (%v) — using property writes", err)
// Fallback: write the active VFO's property AND the generic Freq
// (always — some .ini honour only one, and split here is often misread).
prop := "FreqA"
switch vfo {
case "B", "BB", "BA": case "B", "BB", "BA":
prop = "FreqB" prop = "FreqB"
case "A", "AA", "AB":
prop = "FreqA"
} }
} okAny := false
debugLog.Printf("OmniRig.SetFrequency(%d Hz / %.6f MHz) → %s", hz, float64(hz)/1e6, prop) for _, p := range []string{prop, "Freq"} {
if _, err := oleutil.PutProperty(o.rig, prop, hz32); err != nil { if _, e := oleutil.PutProperty(o.rig, p, hz32); e != nil {
debugLog.Printf("OmniRig.SetFrequency(%s) error: %v — falling back to Freq", prop, err) debugLog.Printf("OmniRig.SetFrequency: PutProperty(%s) error: %v", p, e)
if _, err2 := oleutil.PutProperty(o.rig, "Freq", hz32); err2 != nil { } else {
debugLog.Printf("OmniRig.SetFrequency(Freq) also failed: %v", err2) debugLog.Printf("OmniRig.SetFrequency: PutProperty(%s, %d) OK", p, hz32)
return err2 okAny = true
}
}
if !okAny {
return fmt.Errorf("OmniRig: no writable frequency property for this rig")
} }
} }
// Read back the active VFO freq after a short delay so the log shows // Read back all three immediately. OmniRig is async (the CAT command is
// whether the rig actually moved. Useful when the .ini accepts the write // queued + sent over serial), so these may still show the OLD value for
// silently but the rig doesn't honour it (wrong WRITE command etc.). // one poll cycle — but if they NEVER change in the next poll, the rig
if fv, err := oleutil.GetProperty(o.rig, "Freq"); err == nil { // isn't honouring the write (wrong .ini WRITE command for this model).
debugLog.Printf("OmniRig.Freq immediately after Put = %d Hz", fv.Val) fa, fb, fg := int64(-1), int64(-1), int64(-1)
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
fa = v.Val
} }
if v, err := oleutil.GetProperty(o.rig, "FreqB"); err == nil {
fb = v.Val
}
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
fg = v.Val
}
debugLog.Printf("OmniRig.SetFrequency: readback FreqA=%d FreqB=%d Freq=%d (target %d)", fa, fb, fg, hz)
return nil return nil
} }
@@ -263,6 +313,43 @@ func (o *OmniRig) SetMode(mode string) error {
return nil return nil
} }
// SetPTT keys or unkeys the rig via OmniRig's SetTx(PM_RX|PM_TX). Used by the
// Digital Voice Keyer to put the rig into TX while a voice message plays.
func (o *OmniRig) SetPTT(on bool) error {
if o.rig == nil {
debugLog.Printf("OmniRig.SetPTT(%v): NOT CONNECTED", on)
return fmt.Errorf("not connected")
}
status, statusStr, writeable := int64(-1), "", int64(-1)
if v, err := oleutil.GetProperty(o.rig, "Status"); err == nil {
status = v.Val
}
if v, err := oleutil.GetProperty(o.rig, "StatusStr"); err == nil {
statusStr = v.ToString()
}
if v, err := oleutil.GetProperty(o.rig, "WriteableParams"); err == nil {
writeable = v.Val
}
txWriteable := writeable != -1 && writeable&pmTX != 0
param, name := pmRX, "PM_RX"
if on {
param, name = pmTX, "PM_TX"
}
debugLog.Printf("OmniRig.SetPTT(%v): status=%d(%s) writeableParams=0x%X PM_TX-writeable=%v → Tx=%s",
on, status, statusStr, writeable, txWriteable, name)
if on && !txWriteable {
debugLog.Printf("OmniRig.SetPTT: ⚠ this rig's OmniRig .ini does NOT expose TX keying (PM_TX not writeable). " +
"Use VOX or serial RTS/DTR PTT instead.")
}
// OmniRig has NO SetTx method (that returns "unknown name"); the Tx
// parameter is set via the writeable Tx PROPERTY (PM_TX / PM_RX).
if _, err := oleutil.PutProperty(o.rig, "Tx", int32(param)); err != nil {
debugLog.Printf("OmniRig.SetPTT error: %v", err)
return fmt.Errorf("set Tx=%s: %w", name, err)
}
return nil
}
// ===== OmniRig enum decoders ===== // ===== OmniRig enum decoders =====
// Bit flags from OmniRig type library (RigParamX enum in OmniRig_TLB.pas). // Bit flags from OmniRig type library (RigParamX enum in OmniRig_TLB.pas).
@@ -272,6 +359,8 @@ func (o *OmniRig) SetMode(mode string) error {
// too low which causes every mode to map to the slot below it (AM → DIG_L, // too low which causes every mode to map to the slot below it (AM → DIG_L,
// FT8 → SSB_L, etc.). // FT8 → SSB_L, etc.).
const ( const (
pmRX int64 = 1 << 20 // 0x00100000 — PM_RX (receive)
pmTX int64 = 1 << 21 // 0x00200000 — PM_TX (transmit / PTT on)
pmCWU int64 = 1 << 23 // 0x00800000 pmCWU int64 = 1 << 23 // 0x00800000
pmCWL int64 = 1 << 24 // 0x01000000 pmCWL int64 = 1 << 24 // 0x01000000
pmSSBU int64 = 1 << 25 // 0x02000000 pmSSBU int64 = 1 << 25 // 0x02000000
+239
View File
@@ -0,0 +1,239 @@
// Package clublog uses the ClubLog Country File (cty.xml) to resolve callsign
// EXCEPTIONS — date-ranged full-callsign overrides for DXpeditions and special
// operations that prefix-based files (cty.dat) get wrong. e.g. VK2/SP9FIH was
// Lord Howe Island (not Australia) between specific 2025 dates.
package clublog
import (
"compress/gzip"
"encoding/xml"
"fmt"
"io"
"strings"
"time"
)
// Exception is one date-ranged full-callsign override.
type Exception struct {
Call string
Entity string
ADIF int
CQZ int
Cont string
Lat float64
Lon float64
Start time.Time // zero = no lower bound
End time.Time // zero = no upper bound
}
func (e Exception) covers(t time.Time) bool {
if !e.Start.IsZero() && t.Before(e.Start) {
return false
}
if !e.End.IsZero() && t.After(e.End) {
return false
}
return true
}
// DB holds the parsed exception + prefix tables. Exceptions are keyed by the
// full callsign; prefixes are keyed by the prefix string (both may hold several
// date-ranged entries). cty.xml carries entity + CQ zone + continent per
// record, but NOT ITU zone.
type DB struct {
exceptions map[string][]Exception
prefixes map[string][]Exception
date string // cty.xml generation date (for the UI)
count int
prefixCount int
}
// Count returns how many exceptions were loaded.
func (db *DB) Count() int { return db.count }
// PrefixCount returns how many prefix records were loaded.
func (db *DB) PrefixCount() int { return db.prefixCount }
// Date returns the cty.xml generation timestamp.
func (db *DB) Date() string { return db.date }
// xml decode shapes.
type xlException struct {
Call string `xml:"call"`
Entity string `xml:"entity"`
ADIF int `xml:"adif"`
CQZ int `xml:"cqz"`
Cont string `xml:"cont"`
Long string `xml:"long"`
Lat string `xml:"lat"`
Start string `xml:"start"`
End string `xml:"end"`
}
// LoadGzip parses a gzipped ClubLog cty.xml stream.
func LoadGzip(r io.Reader) (*DB, error) {
zr, err := gzip.NewReader(r)
if err != nil {
return nil, fmt.Errorf("gunzip: %w", err)
}
defer zr.Close()
return Load(zr)
}
// Load parses a (plain) ClubLog cty.xml stream, extracting only the
// <exceptions> section via a streaming decoder (the file is ~10 MB).
func Load(r io.Reader) (*DB, error) {
db := &DB{exceptions: map[string][]Exception{}, prefixes: map[string][]Exception{}}
dec := xml.NewDecoder(r)
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
return nil, fmt.Errorf("xml: %w", err)
}
se, ok := tok.(xml.StartElement)
if !ok {
continue
}
switch se.Name.Local {
case "clublog":
for _, a := range se.Attr {
if a.Name.Local == "date" {
db.date = a.Value
}
}
case "exception":
var x xlException
if err := dec.DecodeElement(&x, &se); err != nil {
continue
}
call := strings.ToUpper(strings.TrimSpace(x.Call))
if call == "" || x.ADIF == 0 {
continue
}
e := Exception{
Call: call, Entity: x.Entity, ADIF: x.ADIF, CQZ: x.CQZ,
Cont: strings.ToUpper(strings.TrimSpace(x.Cont)),
Lat: parseFloat(x.Lat), Lon: parseFloat(x.Long),
Start: parseTime(x.Start), End: parseTime(x.End),
}
db.exceptions[call] = append(db.exceptions[call], e)
db.count++
case "prefix":
var x xlException // same shape; <call> holds the prefix string
if err := dec.DecodeElement(&x, &se); err != nil {
continue
}
pfx := strings.ToUpper(strings.TrimSpace(x.Call))
if pfx == "" || x.ADIF == 0 {
continue
}
e := Exception{
Call: pfx, Entity: x.Entity, ADIF: x.ADIF, CQZ: x.CQZ,
Cont: strings.ToUpper(strings.TrimSpace(x.Cont)),
Lat: parseFloat(x.Lat), Lon: parseFloat(x.Long),
Start: parseTime(x.Start), End: parseTime(x.End),
}
db.prefixes[pfx] = append(db.prefixes[pfx], e)
db.prefixCount++
}
}
return db, nil
}
// ResolvePrefix returns the longest prefix entry matching a callsign and valid
// at the given date (cascade step 2). The callsign should already be normalized
// (operating affixes stripped); we still strip a trailing "/x" defensively.
func (db *DB) ResolvePrefix(call string, date time.Time) (Exception, bool) {
if db == nil {
return Exception{}, false
}
c := strings.ToUpper(strings.TrimSpace(call))
if i := strings.LastIndex(c, "/"); i > 0 {
// Prefer the operating prefix when present (MM/DL1ABC → MM).
if pre, post := c[:i], c[i+1:]; len(pre) <= len(post) {
c = pre
}
}
for n := len(c); n >= 1; n-- {
for _, e := range db.prefixes[c[:n]] {
if e.covers(date) {
return e, true
}
}
}
return Exception{}, false
}
// ResolveFull runs the ClubLog cascade: a full-callsign Exception first, then
// the longest valid Prefix. Returns the matched record and its source
// ("exception" | "prefix"), or ok=false when ClubLog has nothing (caller falls
// back to cty.dat).
func (db *DB) ResolveFull(call string, date time.Time) (e Exception, source string, ok bool) {
if db == nil {
return Exception{}, "", false
}
if e, ok := db.Resolve(call, date); ok {
return e, "exception", true
}
if e, ok := db.ResolvePrefix(call, date); ok {
return e, "prefix", true
}
return Exception{}, "", false
}
// Resolve returns the exception for a callsign valid at the given date, if any.
// It tries the call as-is, then with a trailing "/x" affix stripped (so
// VK2/SP9FIH/P still matches the VK2/SP9FIH exception).
func (db *DB) Resolve(call string, date time.Time) (Exception, bool) {
if db == nil {
return Exception{}, false
}
c := strings.ToUpper(strings.TrimSpace(call))
for _, key := range candidates(c) {
for _, e := range db.exceptions[key] {
if e.covers(date) {
return e, true
}
}
}
return Exception{}, false
}
// candidates yields the call and a version with one trailing affix removed.
func candidates(c string) []string {
out := []string{c}
if i := strings.LastIndex(c, "/"); i > 0 {
suffix := c[i+1:]
// Only strip short operational affixes, not a real prefix override
// (e.g. keep "VK2/SP9FIH" intact; strip "VK2/SP9FIH/P").
switch suffix {
case "P", "M", "MM", "AM", "QRP", "A":
out = append(out, c[:i])
}
}
return out
}
func parseTime(s string) time.Time {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}
}
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t
}
return time.Time{}
}
func parseFloat(s string) float64 {
s = strings.TrimSpace(s)
if s == "" {
return 0
}
var f float64
fmt.Sscanf(s, "%g", &f)
return f
}
+127
View File
@@ -0,0 +1,127 @@
package clublog
import (
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sync"
"time"
)
// ctyURL is the ClubLog Country File endpoint. It returns a gzipped cty.xml.
const ctyURL = "https://cdn.clublog.org/cty.php?api="
// Manager owns the on-disk cty.xml.gz cache and the parsed exception DB.
type Manager struct {
apiKey string
cacheDir string
mu sync.RWMutex
db *DB
}
func NewManager(apiKey, cacheDir string) *Manager {
return &Manager{apiKey: apiKey, cacheDir: cacheDir}
}
// Path is where the cached gzipped country file lives.
func (m *Manager) Path() string {
return filepath.Join(m.cacheDir, "clublog_cty.xml.gz")
}
// Loaded reports whether an exception DB is in memory.
func (m *Manager) Loaded() bool {
m.mu.RLock()
defer m.mu.RUnlock()
return m.db != nil
}
// Info returns the loaded file's generation date + exception count (zeros when
// not loaded).
func (m *Manager) Info() (date string, count int) {
m.mu.RLock()
defer m.mu.RUnlock()
if m.db == nil {
return "", 0
}
return m.db.Date(), m.db.Count()
}
// EnsureLoaded loads the cached file into memory if present. Does NOT download.
func (m *Manager) EnsureLoaded() error {
if m.Loaded() {
return nil
}
return m.LoadFromDisk()
}
// LoadFromDisk parses the cached cty.xml.gz and swaps it in.
func (m *Manager) LoadFromDisk() error {
f, err := os.Open(m.Path())
if err != nil {
return err
}
defer f.Close()
db, err := LoadGzip(f)
if err != nil {
return err
}
m.mu.Lock()
m.db = db
m.mu.Unlock()
return nil
}
// Download fetches a fresh cty.xml.gz from ClubLog and writes it atomically,
// then loads it.
func (m *Manager) Download(ctx context.Context) error {
if m.apiKey == "" {
return fmt.Errorf("clublog api key not set")
}
if err := os.MkdirAll(m.cacheDir, 0o755); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, "GET", ctyURL+m.apiKey, nil)
if err != nil {
return err
}
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("clublog HTTP %d", resp.StatusCode)
}
tmp, err := os.CreateTemp(m.cacheDir, "clublog-*.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
if _, err := io.Copy(tmp, resp.Body); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
tmp.Close()
if err := os.Rename(tmpName, m.Path()); err != nil {
os.Remove(tmpName)
return err
}
return m.LoadFromDisk()
}
// Resolve returns the matching exception for a callsign at a date, if loaded.
func (m *Manager) Resolve(call string, date time.Time) (Exception, bool) {
m.mu.RLock()
db := m.db
m.mu.RUnlock()
if db == nil {
return Exception{}, false
}
return db.Resolve(call, date)
}
+2
View File
@@ -59,6 +59,8 @@ type Spot struct {
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360 LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
ReceivedAt time.Time `json:"received_at"` ReceivedAt time.Time `json:"received_at"`
Raw string `json:"raw"` Raw string `json:"raw"`
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
POTAName string `json:"pota_name,omitempty"` // park name
} }
// State enumerates the per-server lifecycle. // State enumerates the per-server lifecycle.
@@ -0,0 +1,15 @@
-- Award reference lists (Parks On The Air, SOTA summits, WWFF, IOTA…).
-- Each row is one valid reference for an award, used to provide award totals,
-- reference names, and (later) per-QSO reference assignment + per-DXCC filtering.
-- Lists are downloaded/updated from each program's published file.
CREATE TABLE IF NOT EXISTS award_references (
award_code TEXT NOT NULL,
ref_code TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
dxcc INTEGER NOT NULL DEFAULT 0,
grp TEXT NOT NULL DEFAULT '',
subgrp TEXT NOT NULL DEFAULT '',
PRIMARY KEY (award_code, ref_code)
);
CREATE INDEX IF NOT EXISTS idx_award_ref_dxcc ON award_references(award_code, dxcc);
@@ -0,0 +1,13 @@
-- Richer per-reference metadata, mirroring Log4OM's reference editor:
-- a per-reference regexp, validity window, score/bonus, grid, alias, a
-- "valid" flag, and a multi-DXCC list (JSON array) on top of the single
-- primary dxcc kept for fast filtering.
ALTER TABLE award_references ADD COLUMN dxcc_list TEXT NOT NULL DEFAULT '';
ALTER TABLE award_references ADD COLUMN pattern TEXT NOT NULL DEFAULT '';
ALTER TABLE award_references ADD COLUMN valid INTEGER NOT NULL DEFAULT 1;
ALTER TABLE award_references ADD COLUMN valid_from TEXT NOT NULL DEFAULT '';
ALTER TABLE award_references ADD COLUMN valid_to TEXT NOT NULL DEFAULT '';
ALTER TABLE award_references ADD COLUMN score INTEGER NOT NULL DEFAULT 0;
ALTER TABLE award_references ADD COLUMN bonus INTEGER NOT NULL DEFAULT 0;
ALTER TABLE award_references ADD COLUMN gridsquare TEXT NOT NULL DEFAULT '';
ALTER TABLE award_references ADD COLUMN alias TEXT NOT NULL DEFAULT '';
@@ -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);
+166 -318
View File
@@ -1,333 +1,181 @@
package dxcc package dxcc
import "strings" import (
"sort"
"strings"
)
// dxccByName maps cty.dat entity names to ADIF DXCC entity numbers // The dxccByName table itself lives in dxcc_names_gen.go (generated by joining
// (ARRL DXCC List). cty.dat itself doesn't carry this number — it's a // cty.dat to the authoritative ARRL/ADIF entity list). cty.dat doesn't carry
// separate ARRL-maintained list. We embed the current entities here so // the ADIF DXCC number, so we map its entity names → numbers here to stamp
// QSO records can be stamped with MY_DXCC / DXCC at log time without a // MY_DXCC / DXCC at log time without a network round-trip.
// network round-trip.
//
// Coverage: every current ARRL DXCC entity (340+). Deleted entities are
// included for legacy compatibility. The lookup is case-insensitive and
// space-tolerant on the caller side.
var dxccByName = map[string]int{
// 0xx
"sovereign military order of malta": 246,
"spratly is.": 247,
"sable i.": 211,
"st. paul i.": 252,
"hawaii": 110,
"agalega & st. brandon is.": 4,
"alaska": 6,
"american samoa": 9,
"amsterdam & st. paul is.": 10,
"andaman & nicobar is.": 11,
"anguilla": 12,
"antarctica": 13,
"armenia": 14,
"asiatic russia": 15,
"aves i.": 17,
"azerbaijan": 18,
"baker & howland is.": 20,
"balearic is.": 21,
"palmyra & jarvis is.": 22,
"central kiribati": 31,
"central african republic": 27,
"cape verde": 32,
"chagos is.": 33,
"chatham is.": 34,
"christmas i.": 35,
"clipperton i.": 36,
"cocos i.": 37,
"cocos (keeling) is.": 38,
"comoros": 39,
"crete": 40,
"crozet i.": 41,
"falkland is.": 141,
"chesterfield is.": 512,
"easter i.": 47,
"sint eustatius & saba": 519,
"ducie i.": 513,
"european russia": 54,
"farquhar": 55,
"fernando de noronha": 56,
"french equatorial africa": 57,
"french indo-china": 58,
"french polynesia": 175,
"djibouti": 382,
"gabon": 420,
"galapagos is.": 71,
"guantanamo bay": 105,
"guatemala": 76,
"guernsey": 106,
"guinea": 107,
"guyana": 129,
"hong kong": 321,
"howland & baker is.": 20,
"isle of man": 114,
"itu hq": 117,
"iran": 330,
"iraq": 333,
"juan de nova & europa": 124,
"juan fernandez is.": 125,
"kaliningrad": 126,
"kerguelen is.": 131,
"kermadec is.": 133,
"kingman reef": 134,
"kuwait": 348,
"kyrgyzstan": 135,
"jersey": 122,
"laccadive is.": 142,
"laos": 143,
"lord howe i.": 147,
"market reef": 151,
"marquesas is.": 509,
"marshall is.": 168,
"mauritania": 444,
"mayotte": 169,
"mexico": 50,
"midway i.": 174,
"minami torishima": 177,
"monaco": 260,
"mongolia": 363,
"mount athos": 180,
"navassa i.": 182,
"new caledonia": 162,
"new zealand": 170,
"niue": 188,
"norfolk i.": 189,
"north cook is.": 191,
"north korea": 344,
"ogasawara": 192,
"oman": 370,
"palestine": 510,
"pratas i.": 505,
"qatar": 376,
"rotuma i.": 460,
"rwanda": 454,
"san andres & providencia": 216,
"south georgia i.": 235,
"south orkney is.": 238,
"south sandwich is.": 240,
"south shetland is.": 241,
"swains i.": 515,
"swaziland": 468,
"taiwan": 386,
"tajikistan": 262,
"thailand": 387,
"timor-leste": 511,
"tokelau is.": 270,
"tonga": 160,
"trindade & martim vaz is.": 273,
"tristan da cunha & gough is.": 274,
"tromelin i.": 276,
"tunisia": 474,
"turkmenistan": 280,
"turks & caicos is.": 89,
"tuvalu": 282,
"uk sov. base areas on cyprus": 283,
"united nations hq": 289,
"vatican city": 295,
"venezuela": 148,
"viet nam": 293,
"wake i.": 297,
"wallis & futuna is.": 298,
"western kiribati": 301,
"yemen": 492,
// Major populous entities // EntityDXCC returns the ADIF DXCC entity number for the given cty.dat entity
"france": 227, // name, or 0 when unknown (callers should then leave the field empty rather
"germany": 230, // than guess). Case-insensitive and whitespace-tolerant.
"belgium": 209,
"netherlands": 263,
"luxembourg": 254,
"switzerland": 287,
"liechtenstein": 251,
"austria": 206,
"italy": 248,
// Sicily (IT9) and African Italy (IG9/IH9) are cty.dat WAE splits, not
// DXCC entities — they count as Italy (248). Sardinia (IS0) IS its own
// DXCC entity (225) and keeps its number.
"sicily": 248,
"african italy": 248,
"sardinia": 225,
"spain": 281,
"portugal": 272,
"andorra": 203,
"san marino": 278,
"corsica": 214,
"vatican": 295,
"england": 223,
"scotland": 279,
"wales": 294,
"northern ireland": 265,
"ireland": 245,
"shetland is.": 279,
"poland": 269,
"czech republic": 503,
"slovak republic": 504,
"hungary": 239,
"romania": 275,
"bulgaria": 212,
"greece": 236,
"dodecanese": 45,
"turkey": 390,
"european turkey": 390,
"asiatic turkey": 390,
"cyprus": 215,
"malta": 257,
"denmark": 221,
"faroe is.": 222,
"greenland": 237,
"sweden": 284,
"norway": 266,
"finland": 224,
"aland is.": 5,
"iceland": 242,
"estonia": 52,
"latvia": 145,
"lithuania": 146,
"belarus": 27,
"ukraine": 288,
"moldova": 179,
"georgia": 75,
"serbia": 296,
"montenegro": 514,
"slovenia": 499,
"croatia": 497,
"bosnia-herzegovina": 501,
"macedonia": 502,
"kosovo": 522,
"albania": 7,
"israel": 336,
"jordan": 342,
"lebanon": 354,
"syria": 384,
"saudi arabia": 378,
"united arab emirates": 391,
"bahrain": 304,
"egypt": 478,
"libya": 436,
"algeria": 400,
"morocco": 446,
"western sahara": 302,
"south africa": 462,
"namibia": 464,
"botswana": 402,
"zimbabwe": 452,
"zambia": 482,
"mozambique": 181,
"madagascar": 438,
"mauritius": 165,
"reunion i.": 453,
"seychelles": 379,
"kenya": 430,
"tanzania": 470,
"uganda": 286,
"ethiopia": 53,
"eritrea": 51,
"sudan": 466,
"south sudan republic of": 521,
"nigeria": 450,
"ghana": 424,
"cameroon": 406,
"senegal": 456,
"liberia": 434,
"sierra leone": 458,
"benin": 416,
"togo": 483,
"ivory coast": 428,
"mali": 442,
"niger": 187,
"chad": 410,
"japan": 339,
"south korea": 137,
"china": 318,
"india": 324,
"pakistan": 372,
"sri lanka": 315,
"nepal": 369,
"bangladesh": 305,
"bhutan": 306,
"myanmar": 309,
"west malaysia": 299,
"east malaysia": 46,
"singapore": 381,
"indonesia": 327,
"philippines": 375,
"brunei darussalam": 345,
"cambodia": 312,
"kazakhstan": 130,
"uzbekistan": 292,
"afghanistan": 3,
"maldives": 159,
"australia": 150,
"tasmania": 150,
"papua new guinea": 163,
"solomon is.": 185,
"vanuatu": 158,
"fiji": 176,
"samoa": 190,
"canada": 1,
"united states": 291,
"united states of america": 291,
"puerto rico": 202,
"us virgin is.": 285,
"british virgin is.": 91,
"cayman is.": 69,
"jamaica": 82,
"bahamas": 60,
"bermuda": 64,
"haiti": 78,
"dominican republic": 72,
"cuba": 70,
"barbados": 62,
"trinidad & tobago": 90,
"grenada": 77,
"st. lucia": 97,
"st. vincent": 98,
"dominica": 95,
"montserrat": 96,
"st. kitts & nevis": 249,
"antigua & barbuda": 94,
"guadeloupe": 79,
"martinique": 84,
"french guiana": 63,
"suriname": 140,
"colombia": 116,
"ecuador": 120,
"peru": 136,
"bolivia": 104,
"chile": 112,
"argentina": 100,
"uruguay": 144,
"paraguay": 132,
"brazil": 108,
"belize": 66,
"honduras": 80,
"el salvador": 74,
"nicaragua": 86,
"costa rica": 308,
"panama": 88,
}
// EntityDXCC returns the ADIF DXCC entity number for the given cty.dat
// entity name. Returns 0 when the name isn't in our table — callers
// should leave the field empty in that case rather than guess. The match
// is case-insensitive and tolerant of leading/trailing whitespace.
func EntityDXCC(name string) int { func EntityDXCC(name string) int {
if name == "" { if name == "" {
return 0 return 0
} }
return dxccByName[strings.ToLower(strings.TrimSpace(name))] // Fast path: exact (lower-cased) match against the cty.dat names.
if n := dxccByName[strings.ToLower(strings.TrimSpace(name))]; n != 0 {
return n
}
// Fallback: canonicalise so abbreviation/spelling differences still match
// (e.g. an ADIF import that wrote "Lord Howe I." instead of cty.dat's
// "Lord Howe Island").
if n := dxccByCanon[canonEntity(name)]; n != 0 {
return n
}
// Last resort: cty.dat pseudo-entities (Sicily, African Italy) report a
// parent DXCC entity for the number.
if c := CanonicalEntityName(name); !strings.EqualFold(c, name) {
return dxccByName[strings.ToLower(strings.TrimSpace(c))]
}
return 0
}
// nameByDXCC reverses dxccByName (number → a representative entity name),
// built once. When several names share a number, the longest (usually the most
// complete) wins. Names are Title-cased for display.
var nameByDXCC = func() map[int]string {
m := make(map[int]string, len(dxccByName))
for name, num := range dxccByName {
if cur, ok := m[num]; !ok || len(name) > len(cur) {
m[num] = name
}
}
return m
}()
// NameForDXCC returns a display name for an ADIF DXCC entity number, or "" if
// unknown.
func NameForDXCC(n int) string {
name, ok := nameByDXCC[n]
if !ok {
return ""
}
return strings.Title(name) //nolint:staticcheck // ASCII entity names
}
// ZoneByCallDistrict returns the CQ and ITU zone for a callsign in a country
// that is split across zones by call district (USA, Australia…). cty.dat and
// ClubLog's cty.xml only carry one zone per entity, so loggers apply this
// district→zone convention to get e.g. W6 = CQ3/ITU6 instead of the entity
// default CQ5/ITU8. ok=false means no district rule applies (use the entity
// default). The district is the first digit of the callsign.
func ZoneByCallDistrict(adif int, call string) (cqz, ituz int, ok bool) {
d := districtDigit(call)
if d < 0 {
return 0, 0, false
}
switch adif {
case 291: // United States — standard district defaults (state-level
// exceptions exist, but this matches what Log4OM/DXKeeper default to).
if z, o := usDistrictZones[d]; o {
return z[0], z[1], true
}
case 150: // Australia — VK6/VK8 (west/north) are CQ29/ITU58, rest CQ30/ITU59.
if d == 6 || d == 8 {
return 29, 58, true
}
return 30, 59, true
}
return 0, 0, false
}
// usDistrictZones maps a US call district digit to its {CQ, ITU} zone.
var usDistrictZones = map[int][2]int{
0: {4, 7}, 1: {5, 8}, 2: {5, 8}, 3: {5, 8}, 4: {5, 8},
5: {4, 7}, 6: {3, 6}, 7: {3, 6}, 8: {5, 8}, 9: {4, 8},
}
// firstDigit returns the first 0-9 digit in a callsign, or -1 if none.
func firstDigit(call string) int {
for i := 0; i < len(call); i++ {
if call[i] >= '0' && call[i] <= '9' {
return int(call[i] - '0')
}
}
return -1
}
// districtDigit returns the effective call-area digit: a trailing "/N" (single
// digit) re-homes the call to area N (W6ABC/7 → area 7), otherwise the first
// digit of the call.
func districtDigit(call string) int {
if i := strings.LastIndex(call, "/"); i >= 0 && i == len(call)-2 {
if c := call[len(call)-1]; c >= '0' && c <= '9' {
return int(c - '0')
}
}
return firstDigit(call)
}
// EntityNumberName pairs a DXCC entity number with its display name.
type EntityNumberName struct {
Num int
Name string
}
// AllEntities returns every known DXCC entity (number + display name), sorted by
// number. Used to seed the DXCC award's reference list.
func AllEntities() []EntityNumberName {
out := make([]EntityNumberName, 0, len(nameByDXCC))
for num := range nameByDXCC {
out = append(out, EntityNumberName{Num: num, Name: NameForDXCC(num)})
}
sort.Slice(out, func(i, j int) bool { return out[i].Num < out[j].Num })
return out
}
// dxccByCanon is dxccByName re-keyed by the canonical entity form, built once.
var dxccByCanon = func() map[string]int {
m := make(map[string]int, len(dxccByName))
for name, num := range dxccByName {
m[canonEntity(name)] = num
}
return m
}()
// canonEntity reduces an entity name to a canonical token stream, expanding the
// common abbreviations that differ between naming conventions and normalising
// punctuation / "&".
func canonEntity(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
s = strings.ReplaceAll(s, "&", " and ")
fields := strings.FieldsFunc(s, func(r rune) bool {
switch r {
case ' ', '.', ',', '-', '\'', '(', ')', '/':
return true
}
return false
})
for i, w := range fields {
switch w {
case "i":
fields[i] = "island"
case "is":
fields[i] = "islands"
case "st":
fields[i] = "saint"
case "mt":
fields[i] = "mount"
case "rep":
fields[i] = "republic"
case "dem":
fields[i] = "democratic"
case "fed":
fields[i] = "federal"
}
}
return strings.Join(fields, " ")
} }
// ctyEntityAliases maps cty.dat's non-DXCC pseudo-entities — the CQ-zone / // ctyEntityAliases maps cty.dat's non-DXCC pseudo-entities — the CQ-zone /
// WAE splits AD1C lists separately — onto the ARRL DXCC entity they belong // WAE splits AD1C lists separately — onto the ARRL DXCC entity they belong
// to. cty.dat reports e.g. "Sicily" or "Sardinia" so contesters get the // to. cty.dat reports e.g. "Sicily" so contesters get the right zones, but
// right zones, but for DXCC (and the COUNTRY field) they are Italy. Add an // for DXCC (and the COUNTRY field) they are Italy.
// entry here for any other split that should report its parent entity.
var ctyEntityAliases = map[string]string{ var ctyEntityAliases = map[string]string{
"sicily": "Italy", "sicily": "Italy",
"african italy": "Italy", "african italy": "Italy",
+54 -11
View File
@@ -139,7 +139,15 @@ func (db *DB) Lookup(callsign string) (Match, bool) {
if e, ok := db.exact[call]; ok { if e, ok := db.exact[call]; ok {
return materialize(e), true return materialize(e), true
} }
// KG4 special case: Guantanamo Bay (DXCC 105) is "KG4" followed by EXACTLY
// two characters (KG4XX). "KG4", "KG4X", "KG4XYZ"… are continental USA.
// cty.dat carries a bare "KG4" prefix for Guantanamo, so for the other
// suffix lengths we must skip it and fall through to the USA prefixes.
skipKG4 := strings.HasPrefix(call, "KG4") && len(call) != len("KG4")+2
for _, p := range db.byPrefix { for _, p := range db.byPrefix {
if skipKG4 && p.prefix == "KG4" {
continue
}
if strings.HasPrefix(call, p.prefix) { if strings.HasPrefix(call, p.prefix) {
return materialize(p), true return materialize(p), true
} }
@@ -262,22 +270,33 @@ func stripAnnotation(s string, open, close rune, cb func(string)) string {
} }
// suffixModifiers are non-DXCC-relevant callsign suffixes we strip before // suffixModifiers are non-DXCC-relevant callsign suffixes we strip before
// matching. /P /M /MM /AM /QRP /A and single-digit area changes (/5 …) all // matching. /P /M /QRP /A and single-digit area changes (/5 …) all keep the
// keep the operator's home DXCC. // operator's home DXCC. NOTE: "MM" and "AM" are NOT here — a TRAILING /MM or
// /AM (maritime/aeronautical mobile) means "no DXCC entity", while a LEADING
// "MM" is the Scotland operating prefix; both are handled in normalizeCallsign.
var suffixModifiers = map[string]bool{ var suffixModifiers = map[string]bool{
"P": true, "M": true, "MM": true, "AM": true, "QRP": true, "A": true, "P": true, "M": true, "QRP": true, "A": true,
"PM": true, "LH": true, "PM": true, "LH": true,
} }
// normalizeCallsign uppercases, trims, and resolves the "active" call when // normalizeCallsign uppercases, trims, and resolves the "active" call when
// the operator uses slashes (DL/F4NIE → DL; F4NIE/P → F4NIE). // the operator uses slashes (DL/F4NIE → DL; F4NIE/P → F4NIE). Returns "" for
// maritime/aeronautical mobile (.../MM, .../AM), which count for no DXCC.
func normalizeCallsign(s string) string { func normalizeCallsign(s string) string {
s = strings.ToUpper(strings.TrimSpace(s)) s = strings.ToUpper(strings.TrimSpace(s))
if !strings.ContainsRune(s, '/') { if !strings.ContainsRune(s, '/') {
return s return s
} }
parts := strings.Split(s, "/") parts := strings.Split(s, "/")
// A trailing /MM or /AM is maritime/aeronautical mobile → no DXCC entity.
// (A leading "MM" is the Scotland prefix and must NOT trigger this.)
for i, p := range parts {
if i > 0 && (p == "MM" || p == "AM") {
return ""
}
}
keep := parts[:0] keep := parts[:0]
var areaDigit byte // a single-digit "/N" re-homes the call to call area N
for _, p := range parts { for _, p := range parts {
if p == "" { if p == "" {
continue continue
@@ -285,21 +304,45 @@ func normalizeCallsign(s string) string {
if suffixModifiers[p] { if suffixModifiers[p] {
continue continue
} }
if len(p) == 1 && p >= "0" && p <= "9" { if len(p) == 1 && p[0] >= '0' && p[0] <= '9' {
areaDigit = p[0]
continue continue
} }
keep = append(keep, p) keep = append(keep, p)
} }
var main string
switch len(keep) { switch len(keep) {
case 0: case 0:
return s return s
case 1: case 1:
return keep[0] main = keep[0]
default:
// Two non-modifier parts → operating-from prefix wins (shorter one).
// DL/F4NIE: DL is shorter → use DL (Germany). F4NIE/W6: W6 → W6.
if len(keep[0]) <= len(keep[1]) {
main = keep[0]
} else {
main = keep[1]
}
} }
// Two non-modifier parts → operating-from prefix wins (shorter one). // Apply the call-area digit: "/N" replaces the area digit of the base call,
// DL/F4NIE: DL is shorter → use DL (Germany). F4NIE/W6: W6 shorter → W6. // which can change the DXCC entity (HD5MW/8 → HD8MW → Galápagos, not
if len(keep[0]) <= len(keep[1]) { // Ecuador). This is the same class of rule as KG4 and /MM.
return keep[0] if areaDigit != 0 {
main = replaceFirstDigit(main, areaDigit)
} }
return keep[1] return main
}
// replaceFirstDigit substitutes the first 0-9 digit of a call with d (used to
// apply a "/N" call-area change). Returns the call unchanged if it has no digit.
func replaceFirstDigit(call string, d byte) string {
b := []byte(call)
for i := range b {
if b[i] >= '0' && b[i] <= '9' {
b[i] = d
return string(b)
}
}
return call
} }
+351
View File
@@ -0,0 +1,351 @@
package dxcc
// dxccByName maps cty.dat entity names (lower-cased) to ADIF DXCC entity
// numbers. Generated by joining cty.dat to the authoritative ARRL/ADIF entity
// list (k0swe/dxcc-json) by primary prefix + canonical name. 344 entities.
var dxccByName = map[string]int{
"afghanistan": 3,
"agalega & st. brandon": 4,
"aland islands": 5,
"alaska": 6,
"albania": 7,
"algeria": 400,
"american samoa": 9,
"amsterdam & st. paul is.": 10,
"andaman & nicobar is.": 11,
"andorra": 203,
"angola": 401,
"anguilla": 12,
"annobon island": 195,
"antarctica": 13,
"antigua & barbuda": 94,
"argentina": 100,
"armenia": 14,
"aruba": 91,
"ascension island": 205,
"asiatic russia": 15,
"asiatic turkey": 390,
"austral islands": 508,
"australia": 150,
"austria": 206,
"aves island": 17,
"azerbaijan": 18,
"azores": 149,
"bahamas": 60,
"bahrain": 304,
"baker & howland islands": 20,
"balearic islands": 21,
"banaba island": 490,
"bangladesh": 305,
"barbados": 62,
"bear island": 259,
"belarus": 27,
"belgium": 209,
"belize": 66,
"benin": 416,
"bermuda": 64,
"bhutan": 306,
"bolivia": 104,
"bonaire": 520,
"bosnia-herzegovina": 501,
"botswana": 402,
"bouvet": 24,
"brazil": 108,
"british virgin islands": 65,
"brunei darussalam": 345,
"bulgaria": 212,
"burkina faso": 480,
"burundi": 404,
"cambodia": 312,
"cameroon": 406,
"canada": 1,
"canary islands": 29,
"cape verde": 409,
"cayman islands": 69,
"central african republic": 408,
"central kiribati": 31,
"ceuta & melilla": 32,
"chad": 410,
"chagos islands": 33,
"chatham islands": 34,
"chesterfield islands": 512,
"chile": 112,
"china": 318,
"christmas island": 35,
"clipperton island": 36,
"cocos (keeling) islands": 38,
"cocos island": 37,
"colombia": 116,
"comoros": 411,
"conway reef": 489,
"corsica": 214,
"costa rica": 308,
"cote d'ivoire": 428,
"crete": 40,
"croatia": 497,
"crozet island": 41,
"cuba": 70,
"curacao": 517,
"cyprus": 215,
"czech republic": 503,
"dem. rep. of the congo": 414,
"denmark": 221,
"desecheo island": 43,
"djibouti": 382,
"dodecanese": 45,
"dominica": 95,
"dominican republic": 72,
"dpr of korea": 344,
"ducie island": 513,
"east malaysia": 46,
"easter island": 47,
"eastern kiribati": 48,
"ecuador": 120,
"egypt": 478,
"el salvador": 74,
"england": 223,
"equatorial guinea": 49,
"eritrea": 51,
"estonia": 52,
"ethiopia": 53,
"european russia": 54,
"european turkey": 390,
"falkland islands": 141,
"faroe islands": 222,
"fed. rep. of germany": 230,
"fernando de noronha": 56,
"fiji": 176,
"finland": 224,
"france": 227,
"franz josef land": 61,
"french guiana": 63,
"french polynesia": 175,
"gabon": 420,
"galapagos islands": 71,
"georgia": 75,
"ghana": 424,
"gibraltar": 233,
"glorioso islands": 99,
"greece": 236,
"greenland": 237,
"grenada": 77,
"guadeloupe": 79,
"guam": 103,
"guantanamo bay": 105,
"guatemala": 76,
"guernsey": 106,
"guinea": 107,
"guinea-bissau": 109,
"guyana": 129,
"haiti": 78,
"hawaii": 110,
"heard island": 111,
"honduras": 80,
"hong kong": 321,
"hungary": 239,
"iceland": 242,
"india": 324,
"indonesia": 327,
"iran": 330,
"iraq": 333,
"ireland": 245,
"isle of man": 114,
"israel": 336,
"italy": 248,
"itu hq": 117,
"jamaica": 82,
"jan mayen": 118,
"japan": 339,
"jersey": 122,
"johnston island": 123,
"jordan": 342,
"juan de nova, europa": 124,
"juan fernandez islands": 125,
"kaliningrad": 126,
"kazakhstan": 130,
"kenya": 430,
"kerguelen islands": 131,
"kermadec islands": 133,
"kingdom of eswatini": 468,
"kure island": 138,
"kuwait": 348,
"kyrgyzstan": 135,
"lakshadweep islands": 142,
"laos": 143,
"latvia": 145,
"lebanon": 354,
"lesotho": 432,
"liberia": 434,
"libya": 436,
"liechtenstein": 251,
"lithuania": 146,
"lord howe island": 147,
"luxembourg": 254,
"macao": 152,
"macquarie island": 153,
"madagascar": 438,
"madeira islands": 256,
"malawi": 440,
"maldives": 159,
"mali": 442,
"malpelo island": 161,
"malta": 257,
"mariana islands": 166,
"market reef": 167,
"marquesas islands": 509,
"marshall islands": 168,
"martinique": 84,
"mauritania": 444,
"mauritius": 165,
"mayotte": 169,
"mellish reef": 171,
"mexico": 50,
"micronesia": 173,
"midway island": 174,
"minami torishima": 177,
"moldova": 179,
"monaco": 260,
"mongolia": 363,
"montenegro": 514,
"montserrat": 96,
"morocco": 446,
"mount athos": 180,
"mozambique": 181,
"myanmar": 309,
"n.z. subantarctic is.": 16,
"namibia": 464,
"nauru": 157,
"navassa island": 182,
"nepal": 369,
"netherlands": 263,
"new caledonia": 162,
"new zealand": 170,
"nicaragua": 86,
"niger": 187,
"nigeria": 450,
"niue": 188,
"norfolk island": 189,
"north cook islands": 191,
"north macedonia": 502,
"northern ireland": 265,
"norway": 266,
"ogasawara": 192,
"oman": 370,
"pakistan": 372,
"palau": 22,
"palestine": 510,
"palmyra & jarvis islands": 197,
"panama": 88,
"papua new guinea": 163,
"paraguay": 132,
"peru": 136,
"peter 1 island": 199,
"philippines": 375,
"pitcairn island": 172,
"poland": 269,
"portugal": 272,
"pr. edward & marion is.": 201,
"pratas island": 505,
"puerto rico": 202,
"qatar": 376,
"republic of korea": 137,
"republic of kosovo": 522,
"republic of south sudan": 521,
"republic of the congo": 412,
"reunion island": 453,
"revillagigedo": 204,
"rodriguez island": 207,
"romania": 275,
"rotuma island": 460,
"rwanda": 454,
"saba & st. eustatius": 519,
"sable island": 211,
"samoa": 190,
"san andres & providencia": 216,
"san felix & san ambrosio": 217,
"san marino": 278,
"sao tome & principe": 219,
"sardinia": 225,
"saudi arabia": 378,
"scarborough reef": 506,
"scotland": 279,
"senegal": 456,
"serbia": 296,
"seychelles": 379,
"shetland islands": 279,
"sierra leone": 458,
"singapore": 381,
"sint maarten": 518,
"slovak republic": 504,
"slovenia": 499,
"solomon islands": 185,
"somalia": 232,
"south africa": 462,
"south cook islands": 234,
"south georgia island": 235,
"south orkney islands": 238,
"south sandwich islands": 240,
"south shetland islands": 241,
"sov mil order of malta": 246,
"spain": 281,
"spratly islands": 247,
"sri lanka": 315,
"st. barthelemy": 516,
"st. helena": 250,
"st. kitts & nevis": 249,
"st. lucia": 97,
"st. martin": 213,
"st. paul island": 252,
"st. peter & st. paul": 253,
"st. pierre & miquelon": 277,
"st. vincent": 98,
"sudan": 466,
"suriname": 140,
"svalbard": 259,
"swains island": 515,
"sweden": 284,
"switzerland": 287,
"syria": 384,
"taiwan": 386,
"tajikistan": 262,
"tanzania": 470,
"temotu province": 507,
"thailand": 387,
"the gambia": 422,
"timor - leste": 511,
"togo": 483,
"tokelau islands": 270,
"tonga": 160,
"trindade & martim vaz": 273,
"trinidad & tobago": 90,
"tristan da cunha & gough": 274,
"tromelin island": 276,
"tunisia": 474,
"turkmenistan": 280,
"turks & caicos islands": 89,
"tuvalu": 282,
"uganda": 286,
"uk base areas on cyprus": 283,
"ukraine": 288,
"united arab emirates": 391,
"united nations hq": 289,
"united states": 291,
"uruguay": 144,
"us virgin islands": 285,
"uzbekistan": 292,
"vanuatu": 158,
"vatican city": 295,
"venezuela": 148,
"vienna intl ctr": 206,
"vietnam": 293,
"wake island": 297,
"wales": 294,
"wallis & futuna islands": 298,
"west malaysia": 299, // hand-fixed: generation joined it to 155 by mistake (9M2 = ADIF 299)
"western kiribati": 301,
"western sahara": 302,
"willis island": 303,
"yemen": 492,
"zambia": 482,
"zimbabwe": 452,
}
+97 -2
View File
@@ -54,6 +54,96 @@ func TestLookup(t *testing.T) {
} }
} }
// A "/N" call-area suffix can change the DXCC entity: HD5MW/8 re-homes to the
// HD8 area (Galápagos), not the base call's Ecuador.
func TestCallAreaSuffix(t *testing.T) {
const cty = `Ecuador: 10: 12: SA: -1.40: 78.40: 5.0: HC:
HC,HD;
Galapagos Islands: 10: 12: SA: 0.00: 91.00: 6.0: HC8:
HC8,HD8;
`
db, err := Load(strings.NewReader(cty))
if err != nil {
t.Fatalf("load: %v", err)
}
cases := map[string]string{
"HD5MW": "Ecuador",
"HD5MW/8": "Galapagos Islands",
"HC2AO": "Ecuador",
"HD8M": "Galapagos Islands",
"HC1WW/8": "Galapagos Islands",
}
for call, want := range cases {
m, ok := db.Lookup(call)
if !ok {
t.Errorf("%s: no match", call)
continue
}
if m.Entity.Name != want {
t.Errorf("%s: got %q, want %q", call, m.Entity.Name, want)
}
}
}
// US/VK call-district zone refinement (W6 = CQ3/ITU6, not the entity default).
func TestZoneByCallDistrict(t *testing.T) {
cases := []struct {
adif int
call string
cqz, ituz int
ok bool
}{
{291, "W6XYZ", 3, 6, true},
{291, "K7AB", 3, 6, true},
{291, "W4ABC", 5, 8, true},
{291, "N0CALL", 4, 7, true},
{291, "AA5XX", 4, 7, true},
{150, "VK6AA", 29, 58, true}, // West Australia
{150, "VK3XY", 30, 59, true}, // Victoria
{230, "DL1ABC", 0, 0, false}, // Germany: no district rule
{291, "WABC", 0, 0, false}, // no digit
}
for _, c := range cases {
cqz, ituz, ok := ZoneByCallDistrict(c.adif, c.call)
if ok != c.ok || (ok && (cqz != c.cqz || ituz != c.ituz)) {
t.Errorf("ZoneByCallDistrict(%d,%q) = %d/%d ok=%v, want %d/%d ok=%v",
c.adif, c.call, cqz, ituz, ok, c.cqz, c.ituz, c.ok)
}
}
}
// KG4 is Guantanamo Bay only with a 2-character suffix (KG4XX); 1- or 3-char
// suffixes (KG4W, KG4ABC) are continental USA. cty.dat carries a bare "KG4"
// prefix, so the resolver must apply the suffix-length rule.
func TestKG4SuffixRule(t *testing.T) {
const cty = `United States: 05: 08: NA: 37.53: 91.67: 5.0: K:
K,N,W;
Guantanamo Bay: 08: 11: NA: 19.92: 75.18: -5.0: KG4:
KG4;
`
db, err := Load(strings.NewReader(cty))
if err != nil {
t.Fatalf("load: %v", err)
}
cases := map[string]string{
"KG4W": "United States", // 1-char suffix
"KG4AA": "Guantanamo Bay", // 2-char suffix
"KG4ABC": "United States", // 3-char suffix
"KG4": "United States", // no suffix
"KG4W/P": "United States", // modifier stripped, still 1-char
}
for call, want := range cases {
m, ok := db.Lookup(call)
if !ok {
t.Errorf("%s: no match", call)
continue
}
if m.Entity.Name != want {
t.Errorf("%s: got %q, want %q", call, m.Entity.Name, want)
}
}
}
// cty.dat marks non-DXCC entities (Sicily *IT9, African Italy *IG9) with a // cty.dat marks non-DXCC entities (Sicily *IT9, African Italy *IG9) with a
// leading '*'; the parser must fold those into their parent DXCC entity // leading '*'; the parser must fold those into their parent DXCC entity
// "Italy" while leaving real DXCC entities — Sardinia (IS0), no '*' — alone. // "Italy" while leaving real DXCC entities — Sardinia (IS0), no '*' — alone.
@@ -108,9 +198,14 @@ func TestNormalize(t *testing.T) {
"f4bpo": "F4BPO", "f4bpo": "F4BPO",
" F4BPO ": "F4BPO", " F4BPO ": "F4BPO",
"F4BPO/P": "F4BPO", "F4BPO/P": "F4BPO",
"F4BPO/MM": "F4BPO", "F4BPO/MM": "", // maritime mobile → no DXCC entity
"F4BPO/5": "F4BPO", "F4BPO/AM": "", // aeronautical mobile → no DXCC entity
"F4BPO/M": "F4BPO", // plain mobile keeps the home entity
"F4BPO/5": "F5BPO", // "/5" re-homes to call area 5
"HD5MW/8": "HD8MW", // "/8" → Galápagos call area (HD8)
"DL/F4BPO": "DL", "DL/F4BPO": "DL",
"MM/KA9P": "MM", // leading MM = Scotland operating prefix
"MM/LY3X/P": "MM",
"F4BPO/W6": "W6", "F4BPO/W6": "W6",
"VK9/F4BPO": "VK9", "VK9/F4BPO": "VK9",
} }
+73
View File
@@ -0,0 +1,73 @@
// Package email sends QSO recordings to correspondents via SMTP. Pure Go (no
// CGO) using go-mail; supports implicit SSL (465), STARTTLS (587) or none.
package email
import (
"fmt"
"time"
"github.com/wneessen/go-mail"
)
// Config is the user's SMTP configuration.
type Config struct {
Host string
Port int
User string
Password string
From string
Encryption string // "ssl" | "starttls" | "none"
Auth bool // SMTP requires authorization (send username/password)
}
func (c Config) opts() []mail.Option {
o := []mail.Option{mail.WithPort(c.Port), mail.WithTimeout(30 * time.Second)}
if c.Auth && c.User != "" {
// AutoDiscover negotiates whatever mechanism the server advertises
// (LOGIN, PLAIN, CRAM-MD5, …). OVH, for instance, rejects forced PLAIN.
o = append(o, mail.WithSMTPAuth(mail.SMTPAuthAutoDiscover), mail.WithUsername(c.User), mail.WithPassword(c.Password))
}
switch c.Encryption {
case "ssl":
o = append(o, mail.WithSSL())
case "none":
o = append(o, mail.WithTLSPolicy(mail.NoTLS))
default: // starttls
o = append(o, mail.WithTLSPolicy(mail.TLSMandatory))
}
return o
}
// Send delivers a plain-text email to `to`, optionally attaching a file.
func Send(cfg Config, to, subject, body, attachPath string) error {
if cfg.Host == "" {
return fmt.Errorf("SMTP server not configured")
}
if to == "" {
return fmt.Errorf("no recipient e-mail")
}
from := cfg.From
if from == "" {
from = cfg.User
}
m := mail.NewMsg()
if err := m.From(from); err != nil {
return fmt.Errorf("bad sender %q: %w", from, err)
}
if err := m.To(to); err != nil {
return fmt.Errorf("bad recipient %q: %w", to, err)
}
m.Subject(subject)
m.SetBodyString(mail.TypeTextPlain, body)
if attachPath != "" {
m.AttachFile(attachPath)
}
client, err := mail.NewClient(cfg.Host, cfg.opts()...)
if err != nil {
return fmt.Errorf("smtp client: %w", err)
}
if err := client.DialAndSend(m); err != nil {
return fmt.Errorf("send: %w", err)
}
return nil
}
+4 -2
View File
@@ -53,7 +53,9 @@ const (
// //
// QRZ.com → APIKey, ForceStationCallsign // QRZ.com → APIKey, ForceStationCallsign
// Club Log → Email, Password, Callsign, APIKey // Club Log → Email, Password, Callsign, APIKey
// LoTW → TQSLPath, StationLocation, KeyPassword (signs+uploads via TQSL) // LoTW → TQSLPath, StationLocation, ForceStationCallsign, KeyPassword
// (signs+uploads via TQSL; ForceStationCallsign overrides
// STATION_CALLSIGN so one cert can sign F4BPO / F4BPO/P / TM2Q)
// //
// AutoUpload + UploadMode are common to all (timing is per-service, so the // AutoUpload + UploadMode are common to all (timing is per-service, so the
// user can run e.g. Club Log immediate and QRZ delayed). // user can run e.g. Club Log immediate and QRZ delayed).
@@ -63,7 +65,7 @@ type ServiceConfig struct {
Username string `json:"username"` // LoTW website login (for confirmation download) Username string `json:"username"` // LoTW website login (for confirmation download)
Password string `json:"password"` // Club Log account / LoTW website password Password string `json:"password"` // Club Log account / LoTW website password
Callsign string `json:"callsign"` // Club Log logbook (owner) callsign Callsign string `json:"callsign"` // Club Log logbook (owner) callsign
ForceStationCallsign string `json:"force_station_callsign"` // QRZ ForceStationCallsign string `json:"force_station_callsign"` // QRZ + LoTW: override STATION_CALLSIGN
TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe TQSLPath string `json:"tqsl_path"` // LoTW: path to tqsl.exe
StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name StationLocation string `json:"station_location"` // LoTW: TQSL Station Location name
KeyPassword string `json:"key_password"` // LoTW: certificate private-key password (optional) KeyPassword string `json:"key_password"` // LoTW: certificate private-key password (optional)
+61 -3
View File
@@ -2,6 +2,7 @@ package extsvc
import ( import (
"context" "context"
"fmt"
"math/rand" "math/rand"
"net/http" "net/http"
"strings" "strings"
@@ -9,6 +10,29 @@ import (
"time" "time"
) )
// baseCall extracts the operator's base callsign from a possibly-affixed call:
// for slashed forms (F4BPO/P, FW/F4BPO, 9A/F4BPO/P) it returns the longest
// token, which is the real call; otherwise the call itself. Upper-cased.
func baseCall(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
if !strings.Contains(s, "/") {
return s
}
best := ""
for _, part := range strings.Split(s, "/") {
if len(part) > len(best) {
best = part
}
}
return best
}
// sameBaseCall reports whether two callsigns belong to the same operator,
// ignoring portable prefixes/suffixes (F4BPO/P == F4BPO, FW/F4BPO == F4BPO).
func sameBaseCall(a, b string) bool {
return baseCall(a) == baseCall(b)
}
// Deps are the host-app callbacks the Manager needs. Keeping them as // Deps are the host-app callbacks the Manager needs. Keeping them as
// function fields decouples extsvc from the qso/adif/settings packages and // function fields decouples extsvc from the qso/adif/settings packages and
// keeps the upload-scheduling logic testable. // keeps the upload-scheduling logic testable.
@@ -33,6 +57,11 @@ type Deps struct {
// Upload flag ("N" or "R"), à la Log4OM. Returning false skips the QSO. // Upload flag ("N" or "R"), à la Log4OM. Returning false skips the QSO.
ShouldUpload func(svc Service, id int64) bool ShouldUpload func(svc Service, id int64) bool
// StationCallOf returns the QSO's STATION_CALLSIGN. Used to guard against
// uploading a QSO into a logbook for a different callsign (the force-call
// option would otherwise silently relabel it). "" → no station call known.
StationCallOf func(id int64) string
// Logf is an optional diagnostic logger. // Logf is an optional diagnostic logger.
Logf func(format string, args ...any) Logf func(format string, args ...any)
} }
@@ -196,7 +225,8 @@ func (m *Manager) flushLoTWBatch(ids []int64, cfg ServiceConfig) int {
if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(ServiceLoTW, id) { if m.deps.ShouldUpload != nil && !m.deps.ShouldUpload(ServiceLoTW, id) {
continue continue
} }
if rec, ok := m.deps.BuildADIF(id, ""); ok { // Override STATION_CALLSIGN so /P etc. signs against the base cert.
if rec, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign); ok {
records = append(records, rec) records = append(records, rec)
kept = append(kept, id) kept = append(kept, id)
} }
@@ -235,6 +265,33 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
return false return false
} }
// Station-callsign guard. Each logbook belongs to one callsign:
// QRZ/LoTW → the ForceStationCallsign (the call this logbook signs as)
// Club Log → the logbook Callsign param
// If the QSO's own STATION_CALLSIGN is a DIFFERENT operator, uploading
// would push it into the wrong logbook (and the force-call option would
// silently relabel it). Block it with a clear error. Portable variants of
// the SAME call (F4BPO/P, FW/F4BPO…) are allowed.
owner := ""
switch svc {
case ServiceQRZ, ServiceLoTW:
owner = cfg.ForceStationCallsign
case ServiceClublog:
owner = cfg.Callsign
}
if owner != "" && m.deps.StationCallOf != nil {
qcall := m.deps.StationCallOf(id)
if qcall != "" && !sameBaseCall(qcall, owner) {
err := fmt.Errorf("station callsign %s does not match %s logbook %s — not uploaded",
strings.ToUpper(qcall), svc, strings.ToUpper(owner))
m.logf("extsvc: %s upload of QSO %d BLOCKED: %v", svc, id, err)
if m.deps.NotifyError != nil {
m.deps.NotifyError(svc, id, err)
}
return false
}
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
@@ -259,8 +316,9 @@ func (m *Manager) upload(svc Service, id int64, cfg ServiceConfig) bool {
} }
res, err = UploadClublog(ctx, m.deps.Client, cfg, record) res, err = UploadClublog(ctx, m.deps.Client, cfg, record)
case ServiceLoTW: case ServiceLoTW:
// LoTW signs the QSO's own station call via TQSL — no override. // LoTW signs via TQSL; an optional force-call overrides STATION_CALLSIGN
record, ok := m.deps.BuildADIF(id, "") // so the same cert can sign F4BPO, F4BPO/P, TM2Q… per profile.
record, ok := m.deps.BuildADIF(id, cfg.ForceStationCallsign)
if !ok { if !ok {
m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id) m.logf("extsvc: %s upload of QSO %d skipped (no record)", svc, id)
return false return false
+15 -2
View File
@@ -175,7 +175,7 @@ func TestQRZ(ctx context.Context, client *http.Client, apiKey string) (string, e
if err != nil { if err != nil {
return "", fmt.Errorf("qrz: bad response: %w", err) return "", fmt.Errorf("qrz: bad response: %w", err)
} }
status := strings.ToUpper(strings.TrimSpace(vals.Get("STATUS"))) status := qrzStatusField(vals)
if status == "AUTH" || status == "FAIL" { if status == "AUTH" || status == "FAIL" {
reason := strings.TrimSpace(vals.Get("REASON")) reason := strings.TrimSpace(vals.Get("REASON"))
if reason == "" { if reason == "" {
@@ -201,7 +201,11 @@ func parseQRZResponse(body string) (UploadResult, error) {
if err != nil { if err != nil {
return UploadResult{}, fmt.Errorf("qrz: bad response %q: %w", body, err) return UploadResult{}, fmt.Errorf("qrz: bad response %q: %w", body, err)
} }
status := strings.ToUpper(strings.TrimSpace(vals.Get("STATUS"))) // The QRZ Logbook API returns the outcome in RESULT (=OK/FAIL/AUTH).
// Accept STATUS as a fallback for robustness, but RESULT is the real
// field — reading only STATUS made every INSERT (incl. successful ones)
// look like it failed with an empty status.
status := qrzStatusField(vals)
reason := strings.TrimSpace(vals.Get("REASON")) reason := strings.TrimSpace(vals.Get("REASON"))
logID := strings.TrimSpace(vals.Get("LOGID")) logID := strings.TrimSpace(vals.Get("LOGID"))
@@ -222,6 +226,15 @@ func parseQRZResponse(body string) (UploadResult, error) {
} }
} }
// qrzStatusField returns the QRZ outcome code, preferring RESULT (the
// Logbook API's real field) and falling back to STATUS.
func qrzStatusField(vals url.Values) string {
if v := strings.ToUpper(strings.TrimSpace(vals.Get("RESULT"))); v != "" {
return v
}
return strings.ToUpper(strings.TrimSpace(vals.Get("STATUS")))
}
// isDuplicateReason recognises the various phrasings QRZ uses when a QSO is // isDuplicateReason recognises the various phrasings QRZ uses when a QSO is
// already present. // already present.
func isDuplicateReason(reason string) bool { func isDuplicateReason(reason string) bool {
+132
View File
@@ -0,0 +1,132 @@
package pota
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// hunterLogURL is the authenticated POTA user logbook endpoint (paginated).
// hunterOnly=1 restricts it to the user's chaser/hunter QSOs.
const hunterLogURL = "https://api.pota.app/user/logbook?hunterOnly=1&page=%d&size=%d"
// HunterQSO is one entry from the POTA hunter log: a contact the user made with
// a park activator, carrying the park reference to stamp onto the local QSO.
type HunterQSO struct {
Worked string `json:"worked"` // activator callsign (the station worked)
Date time.Time `json:"date"` // QSO date/time (UTC)
Band string `json:"band"` // ADIF band, e.g. "20m"
Mode string `json:"mode"` // logged mode
Reference string `json:"reference"` // park ref, e.g. "US-2072"
}
// hunterEntry mirrors the POTA API logbook record (fields we use).
//
// IMPORTANT: POTA logbook entries come from the ACTIVATOR's uploaded log, so
// "station_callsign" is the activator (the park station you worked) and
// "worked_callsign" is YOU (the hunter, whom the activator worked). To match a
// local QSO — whose callsign field holds the activator — we key on
// station_callsign, NOT worked_callsign.
type hunterEntry struct {
StationCallsign string `json:"station_callsign"` // the activator (park station)
WorkedCallsign string `json:"worked_callsign"` // the hunter (you)
QSODateTime string `json:"qsoDateTime"`
Band string `json:"band"`
LoggedMode string `json:"loggedMode"`
Reference string `json:"reference"`
}
// FetchHunterLog downloads the user's entire POTA hunter log, page by page,
// using their pota.app session token as the Authorization header. logf may be
// nil. Returns a friendly error on an expired/invalid token (HTTP 401/403).
func FetchHunterLog(ctx context.Context, token string, logf func(string, ...any)) ([]HunterQSO, error) {
token = strings.TrimSpace(token)
if token == "" {
return nil, fmt.Errorf("no POTA token — paste it from pota.app (DevTools → Network → Authorization header)")
}
client := &http.Client{Timeout: 30 * time.Second}
const size = 100
const maxPages = 5000 // safety bound
var out []HunterQSO
total := -1
for page := 1; page <= maxPages; page++ {
url := fmt.Sprintf(hunterLogURL, page, size)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", token)
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("POTA fetch: %w", err)
}
switch {
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden:
resp.Body.Close()
return nil, fmt.Errorf("POTA rejected the token (HTTP %d) — it has likely expired; re-copy it from pota.app", resp.StatusCode)
case resp.StatusCode != http.StatusOK:
resp.Body.Close()
return nil, fmt.Errorf("POTA HTTP %d", resp.StatusCode)
}
var body struct {
Count int `json:"count"`
Entries []hunterEntry `json:"entries"`
}
err = json.NewDecoder(resp.Body).Decode(&body)
resp.Body.Close()
if err != nil {
return nil, fmt.Errorf("POTA decode: %w", err)
}
total = body.Count
for _, e := range body.Entries {
ref := strings.ToUpper(strings.TrimSpace(e.Reference))
// The activator is the station we worked → station_callsign.
act := strings.ToUpper(strings.TrimSpace(e.StationCallsign))
if act == "" {
act = strings.ToUpper(strings.TrimSpace(e.WorkedCallsign)) // fallback
}
if ref == "" || act == "" {
continue
}
out = append(out, HunterQSO{
Worked: act,
Date: parseHunterTime(e.QSODateTime),
Band: strings.ToLower(strings.TrimSpace(e.Band)),
Mode: strings.ToUpper(strings.TrimSpace(e.LoggedMode)),
Reference: ref,
})
}
if logf != nil {
logf("pota: hunter log page %d (%d/%d)", page, len(out), total)
}
if len(body.Entries) == 0 || (total >= 0 && len(out) >= total) {
break
}
}
return out, nil
}
// parseHunterTime parses POTA's qsoDateTime, tolerating ISO variants (with/out
// 'T', timezone, or fractional seconds). POTA times are UTC.
func parseHunterTime(s string) time.Time {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}
}
for _, layout := range []string{
time.RFC3339Nano, time.RFC3339,
"2006-01-02T15:04:05.000Z", "2006-01-02T15:04:05Z", "2006-01-02T15:04:05",
"2006-01-02 15:04:05.000", "2006-01-02 15:04:05", "2006-01-02 15:04:05Z",
"2006-01-02T15:04Z", "2006-01-02T15:04", "2006-01-02 15:04",
} {
if t, err := time.Parse(layout, s); err == nil {
return t.UTC()
}
}
return time.Time{}
}
+147
View File
@@ -0,0 +1,147 @@
// Package pota polls the Parks On The Air activator-spots API and exposes a
// fast in-memory lookup so DX-cluster spots can be tagged "this station is
// currently activating a park". No API key required.
package pota
import (
"context"
"encoding/json"
"net/http"
"strings"
"sync"
"time"
)
const apiURL = "https://api.pota.app/spot/activator"
// Info is the park data we surface for a currently-active activator.
type Info struct {
Reference string `json:"reference"` // park id, e.g. "US-2072"
ParkName string `json:"park_name"` // human name
LocationDesc string `json:"location_desc"` // e.g. "US-NY"
}
// apiSpot is the subset of the POTA API record we read.
type apiSpot struct {
Activator string `json:"activator"`
Reference string `json:"reference"`
ParkName string `json:"parkName"`
Name string `json:"name"`
LocationDesc string `json:"locationDesc"`
}
// Cache holds the latest activator set, refreshed in the background.
type Cache struct {
mu sync.RWMutex
byCall map[string]Info // base callsign (upper) → info
client *http.Client
logf func(string, ...any)
}
// New creates a cache. logf may be nil.
func New(logf func(string, ...any)) *Cache {
return &Cache{
byCall: map[string]Info{},
client: &http.Client{Timeout: 20 * time.Second},
logf: logf,
}
}
// Run refreshes immediately, then every 60 s until ctx is cancelled.
func (c *Cache) Run(ctx context.Context) {
c.refresh(ctx)
t := time.NewTicker(60 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
c.refresh(ctx)
}
}
}
func (c *Cache) log(format string, a ...any) {
if c.logf != nil {
c.logf(format, a...)
}
}
func (c *Cache) refresh(ctx context.Context) {
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
c.log("pota: request: %v", err)
return
}
resp, err := c.client.Do(req)
if err != nil {
c.log("pota: fetch: %v", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.log("pota: http %d", resp.StatusCode)
return
}
var spots []apiSpot
if err := json.NewDecoder(resp.Body).Decode(&spots); err != nil {
c.log("pota: decode: %v", err)
return
}
m := make(map[string]Info, len(spots))
for _, s := range spots {
call := baseCall(s.Activator)
if call == "" {
continue
}
name := strings.TrimSpace(s.Name)
if name == "" {
name = strings.TrimSpace(s.ParkName)
}
// Keep the first reference seen for a call (most-recent-first ordering
// from the API), but don't clobber with a blank.
if _, exists := m[call]; exists {
continue
}
m[call] = Info{Reference: s.Reference, ParkName: name, LocationDesc: s.LocationDesc}
}
c.mu.Lock()
c.byCall = m
c.mu.Unlock()
c.log("pota: %d active activators", len(m))
}
// Lookup returns park info for a callsign if it's currently activating.
func (c *Cache) Lookup(call string) (Info, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if len(c.byCall) == 0 {
return Info{}, false
}
i, ok := c.byCall[baseCall(call)]
return i, ok
}
// BaseCall is the exported callsign normaliser used for hunter-log matching.
func BaseCall(s string) string { return baseCall(s) }
// baseCall normalises a callsign for matching: upper-cased, and when it carries
// "/" segments (F4BPO/P, HB9/F4BPO) we take the longest segment, which is
// almost always the home call.
func baseCall(s string) string {
s = strings.ToUpper(strings.TrimSpace(s))
if s == "" {
return ""
}
if !strings.Contains(s, "/") {
return s
}
best := ""
for _, part := range strings.Split(s, "/") {
if len(part) > len(best) {
best = part
}
}
return best
}
+398 -48
View File
@@ -6,10 +6,40 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"reflect"
"sort"
"strings" "strings"
"time" "time"
) )
// MergeNonZero copies every non-zero field of src onto dst. Zero-value src
// fields (empty string, nil pointer, zero time, zero number) are skipped so
// existing data is preserved. Maps (Extras) are merged key-by-key rather than
// replaced. Because an imported QSO has ID==0 and CreatedAt zero, dst's
// identity is naturally preserved. Used by the importer's "update duplicates"
// mode so re-importing an ADIF refreshes QSL/confirmation statuses without
// clobbering fields the source file doesn't carry.
func MergeNonZero(dst *QSO, src QSO) {
dv := reflect.ValueOf(dst).Elem()
sv := reflect.ValueOf(src)
for i := 0; i < dv.NumField(); i++ {
df, sf := dv.Field(i), sv.Field(i)
if !df.CanSet() || sf.IsZero() {
continue
}
if sf.Kind() == reflect.Map {
if df.IsNil() {
df.Set(reflect.MakeMap(sf.Type()))
}
for _, k := range sf.MapKeys() {
df.SetMapIndex(k, sf.MapIndex(k))
}
continue
}
df.Set(sf)
}
}
// QSO represents a contact. Fields are aligned on ADIF naming for // QSO represents a contact. Fields are aligned on ADIF naming for
// import/export. Pointers are used to distinguish "absent" from "zero". // import/export. Pointers are used to distinguish "absent" from "zero".
// Anything in ADIF that is not a promoted column lands in Extras. // Anything in ADIF that is not a promoted column lands in Extras.
@@ -126,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"`
@@ -175,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`
@@ -228,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,
} }
} }
@@ -553,6 +629,248 @@ func (r *Repo) List(ctx context.Context, f ListFilter) ([]QSO, error) {
return out, rows.Err() return out, rows.Err()
} }
// ── Advanced filter builder ──────────────────────────────────────────────
//
// QueryFilter powers the UI's filter builder: a list of field/operator/value
// conditions joined by AND or OR, plus an always-ANDed quick callsign search.
// Every field is validated against filterableColumns so user input can never
// reach the SQL string — only parameterised values do.
// Condition is one "field OP value" clause.
type Condition struct {
Field string `json:"field"` // db column name (validated against whitelist)
Op string `json:"op"` // eq|ne|gt|lt|ge|le|contains|startswith|endswith|empty|notempty
Value string `json:"value"`
}
// QueryFilter is a full filter expression.
type QueryFilter struct {
QuickCallsign string `json:"quick_callsign,omitempty"` // always-ANDed contains-match
Conditions []Condition `json:"conditions,omitempty"`
Match string `json:"match,omitempty"` // "AND" (default) | "OR"
Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"`
}
// filterableColumns whitelists the columns the filter builder may reference.
// Keep field names identical to DB columns so the frontend can send them
// directly; anything not in this set is rejected.
var filterableColumns = map[string]bool{
"callsign": true, "qso_date": true, "qso_date_off": true, "band": true, "band_rx": true,
"mode": true, "submode": true, "freq_hz": true, "freq_rx_hz": true,
"rst_sent": true, "rst_rcvd": true,
"name": true, "qth": true, "address": true, "email": true,
"grid": true, "country": true, "state": true, "cnty": true,
"dxcc": true, "cont": true, "cqz": true, "ituz": true,
"iota": true, "sota_ref": true, "pota_ref": true, "rig": true, "ant": true,
"qsl_sent": true, "qsl_rcvd": true, "qsl_via": true,
"lotw_sent": true, "lotw_rcvd": true, "eqsl_sent": true, "eqsl_rcvd": true,
"qrzcom_qso_upload_status": true, "clublog_qso_upload_status": true,
"contest_id": true, "srx": true, "stx": true,
"prop_mode": true, "sat_name": true,
"station_callsign": true, "operator": true, "my_grid": true, "my_country": true,
"tx_pwr": true, "comment": true, "notes": true,
}
// filterableExtras whitelists virtual filter fields stored inside extras_json
// (valid ADIF fields we don't promote to columns). The value is the uppercase
// ADIF/Extras key; the SQL expression uses json_extract.
var filterableExtras = map[string]string{
"owner_callsign": "OWNER_CALLSIGN",
}
// FilterableFields returns the whitelist (for the frontend to build its field
// dropdown and stay in sync with the backend).
func FilterableFields() []string {
out := make([]string, 0, len(filterableColumns)+len(filterableExtras))
for c := range filterableColumns {
out = append(out, c)
}
for c := range filterableExtras {
out = append(out, c)
}
sort.Strings(out)
return out
}
// columnExpr resolves a filter field to a safe SQL expression — either a
// whitelisted column name or a json_extract over extras_json.
func columnExpr(field string) (string, bool) {
f := strings.ToLower(strings.TrimSpace(field))
if filterableColumns[f] {
return f, true
}
if key, ok := filterableExtras[f]; ok {
return "json_extract(extras_json, '$." + key + "')", true
}
return "", false
}
// conditionSQL turns one condition into a parameterised predicate.
func conditionSQL(c Condition) (string, []any, error) {
col, ok := columnExpr(c.Field)
if !ok {
return "", nil, fmt.Errorf("unknown filter field %q", c.Field)
}
v := c.Value
switch c.Op {
case "eq":
return col + " = ?", []any{v}, nil
case "ne":
return col + " <> ?", []any{v}, nil
case "gt":
return col + " > ?", []any{v}, nil
case "lt":
return col + " < ?", []any{v}, nil
case "ge":
return col + " >= ?", []any{v}, nil
case "le":
return col + " <= ?", []any{v}, nil
case "contains":
return col + " LIKE ?", []any{"%" + v + "%"}, nil
case "startswith":
return col + " LIKE ?", []any{v + "%"}, nil
case "endswith":
return col + " LIKE ?", []any{"%" + v}, nil
case "empty":
return "IFNULL(" + col + ",'') = ''", nil, nil
case "notempty":
return "IFNULL(" + col + ",'') <> ''", nil, nil
default:
return "", nil, fmt.Errorf("unknown operator %q", c.Op)
}
}
// buildWhere assembles the predicate (everything after WHERE) + args.
func buildWhere(f QueryFilter) (string, []any, error) {
pred := "1=1"
var args []any
if qc := strings.TrimSpace(f.QuickCallsign); qc != "" {
pred += " AND callsign LIKE ?"
args = append(args, "%"+qc+"%")
}
if len(f.Conditions) > 0 {
joiner := " AND "
if strings.EqualFold(strings.TrimSpace(f.Match), "OR") {
joiner = " OR "
}
parts := make([]string, 0, len(f.Conditions))
for _, c := range f.Conditions {
if strings.TrimSpace(c.Field) == "" {
continue
}
p, a, err := conditionSQL(c)
if err != nil {
return "", nil, err
}
parts = append(parts, p)
args = append(args, a...)
}
if len(parts) > 0 {
pred += " AND (" + strings.Join(parts, joiner) + ")"
}
}
return pred, args, nil
}
// ListFiltered returns QSOs matching a QueryFilter, newest first, limited.
func (r *Repo) ListFiltered(ctx context.Context, f QueryFilter) ([]QSO, error) {
pred, args, err := buildWhere(f)
if err != nil {
return nil, err
}
q := `SELECT ` + selectCols + ` FROM qso WHERE ` + pred + ` ORDER BY qso_date DESC, id DESC`
limit := f.Limit
if limit <= 0 {
limit = 500
}
if limit > 1_000_000 {
limit = 1_000_000
}
q += " LIMIT ? OFFSET ?"
args = append(args, limit, f.Offset)
rows, err := r.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("query qso: %w", err)
}
defer rows.Close()
out := make([]QSO, 0, 64)
for rows.Next() {
qrow, err := scanQSO(rows)
if err != nil {
return nil, err
}
out = append(out, qrow)
}
return out, rows.Err()
}
// CountFiltered returns how many QSOs match a filter (ignoring limit/offset).
func (r *Repo) CountFiltered(ctx context.Context, f QueryFilter) (int64, error) {
pred, args, err := buildWhere(f)
if err != nil {
return 0, err
}
var n int64
err = r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM qso WHERE `+pred, args...).Scan(&n)
return n, err
}
// IterateFiltered streams all QSOs matching a filter (no limit), chronological,
// for an ADIF export of "the current filtered view, no row limit".
func (r *Repo) IterateFiltered(ctx context.Context, f QueryFilter, fn func(QSO) error) error {
pred, args, err := buildWhere(f)
if err != nil {
return err
}
rows, err := r.db.QueryContext(ctx,
`SELECT `+selectCols+` FROM qso WHERE `+pred+` ORDER BY qso_date ASC, id ASC`, args...)
if err != nil {
return fmt.Errorf("query qso: %w", err)
}
defer rows.Close()
for rows.Next() {
q, err := scanQSO(rows)
if err != nil {
return err
}
if err := fn(q); err != nil {
return err
}
}
return rows.Err()
}
// IterateByIDs streams the QSOs with the given ids, chronological — for
// "export the rows I selected with the mouse".
func (r *Repo) IterateByIDs(ctx context.Context, ids []int64, fn func(QSO) error) error {
if len(ids) == 0 {
return nil
}
ph := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",")
args := make([]any, len(ids))
for i, id := range ids {
args[i] = id
}
rows, err := r.db.QueryContext(ctx,
`SELECT `+selectCols+` FROM qso WHERE id IN (`+ph+`) ORDER BY qso_date ASC, id ASC`, args...)
if err != nil {
return fmt.Errorf("query qso: %w", err)
}
defer rows.Close()
for rows.Next() {
q, err := scanQSO(rows)
if err != nil {
return err
}
if err := fn(q); err != nil {
return err
}
}
return rows.Err()
}
// WorkedBefore summarises prior contacts at two granularities: // WorkedBefore summarises prior contacts at two granularities:
// - by exact callsign → shown as "this call worked N×" // - by exact callsign → shown as "this call worked N×"
// - by DXCC entity → drives NEW ONE / NEW BAND / NEW MODE / NEW SLOT // - by DXCC entity → drives NEW ONE / NEW BAND / NEW MODE / NEW SLOT
@@ -569,7 +887,7 @@ type WorkedBefore struct {
Bands []string `json:"bands"` // distinct bands for this call Bands []string `json:"bands"` // distinct bands for this call
Modes []string `json:"modes"` // distinct modes for this call Modes []string `json:"modes"` // distinct modes for this call
BandModes []BandMode `json:"band_modes"` // distinct (band, mode) pairs BandModes []BandMode `json:"band_modes"` // distinct (band, mode) pairs
Entries []WorkedEntry `json:"entries"` // up to maxWorkedEntries most recent Entries []QSO `json:"entries"` // up to maxWorkedEntries most recent (full records)
// --- Per-DXCC entity (populated when DXCC is known) --- // --- Per-DXCC entity (populated when DXCC is known) ---
DXCC int `json:"dxcc,omitempty"` DXCC int `json:"dxcc,omitempty"`
@@ -615,19 +933,6 @@ type BandMode struct {
// WorkedEntry is one prior contact row, lean enough to ship to the UI for // WorkedEntry is one prior contact row, lean enough to ship to the UI for
// rendering a recent-contacts mini-list. // rendering a recent-contacts mini-list.
type WorkedEntry struct {
ID int64 `json:"id"`
QSODate time.Time `json:"qso_date"`
Band string `json:"band"`
Mode string `json:"mode"`
RSTSent string `json:"rst_sent,omitempty"`
RSTRcvd string `json:"rst_rcvd,omitempty"`
QSLSent string `json:"qsl_sent,omitempty"`
QSLRcvd string `json:"qsl_rcvd,omitempty"`
LOTWSent string `json:"lotw_sent,omitempty"`
LOTWRcvd string `json:"lotw_rcvd,omitempty"`
}
const maxWorkedEntries = 50 const maxWorkedEntries = 50
// WorkedBefore returns aggregated history at both callsign and DXCC level. // WorkedBefore returns aggregated history at both callsign and DXCC level.
@@ -640,7 +945,7 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
Bands: []string{}, Bands: []string{},
Modes: []string{}, Modes: []string{},
BandModes: []BandMode{}, BandModes: []BandMode{},
Entries: []WorkedEntry{}, Entries: []QSO{},
DXCCBands: []string{}, DXCCBands: []string{},
DXCCModes: []string{}, DXCCModes: []string{},
DXCCBandModes: []BandMode{}, DXCCBandModes: []BandMode{},
@@ -655,9 +960,9 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
return wb, fmt.Errorf("count worked: %w", err) return wb, fmt.Errorf("count worked: %w", err)
} }
if wb.Count > 0 { if wb.Count > 0 {
rows, err := r.db.QueryContext(ctx, ` // Pull the full QSO records (same columns as the Recent QSOs list) so
SELECT id, qso_date, band, mode, rst_sent, rst_rcvd, // the Worked-before grid can offer the same rich column picker.
qsl_sent, qsl_rcvd, lotw_sent, lotw_rcvd rows, err := r.db.QueryContext(ctx, `SELECT `+selectCols+`
FROM qso WHERE upper(trim(callsign)) = ? FROM qso WHERE upper(trim(callsign)) = ?
ORDER BY qso_date DESC, id DESC ORDER BY qso_date DESC, id DESC
LIMIT ?`, wb.Callsign, maxWorkedEntries) LIMIT ?`, wb.Callsign, maxWorkedEntries)
@@ -668,40 +973,25 @@ func (r *Repo) WorkedBefore(ctx context.Context, callsign string, dxccHint int)
modesSet := map[string]struct{}{} modesSet := map[string]struct{}{}
bmSet := map[string]BandMode{} bmSet := map[string]BandMode{}
for rows.Next() { for rows.Next() {
var ( q, err := scanQSO(rows)
e WorkedEntry if err != nil {
dateStr string
band, mode sql.NullString
rstS, rstR, qslS, qslR, lotwS, lotwR sql.NullString
)
if err := rows.Scan(&e.ID, &dateStr, &band, &mode,
&rstS, &rstR, &qslS, &qslR, &lotwS, &lotwR); err != nil {
rows.Close() rows.Close()
return wb, fmt.Errorf("scan worked: %w", err) return wb, fmt.Errorf("scan worked: %w", err)
} }
e.QSODate = parseTimeLoose(dateStr) wb.Entries = append(wb.Entries, q)
e.Band = band.String if q.Band != "" {
e.Mode = mode.String bandsSet[q.Band] = struct{}{}
e.RSTSent = rstS.String
e.RSTRcvd = rstR.String
e.QSLSent = qslS.String
e.QSLRcvd = qslR.String
e.LOTWSent = lotwS.String
e.LOTWRcvd = lotwR.String
wb.Entries = append(wb.Entries, e)
if e.Band != "" {
bandsSet[e.Band] = struct{}{}
} }
if e.Mode != "" { if q.Mode != "" {
modesSet[e.Mode] = struct{}{} modesSet[q.Mode] = struct{}{}
} }
if e.Band != "" && e.Mode != "" { if q.Band != "" && q.Mode != "" {
bmSet[e.Band+"|"+e.Mode] = BandMode{Band: e.Band, Mode: e.Mode} bmSet[q.Band+"|"+q.Mode] = BandMode{Band: q.Band, Mode: q.Mode}
} }
if wb.Last.IsZero() { if wb.Last.IsZero() {
wb.Last = e.QSODate wb.Last = q.QSODate
} }
wb.First = e.QSODate wb.First = q.QSODate
} }
rows.Close() rows.Close()
@@ -1228,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
) )
@@ -1250,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)
} }
@@ -1401,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
} }
+388
View File
@@ -0,0 +1,388 @@
// Package winkeyer drives a K1EL WinKeyer (WK1/WK2/WK3) CW keyer over a
// serial port — the same hardware Log4OM, N1MM and fldigi talk to. It opens
// the host-mode interface, applies the operator's keying parameters (speed,
// weight, lead-in/tail, sidetone, paddle mode…), sends arbitrary text as
// Morse, and aborts mid-message on demand.
//
// Protocol reference: K1EL "WinKeyer USB / WK3 Interface Description". The
// host link is 1200 baud 8N1. Bytes 0x000x1F are commands; printable ASCII
// is keyed directly. The device streams status bytes back (busy/idle, the
// speed-pot value, and an echo of each character as it's sent) which we
// surface to the UI via the OnStatus callback.
package winkeyer
import (
"fmt"
"strings"
"sync"
"time"
"go.bug.st/serial"
"hamlog/internal/applog"
)
// Mode selects the paddle keying mode (WinKey "mode register" low bits).
type Mode string
const (
ModeIambicB Mode = "iambic_b"
ModeIambicA Mode = "iambic_a"
ModeUltimatic Mode = "ultimatic"
ModeBug Mode = "bug"
)
// Config is the keyer configuration the UI persists and applies on connect.
type Config struct {
Port string `json:"port"` // e.g. "COM6"
Baud int `json:"baud"` // 1200 for WK2, also fine for WK3
WPM int `json:"wpm"` // 5..99
Weight int `json:"weight"` // 10..90, 50 = normal
LeadInMs int `json:"lead_in_ms"` // PTT lead-in, 10 ms units sent to device
TailMs int `json:"tail_ms"` // PTT tail
Ratio int `json:"ratio"` // dah/dit ratio 33..66 (50 = 3:1)
Farnsworth int `json:"farnsworth"` // Farnsworth WPM (0 = off)
Sidetone int `json:"sidetone_hz"` // 0 = off; else target Hz (mapped to WK code)
Mode Mode `json:"mode"` // paddle mode
Swap bool `json:"swap"` // swap dit/dah paddles
AutoSpace bool `json:"autospace"` // auto letter-space
UsePTT bool `json:"use_ptt"` // key PTT (Key/PTT output)
SerialEcho bool `json:"serial_echo"` // device echoes sent chars back to host
}
func (c Config) normalised() Config {
if c.Baud <= 0 {
c.Baud = 1200
}
if c.WPM < 5 {
c.WPM = 20
}
if c.WPM > 99 {
c.WPM = 99
}
if c.Weight < 10 || c.Weight > 90 {
c.Weight = 50
}
if c.Ratio < 33 || c.Ratio > 66 {
c.Ratio = 50
}
switch c.Mode {
case ModeIambicA, ModeIambicB, ModeUltimatic, ModeBug:
default:
c.Mode = ModeIambicB
}
return c
}
// Status is pushed to the UI whenever the link state or keyer activity changes.
type Status struct {
Connected bool `json:"connected"`
Busy bool `json:"busy"` // device is currently sending CW
WPM int `json:"wpm"` // current speed (tracks the speed pot)
Version int `json:"version"` // host firmware version byte
Port string `json:"port"`
Error string `json:"error,omitempty"`
}
// Manager owns the serial link. Safe for concurrent use.
type Manager struct {
mu sync.Mutex
port serial.Port
cfg Config
status Status
stopRead chan struct{}
doneRead chan struct{}
onStatus func(Status)
onEcho func(string) // chars the device echoes back as it keys them
}
func NewManager(onStatus func(Status), onEcho func(string)) *Manager {
return &Manager{onStatus: onStatus, onEcho: onEcho}
}
// ListPorts returns the available serial port names (COM3, COM6, …).
func ListPorts() ([]string, error) {
ports, err := serial.GetPortsList()
if err != nil {
return nil, err
}
return ports, nil
}
// Status returns a snapshot.
func (m *Manager) Snapshot() Status {
m.mu.Lock()
defer m.mu.Unlock()
return m.status
}
func (m *Manager) emit() {
if m.onStatus != nil {
m.onStatus(m.status)
}
}
// Connect opens the port, performs the host-open handshake and applies cfg.
func (m *Manager) Connect(cfg Config) error {
cfg = cfg.normalised()
if strings.TrimSpace(cfg.Port) == "" {
return fmt.Errorf("winkeyer: no serial port selected")
}
m.Disconnect() // drop any existing link first
p, err := serial.Open(cfg.Port, &serial.Mode{
BaudRate: cfg.Baud,
DataBits: 8,
Parity: serial.NoParity,
StopBits: serial.OneStopBit,
})
if err != nil {
return fmt.Errorf("winkeyer: open %s: %w", cfg.Port, err)
}
_ = p.SetReadTimeout(200 * time.Millisecond)
// Host Open: <0x00 0x02>. Device replies with its firmware version byte.
if _, err := p.Write([]byte{0x00, 0x02}); err != nil {
_ = p.Close()
return fmt.Errorf("winkeyer: host open: %w", err)
}
ver := 0
buf := make([]byte, 16)
_ = p.SetReadTimeout(1 * time.Second)
if n, _ := p.Read(buf); n > 0 {
ver = int(buf[0])
}
_ = p.SetReadTimeout(200 * time.Millisecond)
m.mu.Lock()
m.port = p
m.cfg = cfg
m.status = Status{Connected: true, WPM: cfg.WPM, Version: ver, Port: cfg.Port}
m.stopRead = make(chan struct{})
m.doneRead = make(chan struct{})
stop, done := m.stopRead, m.doneRead
m.mu.Unlock()
applog.Printf("winkeyer: connected on %s (firmware byte %d)", cfg.Port, ver)
go m.readLoop(p, stop, done)
if err := m.applyConfig(cfg); err != nil {
applog.Printf("winkeyer: applyConfig: %v", err)
}
m.emit()
return nil
}
// applyConfig pushes the keying parameters to the device.
func (m *Manager) applyConfig(c Config) error {
cmds := [][]byte{
{0x0E, modeRegister(c)}, // set mode register (paddle mode, swap, autospace…)
{0x02, byte(c.WPM)}, // set speed (WPM)
{0x03, byte(c.Weight)}, // set weighting
{0x04, byte(c.LeadInMs / 10), byte(c.TailMs / 10)}, // PTT lead-in / tail (10 ms units)
{0x11, byte(c.Ratio)}, // set dit/dah ratio
}
// Sidetone: <0x01 n>. Bit6 enables, low nibble selects the pitch divisor.
cmds = append(cmds, []byte{0x01, sidetoneCode(c.Sidetone)})
if c.Farnsworth > 0 {
cmds = append(cmds, []byte{0x0D, byte(c.Farnsworth)}) // Farnsworth WPM
}
for _, cmd := range cmds {
if err := m.write(cmd); err != nil {
return err
}
}
return nil
}
// modeRegister builds the WinKey mode-register byte (command 0x0E).
// bits 1..0 : paddle mode (00 Iambic-B, 01 Iambic-A, 10 Ultimatic, 11 Bug)
// bit 3 : paddle swap
// bit 0/... : (autospace is bit 0 of a separate group on some firmwares)
// We keep to the widely-compatible WK2 layout.
func modeRegister(c Config) byte {
var b byte
switch c.Mode {
case ModeIambicB:
b |= 0x00
case ModeIambicA:
b |= 0x10
case ModeUltimatic:
b |= 0x20
case ModeBug:
b |= 0x30
}
if c.Swap {
b |= 0x08 // bit3 paddle swap
}
if c.AutoSpace {
b |= 0x02 // bit1 autospace
}
if c.SerialEcho {
b |= 0x04 // bit2 serial echoback — device echoes keyed chars to host
}
return b
}
// sidetoneCode maps a target Hz to the WinKey sidetone control byte. 0 = off.
func sidetoneCode(hz int) byte {
if hz <= 0 {
return 0x00 // sidetone off
}
// WK sidetone = 4000 / n Hz, n = 1..10. Pick the nearest n, enable bit6.
best, bestErr := 1, 1<<30
for n := 1; n <= 10; n++ {
f := 4000 / n
e := f - hz
if e < 0 {
e = -e
}
if e < bestErr {
bestErr, best = e, n
}
}
return 0x80 | byte(best) // bit7 paddle-only sidetone on; low nibble = divisor
}
// SetSpeed changes the WPM live (command 0x02).
func (m *Manager) SetSpeed(wpm int) error {
if wpm < 5 {
wpm = 5
}
if wpm > 99 {
wpm = 99
}
if err := m.write([]byte{0x02, byte(wpm)}); err != nil {
return err
}
m.mu.Lock()
m.cfg.WPM = wpm
m.status.WPM = wpm
m.mu.Unlock()
m.emit()
return nil
}
// allowedCW is the set of characters WinKey can key (everything else dropped).
const allowedCW = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 .,?/=+-:();\"'@"
// Send keys the given text as Morse. The text is upper-cased and filtered to
// keyable characters. Non-keyable input is silently dropped.
func (m *Manager) Send(text string) error {
var b strings.Builder
for _, r := range strings.ToUpper(text) {
if strings.ContainsRune(allowedCW, r) {
b.WriteRune(r)
}
}
out := b.String()
if out == "" {
return nil
}
return m.write([]byte(out))
}
// Stop aborts the current message and clears the keyer buffer (command 0x0A).
func (m *Manager) Stop() error {
return m.write([]byte{0x0A})
}
// Backspace removes the most recent character from the keyer's send buffer,
// IF it hasn't been keyed yet (command 0x08). Used by "send on typing" mode
// so a fast typo can be corrected before it goes on the air.
func (m *Manager) Backspace() error {
return m.write([]byte{0x08})
}
func (m *Manager) write(b []byte) error {
m.mu.Lock()
p := m.port
m.mu.Unlock()
if p == nil {
return fmt.Errorf("winkeyer: not connected")
}
_, err := p.Write(b)
return err
}
// Disconnect sends Host Close and releases the port.
func (m *Manager) Disconnect() {
m.mu.Lock()
p := m.port
stop, done := m.stopRead, m.doneRead
m.port = nil
m.stopRead = nil
m.doneRead = nil
connected := m.status.Connected
m.status = Status{Connected: false}
m.mu.Unlock()
if p != nil {
_, _ = p.Write([]byte{0x00, 0x03}) // Host Close
_ = p.Close()
}
if stop != nil {
close(stop)
}
if done != nil {
<-done
}
if connected {
applog.Printf("winkeyer: disconnected")
m.emit()
}
}
// readLoop drains device→host status bytes. WK status frames have bit7 set
// (0xC0 + flags); 0x800xBF carry the speed-pot value; printable bytes are
// the echo of characters being sent. We track busy/idle and the speed pot.
func (m *Manager) readLoop(p serial.Port, stop, done chan struct{}) {
defer close(done)
buf := make([]byte, 64)
for {
select {
case <-stop:
return
default:
}
n, err := p.Read(buf)
if err != nil {
// Timeout is normal (no data); a real error ends the loop.
if isTimeout(err) {
continue
}
return
}
for i := 0; i < n; i++ {
b := buf[i]
switch {
case b&0xC0 == 0xC0: // status byte
busy := b&0x04 != 0 // bit2 = busy (sending)
m.mu.Lock()
changed := m.status.Busy != busy
m.status.Busy = busy
m.mu.Unlock()
if changed {
m.emit()
}
case b&0xC0 == 0x80: // speed-pot value: 0x80 | (wpm-min)
// Reported relative to the configured pot range; surfaced as-is.
default:
// Echo of a keyed character (serial echo). Surface printable
// ones so the UI can show the text as it's transmitted.
if b >= 0x20 && b < 0x7F && m.onEcho != nil {
m.onEcho(string(rune(b)))
}
}
}
}
}
func isTimeout(err error) bool {
type timeout interface{ Timeout() bool }
if t, ok := err.(timeout); ok {
return t.Timeout()
}
return strings.Contains(strings.ToLower(err.Error()), "timeout")
}